diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 60b99e3..5d2b915 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,7 +17,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v3 with: - go-version: '1.22' + go-version: '1.23' - name: Get the latest tag id: get_latest_tag diff --git a/.gitignore b/.gitignore index 8355cdf..a58caf4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ vendor/ # Go workspace file go.work +go.work.sum # ide data .idea @@ -30,4 +31,4 @@ release/ # binary main -gama \ No newline at end of file +gama diff --git a/README.md b/README.md index 8a5feb6..fb042cd 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,17 @@ GAMA is a powerful terminal-based user interface tool designed to streamline the GAMA Go Report Card GAMA Licence +## Table of Contents +- [Key Features](#key-features) +- [Live Mode](#live-mode) +- [Getting Started](#getting-started) + - [Prerequisites](#prerequisites) + - [Configuration](#configuration) +- [Build & Installation](#build--installation) +- [Contributing](#contributing) +- [License](#license) +- [Contact & Author](#contact--author) + ![gama demo](docs/gama.gif) ## Key Features @@ -14,6 +25,18 @@ GAMA is a powerful terminal-based user interface tool designed to streamline the - **Workflow History**: Conveniently list all historical runs of workflows in a repository. - **Discoverability**: Easily list all triggerable (dispatchable) workflows in a repository. - **Workflow Management**: Trigger specific workflows with custom inputs. +- **Live Updates**: Automatically refresh workflow status at configurable intervals. +- **Docker Support**: Run directly from a container for easy deployment. + +### Live Mode + +GAMA includes a live mode feature that automatically refreshes the workflow status at regular intervals: + +- **Toggle Live Updates**: Press `ctrl+l` to turn live mode on/off +- **Auto-start**: Set `settings.live_mode.enabled: true` to start GAMA with live mode enabled +- **Refresh Interval**: Configure how often the view updates with `settings.live_mode.interval` (e.g., "15s", "1m") + +Live mode is particularly useful when monitoring ongoing workflow runs, as it eliminates the need for manual refreshing. ## Getting Started @@ -36,8 +59,14 @@ keys: switch_tab_left: shift+left quit: ctrl+c refresh: ctrl+r + live_mode: ctrl+l # Toggle live mode on/off enter: enter tab: tab + +settings: + live_mode: + enabled: true # Enable live mode at startup + interval: 15s # Refresh interval for live updates ``` #### Environment Variable Configuration diff --git a/config.yaml b/config.yaml index 65d04b4..e236939 100644 --- a/config.yaml +++ b/config.yaml @@ -6,5 +6,11 @@ keys: switch_tab_left: shift+left quit: ctrl+c refresh: ctrl+r + live_mode: ctrl+l enter: enter tab: tab + +settings: + live_mode: + enabled: true # to enable live mode at startup + interval: 15s # interval to refresh the page diff --git a/go.mod b/go.mod index fbeae81..8b8f3c1 100644 --- a/go.mod +++ b/go.mod @@ -1,54 +1,50 @@ module github.com/termkit/gama -go 1.22.1 +go 1.23 require ( - github.com/Masterminds/semver/v3 v3.2.1 - github.com/charmbracelet/bubbles v0.18.0 - github.com/charmbracelet/bubbletea v0.26.4 - github.com/charmbracelet/lipgloss v0.11.0 + github.com/Masterminds/semver/v3 v3.3.1 + github.com/charmbracelet/bubbles v0.20.0 + github.com/charmbracelet/bubbletea v1.2.4 + github.com/charmbracelet/lipgloss v1.0.0 github.com/spf13/viper v1.19.0 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 + github.com/termkit/skeleton v0.2.0 + golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/x/ansi v0.1.2 // indirect - github.com/charmbracelet/x/input v0.1.1 // indirect - github.com/charmbracelet/x/term v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.1.2 // indirect + github.com/charmbracelet/x/ansi v0.6.0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect + github.com/magiconair/properties v1.8.9 // 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.15 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/mapstructure v1.5.0 // 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.15.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/locafero v0.6.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index 4c16123..a2f5c25 100644 --- a/go.sum +++ b/go.sum @@ -1,34 +1,23 @@ -github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= -github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 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/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= -github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= -github.com/charmbracelet/bubbletea v0.26.3 h1:iXyGvI+FfOWqkB2V07m1DF3xxQijxjY2j8PqiXYqasg= -github.com/charmbracelet/bubbletea v0.26.3/go.mod h1:bpZHfDHTYJC5g+FBK+ptJRCQotRC+Dhh3AoMxa/2+3Q= -github.com/charmbracelet/bubbletea v0.26.4 h1:2gDkkzLZaTjMl/dQBpNVtnvcCxsh/FCkimep7FC9c40= -github.com/charmbracelet/bubbletea v0.26.4/go.mod h1:P+r+RRA5qtI1DOHNFn0otoNwB4rn+zNAzSj/EXz6xU0= -github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= -github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= -github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= -github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= -github.com/charmbracelet/x/ansi v0.1.1 h1:CGAduulr6egay/YVbGc8Hsu8deMg1xZ/bkaXTPi1JDk= -github.com/charmbracelet/x/ansi v0.1.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY= -github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ= -github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28= -github.com/charmbracelet/x/input v0.1.1 h1:YDOJaTUKCqtGnq9PHzx3pkkl4pXDOANUHmhH3DqMtM4= -github.com/charmbracelet/x/input v0.1.1/go.mod h1:jvdTVUnNWj/RD6hjC4FsoB0SeZCJ2ZBkiuFP9zXvZI0= -github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= -github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= -github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= -github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= -github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg= -github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= +github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/x/ansi v0.6.0 h1:qOznutrb93gx9oMiGf7caF7bqqubh6YIM0SWKyA08pA= +github.com/charmbracelet/x/ansi v0.6.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -36,8 +25,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -46,92 +35,77 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= +github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 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.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 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.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/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= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= +github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= -github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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/termkit/skeleton v0.1.3 h1:XiCqLDQXhtU4LhrgDKYHaPFRumngLPs9ssebL8WXHVI= +github.com/termkit/skeleton v0.1.3/go.mod h1:KjHXehkpVm8i3pli9PTG+Lat2PrUBsw6QQe5kbgYXHs= +github.com/termkit/skeleton v0.2.0 h1:Nbs7i5+vsouK25Gl4+vuMB1/7jokZ77HN2wV1IYgpBQ= +github.com/termkit/skeleton v0.2.0/go.mod h1:KjHXehkpVm8i3pli9PTG+Lat2PrUBsw6QQe5kbgYXHs= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= -golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d h1:N0hmiNbwsSNwHBAvR3QB5w25pUwH4tK0Y/RltD1j1h4= -golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= -golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc h1:O9NuF4s+E/PvMIy+9IUZB9znFwUIXEWSstNjek6VpVg= -golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4= +golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= +golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588= +golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 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= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index d9493a9..80bfd53 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,16 +2,24 @@ package config import ( "fmt" + "github.com/spf13/viper" "os" "path/filepath" "strings" - - "github.com/spf13/viper" + "time" ) type Config struct { Github Github `mapstructure:"github"` Shortcuts Shortcuts `mapstructure:"keys"` + Settings Settings `mapstructure:"settings"` +} + +type Settings struct { + LiveMode struct { + Enabled bool `mapstructure:"enabled"` + Interval time.Duration `mapstructure:"interval"` + } `mapstructure:"live_mode"` } type Github struct { @@ -24,6 +32,7 @@ type Shortcuts struct { Quit string `mapstructure:"quit"` Refresh string `mapstructure:"refresh"` Enter string `mapstructure:"enter"` + LiveMode string `mapstructure:"live_mode"` Tab string `mapstructure:"tab"` } @@ -31,6 +40,7 @@ func LoadConfig() (*Config, error) { var config = new(Config) defer func() { config = fillDefaultShortcuts(config) + config = fillDefaultSettings(config) }() setConfig() diff --git a/internal/config/settings.go b/internal/config/settings.go new file mode 100644 index 0000000..9a2f8a2 --- /dev/null +++ b/internal/config/settings.go @@ -0,0 +1,11 @@ +package config + +import "time" + +func fillDefaultSettings(cfg *Config) *Config { + if cfg.Settings.LiveMode.Interval == time.Duration(0) { + cfg.Settings.LiveMode.Interval = 15 * time.Second + } + + return cfg +} diff --git a/internal/config/shortcuts.go b/internal/config/shortcuts.go index 123c728..17286ff 100644 --- a/internal/config/shortcuts.go +++ b/internal/config/shortcuts.go @@ -25,11 +25,16 @@ func fillDefaultShortcuts(cfg *Config) *Config { if tab == "" { tab = defaultKeyMap.Tab } + var liveMode = cfg.Shortcuts.LiveMode + if liveMode == "" { + liveMode = defaultKeyMap.LiveMode + } cfg.Shortcuts = Shortcuts{ SwitchTabRight: switchTabRight, SwitchTabLeft: switchTabLeft, Quit: quit, Refresh: refresh, + LiveMode: liveMode, Enter: enter, Tab: tab, } @@ -44,6 +49,7 @@ type defaultMap struct { Refresh string Enter string Tab string + LiveMode string } var defaultKeyMap = defaultMap{ @@ -53,4 +59,5 @@ var defaultKeyMap = defaultMap{ Refresh: "ctrl+r", Enter: "enter", Tab: "tab", + LiveMode: "ctrl+l", } diff --git a/internal/github/repository/repository.go b/internal/github/repository/repository.go index 646c5c2..a4d7955 100644 --- a/internal/github/repository/repository.go +++ b/internal/github/repository/repository.go @@ -184,22 +184,31 @@ func (r *Repo) GetTriggerableWorkflows(ctx context.Context, repository string) ( return nil, err } - // Create a buffered channel for results and errors - results := make(chan *Workflow, len(workflows.Workflows)) - errs := make(chan error, len(workflows.Workflows)) + // Count how many workflows we'll actually process + var validWorkflowCount int + for _, workflow := range workflows.Workflows { + if strings.HasPrefix(workflow.Path, ".github/workflows/") { + validWorkflowCount++ + } + } - // Filter workflows to only include those that are dispatchable and manually triggerable + // Create buffered channels only for valid workflows + results := make(chan *Workflow, validWorkflowCount) + errs := make(chan error, validWorkflowCount) + + // Only process workflows with valid paths for _, workflow := range workflows.Workflows { - go r.workerGetTriggerableWorkflows(ctx, repository, workflow, results, errs) + if strings.HasPrefix(workflow.Path, ".github/workflows/") { + go r.workerGetTriggerableWorkflows(ctx, repository, workflow, results, errs) + } } // Collect the results and errors var result []Workflow var resultErrs []error - for range workflows.Workflows { + for i := 0; i < validWorkflowCount; i++ { select { case res := <-results: - // append only triggerable (dispatch) workflows if res != nil { result = append(result, *res) } @@ -261,20 +270,6 @@ func (r *Repo) InspectWorkflowContent(ctx context.Context, repository string, br return decodedContent, nil } -//func (r *Repo) GetWorkflowRun(ctx context.Context, repository string, runID int64) (GithubWorkflowRun, error) { -// // Get a workflow run for the given repository and runID -// var workflowRun GithubWorkflowRun -// err := r.do(ctx, nil, &workflowRun, requestOptions{ -// method: http.MethodGet, -// path: []string{"repos",repository,"actions","runs",strconv.FormatInt(runID, 10)}, -// }) -// if err != nil { -// return GithubWorkflowRun{}, err -// } -// -// return workflowRun, nil -//} - func (r *Repo) getWorkflowFile(ctx context.Context, repository string, path string) (string, error) { // Get the content of the workflow file var githubFile githubFile diff --git a/internal/github/usecase/ports.go b/internal/github/usecase/ports.go index a0f706f..b825753 100644 --- a/internal/github/usecase/ports.go +++ b/internal/github/usecase/ports.go @@ -7,6 +7,7 @@ import ( type UseCase interface { GetAuthUser(ctx context.Context) (*GetAuthUserOutput, error) ListRepositories(ctx context.Context, input ListRepositoriesInput) (*ListRepositoriesOutput, error) + GetRepositoryBranches(ctx context.Context, input GetRepositoryBranchesInput) (*GetRepositoryBranchesOutput, error) GetWorkflowHistory(ctx context.Context, input GetWorkflowHistoryInput) (*GetWorkflowHistoryOutput, error) GetTriggerableWorkflows(ctx context.Context, input GetTriggerableWorkflowsInput) (*GetTriggerableWorkflowsOutput, error) InspectWorkflow(ctx context.Context, input InspectWorkflowInput) (*InspectWorkflowOutput, error) diff --git a/internal/github/usecase/types.go b/internal/github/usecase/types.go index 84cc7f8..b398a8e 100644 --- a/internal/github/usecase/types.go +++ b/internal/github/usecase/types.go @@ -31,6 +31,23 @@ type ListRepositoriesOutput struct { Repositories []GithubRepository } +// ------------------------------------------------------------ + +type GetRepositoryBranchesInput struct { + Repository string +} + +type GetRepositoryBranchesOutput struct { + Branches []GithubBranch +} + +type GithubBranch struct { + Name string + IsDefault bool +} + +// ------------------------------------------------------------ + type GithubRepository struct { Name string Private bool diff --git a/internal/github/usecase/usecase.go b/internal/github/usecase/usecase.go index f2486fb..f278a7a 100644 --- a/internal/github/usecase/usecase.go +++ b/internal/github/usecase/usecase.go @@ -75,6 +75,42 @@ func (u useCase) ListRepositories(ctx context.Context, input ListRepositoriesInp }, errors.Join(resultErrs...) } +func (u useCase) GetRepositoryBranches(ctx context.Context, input GetRepositoryBranchesInput) (*GetRepositoryBranchesOutput, error) { + // Get Repository to get the default branch + repository, err := u.githubRepository.GetRepository(ctx, input.Repository) + if err != nil { + return nil, err + } + + var mainBranch = repository.DefaultBranch + + branches, err := u.githubRepository.ListBranches(ctx, input.Repository) + if err != nil { + return nil, err + } + + if len(branches) == 0 { + return &GetRepositoryBranchesOutput{}, nil + } + + var result = []GithubBranch{ + { + Name: mainBranch, + IsDefault: true, + }, + } + + for _, branch := range branches { + result = append(result, GithubBranch{ + Name: branch.Name, + }) + } + + return &GetRepositoryBranchesOutput{ + Branches: result, + }, nil +} + func (u useCase) workerListRepositories(ctx context.Context, repository gr.GithubRepository, results chan<- GithubRepository, errs chan<- error) { getWorkflows, err := u.githubRepository.GetWorkflows(ctx, repository.FullName) if err != nil { @@ -213,21 +249,24 @@ func (u useCase) timeToString(t time.Time) string { } func (u useCase) getDuration(startTime time.Time, endTime time.Time, status string) string { - if status != "completed" { - return "running" - } - // Convert UTC times to local timezone localStartTime := startTime.In(time.Local) localEndTime := endTime.In(time.Local) - diff := localEndTime.Sub(localStartTime) + var diff time.Duration - if diff.Seconds() < 60 { + if status != "completed" { + diff = time.Since(localStartTime) + } else { + diff = localEndTime.Sub(localStartTime) + } + + switch { + case diff.Seconds() < 60: return fmt.Sprintf("%ds", int(diff.Seconds())) - } else if diff.Seconds() < 3600 { + case diff.Seconds() < 3600: return fmt.Sprintf("%dm %ds", int(diff.Minutes()), int(diff.Seconds())%60) - } else { + default: return fmt.Sprintf("%dh %dm %ds", int(diff.Hours()), int(diff.Minutes())%60, int(diff.Seconds())%60) } } diff --git a/internal/terminal/handler/ghinformation.go b/internal/terminal/handler/ghinformation.go new file mode 100644 index 0000000..c6fd829 --- /dev/null +++ b/internal/terminal/handler/ghinformation.go @@ -0,0 +1,221 @@ +package handler + +import ( + "context" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + gu "github.com/termkit/gama/internal/github/usecase" + pkgversion "github.com/termkit/gama/pkg/version" + "github.com/termkit/skeleton" +) + +// ----------------------------------------------------------------------------- +// Model Definition +// ----------------------------------------------------------------------------- + +type ModelInfo struct { + // Core dependencies + skeleton *skeleton.Skeleton + github gu.UseCase + version pkgversion.Version + + // UI Components + help help.Model + status *ModelStatus + keys githubInformationKeyMap + + // Application state + logo string + releaseURL string + applicationDescription string + newVersionAvailableMsg string +} + +// ----------------------------------------------------------------------------- +// Constructor & Initialization +// ----------------------------------------------------------------------------- + +func SetupModelInfo(s *skeleton.Skeleton, githubUseCase gu.UseCase, version pkgversion.Version) *ModelInfo { + const releaseURL = "https://github.com/termkit/gama/releases" + + return &ModelInfo{ + // Initialize core dependencies + skeleton: s, + github: githubUseCase, + version: version, + + // Initialize UI components + help: help.New(), + status: SetupModelStatus(s), + keys: githubInformationKeys, + + // Initialize application state + logo: defaultLogo, + releaseURL: releaseURL, + } +} + +const defaultLogo = ` + ..|'''.| | '|| ||' | +.|' ' ||| ||| ||| ||| +|| .... | || |'|..'|| | || +'|. || .''''|. | '|' || .''''|. +''|...'| .|. .||. .|. | .||. .|. .||. +` + +// ----------------------------------------------------------------------------- +// Bubbletea Model Implementation +// ----------------------------------------------------------------------------- + +func (m *ModelInfo) Init() tea.Cmd { + m.initializeAppDescription() + m.startBackgroundTasks() + + return tea.Batch( + tea.EnterAltScreen, + tea.SetWindowTitle("GitHub Actions Manager (GAMA)"), + ) +} + +func (m *ModelInfo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + } + } + return m, nil +} + +func (m *ModelInfo) View() string { + return lipgloss.JoinVertical(lipgloss.Center, + m.renderMainContent(), + m.status.View(), + m.renderHelpWindow(), + ) +} + +// ----------------------------------------------------------------------------- +// UI Rendering +// ----------------------------------------------------------------------------- + +func (m *ModelInfo) renderMainContent() string { + content := strings.Builder{} + + // Add vertical centering + centerPadding := m.calculateCenterPadding() + content.WriteString(strings.Repeat("\n", centerPadding)) + + // Add main content + content.WriteString(lipgloss.JoinVertical(lipgloss.Center, + m.logo, + m.applicationDescription, + m.newVersionAvailableMsg, + )) + + // Add bottom padding + bottomPadding := m.calculateBottomPadding(content.String()) + content.WriteString(strings.Repeat("\n", bottomPadding)) + + return content.String() +} + +func (m *ModelInfo) renderHelpWindow() string { + helpStyle := WindowStyleHelp.Width(m.skeleton.GetTerminalWidth() - 4) + return helpStyle.Render(m.ViewHelp()) +} + +// ----------------------------------------------------------------------------- +// Layout Calculations +// ----------------------------------------------------------------------------- + +func (m *ModelInfo) calculateCenterPadding() int { + padding := m.skeleton.GetTerminalHeight()/2 - 11 + return max(0, padding) +} + +func (m *ModelInfo) calculateBottomPadding(content string) int { + padding := m.skeleton.GetTerminalHeight() - lipgloss.Height(content) - 12 + return max(0, padding) +} + +// ----------------------------------------------------------------------------- +// Application State Management +// ----------------------------------------------------------------------------- + +func (m *ModelInfo) initializeAppDescription() { + m.applicationDescription = fmt.Sprintf("Github Actions Manager (%s)", m.version.CurrentVersion()) +} + +func (m *ModelInfo) startBackgroundTasks() { + go m.checkUpdates(context.Background()) + go m.testConnection(context.Background()) +} + +// ----------------------------------------------------------------------------- +// Background Tasks +// ----------------------------------------------------------------------------- + +func (m *ModelInfo) checkUpdates(ctx context.Context) { + defer m.skeleton.TriggerUpdate() + + isUpdateAvailable, version, err := m.version.IsUpdateAvailable(ctx) + if err != nil { + m.handleUpdateError(err) + return + } + + if isUpdateAvailable { + m.newVersionAvailableMsg = fmt.Sprintf( + "New version available: %s\nPlease visit: %s", + version, + m.releaseURL, + ) + } +} + +func (m *ModelInfo) testConnection(ctx context.Context) { + defer m.skeleton.TriggerUpdate() + + m.status.SetProgressMessage("Checking your token...") + m.skeleton.LockTabs() + + _, err := m.github.GetAuthUser(ctx) + if err != nil { + m.handleConnectionError(err) + return + } + + m.handleSuccessfulConnection() +} + +// ----------------------------------------------------------------------------- +// Error Handling +// ----------------------------------------------------------------------------- + +func (m *ModelInfo) handleUpdateError(err error) { + m.status.SetError(err) + m.status.SetErrorMessage("failed to check updates") + m.newVersionAvailableMsg = fmt.Sprintf( + "failed to check updates.\nPlease visit: %s", + m.releaseURL, + ) +} + +func (m *ModelInfo) handleConnectionError(err error) { + m.status.SetError(err) + m.status.SetErrorMessage("failed to test connection, please check your token&permission") + m.skeleton.LockTabs() +} + +func (m *ModelInfo) handleSuccessfulConnection() { + m.status.Reset() + m.status.SetSuccessMessage("Welcome to GAMA!") + m.skeleton.UnlockTabs() +} diff --git a/internal/terminal/handler/ghrepository.go b/internal/terminal/handler/ghrepository.go new file mode 100644 index 0000000..e876409 --- /dev/null +++ b/internal/terminal/handler/ghrepository.go @@ -0,0 +1,523 @@ +package handler + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/termkit/gama/internal/github/domain" + gu "github.com/termkit/gama/internal/github/usecase" + "github.com/termkit/gama/pkg/browser" + "github.com/termkit/skeleton" +) + +// ----------------------------------------------------------------------------- +// Model Definition +// ----------------------------------------------------------------------------- + +type ModelGithubRepository struct { + // Core dependencies + skeleton *skeleton.Skeleton + github gu.UseCase + + // UI State + tableReady bool + + // Context management + syncRepositoriesContext context.Context + cancelSyncRepositories context.CancelFunc + + // Shared state + selectedRepository *SelectedRepository + + // UI Components + help help.Model + Keys githubRepositoryKeyMap + tableGithubRepository table.Model + searchTableGithubRepository table.Model + status *ModelStatus + textInput textinput.Model + modelTabOptions *ModelTabOptions +} + +// ----------------------------------------------------------------------------- +// Constructor & Initialization +// ----------------------------------------------------------------------------- + +func SetupModelGithubRepository(s *skeleton.Skeleton, githubUseCase gu.UseCase) *ModelGithubRepository { + m := &ModelGithubRepository{ + // Initialize core dependencies + skeleton: s, + github: githubUseCase, + + // Initialize UI components + help: help.New(), + Keys: githubRepositoryKeys, + status: SetupModelStatus(s), + textInput: setupTextInput(), + modelTabOptions: NewOptions(s, SetupModelStatus(s)), + + // Initialize state + selectedRepository: NewSelectedRepository(), + syncRepositoriesContext: context.Background(), + cancelSyncRepositories: func() {}, + } + + // Setup tables + m.tableGithubRepository = setupMainTable() + m.searchTableGithubRepository = setupSearchTable() + + return m +} + +func setupMainTable() table.Model { + t := table.New( + table.WithColumns(tableColumnsGithubRepository), + table.WithRows([]table.Row{}), + table.WithFocused(true), + table.WithHeight(13), + ) + + // Apply styles + t.SetStyles(defaultTableStyles()) + + // Apply keymap + t.KeyMap = defaultTableKeyMap() + + return t +} + +func setupSearchTable() table.Model { + return table.New( + table.WithColumns(tableColumnsGithubRepository), + table.WithRows([]table.Row{}), + ) +} + +func setupTextInput() textinput.Model { + ti := textinput.New() + ti.Blur() + ti.CharLimit = 128 + ti.Placeholder = "Type to search repository" + ti.ShowSuggestions = false + return ti +} + +func defaultTableStyles() table.Styles { + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + return s +} + +func defaultTableKeyMap() table.KeyMap { + // We use "up" and "down" for both line up and down, we do not use "k" and "j" to prevent conflict with text input + return table.KeyMap{ + LineUp: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")), + LineDown: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")), + PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")), + PageDown: key.NewBinding(key.WithKeys("pgdown", " "), key.WithHelp("pgdn", "page down")), + GotoTop: key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "go to start")), + GotoBottom: key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "go to end")), + } +} + +// ----------------------------------------------------------------------------- +// Bubbletea Model Implementation +// ----------------------------------------------------------------------------- + +func (m *ModelGithubRepository) Init() tea.Cmd { + m.setupBrowserOption() + go m.syncRepositories(m.syncRepositoriesContext) + + return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { + return initSyncMsg{} + }) +} + +func (m *ModelGithubRepository) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + inputMsg := msg + switch msg := msg.(type) { + case initSyncMsg: + m.modelTabOptions.SetStatus(StatusIdle) + m.tableGithubRepository.SetCursor(0) + return m, nil + case tea.KeyMsg: + // Handle number keys for tab options + if m.isNumber(msg.String()) { + inputMsg = tea.KeyMsg{} + } + + // Handle refresh key + if key.Matches(msg, m.Keys.Refresh) { + m.tableReady = false + m.cancelSyncRepositories() + m.syncRepositoriesContext, m.cancelSyncRepositories = context.WithCancel(context.Background()) + go m.syncRepositories(m.syncRepositoriesContext) + return m, nil + } + + // Handle character input for search + if m.isCharAndSymbol(msg.Runes) { + m.resetTableCursors() + } + } + + // Update text input and search functionality + if cmd := m.updateTextInput(inputMsg); cmd != nil { + cmds = append(cmds, cmd) + } + + // Update main table and handle row selection + if cmd := m.updateTable(msg); cmd != nil { + cmds = append(cmds, cmd) + } + + m.modelTabOptions, cmd = m.modelTabOptions.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *ModelGithubRepository) updateTextInput(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + m.updateTableRowsBySearchBar() + return cmd +} + +func (m *ModelGithubRepository) updateTable(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + var cmd tea.Cmd + + // Update main table + m.tableGithubRepository, cmd = m.tableGithubRepository.Update(msg) + cmds = append(cmds, cmd) + + // Update search table + m.searchTableGithubRepository, cmd = m.searchTableGithubRepository.Update(msg) + cmds = append(cmds, cmd) + + // Handle table selection + m.handleTableInputs(m.syncRepositoriesContext) + + m.modelTabOptions, cmd = m.modelTabOptions.Update(msg) + cmds = append(cmds, cmd) + + return tea.Batch(cmds...) +} + +func (m *ModelGithubRepository) View() string { + return lipgloss.JoinVertical(lipgloss.Top, + m.renderTable(), + m.renderSearchBar(), + m.modelTabOptions.View(), + m.status.View(), + m.renderHelp(), + ) +} + +// ----------------------------------------------------------------------------- +// UI Rendering +// ----------------------------------------------------------------------------- + +func (m *ModelGithubRepository) renderTable() string { + baseStyle := lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#3b698f")). + MarginLeft(1) + + // Update table dimensions + m.updateTableDimensions() + + return baseStyle.Render(m.tableGithubRepository.View()) +} + +func (m *ModelGithubRepository) renderSearchBar() string { + style := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#3b698f")). + Padding(0, 1). + Width(m.skeleton.GetTerminalWidth() - 6). + MarginLeft(1) + + if len(m.textInput.Value()) > 0 { + style = style.BorderForeground(lipgloss.Color("39")) + } + + return style.Render(m.textInput.View()) +} + +func (m *ModelGithubRepository) renderHelp() string { + helpStyle := WindowStyleHelp.Width(m.skeleton.GetTerminalWidth() - 4) + return helpStyle.Render(m.ViewHelp()) +} + +// ----------------------------------------------------------------------------- +// Data Synchronization +// ----------------------------------------------------------------------------- + +func (m *ModelGithubRepository) syncRepositories(ctx context.Context) { + defer m.skeleton.TriggerUpdate() + + m.status.Reset() + m.status.SetProgressMessage("Fetching repositories...") + m.clearTables() + + // Add timeout to prevent hanging + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + repos, err := m.fetchRepositories(ctx) + if err != nil { + m.handleFetchError(err) + return + } + + m.updateRepositoryData(repos) +} + +func (m *ModelGithubRepository) fetchRepositories(ctx context.Context) (*gu.ListRepositoriesOutput, error) { + return m.github.ListRepositories(ctx, gu.ListRepositoriesInput{ + Limit: 100, + Page: 5, + Sort: domain.SortByUpdated, + }) +} + +// ----------------------------------------------------------------------------- +// Table Management +// ----------------------------------------------------------------------------- + +func (m *ModelGithubRepository) clearTables() { + m.tableGithubRepository.SetRows([]table.Row{}) + m.searchTableGithubRepository.SetRows([]table.Row{}) +} + +func (m *ModelGithubRepository) updateTableDimensions() { + const minTableWidth = 60 // Minimum width to maintain readability + const tablePadding = 14 // Account for borders and margins + + var tableWidth int + for _, t := range tableColumnsGithubRepository { + tableWidth += t.Width + } + + termWidth := m.skeleton.GetTerminalWidth() + if termWidth <= minTableWidth { + return // Prevent table from becoming too narrow + } + + newTableColumns := make([]table.Column, len(tableColumnsGithubRepository)) + copy(newTableColumns, tableColumnsGithubRepository) + + widthDiff := termWidth - tableWidth - tablePadding + if widthDiff > 0 { + // Add extra width to repository name column + newTableColumns[0].Width += widthDiff + m.tableGithubRepository.SetColumns(newTableColumns) + + // Adjust height while maintaining some padding + maxHeight := m.skeleton.GetTerminalHeight() - 20 + if maxHeight > 0 { + m.tableGithubRepository.SetHeight(maxHeight) + } + } +} + +func (m *ModelGithubRepository) resetTableCursors() { + m.tableGithubRepository.GotoTop() + m.tableGithubRepository.SetCursor(0) + m.searchTableGithubRepository.GotoTop() + m.searchTableGithubRepository.SetCursor(0) +} + +// ----------------------------------------------------------------------------- +// Repository Data Management +// ----------------------------------------------------------------------------- + +func (m *ModelGithubRepository) updateRepositoryData(repos *gu.ListRepositoriesOutput) { + if len(repos.Repositories) == 0 { + m.modelTabOptions.SetStatus(StatusNone) + m.status.SetDefaultMessage("No repositories found") + m.textInput.Blur() + return + } + + m.skeleton.UpdateWidgetValue("repositories", fmt.Sprintf("Repository Count: %d", len(repos.Repositories))) + m.updateTableRows(repos.Repositories) + m.finalizeTableUpdate() +} + +func (m *ModelGithubRepository) updateTableRows(repositories []gu.GithubRepository) { + rows := make([]table.Row, 0, len(repositories)) + for _, repo := range repositories { + rows = append(rows, table.Row{ + repo.Name, + repo.DefaultBranch, + strconv.Itoa(repo.Stars), + strconv.Itoa(len(repo.Workflows)), + }) + } + + m.tableGithubRepository.SetRows(rows) + m.searchTableGithubRepository.SetRows(rows) +} + +func (m *ModelGithubRepository) finalizeTableUpdate() { + m.tableGithubRepository.SetCursor(0) + m.searchTableGithubRepository.SetCursor(0) + m.tableReady = true + m.textInput.Focus() + m.status.SetSuccessMessage("Repositories fetched") + + m.skeleton.TriggerUpdateWithMsg(initSyncMsg{}) +} + +// ----------------------------------------------------------------------------- +// Search Functionality +// ----------------------------------------------------------------------------- + +func (m *ModelGithubRepository) updateTableRowsBySearchBar() { + searchValue := strings.ToLower(m.textInput.Value()) + if searchValue == "" { + // If search is empty, restore original rows + m.tableGithubRepository.SetRows(m.searchTableGithubRepository.Rows()) + return + } + + rows := m.searchTableGithubRepository.Rows() + filteredRows := make([]table.Row, 0, len(rows)) + + for _, row := range rows { + if strings.Contains(strings.ToLower(row[0]), searchValue) { + filteredRows = append(filteredRows, row) + } + } + + m.tableGithubRepository.SetRows(filteredRows) + if len(filteredRows) == 0 { + m.clearSelectedRepository() + } +} + +func (m *ModelGithubRepository) clearSelectedRepository() { + m.selectedRepository.RepositoryName = "" + m.selectedRepository.BranchName = "" + m.selectedRepository.WorkflowName = "" +} + +// ----------------------------------------------------------------------------- +// Input Validation & Handling +// ----------------------------------------------------------------------------- + +func (m *ModelGithubRepository) isNumber(s string) bool { + _, err := strconv.Atoi(s) + return err == nil +} + +func (m *ModelGithubRepository) isCharAndSymbol(r []rune) bool { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_./" + for _, c := range r { + if strings.ContainsRune(chars, c) { + return true + } + } + return false +} + +// ----------------------------------------------------------------------------- +// Browser Integration +// ----------------------------------------------------------------------------- + +func (m *ModelGithubRepository) setupBrowserOption() { + openInBrowser := func() { + m.status.SetProgressMessage("Opening in browser...") + + url := fmt.Sprintf("https://github.com/%s", m.selectedRepository.RepositoryName) + if err := browser.OpenInBrowser(url); err != nil { + m.status.SetError(err) + m.status.SetErrorMessage(fmt.Sprintf("Cannot open in browser: %v", err)) + return + } + + m.status.SetSuccessMessage("Opened in browser") + } + + m.modelTabOptions.AddOption("Open in browser", openInBrowser) +} + +// ----------------------------------------------------------------------------- +// Error Handling +// ----------------------------------------------------------------------------- + +func (m *ModelGithubRepository) handleFetchError(err error) { + if errors.Is(err, context.Canceled) { + m.status.SetDefaultMessage("Repository fetch cancelled") + return + } + if errors.Is(err, context.DeadlineExceeded) { + m.status.SetErrorMessage("Repository fetch timed out") + return + } + + m.status.SetError(err) + m.status.SetErrorMessage(fmt.Sprintf("Failed to list repositories: %v", err)) +} + +// ----------------------------------------------------------------------------- +// Table Selection Handling +// ----------------------------------------------------------------------------- + +func (m *ModelGithubRepository) handleTableInputs(_ context.Context) { + if !m.tableReady { + return + } + + selectedRow := m.tableGithubRepository.SelectedRow() + if len(selectedRow) > 0 && selectedRow[0] != "" { + m.updateSelectedRepository(selectedRow) + } +} + +func (m *ModelGithubRepository) updateSelectedRepository(row []string) { + m.selectedRepository.RepositoryName = row[0] + m.selectedRepository.BranchName = row[1] + + if workflowCount := row[3]; workflowCount != "" { + m.handleWorkflowTabLocking(workflowCount) + } +} + +func (m *ModelGithubRepository) handleWorkflowTabLocking(workflowCount string) { + count, _ := strconv.Atoi(workflowCount) + if count == 0 { + m.skeleton.LockTab("workflow") + m.skeleton.LockTab("trigger") + } else { + m.skeleton.UnlockTab("workflow") + m.skeleton.UnlockTab("trigger") + } +} + +// initSyncMsg is a message type used to trigger a UI update after the initial sync +type initSyncMsg struct{} diff --git a/internal/terminal/handler/ghrepository/ghrepository.go b/internal/terminal/handler/ghrepository/ghrepository.go deleted file mode 100644 index 75892ea..0000000 --- a/internal/terminal/handler/ghrepository/ghrepository.go +++ /dev/null @@ -1,352 +0,0 @@ -package ghrepository - -import ( - "context" - "errors" - "fmt" - "strconv" - "strings" - - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/table" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/termkit/gama/internal/github/domain" - gu "github.com/termkit/gama/internal/github/usecase" - hdlerror "github.com/termkit/gama/internal/terminal/handler/error" - "github.com/termkit/gama/internal/terminal/handler/taboptions" - hdltypes "github.com/termkit/gama/internal/terminal/handler/types" - "github.com/termkit/gama/pkg/browser" -) - -type ModelGithubRepository struct { - // current handler's properties - syncRepositoriesContext context.Context - cancelSyncRepositories context.CancelFunc - tableReady bool - - // shared properties - SelectedRepository *hdltypes.SelectedRepository - - // use cases - github gu.UseCase - - // keymap - Keys keyMap - - // models - Help help.Model - Viewport *viewport.Model - tableGithubRepository table.Model - searchTableGithubRepository table.Model - modelError *hdlerror.ModelError - - modelTabOptions tea.Model - actualModelTabOptions *taboptions.Options - - textInput textinput.Model -} - -var baseStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")) - -func SetupModelGithubRepository(githubUseCase gu.UseCase, selectedRepository *hdltypes.SelectedRepository) *ModelGithubRepository { - var tableRowsGithubRepository []table.Row - - tableGithubRepository := table.New( - table.WithColumns(tableColumnsGithubRepository), - table.WithRows(tableRowsGithubRepository), - table.WithFocused(true), - table.WithHeight(13), - ) - - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(false) - s.Selected = s.Selected. - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). - Bold(false) - tableGithubRepository.SetStyles(s) - - tableGithubRepository.KeyMap = table.KeyMap{ - LineUp: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("↑", "up"), - ), - LineDown: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("↓", "down"), - ), - PageUp: key.NewBinding( - key.WithKeys("pgup"), - key.WithHelp("pgup", "page up"), - ), - PageDown: key.NewBinding( - key.WithKeys("pgdown", " "), - key.WithHelp("pgdn", "page down"), - ), - GotoTop: key.NewBinding( - key.WithKeys("home"), - key.WithHelp("home", "go to start"), - ), - GotoBottom: key.NewBinding( - key.WithKeys("end"), - key.WithHelp("end", "go to end"), - ), - } - - ti := textinput.New() - ti.Blur() - ti.CharLimit = 72 - ti.Placeholder = "Type to search repository" - ti.ShowSuggestions = false // disable suggestions, it will be enabled future. - - // setup models - modelError := hdlerror.SetupModelError() - tabOptions := taboptions.NewOptions(&modelError) - - return &ModelGithubRepository{ - Help: help.New(), - Keys: keys, - github: githubUseCase, - tableGithubRepository: tableGithubRepository, - modelError: &modelError, - SelectedRepository: selectedRepository, - modelTabOptions: tabOptions, - actualModelTabOptions: tabOptions, - textInput: ti, - syncRepositoriesContext: context.Background(), - cancelSyncRepositories: func() {}, - } -} - -func (m *ModelGithubRepository) Init() tea.Cmd { - go m.syncRepositories(m.syncRepositoriesContext) - - openInBrowser := func() { - m.modelError.SetProgressMessage("Opening in browser...") - - err := browser.OpenInBrowser(fmt.Sprintf("https://github.com/%s", m.SelectedRepository.RepositoryName)) - if err != nil { - m.modelError.SetError(err) - m.modelError.SetErrorMessage(fmt.Sprintf("Cannot open in browser: %v", err)) - return - } - - m.modelError.SetSuccessMessage("Opened in browser") - } - - m.actualModelTabOptions.AddOption("Open in browser", openInBrowser) - - return nil -} - -func (m *ModelGithubRepository) syncRepositories(ctx context.Context) { - m.modelError.ResetError() // reset previous errors - m.actualModelTabOptions.SetStatus(taboptions.OptionWait) - m.modelError.SetProgressMessage("Fetching repositories...") - - // delete all rows - m.tableGithubRepository.SetRows([]table.Row{}) - m.searchTableGithubRepository.SetRows([]table.Row{}) - - repositories, err := m.github.ListRepositories(ctx, gu.ListRepositoriesInput{ - Limit: 100, // limit to 100 repositories - Page: 5, // page 1 to page 5, at summary we fetch 500 repositories - Sort: domain.SortByUpdated, - }) - if errors.Is(err, context.Canceled) { - return - } else if err != nil { - m.modelError.SetError(err) - m.modelError.SetErrorMessage("Repositories cannot be listed") - return - } - - if len(repositories.Repositories) == 0 { - m.actualModelTabOptions.SetStatus(taboptions.OptionNone) - m.modelError.SetDefaultMessage("No repositories found") - m.textInput.Blur() - return - } - - tableRowsGithubRepository := make([]table.Row, 0, len(repositories.Repositories)) - for _, repository := range repositories.Repositories { - tableRowsGithubRepository = append(tableRowsGithubRepository, - table.Row{repository.Name, repository.DefaultBranch, strconv.Itoa(repository.Stars), strconv.Itoa(len(repository.Workflows))}) - } - - m.tableGithubRepository.SetRows(tableRowsGithubRepository) - m.searchTableGithubRepository.SetRows(tableRowsGithubRepository) - - // set cursor to 0 - m.tableGithubRepository.SetCursor(0) - m.searchTableGithubRepository.SetCursor(0) - - m.tableReady = true - //m.updateSearchBarSuggestions() - m.textInput.Focus() - m.modelError.SetSuccessMessage("Repositories fetched") - go m.Update(m) // update model -} - -func (m *ModelGithubRepository) handleTableInputs(_ context.Context) { - if !m.tableReady { - return - } - - // To avoid go routine leak - selectedRow := m.tableGithubRepository.SelectedRow() - - // Synchronize selected repository name with parent model - if len(selectedRow) > 0 && selectedRow[0] != "" { - m.SelectedRepository.RepositoryName = selectedRow[0] - m.SelectedRepository.BranchName = selectedRow[1] - } - - m.actualModelTabOptions.SetStatus(taboptions.OptionIdle) -} - -func (m *ModelGithubRepository) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - var cmd tea.Cmd - - var textInputMsg = msg - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, m.Keys.Refresh): - m.tableReady = false // reset table ready status - m.cancelSyncRepositories() // cancel previous sync - m.syncRepositoriesContext, m.cancelSyncRepositories = context.WithCancel(context.Background()) - go m.syncRepositories(m.syncRepositoriesContext) - case msg.String() == " " || m.isNumber(msg.String()): - textInputMsg = tea.KeyMsg{} - case m.isCharAndSymbol(msg.Runes): - m.tableGithubRepository.GotoTop() - m.tableGithubRepository.SetCursor(0) - m.searchTableGithubRepository.GotoTop() - m.searchTableGithubRepository.SetCursor(0) - } - } - - m.textInput, cmd = m.textInput.Update(textInputMsg) - cmds = append(cmds, cmd) - - m.updateTableRowsBySearchBar() - - m.tableGithubRepository, cmd = m.tableGithubRepository.Update(msg) - cmds = append(cmds, cmd) - - m.searchTableGithubRepository, cmd = m.searchTableGithubRepository.Update(msg) - cmds = append(cmds, cmd) - - m.modelTabOptions, cmd = m.modelTabOptions.Update(msg) - cmds = append(cmds, cmd) - - m.handleTableInputs(m.syncRepositoriesContext) - - return m, tea.Batch(cmds...) -} - -func (m *ModelGithubRepository) View() string { - termWidth := m.Viewport.Width - termHeight := m.Viewport.Height - - var tableWidth int - for _, t := range tableColumnsGithubRepository { - tableWidth += t.Width - } - - newTableColumns := tableColumnsGithubRepository - widthDiff := termWidth - tableWidth - if widthDiff > 0 { - newTableColumns[0].Width += widthDiff - 15 - m.tableGithubRepository.SetColumns(newTableColumns) - m.tableGithubRepository.SetHeight(termHeight - 20) - } - - doc := strings.Builder{} - doc.WriteString(baseStyle.Render(m.tableGithubRepository.View())) - - return lipgloss.JoinVertical(lipgloss.Top, doc.String(), m.viewSearchBar(), m.actualModelTabOptions.View()) -} - -func (m *ModelGithubRepository) viewSearchBar() string { - // Define window style - windowStyle := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - Padding(0, 1). - Width(*hdltypes.ScreenWidth - 2) - - // Build the options list - doc := strings.Builder{} - - if len(m.textInput.Value()) > 0 { - windowStyle = windowStyle.BorderForeground(lipgloss.Color("39")) - } - - doc.WriteString(m.textInput.View()) - - return windowStyle.Render(doc.String()) -} - -func (m *ModelGithubRepository) updateSearchBarSuggestions() { - m.textInput.SetSuggestions([]string{}) - - var suggestions = make([]string, 0, len(m.tableGithubRepository.Rows())) - for _, r := range m.tableGithubRepository.Rows() { - suggestions = append(suggestions, r[0]) - } - - m.textInput.SetSuggestions(suggestions) -} - -func (m *ModelGithubRepository) updateTableRowsBySearchBar() { - var tableRowsGithubRepository = make([]table.Row, 0, len(m.tableGithubRepository.Rows())) - - for _, r := range m.searchTableGithubRepository.Rows() { - if strings.Contains(r[0], m.textInput.Value()) { - tableRowsGithubRepository = append(tableRowsGithubRepository, r) - } - } - - if len(tableRowsGithubRepository) == 0 { - m.SelectedRepository.RepositoryName = "" - m.SelectedRepository.BranchName = "" - m.SelectedRepository.WorkflowName = "" - } - - m.tableGithubRepository.SetRows(tableRowsGithubRepository) -} - -func (m *ModelGithubRepository) isNumber(s string) bool { - if _, err := strconv.Atoi(s); err == nil { - return true - } - - return false -} - -func (m *ModelGithubRepository) isCharAndSymbol(r []rune) bool { - const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_./" - for _, c := range r { - if strings.ContainsRune(chars, c) { - return true - } - } - - return false -} - -func (m *ModelGithubRepository) ViewStatus() string { - return m.modelError.View() -} diff --git a/internal/terminal/handler/ghrepository/keymap.go b/internal/terminal/handler/ghrepository/keymap.go deleted file mode 100644 index c6fecc7..0000000 --- a/internal/terminal/handler/ghrepository/keymap.go +++ /dev/null @@ -1,55 +0,0 @@ -package ghrepository - -import ( - "fmt" - - "github.com/termkit/gama/internal/config" - - teakey "github.com/charmbracelet/bubbles/key" -) - -type keyMap struct { - Refresh teakey.Binding - LaunchTab teakey.Binding - SwitchTab teakey.Binding -} - -func (k keyMap) ShortHelp() []teakey.Binding { - return []teakey.Binding{k.SwitchTab, k.Refresh, k.LaunchTab} -} - -func (k keyMap) FullHelp() [][]teakey.Binding { - return [][]teakey.Binding{ - {k.SwitchTab}, - {k.Refresh}, - {k.LaunchTab}, - } -} - -var keys = func() keyMap { - cfg, err := config.LoadConfig() - if err != nil { - panic(fmt.Sprintf("failed to load config: %v", err)) - } - - var tabSwitch = fmt.Sprintf("%s | %s", cfg.Shortcuts.SwitchTabLeft, cfg.Shortcuts.SwitchTabRight) - - return keyMap{ - Refresh: teakey.NewBinding( - teakey.WithKeys(cfg.Shortcuts.Refresh), - teakey.WithHelp(cfg.Shortcuts.Refresh, "Refresh list"), - ), - LaunchTab: teakey.NewBinding( - teakey.WithKeys(cfg.Shortcuts.Enter), - teakey.WithHelp(cfg.Shortcuts.Enter, "Launch the selected option"), - ), - SwitchTab: teakey.NewBinding( - teakey.WithKeys(""), // help-only binding - teakey.WithHelp(tabSwitch, "switch tab"), - ), - } -}() - -func (m *ModelGithubRepository) ViewHelp() string { - return m.Help.View(m.Keys) -} diff --git a/internal/terminal/handler/ghrepository/table.go b/internal/terminal/handler/ghrepository/table.go deleted file mode 100644 index 256735e..0000000 --- a/internal/terminal/handler/ghrepository/table.go +++ /dev/null @@ -1,12 +0,0 @@ -package ghrepository - -import ( - "github.com/charmbracelet/bubbles/table" -) - -var tableColumnsGithubRepository = []table.Column{ - {Title: "Repository", Width: 24}, - {Title: "Default Branch", Width: 16}, - {Title: "Stars", Width: 6}, - {Title: "Workflows", Width: 9}, -} diff --git a/internal/terminal/handler/ghtrigger.go b/internal/terminal/handler/ghtrigger.go new file mode 100644 index 0000000..e9d8392 --- /dev/null +++ b/internal/terminal/handler/ghtrigger.go @@ -0,0 +1,910 @@ +package handler + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + gu "github.com/termkit/gama/internal/github/usecase" + "github.com/termkit/gama/pkg/workflow" + "github.com/termkit/skeleton" + "golang.org/x/exp/slices" +) + +// ----------------------------------------------------------------------------- +// Model Definition +// ----------------------------------------------------------------------------- + +type ModelGithubTrigger struct { + // Core dependencies + skeleton *skeleton.Skeleton + github gu.UseCase + + // UI Components + help help.Model + Keys githubTriggerKeyMap + tableTrigger table.Model + status *ModelStatus + textInput textinput.Model + + // Workflow state + workflowContent *workflow.Pretty + selectedWorkflow string + selectedRepositoryName string + isTriggerable bool + + // Table state + tableReady bool + + // Option state + optionInit bool + optionCursor int + optionValues []string + currentOption string + + // Input state + triggerFocused bool + + // Context management + syncWorkflowContext context.Context + cancelSyncWorkflow context.CancelFunc + + // Shared state + selectedRepository *SelectedRepository + + // Track last branch for refresh + lastBranch string +} + +// ----------------------------------------------------------------------------- +// Constructor & Initialization +// ----------------------------------------------------------------------------- + +func SetupModelGithubTrigger(s *skeleton.Skeleton, githubUseCase gu.UseCase) *ModelGithubTrigger { + m := &ModelGithubTrigger{ + // Initialize core dependencies + skeleton: s, + github: githubUseCase, + + // Initialize UI components + help: help.New(), + Keys: githubTriggerKeys, + status: SetupModelStatus(s), + textInput: setupTriggerInput(), + tableTrigger: setupTriggerTable(), + + // Initialize state + selectedRepository: NewSelectedRepository(), + syncWorkflowContext: context.Background(), + cancelSyncWorkflow: func() {}, + } + + return m +} + +func setupTriggerInput() textinput.Model { + ti := textinput.New() + ti.Blur() + ti.CharLimit = 72 + return ti +} + +func setupTriggerTable() table.Model { + t := table.New( + table.WithColumns(tableColumnsTrigger), + table.WithRows([]table.Row{}), + table.WithFocused(true), + table.WithHeight(7), + ) + + // Apply styles + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + return t +} + +// ----------------------------------------------------------------------------- +// Bubbletea Model Implementation +// ----------------------------------------------------------------------------- + +func (m *ModelGithubTrigger) Init() tea.Cmd { + return tea.Batch(textinput.Blink) +} + +func (m *ModelGithubTrigger) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if cmd := m.handleWorkflowChange(); cmd != nil { + return m, cmd + } + + var cmds []tea.Cmd + var cmd tea.Cmd + + // Handle key messages + switch msg := msg.(type) { + case tea.KeyMsg: + cmd = m.handleKeyMsg(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + + // Update UI components + if cmd = m.updateUIComponents(msg); cmd != nil { + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m *ModelGithubTrigger) View() string { + return lipgloss.JoinVertical(lipgloss.Top, + m.renderTable(), + m.renderInputArea(), + m.status.View(), + m.renderHelp(), + ) +} + +// ----------------------------------------------------------------------------- +// Workflow Change Handling +// ----------------------------------------------------------------------------- + +func (m *ModelGithubTrigger) handleWorkflowChange() tea.Cmd { + if m.selectedRepository.WorkflowName == "" { + m.handleNoWorkflow() + return nil + } + + if m.shouldSyncWorkflow() { + m.lastBranch = m.selectedRepository.BranchName + return m.initializeWorkflowSync() + } + + return nil +} + +func (m *ModelGithubTrigger) handleNoWorkflow() { + m.status.Reset() + m.status.SetDefaultMessage("No workflow selected.") + m.fillTableWithEmptyMessage() +} + +func (m *ModelGithubTrigger) shouldSyncWorkflow() bool { + return m.selectedRepository.WorkflowName != "" && + (m.selectedRepository.WorkflowName != m.selectedWorkflow || + m.selectedRepository.RepositoryName != m.selectedRepositoryName || + m.lastBranch != m.selectedRepository.BranchName) +} + +func (m *ModelGithubTrigger) initializeWorkflowSync() tea.Cmd { + m.tableReady = false + m.isTriggerable = false + m.triggerFocused = false + + m.cancelSyncWorkflow() + + m.selectedWorkflow = m.selectedRepository.WorkflowName + m.selectedRepositoryName = m.selectedRepository.RepositoryName + m.syncWorkflowContext, m.cancelSyncWorkflow = context.WithCancel(context.Background()) + + go m.syncWorkflowContent(m.syncWorkflowContext) + return nil +} + +// ----------------------------------------------------------------------------- +// Key & Input Handling +// ----------------------------------------------------------------------------- + +func (m *ModelGithubTrigger) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { + switch { + case msg.String() == "up": + return m.handleUpKey() + case msg.String() == "down": + return m.handleDownKey() + case key.Matches(msg, m.Keys.Refresh): + go m.syncWorkflowContent(m.syncWorkflowContext) + case msg.String() == "left": + m.handleLeftKey() + case msg.String() == "right": + m.handleRightKey() + case msg.String() == "tab": + m.handleTabKey() + case msg.String() == "enter": + if m.triggerFocused && m.isTriggerable { + go m.triggerWorkflow() + } + } + return nil +} + +func (m *ModelGithubTrigger) handleUpKey() tea.Cmd { + if len(m.tableTrigger.Rows()) > 0 && !m.triggerFocused { + m.tableTrigger.MoveUp(1) + m.switchBetweenInputAndTable() + m.optionInit = false + } + return nil +} + +func (m *ModelGithubTrigger) handleDownKey() tea.Cmd { + if len(m.tableTrigger.Rows()) > 0 && !m.triggerFocused { + m.tableTrigger.MoveDown(1) + m.switchBetweenInputAndTable() + m.optionInit = false + } + return nil +} + +func (m *ModelGithubTrigger) handleLeftKey() { + if !m.triggerFocused { + m.optionCursor = max(m.optionCursor-1, 0) + } +} + +func (m *ModelGithubTrigger) handleRightKey() { + if !m.triggerFocused { + m.optionCursor = min(m.optionCursor+1, len(m.optionValues)-1) + } +} + +func (m *ModelGithubTrigger) handleTabKey() { + if m.isTriggerable { + m.triggerFocused = !m.triggerFocused + if m.triggerFocused { + m.tableTrigger.Blur() + m.textInput.Blur() + m.showInformationIfAnyEmptyValue() + } else { + m.tableTrigger.Focus() + m.textInput.Focus() + } + } +} + +// ----------------------------------------------------------------------------- +// Input Management +// ----------------------------------------------------------------------------- + +func (m *ModelGithubTrigger) switchBetweenInputAndTable() { + selectedRow := m.tableTrigger.SelectedRow() + if len(selectedRow) == 0 { + return + } + + if selectedRow[1] == "input" || selectedRow[1] == "bool" { + m.textInput.Focus() + m.tableTrigger.Blur() + } else { + m.textInput.Blur() + m.tableTrigger.Focus() + } + + m.textInput.SetValue(selectedRow[4]) + m.textInput.SetCursor(len(m.textInput.Value())) +} + +func (m *ModelGithubTrigger) inputController() { + if m.workflowContent == nil { + return + } + + selectedRow := m.tableTrigger.SelectedRow() + if len(selectedRow) == 0 { + return + } + + switch selectedRow[1] { + case "choice", "bool": + m.handleChoiceInput(selectedRow) + default: + m.handleTextInput() + } +} + +func (m *ModelGithubTrigger) handleChoiceInput(row []string) { + var optionValues []string + if row[1] == "choice" { + optionValues = m.getChoiceValues(row[0]) + } else { + optionValues = m.getBooleanValues(row[0]) + } + + m.optionValues = optionValues + if !m.optionInit { + for i, option := range m.optionValues { + if option == row[4] { + m.optionCursor = i + } + } + } + m.optionInit = true + + state := &InputState{ + Type: row[1], + Options: m.optionValues, + Cursor: m.optionCursor, + } + m.updateInputState(state) +} + +func (m *ModelGithubTrigger) handleTextInput() { + m.optionValues = nil + m.optionCursor = 0 + + if !m.triggerFocused { + m.textInput.Focus() + } + + if m.textInput.Focused() { + state := &InputState{ + Type: "input", + Value: m.textInput.Value(), + IsFocused: true, + } + m.updateInputState(state) + } +} + +// ----------------------------------------------------------------------------- +// Workflow Content Management +// ----------------------------------------------------------------------------- + +func (m *ModelGithubTrigger) syncWorkflowContent(ctx context.Context) { + defer m.skeleton.TriggerUpdate() + + m.status.Reset() + m.status.SetProgressMessage(fmt.Sprintf("[%s@%s] Fetching workflow contents...", + m.selectedRepository.RepositoryName, m.selectedRepository.BranchName)) + + m.tableTrigger.SetRows([]table.Row{}) + + workflowContent, err := m.github.InspectWorkflow(ctx, gu.InspectWorkflowInput{ + Repository: m.selectedRepository.RepositoryName, + Branch: m.selectedRepository.BranchName, + WorkflowFile: m.selectedWorkflow, + }) + + if err != nil { + m.handleWorkflowError(err) + return + } + + m.processWorkflowContent(workflowContent) +} + +func (m *ModelGithubTrigger) processWorkflowContent(content *gu.InspectWorkflowOutput) { + if content.Workflow == nil { + m.status.SetError(errors.New("workflow contents cannot be empty")) + m.status.SetErrorMessage("You have no workflow contents") + return + } + + m.workflowContent = content.Workflow + m.updateTriggerTable() + m.finalizeWorkflowUpdate() +} + +// ----------------------------------------------------------------------------- +// Table Management +// ----------------------------------------------------------------------------- + +func (m *ModelGithubTrigger) updateTriggerTable() { + var rows []table.Row + + // Add key-value inputs + for _, keyVal := range m.workflowContent.KeyVals { + rows = append(rows, table.Row{ + fmt.Sprintf("%d", keyVal.ID), + "input", + keyVal.Key, + keyVal.Default, + keyVal.Value, + }) + } + + // Add choice inputs + for _, choice := range m.workflowContent.Choices { + rows = append(rows, table.Row{ + fmt.Sprintf("%d", choice.ID), + "choice", + choice.Key, + choice.Default, + choice.Value, + }) + } + + // Add regular inputs + for _, input := range m.workflowContent.Inputs { + rows = append(rows, table.Row{ + fmt.Sprintf("%d", input.ID), + "input", + input.Key, + input.Default, + input.Value, + }) + } + + // Add boolean inputs + for _, boolean := range m.workflowContent.Boolean { + rows = append(rows, table.Row{ + fmt.Sprintf("%d", boolean.ID), + "bool", + boolean.Key, + boolean.Default, + boolean.Value, + }) + } + + m.tableTrigger.SetRows(rows) + m.sortTableItemsByName() +} + +func (m *ModelGithubTrigger) sortTableItemsByName() { + rows := m.tableTrigger.Rows() + slices.SortFunc(rows, func(a, b table.Row) int { + return strings.Compare(a[2], b[2]) + }) + m.tableTrigger.SetRows(rows) +} + +func (m *ModelGithubTrigger) finalizeWorkflowUpdate() { + m.tableTrigger.SetCursor(0) + m.optionCursor = 0 + m.optionValues = nil + m.triggerFocused = false + m.tableTrigger.Focus() + + m.textInput.SetCursor(0) + m.textInput.SetValue("") + m.textInput.Placeholder = "" + + m.tableReady = true + m.isTriggerable = true + + if m.hasNoWorkflowOptions() { + m.handleEmptyWorkflow() + } else { + m.status.SetSuccessMessage(fmt.Sprintf("[%s@%s] Workflow contents fetched.", + m.selectedRepository.RepositoryName, m.selectedRepository.BranchName)) + } +} + +// ----------------------------------------------------------------------------- +// Trigger Logic +// ----------------------------------------------------------------------------- + +func (m *ModelGithubTrigger) triggerWorkflow() { + if m.triggerFocused { + m.fillEmptyValuesWithDefault() + } + + m.status.SetProgressMessage(fmt.Sprintf("[%s@%s]:[%s] Triggering workflow...", + m.selectedRepository.RepositoryName, m.selectedRepository.BranchName, m.selectedWorkflow)) + + if m.workflowContent == nil { + m.status.SetErrorMessage("Workflow contents cannot be empty") + return + } + + content, err := m.workflowContent.ToJson() + if err != nil { + m.status.SetError(err) + m.status.SetErrorMessage("Workflow contents cannot be converted to JSON") + return + } + + _, err = m.github.TriggerWorkflow(context.Background(), gu.TriggerWorkflowInput{ + Repository: m.selectedRepository.RepositoryName, + Branch: m.selectedRepository.BranchName, + WorkflowFile: m.selectedWorkflow, + Content: content, + }) + + if err != nil { + m.status.SetError(err) + m.status.SetErrorMessage("Workflow cannot be triggered") + return + } + + m.handleSuccessfulTrigger() +} + +func (m *ModelGithubTrigger) handleSuccessfulTrigger() { + m.status.SetSuccessMessage(fmt.Sprintf("[%s@%s]:[%s] Workflow triggered.", + m.selectedRepository.RepositoryName, m.selectedRepository.BranchName, m.selectedWorkflow)) + + m.status.SetProgressMessage("Switching to workflow history tab...") + time.Sleep(2000 * time.Millisecond) + + m.resetTriggerState() + m.switchToHistoryTab() +} + +// ----------------------------------------------------------------------------- +// UI Rendering +// ----------------------------------------------------------------------------- + +func (m *ModelGithubTrigger) renderTable() string { + baseStyle := lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + MarginLeft(1) + + if m.triggerFocused { + baseStyle = baseStyle.BorderForeground(lipgloss.Color("240")) + } else { + baseStyle = baseStyle.BorderForeground(lipgloss.Color("#3b698f")) + } + + m.updateTableDimensions() + return baseStyle.Render(m.tableTrigger.View()) +} + +func (m *ModelGithubTrigger) renderInputArea() string { + var selectedRow = m.tableTrigger.SelectedRow() + var selector = m.emptySelector() + + if len(m.tableTrigger.Rows()) > 0 { + if selectedRow[1] == "input" { + selector = m.inputSelector() + } else { + selector = m.optionSelector() + } + } + + return lipgloss.JoinHorizontal(lipgloss.Top, selector, m.triggerButton()) +} + +func (m *ModelGithubTrigger) renderHelp() string { + helpStyle := WindowStyleHelp.Width(m.skeleton.GetTerminalWidth() - 4) + return helpStyle.Render(m.help.View(m.Keys)) +} + +// ----------------------------------------------------------------------------- +// Helper Functions - Selectors +// ----------------------------------------------------------------------------- + +func (m *ModelGithubTrigger) emptySelector() string { + return m.getSelectorStyle().Render("") +} + +func (m *ModelGithubTrigger) inputSelector() string { + return m.getSelectorStyle().Render(m.textInput.View()) +} + +func (m *ModelGithubTrigger) optionSelector() string { + var processedValues []string + for i, option := range m.optionValues { + if i == m.optionCursor { + processedValues = append(processedValues, m.getSelectedOptionStyle().Render(option)) + } else { + processedValues = append(processedValues, m.getUnselectedOptionStyle().Render(option)) + } + } + + return m.getSelectorStyle().Render(lipgloss.JoinHorizontal(lipgloss.Left, processedValues...)) +} + +func (m *ModelGithubTrigger) triggerButton() string { + button := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("255")). + Padding(0, 1). + Align(lipgloss.Center) + + if m.triggerFocused { + button = button. + BorderForeground(lipgloss.Color("#399adb")). + Foreground(lipgloss.Color("#399adb")). + BorderStyle(lipgloss.DoubleBorder()) + } + + return button.Render("Trigger") +} + +// ----------------------------------------------------------------------------- +// Helper Functions - Styles +// ----------------------------------------------------------------------------- + +func (m *ModelGithubTrigger) getSelectorStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#3b698f")). + Padding(0, 1). + Width(m.skeleton.GetTerminalWidth() - 17). + MarginLeft(1) +} + +func (m *ModelGithubTrigger) getSelectedOptionStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("120")). + Padding(0, 1) +} + +func (m *ModelGithubTrigger) getUnselectedOptionStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("140")). + Padding(0, 1) +} + +// ----------------------------------------------------------------------------- +// Helper Functions - Value Management +// ----------------------------------------------------------------------------- + +func (m *ModelGithubTrigger) getChoiceValues(id string) []string { + for _, choice := range m.workflowContent.Choices { + if fmt.Sprintf("%d", choice.ID) == id { + return choice.Values + } + } + return nil +} + +func (m *ModelGithubTrigger) getBooleanValues(id string) []string { + for _, boolean := range m.workflowContent.Boolean { + if fmt.Sprintf("%d", boolean.ID) == id { + return boolean.Values + } + } + return nil +} + +func (m *ModelGithubTrigger) updateChoiceValue(row []string, value string) { + rows := m.tableTrigger.Rows() + for i, r := range rows { + if r[0] == row[0] { + rows[i][4] = value + } + } + m.tableTrigger.SetRows(rows) + + if row[1] == "choice" { + m.updateWorkflowChoiceValue(row[0]) + } else { + m.updateWorkflowBooleanValue(row[0]) + } +} + +func (m *ModelGithubTrigger) updateWorkflowChoiceValue(id string) { + for i, choice := range m.workflowContent.Choices { + if fmt.Sprintf("%d", choice.ID) == id { + m.workflowContent.Choices[i].SetValue(m.optionValues[m.optionCursor]) + break + } + } +} + +func (m *ModelGithubTrigger) updateWorkflowBooleanValue(id string) { + for i, boolean := range m.workflowContent.Boolean { + if fmt.Sprintf("%d", boolean.ID) == id { + m.workflowContent.Boolean[i].SetValue(m.optionValues[m.optionCursor]) + break + } + } +} + +func (m *ModelGithubTrigger) updateTextInputValue(row []string, value string) { + if strings.HasPrefix(value, " ") { + return + } + + rows := m.tableTrigger.Rows() + for i, r := range rows { + if r[0] == row[0] { + rows[i][4] = value + } + } + m.tableTrigger.SetRows(rows) + + m.updateWorkflowInputValue(row) +} + +func (m *ModelGithubTrigger) updateWorkflowInputValue(row []string) { + for i, input := range m.workflowContent.Inputs { + if fmt.Sprintf("%d", input.ID) == row[0] { + m.textInput.Placeholder = input.Default + m.workflowContent.Inputs[i].SetValue(m.textInput.Value()) + return + } + } + + for i, keyVal := range m.workflowContent.KeyVals { + if fmt.Sprintf("%d", keyVal.ID) == row[0] { + m.textInput.Placeholder = keyVal.Default + m.workflowContent.KeyVals[i].SetValue(m.textInput.Value()) + return + } + } +} + +// ----------------------------------------------------------------------------- +// Helper Functions - State Management +// ----------------------------------------------------------------------------- + +func (m *ModelGithubTrigger) fillEmptyValuesWithDefault() { + if m.workflowContent == nil { + return + } + + rows := m.tableTrigger.Rows() + for i, row := range rows { + if row[4] == "" { + rows[i][4] = rows[i][3] + } + } + m.tableTrigger.SetRows(rows) + + m.fillWorkflowEmptyValues() +} + +func (m *ModelGithubTrigger) fillWorkflowEmptyValues() { + for i, choice := range m.workflowContent.Choices { + if choice.Value == "" { + m.workflowContent.Choices[i].SetValue(choice.Default) + } + } + + for i, input := range m.workflowContent.Inputs { + if input.Value == "" { + m.workflowContent.Inputs[i].SetValue(input.Default) + } + } + + for i, keyVal := range m.workflowContent.KeyVals { + if keyVal.Value == "" { + m.workflowContent.KeyVals[i].SetValue(keyVal.Default) + } + } + + for i, boolean := range m.workflowContent.Boolean { + if boolean.Value == "" { + m.workflowContent.Boolean[i].SetValue(boolean.Default) + } + } +} + +func (m *ModelGithubTrigger) resetTriggerState() { + m.workflowContent = nil + m.selectedWorkflow = "" + m.currentOption = "" + m.optionValues = nil + m.selectedRepositoryName = "" +} + +func (m *ModelGithubTrigger) switchToHistoryTab() { + m.skeleton.TriggerUpdateWithMsg(workflowHistoryUpdateMsg{time.Second * 3}) + m.skeleton.SetActivePage("history") +} + +func (m *ModelGithubTrigger) hasNoWorkflowOptions() bool { + return len(m.workflowContent.KeyVals) == 0 && + len(m.workflowContent.Choices) == 0 && + len(m.workflowContent.Inputs) == 0 +} + +func (m *ModelGithubTrigger) handleEmptyWorkflow() { + m.fillTableWithEmptyMessage() + m.status.SetDefaultMessage(fmt.Sprintf("[%s@%s] Workflow doesn't contain options but still triggerable", + m.selectedRepository.RepositoryName, m.selectedRepository.BranchName)) +} + +func (m *ModelGithubTrigger) fillTableWithEmptyMessage() { + var rows []table.Row + for i := 0; i < 100; i++ { + idx := fmt.Sprintf("%d", i) + rows = append(rows, table.Row{ + idx, "EMPTY", "EMPTY", "EMPTY", "No workflow input found", + }) + } + + m.tableTrigger.SetRows(rows) + m.tableTrigger.SetCursor(0) +} + +func (m *ModelGithubTrigger) showInformationIfAnyEmptyValue() { + for _, row := range m.tableTrigger.Rows() { + if row[4] == "" { + m.status.SetDefaultMessage("Info: You have empty values. These values uses their default values.") + return + } + } +} + +func (m *ModelGithubTrigger) handleWorkflowError(err error) { + if errors.Is(err, context.Canceled) { + return + } + m.status.SetError(err) + m.status.SetErrorMessage("Workflow contents cannot be fetched") +} + +// ----------------------------------------------------------------------------- +// Helper Functions - Table Dimensions +// ----------------------------------------------------------------------------- + +func (m *ModelGithubTrigger) updateTableDimensions() { + var tableWidth int + for _, t := range tableColumnsTrigger { + tableWidth += t.Width + } + + newTableColumns := tableColumnsTrigger + widthDiff := m.skeleton.GetTerminalWidth() - tableWidth + if widthDiff > 0 { + keyWidth := &newTableColumns[2].Width + valueWidth := &newTableColumns[4].Width + + *valueWidth += widthDiff - 16 + if *valueWidth%2 == 0 { + *keyWidth = *valueWidth / 2 + } + m.tableTrigger.SetColumns(newTableColumns) + m.tableTrigger.SetHeight(m.skeleton.GetTerminalHeight() - 17) + } +} + +// ----------------------------------------------------------------------------- +// UI Component Updates +// ----------------------------------------------------------------------------- + +func (m *ModelGithubTrigger) updateUIComponents(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + var cmd tea.Cmd + + // Update text input + m.textInput, cmd = m.textInput.Update(msg) + cmds = append(cmds, cmd) + + // Update table + m.tableTrigger, cmd = m.tableTrigger.Update(msg) + cmds = append(cmds, cmd) + + // Handle input controller + m.inputController() + + return tea.Batch(cmds...) +} + +// ----------------------------------------------------------------------------- +// Input & Table State Management +// ----------------------------------------------------------------------------- + +type InputState struct { + Type string // "input", "choice", "bool" + Value string + Default string + Options []string + Cursor int + IsFocused bool +} + +func (m *ModelGithubTrigger) updateInputState(state *InputState) { + row := m.tableTrigger.SelectedRow() + if len(row) == 0 { + return + } + + switch state.Type { + case "choice", "bool": + m.updateChoiceValue(row, state.Options[state.Cursor]) + case "input": + m.updateTextInputValue(row, state.Value) + } +} diff --git a/internal/terminal/handler/ghtrigger/ghtrigger.go b/internal/terminal/handler/ghtrigger/ghtrigger.go deleted file mode 100644 index 290b654..0000000 --- a/internal/terminal/handler/ghtrigger/ghtrigger.go +++ /dev/null @@ -1,688 +0,0 @@ -package ghtrigger - -import ( - "context" - "errors" - "fmt" - "slices" - "strings" - "time" - - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/table" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - gu "github.com/termkit/gama/internal/github/usecase" - hdlerror "github.com/termkit/gama/internal/terminal/handler/error" - hdltypes "github.com/termkit/gama/internal/terminal/handler/types" - "github.com/termkit/gama/pkg/workflow" -) - -type ModelGithubTrigger struct { - // current handler's properties - syncWorkflowContext context.Context - cancelSyncWorkflow context.CancelFunc - workflowContent *workflow.Pretty - tableReady bool - isTriggerable bool - currentTab *int - forceUpdateWorkflowHistory *bool - optionInit bool - optionCursor int - optionValues []string - currentOption string - selectedWorkflow string - selectedRepositoryName string - triggerFocused bool - - // shared properties - SelectedRepository *hdltypes.SelectedRepository - - // use cases - github gu.UseCase - - // keymap - Keys keyMap - - // models - Help help.Model - Viewport *viewport.Model - modelError hdlerror.ModelError - textInput textinput.Model - tableTrigger table.Model -} - -func SetupModelGithubTrigger(githubUseCase gu.UseCase, selectedRepository *hdltypes.SelectedRepository, currentTab *int, forceUpdateWorkflowHistory *bool) *ModelGithubTrigger { - var tableRowsTrigger []table.Row - - tableTrigger := table.New( - table.WithColumns(tableColumnsTrigger), - table.WithRows(tableRowsTrigger), - table.WithFocused(true), - table.WithHeight(7), - ) - - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(false) - s.Selected = s.Selected. - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). - Bold(false) - tableTrigger.SetStyles(s) - - ti := textinput.New() - ti.Blur() - ti.CharLimit = 72 - - return &ModelGithubTrigger{ - currentTab: currentTab, - forceUpdateWorkflowHistory: forceUpdateWorkflowHistory, - Help: help.New(), - Keys: keys, - github: githubUseCase, - SelectedRepository: selectedRepository, - modelError: hdlerror.SetupModelError(), - tableTrigger: tableTrigger, - textInput: ti, - syncWorkflowContext: context.Background(), - cancelSyncWorkflow: func() {}, - } -} - -func (m *ModelGithubTrigger) Init() tea.Cmd { - m.modelError.SetDefaultMessage("No workflow contents found.") - return textinput.Blink -} - -func (m *ModelGithubTrigger) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if m.SelectedRepository.WorkflowName == "" { - m.modelError.Reset() - m.modelError.SetDefaultMessage("No workflow selected.") - return m, nil - } - if m.SelectedRepository.WorkflowName != "" && (m.SelectedRepository.WorkflowName != m.selectedWorkflow || m.SelectedRepository.RepositoryName != m.selectedRepositoryName) { - m.tableReady = false - m.isTriggerable = false - m.triggerFocused = false - - m.cancelSyncWorkflow() // cancel previous sync workflow - - m.selectedWorkflow = m.SelectedRepository.WorkflowName - m.selectedRepositoryName = m.SelectedRepository.RepositoryName - m.syncWorkflowContext, m.cancelSyncWorkflow = context.WithCancel(context.Background()) - - go m.syncWorkflowContent(m.syncWorkflowContext) - } - - var cmds []tea.Cmd - var cmd tea.Cmd - - switch shadowMsg := msg.(type) { - case tea.KeyMsg: - switch shadowMsg.String() { - case "up": - if len(m.tableTrigger.Rows()) > 0 && !m.triggerFocused { - m.tableTrigger.MoveUp(1) - m.switchBetweenInputAndTable() - // delete msg key to prevent moving cursor - msg = tea.KeyMsg{Type: tea.KeyNull} - - m.optionInit = false - } - case "down": - if len(m.tableTrigger.Rows()) > 0 && !m.triggerFocused { - m.tableTrigger.MoveDown(1) - m.switchBetweenInputAndTable() - // delete msg key to prevent moving cursor - msg = tea.KeyMsg{Type: tea.KeyNull} - - m.optionInit = false - } - case "ctrl+r", "ctrl+R": - go m.syncWorkflowContent(m.syncWorkflowContext) - case "left": - if !m.triggerFocused { - m.optionCursor = max(m.optionCursor-1, 0) - } - case "right": - if !m.triggerFocused { - m.optionCursor = min(m.optionCursor+1, len(m.optionValues)-1) - } - case "tab": - if m.isTriggerable { - m.triggerFocused = !m.triggerFocused - if m.triggerFocused { - m.tableTrigger.Blur() - m.textInput.Blur() - m.showInformationIfAnyEmptyValue() - } else { - m.tableTrigger.Focus() - m.textInput.Focus() - } - } - case "enter": - if m.triggerFocused && m.isTriggerable { - go m.triggerWorkflow() - } - } - } - - m.tableTrigger, cmd = m.tableTrigger.Update(msg) - cmds = append(cmds, cmd) - - m.textInput, cmd = m.textInput.Update(msg) - cmds = append(cmds, cmd) - - m.inputController(m.syncWorkflowContext) - - return m, tea.Batch(cmds...) -} - -func (m *ModelGithubTrigger) switchBetweenInputAndTable() { - var selectedRow = m.tableTrigger.SelectedRow() - - if selectedRow[1] == "input" || selectedRow[1] == "bool" { - m.textInput.Focus() - m.tableTrigger.Blur() - } else { - m.textInput.Blur() - m.tableTrigger.Focus() - } - m.textInput.SetValue(m.tableTrigger.SelectedRow()[4]) - m.textInput.SetCursor(len(m.textInput.Value())) -} - -func (m *ModelGithubTrigger) inputController(_ context.Context) { - if m.workflowContent == nil { - return - } - - if len(m.tableTrigger.Rows()) > 0 { - var selectedRow = m.tableTrigger.SelectedRow() - if len(selectedRow) == 0 { - return - } - - switch selectedRow[1] { - case "choice": - var optionValues []string - for _, choice := range m.workflowContent.Choices { - if fmt.Sprintf("%d", choice.ID) == selectedRow[0] { - optionValues = append(optionValues, choice.Values...) - } - } - m.optionValues = optionValues - if !m.optionInit { - for i, option := range m.optionValues { - if option == selectedRow[4] { - m.optionCursor = i - } - } - } - m.optionInit = true - case "bool": - var optionValues []string - for _, choice := range m.workflowContent.Boolean { - if fmt.Sprintf("%d", choice.ID) == selectedRow[0] { - optionValues = append(optionValues, choice.Values...) - } - } - m.optionValues = optionValues - if !m.optionInit { - for i, option := range m.optionValues { - if option == selectedRow[4] { - m.optionCursor = i - } - } - } - m.optionInit = true - default: - m.optionValues = nil - m.optionCursor = 0 - - if !m.triggerFocused { - m.textInput.Focus() - } - } - } - - for i, choice := range m.workflowContent.Choices { - var selectedRow = m.tableTrigger.SelectedRow() - var rows = m.tableTrigger.Rows() - - if len(selectedRow) == 0 || len(rows) == 0 { - return - } - if fmt.Sprintf("%d", choice.ID) == selectedRow[0] { - m.workflowContent.Choices[i].SetValue(m.optionValues[m.optionCursor]) - - for i, row := range rows { - if row[0] == selectedRow[0] { - rows[i][4] = m.optionValues[m.optionCursor] - } - } - - m.tableTrigger.SetRows(rows) - } - } - - if m.workflowContent.Boolean != nil { - for i, boolean := range m.workflowContent.Boolean { - var selectedRow = m.tableTrigger.SelectedRow() - var rows = m.tableTrigger.Rows() - if len(selectedRow) == 0 || len(rows) == 0 { - return - } - if fmt.Sprintf("%d", boolean.ID) == selectedRow[0] { - m.workflowContent.Boolean[i].SetValue(m.optionValues[m.optionCursor]) - - for i, row := range rows { - if row[0] == selectedRow[0] { - rows[i][4] = m.optionValues[m.optionCursor] - } - } - - m.tableTrigger.SetRows(rows) - } - } - } - - if m.textInput.Focused() { - if strings.HasPrefix(m.textInput.Value(), " ") { - m.textInput.SetValue("") - } - - var selectedRow = m.tableTrigger.SelectedRow() - var rows = m.tableTrigger.Rows() - if len(selectedRow) == 0 || len(rows) == 0 { - return - } - - for i, input := range m.workflowContent.Inputs { - if fmt.Sprintf("%d", input.ID) == selectedRow[0] { - m.textInput.Placeholder = input.Default - m.workflowContent.Inputs[i].SetValue(m.textInput.Value()) - - for i, row := range rows { - if row[0] == selectedRow[0] { - rows[i][4] = m.textInput.Value() - } - } - - m.tableTrigger.SetRows(rows) - } - } - - for i, keyVal := range m.workflowContent.KeyVals { - if fmt.Sprintf("%d", keyVal.ID) == selectedRow[0] { - m.textInput.Placeholder = keyVal.Default - m.workflowContent.KeyVals[i].SetValue(m.textInput.Value()) - - for i, row := range rows { - if row[0] == selectedRow[0] { - rows[i][4] = m.textInput.Value() - } - } - - m.tableTrigger.SetRows(rows) - } - } - } -} - -func (m *ModelGithubTrigger) View() string { - baseStyle := lipgloss.NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")) - - termWidth := m.Viewport.Width - termHeight := m.Viewport.Height - - var tableWidth int - for _, t := range tableColumnsTrigger { - tableWidth += t.Width - } - - newTableColumns := tableColumnsTrigger - widthDiff := termWidth - tableWidth - if widthDiff > 0 { - keyWidth := &newTableColumns[2].Width - valueWidth := &newTableColumns[4].Width - - *valueWidth += widthDiff - 17 - if *valueWidth%2 == 0 { - *keyWidth = *valueWidth / 2 - } - m.tableTrigger.SetColumns(newTableColumns) - m.tableTrigger.SetHeight(termHeight - 17) - } - - doc := strings.Builder{} - doc.WriteString(baseStyle.Render(m.tableTrigger.View())) - - var selectedRow = m.tableTrigger.SelectedRow() - var selector = m.emptySelector() - if len(m.tableTrigger.Rows()) > 0 { - if selectedRow[1] == "input" { - selector = m.inputSelector() - } else { - selector = m.optionSelector() - } - } - - return lipgloss.JoinVertical(lipgloss.Top, doc.String(), - lipgloss.JoinHorizontal(lipgloss.Top, selector, m.triggerButton())) -} - -func (m *ModelGithubTrigger) syncWorkflowContent(ctx context.Context) { - m.modelError.Reset() - m.modelError.SetProgressMessage( - fmt.Sprintf("[%s@%s] Fetching workflow contents...", - m.SelectedRepository.RepositoryName, m.SelectedRepository.BranchName)) - - // reset table rows - m.tableTrigger.SetRows([]table.Row{}) - - workflowContent, err := m.github.InspectWorkflow(ctx, gu.InspectWorkflowInput{ - Repository: m.SelectedRepository.RepositoryName, - Branch: m.SelectedRepository.BranchName, - WorkflowFile: m.selectedWorkflow, - }) - if errors.Is(err, context.Canceled) { - return - } else if err != nil { - m.modelError.SetError(err) - m.modelError.SetErrorMessage("Workflow contents cannot be fetched") - return - } - - if workflowContent.Workflow == nil { - m.modelError.SetError(errors.New("workflow contents cannot be empty")) - m.modelError.SetErrorMessage("You have no workflow contents") - return - } - - m.workflowContent = workflowContent.Workflow - - var tableRowsTrigger []table.Row - for _, keyVal := range m.workflowContent.KeyVals { - tableRowsTrigger = append(tableRowsTrigger, table.Row{ - fmt.Sprintf("%d", keyVal.ID), - "input", // json type - keyVal.Key, - keyVal.Default, - keyVal.Value, - }) - } - - for _, choice := range m.workflowContent.Choices { - tableRowsTrigger = append(tableRowsTrigger, table.Row{ - fmt.Sprintf("%d", choice.ID), - "choice", - choice.Key, - choice.Default, - choice.Value, - }) - } - - for _, input := range m.workflowContent.Inputs { - tableRowsTrigger = append(tableRowsTrigger, table.Row{ - fmt.Sprintf("%d", input.ID), - "input", - input.Key, - input.Default, - input.Value, - }) - } - - for _, boolean := range m.workflowContent.Boolean { - tableRowsTrigger = append(tableRowsTrigger, table.Row{ - fmt.Sprintf("%d", boolean.ID), - "bool", - boolean.Key, - boolean.Default, - boolean.Value, - }) - } - - m.tableTrigger.SetRows(tableRowsTrigger) - m.sortTableItemsByName() - m.tableTrigger.SetCursor(0) - m.optionCursor = 0 - m.optionValues = nil - m.triggerFocused = false - m.tableTrigger.Focus() - - // reset input value - m.textInput.SetCursor(0) - m.textInput.SetValue("") - m.textInput.Placeholder = "" - - m.tableReady = true - m.isTriggerable = true - - if len(workflowContent.Workflow.KeyVals) == 0 && - len(workflowContent.Workflow.Choices) == 0 && - len(workflowContent.Workflow.Inputs) == 0 { - m.fillTableWithEmptyMessage() - m.modelError.SetDefaultMessage(fmt.Sprintf("[%s@%s] Workflow doesn't contain options but still triggerable", - m.SelectedRepository.RepositoryName, m.SelectedRepository.BranchName)) - } else { - m.modelError.SetSuccessMessage(fmt.Sprintf("[%s@%s] Workflow contents fetched.", - m.SelectedRepository.RepositoryName, m.SelectedRepository.BranchName)) - } - - go m.Update(m) // update model -} - -func (m *ModelGithubTrigger) fillTableWithEmptyMessage() { - var rows []table.Row - for i := 0; i < 100; i++ { - idx := fmt.Sprintf("%d", i) - rows = append(rows, table.Row{ - idx, "EMPTY", "EMPTY", "EMPTY", "No workflow input found", - }) - } - - m.tableTrigger.SetRows(rows) - m.tableTrigger.SetCursor(0) -} - -func (m *ModelGithubTrigger) showInformationIfAnyEmptyValue() { - for _, row := range m.tableTrigger.Rows() { - if row[4] == "" { - m.modelError.SetDefaultMessage("Info: You have empty values. These values uses their default values.") - return - } - } -} - -func (m *ModelGithubTrigger) triggerButton() string { - button := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("255")). - Padding(0, 1). - Align(lipgloss.Center) - - if m.triggerFocused { - button = button.BorderForeground(lipgloss.Color("130")). - Foreground(lipgloss.Color("130")). - BorderStyle(lipgloss.DoubleBorder()) - } - - return button.Render("Trigger") -} - -func (m *ModelGithubTrigger) fillEmptyValuesWithDefault() { - if m.workflowContent == nil { - m.modelError.SetError(errors.New("workflow contents cannot be empty")) - m.modelError.SetErrorMessage("You have no workflow contents") - return - } - - rows := m.tableTrigger.Rows() - for i, row := range rows { - if row[4] == "" { - rows[i][4] = rows[i][3] - } - } - m.tableTrigger.SetRows(rows) - - for i, choice := range m.workflowContent.Choices { - if choice.Value == "" { - m.workflowContent.Choices[i].SetValue(choice.Default) - } - - } - - for i, input := range m.workflowContent.Inputs { - if input.Value == "" { - m.workflowContent.Inputs[i].SetValue(input.Default) - } - } - - for i, keyVal := range m.workflowContent.KeyVals { - if keyVal.Value == "" { - m.workflowContent.KeyVals[i].SetValue(keyVal.Default) - } - } - - for i, boolean := range m.workflowContent.Boolean { - if boolean.Value == "" { - m.workflowContent.Boolean[i].SetValue(boolean.Default) - } - } -} - -func (m *ModelGithubTrigger) triggerWorkflow() { - if m.triggerFocused { - m.fillEmptyValuesWithDefault() - } - - m.modelError.SetProgressMessage( - fmt.Sprintf("[%s@%s]:[%s] Triggering workflow...", - m.SelectedRepository.RepositoryName, m.SelectedRepository.BranchName, m.selectedWorkflow)) - - if m.workflowContent == nil { - m.modelError.SetErrorMessage("Workflow contents cannot be empty") - return - } - - content, err := m.workflowContent.ToJson() - if err != nil { - m.modelError.SetError(err) - m.modelError.SetErrorMessage("Workflow contents cannot be converted to JSON") - return - } - - _, err = m.github.TriggerWorkflow(context.Background(), gu.TriggerWorkflowInput{ - Repository: m.SelectedRepository.RepositoryName, - Branch: m.SelectedRepository.BranchName, - WorkflowFile: m.selectedWorkflow, - Content: content, - }) - if err != nil { - m.modelError.SetError(err) - m.modelError.SetErrorMessage("Workflow cannot be triggered") - return - } - - m.modelError.SetSuccessMessage(fmt.Sprintf("[%s@%s]:[%s] Workflow triggered.", - m.SelectedRepository.RepositoryName, m.SelectedRepository.BranchName, m.selectedWorkflow)) - - time.Sleep(1 * time.Second) - m.modelError.SetProgressMessage("Switching to workflow history tab...") - time.Sleep(1 * time.Second) - - // move these operations under new function named "resetTabSettings" - m.workflowContent = nil // reset workflow content - m.selectedWorkflow = "" // reset selected workflow - m.currentOption = "" // reset current option - m.optionValues = nil // reset option values - m.selectedRepositoryName = "" // reset selected repository name - - go func() { - time.Sleep(1 * time.Second) - *m.forceUpdateWorkflowHistory = true // force update workflow history - }() - *m.currentTab = 2 // switch tab to workflow history -} - -func (m *ModelGithubTrigger) emptySelector() string { - // Define window style - windowStyle := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - Padding(0, 1). - Width(*hdltypes.ScreenWidth - 13) - - // Build the options list - doc := strings.Builder{} - - return windowStyle.Render(doc.String()) -} - -func (m *ModelGithubTrigger) inputSelector() string { - // Define window style - windowStyle := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - Padding(0, 1). - Width(*hdltypes.ScreenWidth - 13) - - // Build the options list - doc := strings.Builder{} - - doc.WriteString(m.textInput.View()) - - return windowStyle.Render(doc.String()) -} - -// optionSelector renders the options list -// TODO: Make this dynamic limited&sized. -func (m *ModelGithubTrigger) optionSelector() string { - // Define window style - windowStyle := lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - Padding(0, 1). - Width(*hdltypes.ScreenWidth - 13) - - // Define styles for selected and unselected options - selectedOptionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("120")).Padding(0, 1) - unselectedOptionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("140")).Padding(0, 1) - - // Build the options list - doc := strings.Builder{} - - var processedValues []string - for i, option := range m.optionValues { - if i == m.optionCursor { - processedValues = append(processedValues, selectedOptionStyle.Render(option)) - } else { - processedValues = append(processedValues, unselectedOptionStyle.Render(option)) - } - } - - horizontal := lipgloss.JoinHorizontal(lipgloss.Left, processedValues...) - - doc.WriteString(horizontal) - - // Apply window style to the entire list - return windowStyle.Render(doc.String()) -} - -func (m *ModelGithubTrigger) sortTableItemsByName() { - rows := m.tableTrigger.Rows() - slices.SortFunc(rows, func(a, b table.Row) int { - return strings.Compare(a[2], b[2]) - }) - m.tableTrigger.SetRows(rows) -} - -func (m *ModelGithubTrigger) ViewStatus() string { - return m.modelError.View() -} diff --git a/internal/terminal/handler/ghtrigger/keymap.go b/internal/terminal/handler/ghtrigger/keymap.go deleted file mode 100644 index e0682f7..0000000 --- a/internal/terminal/handler/ghtrigger/keymap.go +++ /dev/null @@ -1,61 +0,0 @@ -package ghtrigger - -import ( - "fmt" - - "github.com/termkit/gama/internal/config" - - teakey "github.com/charmbracelet/bubbles/key" -) - -type keyMap struct { - SwitchTabLeft teakey.Binding - SwitchTab teakey.Binding - Trigger teakey.Binding - Refresh teakey.Binding -} - -func (k keyMap) ShortHelp() []teakey.Binding { - return []teakey.Binding{k.SwitchTabLeft, k.Refresh, k.SwitchTab, k.Trigger} -} - -func (k keyMap) FullHelp() [][]teakey.Binding { - return [][]teakey.Binding{ - {k.SwitchTabLeft}, - {k.Refresh}, - {k.SwitchTab}, - {k.Trigger}, - } -} - -var keys = func() keyMap { - cfg, err := config.LoadConfig() - if err != nil { - panic(fmt.Sprintf("failed to load config: %v", err)) - } - - previousTab := cfg.Shortcuts.SwitchTabLeft - - return keyMap{ - SwitchTabLeft: teakey.NewBinding( - teakey.WithKeys(""), // help-only binding - teakey.WithHelp(previousTab, "previous tab"), - ), - Refresh: teakey.NewBinding( - teakey.WithKeys(cfg.Shortcuts.Refresh), - teakey.WithHelp(cfg.Shortcuts.Refresh, "Refresh list"), - ), - SwitchTab: teakey.NewBinding( - teakey.WithKeys(cfg.Shortcuts.Tab), - teakey.WithHelp(cfg.Shortcuts.Tab, "switch button"), - ), - Trigger: teakey.NewBinding( - teakey.WithKeys(cfg.Shortcuts.Enter), - teakey.WithHelp(cfg.Shortcuts.Enter, "trigger workflow"), - ), - } -}() - -func (m *ModelGithubTrigger) ViewHelp() string { - return m.Help.View(m.Keys) -} diff --git a/internal/terminal/handler/ghtrigger/table.go b/internal/terminal/handler/ghtrigger/table.go deleted file mode 100644 index 538bdc3..0000000 --- a/internal/terminal/handler/ghtrigger/table.go +++ /dev/null @@ -1,14 +0,0 @@ -package ghtrigger - -import ( - "github.com/charmbracelet/bubbles/table" -) - -var tableColumnsTrigger = []table.Column{ - {Title: "ID", Width: 2}, - {Title: "Type", Width: 6}, - {Title: "Key", Width: 24}, - {Title: "Default", Width: 16}, - //{Title: "Description", Width: 64}, - {Title: "Value", Width: 44}, -} diff --git a/internal/terminal/handler/ghworkflow.go b/internal/terminal/handler/ghworkflow.go new file mode 100644 index 0000000..ab0d40c --- /dev/null +++ b/internal/terminal/handler/ghworkflow.go @@ -0,0 +1,498 @@ +package handler + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + gu "github.com/termkit/gama/internal/github/usecase" + "github.com/termkit/skeleton" +) + +// ----------------------------------------------------------------------------- +// Model Definition +// ----------------------------------------------------------------------------- + +type ModelGithubWorkflow struct { + // Core dependencies + skeleton *skeleton.Skeleton + github gu.UseCase + + // UI Components + help help.Model + keys githubWorkflowKeyMap + tableTriggerableWorkflow table.Model + status *ModelStatus + textInput textinput.Model + + // Table state + tableReady bool + + // Context management + syncTriggerableWorkflowsContext context.Context + cancelSyncTriggerableWorkflows context.CancelFunc + + // Shared state + selectedRepository *SelectedRepository + + // Indicates if there are any available workflows + hasWorkflows bool + lastSelectedRepository string // Track last repository for state persistence + + // State management + state struct { + Ready bool + Repository struct { + Current string + Last string + Branch string + HasFlows bool + } + Syncing bool + } +} + +// ----------------------------------------------------------------------------- +// Constructor & Initialization +// ----------------------------------------------------------------------------- + +func SetupModelGithubWorkflow(s *skeleton.Skeleton, githubUseCase gu.UseCase) *ModelGithubWorkflow { + m := &ModelGithubWorkflow{ + // Initialize core dependencies + skeleton: s, + github: githubUseCase, + + // Initialize UI components + help: help.New(), + keys: githubWorkflowKeys, + status: SetupModelStatus(s), + textInput: setupBranchInput(), + + // Initialize state + selectedRepository: NewSelectedRepository(), + syncTriggerableWorkflowsContext: context.Background(), + cancelSyncTriggerableWorkflows: func() {}, + } + + // Setup table and blur initially + m.tableTriggerableWorkflow = setupWorkflowTable() + m.tableTriggerableWorkflow.Blur() + m.textInput.Blur() + + return m +} + +func setupBranchInput() textinput.Model { + ti := textinput.New() + ti.Focus() + ti.CharLimit = 128 + ti.Placeholder = "Type to switch branch" + ti.ShowSuggestions = true + return ti +} + +func setupWorkflowTable() table.Model { + t := table.New( + table.WithColumns(tableColumnsWorkflow), + table.WithRows([]table.Row{}), + table.WithFocused(true), + table.WithHeight(7), + ) + + // Apply styles + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#3b698f")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + // Set keymap + t.KeyMap = table.KeyMap{ + LineUp: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")), + LineDown: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")), + PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")), + PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")), + GotoTop: key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "go to start")), + GotoBottom: key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "go to end")), + } + + return t +} + +// ----------------------------------------------------------------------------- +// Bubbletea Model Implementation +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflow) Init() tea.Cmd { + // Check initial state + if m.lastSelectedRepository == m.selectedRepository.RepositoryName && !m.hasWorkflows { + m.skeleton.LockTab("trigger") + // Blur components initially + m.tableTriggerableWorkflow.Blur() + m.textInput.Blur() + } + return nil +} + +func (m *ModelGithubWorkflow) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Check repository change and return command if exists + if cmd := m.handleRepositoryChange(); cmd != nil { + return m, cmd + } + + var cmds []tea.Cmd + var cmd tea.Cmd + + // Update text input and handle branch selection + m.textInput, cmd = m.textInput.Update(msg) + cmds = append(cmds, cmd) + m.handleBranchSelection() + + // Update table and handle workflow selection + m.tableTriggerableWorkflow, cmd = m.tableTriggerableWorkflow.Update(msg) + cmds = append(cmds, cmd) + m.handleTableInputs() + + return m, tea.Batch(cmds...) +} + +func (m *ModelGithubWorkflow) View() string { + return lipgloss.JoinVertical(lipgloss.Top, + m.renderTable(), + m.renderBranchInput(), + m.status.View(), + m.renderHelp(), + ) +} + +// ----------------------------------------------------------------------------- +// Repository Change Handling +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflow) handleRepositoryChange() tea.Cmd { + if m.state.Repository.Current != m.selectedRepository.RepositoryName { + m.state.Ready = false + m.state.Repository.Current = m.selectedRepository.RepositoryName + m.state.Repository.Branch = m.selectedRepository.BranchName + m.syncWorkflows() + } else if !m.state.Repository.HasFlows { + m.skeleton.LockTab("trigger") + } + return nil +} + +// ----------------------------------------------------------------------------- +// Branch Selection & Management +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflow) handleBranchSelection() { + selectedBranch := m.textInput.Value() + + // Set branch + if selectedBranch == "" { + m.selectedRepository.BranchName = m.state.Repository.Branch + } else if m.isBranchValid(selectedBranch) { + m.selectedRepository.BranchName = selectedBranch + } else { + m.status.SetErrorMessage(fmt.Sprintf("Branch %s does not exist", selectedBranch)) + m.skeleton.LockTabsToTheRight() + return + } + + // Update tab state + m.updateTabState() +} + +func (m *ModelGithubWorkflow) isBranchValid(branch string) bool { + for _, suggestion := range m.textInput.AvailableSuggestions() { + if suggestion == branch { + return true + } + } + return false +} + +// ----------------------------------------------------------------------------- +// Workflow Sync & Management +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflow) syncWorkflows() { + if m.state.Syncing { + m.cancelSyncTriggerableWorkflows() + } + + ctx, cancel := context.WithCancel(context.Background()) + m.cancelSyncTriggerableWorkflows = cancel + m.state.Syncing = true + + go func() { + defer func() { + m.state.Syncing = false + m.skeleton.TriggerUpdate() + }() + + m.syncBranches(ctx) + m.syncTriggerableWorkflows(ctx) + }() +} + +func (m *ModelGithubWorkflow) syncTriggerableWorkflows(ctx context.Context) { + defer m.skeleton.TriggerUpdate() + + m.initializeSyncState() + workflows, err := m.fetchTriggerableWorkflows(ctx) + if err != nil { + return + } + + m.processWorkflows(workflows) +} + +func (m *ModelGithubWorkflow) initializeSyncState() { + m.status.Reset() + m.status.SetProgressMessage(fmt.Sprintf("[%s@%s] Fetching triggerable workflows...", + m.selectedRepository.RepositoryName, m.selectedRepository.BranchName)) + m.tableTriggerableWorkflow.SetRows([]table.Row{}) +} + +func (m *ModelGithubWorkflow) fetchTriggerableWorkflows(ctx context.Context) (*gu.GetTriggerableWorkflowsOutput, error) { + workflows, err := m.github.GetTriggerableWorkflows(ctx, gu.GetTriggerableWorkflowsInput{ + Repository: m.selectedRepository.RepositoryName, + Branch: m.selectedRepository.BranchName, + }) + + if err != nil { + if !errors.Is(err, context.Canceled) { + m.status.SetError(err) + m.status.SetErrorMessage("Triggerable workflows cannot be listed") + } + return nil, err + } + + return workflows, nil +} + +func (m *ModelGithubWorkflow) processWorkflows(workflows *gu.GetTriggerableWorkflowsOutput) { + m.state.Repository.HasFlows = len(workflows.TriggerableWorkflows) > 0 + m.state.Repository.Current = m.selectedRepository.RepositoryName + m.state.Ready = true + + if !m.state.Repository.HasFlows { + m.handleEmptyWorkflows() + return + } + + m.updateWorkflowTable(workflows.TriggerableWorkflows) + m.updateTabState() + m.finalizeUpdate() + + // Focus components when workflows exist + m.tableTriggerableWorkflow.Focus() + m.textInput.Focus() +} + +func (m *ModelGithubWorkflow) handleEmptyWorkflows() { + m.selectedRepository.WorkflowName = "" + m.skeleton.LockTab("trigger") + + // Blur components when no workflows + m.tableTriggerableWorkflow.Blur() + m.textInput.Blur() + + m.status.SetDefaultMessage(fmt.Sprintf("[%s@%s] No triggerable workflow found.", + m.selectedRepository.RepositoryName, m.selectedRepository.BranchName)) + + m.fillTableWithEmptyMessage() +} + +func (m *ModelGithubWorkflow) fillTableWithEmptyMessage() { + var rows []table.Row + for i := 0; i < 100; i++ { + rows = append(rows, table.Row{ + "EMPTY", + "No triggerable workflow found", + }) + } + + m.tableTriggerableWorkflow.SetRows(rows) + m.tableTriggerableWorkflow.SetCursor(0) +} + +// ----------------------------------------------------------------------------- +// Branch Sync & Management +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflow) syncBranches(ctx context.Context) { + defer m.skeleton.TriggerUpdate() + + m.status.Reset() + m.status.SetProgressMessage(fmt.Sprintf("[%s] Fetching branches...", + m.selectedRepository.RepositoryName)) + + branches, err := m.fetchBranches(ctx) + if err != nil { + return + } + + m.processBranches(branches) +} + +func (m *ModelGithubWorkflow) fetchBranches(ctx context.Context) (*gu.GetRepositoryBranchesOutput, error) { + branches, err := m.github.GetRepositoryBranches(ctx, gu.GetRepositoryBranchesInput{ + Repository: m.selectedRepository.RepositoryName, + }) + + if err != nil { + if !errors.Is(err, context.Canceled) { + m.status.SetError(err) + m.status.SetErrorMessage("Branches cannot be listed") + } + return nil, err + } + + return branches, nil +} + +func (m *ModelGithubWorkflow) processBranches(branches *gu.GetRepositoryBranchesOutput) { + if branches == nil || len(branches.Branches) == 0 { + m.handleEmptyBranches() + return + } + + branchNames := make([]string, len(branches.Branches)) + for i, branch := range branches.Branches { + branchNames[i] = branch.Name + } + + m.textInput.SetSuggestions(branchNames) + m.status.SetSuccessMessage(fmt.Sprintf("[%s] Branches fetched.", + m.selectedRepository.RepositoryName)) +} + +func (m *ModelGithubWorkflow) handleEmptyBranches() { + m.selectedRepository.BranchName = "" + m.status.SetDefaultMessage(fmt.Sprintf("[%s] No branches found.", + m.selectedRepository.RepositoryName)) +} + +// ----------------------------------------------------------------------------- +// Table Management +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflow) updateWorkflowTable(workflows []gu.TriggerableWorkflow) { + rows := make([]table.Row, 0, len(workflows)) + for _, workflow := range workflows { + rows = append(rows, table.Row{ + workflow.Name, + workflow.Path, + }) + } + + sort.SliceStable(rows, func(i, j int) bool { + return rows[i][0] < rows[j][0] + }) + + m.tableTriggerableWorkflow.SetRows(rows) + if len(rows) > 0 { + m.tableTriggerableWorkflow.SetCursor(0) + } +} + +func (m *ModelGithubWorkflow) finalizeUpdate() { + m.tableReady = true + m.status.SetSuccessMessage(fmt.Sprintf("[%s@%s] Triggerable workflows fetched.", + m.selectedRepository.RepositoryName, m.selectedRepository.BranchName)) +} + +func (m *ModelGithubWorkflow) handleTableInputs() { + if !m.tableReady { + return + } + + rows := m.tableTriggerableWorkflow.Rows() + selectedRow := m.tableTriggerableWorkflow.SelectedRow() + if len(rows) > 0 && len(selectedRow) > 0 { + m.selectedRepository.WorkflowName = selectedRow[1] + } +} + +// ----------------------------------------------------------------------------- +// UI Rendering +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflow) renderTable() string { + style := lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#3b698f")). + MarginLeft(1) + + m.updateTableDimensions() + return style.Render(m.tableTriggerableWorkflow.View()) +} + +func (m *ModelGithubWorkflow) renderBranchInput() string { + style := lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#3b698f")). + Padding(0, 1). + Width(m.skeleton.GetTerminalWidth() - 6). + MarginLeft(1) + + if len(m.textInput.AvailableSuggestions()) > 0 && m.textInput.Value() == "" { + if !m.state.Repository.HasFlows { + m.textInput.Placeholder = "Branch selection disabled - No triggerable workflows available" + } else { + m.textInput.Placeholder = fmt.Sprintf("Type to switch branch (default: %s)", m.state.Repository.Branch) + } + } + + return style.Render(m.textInput.View()) +} + +func (m *ModelGithubWorkflow) renderHelp() string { + helpStyle := WindowStyleHelp.Width(m.skeleton.GetTerminalWidth() - 4) + return helpStyle.Render(m.help.View(m.keys)) +} + +func (m *ModelGithubWorkflow) updateTableDimensions() { + termWidth := m.skeleton.GetTerminalWidth() + termHeight := m.skeleton.GetTerminalHeight() + + var tableWidth int + for _, t := range tableColumnsWorkflow { + tableWidth += t.Width + } + + newTableColumns := tableColumnsWorkflow + widthDiff := termWidth - tableWidth + if widthDiff > 0 { + newTableColumns[1].Width += widthDiff - 10 + m.tableTriggerableWorkflow.SetColumns(newTableColumns) + m.tableTriggerableWorkflow.SetHeight(termHeight - 17) + } +} + +// ----------------------------------------------------------------------------- +// Tab Management +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflow) updateTabState() { + if !m.state.Repository.HasFlows { + m.skeleton.LockTab("trigger") + return + } + m.skeleton.UnlockTabs() +} diff --git a/internal/terminal/handler/ghworkflow/ghworkflow.go b/internal/terminal/handler/ghworkflow/ghworkflow.go deleted file mode 100644 index c79cf12..0000000 --- a/internal/terminal/handler/ghworkflow/ghworkflow.go +++ /dev/null @@ -1,209 +0,0 @@ -package ghworkflow - -import ( - "context" - "errors" - "fmt" - "sort" - "strings" - - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/table" - hdlerror "github.com/termkit/gama/internal/terminal/handler/error" - "github.com/termkit/gama/internal/terminal/handler/ghtrigger" - "github.com/termkit/gama/internal/terminal/handler/taboptions" - hdltypes "github.com/termkit/gama/internal/terminal/handler/types" - - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - gu "github.com/termkit/gama/internal/github/usecase" -) - -type ModelGithubWorkflow struct { - // current handler's properties - syncTriggerableWorkflowsContext context.Context - cancelSyncTriggerableWorkflows context.CancelFunc - tableReady bool - lastRepository string - - // shared properties - SelectedRepository *hdltypes.SelectedRepository - - // use cases - github gu.UseCase - - // keymap - Keys keyMap - - // models - Help help.Model - Viewport *viewport.Model - list list.Model - tableTriggerableWorkflow table.Model - modelError *hdlerror.ModelError - - modelTabOptions tea.Model - actualModelTabOptions *taboptions.Options - - modelGithubTrigger tea.Model - actualModelGithubTrigger *ghtrigger.ModelGithubTrigger -} - -var baseStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")) - -func SetupModelGithubWorkflow(githubUseCase gu.UseCase, selectedRepository *hdltypes.SelectedRepository) *ModelGithubWorkflow { - var tableRowsTriggerableWorkflow []table.Row - - tableTriggerableWorkflow := table.New( - table.WithColumns(tableColumnsWorkflow), - table.WithRows(tableRowsTriggerableWorkflow), - table.WithFocused(true), - table.WithHeight(7), - ) - - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(false) - s.Selected = s.Selected. - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). - Bold(false) - tableTriggerableWorkflow.SetStyles(s) - - modelError := hdlerror.SetupModelError() - tabOptions := taboptions.NewOptions(&modelError) - - return &ModelGithubWorkflow{ - Help: help.New(), - Keys: keys, - github: githubUseCase, - modelError: &modelError, - tableTriggerableWorkflow: tableTriggerableWorkflow, - SelectedRepository: selectedRepository, - modelTabOptions: tabOptions, - actualModelTabOptions: tabOptions, - syncTriggerableWorkflowsContext: context.Background(), - cancelSyncTriggerableWorkflows: func() {}, - } -} - -func (m *ModelGithubWorkflow) Init() tea.Cmd { - return nil -} - -func (m *ModelGithubWorkflow) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - if m.lastRepository != m.SelectedRepository.RepositoryName { - m.tableReady = false // reset table ready status - m.cancelSyncTriggerableWorkflows() // cancel previous sync - m.syncTriggerableWorkflowsContext, m.cancelSyncTriggerableWorkflows = context.WithCancel(context.Background()) - - m.lastRepository = m.SelectedRepository.RepositoryName - - go m.syncTriggerableWorkflows(m.syncTriggerableWorkflowsContext) - } - - m.tableTriggerableWorkflow, cmd = m.tableTriggerableWorkflow.Update(msg) - - m.handleTableInputs(m.syncTriggerableWorkflowsContext) // update table operations - - return m, cmd -} - -func (m *ModelGithubWorkflow) View() string { - termWidth := m.Viewport.Width - termHeight := m.Viewport.Height - - var tableWidth int - for _, t := range tableColumnsWorkflow { - tableWidth += t.Width - } - - newTableColumns := tableColumnsWorkflow - widthDiff := termWidth - tableWidth - if widthDiff > 0 { - newTableColumns[1].Width += widthDiff - 11 - m.tableTriggerableWorkflow.SetColumns(newTableColumns) - m.tableTriggerableWorkflow.SetHeight(termHeight - 17) - } - - doc := strings.Builder{} - doc.WriteString(baseStyle.Render(m.tableTriggerableWorkflow.View())) - - return doc.String() -} - -func (m *ModelGithubWorkflow) syncTriggerableWorkflows(ctx context.Context) { - m.modelError.Reset() - m.modelError.SetProgressMessage( - fmt.Sprintf("[%s@%s] Fetching triggerable workflows...", m.SelectedRepository.RepositoryName, m.SelectedRepository.BranchName)) - m.actualModelTabOptions.SetStatus(taboptions.OptionWait) - - // delete all rows - m.tableTriggerableWorkflow.SetRows([]table.Row{}) - - triggerableWorkflows, err := m.github.GetTriggerableWorkflows(ctx, gu.GetTriggerableWorkflowsInput{ - Repository: m.SelectedRepository.RepositoryName, - Branch: m.SelectedRepository.BranchName, - }) - if errors.Is(err, context.Canceled) { - return - } else if err != nil { - m.modelError.SetError(err) - m.modelError.SetErrorMessage("Triggerable workflows cannot be listed") - return - } - - if len(triggerableWorkflows.TriggerableWorkflows) == 0 { - m.actualModelTabOptions.SetStatus(taboptions.OptionNone) - m.modelError.SetDefaultMessage(fmt.Sprintf("[%s@%s] No triggerable workflow found.", m.SelectedRepository.RepositoryName, m.SelectedRepository.BranchName)) - return - } - - var tableRowsTriggerableWorkflow []table.Row - for _, workflow := range triggerableWorkflows.TriggerableWorkflows { - tableRowsTriggerableWorkflow = append(tableRowsTriggerableWorkflow, table.Row{ - workflow.Name, - workflow.Path, - }) - } - - sort.SliceStable(tableRowsTriggerableWorkflow, func(i, j int) bool { - return tableRowsTriggerableWorkflow[i][0] < tableRowsTriggerableWorkflow[j][0] - }) - - m.tableTriggerableWorkflow.SetRows(tableRowsTriggerableWorkflow) - - m.tableReady = true - m.modelError.SetSuccessMessage(fmt.Sprintf("[%s@%s] Triggerable workflows fetched.", m.SelectedRepository.RepositoryName, m.SelectedRepository.BranchName)) - - go m.Update(m) // update model -} - -func (m *ModelGithubWorkflow) handleTableInputs(_ context.Context) { - if !m.tableReady { - return - } - - // To avoid go routine leak - rows := m.tableTriggerableWorkflow.Rows() - selectedRow := m.tableTriggerableWorkflow.SelectedRow() - - if len(rows) > 0 && len(selectedRow) > 0 { - m.SelectedRepository.WorkflowName = selectedRow[1] - } - - m.actualModelTabOptions.SetStatus(taboptions.OptionIdle) -} - -func (m *ModelGithubWorkflow) ViewStatus() string { - return m.modelError.View() -} diff --git a/internal/terminal/handler/ghworkflow/keymap.go b/internal/terminal/handler/ghworkflow/keymap.go deleted file mode 100644 index 5e6f61b..0000000 --- a/internal/terminal/handler/ghworkflow/keymap.go +++ /dev/null @@ -1,43 +0,0 @@ -package ghworkflow - -import ( - "fmt" - - "github.com/termkit/gama/internal/config" - - teakey "github.com/charmbracelet/bubbles/key" -) - -type keyMap struct { - SwitchTab teakey.Binding -} - -func (k keyMap) ShortHelp() []teakey.Binding { - return []teakey.Binding{k.SwitchTab} -} - -func (k keyMap) FullHelp() [][]teakey.Binding { - return [][]teakey.Binding{ - {k.SwitchTab}, - } -} - -var keys = func() keyMap { - cfg, err := config.LoadConfig() - if err != nil { - panic(fmt.Sprintf("failed to load config: %v", err)) - } - - var tabSwitch = fmt.Sprintf("%s | %s", cfg.Shortcuts.SwitchTabLeft, cfg.Shortcuts.SwitchTabRight) - - return keyMap{ - SwitchTab: teakey.NewBinding( - teakey.WithKeys(""), // help-only binding - teakey.WithHelp(tabSwitch, "switch tab"), - ), - } -}() - -func (m *ModelGithubWorkflow) ViewHelp() string { - return m.Help.View(m.Keys) -} diff --git a/internal/terminal/handler/ghworkflow/table.go b/internal/terminal/handler/ghworkflow/table.go deleted file mode 100644 index 344eeff..0000000 --- a/internal/terminal/handler/ghworkflow/table.go +++ /dev/null @@ -1,10 +0,0 @@ -package ghworkflow - -import ( - "github.com/charmbracelet/bubbles/table" -) - -var tableColumnsWorkflow = []table.Column{ - {Title: "Workflow", Width: 32}, - {Title: "File", Width: 48}, -} diff --git a/internal/terminal/handler/ghworkflowhistory.go b/internal/terminal/handler/ghworkflowhistory.go new file mode 100644 index 0000000..86e649b --- /dev/null +++ b/internal/terminal/handler/ghworkflowhistory.go @@ -0,0 +1,556 @@ +package handler + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/termkit/gama/internal/config" + gu "github.com/termkit/gama/internal/github/usecase" + "github.com/termkit/gama/pkg/browser" + "github.com/termkit/skeleton" +) + +// ----------------------------------------------------------------------------- +// Model Definition +// ----------------------------------------------------------------------------- + +type ModelGithubWorkflowHistory struct { + // Core dependencies + skeleton *skeleton.Skeleton + github gu.UseCase + + // UI Components + Help help.Model + keys githubWorkflowHistoryKeyMap + tableWorkflowHistory table.Model + status *ModelStatus + modelTabOptions *ModelTabOptions + + // Table state + tableReady bool + tableStyle lipgloss.Style + workflows []gu.Workflow + lastRepository string + + // Live mode state + liveMode bool + liveModeInterval time.Duration + + // Workflow state + selectedWorkflowID int64 + + // Context management + syncWorkflowHistoryContext context.Context + cancelSyncWorkflowHistory context.CancelFunc + + // Shared state + selectedRepository *SelectedRepository +} + +type workflowHistoryUpdateMsg struct { + UpdateAfter time.Duration +} + +// ----------------------------------------------------------------------------- +// Constructor & Initialization +// ----------------------------------------------------------------------------- + +func SetupModelGithubWorkflowHistory(s *skeleton.Skeleton, githubUseCase gu.UseCase) *ModelGithubWorkflowHistory { + cfg, err := config.LoadConfig() + if err != nil { + panic(fmt.Sprintf("failed to load config: %v", err)) + } + + m := &ModelGithubWorkflowHistory{ + // Initialize core dependencies + skeleton: s, + github: githubUseCase, + + // Initialize UI components + Help: help.New(), + keys: githubWorkflowHistoryKeys, + status: SetupModelStatus(s), + modelTabOptions: NewOptions(s, SetupModelStatus(s)), + + // Initialize state + selectedRepository: NewSelectedRepository(), + syncWorkflowHistoryContext: context.Background(), + cancelSyncWorkflowHistory: func() {}, + liveMode: cfg.Settings.LiveMode.Enabled, + liveModeInterval: cfg.Settings.LiveMode.Interval, + tableStyle: setupTableStyle(), + } + + // Setup table + m.tableWorkflowHistory = setupWorkflowHistoryTable() + + return m +} + +func setupTableStyle() lipgloss.Style { + return lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#3b698f")). + MarginLeft(1) +} + +func setupWorkflowHistoryTable() table.Model { + t := table.New( + table.WithColumns(tableColumnsWorkflowHistory), + table.WithRows([]table.Row{}), + table.WithFocused(true), + table.WithHeight(7), + ) + + // Apply styles + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + t.SetStyles(s) + + t.KeyMap = table.KeyMap{ + LineUp: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")), + LineDown: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")), + PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")), + PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")), + GotoTop: key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "go to start")), + GotoBottom: key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "go to end")), + } + + return t +} + +// ----------------------------------------------------------------------------- +// Bubbletea Model Implementation +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflowHistory) Init() tea.Cmd { + m.setupOptions() + m.startLiveMode() + return tea.Batch(m.modelTabOptions.Init()) +} + +func (m *ModelGithubWorkflowHistory) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Handle repository changes + if cmd := m.handleRepositoryChange(); cmd != nil { + return m, cmd + } + + cursor := m.tableWorkflowHistory.Cursor() + if m.workflows != nil && cursor >= 0 && cursor < len(m.workflows) { + m.selectedWorkflowID = m.workflows[cursor].ID + } + + var cmds []tea.Cmd + var cmd tea.Cmd + + // Handle different message types + switch msg := msg.(type) { + case tea.KeyMsg: + cmd = m.handleKeyMsg(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + case workflowHistoryUpdateMsg: + cmd = m.handleUpdateMsg(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + + // Update UI components + if cmd = m.updateUIComponents(msg); cmd != nil { + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m *ModelGithubWorkflowHistory) View() string { + return lipgloss.JoinVertical(lipgloss.Top, + m.renderTable(), + m.modelTabOptions.View(), + m.status.View(), + m.renderHelp(), + ) +} + +// ----------------------------------------------------------------------------- +// Event Handlers +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflowHistory) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { + switch { + case key.Matches(msg, m.keys.Refresh): + go m.syncWorkflowHistory(m.syncWorkflowHistoryContext) + return nil + case key.Matches(msg, m.keys.LiveMode): + return m.toggleLiveMode() + } + return nil +} + +func (m *ModelGithubWorkflowHistory) handleUpdateMsg(msg workflowHistoryUpdateMsg) tea.Cmd { + go func() { + time.Sleep(msg.UpdateAfter) + m.syncWorkflowHistory(m.syncWorkflowHistoryContext) + m.skeleton.TriggerUpdate() + }() + return nil +} + +// ----------------------------------------------------------------------------- +// Live Mode Management +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflowHistory) startLiveMode() { + ticker := time.NewTicker(m.liveModeInterval) + go func() { + defer ticker.Stop() + for { + select { + case <-ticker.C: + if m.liveMode { + m.skeleton.TriggerUpdateWithMsg(workflowHistoryUpdateMsg{ + UpdateAfter: time.Nanosecond, + }) + } + case <-m.syncWorkflowHistoryContext.Done(): + return + } + } + }() +} + +func (m *ModelGithubWorkflowHistory) toggleLiveMode() tea.Cmd { + m.liveMode = !m.liveMode + + status := "Off" + message := "Live mode disabled" + + if m.liveMode { + status = "On" + message = "Live mode enabled" + // Trigger immediate update when enabling + go m.syncWorkflowHistory(m.syncWorkflowHistoryContext) + } + + m.status.SetSuccessMessage(message) + m.skeleton.UpdateWidgetValue("live", fmt.Sprintf("Live Mode: %s", status)) + + return nil +} + +// ----------------------------------------------------------------------------- +// Repository Change Handling +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflowHistory) handleRepositoryChange() tea.Cmd { + if m.lastRepository == m.selectedRepository.RepositoryName { + return nil + } + + if m.cancelSyncWorkflowHistory != nil { + m.cancelSyncWorkflowHistory() + } + + m.lastRepository = m.selectedRepository.RepositoryName + m.syncWorkflowHistoryContext, m.cancelSyncWorkflowHistory = context.WithCancel(context.Background()) + + go m.syncWorkflowHistory(m.syncWorkflowHistoryContext) + return nil +} + +// ----------------------------------------------------------------------------- +// Workflow History Sync +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflowHistory) syncWorkflowHistory(ctx context.Context) { + defer m.skeleton.TriggerUpdate() + + // Add timeout to prevent hanging + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + m.initializeSyncState() + + // Check context before proceeding + if ctx.Err() != nil { + m.status.SetDefaultMessage("Operation cancelled") + return + } + + workflowHistory, err := m.fetchWorkflowHistory(ctx) + if err != nil { + m.handleFetchError(err) + return + } + + m.processWorkflowHistory(workflowHistory) +} + +func (m *ModelGithubWorkflowHistory) handleFetchError(err error) { + switch { + case errors.Is(err, context.Canceled): + m.status.SetDefaultMessage("Workflow history fetch cancelled") + case errors.Is(err, context.DeadlineExceeded): + m.status.SetErrorMessage("Workflow history fetch timed out") + default: + m.status.SetError(err) + m.status.SetErrorMessage(fmt.Sprintf("Failed to fetch workflow history: %v", err)) + } +} + +func (m *ModelGithubWorkflowHistory) initializeSyncState() { + m.tableReady = false + m.status.Reset() + m.status.SetProgressMessage(fmt.Sprintf("[%s@%s] Fetching workflow history...", + m.selectedRepository.RepositoryName, m.selectedRepository.BranchName)) + m.modelTabOptions.SetStatus(StatusWait) + m.clearWorkflowHistory() +} + +func (m *ModelGithubWorkflowHistory) clearWorkflowHistory() { + m.tableWorkflowHistory.SetRows([]table.Row{}) + m.workflows = nil +} + +func (m *ModelGithubWorkflowHistory) fetchWorkflowHistory(ctx context.Context) (*gu.GetWorkflowHistoryOutput, error) { + history, err := m.github.GetWorkflowHistory(ctx, gu.GetWorkflowHistoryInput{ + Repository: m.selectedRepository.RepositoryName, + Branch: m.selectedRepository.BranchName, + }) + + if err != nil { + if !errors.Is(err, context.Canceled) { + m.status.SetError(err) + m.status.SetErrorMessage("Workflow history cannot be listed") + } + return nil, err + } + + return history, nil +} + +// ----------------------------------------------------------------------------- +// Workflow History Processing +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflowHistory) processWorkflowHistory(history *gu.GetWorkflowHistoryOutput) { + if len(history.Workflows) == 0 { + m.handleEmptyWorkflowHistory() + return + } + + m.workflows = history.Workflows + m.updateWorkflowTable() + m.finalizeUpdate() +} + +func (m *ModelGithubWorkflowHistory) handleEmptyWorkflowHistory() { + m.modelTabOptions.SetStatus(StatusNone) + m.status.SetDefaultMessage(fmt.Sprintf("[%s@%s] No workflow history found.", + m.selectedRepository.RepositoryName, m.selectedRepository.BranchName)) +} + +func (m *ModelGithubWorkflowHistory) updateWorkflowTable() { + rows := make([]table.Row, 0, len(m.workflows)) + for _, workflow := range m.workflows { + rows = append(rows, table.Row{ + workflow.WorkflowName, + workflow.ActionName, + workflow.TriggeredBy, + workflow.StartedAt, + workflow.Status, + workflow.Duration, + }) + } + m.tableWorkflowHistory.SetRows(rows) +} + +func (m *ModelGithubWorkflowHistory) finalizeUpdate() { + m.tableReady = true + m.tableWorkflowHistory.SetCursor(0) + m.modelTabOptions.SetStatus(StatusIdle) + m.status.SetSuccessMessage(fmt.Sprintf("[%s@%s] Workflow history fetched.", + m.selectedRepository.RepositoryName, m.selectedRepository.BranchName)) +} + +// ----------------------------------------------------------------------------- +// UI Component Updates +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflowHistory) updateUIComponents(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + var cmd tea.Cmd + + // Update table and handle navigation + m.tableWorkflowHistory, cmd = m.tableWorkflowHistory.Update(msg) + cmds = append(cmds, cmd) + + // Update tab options + m.modelTabOptions, cmd = m.modelTabOptions.Update(msg) + cmds = append(cmds, cmd) + + return tea.Batch(cmds...) +} + +// ----------------------------------------------------------------------------- +// UI Rendering +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflowHistory) renderTable() string { + m.updateTableDimensions() + return m.tableStyle.Render(m.tableWorkflowHistory.View()) +} + +func (m *ModelGithubWorkflowHistory) renderHelp() string { + helpStyle := WindowStyleHelp.Width(m.skeleton.GetTerminalWidth() - 4) + return helpStyle.Render(m.ViewHelp()) +} + +func (m *ModelGithubWorkflowHistory) updateTableDimensions() { + const ( + minTableWidth = 80 // Minimum width to maintain readability + tablePadding = 18 // Account for borders and margins + minColumnWidth = 10 // Minimum width for any column + ) + + termWidth := m.skeleton.GetTerminalWidth() + termHeight := m.skeleton.GetTerminalHeight() + + if termWidth <= minTableWidth { + return // Prevent table from becoming too narrow + } + + var tableWidth int + for _, t := range tableColumnsWorkflowHistory { + tableWidth += t.Width + } + + newTableColumns := make([]table.Column, len(tableColumnsWorkflowHistory)) + copy(newTableColumns, tableColumnsWorkflowHistory) + + widthDiff := termWidth - tableWidth - tablePadding + if widthDiff > 0 { + // Distribute extra width between workflow name and action name columns + extraWidth := widthDiff / 2 + newTableColumns[0].Width = max(newTableColumns[0].Width+extraWidth, minColumnWidth) + newTableColumns[1].Width = max(newTableColumns[1].Width+extraWidth, minColumnWidth) + + m.tableWorkflowHistory.SetColumns(newTableColumns) + } + + // Ensure reasonable table height + maxHeight := termHeight - 17 + if maxHeight > 0 { + m.tableWorkflowHistory.SetHeight(maxHeight) + } +} + +// ----------------------------------------------------------------------------- +// Option Management +// ----------------------------------------------------------------------------- + +func (m *ModelGithubWorkflowHistory) setupOptions() { + m.modelTabOptions.AddOption("Open in browser", m.openInBrowser) + m.modelTabOptions.AddOption("Rerun failed jobs", m.rerunFailedJobs) + m.modelTabOptions.AddOption("Rerun workflow", m.rerunWorkflow) + m.modelTabOptions.AddOption("Cancel workflow", m.cancelWorkflow) +} + +func (m *ModelGithubWorkflowHistory) openInBrowser() { + m.status.SetProgressMessage("Opening in browser...") + + url := fmt.Sprintf("https://github.com/%s/actions/runs/%d", + m.selectedRepository.RepositoryName, + m.selectedWorkflowID) + + if err := browser.OpenInBrowser(url); err != nil { + m.status.SetError(err) + m.status.SetErrorMessage("Failed to open in browser") + return + } + m.status.SetSuccessMessage("Opened in browser") +} + +func (m *ModelGithubWorkflowHistory) rerunFailedJobs() { + m.status.SetProgressMessage("Re-running failed jobs...") + + _, err := m.github.ReRunFailedJobs(context.Background(), gu.ReRunFailedJobsInput{ + Repository: m.selectedRepository.RepositoryName, + WorkflowID: m.selectedWorkflowID, + }) + + if err != nil { + m.status.SetError(err) + m.status.SetErrorMessage("Failed to re-run failed jobs") + return + } + + m.status.SetSuccessMessage("Re-ran failed jobs") +} + +func (m *ModelGithubWorkflowHistory) rerunWorkflow() { + if m.selectedWorkflowID == 0 { + m.status.SetErrorMessage("No workflow selected") + return + } + + m.status.SetProgressMessage("Re-running workflow...") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := m.github.ReRunWorkflow(ctx, gu.ReRunWorkflowInput{ + Repository: m.selectedRepository.RepositoryName, + WorkflowID: m.selectedWorkflowID, + }) + + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + m.status.SetErrorMessage("Workflow re-run request timed out") + } else { + m.status.SetError(err) + m.status.SetErrorMessage(fmt.Sprintf("Failed to re-run workflow: %v", err)) + } + return + } + + m.status.SetSuccessMessage("Workflow re-run initiated") + // Trigger refresh after short delay to show updated status + go func() { + time.Sleep(2 * time.Second) + m.syncWorkflowHistory(m.syncWorkflowHistoryContext) + }() +} + +func (m *ModelGithubWorkflowHistory) cancelWorkflow() { + m.status.SetProgressMessage("Canceling workflow...") + + _, err := m.github.CancelWorkflow(context.Background(), gu.CancelWorkflowInput{ + Repository: m.selectedRepository.RepositoryName, + WorkflowID: m.selectedWorkflowID, + }) + + if err != nil { + m.status.SetError(err) + m.status.SetErrorMessage("Failed to cancel workflow") + return + } + + m.status.SetSuccessMessage("Canceled workflow") +} diff --git a/internal/terminal/handler/ghworkflowhistory/ghworkflowhistory.go b/internal/terminal/handler/ghworkflowhistory/ghworkflowhistory.go deleted file mode 100644 index 1fa798a..0000000 --- a/internal/terminal/handler/ghworkflowhistory/ghworkflowhistory.go +++ /dev/null @@ -1,300 +0,0 @@ -package ghworkflowhistory - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/table" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - gu "github.com/termkit/gama/internal/github/usecase" - hdlerror "github.com/termkit/gama/internal/terminal/handler/error" - "github.com/termkit/gama/internal/terminal/handler/taboptions" - hdltypes "github.com/termkit/gama/internal/terminal/handler/types" - "github.com/termkit/gama/pkg/browser" -) - -type ModelGithubWorkflowHistory struct { - // current handler's properties - tableReady bool - updateRound int - selectedWorkflowID int64 - isTableFocused bool - lastRepository string - forceUpdate *bool - syncWorkflowHistoryContext context.Context - cancelSyncWorkflowHistory context.CancelFunc - Workflows []gu.Workflow - - // shared properties - SelectedRepository *hdltypes.SelectedRepository - - // use cases - github gu.UseCase - - // keymap - Keys keyMap - - // models - Help help.Model - Viewport *viewport.Model - tableWorkflowHistory table.Model - modelError *hdlerror.ModelError - - modelTabOptions tea.Model - actualModelTabOptions *taboptions.Options -} - -var baseStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")) - -func SetupModelGithubWorkflowHistory(githubUseCase gu.UseCase, selectedRepository *hdltypes.SelectedRepository, forceUpdate *bool) *ModelGithubWorkflowHistory { - var tableRowsWorkflowHistory []table.Row - - tableWorkflowHistory := table.New( - table.WithColumns(tableColumnsWorkflowHistory), - table.WithRows(tableRowsWorkflowHistory), - table.WithFocused(true), - table.WithHeight(7), - ) - - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(false) - s.Selected = s.Selected. - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). - Bold(false) - tableWorkflowHistory.SetStyles(s) - - modelError := hdlerror.SetupModelError() - tabOptions := taboptions.NewOptions(&modelError) - - return &ModelGithubWorkflowHistory{ - Help: help.New(), - Keys: keys, - github: githubUseCase, - tableWorkflowHistory: tableWorkflowHistory, - modelError: &modelError, - SelectedRepository: selectedRepository, - modelTabOptions: tabOptions, - actualModelTabOptions: tabOptions, - forceUpdate: forceUpdate, - syncWorkflowHistoryContext: context.Background(), - cancelSyncWorkflowHistory: func() {}, - } -} - -func (m *ModelGithubWorkflowHistory) Init() tea.Cmd { - openInBrowser := func() { - m.modelError.SetProgressMessage("Opening in browser...") - - var selectedWorkflow = fmt.Sprintf("https://github.com/%s/actions/runs/%d", m.SelectedRepository.RepositoryName, m.selectedWorkflowID) - - err := browser.OpenInBrowser(selectedWorkflow) - if err != nil { - m.modelError.SetError(err) - m.modelError.SetErrorMessage("Failed to open in browser") - return - } - m.modelError.SetSuccessMessage("Opened in browser") - } - - reRunFailedJobs := func() { - m.modelError.SetProgressMessage("Re-running failed jobs...") - - _, err := m.github.ReRunFailedJobs(context.Background(), gu.ReRunFailedJobsInput{ - Repository: m.SelectedRepository.RepositoryName, - WorkflowID: m.selectedWorkflowID, - }) - - if err != nil { - m.modelError.SetError(err) - m.modelError.SetErrorMessage("Failed to re-run failed jobs") - return - } - - m.modelError.SetSuccessMessage("Re-ran failed jobs") - } - - reRunWorkflow := func() { - m.modelError.SetProgressMessage("Re-running workflow...") - - _, err := m.github.ReRunWorkflow(context.Background(), gu.ReRunWorkflowInput{ - Repository: m.SelectedRepository.RepositoryName, - WorkflowID: m.selectedWorkflowID, - }) - - if err != nil { - m.modelError.SetError(err) - m.modelError.SetErrorMessage("Failed to re-run workflow") - return - } - - m.modelError.SetSuccessMessage("Re-ran workflow") - } - - cancelWorkflow := func() { - m.modelError.SetProgressMessage("Canceling workflow...") - - _, err := m.github.CancelWorkflow(context.Background(), gu.CancelWorkflowInput{ - Repository: m.SelectedRepository.RepositoryName, - WorkflowID: m.selectedWorkflowID, - }) - - if err != nil { - m.modelError.SetError(err) - m.modelError.SetErrorMessage("Failed to cancel workflow") - return - } - - m.modelError.SetSuccessMessage("Canceled workflow") - } - m.actualModelTabOptions.AddOption("Open in browser", openInBrowser) - m.actualModelTabOptions.AddOption("Rerun failed jobs", reRunFailedJobs) - m.actualModelTabOptions.AddOption("Rerun workflow", reRunWorkflow) - m.actualModelTabOptions.AddOption("Cancel workflow", cancelWorkflow) - - go func() { - // Make it works with to channels - for { - if *m.forceUpdate { - m.tableReady = false - go m.syncWorkflowHistory(m.syncWorkflowHistoryContext) - *m.forceUpdate = false - } - } - }() - - return tea.Batch(m.modelTabOptions.Init()) -} - -func (m *ModelGithubWorkflowHistory) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if m.lastRepository != m.SelectedRepository.RepositoryName { - m.tableReady = false - m.cancelSyncWorkflowHistory() // cancel previous sync - - m.lastRepository = m.SelectedRepository.RepositoryName - - m.syncWorkflowHistoryContext, m.cancelSyncWorkflowHistory = context.WithCancel(context.Background()) - go m.syncWorkflowHistory(m.syncWorkflowHistoryContext) - } - - if m.Workflows != nil { - m.selectedWorkflowID = m.Workflows[m.tableWorkflowHistory.Cursor()].ID - } - - var cmds []tea.Cmd - var cmd tea.Cmd - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, m.Keys.Refresh): - m.tableReady = false - go m.syncWorkflowHistory(m.syncWorkflowHistoryContext) - } - } - - m.modelTabOptions, cmd = m.modelTabOptions.Update(msg) - cmds = append(cmds, cmd) - - m.tableWorkflowHistory, cmd = m.tableWorkflowHistory.Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -func (m *ModelGithubWorkflowHistory) syncWorkflowHistory(ctx context.Context) { - m.modelError.Reset() - m.modelError.SetProgressMessage( - fmt.Sprintf("[%s@%s] Fetching workflow history...", m.SelectedRepository.RepositoryName, m.SelectedRepository.BranchName)) - m.actualModelTabOptions.SetStatus(taboptions.OptionWait) - - // delete all rows - m.tableWorkflowHistory.SetRows([]table.Row{}) - - // delete old workflows - m.Workflows = nil - - workflowHistory, err := m.github.GetWorkflowHistory(ctx, gu.GetWorkflowHistoryInput{ - Repository: m.SelectedRepository.RepositoryName, - Branch: m.SelectedRepository.BranchName, - }) - if errors.Is(err, context.Canceled) { - return - } else if err != nil { - m.modelError.SetError(err) - m.modelError.SetErrorMessage("Workflow history cannot be listed") - return - } - - if len(workflowHistory.Workflows) == 0 { - m.actualModelTabOptions.SetStatus(taboptions.OptionNone) - m.modelError.SetDefaultMessage(fmt.Sprintf("[%s@%s] No workflows found.", m.SelectedRepository.RepositoryName, m.SelectedRepository.BranchName)) - return - } - - m.Workflows = workflowHistory.Workflows - - var tableRowsWorkflowHistory []table.Row - for _, workflowRun := range m.Workflows { - tableRowsWorkflowHistory = append(tableRowsWorkflowHistory, table.Row{ - workflowRun.WorkflowName, - workflowRun.ActionName, - workflowRun.TriggeredBy, - workflowRun.StartedAt, - workflowRun.Conclusion, - workflowRun.Duration, - }) - } - - m.tableReady = true - m.tableWorkflowHistory.SetRows(tableRowsWorkflowHistory) - m.tableWorkflowHistory.SetCursor(0) - m.actualModelTabOptions.SetStatus(taboptions.OptionIdle) - m.modelError.SetSuccessMessage(fmt.Sprintf("[%s@%s] Workflow history fetched.", m.SelectedRepository.RepositoryName, m.SelectedRepository.BranchName)) - go m.Update(m) // update model -} - -func (m *ModelGithubWorkflowHistory) View() string { - termWidth := m.Viewport.Width - termHeight := m.Viewport.Height - - var tableWidth int - for _, t := range tableColumnsWorkflowHistory { - tableWidth += t.Width - } - - newTableColumns := tableColumnsWorkflowHistory - widthDiff := termWidth - tableWidth - - if widthDiff > 0 { - if m.updateRound%2 == 0 { - newTableColumns[0].Width += widthDiff - 19 - } else { - newTableColumns[1].Width += widthDiff - 19 - } - m.updateRound++ - m.tableWorkflowHistory.SetColumns(newTableColumns) - } - - m.tableWorkflowHistory.SetHeight(termHeight - 17) - - doc := strings.Builder{} - doc.WriteString(baseStyle.Render(m.tableWorkflowHistory.View())) - - return lipgloss.JoinVertical(lipgloss.Top, doc.String(), m.actualModelTabOptions.View()) -} - -func (m *ModelGithubWorkflowHistory) ViewStatus() string { - return m.modelError.View() -} diff --git a/internal/terminal/handler/ghworkflowhistory/keymap.go b/internal/terminal/handler/ghworkflowhistory/keymap.go deleted file mode 100644 index e0ff75f..0000000 --- a/internal/terminal/handler/ghworkflowhistory/keymap.go +++ /dev/null @@ -1,55 +0,0 @@ -package ghworkflowhistory - -import ( - "fmt" - - "github.com/termkit/gama/internal/config" - - teakey "github.com/charmbracelet/bubbles/key" -) - -type keyMap struct { - LaunchTab teakey.Binding - Refresh teakey.Binding - SwitchTab teakey.Binding -} - -func (k keyMap) ShortHelp() []teakey.Binding { - return []teakey.Binding{k.SwitchTab, k.Refresh, k.LaunchTab} -} - -func (k keyMap) FullHelp() [][]teakey.Binding { - return [][]teakey.Binding{ - {k.SwitchTab}, - {k.Refresh}, - {k.LaunchTab}, - } -} - -var keys = func() keyMap { - cfg, err := config.LoadConfig() - if err != nil { - panic(fmt.Sprintf("failed to load config: %v", err)) - } - - var tabSwitch = fmt.Sprintf("%s | %s", cfg.Shortcuts.SwitchTabLeft, cfg.Shortcuts.SwitchTabRight) - - return keyMap{ - Refresh: teakey.NewBinding( - teakey.WithKeys(cfg.Shortcuts.Refresh), - teakey.WithHelp(cfg.Shortcuts.Refresh, "Refresh list"), - ), - LaunchTab: teakey.NewBinding( - teakey.WithKeys(cfg.Shortcuts.Enter), - teakey.WithHelp(cfg.Shortcuts.Enter, "Launch the selected option"), - ), - SwitchTab: teakey.NewBinding( - teakey.WithKeys(""), // help-only binding - teakey.WithHelp(tabSwitch, "switch tab"), - ), - } -}() - -func (m *ModelGithubWorkflowHistory) ViewHelp() string { - return m.Help.View(m.Keys) -} diff --git a/internal/terminal/handler/ghworkflowhistory/table.go b/internal/terminal/handler/ghworkflowhistory/table.go deleted file mode 100644 index 18ada96..0000000 --- a/internal/terminal/handler/ghworkflowhistory/table.go +++ /dev/null @@ -1,14 +0,0 @@ -package ghworkflowhistory - -import ( - "github.com/charmbracelet/bubbles/table" -) - -var tableColumnsWorkflowHistory = []table.Column{ - {Title: "Workflow", Width: 12}, - {Title: "Commit Message", Width: 16}, - {Title: "Triggered", Width: 12}, - {Title: "Started At", Width: 19}, - {Title: "Status", Width: 9}, - {Title: "Duration", Width: 8}, -} diff --git a/internal/terminal/handler/handler.go b/internal/terminal/handler/handler.go index b2cd4a0..91d33bf 100644 --- a/internal/terminal/handler/handler.go +++ b/internal/terminal/handler/handler.go @@ -2,262 +2,44 @@ package handler import ( "fmt" - "strings" - "time" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/timer" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "github.com/termkit/gama/internal/config" gu "github.com/termkit/gama/internal/github/usecase" - hdlgithubrepo "github.com/termkit/gama/internal/terminal/handler/ghrepository" - hdltrigger "github.com/termkit/gama/internal/terminal/handler/ghtrigger" - hdlWorkflow "github.com/termkit/gama/internal/terminal/handler/ghworkflow" - hdlworkflowhistory "github.com/termkit/gama/internal/terminal/handler/ghworkflowhistory" - hdlinfo "github.com/termkit/gama/internal/terminal/handler/information" - hdltypes "github.com/termkit/gama/internal/terminal/handler/types" - ts "github.com/termkit/gama/internal/terminal/style" pkgversion "github.com/termkit/gama/pkg/version" -) - -type model struct { - // current handler's properties - TabsWithColor []string - currentTab *int - isTabActive bool - - // Shared properties - SelectedRepository *hdltypes.SelectedRepository - lockTabs *bool // lockTabs will be set true if test connection fails - - // models - viewport viewport.Model - timer timer.Model - - modelInfo tea.Model - actualModelInfo *hdlinfo.ModelInfo - - modelGithubRepository tea.Model - actualModelGithubRepository *hdlgithubrepo.ModelGithubRepository - - modelWorkflow tea.Model - directModelWorkflow *hdlWorkflow.ModelGithubWorkflow - - modelWorkflowHistory tea.Model - directModelWorkflowHistory *hdlworkflowhistory.ModelGithubWorkflowHistory - - modelTrigger tea.Model - actualModelTrigger *hdltrigger.ModelGithubTrigger - - // keymap - keys keyMap -} - -const ( - minTerminalWidth = 102 - minTerminalHeight = 24 + "github.com/termkit/skeleton" ) func SetupTerminal(githubUseCase gu.UseCase, version pkgversion.Version) tea.Model { - var currentTab = new(int) - var forceUpdateWorkflowHistory = new(bool) - var lockTabs = new(bool) - - *lockTabs = true // by default lock tabs - - tabsWithColor := []string{"Info", "Repository", "Workflow History", "Workflow", "Trigger"} - - selectedRepository := hdltypes.SelectedRepository{} - - // setup models - hdlModelInfo := hdlinfo.SetupModelInfo(githubUseCase, version, lockTabs) - hdlModelGithubRepository := hdlgithubrepo.SetupModelGithubRepository(githubUseCase, &selectedRepository) - hdlModelWorkflowHistory := hdlworkflowhistory.SetupModelGithubWorkflowHistory(githubUseCase, &selectedRepository, forceUpdateWorkflowHistory) - hdlModelWorkflow := hdlWorkflow.SetupModelGithubWorkflow(githubUseCase, &selectedRepository) - hdlModelTrigger := hdltrigger.SetupModelGithubTrigger(githubUseCase, &selectedRepository, currentTab, forceUpdateWorkflowHistory) - - m := model{ - lockTabs: lockTabs, - currentTab: currentTab, - TabsWithColor: tabsWithColor, - timer: timer.NewWithInterval(1<<63-1, time.Millisecond*200), - modelInfo: hdlModelInfo, actualModelInfo: hdlModelInfo, - SelectedRepository: &selectedRepository, - modelGithubRepository: hdlModelGithubRepository, actualModelGithubRepository: hdlModelGithubRepository, - modelWorkflowHistory: hdlModelWorkflowHistory, directModelWorkflowHistory: hdlModelWorkflowHistory, - modelWorkflow: hdlModelWorkflow, directModelWorkflow: hdlModelWorkflow, - modelTrigger: hdlModelTrigger, actualModelTrigger: hdlModelTrigger, - keys: keys, + cfg, err := config.LoadConfig() + if err != nil { + panic(fmt.Sprintf("failed to load config: %v", err)) } - hdlModelInfo.Viewport = &m.viewport - hdlModelGithubRepository.Viewport = &m.viewport - hdlModelWorkflowHistory.Viewport = &m.viewport - hdlModelWorkflow.Viewport = &m.viewport - hdlModelTrigger.Viewport = &m.viewport - - return &m -} - -func (m *model) Init() tea.Cmd { - m.viewport = viewport.Model{Width: minTerminalWidth, Height: minTerminalHeight} - return tea.Batch( - tea.EnterAltScreen, - tea.SetWindowTitle("GitHub Actions Manager (GAMA)"), - m.timer.Init(), - m.modelInfo.Init(), - m.modelGithubRepository.Init(), - m.modelWorkflowHistory.Init(), - m.modelWorkflow.Init(), - m.modelTrigger.Init()) -} + s := skeleton.NewSkeleton() -func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - var cmds []tea.Cmd + s.AddPage("info", "Info", SetupModelInfo(s, githubUseCase, version)) + s.AddPage("repository", "Repository", SetupModelGithubRepository(s, githubUseCase)) + s.AddPage("history", "Workflow History", SetupModelGithubWorkflowHistory(s, githubUseCase)) + s.AddPage("workflow", "Workflow", SetupModelGithubWorkflow(s, githubUseCase)) + s.AddPage("trigger", "Trigger", SetupModelGithubTrigger(s, githubUseCase)) - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.viewport.Width = msg.Width - m.viewport.Height = msg.Height - case tea.KeyMsg: - switch { - case key.Matches(msg, m.keys.SwitchTabLeft): - if !*m.lockTabs { - *m.currentTab = max(*m.currentTab-1, 0) - } - cmds = append(cmds, m.handleTabContent(cmd, msg)) - case key.Matches(msg, m.keys.SwitchTabRight): - if !*m.lockTabs { - *m.currentTab = min(*m.currentTab+1, len(m.TabsWithColor)-1) - } - cmds = append(cmds, m.handleTabContent(cmd, msg)) - case key.Matches(msg, m.keys.Quit): - return m, tea.Quit - default: - cmds = append(cmds, m.handleTabContent(cmd, msg)) - } - case timer.TickMsg: - m.timer, cmd = m.timer.Update(msg) - cmds = append(cmds, cmd) - } - - return m, tea.Batch(cmds...) -} + s.SetBorderColor("#ff0055"). + SetActiveTabBorderColor("#ff0055"). + SetInactiveTabBorderColor("#82636f"). + SetWidgetBorderColor("#ff0055") -func (m *model) View() string { - if m.viewport.Width < minTerminalWidth || m.viewport.Height < minTerminalHeight { - return fmt.Sprintf("Terminal window is too small. Please resize to at least %dx%d.", minTerminalWidth, minTerminalHeight) + if cfg.Settings.LiveMode.Enabled { + s.AddWidget("live", "Live Mode: On") + } else { + s.AddWidget("live", "Live Mode: Off") } - var mainDoc strings.Builder - var helpDoc string - var operationDoc string - var helpDocHeight int - - header := newHeader(&m.viewport) - for i, t := range m.TabsWithColor { - var tabStyle lipgloss.Style - isActive := i == *m.currentTab - if isActive { - tabStyle = ts.TitleStyleActive - } else { - if *m.lockTabs { - tabStyle = ts.TitleStyleDisabled - } else { - tabStyle = ts.TitleStyleInactive - } - } - header.addHeaderTab(t, tabStyle) - } - - mainDoc.WriteString("\n" + header.renderHeader() + "\n") - - var width = lipgloss.Width(strings.Repeat("-", m.viewport.Width)) - 4 - hdltypes.ScreenWidth = &width - - dynamicWindowStyle := ts.WindowStyleCyan.Width(width).Height(m.viewport.Height - 20) - - helpWindowStyle := ts.WindowStyleHelp.Width(width) - operationWindowStyle := lipgloss.NewStyle() - - switch *m.currentTab { - case 0: - mainDoc.WriteString(dynamicWindowStyle.Render(m.modelInfo.View())) - operationDoc = operationWindowStyle.Render(m.actualModelInfo.ViewStatus()) - helpDoc = helpWindowStyle.Render(m.actualModelInfo.ViewHelp()) - case 1: - mainDoc.WriteString(dynamicWindowStyle.Render(m.modelGithubRepository.View())) - operationDoc = operationWindowStyle.Render(m.actualModelGithubRepository.ViewStatus()) - helpDoc = helpWindowStyle.Render(m.actualModelGithubRepository.ViewHelp()) - case 2: - mainDoc.WriteString(dynamicWindowStyle.Render(m.modelWorkflowHistory.View())) - operationDoc = operationWindowStyle.Render(m.directModelWorkflowHistory.ViewStatus()) - helpDoc = helpWindowStyle.Render(m.directModelWorkflowHistory.ViewHelp()) - case 3: - mainDoc.WriteString(dynamicWindowStyle.Render(m.modelWorkflow.View())) - operationDoc = operationWindowStyle.Render(m.directModelWorkflow.ViewStatus()) - helpDoc = helpWindowStyle.Render(m.directModelWorkflow.ViewHelp()) - case 4: - mainDoc.WriteString(dynamicWindowStyle.Render(m.modelTrigger.View())) - operationDoc = operationWindowStyle.Render(m.actualModelTrigger.ViewStatus()) - helpDoc = helpWindowStyle.Render(m.actualModelTrigger.ViewHelp()) - } - - mainDocContent := ts.DocStyle.Render(mainDoc.String()) - - mainDocHeight := strings.Count(mainDocContent, "\n") - helpDocHeight = strings.Count(helpDoc, "\n") - errorDocHeight := strings.Count(operationDoc, "\n") - requiredNewlinesForPadding := m.viewport.Height - mainDocHeight - helpDocHeight - errorDocHeight - padding := strings.Repeat("\n", max(0, requiredNewlinesForPadding)) - - informationPane := lipgloss.JoinVertical(lipgloss.Top, operationDoc, helpDoc) - - return mainDocContent + padding + informationPane -} - -func (m *model) handleTabContent(cmd tea.Cmd, msg tea.Msg) tea.Cmd { - switch *m.currentTab { - case 0: - m.modelInfo, cmd = m.modelInfo.Update(msg) - case 1: - m.modelGithubRepository, cmd = m.modelGithubRepository.Update(msg) - case 2: - m.modelWorkflowHistory, cmd = m.modelWorkflowHistory.Update(msg) - case 3: - m.modelWorkflow, cmd = m.modelWorkflow.Update(msg) - case 4: - m.modelTrigger, cmd = m.modelTrigger.Update(msg) - } - return cmd -} - -// --- Header --- - -// header is a helper for rendering the header of the terminal. -type header struct { - viewport *viewport.Model - titles []string -} - -// newHeader returns a new header. -func newHeader(viewport *viewport.Model) *header { - return &header{ - titles: make([]string, 0), - viewport: viewport, - } -} - -// addHeaderTab adds a tab to the header. -func (h *header) addHeaderTab(title string, style lipgloss.Style) { - h.titles = append(h.titles, style.Render(title)) -} + s.SetTerminalViewportWidth(MinTerminalWidth) + s.SetTerminalViewportHeight(MinTerminalHeight) -// renderHeader renders the header. -func (h *header) renderHeader() string { - line := strings.Repeat("─", max(0, h.viewport.Width-79)) + s.KeyMap.SetKeyNextTab(handlerKeys.SwitchTabRight) + s.KeyMap.SetKeyPrevTab(handlerKeys.SwitchTabLeft) + s.KeyMap.SetKeyQuit(handlerKeys.Quit) - return lipgloss.JoinHorizontal(lipgloss.Center, append(h.titles, line)...) + return s } diff --git a/internal/terminal/handler/information/information.go b/internal/terminal/handler/information/information.go deleted file mode 100644 index f06515d..0000000 --- a/internal/terminal/handler/information/information.go +++ /dev/null @@ -1,169 +0,0 @@ -package information - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - gu "github.com/termkit/gama/internal/github/usecase" - hdlerror "github.com/termkit/gama/internal/terminal/handler/error" - pkgversion "github.com/termkit/gama/pkg/version" -) - -type ModelInfo struct { - version pkgversion.Version - - // use cases - github gu.UseCase - - // lockTabs will be set true if test connection fails - lockTabs *bool - - // models - Help help.Model - Viewport *viewport.Model - modelError hdlerror.ModelError - spinner spinner.Model - - // keymap - Keys keyMap -} - -const ( - releaseURL = "https://github.com/termkit/gama/releases" - - applicationName = ` - ..|'''.| | '|| ||' | -.|' ' ||| ||| ||| ||| -|| .... | || |'|..'|| | || -'|. || .''''|. | '|' || .''''|. -''|...'| .|. .||. .|. | .||. .|. .||. -` -) - -var ( - currentVersion string - newVersionAvailableMsg string - applicationDescription string -) - -func SetupModelInfo(githubUseCase gu.UseCase, version pkgversion.Version, lockTabs *bool) *ModelInfo { - modelError := hdlerror.SetupModelError() - - s := spinner.New() - s.Spinner = spinner.Pulse - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("120")) - - return &ModelInfo{ - github: githubUseCase, - version: version, - Help: help.New(), - Keys: keys, - modelError: modelError, - lockTabs: lockTabs, - spinner: s, - } -} - -func (m *ModelInfo) Init() tea.Cmd { - currentVersion = m.version.CurrentVersion() - applicationDescription = fmt.Sprintf("Github Actions Manager (%s)", currentVersion) - - go m.testConnection(context.Background()) - go m.checkUpdates(context.Background()) - return nil -} - -func (m *ModelInfo) checkUpdates(ctx context.Context) { - isUpdateAvailable, version, err := m.version.IsUpdateAvailable(ctx) - if err != nil { - m.modelError.SetError(err) - m.modelError.SetErrorMessage("failed to check updates") - newVersionAvailableMsg = fmt.Sprintf("failed to check updates: %v\nPlease visit: %s", err, releaseURL) - return - } - - if isUpdateAvailable { - newVersionAvailableMsg = fmt.Sprintf("New version available: %s\nPlease visit: %s", version, releaseURL) - } - - go m.Update(m) -} - -func (m *ModelInfo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.Help.Width = msg.Width - case tea.KeyMsg: - switch { - case key.Matches(msg, m.Keys.Quit): - return m, tea.Quit - } - } - - return m, cmd -} - -func (m *ModelInfo) View() string { - infoDoc := strings.Builder{} - - ws := lipgloss.NewStyle(). - BorderForeground(lipgloss.Color("39")). - Align(lipgloss.Center). - Border(lipgloss.RoundedBorder()). - Width(m.Viewport.Width - 7) - - infoDoc.WriteString(lipgloss.JoinVertical(lipgloss.Center, applicationName, applicationDescription, newVersionAvailableMsg)) - - docHeight := strings.Count(infoDoc.String(), "\n") - requiredNewlinesForPadding := m.Viewport.Height - docHeight - 13 - - infoDoc.WriteString(strings.Repeat("\n", max(0, requiredNewlinesForPadding))) - - return ws.Render(infoDoc.String()) -} - -func (m *ModelInfo) testConnection(ctx context.Context) { - ctxWithCancel, cancel := context.WithCancel(ctx) - - // TODO: make it better - go func(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - default: - m.spinner, _ = m.spinner.Update(m.spinner.Tick()) - m.modelError.SetProgressMessage("Checking your token " + m.spinner.View()) - time.Sleep(200 * time.Millisecond) - } - } - }(ctxWithCancel) - defer cancel() - - _, err := m.github.GetAuthUser(ctx) - if err != nil { - m.modelError.SetError(err) - m.modelError.SetErrorMessage("failed to test connection, please check your token&permission") - *m.lockTabs = true - return - } - - m.modelError.Reset() - m.modelError.SetSuccessMessage("Welcome to GAMA!") - *m.lockTabs = false - - go m.Update(m) -} - -func (m *ModelInfo) ViewStatus() string { - return m.modelError.View() -} diff --git a/internal/terminal/handler/information/keymap.go b/internal/terminal/handler/information/keymap.go deleted file mode 100644 index ad1ec59..0000000 --- a/internal/terminal/handler/information/keymap.go +++ /dev/null @@ -1,49 +0,0 @@ -package information - -import ( - "fmt" - - "github.com/termkit/gama/internal/config" - - teakey "github.com/charmbracelet/bubbles/key" -) - -type keyMap struct { - SwitchTabRight teakey.Binding - Quit teakey.Binding -} - -func (k keyMap) ShortHelp() []teakey.Binding { - return []teakey.Binding{k.SwitchTabRight, k.Quit} -} - -func (k keyMap) FullHelp() [][]teakey.Binding { - return [][]teakey.Binding{ - {k.SwitchTabRight}, - {k.Quit}, - } -} - -var keys = func() keyMap { - cfg, err := config.LoadConfig() - if err != nil { - panic(fmt.Sprintf("failed to load config: %v", err)) - } - - switchTabRight := cfg.Shortcuts.SwitchTabRight - - return keyMap{ - SwitchTabRight: teakey.NewBinding( - teakey.WithKeys(""), // help-only binding - teakey.WithHelp(switchTabRight, "next tab"), - ), - Quit: teakey.NewBinding( - teakey.WithKeys("q", cfg.Shortcuts.Quit), - teakey.WithHelp("q", "quit"), - ), - } -}() - -func (m *ModelInfo) ViewHelp() string { - return m.Help.View(m.Keys) -} diff --git a/internal/terminal/handler/keymap.go b/internal/terminal/handler/keymap.go index 034c703..1feb45c 100644 --- a/internal/terminal/handler/keymap.go +++ b/internal/terminal/handler/keymap.go @@ -2,23 +2,32 @@ package handler import ( "fmt" + "github.com/termkit/gama/internal/config" teakey "github.com/charmbracelet/bubbles/key" ) -type keyMap struct { +func loadConfig() *config.Config { + cfg, err := config.LoadConfig() + if err != nil { + panic(fmt.Sprintf("failed to load config: %v", err)) + } + return cfg +} + +// --------------------------------------------------------------------------- + +type handlerKeyMap struct { SwitchTabRight teakey.Binding SwitchTabLeft teakey.Binding Quit teakey.Binding } -var keys = func() keyMap { - cfg, err := config.LoadConfig() - if err != nil { - panic(fmt.Sprintf("failed to load config: %v", err)) - } - return keyMap{ +var handlerKeys = func() handlerKeyMap { + cfg := loadConfig() + + return handlerKeyMap{ SwitchTabRight: teakey.NewBinding( teakey.WithKeys(cfg.Shortcuts.SwitchTabRight), ), @@ -30,3 +39,210 @@ var keys = func() keyMap { ), } }() + +// --------------------------------------------------------------------------- + +type githubInformationKeyMap struct { + SwitchTabRight teakey.Binding + Quit teakey.Binding +} + +func (k githubInformationKeyMap) ShortHelp() []teakey.Binding { + return []teakey.Binding{k.SwitchTabRight, k.Quit} +} + +func (k githubInformationKeyMap) FullHelp() [][]teakey.Binding { + return [][]teakey.Binding{ + {k.SwitchTabRight}, + {k.Quit}, + } +} + +var githubInformationKeys = func() githubInformationKeyMap { + cfg := loadConfig() + + switchTabRight := cfg.Shortcuts.SwitchTabRight + + return githubInformationKeyMap{ + SwitchTabRight: teakey.NewBinding( + teakey.WithKeys(""), // help-only binding + teakey.WithHelp(switchTabRight, "next tab"), + ), + Quit: teakey.NewBinding( + teakey.WithKeys("q", cfg.Shortcuts.Quit), + teakey.WithHelp("q", "quit"), + ), + } +}() + +func (m *ModelInfo) ViewHelp() string { + return m.help.View(m.keys) +} + +// --------------------------------------------------------------------------- + +type githubRepositoryKeyMap struct { + Refresh teakey.Binding + SwitchTab teakey.Binding +} + +func (k githubRepositoryKeyMap) ShortHelp() []teakey.Binding { + return []teakey.Binding{k.SwitchTab, k.Refresh} +} + +func (k githubRepositoryKeyMap) FullHelp() [][]teakey.Binding { + return [][]teakey.Binding{ + {k.SwitchTab}, + {k.Refresh}, + } +} + +var githubRepositoryKeys = func() githubRepositoryKeyMap { + cfg := loadConfig() + + var tabSwitch = fmt.Sprintf("%s | %s", cfg.Shortcuts.SwitchTabLeft, cfg.Shortcuts.SwitchTabRight) + + return githubRepositoryKeyMap{ + Refresh: teakey.NewBinding( + teakey.WithKeys(cfg.Shortcuts.Refresh), + teakey.WithHelp(cfg.Shortcuts.Refresh, "Refresh list"), + ), + SwitchTab: teakey.NewBinding( + teakey.WithKeys(""), // help-only binding + teakey.WithHelp(tabSwitch, "switch tab"), + ), + } +}() + +func (m *ModelGithubRepository) ViewHelp() string { + return m.help.View(m.Keys) +} + +// --------------------------------------------------------------------------- + +type githubWorkflowHistoryKeyMap struct { + Refresh teakey.Binding + SwitchTab teakey.Binding + LiveMode teakey.Binding +} + +func (k githubWorkflowHistoryKeyMap) ShortHelp() []teakey.Binding { + return []teakey.Binding{k.SwitchTab, k.Refresh, k.LiveMode} +} + +func (k githubWorkflowHistoryKeyMap) FullHelp() [][]teakey.Binding { + return [][]teakey.Binding{ + {k.SwitchTab}, + {k.Refresh}, + {k.LiveMode}, + } +} + +var githubWorkflowHistoryKeys = func() githubWorkflowHistoryKeyMap { + cfg := loadConfig() + + var tabSwitch = fmt.Sprintf("%s | %s", cfg.Shortcuts.SwitchTabLeft, cfg.Shortcuts.SwitchTabRight) + + return githubWorkflowHistoryKeyMap{ + Refresh: teakey.NewBinding( + teakey.WithKeys(cfg.Shortcuts.Refresh), + teakey.WithHelp(cfg.Shortcuts.Refresh, "Refresh list"), + ), + LiveMode: teakey.NewBinding( + teakey.WithKeys(cfg.Shortcuts.LiveMode), + teakey.WithHelp(cfg.Shortcuts.LiveMode, "Toggle live mode"), + ), + SwitchTab: teakey.NewBinding( + teakey.WithKeys(""), // help-only binding + teakey.WithHelp(tabSwitch, "switch tab"), + ), + } +}() + +func (m *ModelGithubWorkflowHistory) ViewHelp() string { + return m.Help.View(m.keys) +} + +// --------------------------------------------------------------------------- + +type githubWorkflowKeyMap struct { + SwitchTab teakey.Binding +} + +func (k githubWorkflowKeyMap) ShortHelp() []teakey.Binding { + return []teakey.Binding{k.SwitchTab} +} + +func (k githubWorkflowKeyMap) FullHelp() [][]teakey.Binding { + return [][]teakey.Binding{ + {k.SwitchTab}, + } +} + +var githubWorkflowKeys = func() githubWorkflowKeyMap { + cfg := loadConfig() + + var tabSwitch = fmt.Sprintf("%s | %s", cfg.Shortcuts.SwitchTabLeft, cfg.Shortcuts.SwitchTabRight) + + return githubWorkflowKeyMap{ + SwitchTab: teakey.NewBinding( + teakey.WithKeys(""), // help-only binding + teakey.WithHelp(tabSwitch, "switch tab"), + ), + } +}() + +func (m *ModelGithubWorkflow) ViewHelp() string { + return m.help.View(m.keys) +} + +// --------------------------------------------------------------------------- + +type githubTriggerKeyMap struct { + SwitchTabLeft teakey.Binding + SwitchTab teakey.Binding + Trigger teakey.Binding + Refresh teakey.Binding +} + +func (k githubTriggerKeyMap) ShortHelp() []teakey.Binding { + return []teakey.Binding{k.SwitchTabLeft, k.Refresh, k.SwitchTab, k.Trigger} +} + +func (k githubTriggerKeyMap) FullHelp() [][]teakey.Binding { + return [][]teakey.Binding{ + {k.SwitchTabLeft}, + {k.Refresh}, + {k.SwitchTab}, + {k.Trigger}, + } +} + +var githubTriggerKeys = func() githubTriggerKeyMap { + cfg := loadConfig() + + previousTab := cfg.Shortcuts.SwitchTabLeft + + return githubTriggerKeyMap{ + SwitchTabLeft: teakey.NewBinding( + teakey.WithKeys(""), // help-only binding + teakey.WithHelp(previousTab, "previous tab"), + ), + Refresh: teakey.NewBinding( + teakey.WithKeys(cfg.Shortcuts.Refresh), + teakey.WithHelp(cfg.Shortcuts.Refresh, "Refresh list"), + ), + SwitchTab: teakey.NewBinding( + teakey.WithKeys(cfg.Shortcuts.Tab), + teakey.WithHelp(cfg.Shortcuts.Tab, "switch button"), + ), + Trigger: teakey.NewBinding( + teakey.WithKeys(cfg.Shortcuts.Enter), + teakey.WithHelp(cfg.Shortcuts.Enter, "trigger workflow"), + ), + } +}() + +func (m *ModelGithubTrigger) ViewHelp() string { + return m.help.View(m.Keys) +} diff --git a/internal/terminal/handler/error/error.go b/internal/terminal/handler/status.go similarity index 52% rename from internal/terminal/handler/error/error.go rename to internal/terminal/handler/status.go index fdecb07..fa8c8ac 100644 --- a/internal/terminal/handler/error/error.go +++ b/internal/terminal/handler/status.go @@ -1,15 +1,16 @@ -package error +package handler import ( "fmt" "strings" "github.com/charmbracelet/lipgloss" - hdltypes "github.com/termkit/gama/internal/terminal/handler/types" - ts "github.com/termkit/gama/internal/terminal/style" + "github.com/termkit/skeleton" ) -type ModelError struct { +type ModelStatus struct { + skeleton *skeleton.Skeleton + // err is hold the error err error @@ -36,100 +37,101 @@ const ( MessageTypeSuccess MessageType = "success" ) -func SetupModelError() ModelError { - return ModelError{ +func SetupModelStatus(skeleton *skeleton.Skeleton) *ModelStatus { + return &ModelStatus{ + skeleton: skeleton, err: nil, errorMessage: "", } } -func (m *ModelError) SetError(err error) { +func (m *ModelStatus) View() string { + var windowStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()) + width := m.skeleton.GetTerminalWidth() - 4 + doc := strings.Builder{} + + if m.HaveError() { + windowStyle = WindowStyleError.Width(width) + doc.WriteString(windowStyle.Render(m.viewError())) + return lipgloss.JoinHorizontal(lipgloss.Top, doc.String()) + } + + switch m.messageType { + case MessageTypeDefault: + windowStyle = WindowStyleDefault.Width(width) + case MessageTypeProgress: + windowStyle = WindowStyleProgress.Width(width) + case MessageTypeSuccess: + windowStyle = WindowStyleSuccess.Width(width) + default: + windowStyle = WindowStyleDefault.Width(width) + } + + doc.WriteString(windowStyle.Render(m.viewMessage())) + return doc.String() +} + +func (m *ModelStatus) SetError(err error) { m.err = err } -func (m *ModelError) SetErrorMessage(errorMessage string) { - m.errorMessage = errorMessage +func (m *ModelStatus) SetErrorMessage(message string) { + m.errorMessage = message } -func (m *ModelError) SetProgressMessage(message string) { +func (m *ModelStatus) SetProgressMessage(message string) { m.messageType = MessageTypeProgress m.message = message } -func (m *ModelError) SetSuccessMessage(message string) { +func (m *ModelStatus) SetSuccessMessage(message string) { m.messageType = MessageTypeSuccess m.message = message } -func (m *ModelError) SetDefaultMessage(message string) { +func (m *ModelStatus) SetDefaultMessage(message string) { m.messageType = MessageTypeDefault m.message = message } -func (m *ModelError) GetError() error { +func (m *ModelStatus) GetError() error { return m.err } -func (m *ModelError) GetErrorMessage() string { +func (m *ModelStatus) GetErrorMessage() string { return m.errorMessage } -func (m *ModelError) GetMessage() string { +func (m *ModelStatus) GetMessage() string { return m.message } -func (m *ModelError) ResetError() { +func (m *ModelStatus) ResetError() { m.err = nil m.errorMessage = "" } -func (m *ModelError) ResetMessage() { +func (m *ModelStatus) ResetMessage() { m.message = "" } -func (m *ModelError) Reset() { +func (m *ModelStatus) Reset() { m.ResetError() m.ResetMessage() } -func (m *ModelError) HaveError() bool { +func (m *ModelStatus) HaveError() bool { return m.err != nil } -func (m *ModelError) ViewError() string { +func (m *ModelStatus) viewError() string { doc := strings.Builder{} doc.WriteString(fmt.Sprintf("Error [%v]: %s", m.err, m.errorMessage)) return doc.String() } -func (m *ModelError) ViewMessage() string { +func (m *ModelStatus) viewMessage() string { doc := strings.Builder{} doc.WriteString(m.message) return doc.String() } - -func (m *ModelError) View() string { - var windowStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()) - - doc := strings.Builder{} - if m.HaveError() { - windowStyle = ts.WindowStyleError.Width(*hdltypes.ScreenWidth) - doc.WriteString(windowStyle.Render(m.ViewError())) - return doc.String() - } - - switch m.messageType { - case MessageTypeDefault: - windowStyle = ts.WindowStyleDefault.Width(*hdltypes.ScreenWidth) - case MessageTypeProgress: - windowStyle = ts.WindowStyleProgress.Width(*hdltypes.ScreenWidth) - case MessageTypeSuccess: - windowStyle = ts.WindowStyleSuccess.Width(*hdltypes.ScreenWidth) - default: - windowStyle = ts.WindowStyleDefault.Width(*hdltypes.ScreenWidth) - } - - doc.WriteString(windowStyle.Render(m.ViewMessage())) - - return doc.String() -} diff --git a/internal/terminal/handler/table.go b/internal/terminal/handler/table.go new file mode 100644 index 0000000..65dac36 --- /dev/null +++ b/internal/terminal/handler/table.go @@ -0,0 +1,38 @@ +package handler + +import "github.com/charmbracelet/bubbles/table" + +var tableColumnsGithubRepository = []table.Column{ + {Title: "Repository", Width: 24}, + {Title: "Default Branch", Width: 16}, + {Title: "Stars", Width: 6}, + {Title: "Workflows", Width: 9}, +} + +// --------------------------------------------------------------------------- + +var tableColumnsTrigger = []table.Column{ + {Title: "ID", Width: 2}, + {Title: "Type", Width: 6}, + {Title: "Key", Width: 24}, + {Title: "Default", Width: 16}, + {Title: "Value", Width: 44}, +} + +// --------------------------------------------------------------------------- + +var tableColumnsWorkflow = []table.Column{ + {Title: "Workflow", Width: 32}, + {Title: "File", Width: 48}, +} + +// --------------------------------------------------------------------------- + +var tableColumnsWorkflowHistory = []table.Column{ + {Title: "Workflow", Width: 12}, + {Title: "Commit Message", Width: 16}, + {Title: "Triggered", Width: 12}, + {Title: "Started At", Width: 19}, + {Title: "Status", Width: 9}, + {Title: "Duration", Width: 8}, +} diff --git a/internal/terminal/handler/taboptions/taboptions.go b/internal/terminal/handler/taboptions.go similarity index 57% rename from internal/terminal/handler/taboptions/taboptions.go rename to internal/terminal/handler/taboptions.go index ce9f29d..5ac3c21 100644 --- a/internal/terminal/handler/taboptions/taboptions.go +++ b/internal/terminal/handler/taboptions.go @@ -1,23 +1,24 @@ -package taboptions +package handler import ( "fmt" "strings" "time" + "github.com/termkit/skeleton" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - hdlerror "github.com/termkit/gama/internal/terminal/handler/error" ) -type Options struct { - Style lipgloss.Style +type ModelTabOptions struct { + skeleton *skeleton.Skeleton - modelError *hdlerror.ModelError - previousModelError hdlerror.ModelError - modelLock bool + status *ModelStatus + previousStatus ModelStatus + modelLock bool - status OptionStatus + optionStatus OptionStatus options []string optionsAction []string @@ -34,119 +35,49 @@ type Options struct { type OptionStatus string const ( - // OptionIdle is for when the options are ready to use - OptionIdle OptionStatus = "Idle" + // StatusIdle is for when the options are ready to use + StatusIdle OptionStatus = "Idle" - // OptionWait is for when the options are not ready to use - OptionWait OptionStatus = "Wait" + // StatusWait is for when the options are not ready to use + StatusWait OptionStatus = "Wait" - // OptionNone is for when the options are not usable - OptionNone OptionStatus = "None" + // StatusNone is for when the options are not usable + StatusNone OptionStatus = "None" ) func (o OptionStatus) String() string { return string(o) } -func NewOptions(modelError *hdlerror.ModelError) *Options { - var b = lipgloss.RoundedBorder() - b.Right = "├" - b.Left = "┤" - - var OptionsStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("15")). - Align(lipgloss.Center).Padding(0, 1, 0, 1). - Border(b) - +func NewOptions(sk *skeleton.Skeleton, modelStatus *ModelStatus) *ModelTabOptions { var initialOptions = []string{ - OptionWait.String(), + StatusWait.String(), } var initialOptionsAction = []string{ - OptionWait.String(), + StatusWait.String(), } optionsWithFunc := make(map[int]func()) optionsWithFunc[0] = func() {} // NO OPERATION - return &Options{ - Style: OptionsStyle, + return &ModelTabOptions{ + skeleton: sk, options: initialOptions, optionsAction: initialOptionsAction, optionsWithFunc: optionsWithFunc, - status: OptionWait, - modelError: modelError, - } -} - -func (o *Options) SetStatus(status OptionStatus) { - o.status = status - o.options[0] = status.String() - o.optionsAction[0] = status.String() -} - -func (o *Options) AddOption(option string, action func()) { - var optionWithNumber string - var optionNumber = len(o.options) - optionWithNumber = fmt.Sprintf("%d) %s", optionNumber, option) - o.options = append(o.options, optionWithNumber) - o.optionsAction = append(o.optionsAction, optionWithNumber) - o.optionsWithFunc[optionNumber] = action -} - -func (o *Options) getOptionMessage() string { - option := o.options[o.cursor] - option = strings.TrimPrefix(option, fmt.Sprintf("%d) ", o.cursor)) - return option -} - -func (o *Options) showAreYouSure() { - if !o.modelLock { - o.previousModelError = *o.modelError - o.modelLock = true - } - o.modelError.Reset() - o.modelError.SetProgressMessage(fmt.Sprintf("Are you sure you want to %s?", o.getOptionMessage())) -} - -func (o *Options) switchToPreviousError() { - if o.modelLock { - return + optionStatus: StatusWait, + status: modelStatus, } - *o.modelError = o.previousModelError -} - -func (o *Options) executeOption() { - go o.optionsWithFunc[o.cursor]() - o.cursor = 0 - o.timer = -1 } -func (o *Options) Init() tea.Cmd { +func (o *ModelTabOptions) Init() tea.Cmd { return nil } -func (o *Options) resetOptionsWithOriginal() { - if o.isTabSelected { - return - } - o.isTabSelected = true - o.timer = 3 - for o.timer >= 0 { - o.optionsAction[0] = fmt.Sprintf("> %ds", o.timer) - time.Sleep(1 * time.Second) - o.timer-- - } - o.modelLock = false - o.switchToPreviousError() - o.optionsAction[0] = string(OptionIdle) - o.cursor = 0 - o.isTabSelected = false -} - -func (o *Options) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (o *ModelTabOptions) Update(msg tea.Msg) (*ModelTabOptions, tea.Cmd) { var cmd tea.Cmd - if o.status == OptionWait || o.status == OptionNone { + if o.optionStatus == StatusWait || o.optionStatus == StatusNone { return o, cmd } @@ -169,24 +100,24 @@ func (o *Options) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return o, cmd } -func (o *Options) updateCursor(cursor int) { - if cursor < len(o.options) { - o.cursor = cursor - o.showAreYouSure() - go o.resetOptionsWithOriginal() - } -} +func (o *ModelTabOptions) View() string { + var b = lipgloss.RoundedBorder() + b.Right = "├" + b.Left = "┤" -func (o *Options) View() string { - var style = o.Style.Foreground(lipgloss.Color("15")) + var style = lipgloss.NewStyle(). + Foreground(lipgloss.Color("15")). + Align(lipgloss.Center).Padding(0, 1, 0, 1). + Border(b).Foreground(lipgloss.Color("15")) var opts []string + opts = append(opts, " ") for i, option := range o.optionsAction { - switch o.status { - case OptionWait: + switch o.optionStatus { + case StatusWait: style = style.BorderForeground(lipgloss.Color("208")) - case OptionNone: + case StatusNone: style = style.BorderForeground(lipgloss.Color("240")) default: isActive := i == o.cursor @@ -202,3 +133,80 @@ func (o *Options) View() string { return lipgloss.JoinHorizontal(lipgloss.Top, opts...) } + +func (o *ModelTabOptions) resetOptionsWithOriginal() { + if o.isTabSelected { + return + } + o.isTabSelected = true + o.timer = 3 + for o.timer >= 0 { + o.optionsAction[0] = fmt.Sprintf("> %ds", o.timer) + time.Sleep(1 * time.Second) + o.timer-- + o.skeleton.TriggerUpdate() + } + o.modelLock = false + o.switchToPreviousError() + o.optionsAction[0] = string(StatusIdle) + o.cursor = 0 + o.isTabSelected = false +} + +func (o *ModelTabOptions) updateCursor(cursor int) { + if cursor < len(o.options) { + o.cursor = cursor + o.showAreYouSure() + go o.resetOptionsWithOriginal() + } +} + +func (o *ModelTabOptions) SetStatus(status OptionStatus) { + o.optionStatus = status + o.options[0] = status.String() + o.optionsAction[0] = status.String() +} + +func (o *ModelTabOptions) AddOption(option string, action func()) { + var optionWithNumber string + var optionNumber = len(o.options) + optionWithNumber = fmt.Sprintf("%d) %s", optionNumber, option) + o.options = append(o.options, optionWithNumber) + o.optionsAction = append(o.optionsAction, optionWithNumber) + o.optionsWithFunc[optionNumber] = action +} + +func (o *ModelTabOptions) getOptionMessage() string { + option := o.options[o.cursor] + option = strings.TrimPrefix(option, fmt.Sprintf("%d) ", o.cursor)) + return option +} + +func (o *ModelTabOptions) showAreYouSure() { + var yellowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("4")).Blink(true) + + if !o.modelLock { + o.previousStatus = *o.status + o.modelLock = true + } + o.status.Reset() + o.status.SetProgressMessage(fmt.Sprintf( + "Are you sure you want to %s? %s", + o.getOptionMessage(), + yellowStyle.Render("[ Press Enter ]"), + )) + +} + +func (o *ModelTabOptions) switchToPreviousError() { + if o.modelLock { + return + } + *o.status = o.previousStatus +} + +func (o *ModelTabOptions) executeOption() { + go o.optionsWithFunc[o.cursor]() + o.cursor = 0 + o.timer = -1 +} diff --git a/internal/terminal/handler/types.go b/internal/terminal/handler/types.go new file mode 100644 index 0000000..27a48f5 --- /dev/null +++ b/internal/terminal/handler/types.go @@ -0,0 +1,49 @@ +package handler + +import ( + "sync" + + "github.com/charmbracelet/lipgloss" +) + +// SelectedRepository is a struct that holds the selected repository, workflow, and branch +// It is a shared state between the different tabs +type SelectedRepository struct { + RepositoryName string + WorkflowName string + BranchName string +} + +// Constants +const ( + MinTerminalWidth = 102 + MinTerminalHeight = 24 +) + +// Styles +var ( + WindowStyleOrange = lipgloss.NewStyle().BorderForeground(lipgloss.Color("#ffaf00")).Border(lipgloss.RoundedBorder()) + WindowStyleRed = lipgloss.NewStyle().BorderForeground(lipgloss.Color("9")).Border(lipgloss.RoundedBorder()) + WindowStyleGreen = lipgloss.NewStyle().BorderForeground(lipgloss.Color("10")).Border(lipgloss.RoundedBorder()) + WindowStyleGray = lipgloss.NewStyle().BorderForeground(lipgloss.Color("240")).Border(lipgloss.RoundedBorder()) + WindowStyleWhite = lipgloss.NewStyle().BorderForeground(lipgloss.Color("255")).Border(lipgloss.RoundedBorder()) + + WindowStyleHelp = WindowStyleGray.Margin(0, 0, 0, 0).Padding(0, 2, 0, 2) + WindowStyleError = WindowStyleRed.Margin(0, 0, 0, 0).Padding(0, 2, 0, 2) + WindowStyleProgress = WindowStyleOrange.Margin(0, 0, 0, 0).Padding(0, 2, 0, 2) + WindowStyleSuccess = WindowStyleGreen.Margin(0, 0, 0, 0).Padding(0, 2, 0, 2) + WindowStyleDefault = WindowStyleWhite.Margin(0, 0, 0, 0).Padding(0, 2, 0, 2) +) + +// Constructor +var ( + onceSelectedRepository sync.Once + selectedRepository *SelectedRepository +) + +func NewSelectedRepository() *SelectedRepository { + onceSelectedRepository.Do(func() { + selectedRepository = &SelectedRepository{} + }) + return selectedRepository +} diff --git a/internal/terminal/handler/types/types.go b/internal/terminal/handler/types/types.go deleted file mode 100644 index 8c9fcc3..0000000 --- a/internal/terminal/handler/types/types.go +++ /dev/null @@ -1,9 +0,0 @@ -package types - -type SelectedRepository struct { - RepositoryName string // full repository name (owner/name) - WorkflowName string // workflow name - BranchName string // branch name -} - -var ScreenWidth *int diff --git a/internal/terminal/style/style.go b/internal/terminal/style/style.go deleted file mode 100644 index 254d5b5..0000000 --- a/internal/terminal/style/style.go +++ /dev/null @@ -1,47 +0,0 @@ -package style - -import ( - "github.com/charmbracelet/lipgloss" -) - -var ( - DocStyle = lipgloss.NewStyle().Padding(1, 2, 1, 2) - WindowStyleCyan = lipgloss.NewStyle().BorderForeground(lipgloss.Color("39")) - WindowStyleOrange = lipgloss.NewStyle().BorderForeground(lipgloss.Color("#ffaf00")).Border(lipgloss.RoundedBorder()) - WindowStyleRed = lipgloss.NewStyle().BorderForeground(lipgloss.Color("9")).Border(lipgloss.RoundedBorder()) - WindowStyleGreen = lipgloss.NewStyle().BorderForeground(lipgloss.Color("10")).Border(lipgloss.RoundedBorder()) - WindowStyleGray = lipgloss.NewStyle().BorderForeground(lipgloss.Color("240")).Border(lipgloss.NormalBorder()) - WindowStyleWhite = lipgloss.NewStyle().BorderForeground(lipgloss.Color("255")).Border(lipgloss.NormalBorder()) - WindowStyleYellow = lipgloss.NewStyle().BorderForeground(lipgloss.Color("11")).Border(lipgloss.NormalBorder()) - WindowStylePink = lipgloss.NewStyle().BorderForeground(lipgloss.Color("205")).Border(lipgloss.RoundedBorder()) - - WindowStyleHelp = WindowStyleGray.Margin(0, 0, 0, 1).Padding(0, 2, 0, 2).Border(lipgloss.RoundedBorder()) - WindowStyleError = WindowStyleRed.Margin(0, 0, 0, 1).Padding(0, 2, 0, 2).Border(lipgloss.RoundedBorder()) - WindowStyleProgress = WindowStyleOrange.Margin(0, 0, 0, 1).Padding(0, 2, 0, 2).Border(lipgloss.RoundedBorder()) - WindowStyleSuccess = WindowStyleGreen.Margin(0, 0, 0, 1).Padding(0, 2, 0, 2).Border(lipgloss.RoundedBorder()) - WindowStyleDefault = WindowStyleWhite.Margin(0, 0, 0, 1).Padding(0, 2, 0, 2).Border(lipgloss.RoundedBorder()) - WindowStyleOptionSelector = WindowStylePink.Margin(0, 0, 0, 1).Padding(0, 2, 0, 2).Border(lipgloss.RoundedBorder()) -) - -var ( - TitleStyleActive = func() lipgloss.Style { - b := lipgloss.DoubleBorder() - b.Right = "├" - b.Left = "┤" - return lipgloss.NewStyle().BorderStyle(b).Padding(0, 2).BorderForeground(lipgloss.Color("205")) - }() - - TitleStyleInactive = func() lipgloss.Style { - b := lipgloss.RoundedBorder() - b.Right = "├" - b.Left = "┤" - return lipgloss.NewStyle().BorderStyle(b).Padding(0, 2).BorderForeground(lipgloss.Color("255")) - }() - - TitleStyleDisabled = func() lipgloss.Style { - b := lipgloss.RoundedBorder() - b.Right = "├" - b.Left = "┤" - return lipgloss.NewStyle().BorderStyle(b).Padding(0, 2).BorderForeground(lipgloss.Color("240")).Foreground(lipgloss.Color("240")) - }() -) diff --git a/main.go b/main.go index 8b7a27a..6d7e1d5 100644 --- a/main.go +++ b/main.go @@ -2,14 +2,15 @@ package main import ( "fmt" - "github.com/termkit/gama/internal/config" - pkgversion "github.com/termkit/gama/pkg/version" "os" - tea "github.com/charmbracelet/bubbletea" + "github.com/termkit/gama/internal/config" gr "github.com/termkit/gama/internal/github/repository" gu "github.com/termkit/gama/internal/github/usecase" th "github.com/termkit/gama/internal/terminal/handler" + pkgversion "github.com/termkit/gama/pkg/version" + + tea "github.com/charmbracelet/bubbletea" ) const ( diff --git a/pkg/workflow/workflow.go b/pkg/workflow/workflow.go index bce1a0e..72f4957 100644 --- a/pkg/workflow/workflow.go +++ b/pkg/workflow/workflow.go @@ -49,15 +49,13 @@ type Choice struct { Value string } -// TODO: Add support for boolean - func ParseWorkflow(content py.WorkflowContent) (*Workflow, error) { var w = &Workflow{ Content: make(map[string]Content), } for key, value := range content.On.WorkflowDispatch.Inputs { - if value.JSONContent != nil && len(value.JSONContent) > 0 { + if len(value.JSONContent) > 0 { var keyValue []KeyValue for k, v := range value.JSONContent { keyValue = append(keyValue, KeyValue{