├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── cmd └── refresh │ ├── .goreleaser.yaml │ └── main.go ├── engine ├── config.go ├── engine.go ├── event.go ├── eventmap_darwin.go ├── eventmap_linux.go ├── eventmap_unsupported.go ├── eventmap_windows.go ├── ignore.go ├── ignore_test.go ├── logger.go ├── patternMatch.go ├── testdata │ ├── counter.sh │ └── ignore.txt └── watch.go ├── example ├── README.md ├── example.toml ├── example.yaml ├── go.mod ├── go.sum ├── main.go └── test │ ├── .gitignore │ ├── README.md │ ├── app │ ├── bin │ ├── app │ └── myapp │ ├── ignoreme │ ├── ignored.go │ └── ignoredFile.go │ ├── main.go │ ├── monitored │ ├── README.md │ ├── ignore.go │ ├── ignore.txt │ └── monitored.go │ └── nested │ ├── README.md │ ├── ig │ ├── file.go │ └── file.txt │ ├── ignore │ └── file.txt │ ├── ignore1 │ └── file.txt │ └── ignore2 │ └── file.txt ├── examples └── basic │ └── config.yaml ├── go.mod ├── go.sum ├── main.go ├── process ├── execute.go ├── process.go ├── process_unix.go └── process_windows.go └── tui └── tui.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Refresh Build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v2 12 | 13 | - name: Set up Go 14 | uses: actions/setup-go@v4 15 | with: 16 | go-version: '>=1.21.0' 17 | id: go 18 | 19 | - name: Tidy 20 | run: go mod tidy 21 | 22 | - name: Test 23 | run: go test -v ./engine 24 | 25 | build: 26 | runs-on: ${{ matrix.os }} 27 | 28 | strategy: 29 | matrix: 30 | os: [macos-latest, ubuntu-latest, windows-latest] 31 | 32 | steps: 33 | - name: Checkout code 34 | uses: actions/checkout@v2 35 | 36 | - name: Set up Go 37 | uses: actions/setup-go@v4 38 | with: 39 | go-version: '>=1.21.0' 40 | 41 | - name: Dependencies 42 | run: go mod tidy 43 | 44 | - name: Build application 45 | run: go build ./cmd/refresh/ 46 | 47 | - name: Upload artifact 48 | uses: actions/upload-artifact@v2 49 | with: 50 | name: refresh 51 | path: ./refresh 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | dist/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Atterpac 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Refresh Hot Reload 2 | Refresh is CLI tool for hot reloading your codebase based on file system changes using [notify](https://github.com/rjeczalik/notify) with the ablity to use as a golang library in your own projects. 3 | 4 | While refresh was built for reloading codebases it can be used to execute terminal commands based on file system changes 5 | 6 | ## Key Features 7 | - Based on [Notify](https://github.com/rjeczalik/notify) to allievate common problems with popular FS libraries on mac that open a listener per file by using apples built-in FSEvents. 8 | - Allows for customization via code / config file / cli flags 9 | - Extended customization when used as a library using reloadCallback to bypass refresh rulesets and add addtional logic/logging on your applications end 10 | - Default slogger built in with the ablity to mute logs as well as pass in your own slog handler to be used in app 11 | - MIT licensed 12 | 13 | ## Install 14 | Installing via go CLI is the easiest method .tar.gz files per platform are available via github releases 15 | ```bash 16 | go install github.com/atterpac/refresh/cmd/refresh@latest 17 | ``` 18 | Alternative if you wish to use as a package and not a cli 19 | ```bash 20 | go get github.com/atterpac/refresh 21 | ``` 22 | ## Usage 23 | 24 | #### Execute Lifecycle 25 | In order to provide flexibility in your execute calls and project reloads refresh provides two declarations that are required in your execute list 26 | 27 | `"REFRESH_EXEC"` -> The next execute after `"REFRESH"` will be consider the "main" subprocess to refresh 28 | 29 | `"KILL_EXEC"` -> This declaration is replaced with the calls to kill the "main" subprocess, if one is not running this step is ignored 30 | 31 | **THESE ARE REQUIRED INSIDE YOUR EXEC LIST TO PROPERLY FUNCTION** 32 | 33 | These declarations let refresh know when you would like to kill the stale process thats been out of date due to a filechange and when to start your new version for example 34 | 35 | ` "go build -o app", "KILL_STALE", "REFRESH", "./app" ` -> This list would detect a change in the watched files build the new version kill the old one and start the new version (the most likely case) 36 | 37 | ` "KILL_STALE", "go build -o app", "REFRESH", "./app" ` -> This list would detect a change in the watched files Kill the now stale version build a new one and run it 38 | 39 | Whatever command after REFRESH is considered your "main" subprocess and the one that is tracked inside of refresh 40 | 41 | ## Embedding into your dev project 42 | There can be some uses where you might want to start a watcher internally or for a tool for development refresh provides a function `NewEngineFromOptions` which takes an `engine.Config` and allows for the `engine.Start()` function 43 | 44 | Using refresh as a library also opens the ability to add a [Callback](https://github.com/atterpac/refresh#reload-callback) function that is called on every FS notification 45 | 46 | ### Structs 47 | ```go 48 | type Config struct { 49 | RootPath string `toml:"root_path"` 50 | BackgroundExec string `toml:"background_exec"` // Execute that stays running and is unaffected by any reloads npm run dev for example 51 | BackgroundCheck bool `toml:"background_check"` 52 | Ignore Ignore `toml:"ignore"` 53 | ExecList []string `toml:"exec_list"` // See [Execute Lifecycle](https://github.com/atterpac/refresh#execute-lifecycle) 54 | LogLevel string `toml:"log_level"` 55 | Debounce int `toml:"debounce"` 56 | Callback func(*EventCallback) EventHandle 57 | Slog *slog.Logger 58 | } 59 | 60 | type Ignore struct { 61 | Dir []string `toml:"dir"` // Specfic directory to ignore ie; node_modules 62 | File []string `toml:"file"` // Specific file to ignore 63 | WatchExten []string `toml:"extension"` // Extensions to watch NOT ignore, ie; `*.go, *.js` would ignore any file that is not go or javascript 64 | GitIgnore bool `toml:"git_ignore"` // When true will check for a .gitignore in the root directory and add all entries to the ignore 65 | } 66 | 67 | type Execute struct { 68 | Cmd string `toml:"cmd" yaml:"cmd"` // Execute command 69 | ChangeDir string `toml:"dir" yaml:"dir"` // If directory needs to be changed to call this command relative to the root path 70 | IsBlocking bool `toml:"blocking" yaml:"blocking"` // Should the following executes wait for this one to complete 71 | IsPrimary bool `toml:"primary" yaml:"primary"` // Only one primary command can be run at a time 72 | DelayNext int `toml:"delay_next" yaml:"delay_next"` // Delay in milliseconds before running command 73 | } 74 | ``` 75 | 76 | ### Example 77 | For a functioning example see ./example and run main.go below describes what declaring an engine could look like 78 | ```go 79 | import ( // other imports 80 | "github.com/atterpac/refresh/engine" 81 | ) 82 | 83 | func main () { 84 | // Setup your watched exensions and any ignored files or directories 85 | ignore := engine.Ignore{ 86 | // Can use * wildcards per usual filepath pattern matching (including /**/) 87 | // ! denoted an invert in this example ignoring any extensions that are not *.go 88 | WatchedExten: []string{"*.go"}, // Ignore all files that are not go 89 | File: []string{"ignore*.go"}, // Pattern match to ignore any golang files that start with ignore 90 | Dir: []string{".git","*/node_modules", "!api/*"}, // Ignore .git and any node_modules in the directory or anything not within the api directory 91 | IgnoreGit: true, // .gitignore sitting in the root directory? set this to true to automatially ignore those files 92 | } 93 | // Build execute structs 94 | tidy := engine.Execute{ 95 | Cmd: "go mod tidy", 96 | IsBlocking: true, // Next command should wait for this to finish 97 | } 98 | build := engine.Execute{ 99 | Cmd: "go build -o ./bin/myapp", 100 | IsBlocking: true, // Wait to kill (next step) until the new binary is built 101 | } 102 | // Provided KILL_STALE will tell refresh when you would like to remove the stale process to prepare to launch the new one 103 | kill := engine.KILL_STALE 104 | // Primary process usually runs your binary 105 | run := engine.Execute{ 106 | ChangeDir: "./bin", // Change directory to call command in 107 | Cmd: "./myapp", 108 | IsBlocking: false, // Should not block because it doesnt finish until Killed by refresh 109 | IsPrimary: true, // This is the main process refersh is rerunning so denoting it as primary 110 | } 111 | // Create config to pass into refresh.NewEngineFromConfig() 112 | config := engine.Config{ 113 | RootPath: "./test", 114 | // Below is ran when a reload is triggered before killing the stale version 115 | Ignore: ignore, 116 | Debounce: 1000, // Time in ms to ignore repitive reload triggers usually caused by an OS creating multiple write/rename events for a singular change 117 | LogLevel: "debug", // debug | info | warn | error | mute -> surpresses all logs to the stdOut 118 | Callback: RefreshCallback, // func(*engine.Callback) refresh.EventHandle {} 119 | ExecStruct: []refresh.Execute{tidy, build, kill, run}, 120 | // Alternatively for easier config but less control over executes 121 | // ExecList: []string{"go mod tidy", "go build -o ./myapp", refresh.KILL_EXEC, refresh.REFRESH_EXEC, "./myapp"} 122 | // All calls will be blocking with the exception of the call after REFRESH 123 | // Both KILL_EXEC and REFRESH_EXEC are **REQUIRED** for refresh to function properly 124 | // engine.KILL_EXEC denotes when the stale process should be killed 125 | // engine.REFRESH_EXEC denotes the next execute is "primary" 126 | Slog: nil, // Optionally provide a slog interface 127 | // if nil a default will be provided 128 | // If provided stdout will not be piped through refresh 129 | } 130 | 131 | engine, err := refresh.NewEngineFromConfig(config) 132 | if err != nil { 133 | //Handle err 134 | } 135 | err = engine.Start() 136 | if err != nil { 137 | // Start will return an error when a user hits ctrl-c after it gracefully kills the processes 138 | } 139 | 140 | // Stop monitoring files and kill child processes 141 | engine.Stop() 142 | } 143 | 144 | func RefreshCallback(e *engine.EventCallback) engine.EventHandle { 145 | switch e.Type { 146 | case engine.Create: 147 | return engine.EventIgnore 148 | case engine.Write: 149 | if e.Path == "test/monitored/ignore.go" { 150 | return engine.EventBypass 151 | } 152 | return engine.EventContinue 153 | case engine.Remove: 154 | return engine.EventContinue 155 | // Other cases as needed ... 156 | } 157 | return engine.EventContinue 158 | } 159 | ``` 160 | ### Reload Callback 161 | 162 | #### Event Types 163 | The following are all the file system event types that can be passed into the callback functions. 164 | Important to note that some actions only are emitted are certain OSs and you may have to handle those if you wish to bypass refresh rulesets 165 | ```go 166 | const ( 167 | // Base Actions 168 | Create Event = iota 169 | Write 170 | Remove 171 | Rename 172 | // Windows Specific Actions 173 | ActionModified 174 | ActionRenamedNewName 175 | ActionRenamedOldName 176 | ActionAdded 177 | ActionRemoved 178 | ChangeLastWrite 179 | ChangeAttributes 180 | ChangeSize 181 | ChangeDirName 182 | ChangeFileName 183 | ChangeSecurity 184 | ChangeCreation 185 | ChangeLastAccess 186 | // Linux Specific Actions 187 | InCloseWrite 188 | InModify 189 | InMovedTo 190 | InMovedFrom 191 | InCreate 192 | InDelete 193 | ) 194 | 195 | // Used as a response to the Callback 196 | const ( 197 | EventContinue EventHandle = iota 198 | EventBypass 199 | EventIgnore 200 | ) 201 | ``` 202 | 203 | #### Callback Function 204 | 205 | Below describes the data that you recieve in the callback function as well as an example of how this could be used. 206 | 207 | Callbacks should return an refresh.EventHandle 208 | 209 | `engine.EventContinue` continues with the reload process as normal and follows the refresh ruleset defined in the config 210 | 211 | `engine.EventBypass` disregards all config rulesets and restarts the exec process 212 | 213 | `engine.EventIgnore` ignores the event and continues monitoring 214 | 215 | ```go 216 | // Called whenever a change is detected in the filesystem 217 | // By default we ignore file rename/remove and a bunch of other events that would likely cause breaking changes on a reload see eventmap_[oos].go for default rules 218 | type EventCallback struct { 219 | Type Event // Type of Notification (Write/Create/Remove...) 220 | Time time.Time // time.Now() when event was triggered 221 | Path string // Relative path based on root if root is ./myProject paths start with "myProject/..." 222 | } 223 | // Available returns from the Callback function 224 | const ( 225 | EventContinue EventHandle = iota // Continue with refresh ruleset 226 | EventBypass // Bypass all rule and reload the process 227 | EventIgnore // Force Ignore event and continue watching 228 | ) 229 | 230 | func ExampleCallback(e refresh.EventCallback) refresh.EventHandle { 231 | switch e.Type { 232 | case engine.Create: 233 | // Continue with reload process based on configured ruleset 234 | return refresh.EventContinue 235 | case engine.Write: 236 | // Ignore a file that would normally trigger a reload based on config 237 | if e.Path == "path/to/watched/file" { 238 | return engine.EventIgnore 239 | } 240 | // Continue with reload ruleset but add some extra logs/logic 241 | fmt.Println("File Modified: %s", e.Path) 242 | return engine.EventContinue 243 | case engine.Remove: 244 | // refresh will ignore this event by default 245 | // Return EventBypass to force reload process 246 | return engine.EventBypass 247 | } 248 | return engine.EventContinue 249 | } 250 | ``` 251 | ### Config File 252 | 253 | If you would prefer to load from a [config](https://github.com/Atterpac/refresh#config-file) file rather than building the structs you can use 254 | ```go 255 | engine.NewEngineFromTOML("path/to/toml") 256 | engine.SetLogger(//Input slog.Logger) 257 | ``` 258 | #### Example Config 259 | ```toml 260 | [config] 261 | # Relative to this files location 262 | root_path = "./" 263 | # debug | info(default) | warn | error | mute 264 | log_level = "info" 265 | # Debounce setting for ignoring reptitive file system notifications 266 | debounce = 1000 # Milliseconds 267 | # Sets what files the watcher should ignore 268 | background_check = true 269 | 270 | [config.ignore] 271 | # Ignore follows normal pattern matching including /**/ 272 | # Directories to ignore 273 | dir = [".git", "node_modules", "newdir"] 274 | # Files to ignore 275 | file = [".DS_Store", ".gitignore", ".gitkeep", "newfile.go", "*ignoreme*"] 276 | # File extensions to watch 277 | watched_extensions = ["*.go"] 278 | # Add .gitignore paths to ignore 279 | git_ignore = true 280 | 281 | # Runs process in the background and doesnt restart when a refresh is triggered 282 | # Vite dev and other processes take varying durations and the following commands might rely on them being "complete" 283 | # This is where setting background_check = true and using a callback in golang library to confirm its state 284 | [config.background] 285 | cmd="vite dev" 286 | 287 | # Execute structs 288 | # dir is used to change the working directory to execute into 289 | # cmd is the command to be executed 290 | # primary denotes this is the process refresh should be tracking to kill on reload 291 | # blocking denotes wether the next execute should wait for it to complete ie; build the application and when its done run it 292 | # KILL_STALE is required to be ran at any point before the primary is executed this kills the previous version of the application 293 | [[config.executes]] 294 | cmd="go mod tidy" 295 | primary=false 296 | blocking=true 297 | 298 | [[config.executes]] 299 | cmd="go build -o ./bin/app" 300 | blocking=true 301 | 302 | [[config.executes]] 303 | cmd="KILL_STALE" 304 | 305 | [[config.executes]] 306 | dir="./bin" 307 | cmd="./app" 308 | primary=true 309 | ``` 310 | 311 | ### Background Check Callback 312 | There are instances where you want to wait for the "build" steps for something like vite or a server connection that could take a varying amount 313 | of time to reach a ready state. Refresh adds `engine.AttachBackgroundCallback()` which will hault the execute commands until the callback returns 314 | true (or false for error and shutting down). This could be used along side a ping to the vite port for example to ensure it is reached before 315 | running commands that rely on it. This requires 2 things 316 | 317 | - A callback function that is `func() bool` and returns true when ready and false when errored or exited 318 | - Attaching the callback via `engine.AttachBackgroundCallback()` prior to running `engine.Start()` 319 | 320 | #### Flags 321 | This method is possible but not the most verbose and controlled way to use refresh 322 | 323 | `-p` Root path that will be watched and commands will be executed in typically this is './' 324 | 325 | `-w` Flag to decide wether the exec process should wait on the pre exec to complete 326 | 327 | `-e` Commands to be called when a modification is detected in the form of a comma seperated list required refresh declrations 328 | 329 | **See [Execute Lifecycle](https://github.com/atterpac/refresh#execute-lifecycle) for more details** 330 | 331 | `-l` Log Level to display options can include `"debug", "info","warn","error", "mute"` 332 | 333 | `-f` path to a TOML config file see [Config File](https://github.com/atterpac/refresh#config-file) for details on the format of config 334 | 335 | `-id` Ignore directories provided as a comma-separated list 336 | 337 | `-if` Ignore files provided as a comma-separated list 338 | 339 | `-ie` Ignore extensions provided as a comma-separated list 340 | 341 | `-d` Debounce timer in milliseconds, used to ignore repetitive system 342 | 343 | #### Example 344 | ```bash 345 | refresh -p ./ -e "go mod tidy, go build -o ./myapp, KILL_STALE, REFRESH, ./myapp" -l "debug" -id ".git, node_modules" -if ".env" -ie ".db, .sqlite" -d 500 346 | ``` 347 | ### Alternatives 348 | Refresh not for you? Here are some popular hot reload alternatives 349 | 350 | - [Air](https://github.com/cosmtrek/air) 351 | - [Realize](https://github.com/oxequa/realize) 352 | - [Fresh](https://github.com/gravityblast/fresh) 353 | -------------------------------------------------------------------------------- /app: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atterpac/refresh/311e43d3bca3108b50939995c766dc2986f1df66/app -------------------------------------------------------------------------------- /cmd/refresh/.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | before: 4 | hooks: 5 | # You may remove this if you don't use go modules. 6 | - go mod tidy 7 | # you may remove this if you don't need go generate 8 | - go generate ./... 9 | 10 | builds: 11 | - env: 12 | - CGO_ENABLED=0 13 | goos: 14 | - linux 15 | - windows 16 | - darwin 17 | 18 | archives: 19 | - format: tar.gz 20 | # this name template makes the OS and Arch compatible with the results of `uname`. 21 | name_template: >- 22 | {{ .ProjectName }}_ 23 | {{- title .Os }}_ 24 | {{- if eq .Arch "amd64" }}x86_64 25 | {{- else if eq .Arch "386" }}i386 26 | {{- else }}{{ .Arch }}{{ end }} 27 | {{- if .Arm }}v{{ .Arm }}{{ end }} 28 | # use zip for windows archives 29 | format_overrides: 30 | - goos: windows 31 | format: zip 32 | 33 | changelog: 34 | sort: asc 35 | filters: 36 | exclude: 37 | - "^docs:" 38 | - "^test:" 39 | -------------------------------------------------------------------------------- /cmd/refresh/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "strconv" 9 | "strings" 10 | 11 | refresh "github.com/atterpac/refresh/engine" 12 | ) 13 | 14 | func main() { 15 | var version string = "0.4.9" 16 | 17 | var rootPath string 18 | var execCommand string 19 | var logLevel string 20 | var configPath string 21 | var debounce string 22 | 23 | var versFlag bool 24 | var gitIgnore bool 25 | 26 | // Ignore 27 | var ignoreDir string 28 | var ignoreFile string 29 | var ignoreExt string 30 | 31 | flag.StringVar(&rootPath, "p", "./", "Root path to watch") 32 | flag.StringVar(&execCommand, "e", "", "Command to execute on changes") 33 | flag.StringVar(&logLevel, "l", "info", "Level to set Logs") 34 | flag.StringVar(&configPath, "f", "", "File to read config from") 35 | flag.StringVar(&ignoreDir, "id", "", "Ignore Directory list as comma-separated list") 36 | flag.StringVar(&ignoreFile, "if", "", "Ignore File list as comma-separated list") 37 | flag.StringVar(&ignoreExt, "ie", "", "Watched Extension list as comma-separated list") 38 | flag.StringVar(&debounce, "d", "1000", "Debounce time in milliseconds") 39 | flag.BoolVar(&versFlag, "v", false, "Print version") 40 | flag.BoolVar(&gitIgnore, "git", false, "Read from .gitignore") 41 | flag.Parse() 42 | 43 | if versFlag { 44 | fmt.Println(PrintBanner(version)) 45 | os.Exit(0) 46 | } 47 | var watch *refresh.Engine 48 | 49 | if len(configPath) != 0 { 50 | // If toml vs yaml 51 | var err error 52 | if strings.Contains(configPath, ".toml") { 53 | watch, err = refresh.NewEngineFromTOML(configPath) 54 | } else if strings.Contains(configPath, ".yaml") { 55 | watch, err = refresh.NewEngineFromYAML(configPath) 56 | } 57 | if err != nil { 58 | slog.Error("Error reading config file", "err", err) 59 | } 60 | } else { 61 | ignore := refresh.Ignore{ 62 | File: strings.Split(ignoreFile, ","), 63 | Dir: strings.Split(ignoreDir, ","), 64 | WatchedExten: strings.Split(ignoreExt, ","), 65 | IgnoreGit: gitIgnore, 66 | } 67 | // Debounce string to int 68 | debounceThreshold, err := strconv.Atoi(debounce) 69 | if err != nil { 70 | fmt.Println("Error converting debounce to int") 71 | os.Exit(1) 72 | } 73 | config := refresh.Config{ 74 | RootPath: rootPath, 75 | ExecList: strings.Split(execCommand, ","), 76 | LogLevel: logLevel, 77 | Ignore: ignore, 78 | Debounce: debounceThreshold, 79 | } 80 | watch, err = refresh.NewEngineFromConfig(config) 81 | if err != nil { 82 | fmt.Println(err) 83 | os.Exit(1) 84 | } 85 | } 86 | 87 | err := watch.Start() 88 | if err != nil { 89 | os.Exit(1) 90 | } 91 | <-make(chan struct{}) 92 | } 93 | 94 | func PrintBanner(ver string) string { 95 | return fmt.Sprintf(` 96 | ___ ___________ __________ __ 97 | / _ \/ __/ __/ _ \/ __/ __/ // / 98 | / , _/ _// _// , _/ _/_\ \/ _ / 99 | /_/|_/___/_/ /_/|_/___/___/_//_/ CLI v%s 100 | `, ver) 101 | } 102 | -------------------------------------------------------------------------------- /engine/config.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "runtime" 10 | "strings" 11 | 12 | "github.com/BurntSushi/toml" 13 | "github.com/atterpac/refresh/process" 14 | "gopkg.in/yaml.v2" 15 | ) 16 | 17 | type Config struct { 18 | RootPath string `toml:"root_path" yaml:"root_path"` 19 | BackgroundStruct process.Execute `toml:"background" yaml:"background"` 20 | BackgroundCallback func() bool `toml:"-" yaml:"-"` 21 | Ignore Ignore `toml:"ignore" yaml:"ignore"` 22 | ExecStruct []process.Execute `toml:"executes" yaml:"executes"` 23 | ExecList []string `toml:"exec_list" yaml:"exec_list"` 24 | LogLevel string `toml:"log_level" yaml:"log_level"` 25 | Debounce int `toml:"debounce" yaml:"debounce"` 26 | Callback func(*EventCallback) EventHandle 27 | Slog *slog.Logger 28 | ignoreMap ignoreMap 29 | externalSlog bool 30 | } 31 | 32 | // Reads a config.toml file and returns the engine 33 | func (engine *Engine) readConfigFile(path string) (*Engine, error) { 34 | if _, err := toml.DecodeFile(path, &engine); err != nil { 35 | slog.Error("Error reading config file", err) 36 | return nil, err 37 | } 38 | return engine, nil 39 | } 40 | 41 | func (engine *Engine) readConfigYaml(path string) (*Engine, error) { 42 | file, err := os.ReadFile(path) 43 | if err != nil { 44 | slog.Error("Error reading config file", err) 45 | return nil, err 46 | } 47 | err = yaml.Unmarshal(file, &engine) 48 | if err != nil { 49 | slog.Error("Error reading config file", err) 50 | slog.Error(err.Error()) 51 | return nil, err 52 | } 53 | return engine, nil 54 | } 55 | 56 | func (engine *Engine) StringtoConfigYAML(yamlString string) error { 57 | err := yaml.Unmarshal([]byte(yamlString), &engine) 58 | if err != nil { 59 | slog.Error("Error reading config file", err) 60 | slog.Error(err.Error()) 61 | return err 62 | } 63 | return nil 64 | } 65 | 66 | func (engine *Engine) StringtoConfigTOML(tomlString string) error { 67 | err := yaml.Unmarshal([]byte(tomlString), &engine) 68 | if err != nil { 69 | slog.Error("Error reading config file", err) 70 | slog.Error(err.Error()) 71 | return err 72 | } 73 | return nil 74 | } 75 | 76 | // Verify required data is present in config 77 | func (engine *Engine) verifyConfig() error { 78 | slog.Debug("Verifying Config") 79 | if engine.Config.RootPath == "" { 80 | slog.Error("Required Root Path is not set") 81 | return errors.New("Required Root Path is not set") 82 | } 83 | err := engine.verifyExecute() 84 | if err != nil { 85 | return err 86 | } 87 | slog.Debug("Config Verified") 88 | // Change directory executes are called in to match root directory 89 | cleaned := cleanDirectory(engine.Config.RootPath) 90 | slog.Info("Changing Working Directory", "dir", cleaned) 91 | changeWorkingDirectory(cleaned) 92 | return nil 93 | } 94 | 95 | // Verify execute structs 96 | func (engine *Engine) verifyExecute() error { 97 | var primary bool 98 | if len(engine.Config.ExecList) == 2 && len(engine.Config.ExecStruct) < 2 { 99 | return errors.New("Execute list or struct's must be provided in the refresh config") 100 | } 101 | if engine.Config.ExecList == nil { 102 | for _, exe := range engine.Config.ExecStruct { 103 | if exe.Type == "primary" { 104 | if primary { 105 | return errors.New("Only one primary execute can be set") 106 | } 107 | primary = true 108 | } 109 | } 110 | } 111 | return nil 112 | } 113 | 114 | func readGitIgnore(path string) map[string]struct{} { 115 | file, err := os.Open(path + "/.gitignore") 116 | if err != nil { 117 | return nil 118 | } 119 | defer file.Close() 120 | slog.Debug("Reading .gitignore") 121 | scanner := bufio.NewScanner(file) 122 | var linesMap = make(map[string]struct{}) 123 | for scanner.Scan() { 124 | // Check if line is a comment 125 | if strings.HasPrefix(scanner.Text(), "#") { 126 | continue 127 | } 128 | // Check if line is empty 129 | if len(scanner.Text()) == 0 { 130 | continue 131 | } 132 | 133 | line := scanner.Text() 134 | // Check if line does not start with '*' 135 | if !strings.HasPrefix(line, "*") { 136 | // Add asterisk to the beginning of line 137 | line = "*" + line 138 | } 139 | // Add to the map 140 | linesMap[line] = struct{}{} 141 | } 142 | slog.Debug(fmt.Sprintf("Read %v lines from .gitignore", linesMap)) 143 | return linesMap 144 | } 145 | 146 | func cleanDirectory(path string) string { 147 | cleaned := strings.TrimPrefix(path, ".") 148 | cleaned = strings.TrimPrefix(cleaned, "/") 149 | if runtime.GOOS == "windows" { 150 | cleaned = strings.TrimPrefix(cleaned, `\`) // Windows >:( 151 | } 152 | wd, err := os.Getwd() 153 | if err != nil { 154 | slog.Error("Getting Working Directory") 155 | } 156 | return wd + "/" + cleaned 157 | } 158 | 159 | func changeWorkingDirectory(path string) { 160 | err := os.Chdir(path) 161 | if err != nil { 162 | slog.Error("Setting new directory", "dir", path) 163 | } 164 | } 165 | 166 | func (e *Engine) generateProcess() { 167 | for _, ex := range e.Config.ExecStruct { 168 | e.ProcessManager.AddProcess(ex.Cmd, string(ex.Type), ex.ChangeDir) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /engine/engine.go: -------------------------------------------------------------------------------- 1 | //go:build windows || linux || darwin 2 | // +build windows linux darwin 3 | 4 | package engine 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "io" 10 | "log/slog" 11 | "os" 12 | "os/signal" 13 | "time" 14 | 15 | "github.com/atterpac/refresh/process" 16 | "github.com/rjeczalik/notify" 17 | ) 18 | 19 | type Engine struct { 20 | PrimaryProcess process.Process 21 | BgProcess process.Process 22 | Chan chan notify.EventInfo 23 | Active bool 24 | Config Config `toml:"config" yaml:"config"` 25 | ProcessLogFile *os.File 26 | ProcessLogPipe io.ReadCloser 27 | ProcessManager *process.ProcessManager 28 | ctx context.Context 29 | cancel context.CancelFunc 30 | isPaused bool 31 | } 32 | 33 | func (engine *Engine) Start() error { 34 | config := engine.Config 35 | slog.Info("Refresh Starting...") 36 | if config.Ignore.IgnoreGit { 37 | config.ignoreMap.git = readGitIgnore(config.RootPath) 38 | } 39 | 40 | waitTime := time.Duration(engine.Config.BackgroundStruct.DelayNext) * time.Millisecond 41 | 42 | ctx, cancel := context.WithCancel(context.Background()) 43 | engine.ctx = ctx 44 | engine.cancel = cancel 45 | time.Sleep(waitTime) 46 | 47 | trapChan := make(chan error) 48 | go engine.sigTrap(trapChan) 49 | go engine.ProcessManager.StartProcess(engine.ctx, engine.cancel) 50 | go func() { 51 | <-ctx.Done() 52 | if ctx.Err() == context.Canceled { 53 | if !engine.ProcessManager.FirstRun { 54 | slog.Error("Could not refresh processes due to execution errors") 55 | newCtx, newCancel := context.WithCancel(context.Background()) 56 | engine.ctx = newCtx 57 | engine.cancel = newCancel 58 | return 59 | } 60 | engine.Stop() 61 | trapChan <- errors.New("An error occured while starting proceses") 62 | } 63 | }() 64 | 65 | eventManager := NewEventManager(engine, engine.Config.Debounce) 66 | go engine.watch(eventManager) 67 | return <-trapChan 68 | } 69 | 70 | func (engine *Engine) Stop() { 71 | engine.ProcessManager.KillProcesses() 72 | engine.cancel() 73 | notify.Stop(engine.Chan) 74 | } 75 | 76 | func (engine *Engine) SetLogger(logger *slog.Logger) { 77 | engine.Config.Slog = logger 78 | engine.Config.externalSlog = true 79 | } 80 | 81 | // This is out of date 82 | func NewEngine(rootPath, execCommand, logLevel string, execList []string, ignore Ignore, debounce int, chunkSize string) (*Engine, error) { 83 | engine := &Engine{} 84 | engine.Config = Config{ 85 | RootPath: rootPath, 86 | ExecList: execList, 87 | LogLevel: logLevel, 88 | Ignore: ignore, 89 | Debounce: debounce, 90 | } 91 | err := engine.verifyConfig() 92 | if err != nil { 93 | return nil, err 94 | } 95 | engine.ProcessManager = process.NewProcessManager() 96 | engine.generateProcess() 97 | _ = engine.ProcessManager.SetRootDirectory(engine.Config.RootPath) 98 | return engine, nil 99 | } 100 | 101 | func NewEngineFromConfig(options Config) (*Engine, error) { 102 | engine := &Engine{} 103 | engine.Config = options 104 | engine.Config.ignoreMap = convertToIgnoreMap(engine.Config.Ignore) 105 | err := engine.verifyConfig() 106 | if err != nil { 107 | return nil, err 108 | } 109 | engine.ProcessManager = process.NewProcessManager() 110 | engine.generateProcess() 111 | _ = engine.ProcessManager.SetRootDirectory(engine.Config.RootPath) 112 | return engine, nil 113 | } 114 | 115 | func NewEngineFromTOML(confPath string) (*Engine, error) { 116 | engine := Engine{} 117 | _, err := engine.readConfigFile(confPath) 118 | if err != nil { 119 | return nil, err 120 | } 121 | config := engine.Config 122 | config.Slog = newLogger(config.LogLevel) 123 | config.externalSlog = false 124 | slog.SetDefault(config.Slog) 125 | engine.Config.ignoreMap = convertToIgnoreMap(engine.Config.Ignore) 126 | engine.Config.externalSlog = false 127 | err = engine.verifyConfig() 128 | if err != nil { 129 | return nil, err 130 | } 131 | engine.ProcessManager = process.NewProcessManager() 132 | engine.generateProcess() 133 | return &engine, nil 134 | } 135 | 136 | func NewEngineFromYAML(confPath string) (*Engine, error) { 137 | engine := Engine{} 138 | _, err := engine.readConfigYaml(confPath) 139 | if err != nil { 140 | return nil, err 141 | } 142 | config := engine.Config 143 | config.Slog = newLogger(config.LogLevel) 144 | config.externalSlog = false 145 | slog.SetDefault(config.Slog) 146 | engine.Config.ignoreMap = convertToIgnoreMap(engine.Config.Ignore) 147 | engine.Config.externalSlog = false 148 | err = engine.verifyConfig() 149 | if err != nil { 150 | return nil, err 151 | } 152 | engine.ProcessManager = process.NewProcessManager() 153 | engine.generateProcess() 154 | _ = engine.ProcessManager.SetRootDirectory(engine.Config.RootPath) 155 | return &engine, nil 156 | } 157 | 158 | func (engine *Engine) AttachBackgroundCallback(callback func() bool) *Engine { 159 | engine.Config.BackgroundCallback = callback 160 | return engine 161 | } 162 | 163 | func (engine *Engine) sigTrap(ch chan error) { 164 | signalChan := make(chan os.Signal, 1) 165 | signal.Notify(signalChan, os.Interrupt) 166 | go func() { 167 | sig := <-signalChan 168 | slog.Warn("Graceful Exit Requested", "signal", sig) 169 | engine.Stop() 170 | ch <- errors.New("Graceful Exit Requested") 171 | }() 172 | } 173 | -------------------------------------------------------------------------------- /engine/event.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type eventInfo struct { 8 | Name string 9 | Reload bool 10 | } 11 | 12 | // Called whenever a change is detected in the filesystem 13 | // By default we ignore file rename/remove and a bunch of other events that would likely cause breaking changes on a reload see eventmap_[oos].go for default rules 14 | type EventCallback struct { 15 | Type Event // Event enum 16 | Time time.Time // time.Now() when event was triggered 17 | Path string // Full path to the modified file 18 | } 19 | 20 | // EventHandle is used to determine how to handle a reload callback 21 | type EventHandle int 22 | 23 | const ( 24 | EventContinue EventHandle = iota 25 | EventBypass 26 | EventIgnore 27 | ) 28 | 29 | // Event is used to determine what type of event was triggered 30 | type Event int 31 | 32 | const ( 33 | Create Event = iota 34 | Write 35 | Remove 36 | Rename 37 | // Windows Specific Actions 38 | ActionModified 39 | ActionRenamedNewName 40 | ActionRenamedOldName 41 | ActionAdded 42 | ActionRemoved 43 | ChangeLastWrite 44 | ChangeAttributes 45 | ChangeSize 46 | ChangeDirName 47 | ChangeFileName 48 | ChangeSecurity 49 | ChangeCreation 50 | ChangeLastAccess 51 | // Linux Specific Actions 52 | InCloseWrite 53 | InModify 54 | InMovedTo 55 | InMovedFrom 56 | InCreate 57 | InDelete 58 | ) 59 | -------------------------------------------------------------------------------- /engine/eventmap_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | 3 | package engine 4 | 5 | import ( 6 | "github.com/rjeczalik/notify" 7 | ) 8 | 9 | var EventMap = map[notify.Event]eventInfo{ 10 | notify.Write: {Name: "Write", Reload: true}, 11 | notify.Create: {Name: "Create", Reload: false}, 12 | notify.Remove: {Name: "Remove", Reload: false}, 13 | notify.Rename: {Name: "Rename", Reload: false}, 14 | } 15 | 16 | var CallbackMap = map[notify.Event]Event{ 17 | notify.Write: Write, 18 | notify.Create: Create, 19 | notify.Remove: Remove, 20 | notify.Rename: Rename, 21 | } 22 | -------------------------------------------------------------------------------- /engine/eventmap_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package engine 4 | 5 | import ( 6 | "github.com/rjeczalik/notify" 7 | ) 8 | 9 | var EventMap = map[notify.Event]eventInfo{ 10 | notify.InCloseWrite: {Name: "InCloseWrite", Reload: true}, 11 | notify.InModify: {Name: "InModify", Reload: true}, 12 | notify.InMovedTo: {Name: "InMovedTo", Reload: true}, 13 | notify.InMovedFrom: {Name: "InMovedFrom", Reload: true}, 14 | notify.InCreate: {Name: "InCreate", Reload: true}, 15 | notify.InDelete: {Name: "InDelete", Reload: true}, 16 | notify.Write: {Name: "Write", Reload: true}, 17 | notify.Create: {Name: "Create", Reload: false}, 18 | notify.Remove: {Name: "Remove", Reload: false}, 19 | notify.Rename: {Name: "Rename", Reload: false}, 20 | } 21 | 22 | var CallbackMap = map[notify.Event]Event{ 23 | notify.InCloseWrite: InCloseWrite, 24 | notify.InModify: InModify, 25 | notify.InMovedTo: InMovedTo, 26 | notify.InMovedFrom: InMovedFrom, 27 | notify.InCreate: InCreate, 28 | notify.InDelete: InDelete, 29 | notify.Write: Write, 30 | notify.Create: Create, 31 | notify.Remove: Remove, 32 | notify.Rename: Rename, 33 | } 34 | -------------------------------------------------------------------------------- /engine/eventmap_unsupported.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !linux && !darwin 2 | 3 | package engine 4 | 5 | func init() { 6 | println("Unsupported OS detected. File watching will not work.") 7 | os.Exit(1) 8 | } 9 | -------------------------------------------------------------------------------- /engine/eventmap_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package engine 4 | 5 | import ( 6 | "github.com/rjeczalik/notify" 7 | ) 8 | 9 | var EventMap = map[notify.Event]eventInfo{ 10 | notify.FileNotifyChangeLastWrite: {Name: "FileNotifyChangeLastWrite", Reload: true}, 11 | notify.FileActionModified: {Name: "FileActionModified", Reload: true}, 12 | notify.FileActionRenamedNewName: {Name: "FileActionRenamedNewName", Reload: false}, 13 | notify.FileActionRenamedOldName: {Name: "FileActionRenamedOldName", Reload: false}, 14 | notify.FileActionAdded: {Name: "FileActionAdded", Reload: true}, 15 | notify.FileActionRemoved: {Name: "FileActionRemoved", Reload: false}, 16 | notify.FileNotifyChangeAttributes: {Name: "FileNotifyChangeAttributes", Reload: false}, 17 | notify.FileNotifyChangeSize: {Name: "FileNotifyChangeSize", Reload: false}, 18 | notify.FileNotifyChangeDirName: {Name: "FileNotifyChangeDirName", Reload: false}, 19 | notify.FileNotifyChangeFileName: {Name: "FileNotifyChangeFileName", Reload: false}, 20 | notify.FileNotifyChangeSecurity: {Name: "FileNotifyChangeSecurity", Reload: false}, 21 | notify.FileNotifyChangeCreation: {Name: "FileNotifyChangeCreation", Reload: false}, 22 | notify.FileNotifyChangeLastAccess: {Name: "FileNotifyChangeLastAccess", Reload: true}, 23 | notify.Write: {Name: "Write", Reload: true}, 24 | notify.Create: {Name: "Create", Reload: false}, 25 | notify.Remove: {Name: "Remove", Reload: false}, 26 | notify.Rename: {Name: "Rename", Reload: false}, 27 | } 28 | 29 | var CallbackMap = map[notify.Event]Event{ 30 | notify.FileNotifyChangeLastWrite: ChangeLastWrite, 31 | notify.FileActionModified: ActionModified, 32 | notify.FileActionRenamedNewName: ActionRenamedNewName, 33 | notify.FileActionRenamedOldName: ActionRenamedOldName, 34 | notify.FileActionAdded: ActionAdded, 35 | notify.FileActionRemoved: ActionRemoved, 36 | notify.FileNotifyChangeAttributes: ChangeAttributes, 37 | notify.FileNotifyChangeSize: ChangeSize, 38 | notify.FileNotifyChangeDirName: ChangeDirName, 39 | notify.FileNotifyChangeFileName: ChangeFileName, 40 | notify.FileNotifyChangeSecurity: ChangeSecurity, 41 | notify.FileNotifyChangeCreation: ChangeCreation, 42 | notify.FileNotifyChangeLastAccess: ChangeLastAccess, 43 | notify.Write: Write, 44 | notify.Create: Create, 45 | notify.Remove: Remove, 46 | notify.Rename: Rename, 47 | } 48 | -------------------------------------------------------------------------------- /engine/ignore.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "log/slog" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | type Ignore struct { 10 | Dir []string `toml:"dir" yaml:"dir"` 11 | File []string `toml:"file" yaml:"file"` 12 | WatchedExten []string `toml:"watched_extension" yaml:"watched_extension"` 13 | IgnoreGit bool `toml:"git" yaml:"git"` 14 | } 15 | 16 | type ignoreMap struct { 17 | dir map[string]struct{} 18 | file map[string]struct{} 19 | extension map[string]struct{} 20 | git map[string]struct{} 21 | } 22 | 23 | // Runs all ignore checks to decide if reload should happen 24 | // func (i *ignoreMap) checkIgnore(path string) bool { 25 | // slog.Debug("Checking Ignore") 26 | // basePath := filepath.Base(path) 27 | // if isTmp(basePath) { 28 | // return true 29 | // } 30 | // if isIgnoreDir(path, i.dir) { 31 | // return true 32 | // } 33 | // dir := checkIgnoreMap(path, i.dir) 34 | // file := checkIgnoreMap(path, i.file) 35 | // git := checkIgnoreMap(path, i.git) 36 | // return dir || file || git 37 | // return i.shouldIgnore(path) 38 | // } 39 | 40 | func (i *Ignore) shouldIgnore(path string) bool { 41 | if i.isWatchedExtension(path) { 42 | slog.Debug("Checking Watched Extension", "path", path) 43 | if isIgnoreDir(path, i.Dir) || 44 | patternMatch(path, i.Dir) || 45 | patternMatch(path, i.File) { 46 | return true 47 | } 48 | return false 49 | } 50 | return true 51 | } 52 | 53 | func (i *Ignore) isWatchedExtension(path string) bool { 54 | ext := filepath.Ext(path) 55 | if ext == "" { 56 | return false 57 | } 58 | 59 | // First check for direct extension matches (e.g., ".go") 60 | for _, watchedExt := range i.WatchedExten { 61 | if watchedExt == ext || watchedExt == "*"+ext { 62 | return true 63 | } 64 | } 65 | 66 | // Then try pattern matching for more complex patterns 67 | return patternMatch(path, i.WatchedExten) 68 | } 69 | 70 | // func checkIgnoreMap(path string, rules map[string]struct{}) bool { 71 | // slog.Debug(fmt.Sprintf("Checking map: %v for %s", rules, path)) 72 | // _, ok := rules[path] 73 | // return mapHasItems(rules) && patternMatch(path, rules) || ok 74 | // } 75 | // 76 | // func checkExtension(path string, rules map[string]struct{}) bool { 77 | // slog.Debug(fmt.Sprintf("Checking Extension map: %v for %s", rules, path)) 78 | // return patternMatch(path, rules) 79 | // } 80 | 81 | func mapHasItems(m map[string]struct{}) bool { 82 | return len(m) >= 0 83 | } 84 | 85 | // Checks if filepath ends in tilde returns true if it does 86 | func isTmp(path string) bool { 87 | return len(path) > 0 && path[len(path)-1] == '~' 88 | } 89 | 90 | // Checks if path contains any directories in the ignore directory config 91 | func isIgnoreDir(path string, rules []string) bool { 92 | dirs := strings.Split(path, string(filepath.Separator)) 93 | for _, dir := range dirs { 94 | for _, rule := range rules { 95 | if dir == rule { 96 | slog.Debug("Ignore Dir", "dir", dir) 97 | return true 98 | } 99 | } 100 | } 101 | return false 102 | } 103 | 104 | func convertToIgnoreMap(ignore Ignore) ignoreMap { 105 | return ignoreMap{ 106 | file: convertToMap(ignore.File), 107 | dir: convertToMap(ignore.Dir), 108 | extension: convertToMap(ignore.WatchedExten), 109 | } 110 | } 111 | 112 | func convertToMap(slice []string) map[string]struct{} { 113 | m := make(map[string]struct{}) 114 | for _, v := range slice { 115 | m[v] = struct{}{} 116 | } 117 | return m 118 | } 119 | -------------------------------------------------------------------------------- /engine/ignore_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "bufio" 5 | _ "embed" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | //go:embed testdata/ignore.txt 11 | var testIgnoreData string 12 | 13 | func Test_patternCompare(t *testing.T) { 14 | // Process the ignore data by reading each line, trimming whitespace, ignore line if first char is #, split into fields 15 | // and test the values 16 | scanner := bufio.NewScanner(strings.NewReader(testIgnoreData)) 17 | for scanner.Scan() { 18 | // trim whitespace 19 | line := strings.TrimSpace(scanner.Text()) 20 | // ignore empty lines or lines that start with # 21 | if len(line) == 0 || strings.HasPrefix(line, "#") { 22 | continue 23 | } 24 | // split into fields 25 | fields := strings.Fields(line) 26 | 27 | // Call patternCompare with the fields 28 | result := patternCompare(fields[0], fields[1]) 29 | want := fields[2] == "true" 30 | if result != want { 31 | t.Errorf("patternCompare(%s, %s) = %t; want %s", fields[0], fields[1], result, fields[2]) 32 | } 33 | } 34 | } 35 | 36 | func Test_isWatchedExtension(t *testing.T) { 37 | tests := []struct { 38 | name string 39 | path string 40 | watchedExten []string 41 | wantIsWatched bool 42 | }{ 43 | { 44 | name: "go file with full path should match *.go", 45 | path: "/Users/atterpac/projects/atterpac/refresh/example/test/monitored/ignore.go", 46 | watchedExten: []string{"*.go"}, 47 | wantIsWatched: true, 48 | }, 49 | { 50 | name: "go file with just extension match", 51 | path: "/some/path/file.go", 52 | watchedExten: []string{".go"}, 53 | wantIsWatched: true, 54 | }, 55 | { 56 | name: "go file with *extension match", 57 | path: "/some/path/file.go", 58 | watchedExten: []string{"*.go"}, 59 | wantIsWatched: true, 60 | }, 61 | { 62 | name: "txt file should not match go patterns", 63 | path: "/some/path/file.txt", 64 | watchedExten: []string{"*.go", ".go"}, 65 | wantIsWatched: false, 66 | }, 67 | { 68 | name: "multiple extensions", 69 | path: "/some/path/file.js", 70 | watchedExten: []string{"*.go", "*.js", "*.html"}, 71 | wantIsWatched: true, 72 | }, 73 | { 74 | name: "no extension in path", 75 | path: "/some/path/noextension", 76 | watchedExten: []string{"*.go", "*.js"}, 77 | wantIsWatched: false, 78 | }, 79 | } 80 | 81 | for _, tt := range tests { 82 | t.Run(tt.name, func(t *testing.T) { 83 | i := &Ignore{ 84 | WatchedExten: tt.watchedExten, 85 | } 86 | got := i.isWatchedExtension(tt.path) 87 | if got != tt.wantIsWatched { 88 | t.Errorf("isWatchedExtension() = %v, want %v", got, tt.wantIsWatched) 89 | } 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /engine/logger.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "io" 7 | "log/slog" 8 | "os" 9 | "time" 10 | 11 | "github.com/lmittmann/tint" 12 | ) 13 | 14 | // SetDefault sets the default logger. 15 | func newLogger(level string) *slog.Logger { 16 | var writer io.Writer = os.Stderr 17 | if level == "mute" { 18 | writer = io.Discard 19 | } 20 | return slog.New(tint.NewHandler(writer, &tint.Options{ 21 | Level: getLogLevel(level), 22 | TimeFormat: time.Kitchen, 23 | })) 24 | } 25 | 26 | func printSubProcess(ctx context.Context, pipe io.ReadCloser) { 27 | scanner := bufio.NewScanner(pipe) 28 | defer pipe.Close() 29 | 30 | for { 31 | select { 32 | case <-ctx.Done(): 33 | return 34 | default: 35 | if scanner.Scan() { 36 | println(scanner.Text()) 37 | } else { 38 | if err := scanner.Err(); err != nil { 39 | slog.Debug("Couldnt connect to process log pipe", "err", err) 40 | } 41 | return 42 | } 43 | } 44 | } 45 | } 46 | 47 | func getLogLevel(level string) slog.Level { 48 | switch level { 49 | case "debug": 50 | return slog.LevelDebug 51 | case "info": 52 | return slog.LevelInfo 53 | case "warn": 54 | return slog.LevelWarn 55 | case "error": 56 | return slog.LevelError 57 | default: 58 | return slog.LevelInfo 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /engine/patternMatch.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "path/filepath" 7 | "unicode/utf8" 8 | ) 9 | 10 | func patternMatch(path string, PatternMap []string) bool { 11 | path = filepath.ToSlash(path) 12 | for _, pattern := range PatternMap { 13 | slog.Debug("Checking Pattern", "pattern", pattern) 14 | pattern = filepath.ToSlash(pattern) 15 | matched := patternCompare(pattern, path) 16 | if matched { 17 | slog.Debug(fmt.Sprintf("Pattern Match: %s with %s", path, pattern)) 18 | return true 19 | } 20 | } 21 | return false 22 | } 23 | 24 | // patternCompare reports whether name matches the shell file name pattern. 25 | // Unfortunately filepath.Match doesnt work for this use case 26 | // Comparision laid out here: https://go.dev/play/p/Ega9qgD4Qz thanks to https://gitlab.com/hackandsla.sh/letterbox 27 | func patternCompare(pattern, name string) (matched bool) { 28 | Pattern: 29 | for len(pattern) > 0 { 30 | var star bool 31 | var chunk string 32 | star, chunk, pattern = scanChunk(pattern) 33 | if star && chunk == "" { 34 | // Trailing * matches rest of string. 35 | return true 36 | } 37 | // Look for match at current position. 38 | t, ok := matchChunk(chunk, name) 39 | // if we're the last chunk, make sure we've exhausted the name 40 | // otherwise we'll give a false result even if we could still match 41 | // using the star 42 | if ok && (len(t) == 0 || len(pattern) > 0) { 43 | name = t 44 | continue 45 | } 46 | if star { 47 | // Look for match skipping i+1 bytes. 48 | for i := 0; i < len(name); i++ { 49 | t, ok := matchChunk(chunk, name[i+1:]) 50 | if ok { 51 | // if we're the last chunk, make sure we exhausted the name 52 | if len(pattern) == 0 && len(t) > 0 { 53 | continue 54 | } 55 | name = t 56 | continue Pattern 57 | } 58 | } 59 | } 60 | return false 61 | } 62 | return len(name) == 0 63 | } 64 | 65 | // scanChunk gets the next segment of pattern, which is a non-star string 66 | // possibly preceded by a star. 67 | func scanChunk(pattern string) (star bool, chunk, rest string) { 68 | for len(pattern) > 0 && pattern[0] == '*' { 69 | pattern = pattern[1:] 70 | star = true 71 | } 72 | inrange := false 73 | var i int 74 | Scan: 75 | for i = 0; i < len(pattern); i++ { 76 | switch pattern[i] { 77 | case '*': 78 | if !inrange { 79 | break Scan 80 | } 81 | } 82 | } 83 | return star, pattern[0:i], pattern[i:] 84 | } 85 | 86 | // matchChunk checks whether chunk matches the beginning of s. 87 | // If so, it returns the remainder of s (after the match). 88 | // Chunk is all single-character operators: literals, char classes, and ?. 89 | func matchChunk(chunk, s string) (rest string, ok bool) { 90 | for len(chunk) > 0 { 91 | if len(s) == 0 { 92 | return 93 | } 94 | switch chunk[0] { 95 | case '?': 96 | _, n := utf8.DecodeRuneInString(s) 97 | s = s[n:] 98 | chunk = chunk[1:] 99 | default: 100 | if chunk[0] != s[0] { 101 | return 102 | } 103 | s = s[1:] 104 | chunk = chunk[1:] 105 | } 106 | } 107 | return s, true 108 | } 109 | -------------------------------------------------------------------------------- /engine/testdata/counter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Function to display script usage 4 | usage() { 5 | echo "Usage: $0 " 6 | exit 1 7 | } 8 | 9 | # Check if count is provided 10 | if [ -z "$1" ]; then 11 | echo "Count not provided." 12 | usage 13 | fi 14 | 15 | # Parse count from the command line 16 | count="$1" 17 | 18 | # Main loop to log count every second 19 | for ((i=1; i<=$count; i++)); do 20 | sleep 1 21 | done 22 | 23 | 24 | -------------------------------------------------------------------------------- /engine/testdata/ignore.txt: -------------------------------------------------------------------------------- 1 | # "Pattern" "Text" Should match 2 | "test.txt" "test.txt" true 3 | "*.txt" "test.txt" true 4 | "*.txt" "test.go" false 5 | "test/*.go" "test/test.go" true 6 | "test/*.go" "test/test.txt" false 7 | "*.go" "my/really/long/path/to/test.go" true 8 | "*.go" "my/really/long/path/to/test.txt" false 9 | "test/**/test.go" "test/test1/test.go" true 10 | "test/**/test.go" "test/test1/test1.go" false 11 | "test/**/test.go" "test/other/test.go" true 12 | "test/**/test.go" "test/other/test/test.go" true 13 | "test/dir*/*test.go" "test/dir1/1test.go" true 14 | "test/dir*/*test.go" "test/dir1/1test1.go" false 15 | "test/dir*/*test.go" "test/dir2/2test.go" true 16 | "*.go" "/home/atterpac/projects/gotato/example/test/main.go" true 17 | 18 | "*\manifest\manifest.go" "D:\projects\wailsapp\internal\manifest\manifest.go" true 19 | -------------------------------------------------------------------------------- /engine/watch.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin || windows 2 | // +build linux darwin windows 3 | 4 | package engine 5 | 6 | import ( 7 | "context" 8 | "log/slog" 9 | 10 | // "log/slog" 11 | "os" 12 | "path/filepath" 13 | "time" 14 | 15 | "github.com/atterpac/refresh/process" 16 | "github.com/rjeczalik/notify" 17 | ) 18 | 19 | type EventManager struct { 20 | engine *Engine 21 | lastEventTime time.Time 22 | debounceThreshold time.Duration 23 | debounceTimer *time.Timer 24 | ctx context.Context 25 | cancel context.CancelFunc 26 | } 27 | 28 | func NewEventManager(engine *Engine, debounce int) *EventManager { 29 | ctx, cancel := context.WithCancel(context.Background()) 30 | em := &EventManager{ 31 | engine: engine, 32 | debounceThreshold: time.Duration(debounce) * time.Millisecond, 33 | ctx: ctx, 34 | cancel: cancel, 35 | } 36 | return em 37 | } 38 | 39 | func (em *EventManager) HandleEvent(ei notify.EventInfo) { 40 | eventInfo, ok := EventMap[ei.Event()] 41 | if !ok { 42 | slog.Error("Unknown event", "event", ei.Event()) 43 | return 44 | } 45 | 46 | if em.engine.Config.Callback != nil { 47 | event := CallbackMap[ei.Event()] 48 | handle := em.engine.Config.Callback(&EventCallback{ 49 | Type: event, 50 | Path: getPath(ei.Path()), 51 | Time: time.Now(), 52 | }) 53 | switch handle { 54 | case EventContinue: 55 | // Continue 56 | case EventBypass: 57 | // slog.Debug("Bypassing event", "event", ei.Event(), "path", ei.Path()) 58 | return 59 | case EventIgnore: 60 | // slog.Debug("Ignoring event", "event", ei.Event(), "path", ei.Path()) 61 | return 62 | default: 63 | } 64 | } 65 | 66 | if eventInfo.Reload { 67 | newCtx, newCancel := context.WithCancel(context.Background()) 68 | em.engine.ctx = newCtx 69 | em.engine.cancel = newCancel 70 | if em.engine.Config.Ignore.shouldIgnore(ei.Path()) { 71 | return 72 | } 73 | slog.Debug("Event", "event", ei.Event(), "path", ei.Path(), "time", time.Now()) 74 | currentTime := time.Now() 75 | if currentTime.Sub(em.lastEventTime) >= em.debounceThreshold { 76 | slog.Debug("Setting debounce timer", "event", ei.Event(), "path", ei.Path(), "time", time.Now()) 77 | slog.Info("File modified...Refreshing", "file", getPath(ei.Path())) 78 | 79 | // Find the specific process associated with the file change event 80 | for _, p := range em.engine.ProcessManager.Processes { 81 | if p.Type == process.Primary { 82 | // Kill the specific process by canceling its context 83 | if cancel, ok := em.engine.ProcessManager.Cancels[p.Exec]; ok { 84 | cancel() 85 | delete(em.engine.ProcessManager.Ctxs, p.Exec) 86 | delete(em.engine.ProcessManager.Cancels, p.Exec) 87 | } 88 | break 89 | } 90 | } 91 | 92 | // Start a new instance of the process 93 | go em.engine.ProcessManager.StartProcess(em.engine.ctx, em.engine.cancel) 94 | go func() { 95 | <-em.engine.ctx.Done() 96 | if em.engine.ctx.Err() == context.Canceled { 97 | if !em.engine.ProcessManager.FirstRun { 98 | // slog.Error("Could not refresh processes due to build errors") 99 | newCtx, newCancel := context.WithCancel(context.Background()) 100 | em.engine.ctx = newCtx 101 | em.engine.cancel = newCancel 102 | return 103 | } 104 | em.engine.Stop() 105 | } 106 | }() 107 | 108 | em.lastEventTime = currentTime 109 | } else { 110 | // slog.Debug("Debouncing event", "event", ei.Event(), "path", ei.Path(), "time", time.Now()) 111 | } 112 | } 113 | } 114 | 115 | func (engine *Engine) watch(eventManager *EventManager) { 116 | engine.Chan = make(chan notify.EventInfo, 5) 117 | defer notify.Stop(engine.Chan) 118 | 119 | wd, err := os.Getwd() 120 | if err != nil { 121 | slog.Error("Getting working directory") 122 | return 123 | } 124 | 125 | if err := notify.Watch(wd+"/...", engine.Chan, notify.All); err != nil { 126 | slog.Error("Watch Error", "err", err.Error()) 127 | return 128 | } 129 | 130 | for { 131 | select { 132 | case ei := <-engine.Chan: 133 | eventManager.HandleEvent(ei) 134 | } 135 | } 136 | 137 | } 138 | 139 | func getPath(path string) string { 140 | wd, err := os.Getwd() 141 | if err != nil { 142 | // slog.Error("Getting working directory") 143 | return "" 144 | } 145 | relPath, err := filepath.Rel(wd, path) 146 | if err != nil { 147 | // slog.Error("Getting relative path") 148 | return "" 149 | } 150 | return relPath 151 | } 152 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | ## Embedded Project Example 2 | 3 | This example showcases how you can use refresh as an embedded library to reload a project. 4 | See the ignore config in main.go and make your way through the nested project folders making changes to see how refresh handles it 5 | 6 | Keep logs to debug to see refresh monitor all files being changed but choose to ignore based on the configured ruleset 7 | Debug logs will show all rules being checked 8 | 9 | Run `go run main.go` to run via the embedded structs setup in main.go file 10 | 11 | Run `refresh -f example.toml` to run via CLI and the provided toml file 12 | 13 | -------------------------------------------------------------------------------- /example/example.toml: -------------------------------------------------------------------------------- 1 | ## Run this example in the refresh/example directory 2 | ## refresh -f example.toml 3 | [config] 4 | # Just used in the TUI not required 5 | label = "My Project" 6 | # Relative to this files location 7 | root_path = "./example/test" 8 | # debug | info(default) | warn | error | mute 9 | log_level = "debug" 10 | # Debounce setting for ignoring reptitive file system notifications 11 | debounce = 1000 # Milliseconds 12 | 13 | # Sets what files the watcher should ignore 14 | [config.ignore] 15 | # Directories to ignore 16 | dir = ["ignoreme"] 17 | # Files to ignore 18 | file = ["*ignore.go", "ignoredFile.go"] 19 | # File extensions to watch 20 | watched_extension = ["*.go"] 21 | 22 | # Executes are run in order 23 | # cmd is the command to run 24 | # blocking will block the next command from running until it is complete 25 | # primary will be the command that will persist through even when a file change is detected 26 | # change_dir will change the directory to the root_path prior to the command 27 | [[config.executes]] 28 | cmd="echo 'hello from refresh'" 29 | type="once" 30 | 31 | [[config.executes]] 32 | cmd="go mod tidy" 33 | type="blocking" 34 | 35 | [[config.executes]] 36 | cmd="go build -o ./app" 37 | type="blocking" 38 | 39 | 40 | [[config.executes]] 41 | cmd="./app" 42 | type="primary" 43 | 44 | -------------------------------------------------------------------------------- /example/example.yaml: -------------------------------------------------------------------------------- 1 | config: 2 | label: My Project 3 | root_path: test 4 | log_level: debug 5 | debounce: 1000 6 | ignore: 7 | dir: 8 | - ignoreme 9 | file: 10 | - '*ignore.go' 11 | - ignoredFile.go 12 | watched_extension: 13 | - '*.go' 14 | executes: 15 | - cmd: echo 'Hello from refresh' 16 | type: once 17 | - cmd: go mod tidy 18 | type: blocking 19 | - cmd: go build -o ./bin/app 20 | type: blocking 21 | - cmd: ./app 22 | dir: ./bin 23 | type: primary 24 | -------------------------------------------------------------------------------- /example/go.mod: -------------------------------------------------------------------------------- 1 | module example 2 | 3 | go 1.24.0 4 | 5 | require github.com/atterpac/refresh v0.1.0 6 | 7 | require ( 8 | github.com/BurntSushi/toml v1.3.2 // indirect 9 | github.com/lmittmann/tint v1.0.3 // indirect 10 | github.com/rjeczalik/notify v0.9.3 // indirect 11 | golang.org/x/sys v0.18.0 // indirect 12 | gopkg.in/yaml.v2 v2.4.0 // indirect 13 | ) 14 | 15 | replace github.com/atterpac/refresh => ../ 16 | -------------------------------------------------------------------------------- /example/go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= 2 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/lmittmann/tint v1.0.3 h1:W5PHeA2D8bBJVvabNfQD/XW9HPLZK1XoPZH0cq8NouQ= 4 | github.com/lmittmann/tint v1.0.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 5 | github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= 6 | github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc= 7 | golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 8 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 9 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 12 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 13 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 14 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | 6 | refresh "github.com/atterpac/refresh/engine" 7 | "github.com/atterpac/refresh/process" 8 | ) 9 | 10 | func main() { 11 | background := process.Execute{ 12 | Cmd: "pwd", 13 | } 14 | tidy := process.Execute{ 15 | Cmd: "go mod tidy", 16 | Type: process.Background, 17 | } 18 | build := process.Execute{ 19 | Cmd: "go build -o ./bin/myapp", 20 | Type: process.Blocking, 21 | } 22 | kill := process.KILL_STALE 23 | run := process.Execute{ 24 | Cmd: "./myapp", 25 | ChangeDir: "./binn", 26 | Type: process.Primary, 27 | } 28 | ignore := refresh.Ignore{ 29 | File: []string{"ignore.go"}, 30 | Dir: []string{"*/ignore*"}, 31 | WatchedExten: []string{"*.go", "*.mod", "*.js"}, 32 | IgnoreGit: true, 33 | } 34 | _ = refresh.Config{ 35 | RootPath: "./test", 36 | BackgroundStruct: background, 37 | // Below is ran when a reload is triggered before killing the stale version 38 | Ignore: ignore, 39 | Debounce: 1000, 40 | LogLevel: "debug", 41 | ExecStruct: []process.Execute{tidy, build, kill, run}, 42 | Slog: nil, 43 | } 44 | 45 | // watch := refresh.NewEngineFromConfig(config) 46 | watch, err := refresh.NewEngineFromYAML("./example.yaml") 47 | if err != nil { 48 | panic(err) 49 | } 50 | 51 | watch.AttachBackgroundCallback(func() bool { 52 | time.Sleep(5000 * time.Millisecond) 53 | return true 54 | }) 55 | err = watch.Start() 56 | if err != nil { 57 | panic(err) 58 | } 59 | 60 | <-make(chan struct{}) 61 | } 62 | 63 | func RefreshCallback(e *refresh.EventCallback) refresh.EventHandle { 64 | switch e.Type { 65 | case refresh.Create: 66 | return refresh.EventIgnore 67 | case refresh.Write: 68 | if e.Path == "test/monitored/ignore.go" { 69 | return refresh.EventBypass 70 | } 71 | return refresh.EventContinue 72 | case refresh.Remove: 73 | return refresh.EventContinue 74 | // Other cases as needed ... 75 | } 76 | return refresh.EventContinue 77 | } 78 | -------------------------------------------------------------------------------- /example/test/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | ignoreme/ 3 | 4 | .txt 5 | -------------------------------------------------------------------------------- /example/test/README.md: -------------------------------------------------------------------------------- 1 | ## Example's project 2 | 3 | This is a simple project that main.go just prints a log ever second for 100 seconds. 4 | Each folder is a different case of montiored or ignored or tests for wildcards open each one and change files 5 | while refrencing the config in the example/ root to understand why certain things trigger reloads and others do not 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /example/test/app: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atterpac/refresh/311e43d3bca3108b50939995c766dc2986f1df66/example/test/app -------------------------------------------------------------------------------- /example/test/bin/app: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atterpac/refresh/311e43d3bca3108b50939995c766dc2986f1df66/example/test/bin/app -------------------------------------------------------------------------------- /example/test/bin/myapp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atterpac/refresh/311e43d3bca3108b50939995c766dc2986f1df66/example/test/bin/myapp -------------------------------------------------------------------------------- /example/test/ignoreme/ignored.go: -------------------------------------------------------------------------------- 1 | package ignoreme 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func Ignore() { 8 | fmt.Println("This file is ignored") 9 | 10 | } 11 | -------------------------------------------------------------------------------- /example/test/ignoreme/ignoredFile.go: -------------------------------------------------------------------------------- 1 | package ignoreme 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func FullIgnore() { 8 | fmt.Println("This file is ignored") 9 | } 10 | 11 | -------------------------------------------------------------------------------- /example/test/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | func main() { 9 | for i := 0; i < 30; i++ { 10 | time.Sleep(1 * time.Second) 11 | fmt.Println("changed me", i) 12 | time.Sleep(1 * time.Second) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /example/test/monitored/README.md: -------------------------------------------------------------------------------- 1 | ## Monitored Folder 2 | This folder is monitored according to the ruleset but does contain files and extensions that break the ruleset and will not trigger a reload 3 | 4 | -------------------------------------------------------------------------------- /example/test/monitored/ignore.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // This file while in a monitored folder with a monitored extension carries the same name as an ignore.go in the ignored files 8 | // Changes to this file will be recognized in the debug logs but will not trigger a reload 9 | func ignored() { 10 | 11 | fmt.Println("This file is ignored and not changed") 12 | } 13 | -------------------------------------------------------------------------------- /example/test/monitored/ignore.txt: -------------------------------------------------------------------------------- 1 | I am a file in a monitored folder 2 | I do not trigger a reload due to my extension being .txt and not in the watched extensions 3 | 4 | -------------------------------------------------------------------------------- /example/test/monitored/monitored.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // I am a monitored file that has no exceptions and will trigger a reload 8 | // Changeme to see 9 | func monitor() { 10 | fmt.Println("Changeme") 11 | } 12 | -------------------------------------------------------------------------------- /example/test/nested/README.md: -------------------------------------------------------------------------------- 1 | ## Nested Folders 2 | 3 | This folder is used to showcase patternmatching 4 | o 5 | -------------------------------------------------------------------------------- /example/test/nested/ig/file.go: -------------------------------------------------------------------------------- 1 | 2 | ooo 3 | o 4 | :w 5 | o 6 | -------------------------------------------------------------------------------- /example/test/nested/ig/file.txt: -------------------------------------------------------------------------------- 1 | writing thingd 2 | 3 | -------------------------------------------------------------------------------- /example/test/nested/ignore/file.txt: -------------------------------------------------------------------------------- 1 | adddwriting text 2 | 3 | o 4 | -------------------------------------------------------------------------------- /example/test/nested/ignore1/file.txt: -------------------------------------------------------------------------------- 1 | Stuffs 2 | // 3 | o 4 | 5 | 6 | -------------------------------------------------------------------------------- /example/test/nested/ignore2/file.txt: -------------------------------------------------------------------------------- 1 | 2 | o/ 3 | -------------------------------------------------------------------------------- /examples/basic/config.yaml: -------------------------------------------------------------------------------- 1 | # Sample configuration for refresh 2 | root_path: "./src" 3 | log_level: "debug" 4 | debounce: 500 5 | 6 | ignore: 7 | dir: [".git", "node_modules", "vendor"] 8 | file: [".gitignore", ".DS_Store"] 9 | watched_extension: ["*.go", "*.js", "*.ts", "*.jsx", "*.tsx", "*.html", "*.css"] 10 | git_ignore: true 11 | 12 | # Define processes to run 13 | executes: 14 | - cmd: "go run main.go" 15 | type: "primary" 16 | change_dir: "./src" 17 | 18 | - cmd: "npm run watch" 19 | type: "background" 20 | change_dir: "./frontend" 21 | 22 | - cmd: "go generate ./..." 23 | type: "once" 24 | 25 | - cmd: "go test ./..." 26 | type: "blocking" 27 | timeout: 30 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/atterpac/refresh 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.3.2 7 | github.com/charmbracelet/bubbles v0.18.0 8 | github.com/charmbracelet/bubbletea v0.25.0 9 | github.com/charmbracelet/lipgloss v0.10.0 10 | github.com/lmittmann/tint v1.0.3 11 | github.com/rjeczalik/notify v0.9.3 12 | gopkg.in/yaml.v2 v2.4.0 13 | ) 14 | 15 | require ( 16 | github.com/atotto/clipboard v0.1.4 // indirect 17 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 18 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 19 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 20 | github.com/mattn/go-isatty v0.0.18 // indirect 21 | github.com/mattn/go-localereader v0.0.1 // indirect 22 | github.com/mattn/go-runewidth v0.0.15 // indirect 23 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 24 | github.com/muesli/cancelreader v0.2.2 // indirect 25 | github.com/muesli/reflow v0.3.0 // indirect 26 | github.com/muesli/termenv v0.15.2 // indirect 27 | github.com/rivo/uniseg v0.4.7 // indirect 28 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect 29 | golang.org/x/sync v0.1.0 // indirect 30 | golang.org/x/sys v0.18.0 // indirect 31 | golang.org/x/term v0.18.0 // indirect 32 | golang.org/x/text v0.3.8 // indirect 33 | ) 34 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= 2 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 4 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 5 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 6 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 7 | github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= 8 | github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= 9 | github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= 10 | github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= 11 | github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= 12 | github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= 13 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= 14 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 15 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 16 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 17 | github.com/lmittmann/tint v1.0.3 h1:W5PHeA2D8bBJVvabNfQD/XW9HPLZK1XoPZH0cq8NouQ= 18 | github.com/lmittmann/tint v1.0.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 19 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 20 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 21 | github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= 22 | github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 23 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 24 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 25 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 26 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 27 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 28 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 29 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 30 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 31 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 32 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 33 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 34 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 35 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 36 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 37 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 38 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 39 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 40 | github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= 41 | github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc= 42 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= 43 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 44 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 45 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 46 | golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 47 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 50 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 51 | golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= 52 | golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= 53 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 54 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 55 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 56 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 57 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 58 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 59 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/atterpac/refresh/tui" 5 | ) 6 | 7 | func main() { 8 | tui.StartTui(false) 9 | } 10 | -------------------------------------------------------------------------------- /process/execute.go: -------------------------------------------------------------------------------- 1 | package process 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os/exec" 7 | "strings" 8 | ) 9 | 10 | type Execute struct { 11 | Cmd string `toml:"cmd" yaml:"cmd"` // Execute command 12 | ChangeDir string `toml:"dir" yaml:"dir"` // If directory needs to be changed to call this command relative to the root path 13 | DelayNext int `toml:"delay_next" yaml:"delay_next"` // Delay in milliseconds before running command 14 | // Type can have one of a few types to define how it reacts to a file change 15 | // background -- runs once at startup and is killed when refresh is canceled 16 | // once -- runs once at refresh startup but is blocking 17 | // blocking -- runs every refresh cycle as a blocking process 18 | // primary -- Is the primary process that kills the previous processes before running 19 | Type ExecuteType `toml:"type" yaml:"type"` 20 | } 21 | 22 | type ExecuteType string 23 | 24 | var ( 25 | Background ExecuteType = "background" 26 | Once ExecuteType = "once" 27 | Blocking ExecuteType = "blocking" 28 | Primary ExecuteType = "primary" 29 | ) 30 | 31 | var KILL_STALE = Execute{ 32 | Cmd: "KILL_STALE", 33 | Type: "blocking", 34 | } 35 | 36 | var REFRESH_EXEC = "REFRESH" 37 | var KILL_EXEC = "KILL_STALE" 38 | 39 | // Takes a string and splits it on spaces to create a slice of strings 40 | func generateExec(cmd string) *exec.Cmd { 41 | slice := strings.Split(cmd, " ") 42 | cmdEx := exec.Command(slice[0], slice[1:]...) 43 | return cmdEx 44 | } 45 | 46 | func stringToExecuteType(typing string) (ExecuteType, error) { 47 | switch typing { 48 | case "background": 49 | return Background, nil 50 | case "once": 51 | return Once, nil 52 | case "blocking": 53 | return Blocking, nil 54 | case "primary": 55 | return Primary, nil 56 | default: 57 | return "", errors.New(fmt.Sprintf("Execute type of %s, is invalid", typing)) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /process/process.go: -------------------------------------------------------------------------------- 1 | package process 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "sync" 14 | ) 15 | 16 | type Process struct { 17 | Exec string 18 | Type ExecuteType 19 | Dir string 20 | logPipe io.ReadCloser 21 | cmd *exec.Cmd 22 | pid int 23 | pgid int 24 | } 25 | 26 | type ProcessManager struct { 27 | Processes []*Process 28 | RootDir string 29 | mu sync.RWMutex 30 | Ctxs map[string]context.Context 31 | Cancels map[string]context.CancelFunc 32 | FirstRun bool 33 | } 34 | 35 | func NewProcessManager() *ProcessManager { 36 | return &ProcessManager{ 37 | Processes: make([]*Process, 0), 38 | Ctxs: make(map[string]context.Context), 39 | Cancels: make(map[string]context.CancelFunc), 40 | FirstRun: true, 41 | } 42 | } 43 | 44 | func (pm *ProcessManager) AddProcess(exec string, typing string, dir string) error { 45 | execType, err := stringToExecuteType(typing) 46 | if err != nil { 47 | return err 48 | } 49 | pm.Processes = append(pm.Processes, &Process{ 50 | Exec: exec, 51 | Type: execType, 52 | Dir: dir, 53 | }) 54 | return nil 55 | } 56 | 57 | func (pm *ProcessManager) GetExecutes() []string { 58 | pm.mu.RLock() 59 | defer pm.mu.RUnlock() 60 | 61 | var execs []string 62 | for _, p := range pm.Processes { 63 | execs = append(execs, p.Exec) 64 | } 65 | return execs 66 | } 67 | 68 | func (pm *ProcessManager) SetRootDirectory(dir string) error { 69 | // First, get the current working directory as a fallback 70 | currentDir, err := os.Getwd() 71 | if err != nil { 72 | return errors.New("Unable to get current working directory") 73 | } 74 | 75 | // If dir is empty, use the current directory 76 | if dir == "" { 77 | pm.RootDir = currentDir 78 | return nil 79 | } 80 | 81 | // Change to the requested directory 82 | err = os.Chdir(dir) 83 | if err != nil { 84 | return fmt.Errorf("Unable to change to root directory %s: %w", dir, err) 85 | } 86 | 87 | // Get the absolute path of the new working directory 88 | absDir, err := os.Getwd() 89 | if err != nil { 90 | // If we can't get the absolute path, restore the original directory 91 | os.Chdir(currentDir) 92 | return errors.New("Unable to get absolute path of root directory") 93 | } 94 | 95 | // Store the absolute path 96 | pm.RootDir = absDir 97 | slog.Debug("Set root directory", "dir", pm.RootDir) 98 | 99 | return nil 100 | } 101 | 102 | func (pm *ProcessManager) ChangeExecuteDirectory(dir string) error { 103 | if dir == "" { 104 | return nil 105 | } 106 | 107 | targetDir := dir 108 | // Check if the path is relative (doesn't start with / or drive letter) 109 | if !filepath.IsAbs(dir) { 110 | // Combine with the root directory 111 | targetDir = filepath.Join(pm.RootDir, dir) 112 | } 113 | 114 | slog.Debug("Changing directory", "to", targetDir) 115 | err := os.Chdir(targetDir) 116 | if err != nil { 117 | return fmt.Errorf("Unable to change execute directory: %s: %w", targetDir, err) 118 | } 119 | return nil 120 | } 121 | 122 | func (pm *ProcessManager) RestoreRootDirectory() error { 123 | if pm.RootDir == "" { 124 | return nil 125 | } 126 | slog.Debug("Restoring directory to root", "dir", pm.RootDir) 127 | err := os.Chdir(pm.RootDir) 128 | if err != nil { 129 | return fmt.Errorf("Unable to restore root directory: %s: %w", pm.RootDir, err) 130 | } 131 | return nil 132 | } 133 | 134 | func printSubProcess(ctx context.Context, pipe io.ReadCloser) { 135 | scanner := bufio.NewScanner(pipe) 136 | done := make(chan struct{}) 137 | 138 | go func() { 139 | defer close(done) 140 | for scanner.Scan() { 141 | select { 142 | case <-ctx.Done(): 143 | return 144 | default: 145 | fmt.Println(scanner.Text()) 146 | } 147 | } 148 | }() 149 | 150 | select { 151 | case <-ctx.Done(): 152 | // Context was canceled, try to close the pipe 153 | pipe.Close() 154 | case <-done: 155 | // Scanner finished naturally 156 | } 157 | 158 | if err := scanner.Err(); err != nil && err != io.EOF && !errors.Is(err, os.ErrClosed) { 159 | slog.Debug("Scanner error", "err", err) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /process/process_unix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | 3 | package process 4 | 5 | import ( 6 | "context" 7 | "log/slog" 8 | "os" 9 | "path/filepath" 10 | "syscall" 11 | "time" 12 | ) 13 | 14 | func (pm *ProcessManager) StartProcess(ctx context.Context, cancel context.CancelFunc) { 15 | pm.mu.Lock() 16 | defer pm.mu.Unlock() 17 | 18 | // Store the original directory to ensure we restore it at the end of function 19 | originalDir, err := os.Getwd() 20 | if err != nil { 21 | slog.Error("Failed to get current working directory", "err", err) 22 | // If we can't get the current directory, use our saved RootDir 23 | originalDir = pm.RootDir 24 | } 25 | 26 | // Ensure we always restore the original directory when this function exits 27 | defer func() { 28 | err := os.Chdir(originalDir) 29 | if err != nil { 30 | slog.Error("Failed to restore original directory", "dir", originalDir, "err", err) 31 | } 32 | }() 33 | 34 | if len(pm.Processes) == 0 { 35 | slog.Warn("No Processes to Start") 36 | os.Exit(1) 37 | return 38 | } 39 | for _, p := range pm.Processes { 40 | slog.Debug("Starting Process", "exec", p.Exec) 41 | if p.Exec == "KILL_STALE" { 42 | continue 43 | } 44 | if !pm.FirstRun && p.Type == Background { 45 | continue 46 | } 47 | cmd := generateExec(p.Exec) 48 | p.cmd = cmd 49 | if p.Type == Primary { 50 | // Ensure previous processes are killed if this isnt the first run 51 | if !pm.FirstRun { 52 | for _, pr := range pm.Processes { 53 | if pr.Type != Background { 54 | // check if pid is running 55 | if pr.pid != 0 { 56 | _, err := os.FindProcess(pr.pid) 57 | if err != nil { 58 | // slog.Debug("Process not running", "exec", pr.Exec) 59 | continue 60 | } 61 | } 62 | // Remove contexts 63 | delete(pm.Ctxs, pr.Exec) 64 | delete(pm.Cancels, pr.Exec) 65 | // Wait for the process to terminate 66 | select { 67 | case <-ctx.Done(): 68 | slog.Debug("Process terminated", "exec", pr.Exec) 69 | case <-time.After(100 * time.Millisecond): 70 | slog.Debug("Process not terminated... forcefully killing", "exec", pr.Exec) 71 | } 72 | // Kill any remaining child processes 73 | if pr.pgid != 0 { 74 | // slog.Debug("Killing process group", "pgid", pr.pgid) 75 | syscall.Kill(-pr.pgid, syscall.SIGKILL) 76 | } 77 | } 78 | } 79 | time.Sleep(200 * time.Millisecond) 80 | } else { 81 | pm.FirstRun = false 82 | } 83 | } 84 | var err error 85 | if p.Type == Blocking || p.Type == Once { 86 | if !pm.FirstRun && p.Type == Once { 87 | continue 88 | } 89 | cmd.Stderr = os.Stderr 90 | p.logPipe, err = cmd.StdoutPipe() 91 | if err != nil { 92 | slog.Error("Getting Stdout Pipe", "exec", p.Exec, "err", err) 93 | } 94 | go printSubProcess(ctx, p.logPipe) 95 | 96 | // Change to the command's directory if specified 97 | if p.Dir != "" { 98 | targetDir := p.Dir 99 | if !filepath.IsAbs(p.Dir) { 100 | // If relative path, make it relative to RootDir 101 | targetDir = filepath.Join(pm.RootDir, p.Dir) 102 | } 103 | currentDir, _ := os.Getwd() 104 | slog.Debug("Changing directory for process", "from", currentDir, "to", targetDir, "process", p.Exec) 105 | err = os.Chdir(targetDir) 106 | if err != nil { 107 | slog.Error("Failed to change directory", "dir", targetDir, "err", err) 108 | cancel() 109 | return 110 | } 111 | } 112 | 113 | err = cmd.Run() 114 | if err != nil { 115 | slog.Error("Running Command", "exec", p.Exec, "err", err) 116 | cancel() 117 | return 118 | } 119 | slog.Debug("Process completed closing context", "exec", p.Exec) 120 | ctx.Done() 121 | } else { 122 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 123 | cmd.Stderr = os.Stderr 124 | p.logPipe, err = cmd.StdoutPipe() 125 | if err != nil { 126 | slog.Error("Getting Stdout Pipe", "exec", p.Exec, "err", err) 127 | } 128 | go printSubProcess(ctx, p.logPipe) 129 | 130 | // Change to the command's directory if specified 131 | if p.Dir != "" { 132 | targetDir := p.Dir 133 | if !filepath.IsAbs(p.Dir) { 134 | // If relative path, make it relative to RootDir 135 | targetDir = filepath.Join(pm.RootDir, p.Dir) 136 | } 137 | currentDir, _ := os.Getwd() 138 | slog.Debug("Changing directory for process", "from", currentDir, "to", targetDir, "process", p.Exec) 139 | err = os.Chdir(targetDir) 140 | if err != nil { 141 | slog.Error("Failed to change directory", "dir", targetDir, "err", err) 142 | cancel() 143 | continue 144 | } 145 | } 146 | 147 | err = cmd.Start() 148 | if cmd.Process == nil { 149 | slog.Error("Primary process not running", "exec", p.Exec) 150 | cancel() 151 | continue 152 | } 153 | 154 | p.pgid, _ = syscall.Getpgid(cmd.Process.Pid) 155 | p.pid = cmd.Process.Pid 156 | 157 | processCtx, processCancel := context.WithCancel(ctx) 158 | pm.Ctxs[p.Exec] = processCtx 159 | pm.Cancels[p.Exec] = processCancel 160 | // slog.Debug("Stored Process Context", "exec", p.Exec) 161 | 162 | go func() { 163 | errCh := make(chan error, 1) 164 | go func() { 165 | errCh <- cmd.Wait() 166 | }() 167 | select { 168 | case <-processCtx.Done(): 169 | _ = syscall.Kill(-p.pid, syscall.SIGKILL) 170 | case <-ctx.Done(): 171 | slog.Debug("Context closed", "exec", p.Exec) 172 | _ = syscall.Kill(-p.pid, syscall.SIGKILL) 173 | case err := <-errCh: 174 | if err != nil { 175 | cancel() 176 | } 177 | slog.Debug("Process Errored closing context", "exec", p.Exec) 178 | ctx.Done() 179 | delete(pm.Ctxs, p.Exec) 180 | delete(pm.Cancels, p.Exec) 181 | } 182 | }() 183 | } 184 | if err != nil { 185 | slog.Error("Running Command", "exec", p.Exec, "err", err) 186 | cancel() 187 | } 188 | 189 | // After each process, restore to the original directory 190 | err = os.Chdir(originalDir) 191 | if err != nil { 192 | slog.Error("Failed to restore directory after process", "dir", originalDir, "err", err) 193 | } 194 | } 195 | pm.FirstRun = false 196 | } 197 | 198 | func (pm *ProcessManager) KillProcesses() { 199 | for _, p := range pm.Processes { 200 | if p.pid != 0 { 201 | _, err := os.FindProcess(p.pid) 202 | if err != nil { 203 | continue 204 | } 205 | syscall.Kill(-p.pid, syscall.SIGKILL) 206 | if cancel, ok := pm.Cancels[p.Exec]; ok { 207 | cancel() 208 | } 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /process/process_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package process 4 | 5 | import ( 6 | "context" 7 | "log/slog" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | func (pm *ProcessManager) StartProcess(ctx context.Context, cancel context.CancelFunc) { 16 | pm.mu.Lock() 17 | defer pm.mu.Unlock() 18 | 19 | // Store the original directory to ensure we restore it at the end of function 20 | originalDir, err := os.Getwd() 21 | if err != nil { 22 | slog.Error("Failed to get current working directory", "err", err) 23 | // If we can't get the current directory, use our saved RootDir 24 | originalDir = pm.RootDir 25 | } 26 | 27 | // Ensure we always restore the original directory when this function exits 28 | defer func() { 29 | err := os.Chdir(originalDir) 30 | if err != nil { 31 | slog.Error("Failed to restore original directory", "dir", originalDir, "err", err) 32 | } 33 | }() 34 | 35 | if len(pm.Processes) == 0 { 36 | // slog.Warn("No Processes to Start") 37 | return 38 | } 39 | for _, p := range pm.Processes { 40 | if p.Exec == "KILL_STALE" { 41 | continue 42 | } 43 | if !pm.FirstRun && p.Type == Background { 44 | continue 45 | } 46 | 47 | cmd := generateExec(p.Exec) 48 | p.cmd = cmd 49 | 50 | if p.Type == Primary { 51 | if !pm.FirstRun { 52 | for _, pr := range pm.Processes { 53 | if pr.Type != Background { 54 | // check if pid is running 55 | if pr.pid != 0 { 56 | if _, err := os.FindProcess(pr.pid); err == nil { 57 | if cancel, exists := pm.Cancels[pr.Exec]; exists { 58 | cancel() 59 | delete(pm.Ctxs, pr.Exec) 60 | delete(pm.Cancels, pr.Exec) 61 | } 62 | 63 | time.Sleep(100 * time.Millisecond) 64 | 65 | if err := taskKill(pr.pid); err != nil { 66 | slog.Debug("Failed to kill process", "exec", pr.Exec, "pid", pr.pid, "err", err) 67 | } 68 | } 69 | } 70 | } 71 | } 72 | // slog.Debug("Processes killed") 73 | time.Sleep(200 * time.Millisecond) 74 | } else { 75 | // slog.Debug("First run, not killing processes") 76 | pm.FirstRun = false 77 | } 78 | // Log buffers 79 | } 80 | 81 | // Change to the command's directory if specified 82 | if p.Dir != "" { 83 | targetDir := p.Dir 84 | if !filepath.IsAbs(p.Dir) { 85 | // If relative path, make it relative to RootDir 86 | targetDir = filepath.Join(pm.RootDir, p.Dir) 87 | } 88 | currentDir, _ := os.Getwd() 89 | slog.Debug("Changing directory for process", "from", currentDir, "to", targetDir, "process", p.Exec) 90 | err = os.Chdir(targetDir) 91 | if err != nil { 92 | slog.Error("Failed to change directory", "dir", targetDir, "err", err) 93 | cancel() 94 | continue 95 | } 96 | } 97 | 98 | var err error 99 | if p.Type == Blocking || p.Type == Once { 100 | if p.Type == Once && !pm.FirstRun { 101 | continue 102 | } 103 | cmd.Stderr = os.Stderr 104 | p.logPipe, err = cmd.StdoutPipe() 105 | if err != nil { 106 | slog.Error("Getting stdout pipe", "exec", p.Exec, "err", err) 107 | cancel() 108 | continue 109 | } 110 | 111 | subProcessCtx, subProcessCancel := context.WithCancel(ctx) 112 | go printSubProcess(subProcessCtx, p.logPipe) 113 | 114 | err = cmd.Run() 115 | subProcessCancel() 116 | 117 | if err != nil { 118 | slog.Error("Running Command", "exec", p.Exec, "err", err) 119 | cancel() 120 | continue 121 | } 122 | } else { 123 | cmd.Stderr = os.Stderr 124 | p.logPipe, err = cmd.StdoutPipe() 125 | if err != nil { 126 | slog.Error("Getting Stdout Pipe", "exec", p.Exec, "err", err) 127 | cancel() 128 | continue 129 | } 130 | 131 | err = cmd.Start() 132 | if err != nil { 133 | slog.Error("Starting command", "exec", p.Exec, "err", err) 134 | cancel() 135 | continue 136 | } 137 | 138 | if cmd.Process != nil { 139 | p.pid = cmd.Process.Pid 140 | processCtx, processCancel := context.WithCancel(ctx) 141 | pm.Ctxs[p.Exec] = processCtx 142 | pm.Cancels[p.Exec] = processCancel 143 | 144 | subProcessCtx, subProcessCancel := context.WithCancel(processCtx) 145 | go printSubProcess(subProcessCtx, p.logPipe) 146 | 147 | go func(exec string, pid int, subCancel context.CancelFunc) { 148 | defer subCancel() 149 | 150 | select { 151 | case <-processCtx.Done(): 152 | if err := taskKill(pid); err != nil { 153 | slog.Debug("Failed to kill process after context done", "exec", exec, "pid", pid, "err", err) 154 | } 155 | case <-ctx.Done(): 156 | if err := taskKill(pid); err != nil { 157 | slog.Debug("Failed to kill process after parent context done", "exec", exec, "pid", pid, "err", err) 158 | } 159 | default: 160 | err := cmd.Wait() 161 | if err != nil { 162 | slog.Error("Process exited with error", "exec", exec, "err", err) 163 | cancel() 164 | } 165 | 166 | pm.mu.Lock() 167 | delete(pm.Ctxs, exec) 168 | delete(pm.Cancels, exec) 169 | pm.mu.Unlock() 170 | } 171 | }(p.Exec, p.pid, subProcessCancel) 172 | } else { 173 | slog.Error("Process did not start properly", "exec", p.Exec) 174 | cancel() 175 | continue 176 | } 177 | } 178 | 179 | // After each process, restore to the original directory 180 | err = os.Chdir(originalDir) 181 | if err != nil { 182 | slog.Error("Failed to restore directory after process", "dir", originalDir, "err", err) 183 | } 184 | } 185 | 186 | pm.FirstRun = false 187 | } 188 | 189 | // Window specific kill process 190 | func (pm *ProcessManager) KillProcesses() { 191 | // slog.Debug("Killing Processes") 192 | for _, p := range pm.Processes { 193 | err := taskKill(p.pid) 194 | if err != nil { 195 | // slog.Error("Error killing process", "pid", p.cmd.Process.Pid, "err", err.Error()) 196 | } 197 | } 198 | } 199 | 200 | func taskKill(pid int) error { 201 | kill := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)) 202 | err := kill.Run() 203 | if err != nil { 204 | // slog.Error("Error killing process", "pid", pid, "err", err.Error()) 205 | return err 206 | } 207 | // slog.Debug("Process successfull killed", "pid", pid) 208 | return nil 209 | } 210 | -------------------------------------------------------------------------------- /tui/tui.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | 8 | "github.com/atterpac/refresh/engine" 9 | "github.com/charmbracelet/bubbles/list" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | ) 13 | 14 | func StartTui(withTui bool) { 15 | engine, err := engine.NewEngineFromTOML("./example/example.toml") 16 | if err != nil { 17 | panic(err) 18 | } 19 | if !withTui { 20 | err := engine.Start() 21 | if err != nil { 22 | slog.Error("Refresh has exited", "err", err) 23 | os.Exit(0) 24 | } 25 | } 26 | executes := engine.ProcessManager.GetExecutes() 27 | println("count", len(executes)) 28 | items := make([]list.Item, len(executes)) 29 | for _, execute := range executes { 30 | items = append(items, item{title: execute, desc: "Description"}) 31 | } 32 | m := model{list: list.New(items, list.NewDefaultDelegate(), 0, 5)} 33 | m.list.Title = "Dev Mode Active" 34 | 35 | p := tea.NewProgram(m, tea.WithAltScreen()) 36 | 37 | if _, err := p.Run(); err != nil { 38 | fmt.Println("Error running program:", err) 39 | os.Exit(1) 40 | } 41 | } 42 | 43 | var docStyle = lipgloss.NewStyle().Margin(1, 2) 44 | 45 | type item struct { 46 | title, desc string 47 | } 48 | 49 | func (i item) Title() string { return i.title } 50 | func (i item) Description() string { return i.desc } 51 | func (i item) FilterValue() string { return i.title } 52 | 53 | type model struct { 54 | list list.Model 55 | } 56 | 57 | func (m model) Init() tea.Cmd { 58 | return nil 59 | } 60 | 61 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 62 | switch msg := msg.(type) { 63 | case tea.KeyMsg: 64 | if msg.String() == "ctrl+c" { 65 | return m, tea.Quit 66 | } 67 | case tea.WindowSizeMsg: 68 | h, v := docStyle.GetFrameSize() 69 | m.list.SetSize(msg.Width-h, msg.Height-v) 70 | } 71 | 72 | var cmd tea.Cmd 73 | m.list, cmd = m.list.Update(msg) 74 | return m, cmd 75 | } 76 | 77 | func (m model) View() string { 78 | return docStyle.Render(m.list.View()) 79 | } 80 | --------------------------------------------------------------------------------