github.com/swaros/contxt/module/runner@v0.0.0-20240305083542-3dbd4436ac40/ctxshell.go (about) 1 // Copyright (c) 2023 Thomas Ziegler <thomas.zglr@googlemail.com>. All rights reserved. 2 // 3 // # Licensed under the MIT License 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 package runner 23 24 import ( 25 "os" 26 "sync" 27 "time" 28 29 "github.com/abiosoft/readline" 30 "github.com/swaros/contxt/module/ctxout" 31 "github.com/swaros/contxt/module/ctxshell" 32 "github.com/swaros/contxt/module/dirhandle" 33 "github.com/swaros/contxt/module/process" 34 "github.com/swaros/contxt/module/systools" 35 "github.com/swaros/contxt/module/tasks" 36 ) 37 38 const ( 39 ModusInit = 1 40 ModusRun = 2 41 ModusTask = 3 42 ModusIdle = 4 43 ) 44 45 var ( 46 WhiteBlue = "" 47 Black = "" 48 Blue = "" 49 Prompt = "" 50 ProgressBar = "" 51 Lc = "" 52 OkSign = "" 53 MesgStartCol = "" 54 MesgErrorCol = "" 55 Yellow = "" 56 57 CurrentLabelSize = 0 58 NeededLabelSize = 0 59 BaseLabelSize = 10 60 MaxeLabelSize = 40 61 ) 62 63 type CtxShell struct { 64 cmdSession *CmdExecutorImpl 65 shell *ctxshell.Cshell 66 Modus int 67 MaxTasks int 68 CollectedTasks []string 69 SynMutex sync.Mutex 70 LabelForeColor string 71 LabelBackColor string 72 bashRunner *process.Process 73 once bool 74 } 75 76 func initVars() { 77 WhiteBlue = ctxout.ToString(ctxout.NewMOWrap(), ctxout.ForeWhite+ctxout.BackBlue) 78 Black = ctxout.ToString(ctxout.NewMOWrap(), ctxout.ForeBlack) 79 Blue = ctxout.ToString(ctxout.NewMOWrap(), ctxout.ForeBlue) 80 Prompt = ctxout.ToString("<sign prompt>") 81 ProgressBar = ctxout.ToString("<sign pbar>") 82 Lc = ctxout.ToString(ctxout.NewMOWrap(), ctxout.CleanTag) 83 OkSign = ctxout.ToString(ctxout.BaseSignSuccess) 84 MesgStartCol = ctxout.ToString(ctxout.NewMOWrap(), ctxout.ForeLightBlue, ctxout.BackBlack) 85 MesgErrorCol = ctxout.ToString(ctxout.NewMOWrap(), ctxout.ForeLightRed, ctxout.BackBlack) 86 Yellow = ctxout.ToString(ctxout.NewMOWrap(), ctxout.ForeYellow) 87 } 88 89 func shellRunner(c *CmdExecutorImpl) *CtxShell { 90 // init the vars 91 initVars() 92 93 // run the context shell 94 shell := ctxshell.NewCshell() 95 shellHandler := &CtxShell{ 96 cmdSession: c, 97 shell: shell, 98 Modus: ModusInit, 99 MaxTasks: 0, 100 LabelForeColor: ctxout.ForeBlue, 101 LabelBackColor: ctxout.BackWhite, 102 bashRunner: process.NewTerminal(), 103 } 104 105 // add cobra commands to the shell, so they can be used there too. 106 // first we need to define the exceptions 107 // we do not want to have in the menu 108 shell.SetIgnoreCobraCmd("completion", "interactive") 109 // afterwards we can add the commands by injecting the root command 110 shell.SetCobraRootCommand(c.session.Cobra.RootCmd) 111 112 // set behavior on exit 113 shell.OnShutDownFunc(func() { 114 shellHandler.bashRunner.Stop() // this we will stop anyway 115 // but if we runce once anyway, we will ignore the rest 116 // because we are shutting down anyway 117 if shellHandler.once { 118 return 119 } 120 ctxout.PrintLn(ctxout.NewMOWrap(), ctxout.ForeBlue, "shutting down triggered by shell ...", ctxout.CleanTag) 121 shellHandler.stopTasks([]string{}) 122 }) 123 124 shell.OnUnknownCmdFunc(func(cmd string) error { 125 msg := ctxout.ToString(ctxout.NewMOWrap(), ctxout.ForeBlue, "unknown command: ", ctxout.ForeCyan, cmd, ctxout.ForeBlue, " - try to execute it in bash", ctxout.CleanTag) 126 shellHandler.shell.Stdoutln(msg) 127 return shellHandler.bashRunner.Command(cmd) 128 }) 129 130 // rename the exit command to quit 131 shell.SetExitCmdStr("exit") 132 133 // some of the commands are not working well async, because they 134 // are switching between workspaces. so we have to disable async 135 // for them 136 shell.SetNeverAsyncCmd("workspace") 137 138 // capture ctrl+z and do nothing, so we will not send to the background 139 shell.AddKeyBinding(readline.CharCtrlZ, func() bool { return false }) 140 141 // add task clean command. 142 // this will reset all the watchman stored tasks, depending running or not. 143 // this makes it possible to re run a task togehther with the the needs 144 cleanTasksCmd := ctxshell.NewNativeCmd("taskreset", "resets all tasks", func(args []string) error { 145 return tasks.NewGlobalWatchman().ResetAllTasksIfPossible() 146 }) 147 cleanTasksCmd.SetCompleterFunc(func(line string) []string { 148 return []string{"taskreset"} 149 }) 150 shell.AddNativeCmd(cleanTasksCmd) 151 152 // add the showpath cmd 153 showPathCmd := ctxshell.NewNativeCmd("showpath", "shows the current path", func(args []string) error { 154 if dir, err := dirhandle.Current(); err == nil { 155 shell.Stdoutln(dir) 156 } else { 157 shell.Stderrln(err.Error()) 158 } 159 return nil 160 }) 161 showPathCmd.SetCompleterFunc(func(line string) []string { 162 return []string{"showpath"} 163 }) 164 shell.AddNativeCmd(showPathCmd) 165 166 // add task stop command 167 stoppAllCmd := ctxshell.NewNativeCmd("stoptasks", "stop all the running processes", shellHandler.stopTasks) 168 stoppAllCmd.SetCompleterFunc(func(line string) []string { 169 return []string{"stoptasks"} 170 }) 171 shell.AddNativeCmd(stoppAllCmd) 172 173 // set the prompt handler 174 shell.SetPromptFunc(func(reason int) string { 175 176 label := "" 177 // in idle or init mode we display the current directory 178 if shellHandler.Modus == ModusIdle || shellHandler.Modus == ModusInit { 179 if dir, err := dirhandle.Current(); err == nil { 180 label += dir 181 } else { 182 label += err.Error() 183 } 184 } 185 return shellHandler.PromtDraw(reason, label) 186 187 }) 188 189 // set the hooks.for any command we want to change the directory to the basepath 190 pathHook := ctxshell.NewHook(".*", func() error { 191 basePath := shellHandler.cmdSession.GetVariable("BASEPATH") 192 if basePath != "" { 193 return os.Chdir(shellHandler.cmdSession.GetVariable("BASEPATH")) 194 } 195 return nil 196 }, nil) 197 shell.AddHook(pathHook) 198 199 // rebind the the session output handler 200 // so any output will be handled by the shell 201 c.session.OutPutHdnl = shell 202 // start the shell 203 204 // start the background shell 205 shellHandler.bashRunner.SetOnOutput(func(output string, err error) bool { 206 if err != nil { 207 msg := ctxout.ToString(ctxout.NewMOWrap(), ctxout.ForeRed, "error: ", ctxout.ForeYellow, output, ctxout.ForeBlue, ctxout.CleanTag) 208 shellHandler.shell.Stdoutln(msg) 209 return true 210 } 211 shellHandler.shell.Stdoutln(output) 212 return true 213 }) 214 shellHandler.bashRunner.SetLogger(c.GetLogger()) 215 shellHandler.bashRunner.SetReportChildCount(true) 216 shellHandler.bashRunner.SetKeepRunning(true) 217 if _, _, err := shellHandler.bashRunner.Exec(); err != nil { 218 ctxout.PrintLn(ctxout.NewMOWrap(), ctxout.ForeRed, "failed to start background shell: ", err.Error()) 219 220 } 221 222 return shellHandler 223 } 224 225 func (cs *CtxShell) runAsShell() error { 226 return cs.shell.SetAsyncCobraExec(true). 227 SetAsyncNativeCmd(true). 228 UpdatePromptEnabled(true). 229 UpdatePromptPeriod(1 * time.Second). 230 Run() 231 } 232 233 func (cs *CtxShell) runWithCmds(cmd []string, timeOutMilliSecs int) error { 234 cs.once = true 235 err := cs.shell.SetAsyncCobraExec(true). 236 SetAsyncNativeCmd(true). 237 UpdatePromptEnabled(false). 238 RunOnce(cmd) 239 if err != nil { 240 return err 241 } 242 // first wait if there any task about to start 243 cs.shell.Stdoutln(ctxout.ToString(ctxout.NewMOWrap(), ctxout.ForeDarkGrey, " ---- waiting for tasks to start...")) 244 if tasks.NewGlobalWatchman().ExpectTaskToStart(100*time.Millisecond, 100) { 245 cs.shell.Stdoutln(ctxout.ToString(ctxout.NewMOWrap(), ctxout.ForeDarkGrey, " ---- waiting for tasks to finish...", timeOutMilliSecs)) 246 if !tasks.NewGlobalWatchman().UntilDone(500*time.Millisecond, time.Duration(timeOutMilliSecs)*time.Millisecond) { 247 cs.shell.Stdoutln(ctxout.ToString(ctxout.NewMOWrap(), ctxout.ForeRed, " ---- timeout reached, stopping tasks...")) 248 return cs.stopTasks([]string{}) 249 } 250 251 } 252 253 return tasks.NewGlobalWatchman().ResetAllTasksIfPossible() 254 } 255 256 // stop all the running processes 257 // and kill all the running processes 258 func (cs *CtxShell) stopTasks(args []string) error { 259 ctxshell.NewCshell().SetStopOutput(true) 260 tasks.NewGlobalWatchman().StopAllTasks(func(target string, time int, succeed bool) { 261 if succeed { 262 ctxout.PrintLn(ctxout.NewMOWrap(), ctxout.ForeDarkGrey, "stopped process: ", ctxout.ForeGreen, target) 263 } else { 264 ctxout.PrintLn(ctxout.NewMOWrap(), ctxout.ForeRed, "failed to stop processes: ", ctxout.ForeWhite, target) 265 } 266 }) 267 ctxout.PrintLn(ctxout.NewMOWrap(), ctxout.CleanTag) 268 ctxshell.NewCshell().SetStopOutput(false) 269 tasks.HandleAllMyPid(func(pid int) error { 270 ctxout.PrintLn(ctxout.NewMOWrap(), ctxout.ForeDarkGrey, "killing process: ", ctxout.ForeBlue, pid) 271 if proc, err := os.FindProcess(pid); err == nil { 272 if err := proc.Kill(); err != nil { 273 return err 274 } else { 275 ctxout.PrintLn(ctxout.NewMOWrap(), ctxout.ForeGreen, "killed process: ", pid) 276 } 277 } else { 278 ctxout.PrintLn(ctxout.NewMOWrap(), ctxout.ForeRed, "failed to kill process: ", pid) 279 return err 280 } 281 return nil 282 }) 283 ctxout.PrintLn(ctxout.NewMOWrap(), ctxout.CleanTag) 284 return nil 285 } 286 287 // adds an additonial task label to the prompt and increases the prompt update period 288 // if there are running tasks. 289 // if no tasks are running, the prompt update period will be set to 1 second. 290 // also it sets the mode to ModusTask if any tasks are running. 291 // returns the new label and a bool if there are any tasks running 292 func (cs *CtxShell) autoSetLabel(label string) (string, bool) { 293 watchers := tasks.ListWatcherInstances() 294 taskCount := 0 295 296 // this is only saying, we have some watchers found. it is not saying, that there are any tasks running 297 // for this we have to check the watchers one by one 298 cs.shell.SetNoMessageDuplication(true) // we will spam a lot of messages, so we do not want to have duplicates 299 if len(watchers) > 0 { 300 taskBar := Yellow + "running tasks: " 301 for _, watcher := range watchers { 302 watchMan := tasks.GetWatcherInstance(watcher) 303 if watchMan != nil { 304 allRunnungs := watchMan.GetAllRunningTasks() 305 if len(allRunnungs) > 0 { 306 taskCount += len(allRunnungs) 307 // add the tasks to the collected tasks they are not already in 308 for _, task := range allRunnungs { 309 if !systools.StringInSlice(task, cs.CollectedTasks) { 310 cs.CollectedTasks = append(cs.CollectedTasks, task) 311 } 312 } 313 } 314 // build the taskbar 315 316 doneChar := OkSign 317 for in, task := range cs.CollectedTasks { 318 if watchMan.TaskRunning(task) { 319 runningChar := cs.getABraillCharByTime(in) 320 taskBar += ctxout.ForeWhite + runningChar 321 } else { 322 taskBar += ctxout.ForeBlack + doneChar 323 } 324 } 325 } 326 } 327 // do we have any tasks running? 328 if taskCount > 0 { 329 cs.shell.UpdatePromptPeriod(100 * time.Millisecond) 330 label += taskBar 331 cs.LabelForeColor = ctxout.ForeWhite 332 cs.LabelBackColor = ctxout.BackDarkGrey 333 cs.Modus = ModusTask 334 label = ctxout.ToString(ctxout.NewMOWrap(), label) 335 return cs.fitStringLen(label, ctxout.ToString("t", taskCount)), true 336 } else { 337 // no tasks running, so reset the all the task related stuff 338 cs.shell.UpdatePromptPeriod(1 * time.Second) 339 cs.LabelForeColor = ctxout.ForeBlue 340 cs.LabelBackColor = ctxout.BackWhite 341 cs.MaxTasks = 0 342 cs.Modus = ModusIdle 343 cs.CollectedTasks = []string{} 344 } 345 } 346 return cs.fitStringLen(label, ""), false 347 348 } 349 350 // fit the string length to the half of the terminal width, if an fallback is set, it will be returned 351 func (cs *CtxShell) fitStringLen(label string, fallBack string) string { 352 w, _, err := systools.GetStdOutTermSize() 353 if err != nil { 354 w = 80 355 } 356 maxLen := int(float64(w)*0.33) - (BaseLabelSize + CurrentLabelSize) // max is one third of the screen minus current label size 357 if systools.StrLen(systools.NoEscapeSequences(label)) > maxLen { 358 // if fallback is set, we return it 359 if fallBack != "" { 360 return fallBack 361 } 362 // if no fallback is set, we reduce the label 363 return systools.StringSubRight(label, maxLen) 364 365 } 366 return systools.FillString(" ", maxLen-systools.StrLen(systools.NoEscapeSequences(label))) + label 367 } 368 369 // a braille char 370 // depending on the milliseconds of the current time 371 func (cs *CtxShell) getABraillCharByTime(offset int) string { 372 braillTableString := ProgressBar 373 braillTable := []rune(braillTableString) 374 millis := time.Now().UnixNano() / int64(time.Millisecond) 375 millis += int64(offset) 376 index := int(millis % int64(len(braillTable))) 377 return string(braillTable[index]) 378 } 379 380 func (cs *CtxShell) calcLabelNeeds(forString string) int { 381 len := systools.StrLen(systools.NoEscapeSequences(forString)) 382 if len > BaseLabelSize { 383 NeededLabelSize = len - BaseLabelSize 384 } else { 385 NeededLabelSize = 0 386 } 387 388 // some changes to the label size needed? 389 // if so, then mae the updates faster 390 if CurrentLabelSize < NeededLabelSize { 391 CurrentLabelSize++ 392 cs.shell.UpdatePromptPeriod(100 * time.Millisecond) 393 } else if CurrentLabelSize > NeededLabelSize { 394 CurrentLabelSize-- 395 cs.shell.UpdatePromptPeriod(100 * time.Millisecond) 396 } 397 return BaseLabelSize + CurrentLabelSize 398 } 399 400 // returns the prompt for linux. 401 func (cs *CtxShell) PromtDraw(reason int, label string) string { 402 label, _ = cs.autoSetLabel(label) 403 // display the current time in the prompt 404 // this is just for testing 405 406 timeNowAsString := time.Now().Format("15:04:05") 407 MessageColor := WhiteBlue 408 if reason == ctxshell.UpdateByNotify { 409 if found, msg := cs.shell.GetCurrentMessage(); found { 410 msgString := systools.PadStringToR(msg.GetMsg(), cs.calcLabelNeeds(msg.GetMsg())) 411 if msg.GetTopic() != ctxshell.TopicError { 412 // not an error 413 timeNowAsString = MesgStartCol + msgString + " " 414 } else { 415 timeNowAsString = MesgErrorCol + msgString + " " 416 } 417 // any time we have a message, we force to a faster update period 418 cs.shell.UpdatePromptPeriod(100 * time.Millisecond) 419 } 420 } else { 421 timeNowAsString = systools.PadStringToR(timeNowAsString, cs.calcLabelNeeds(timeNowAsString)) 422 } 423 424 return ctxout.ToString( 425 ctxout.NewMOWrap(), 426 MessageColor, 427 Prompt, 428 timeNowAsString, 429 " ", 430 cs.LabelForeColor, 431 cs.LabelBackColor, 432 label, 433 WhiteBlue, 434 Prompt, 435 "ctx", 436 Black, 437 ":", 438 Lc, 439 Blue, 440 Prompt, 441 Lc, 442 " ", 443 ) 444 }