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  }