From 96bb2eb1a51b53122cbdb2d5f7e1a3c7e3ef52ee Mon Sep 17 00:00:00 2001 From: samiralajmovic Date: Sat, 30 May 2026 12:01:50 +0200 Subject: [PATCH 1/2] Start tui work --- Makefile | 6 +- cmd/root.go | 1 + cmd/run.go | 8 +- cmd/tui.go | 37 ++ core/dao/config.go | 2 + core/dao/import_config.go | 14 +- core/run/text.go | 623 ++++++++++++++++++++++++ core/tui/components/tui_button.go | 37 ++ core/tui/components/tui_checkbox.go | 56 +++ core/tui/components/tui_filter.go | 37 ++ core/tui/components/tui_list.go | 207 ++++++++ core/tui/components/tui_modal.go | 187 ++++++++ core/tui/components/tui_output.go | 116 +++++ core/tui/components/tui_search.go | 173 +++++++ core/tui/components/tui_table.go | 298 ++++++++++++ core/tui/components/tui_toggle.go | 142 ++++++ core/tui/components/tui_tree.go | 560 ++++++++++++++++++++++ core/tui/misc/tui_block.go | 197 ++++++++ core/tui/misc/tui_event.go | 55 +++ core/tui/misc/tui_focus.go | 85 ++++ core/tui/misc/tui_global.go | 30 ++ core/tui/misc/tui_theme.go | 261 ++++++++++ core/tui/misc/tui_utils.go | 68 +++ core/tui/misc/tui_writer.go | 28 ++ core/tui/pages.go | 131 +++++ core/tui/pages/tui_exec.go | 554 +++++++++++++++++++++ core/tui/pages/tui_run.go | 720 ++++++++++++++++++++++++++++ core/tui/pages/tui_server.go | 156 ++++++ core/tui/pages/tui_task.go | 134 ++++++ core/tui/tui.go | 70 +++ core/tui/tui_input.go | 173 +++++++ core/tui/views/tui_help.go | 148 ++++++ core/tui/views/tui_server_view.go | 678 ++++++++++++++++++++++++++ core/tui/views/tui_shortcut_info.go | 88 ++++ core/tui/views/tui_spec_view.go | 158 ++++++ core/tui/views/tui_task_view.go | 362 ++++++++++++++ core/tui/watcher.go | 41 ++ go.mod | 6 +- go.sum | 6 - 39 files changed, 6637 insertions(+), 16 deletions(-) create mode 100644 cmd/tui.go create mode 100644 core/tui/components/tui_button.go create mode 100644 core/tui/components/tui_checkbox.go create mode 100644 core/tui/components/tui_filter.go create mode 100644 core/tui/components/tui_list.go create mode 100644 core/tui/components/tui_modal.go create mode 100644 core/tui/components/tui_output.go create mode 100644 core/tui/components/tui_search.go create mode 100644 core/tui/components/tui_table.go create mode 100644 core/tui/components/tui_toggle.go create mode 100644 core/tui/components/tui_tree.go create mode 100644 core/tui/misc/tui_block.go create mode 100644 core/tui/misc/tui_event.go create mode 100644 core/tui/misc/tui_focus.go create mode 100644 core/tui/misc/tui_global.go create mode 100644 core/tui/misc/tui_theme.go create mode 100644 core/tui/misc/tui_utils.go create mode 100644 core/tui/misc/tui_writer.go create mode 100644 core/tui/pages.go create mode 100644 core/tui/pages/tui_exec.go create mode 100644 core/tui/pages/tui_run.go create mode 100644 core/tui/pages/tui_server.go create mode 100644 core/tui/pages/tui_task.go create mode 100644 core/tui/tui.go create mode 100644 core/tui/tui_input.go create mode 100644 core/tui/views/tui_help.go create mode 100644 core/tui/views/tui_server_view.go create mode 100644 core/tui/views/tui_shortcut_info.go create mode 100644 core/tui/views/tui_spec_view.go create mode 100644 core/tui/views/tui_task_view.go create mode 100644 core/tui/watcher.go diff --git a/Makefile b/Makefile index e3d7fb6..23206d3 100644 --- a/Makefile +++ b/Makefile @@ -36,10 +36,10 @@ test: go test -v ./test/integration/... -count=5 -clean cd ./test && docker compose down -unit-test: +test-unit: go test -v ./core/... -integration-test: +test-integration: go test -v ./test/integration/... -clean update-golden-files: @@ -74,4 +74,4 @@ release: clean: $(RM) -r dist target -.PHONY: tidy gofmt lint benchmark benchmark-save test unit-test integration-test update-golden-files mock-ssh build build-all build-and-link gen-man release clean +.PHONY: tidy gofmt lint benchmark benchmark-save test test-unit test-integration update-golden-files mock-ssh build build-all build-and-link gen-man release clean diff --git a/cmd/root.go b/cmd/root.go index ae698be..b80a384 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -74,6 +74,7 @@ func init() { execCmd(&config, &configErr), sshCmd(&config, &configErr), editCmd(&config, &configErr), + tuiCmd(&config, &configErr), checkCmd(&configErr), completionCmd(), genCmd(), diff --git a/cmd/run.go b/cmd/run.go index 756c1f0..75eaff0 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -321,13 +321,15 @@ func runTask( core.CheckIfError(err) } } else { + err := config.ParseInventory(userArgs) + core.CheckIfError(err) + + // If many tasks are provided: + // for _, taskID := range taskIDs { task, err := config.GetTask(taskID) core.CheckIfError(err) - err = config.ParseInventory(userArgs) - core.CheckIfError(err) - servers, err := config.GetTaskServers(task, runFlags, setRunFlags) core.CheckIfError(err) diff --git a/cmd/tui.go b/cmd/tui.go new file mode 100644 index 0000000..729102f --- /dev/null +++ b/cmd/tui.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/alajmo/sake/core" + "github.com/alajmo/sake/core/dao" + "github.com/alajmo/sake/core/tui" +) + +func tuiCmd(config *dao.Config, configErr *error) *cobra.Command { + var reload bool + + cmd := cobra.Command{ + Use: "tui", + Short: "Open interactive TUI", + Long: `Open an interactive terminal user interface for browsing servers, tasks, and executing commands.`, + Example: ` # Open TUI + sake tui + + # Open TUI with auto-reload on config changes + sake tui --reload`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + core.CheckIfError(*configErr) + + tui.RunTui(config, reload) + }, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveNoFileComp + }, + } + + cmd.Flags().BoolVar(&reload, "reload", false, "auto-reload on config file changes") + + return &cmd +} diff --git a/core/dao/config.go b/core/dao/config.go index 9bd3e5e..32f6b55 100644 --- a/core/dao/config.go +++ b/core/dao/config.go @@ -564,6 +564,8 @@ tasks: return servers, nil } +// ParseInventory processes server configurations and evaluates any inventory scripts +// to generate the final list of server configurations. func (c *Config) ParseInventory(userArgs []string) error { var servers []Server var shell = DEFAULT_SHELL diff --git a/core/dao/import_config.go b/core/dao/import_config.go index 689896a..00d3644 100644 --- a/core/dao/import_config.go +++ b/core/dao/import_config.go @@ -428,7 +428,19 @@ func (c *ConfigYAML) loadResources(cr *ConfigResources) { cr.ConfigErrors = append(cr.ConfigErrors, configError) } else { servers, serverErrors := c.ParseServersYAML() - cr.Servers = append(cr.Servers, servers...) + // Only add servers that don't already exist (avoid duplicates from imports) + for _, server := range servers { + exists := false + for _, existing := range cr.Servers { + if existing.Name == server.Name { + exists = true + break + } + } + if !exists { + cr.Servers = append(cr.Servers, server) + } + } cr.ServerErrors = append(cr.ServerErrors, serverErrors...) } } diff --git a/core/run/text.go b/core/run/text.go index 4a6fbcb..7ca29a3 100644 --- a/core/run/text.go +++ b/core/run/text.go @@ -916,3 +916,626 @@ func printCmd(prefix string, cmd string) { fmt.Printf("%s%s\n", prefix, scanner.Text()) } } + +// TextTUI runs tasks and writes output to provided writers instead of os.Stdout/os.Stderr +func (run *Run) TextTUI(dryRun bool, stdout io.Writer, stderr io.Writer) (dao.ReportData, error) { + task := run.Task + servers := run.Servers + uServers := run.UnreachableServers + + prefixMaxLen, perr := calcMaxPrefixLength(run.RemoteClients, *task) + if perr != nil { + return dao.ReportData{}, perr + } + + var reportData dao.ReportData + reportData.Headers = append(reportData.Headers, "server") + for _, subTask := range task.Tasks { + reportData.Headers = append(reportData.Headers, subTask.Name) + } + for i, p := range servers { + reportData.Tasks = append(reportData.Tasks, dao.ReportRow{Name: p.Host, Rows: []dao.Report{}}) + for range task.Tasks { + reportData.Tasks[i].Rows = append(reportData.Tasks[i].Rows, dao.Report{}) + } + } + + k := len(servers) + for i, p := range uServers { + reportData.Tasks = append(reportData.Tasks, dao.ReportRow{Name: p.Host, Rows: []dao.Report{}}) + for range task.Tasks { + reportData.Tasks[k+i].Rows = append(reportData.Tasks[k+i].Rows, dao.Report{Status: dao.Unreachable}) + } + } + + var err error + switch task.Spec.Strategy { + case "free": + err = run.freeTextTUI(prefixMaxLen, reportData, dryRun, stdout, stderr) + case "host_pinned": + err = run.hostPinnedTextTUI(prefixMaxLen, reportData, dryRun, stdout, stderr) + default: // linear + err = run.linearTextTUI(prefixMaxLen, reportData, dryRun, stdout, stderr) + } + + reportData.Status = make(map[dao.TaskStatus]int, 5) + for i := range reportData.Tasks { + reportData.Tasks[i].Status = make(map[dao.TaskStatus]int, 5) + for j := range reportData.Tasks[i].Rows { + if reportData.Tasks[i].Rows[j].Status == dao.Unreachable { + status := reportData.Tasks[i].Rows[j].Status + reportData.Tasks[i].Status[status] = 1 + reportData.Status[status] += 1 + break + } else { + status := reportData.Tasks[i].Rows[j].Status + reportData.Tasks[i].Status[status] += 1 + reportData.Status[status] += 1 + } + } + } + + if err != nil && run.Task.Spec.AnyErrorsFatal { + switch err := err.(type) { + case *ssh.ExitError: + return reportData, &core.ExecError{Err: err, ExitCode: err.ExitStatus()} + case *exec.ExitError: + return reportData, &core.ExecError{Err: err, ExitCode: err.ExitCode()} + default: + return reportData, err + } + } + + return reportData, nil +} + +func (run *Run) linearTextTUI( + prefixMaxLen int, + reportData dao.ReportData, + dryRun bool, + stdout io.Writer, + stderr io.Writer, +) error { + serverLen := len(run.Servers) + taskLen := len(run.Task.Tasks) + batch := int(run.Task.Spec.Batch) + forks := CalcForks(batch, run.Task.Spec.Forks) + maxFailPercentage := run.Task.Spec.MaxFailPercentage + + register := make(map[string]map[string]string) + for i := range run.Servers { + register[run.Servers[i].Name] = map[string]string{} + } + var runs []ServerTask + for i := range run.Task.Tasks { + for j := range run.Servers { + runs = append(runs, ServerTask{ + Server: &run.Servers[j], + Task: run.Task, + Cmd: &run.Task.Tasks[i], + i: j, + j: i, + }) + } + } + + quotient, remainder := serverLen/batch, serverLen%batch + + if remainder > 0 { + quotient += 1 + } + numFailed := 0 + failedHosts := make(map[string]bool, serverLen) + waitChan := make(chan struct{}, forks) + for t := 0; t < taskLen; t++ { + var wg sync.WaitGroup + + errCh := make(chan error, serverLen) + + if run.Task.Theme.Text.Header != "" { + if t > 0 { + fmt.Fprintln(stdout) + } + err := printTaskHeaderTUI(t, taskLen, run.Task.Tasks[t].Name, run.Task.Tasks[t].Desc, run.Task.Theme.Text, stdout) + if err != nil { + return err + } + fmt.Fprintln(stdout) + } + + for k := 0; k < quotient; k++ { + failedHostsCh := make(chan struct { + string + bool + }, batch) + + start := t*serverLen + k*batch + end := start + batch + + if end > (t+1)*serverLen { + end = start + remainder + } + + for _, r := range runs[start:end] { + if failedHosts[r.Server.Name] { + continue + } + + waitChan <- struct{}{} + + wg.Add(1) + + go func( + r ServerTask, + register map[string]string, + errCh chan<- error, + wg *sync.WaitGroup, + ) { + defer wg.Done() + + err := run.textWorkTUI(r, 0, register, prefixMaxLen, reportData, dryRun, batch, stdout, stderr) + <-waitChan + if err != nil { + errCh <- err + failedHostsCh <- struct { + string + bool + }{r.Server.Name, true} + } else { + failedHostsCh <- struct { + string + bool + }{r.Server.Name, false} + } + }(r, register[r.Server.Name], errCh, &wg) + } + + wg.Wait() + + close(failedHostsCh) + for p := range failedHostsCh { + failedHosts[p.string] = p.bool + if p.bool { + numFailed += 1 + } + } + + percentageFailed := uint8(math.Floor(float64(numFailed) / float64(serverLen) * 100)) + if percentageFailed > maxFailPercentage { + close(errCh) + return <-errCh + } + } + + close(errCh) + } + + return nil +} + +func (run *Run) freeTextTUI( + prefixMaxLen int, + reportData dao.ReportData, + dryRun bool, + stdout io.Writer, + stderr io.Writer, +) error { + serverLen := len(run.Servers) + taskLen := len(run.Task.Tasks) + batch := int(run.Task.Spec.Batch) + maxFailPercentage := run.Task.Spec.MaxFailPercentage + forks := CalcForks(batch, run.Task.Spec.Forks) + + register := make(map[string]map[string]string) + var runs []ServerTask + for i := range run.Servers { + register[run.Servers[i].Name] = map[string]string{} + for j := range run.Task.Tasks { + runs = append(runs, ServerTask{ + Server: &run.Servers[i], + Task: run.Task, + Cmd: &run.Task.Tasks[j], + i: i, + j: j, + }) + } + } + + quotient, remainder := serverLen/batch, serverLen%batch + + if remainder > 0 { + quotient += 1 + } + + failedHosts := make(chan bool, serverLen*taskLen) + var mu sync.Mutex + waitChan := make(chan struct{}, forks) + for k := 0; k < quotient; k++ { + var wg sync.WaitGroup + errCh := make(chan error, batch*taskLen) + + start := k * batch * taskLen + end := start + batch*taskLen + + if end > serverLen*taskLen { + end = start + remainder*taskLen + } + + for i := range runs[start:end] { + wg.Add(1) + + go func( + r ServerTask, + register map[string]string, + errCh chan<- error, + failedHosts chan<- bool, + wg *sync.WaitGroup, + ) { + defer wg.Done() + waitChan <- struct{}{} + + mu.Lock() + err := run.textWorkTUI(r, r.j, register, prefixMaxLen, reportData, dryRun, batch, stdout, stderr) + mu.Unlock() + + <-waitChan + if err != nil { + errCh <- err + failedHosts <- true + } + }(runs[start+i], register[runs[start+i].Server.Name], errCh, failedHosts, &wg) + } + + wg.Wait() + + percentageFailed := uint8(math.Floor(float64(len(failedHosts)) / float64(serverLen) * 100)) + if percentageFailed > maxFailPercentage { + close(errCh) + return <-errCh + } + + close(errCh) + } + + return nil +} + +func (run *Run) hostPinnedTextTUI( + prefixMaxLen int, + reportData dao.ReportData, + dryRun bool, + stdout io.Writer, + stderr io.Writer, +) error { + serverLen := len(run.Servers) + taskLen := len(run.Task.Tasks) + batch := int(run.Task.Spec.Batch) + forks := CalcForks(batch, run.Task.Spec.Forks) + maxFailPercentage := run.Task.Spec.MaxFailPercentage + + register := make(map[string]map[string]string) + var runs []ServerTask + for i := range run.Servers { + register[run.Servers[i].Name] = map[string]string{} + for j := range run.Task.Tasks { + runs = append(runs, ServerTask{ + Server: &run.Servers[i], + Task: run.Task, + Cmd: &run.Task.Tasks[j], + i: i, + j: j, + }) + } + } + + quotient, remainder := serverLen/batch, serverLen%batch + + if remainder > 0 { + quotient += 1 + } + + failedHosts := make(chan bool, serverLen) + waitChan := make(chan struct{}, forks) + var mu sync.Mutex + for k := 0; k < quotient; k++ { + var wg sync.WaitGroup + errCh := make(chan error, batch) + + start := k * batch * taskLen + end := start + batch*taskLen + + if end > serverLen*taskLen { + end = start + remainder*taskLen + } + + for t := start; t < end; t = t + taskLen { + wg.Add(1) + go func( + r []ServerTask, + register map[string]map[string]string, + errCh chan<- error, + failedHosts chan<- bool, + wg *sync.WaitGroup, + ) { + defer wg.Done() + for i, j := range r { + waitChan <- struct{}{} + + if run.Task.Theme.Text.Header != "" && batch == 1 { + mu.Lock() + fmt.Fprintln(stdout) + err := printTaskHeaderTUI(i, taskLen, j.Cmd.Name, j.Cmd.Desc, run.Task.Theme.Text, stdout) + fmt.Fprintln(stdout) + mu.Unlock() + if err != nil { + <-waitChan + errCh <- err + failedHosts <- true + break + } + } + + err := run.textWorkTUI(j, 0, register[j.Server.Name], prefixMaxLen, reportData, dryRun, batch, stdout, stderr) + <-waitChan + if err != nil { + errCh <- err + failedHosts <- true + break + } + } + }(runs[t:t+taskLen], register, errCh, failedHosts, &wg) + } + + wg.Wait() + + percentageFailed := uint8(math.Floor(float64(len(failedHosts)) / float64(serverLen) * 100)) + if percentageFailed > maxFailPercentage { + close(errCh) + return <-errCh + } + + close(errCh) + } + + return nil +} + +func (run *Run) textWorkTUI( + r ServerTask, + si int, + register map[string]string, + prefixMaxLen int, + reportData dao.ReportData, + dryRun bool, + batch int, + stdout io.Writer, + stderr io.Writer, +) error { + numTasks := len(r.Task.Tasks) + + var registerEnvs []string + for k, v := range register { + envStdout := fmt.Sprintf("%v=%v", k, v) + registerEnvs = append(registerEnvs, envStdout) + } + combinedEnvs := dao.MergeEnvs(r.Cmd.Envs, r.Server.Envs, registerEnvs) + var client Client + if r.Cmd.Local || r.Server.Local { + client = run.LocalClients[r.Server.Name] + } else { + client = run.RemoteClients[r.Server.Name] + } + + prefix, err := getPrefixer(client, r.i, prefixMaxLen, r.Task.Theme.Text, batch) + if err != nil { + return err + } + + shell := dao.SelectFirstNonEmpty((*r.Cmd).Shell, r.Task.Shell, r.Server.Shell, run.Config.Shell) + shell = core.FormatShell(shell) + workDir := getWorkDir((*r.Cmd).Local, (*r.Server).Local, (*r.Cmd).WorkDir, (*r.Server).WorkDir, (*r.Cmd).RootDir, (*r.Server).RootDir) + t := TaskContext{ + rIndex: r.i, + cIndex: r.j, + client: client, + dryRun: dryRun, + env: combinedEnvs, + workDir: workDir, + shell: shell, + cmd: r.Cmd.Cmd, + desc: r.Cmd.Desc, + name: r.Cmd.Name, + numTasks: numTasks, + tty: r.Cmd.TTY, + print: r.Task.Spec.Print, + } + + start := time.Now() + var wg sync.WaitGroup + out, stdoutStr, stderrStr, err := runTextCmdTUI(si, t, prefix, r.Cmd.Register, &wg, stdout, stderr) + reportData.Tasks[r.i].Rows[r.j].Duration = time.Since(start) + + var errCode int + switch err := err.(type) { + case *ssh.ExitError: + errCode = err.ExitStatus() + case *exec.ExitError: + errCode = err.ExitCode() + case *template.ExecError: + return err + case *core.TemplateParseError: + return err + } + + reportData.Tasks[r.i].Rows[r.j].ReturnCode = errCode + + if r.Cmd.Register != "" { + register[r.Cmd.Register] = strings.TrimSuffix(out, "\n") + register[r.Cmd.Register+"_stdout"] = stdoutStr + register[r.Cmd.Register+"_stderr"] = stderrStr + register[r.Cmd.Register+"_rc"] = fmt.Sprint(reportData.Tasks[t.rIndex].Rows[r.j].ReturnCode) + if err != nil { + register[r.Cmd.Register+"_failed"] = "true" + if r.Task.Spec.IgnoreErrors || r.Cmd.IgnoreErrors { + register[r.Cmd.Register+"_status"] = "ignored" + } else { + register[r.Cmd.Register+"_status"] = "failed" + } + } else { + register[r.Cmd.Register+"_failed"] = "false" + register[r.Cmd.Register+"_status"] = "ok" + } + } + + if err != nil { + if r.Task.Spec.IgnoreErrors || r.Cmd.IgnoreErrors { + reportData.Tasks[r.i].Rows[r.j].Status = dao.Ignored + return nil + } else { + reportData.Tasks[r.i].Rows[r.j].Status = dao.Failed + return err + } + } + + reportData.Tasks[r.i].Rows[r.j].Status = dao.Ok + + return nil +} + +func runTextCmdTUI( + i int, + t TaskContext, + prefix string, + register string, + wg *sync.WaitGroup, + stdout io.Writer, + stderr io.Writer, +) (string, string, string, error) { + buf := new(bytes.Buffer) + bufOut := new(bytes.Buffer) + bufErr := new(bytes.Buffer) + + if t.dryRun { + printCmdTUI(prefix, t.cmd, stdout) + return buf.String(), bufOut.String(), bufErr.String(), nil + } + + if t.tty { + return buf.String(), bufOut.String(), bufErr.String(), ExecTTY(t.cmd, t.env) + } + + err := t.client.Run(i, t.env, t.workDir, t.shell, t.cmd) + if err != nil { + return buf.String(), bufOut.String(), bufErr.String(), err + } + + // Copy over commands STDOUT. + go func(client Client) { + defer wg.Done() + var err error + + if register == "" { + if t.print != "stderr" { + if prefix != "" { + _, err = io.Copy(stdout, core.NewPrefixer(client.Stdout(i), prefix)) + } else { + _, err = io.Copy(stdout, client.Stdout(i)) + } + } + } else { + if t.print != "stderr" { + mw := io.MultiWriter(buf, bufOut) + r := io.TeeReader(client.Stdout(i), mw) + if prefix != "" { + _, err = io.Copy(stdout, core.NewPrefixer(r, prefix)) + } else { + _, err = io.Copy(stdout, r) + } + } else { + mw := io.MultiWriter(buf, bufOut) + r := io.TeeReader(client.Stdout(i), mw) + _, err = io.Copy(mw, r) + } + } + + if err != nil && err != io.EOF { + fmt.Fprintf(stderr, "%v", err) + } + }(t.client) + wg.Add(1) + + // Copy over tasks's STDERR. + go func(client Client) { + defer wg.Done() + var err error + + if register == "" { + if t.print != "stdout" { + if prefix != "" { + _, err = io.Copy(stderr, core.NewPrefixer(client.Stderr(i), prefix)) + } else { + _, err = io.Copy(stderr, client.Stderr(i)) + } + } + } else { + if t.print != "stdout" { + mw := io.MultiWriter(buf, bufErr) + r := io.TeeReader(client.Stderr(i), mw) + if prefix != "" { + _, err = io.Copy(stderr, core.NewPrefixer(r, prefix)) + } else { + _, err = io.Copy(stderr, r) + } + } else { + mw := io.MultiWriter(buf, bufErr) + r := io.TeeReader(client.Stderr(i), mw) + _, err = io.Copy(mw, r) + } + } + + if err != nil && err != io.EOF { + fmt.Fprintf(stderr, "%v", err) + } + }(t.client) + wg.Add(1) + + wg.Wait() + + if err := t.client.Wait(i); err != nil { + if t.print != "stdout" { + if prefix != "" { + fmt.Fprintf(stdout, "%s%s\n", prefix, err.Error()) + } else { + fmt.Fprintf(stdout, "%s\n", err.Error()) + } + } + + return buf.String(), bufOut.String(), bufErr.String(), err + } + + return buf.String(), bufOut.String(), bufErr.String(), nil +} + +func printTaskHeaderTUI(i int, numTasks int, name string, desc string, ts dao.Text, w io.Writer) error { + data := HeaderData{ + Name: name, + Desc: desc, + Index: i + 1, + NumTasks: numTasks, + } + header, err := headerTemplate(ts.Header, data) + if err != nil { + return err + } + + fmt.Fprintln(w, header) + + return nil +} + +func printCmdTUI(prefix string, cmd string, w io.Writer) { + scanner := bufio.NewScanner(strings.NewReader(cmd)) + for scanner.Scan() { + fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text()) + } +} diff --git a/core/tui/components/tui_button.go b/core/tui/components/tui_button.go new file mode 100644 index 0000000..41664cc --- /dev/null +++ b/core/tui/components/tui_button.go @@ -0,0 +1,37 @@ +package components + +import ( + "github.com/alajmo/sake/core/tui/misc" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func CreateButton(label string) *tview.Button { + button := tview.NewButton(label) + SetInactiveButtonStyle(button) + return button +} + +func SetActiveButtonStyle(button *tview.Button) { + button. + SetStyle(tcell.StyleDefault. + Foreground(misc.STYLE_BUTTON_ACTIVE.Fg). + Background(misc.STYLE_BUTTON_ACTIVE.Bg). + Attributes(misc.STYLE_BUTTON_ACTIVE.Attr)). + SetActivatedStyle(tcell.StyleDefault. + Foreground(misc.STYLE_BUTTON_ACTIVE.Fg). + Background(misc.STYLE_BUTTON_ACTIVE.Bg). + Attributes(misc.STYLE_BUTTON_ACTIVE.Attr)) +} + +func SetInactiveButtonStyle(button *tview.Button) { + button. + SetStyle(tcell.StyleDefault. + Foreground(misc.STYLE_BUTTON.Fg). + Background(misc.STYLE_BUTTON.Bg). + Attributes(misc.STYLE_BUTTON.Attr)). + SetActivatedStyle(tcell.StyleDefault. + Foreground(misc.STYLE_BUTTON.Fg). + Background(misc.STYLE_BUTTON.Bg). + Attributes(misc.STYLE_BUTTON.Attr)) +} diff --git a/core/tui/components/tui_checkbox.go b/core/tui/components/tui_checkbox.go new file mode 100644 index 0000000..d5f293c --- /dev/null +++ b/core/tui/components/tui_checkbox.go @@ -0,0 +1,56 @@ +package components + +import ( + "github.com/alajmo/sake/core/tui/misc" + "github.com/rivo/tview" +) + +// Checkbox creates a styled checkbox component +func Checkbox(label string, checked *bool, onFocus func(), onBlur func()) *tview.Checkbox { + checkbox := tview.NewCheckbox().SetLabel(" " + label + " ") + checkbox.SetChecked(*checked) + checkbox.SetCheckedStyle(misc.STYLE_ITEM_SELECTED.Style) + checkbox.SetUncheckedStyle(misc.STYLE_ITEM.Style) + + checkbox.SetFieldTextColor(misc.STYLE_ITEM_FOCUSED.Bg) + checkbox.SetFieldBackgroundColor(misc.STYLE_ITEM.Bg) + checkbox.SetCheckedString("") + + if *checked { + checkbox.SetLabelStyle(misc.STYLE_ITEM_SELECTED.Style) + } else { + checkbox.SetLabelStyle(misc.STYLE_ITEM.Style) + } + + // Callbacks + checkbox.SetFocusFunc(func() { + if *checked { + checkbox.SetLabelColor(misc.STYLE_ITEM_SELECTED.Fg) + } else { + checkbox.SetLabelColor(misc.STYLE_ITEM_FOCUSED.Fg) + } + + checkbox.SetBackgroundColor(misc.STYLE_ITEM_FOCUSED.Bg) + onFocus() + }) + checkbox.SetBlurFunc(func() { + if *checked { + checkbox.SetLabelColor(misc.STYLE_ITEM_SELECTED.Fg) + } else { + checkbox.SetLabelColor(misc.STYLE_ITEM.Fg) + } + + checkbox.SetBackgroundColor(misc.STYLE_ITEM.Bg) + onBlur() + }) + checkbox.SetChangedFunc(func(isChecked bool) { + if isChecked { + checkbox.SetLabelStyle(misc.STYLE_ITEM_SELECTED.Style) + } else { + checkbox.SetLabelStyle(misc.STYLE_ITEM.Style) + } + *checked = !*checked + }) + + return checkbox +} diff --git a/core/tui/components/tui_filter.go b/core/tui/components/tui_filter.go new file mode 100644 index 0000000..d50a909 --- /dev/null +++ b/core/tui/components/tui_filter.go @@ -0,0 +1,37 @@ +package components + +import ( + "github.com/rivo/tview" + + "github.com/alajmo/sake/core/tui/misc" +) + +func CreateFilter() *tview.InputField { + filter := tview.NewInputField(). + SetLabel(""). + SetLabelStyle(misc.STYLE_FILTER_LABEL.Style). + SetFieldStyle(misc.STYLE_FILTER_TEXT.Style) + + return filter +} + +func ShowFilter(filter *tview.InputField, text string) { + filter.SetLabel(misc.Colorize("Filter:", misc.STYLE_FILTER_LABEL.FgStr, misc.STYLE_FILTER_LABEL.BgStr, "-")) + filter.SetText(text) + misc.App.SetFocus(filter) +} + +func CloseFilter(filter *tview.InputField) { + filter.SetLabel("") + filter.SetText("") +} + +func InitFilter(filter *tview.InputField, text string) { + if text != "" { + filter.SetLabel(" Filter: ") + filter.SetText(text) + } else { + filter.SetLabel("") + filter.SetText("") + } +} diff --git a/core/tui/components/tui_list.go b/core/tui/components/tui_list.go new file mode 100644 index 0000000..74d2721 --- /dev/null +++ b/core/tui/components/tui_list.go @@ -0,0 +1,207 @@ +package components + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/alajmo/sake/core/tui/misc" +) + +type TList struct { + Root *tview.Flex + List *tview.List + Filter *tview.InputField + + Title string + FilterValue *string + + IsItemSelected func(item string) bool + ToggleSelectItem func(i int, itemName string) + SelectAll func() + UnselectAll func() + FilterItems func() +} + +func (l *TList) Create() { + // Init + list := tview.NewList(). + ShowSecondaryText(false). + SetHighlightFullLine(true). + SetSelectedStyle(misc.STYLE_ITEM_FOCUSED.Style). + SetMainTextColor(misc.STYLE_ITEM.Fg) + filter := CreateFilter() + + root := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(list, 0, 1, true). + AddItem(filter, 1, 0, false) + root.SetTitleColor(misc.STYLE_TITLE.Fg) + root.SetTitleAlign(misc.STYLE_TITLE.Align). + SetBorder(true). + SetBorderPadding(1, 0, 1, 1) + + l.Filter = filter + l.Root = root + l.List = list + + if l.Title != "" { + misc.SetActive(l.Root.Box, l.Title, false) + } + + l.IsItemSelected = func(item string) bool { return false } + l.ToggleSelectItem = func(i int, itemName string) {} + l.SelectAll = func() {} + l.UnselectAll = func() {} + l.FilterItems = func() {} + + // Filter + l.Filter.SetChangedFunc(func(_ string) { + l.applyFilter() + l.FilterItems() + }) + + l.Filter.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + currentFocus := misc.App.GetFocus() + if currentFocus == filter { + switch event.Key() { + case tcell.KeyEscape: + l.ClearFilter() + misc.App.SetFocus(list) + return nil + case tcell.KeyEnter: + l.applyFilter() + misc.App.SetFocus(list) + } + return event + } + return event + }) + + // Input + l.List.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + // Need to check filter in-case list is empty + switch event.Key() { + case tcell.KeyRune: + switch event.Rune() { + case 'f': // Filter + ShowFilter(filter, *l.FilterValue) + return nil + case 'F': // Remove filter + CloseFilter(filter) + *l.FilterValue = "" + return nil + } + } + + numItems := l.List.GetItemCount() + if numItems == 0 { + return nil + } + + currentItemIndex := l.List.GetCurrentItem() + _, secondaryText := l.List.GetItemText(currentItemIndex) + switch event.Key() { + case tcell.KeyEnter: + l.ToggleSelectItem(currentItemIndex, secondaryText) + return nil + case tcell.KeyCtrlD: + current := list.GetCurrentItem() + _, _, _, height := list.GetInnerRect() + newPos := min(current+height/2, list.GetItemCount()-1) + list.SetCurrentItem(newPos) + return nil + case tcell.KeyCtrlU: + current := list.GetCurrentItem() + _, _, _, height := list.GetInnerRect() + newPos := max(current-height/2, 0) + list.SetCurrentItem(newPos) + return nil + case tcell.KeyCtrlF: + current := list.GetCurrentItem() + _, _, _, height := list.GetInnerRect() + newPos := min(current+height, list.GetItemCount()-1) + list.SetCurrentItem(newPos) + return nil + case tcell.KeyCtrlB: + current := list.GetCurrentItem() + _, _, _, height := list.GetInnerRect() + newPos := max(current-height, 0) + list.SetCurrentItem(newPos) + return nil + case tcell.KeyRune: + switch event.Rune() { + case 'g': // top + l.List.SetCurrentItem(0) + return nil + case 'G': // bottom + l.List.SetCurrentItem(numItems - 1) + return nil + case 'j': // down + nextItem := currentItemIndex + 1 + if nextItem < numItems { + l.List.SetCurrentItem(nextItem) + } + return nil + case 'k': // up + nextItem := currentItemIndex - 1 + if nextItem >= 0 { + l.List.SetCurrentItem(nextItem) + } + return nil + case 'a': // Select all + l.SelectAll() + return nil + case 'c': // Unselect all + l.UnselectAll() + return nil + case ' ': // Select (Space) + l.ToggleSelectItem(currentItemIndex, secondaryText) + return nil + } + } + + return event + }) + + // Events + l.List.SetFocusFunc(func() { + misc.PreviousPane = l.List + misc.SetActive(l.Root.Box, l.Title, true) + }) + l.List.SetBlurFunc(func() { + misc.PreviousPane = l.List + misc.SetActive(l.Root.Box, l.Title, false) + }) +} + +func (l *TList) Update(items []string) { + l.List.Clear() + for _, name := range items { + l.List.AddItem(l.getItemText(name), name, 0, nil) + } +} + +func (l *TList) SetItemSelect(i int, item string) { + if l.IsItemSelected(item) { + value := misc.Colorize(item, misc.STYLE_ITEM_SELECTED.FgStr, misc.STYLE_ITEM_SELECTED.BgStr, "b") + l.List.SetItemText(i, value, item) + } else { + l.List.SetItemText(i, misc.PadString(item), item) + } +} + +func (l *TList) ClearFilter() { + CloseFilter(l.Filter) + *l.FilterValue = "" +} + +func (l *TList) applyFilter() { + *l.FilterValue = l.Filter.GetText() +} + +func (l *TList) getItemText(item string) string { + if l.IsItemSelected(item) { + return misc.Colorize(item, misc.STYLE_ITEM_SELECTED.FgStr, misc.STYLE_ITEM_SELECTED.BgStr, "b") + } + return misc.PadString(item) +} diff --git a/core/tui/components/tui_modal.go b/core/tui/components/tui_modal.go new file mode 100644 index 0000000..589f809 --- /dev/null +++ b/core/tui/components/tui_modal.go @@ -0,0 +1,187 @@ +package components + +import ( + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "golang.org/x/term" + + "github.com/alajmo/sake/core/tui/misc" +) + +// OpenTextModal opens a modal with text content +func OpenTextModal(pageTitle string, text string, title string) { + width, height := getTextModalSize(text) + text = strings.TrimSpace(text) + + // Text + contentPane := tview.NewTextView(). + SetText(text). + SetTextAlign(tview.AlignLeft). + SetDynamicColors(true) + + // Border + formattedTitle := misc.ColorizeTitle(title, misc.STYLE_TITLE_ACTIVE.FgStr, misc.STYLE_TITLE_ACTIVE.BgStr, "b") + contentPane.SetBorder(true). + SetTitle(formattedTitle). + SetTitleAlign(misc.STYLE_TITLE.Align). + SetBorderColor(misc.STYLE_BORDER_FOCUS.Fg). + SetBorderPadding(1, 1, 2, 2) + + // Use a background box with draw function for proper rendering (no color = terminal default) + background := tview.NewBox() + containerFlex := tview.NewFlex(). + AddItem(contentPane, 0, 1, true) + containerFlex.SetDrawFunc(func(screen tcell.Screen, x, y, w, h int) (int, int, int, int) { + background.SetRect(x, y, w, h) + background.Draw(screen) + contentPane.SetRect(x, y, w, h) + contentPane.Draw(screen) + return x, y, w, h + }) + + // Container + modal := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem( + tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(nil, 0, 1, false). + AddItem(containerFlex, width, 1, true). + AddItem(nil, 0, 1, false), + height, 1, true, + ). + AddItem(nil, 0, 1, false) + + modal.SetFullScreen(true) + + EmptySearch() + + misc.Pages.AddPage(pageTitle, modal, false, true) + misc.App.SetFocus(contentPane) +} + +// CloseModal closes the front modal +func CloseModal() { + previousPane := misc.PreviousPane + frontPageName, _ := misc.Pages.GetFrontPage() + misc.Pages.RemovePage(frontPageName) + misc.App.SetFocus(previousPane) +} + +// IsModalOpen checks if a modal is currently open +func IsModalOpen() bool { + frontPageName, _ := misc.Pages.GetFrontPage() + return strings.Contains(frontPageName, "-modal") +} + +// IsDescribeModalOpen checks if the describe modal is open +func IsDescribeModalOpen() bool { + frontPageName, _ := misc.Pages.GetFrontPage() + return strings.Contains(frontPageName, "describe") || strings.Contains(frontPageName, "description") +} + +// CloseDescribeModal closes the describe modal if open +func CloseDescribeModal() bool { + frontPageName, _ := misc.Pages.GetFrontPage() + if strings.Contains(frontPageName, "describe") || strings.Contains(frontPageName, "description") { + CloseModal() + return true + } + return false +} + +// OpenModal opens a modal with custom content +func OpenModal(pageTitle string, title string, content tview.Primitive, width int, height int) { + termWidth, termHeight, _ := term.GetSize(0) + if termWidth == 0 { + termWidth = 80 + } + if termHeight == 0 { + termHeight = 24 + } + if width > termWidth { + width = termWidth - 5 + } + if height > termHeight { + height = termHeight - 5 + } + + // Border wrapper + wrapper := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(content, 0, 1, true) + + formattedTitle := misc.ColorizeTitle(title, misc.STYLE_TITLE_ACTIVE.FgStr, misc.STYLE_TITLE_ACTIVE.BgStr, "b") + wrapper.SetBorder(true). + SetTitle(formattedTitle). + SetTitleAlign(misc.STYLE_TITLE.Align). + SetBorderColor(misc.STYLE_BORDER_FOCUS.Fg). + SetBorderPadding(1, 1, 2, 2) + + // Use a background box with draw function for proper rendering (no color = terminal default) + background := tview.NewBox() + containerFlex := tview.NewFlex(). + AddItem(wrapper, 0, 1, true) + containerFlex.SetDrawFunc(func(screen tcell.Screen, x, y, w, h int) (int, int, int, int) { + background.SetRect(x, y, w, h) + background.Draw(screen) + wrapper.SetRect(x, y, w, h) + wrapper.Draw(screen) + return x, y, w, h + }) + + // Container + modal := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem( + tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(nil, 0, 1, false). + AddItem(containerFlex, width, 1, true). + AddItem(nil, 0, 1, false), + height, 1, true, + ). + AddItem(nil, 0, 1, false) + + modal.SetFullScreen(true) + + EmptySearch() + + misc.Pages.AddPage(pageTitle, modal, false, true) + misc.App.SetFocus(content) +} + +// getTextModalSize calculates appropriate modal dimensions based on content +func getTextModalSize(text string) (int, int) { + termWidth, termHeight, _ := term.GetSize(0) + if termWidth == 0 { + termWidth = 80 + } + if termHeight == 0 { + termHeight = 24 + } + + lines := strings.Split(text, "\n") + height := len(lines) + 6 // padding for borders and title + + width := 40 + for _, line := range lines { + lineLen := len(line) + 6 + if lineLen > width { + width = lineLen + } + } + + maxWidth := termWidth - 10 + maxHeight := termHeight - 5 + + if width > maxWidth { + width = maxWidth + } + if height > maxHeight { + height = maxHeight + } + + return width, height +} diff --git a/core/tui/components/tui_output.go b/core/tui/components/tui_output.go new file mode 100644 index 0000000..d920714 --- /dev/null +++ b/core/tui/components/tui_output.go @@ -0,0 +1,116 @@ +package components + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/alajmo/sake/core/tui/misc" +) + +type TOutput struct { + Root *tview.Flex + Output *tview.TextView + Title string +} + +func (t *TOutput) Create() { + output := tview.NewTextView(). + SetDynamicColors(true). + SetScrollable(true). + SetWordWrap(true) + + output.SetBackgroundColor(misc.STYLE_ITEM.Bg) + output.SetTextColor(misc.STYLE_ITEM.Fg) + + root := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(output, 0, 1, true) + + root.SetTitleColor(misc.STYLE_TITLE.Fg) + root.SetTitleAlign(misc.STYLE_TITLE.Align). + SetBorder(true). + SetBorderPadding(1, 0, 1, 1) + + t.Output = output + t.Root = root + + if t.Title != "" { + misc.SetActive(t.Root.Box, t.Title, false) + } + + // Input + t.Output.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyCtrlD: + row, _ := output.GetScrollOffset() + _, _, _, height := output.GetInnerRect() + output.ScrollTo(row+height/2, 0) + return nil + case tcell.KeyCtrlU: + row, _ := output.GetScrollOffset() + _, _, _, height := output.GetInnerRect() + newRow := row - height/2 + if newRow < 0 { + newRow = 0 + } + output.ScrollTo(newRow, 0) + return nil + case tcell.KeyCtrlF: + row, _ := output.GetScrollOffset() + _, _, _, height := output.GetInnerRect() + output.ScrollTo(row+height, 0) + return nil + case tcell.KeyCtrlB: + row, _ := output.GetScrollOffset() + _, _, _, height := output.GetInnerRect() + newRow := row - height + if newRow < 0 { + newRow = 0 + } + output.ScrollTo(newRow, 0) + return nil + case tcell.KeyRune: + switch event.Rune() { + case 'j': + row, col := output.GetScrollOffset() + output.ScrollTo(row+1, col) + return nil + case 'k': + row, col := output.GetScrollOffset() + if row > 0 { + output.ScrollTo(row-1, col) + } + return nil + case 'g': + output.ScrollToBeginning() + return nil + case 'G': + output.ScrollToEnd() + return nil + } + } + return event + }) + + t.Output.SetFocusFunc(func() { + misc.PreviousPane = t.Output + misc.SetActive(t.Root.Box, t.Title, true) + }) + + t.Output.SetBlurFunc(func() { + misc.PreviousPane = t.Output + misc.SetActive(t.Root.Box, t.Title, false) + }) +} + +func (t *TOutput) Clear() { + t.Output.Clear() +} + +func (t *TOutput) Write(text string) { + t.Output.SetText(text) +} + +func (t *TOutput) GetWriter() *misc.ThreadSafeWriter { + return misc.NewThreadSafeWriter(t.Output) +} diff --git a/core/tui/components/tui_search.go b/core/tui/components/tui_search.go new file mode 100644 index 0000000..3f99f36 --- /dev/null +++ b/core/tui/components/tui_search.go @@ -0,0 +1,173 @@ +package components + +import ( + "strings" + + "github.com/rivo/tview" + + "github.com/alajmo/sake/core/tui/misc" +) + +func CreateSearch() *tview.InputField { + search := tview.NewInputField(). + SetLabel(""). + SetLabelStyle(misc.STYLE_SEARCH_LABEL.Style). + SetFieldStyle(misc.STYLE_SEARCH_TEXT.Style) + return search +} + +func ShowSearch() { + misc.Search.SetLabel(misc.Colorize("Search:", misc.STYLE_SEARCH_LABEL.FgStr, misc.STYLE_SEARCH_LABEL.BgStr, "-")) + misc.Search.SetText("") + misc.App.SetFocus(misc.Search) +} + +func EmptySearch() { + misc.Search.SetLabel("") + misc.Search.SetText("") +} + +func SearchInTable(table *tview.Table, query string, lastFoundRow, lastFoundCol *int, direction int) { + query = strings.ToLower(query) + rowCount := table.GetRowCount() + colCount := table.GetColumnCount() + startRow := *lastFoundRow + + if startRow == -1 { + startRow = 0 + } else { + startRow += direction + } + + searchRow := startRow + for i := 0; i < rowCount; i++ { + if searchRow < 0 { + searchRow = rowCount - 1 + } else if searchRow >= rowCount { + searchRow = 0 + } + + for col := 0; col < colCount; col++ { + if cell := table.GetCell(searchRow, col); cell != nil { + if strings.Contains(strings.ToLower(strings.TrimSpace(cell.Text)), query) { + table.Select(searchRow, col) + *lastFoundRow, *lastFoundCol = searchRow, col + return + } + } + } + + searchRow += direction + } + + *lastFoundRow, *lastFoundCol = -1, -1 +} + +func SearchInList(list *tview.List, query string, lastFoundIndex *int, direction int) { + query = strings.ToLower(query) + itemCount := list.GetItemCount() + startIndex := *lastFoundIndex + + if startIndex == -1 { + startIndex = 0 + } else { + startIndex += direction + } + + searchIndex := startIndex + for i := 0; i < itemCount; i++ { + if searchIndex < 0 { + searchIndex = itemCount - 1 + } else if searchIndex >= itemCount { + searchIndex = 0 + } + + mainText, secondaryText := list.GetItemText(searchIndex) + if strings.Contains(strings.ToLower(mainText), query) || + strings.Contains(strings.ToLower(secondaryText), query) { + list.SetCurrentItem(searchIndex) + *lastFoundIndex = searchIndex + return + } + + searchIndex += direction + } + + *lastFoundIndex = -1 +} + +func SearchInTree(tree *tview.TreeView, query string, lastFoundIndex *int, direction int) { + query = strings.ToLower(query) + + // Get all selectable nodes + var nodes []*tview.TreeNode + var walk func(*tview.TreeNode) + walk = func(node *tview.TreeNode) { + if node == nil { + return + } + // Only include selectable nodes (ones with references) + ref := node.GetReference() + if ref != nil && ref.(string) != "" { + nodes = append(nodes, node) + } + for _, child := range node.GetChildren() { + walk(child) + } + } + walk(tree.GetRoot()) + + if len(nodes) == 0 { + return + } + + startIndex := *lastFoundIndex + if startIndex == -1 { + startIndex = 0 + } else { + startIndex += direction + } + + searchIndex := startIndex + for i := 0; i < len(nodes); i++ { + if searchIndex < 0 { + searchIndex = len(nodes) - 1 + } else if searchIndex >= len(nodes) { + searchIndex = 0 + } + + node := nodes[searchIndex] + text := node.GetText() + // Strip color codes for matching + text = stripColorCodes(text) + if strings.Contains(strings.ToLower(text), query) { + tree.SetCurrentNode(node) + *lastFoundIndex = searchIndex + return + } + + searchIndex += direction + } + + *lastFoundIndex = -1 +} + +// stripColorCodes removes tview color tags from text +func stripColorCodes(text string) string { + result := "" + inTag := false + for _, r := range text { + if r == '[' { + inTag = true + continue + } + if r == ']' && inTag { + inTag = false + continue + } + if !inTag { + result += string(r) + } + } + return strings.TrimSpace(result) +} diff --git a/core/tui/components/tui_table.go b/core/tui/components/tui_table.go new file mode 100644 index 0000000..655ea00 --- /dev/null +++ b/core/tui/components/tui_table.go @@ -0,0 +1,298 @@ +package components + +import ( + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/alajmo/sake/core/tui/misc" +) + +type TTable struct { + Root *tview.Flex + Table *tview.Table + Filter *tview.InputField + + Title string + FilterValue *string + ShowHeaders bool + ToggleEnabled bool + + IsRowSelected func(name string) bool + ToggleSelectRow func(name string) + SelectAll func() + UnselectAll func() + FilterRows func() + DescribeRow func(name string) + EditRow func(name string) + SSHRow func(name string) +} + +func (t *TTable) Create() { + // Init + table := tview.NewTable() + table.SetFixed(1, 1) // Fixed header + name column + table.Select(1, 0) // Select first row + table.SetEvaluateAllRows(true) // Avoid resizing of headers when scrolling + table.SetSelectable(true, false) // Only rows can be selected + table.SetBackgroundColor(misc.STYLE_ITEM.Bg) + filter := CreateFilter() + + root := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(table, 0, 1, true). + AddItem(filter, 1, 0, false) + + root.SetTitleColor(misc.STYLE_TITLE.Fg) + root.SetTitleAlign(misc.STYLE_TITLE.Align). + SetBorder(true). + SetBorderPadding(1, 0, 1, 1) + + t.Table = table + t.Filter = filter + t.Root = root + + if t.Title != "" { + misc.SetActive(t.Root.Box, t.Title, false) + } + + // Methods + t.IsRowSelected = func(name string) bool { return false } + t.ToggleSelectRow = func(name string) {} + t.SelectAll = func() {} + t.UnselectAll = func() {} + t.FilterRows = func() {} + t.DescribeRow = func(_ string) {} + t.EditRow = func(name string) {} + t.SSHRow = func(name string) {} + + // Filter + t.Filter.SetChangedFunc(func(_ string) { + t.applyFilter() + t.FilterRows() + }) + + t.Filter.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + currentFocus := misc.App.GetFocus() + if currentFocus == filter { + switch event.Key() { + case tcell.KeyEscape: + t.ClearFilter() + t.FilterRows() + misc.App.SetFocus(table) + return nil + case tcell.KeyEnter: + t.applyFilter() + t.FilterRows() + misc.App.SetFocus(table) + } + return event + } + return event + }) + + // Input + t.Table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEnter: + if t.ToggleEnabled { + row, _ := table.GetSelection() + name := strings.TrimSpace(table.GetCell(row, 0).Text) + t.ToggleSelectRow(name) + } + return nil + case tcell.KeyCtrlD: + row, _ := table.GetSelection() + _, _, _, height := table.GetInnerRect() + newRow := min(row+height/2, table.GetRowCount()-1) + table.Select(newRow, 0) + return nil + case tcell.KeyCtrlU: + row, _ := table.GetSelection() + _, _, _, height := table.GetInnerRect() + newRow := max(row-height/2, 1) + table.Select(newRow, 0) + return nil + case tcell.KeyCtrlF: + row, _ := table.GetSelection() + _, _, _, height := table.GetInnerRect() + newRow := min(row+height, table.GetRowCount()-1) + if newRow == 0 { + newRow = 1 // Skip header + } + table.Select(newRow, 0) + return nil + case tcell.KeyCtrlB: + row, _ := table.GetSelection() + _, _, _, height := table.GetInnerRect() + newRow := max(row-height, 1) + table.Select(newRow, 0) + return nil + + case tcell.KeyRune: + switch event.Rune() { + case ' ': // Toggle item (space) + if t.ToggleEnabled { + row, _ := table.GetSelection() + name := strings.TrimSpace(table.GetCell(row, 0).Text) + t.ToggleSelectRow(name) + } + return nil + case 'a': // Select all + if t.ToggleEnabled { + t.SelectAll() + } + return nil + case 'c': // Unselect all + if t.ToggleEnabled { + t.UnselectAll() + } + return nil + case 'f': // Filter rows + ShowFilter(filter, *t.FilterValue) + return nil + case 'F': // Remove filter + CloseFilter(filter) + *t.FilterValue = "" + return nil + case 'o': // Edit in editor + row, _ := t.Table.GetSelection() + name := strings.TrimSpace(t.Table.GetCell(row, 0).Text) + t.EditRow(name) + return nil + case 'd': // Toggle description modal + if CloseDescribeModal() { + return nil + } + row, _ := t.Table.GetSelection() + name := strings.TrimSpace(t.Table.GetCell(row, 0).Text) + t.DescribeRow(name) + return nil + case 'S': // SSH into server (Shift+S) + row, _ := t.Table.GetSelection() + name := strings.TrimSpace(t.Table.GetCell(row, 0).Text) + t.SSHRow(name) + return nil + } + } + return event + }) + + // Events + t.Table.SetSelectionChangedFunc(func(row, column int) { + t.UpdateRowStyle() + }) + + t.Table.SetFocusFunc(func() { + InitFilter(t.Filter, *t.FilterValue) + + misc.PreviousPane = t.Table + misc.SetActive(t.Root.Box, t.Title, true) + }) + + t.Table.SetBlurFunc(func() { + misc.PreviousPane = t.Table + misc.SetActive(t.Root.Box, t.Title, false) + }) +} + +func (t *TTable) CreateTableHeader(header string) *tview.TableCell { + return tview.NewTableCell(misc.PadString(header)). + SetTextColor(misc.STYLE_TABLE_HEADER.Fg). + SetAttributes(misc.STYLE_TABLE_HEADER.Attr). + SetAlign(misc.STYLE_TABLE_HEADER.Align). + SetSelectable(false) +} + +func (t *TTable) Update(headers []string, rows [][]string) { + t.Table.Clear() + + // Add headers and updates style + for col, header := range headers { + if t.ShowHeaders { + t.Table.SetCell(0, col, t.CreateTableHeader(header)) + } else { + t.Table.SetCell(0, col, t.CreateTableHeader("")) + } + } + + // Add rows and updates style + for i := range rows { + for j := range rows[i] { + name := misc.PadString(rows[i][j]) + cell := tview.NewTableCell(name) + t.Table.SetCell(i+1, j, cell) + t.SetRowSelect(i + 1) + } + } +} + +func (t *TTable) UpdateRowStyle() { + for row := 1; row < t.Table.GetRowCount(); row++ { + t.SetRowSelect(row) + } +} + +func (t *TTable) ToggleSelectCurrentRow(name string) { + index := -1 + for row := 1; row < t.Table.GetRowCount(); row++ { + cell := strings.TrimSpace(t.Table.GetCell(row, 0).Text) + if cell == name { + index = row + break + } + } + t.SetRowSelect(index) +} + +func (t *TTable) SetRowSelect(row int) { + // Ignore header row + focusedRow, _ := t.Table.GetSelection() + if focusedRow == 0 { + return + } + + name := strings.TrimSpace(t.Table.GetCell(row, 0).Text) + isSelected := t.IsRowSelected(name) + isFocused := row == focusedRow + + style := tcell.StyleDefault + if isFocused && isSelected { + style = style. + Foreground(misc.STYLE_ITEM_SELECTED.Fg). + Background(misc.STYLE_ITEM_FOCUSED.Bg). + Attributes(misc.STYLE_ITEM_SELECTED.Attr) + } else if isFocused { + style = style. + Foreground(misc.STYLE_ITEM_FOCUSED.Fg). + Background(misc.STYLE_ITEM_FOCUSED.Bg). + Attributes(misc.STYLE_ITEM_FOCUSED.Attr) + } else if isSelected { + style = style. + Foreground(misc.STYLE_ITEM_SELECTED.Fg). + Background(misc.STYLE_ITEM_SELECTED.Bg). + Attributes(misc.STYLE_ITEM_SELECTED.Attr) + } else { + style = style. + Foreground(misc.STYLE_ITEM.Fg). + Background(misc.STYLE_ITEM.Bg). + Attributes(misc.STYLE_ITEM.Attr) + } + + // Apply styles to all cells in the row + for col := 0; col < t.Table.GetColumnCount(); col++ { + cell := t.Table.GetCell(row, col) + cell.SetStyle(style) + cell.SetSelectedStyle(style) + } +} + +func (t *TTable) ClearFilter() { + CloseFilter(t.Filter) + *t.FilterValue = "" +} + +func (t *TTable) applyFilter() { + *t.FilterValue = t.Filter.GetText() +} diff --git a/core/tui/components/tui_toggle.go b/core/tui/components/tui_toggle.go new file mode 100644 index 0000000..fcd8be9 --- /dev/null +++ b/core/tui/components/tui_toggle.go @@ -0,0 +1,142 @@ +package components + +import ( + "github.com/alajmo/sake/core/tui/misc" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// TToggleText is a toggle component that switches between two text options +type TToggleText struct { + Value *string + Option1 string + Option2 string + Label1 string + Label2 string + TextView *tview.TextView +} + +// Create initializes the toggle text view +func (t *TToggleText) Create() { + textview := tview.NewTextView() + textview.SetTitle("") + if *t.Value == t.Option1 { + textview.SetText(t.Label1) + } else { + textview.SetText(t.Label2) + } + textview.SetSize(1, 22) + textview.SetBorder(false) + textview.SetBorderPadding(0, 0, 0, 0) + textview.SetBackgroundColor(misc.STYLE_ITEM.Bg) + + toggleOutput := func() { + if *t.Value == t.Option1 { + *t.Value = t.Option2 + textview.SetText(t.Label2) + } else { + *t.Value = t.Option1 + textview.SetText(t.Label1) + } + } + + textview.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEnter: + toggleOutput() + return nil + case tcell.KeyRune: + switch event.Rune() { + case ' ': // space + toggleOutput() + return nil + } + } + + return event + }) + + textview.SetFocusFunc(func() { + textview.SetTextColor(misc.STYLE_ITEM_FOCUSED.Fg) + textview.SetBackgroundColor(misc.STYLE_ITEM_FOCUSED.Bg) + }) + + textview.SetBlurFunc(func() { + textview.SetTextColor(misc.STYLE_ITEM.Fg) + textview.SetBackgroundColor(misc.STYLE_ITEM.Bg) + }) + + t.TextView = textview +} + +// TToggleThree is a toggle component that cycles through three options +type TToggleThree struct { + Value *string + Option1 string + Option2 string + Option3 string + Label1 string + Label2 string + Label3 string + TextView *tview.TextView +} + +// Create initializes the toggle three view +func (t *TToggleThree) Create() { + textview := tview.NewTextView() + textview.SetTitle("") + switch *t.Value { + case t.Option1: + textview.SetText(t.Label1) + case t.Option2: + textview.SetText(t.Label2) + default: + textview.SetText(t.Label3) + } + textview.SetSize(1, 22) + textview.SetBorder(false) + textview.SetBorderPadding(0, 0, 0, 0) + textview.SetBackgroundColor(misc.STYLE_ITEM.Bg) + + toggleOutput := func() { + switch *t.Value { + case t.Option1: + *t.Value = t.Option2 + textview.SetText(t.Label2) + case t.Option2: + *t.Value = t.Option3 + textview.SetText(t.Label3) + default: + *t.Value = t.Option1 + textview.SetText(t.Label1) + } + } + + textview.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEnter: + toggleOutput() + return nil + case tcell.KeyRune: + switch event.Rune() { + case ' ': // space + toggleOutput() + return nil + } + } + + return event + }) + + textview.SetFocusFunc(func() { + textview.SetTextColor(misc.STYLE_ITEM_FOCUSED.Fg) + textview.SetBackgroundColor(misc.STYLE_ITEM_FOCUSED.Bg) + }) + + textview.SetBlurFunc(func() { + textview.SetTextColor(misc.STYLE_ITEM.Fg) + textview.SetBackgroundColor(misc.STYLE_ITEM.Bg) + }) + + t.TextView = textview +} diff --git a/core/tui/components/tui_tree.go b/core/tui/components/tui_tree.go new file mode 100644 index 0000000..cc6df62 --- /dev/null +++ b/core/tui/components/tui_tree.go @@ -0,0 +1,560 @@ +package components + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/alajmo/sake/core/tui/misc" +) + +type TTree struct { + Tree *tview.TreeView + Root *tview.Flex + RootNode *tview.TreeNode + Filter *tview.InputField + + List []*TNode + Title string + RootTitle string + FilterValue *string + SelectEnabled bool + + IsNodeSelected func(name string) bool + ToggleSelectNode func(name string) + SelectAll func() + UnselectAll func() + FilterNodes func() + DescribeNode func(name string) + EditNode func(name string) +} + +type TNode struct { + ID string // The reference + DisplayName string // What is shown + Type string + + TreeNode *tview.TreeNode + Children *[]TNode +} + +func (t *TTree) Create() { + title := misc.Colorize(t.RootTitle, "", "", "-") + rootNode := tview.NewTreeNode(title) + rootNode.SetColor(misc.STYLE_DEFAULT.Fg) + rootNode.SetSelectable(false) + + t.IsNodeSelected = func(name string) bool { return false } + t.ToggleSelectNode = func(name string) {} + t.SelectAll = func() {} + t.UnselectAll = func() {} + t.FilterNodes = func() {} + t.DescribeNode = func(name string) {} + t.EditNode = func(name string) {} + + tree := tview.NewTreeView(). + SetRoot(rootNode). + SetCurrentNode(rootNode) + tree.SetGraphics(true) + + filter := CreateFilter() + + root := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(tree, 0, 1, true). + AddItem(filter, 1, 0, false) + root.SetTitleAlign(misc.STYLE_TITLE.Align). + SetBorder(true). + SetBorderPadding(0, 0, 1, 1) + + t.Root = root + t.Filter = filter + t.RootNode = rootNode + t.Tree = tree + + if t.Title != "" { + misc.SetActive(t.Root.Box, t.Title, false) + } + + // Filter + t.Filter.SetChangedFunc(func(_ string) { + t.applyFilter() + t.FilterNodes() + }) + + t.Filter.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + currentFocus := misc.App.GetFocus() + if currentFocus == filter { + switch event.Key() { + case tcell.KeyEscape: + t.ClearFilter() + t.FilterNodes() + misc.App.SetFocus(tree) + return nil + case tcell.KeyEnter: + t.applyFilter() + t.FilterNodes() + misc.App.SetFocus(tree) + } + return event + } + return event + }) + + // Input + t.Tree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEnter: + if t.SelectEnabled { + node := t.Tree.GetCurrentNode() + ref := node.GetReference() + if ref != nil { + name := ref.(string) + t.ToggleSelectNode(name) + } + } + return nil + case tcell.KeyCtrlD: + current := t.Tree.GetCurrentNode() + _, _, _, height := t.Tree.GetInnerRect() + visibleNodes := t.getVisibleNodes() + currentIndex := t.findNodeIndex(visibleNodes, current) + newIndex := min(currentIndex+height/2, len(visibleNodes)-1) + if newIndex > 0 && newIndex < len(visibleNodes) { + t.Tree.SetCurrentNode(visibleNodes[newIndex]) + } + return nil + case tcell.KeyCtrlU: + current := t.Tree.GetCurrentNode() + _, _, _, height := t.Tree.GetInnerRect() + visibleNodes := t.getVisibleNodes() + currentIndex := t.findNodeIndex(visibleNodes, current) + newIndex := max(currentIndex-height/2, 0) + if newIndex >= 0 && newIndex < len(visibleNodes) { + t.Tree.SetCurrentNode(visibleNodes[newIndex]) + } + return nil + case tcell.KeyCtrlF: + current := t.Tree.GetCurrentNode() + _, _, _, height := t.Tree.GetInnerRect() + visibleNodes := t.getVisibleNodes() + currentIndex := t.findNodeIndex(visibleNodes, current) + newIndex := min(currentIndex+height, len(visibleNodes)-1) + if newIndex > 0 && newIndex < len(visibleNodes) { + t.Tree.SetCurrentNode(visibleNodes[newIndex]) + } + return nil + case tcell.KeyCtrlB: + current := t.Tree.GetCurrentNode() + _, _, _, height := t.Tree.GetInnerRect() + visibleNodes := t.getVisibleNodes() + currentIndex := t.findNodeIndex(visibleNodes, current) + newIndex := max(currentIndex-height, 0) + if newIndex >= 0 && newIndex < len(visibleNodes) { + t.Tree.SetCurrentNode(visibleNodes[newIndex]) + } + return nil + case tcell.KeyRune: + switch event.Rune() { + case ' ': // Toggle item (space) + if t.SelectEnabled { + node := t.Tree.GetCurrentNode() + ref := node.GetReference() + if ref != nil { + name := ref.(string) + t.ToggleSelectNode(name) + } + } + return nil + case 'a': // Select all + if t.SelectEnabled { + t.SelectAll() + } + return nil + case 'c': // Unselect all + if t.SelectEnabled { + t.UnselectAll() + } + return nil + case 'f': // Filter rows + ShowFilter(filter, *t.FilterValue) + return nil + case 'F': // Remove filter + CloseFilter(filter) + *t.FilterValue = "" + return nil + case 'o': // Edit in editor + item := tree.GetCurrentNode() + ref := item.GetReference() + if ref != nil { + name := ref.(string) + t.EditNode(name) + } + return nil + case 'd': // Toggle description modal + if CloseDescribeModal() { + return nil + } + item := tree.GetCurrentNode() + ref := item.GetReference() + if ref != nil { + name := ref.(string) + t.DescribeNode(name) + } + return nil + case 'g': // Top + tree.SetCurrentNode(rootNode) + misc.App.QueueEvent(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)) + return nil + case 'G': // Bottom + children := rootNode.GetChildren() + if len(children) > 0 { + last := children[len(children)-1] + ref := last.GetReference() + + if ref == nil || ref.(string) == "" { + children = last.GetChildren() + if len(children) > 0 { + last = children[len(children)-1] + } + } + + tree.SetCurrentNode(last) + misc.App.QueueEvent(tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone)) + } + return nil + } + } + return event + }) + + // Events + var previousNode *tview.TreeNode + var previousColor tcell.Color + tree.SetChangedFunc(func(node *tview.TreeNode) { + if previousNode != nil { + previousNode.SetColor(previousColor) + } + if node != nil { + previousColor = node.GetColor() + previousNode = node + node.SetColor(misc.STYLE_ITEM_FOCUSED.Bg) + } + }) + + t.Tree.SetFocusFunc(func() { + InitFilter(t.Filter, *t.FilterValue) + misc.PreviousPane = t.Tree + misc.SetActive(t.Root.Box, t.Title, true) + }) + + t.Tree.SetBlurFunc(func() { + misc.PreviousPane = t.Tree + misc.SetActive(t.Root.Box, t.Title, false) + }) +} + +func (t *TTree) UpdateTasks(nodes []TNode) { + t.RootNode.ClearChildren() + t.List = []*TNode{} + + for _, parentNode := range nodes { + // Parent + displayName := misc.Colorize(parentNode.DisplayName, misc.STYLE_ITEM.FgStr, misc.STYLE_ITEM.BgStr, "-") + parentTreeNode := tview.NewTreeNode(displayName). + SetReference(parentNode.ID). + SetSelectable(true) + t.RootNode.AddChild(parentTreeNode) + + parentListNode := &TNode{ + DisplayName: parentNode.DisplayName, + ID: parentNode.ID, + Type: parentNode.Type, + TreeNode: parentTreeNode, + Children: &[]TNode{}, + } + t.List = append(t.List, parentListNode) + + // Children + if parentNode.Children != nil { + for _, childNode := range *parentNode.Children { + var childDisplayName string + if childNode.Type == "task-ref" { + // Use magenta (TABLE_HEADER color) for task refs + childDisplayName = misc.Colorize(childNode.DisplayName, misc.STYLE_TABLE_HEADER.FgStr, "", "-") + } else { + childDisplayName = misc.Colorize(childNode.DisplayName, misc.STYLE_ITEM.FgStr, misc.STYLE_ITEM.BgStr, "-") + } + childTreeNode := tview.NewTreeNode(childDisplayName). + SetSelectable(false) + parentTreeNode.AddChild(childTreeNode) + + listChildNode := &TNode{ + DisplayName: childNode.DisplayName, + Type: childNode.Type, + TreeNode: childTreeNode, + Children: &[]TNode{}, + } + *parentListNode.Children = append(*parentListNode.Children, *listChildNode) + } + } + } +} + +func (t *TTree) UpdateTasksStyle() { + for _, node := range t.List { + if t.IsNodeSelected(node.ID) { + displayName := misc.Colorize(node.DisplayName, misc.STYLE_ITEM_SELECTED.FgStr, "", "-") + node.TreeNode.SetText(displayName) + for _, child := range *node.Children { + displayName := misc.Colorize(child.DisplayName, misc.STYLE_ITEM_SELECTED.FgStr, "", "-") + child.TreeNode.SetText(displayName) + } + } else { + displayName := misc.Colorize(node.DisplayName, misc.STYLE_ITEM.FgStr, misc.STYLE_ITEM.BgStr, "-") + node.TreeNode.SetText(displayName) + for _, child := range *node.Children { + if child.Type == "task-ref" { + // Use magenta (TABLE_HEADER color) for task refs + displayName := misc.Colorize(child.DisplayName, misc.STYLE_TABLE_HEADER.FgStr, "", "-") + child.TreeNode.SetText(displayName) + } else { + displayName := misc.Colorize(child.DisplayName, misc.STYLE_ITEM.FgStr, misc.STYLE_ITEM.BgStr, "-") + child.TreeNode.SetText(displayName) + } + } + } + } +} + +func (t *TTree) UpdateServers(nodes []TNode) { + t.RootNode.ClearChildren() + t.List = []*TNode{} + + for _, parentNode := range nodes { + if parentNode.Type == "group" { + // Group node (IP prefix or hostname domain) + displayName := misc.Colorize(parentNode.DisplayName, misc.STYLE_TABLE_HEADER.FgStr, "", "-") + parentTreeNode := tview.NewTreeNode(displayName). + SetReference(""). + SetSelectable(false) + t.RootNode.AddChild(parentTreeNode) + + parentListNode := &TNode{ + DisplayName: parentNode.DisplayName, + ID: "", + Type: "group", + TreeNode: parentTreeNode, + Children: &[]TNode{}, + } + t.List = append(t.List, parentListNode) + + // Children (actual servers) + if parentNode.Children != nil { + for _, childNode := range *parentNode.Children { + childDisplayName := misc.Colorize(childNode.DisplayName, misc.STYLE_ITEM.FgStr, misc.STYLE_ITEM.BgStr, "-") + childTreeNode := tview.NewTreeNode(childDisplayName). + SetReference(childNode.ID). + SetSelectable(true) + parentTreeNode.AddChild(childTreeNode) + + listChildNode := &TNode{ + DisplayName: childNode.DisplayName, + ID: childNode.ID, + Type: "server", + TreeNode: childTreeNode, + Children: &[]TNode{}, + } + *parentListNode.Children = append(*parentListNode.Children, *listChildNode) + } + } + } else { + // Flat server node + displayName := misc.Colorize(parentNode.DisplayName, misc.STYLE_ITEM.FgStr, misc.STYLE_ITEM.BgStr, "-") + parentTreeNode := tview.NewTreeNode(displayName). + SetReference(parentNode.ID). + SetSelectable(true) + t.RootNode.AddChild(parentTreeNode) + + parentListNode := &TNode{ + DisplayName: parentNode.DisplayName, + ID: parentNode.ID, + Type: "server", + TreeNode: parentTreeNode, + Children: &[]TNode{}, + } + t.List = append(t.List, parentListNode) + + // Host info as child + if parentNode.Children != nil { + for _, childNode := range *parentNode.Children { + childDisplayName := misc.Colorize(childNode.DisplayName, misc.STYLE_TABLE_HEADER.FgStr, "", "-") + childTreeNode := tview.NewTreeNode(childDisplayName). + SetSelectable(false) + parentTreeNode.AddChild(childTreeNode) + + listChildNode := &TNode{ + DisplayName: childNode.DisplayName, + ID: childNode.ID, + Type: "host", + TreeNode: childTreeNode, + Children: &[]TNode{}, + } + *parentListNode.Children = append(*parentListNode.Children, *listChildNode) + } + } + } + } +} + +func (t *TTree) UpdateServersStyle() { + for _, node := range t.List { + if node.Type == "group" { + // Group headers stay magenta + displayName := misc.Colorize(node.DisplayName, misc.STYLE_TABLE_HEADER.FgStr, "", "-") + node.TreeNode.SetText(displayName) + for _, child := range *node.Children { + if t.IsNodeSelected(child.ID) { + displayName := misc.Colorize(child.DisplayName, misc.STYLE_ITEM_SELECTED.FgStr, "", "-") + child.TreeNode.SetText(displayName) + } else { + displayName := misc.Colorize(child.DisplayName, misc.STYLE_ITEM.FgStr, misc.STYLE_ITEM.BgStr, "-") + child.TreeNode.SetText(displayName) + } + } + } else { + // Flat server + if t.IsNodeSelected(node.ID) { + displayName := misc.Colorize(node.DisplayName, misc.STYLE_ITEM_SELECTED.FgStr, "", "-") + node.TreeNode.SetText(displayName) + for _, child := range *node.Children { + displayName := misc.Colorize(child.DisplayName, misc.STYLE_ITEM_SELECTED.FgStr, "", "-") + child.TreeNode.SetText(displayName) + } + } else { + displayName := misc.Colorize(node.DisplayName, misc.STYLE_ITEM.FgStr, misc.STYLE_ITEM.BgStr, "-") + node.TreeNode.SetText(displayName) + for _, child := range *node.Children { + // Host info in magenta + displayName := misc.Colorize(child.DisplayName, misc.STYLE_TABLE_HEADER.FgStr, "", "-") + child.TreeNode.SetText(displayName) + } + } + } + } +} + +func (t *TTree) ToggleSelectCurrentNode(id string) { + for i := 0; i < len(t.List); i++ { + node := t.List[i] + if node.ID == id { + t.setNodeSelect(node) + return + } + // Also check children for grouped servers + for _, child := range *node.Children { + if child.ID == id { + t.setServerNodeSelect(&child) + return + } + } + } +} + +func (t *TTree) setServerNodeSelect(node *TNode) { + if t.IsNodeSelected(node.ID) { + displayName := misc.Colorize(node.DisplayName, misc.STYLE_ITEM_SELECTED.FgStr, "", "-") + node.TreeNode.SetText(displayName) + } else { + displayName := misc.Colorize(node.DisplayName, misc.STYLE_ITEM.FgStr, misc.STYLE_ITEM.BgStr, "-") + node.TreeNode.SetText(displayName) + } +} + +func (t *TTree) setNodeSelect(node *TNode) { + if t.IsNodeSelected(node.ID) { + displayName := misc.Colorize(node.DisplayName, misc.STYLE_ITEM_SELECTED.FgStr, "", "-") + node.TreeNode.SetText(displayName) + for _, childNode := range *node.Children { + displayName := misc.Colorize(childNode.DisplayName, misc.STYLE_ITEM_SELECTED.FgStr, "", "-") + childNode.TreeNode.SetText(displayName) + } + return + } + + displayName := misc.Colorize(node.DisplayName, misc.STYLE_ITEM.FgStr, misc.STYLE_ITEM.BgStr, "-") + node.TreeNode.SetText(displayName) + for _, childNode := range *node.Children { + if childNode.Type == "task-ref" { + // Use magenta (TABLE_HEADER color) for task refs + displayName := misc.Colorize(childNode.DisplayName, misc.STYLE_TABLE_HEADER.FgStr, "", "-") + childNode.TreeNode.SetText(displayName) + } else { + displayName := misc.Colorize(childNode.DisplayName, misc.STYLE_ITEM.FgStr, misc.STYLE_ITEM.BgStr, "-") + childNode.TreeNode.SetText(displayName) + } + } +} + +func (t *TTree) FocusFirst() { + children := t.RootNode.GetChildren() + if len(children) > 0 { + t.Tree.SetCurrentNode(children[0]) + } +} + +func (t *TTree) FocusLast() { + children := t.RootNode.GetChildren() + if len(children) == 0 { + return + } + last := children[len(children)-1] + ref := last.GetReference() + + if ref == nil || ref.(string) == "" { + children = last.GetChildren() + if len(children) > 0 { + last = children[len(children)-1] + } + } + + t.Tree.SetCurrentNode(last) +} + +func (t *TTree) ClearFilter() { + CloseFilter(t.Filter) + *t.FilterValue = "" +} + +func (t *TTree) applyFilter() { + *t.FilterValue = t.Filter.GetText() +} + +func (t *TTree) getVisibleNodes() []*tview.TreeNode { + var nodes []*tview.TreeNode + var walk func(*tview.TreeNode) + walk = func(node *tview.TreeNode) { + if node == nil { + return + } + ref := node.GetReference() + if ref != nil && ref.(string) != "" { + nodes = append(nodes, node) + } + if node.IsExpanded() { + for _, child := range node.GetChildren() { + walk(child) + } + } + } + walk(t.RootNode) + return nodes +} + +func (t *TTree) findNodeIndex(nodes []*tview.TreeNode, target *tview.TreeNode) int { + for i, node := range nodes { + if node == target { + return i + } + } + return 0 +} diff --git a/core/tui/misc/tui_block.go b/core/tui/misc/tui_block.go new file mode 100644 index 0000000..ed67bc7 --- /dev/null +++ b/core/tui/misc/tui_block.go @@ -0,0 +1,197 @@ +package misc + +import ( + "bufio" + "fmt" + "strconv" + "strings" +) + +// Block theme colors (mani-style) +var ( + BlockKeyColor = "#5f87d7" // Blue for keys + BlockSeparatorColor = "#5f87d7" // Blue for separators + BlockValueColor = "" // Default for values + BlockTrueColor = "#00af5f" // Green for true + BlockFalseColor = "#d75f5f" // Red for false +) + +// FormatKeyValue formats a key-value pair with tview color tags +func FormatKeyValue(padding bool, prefix, key, separator, value string, valueIsTrue *bool) string { + var valueColor string + if valueIsTrue != nil { + if *valueIsTrue { + valueColor = BlockTrueColor + } else { + valueColor = BlockFalseColor + } + } else { + valueColor = BlockValueColor + } + + keyStr := fmt.Sprintf("[%s:-:-]%s%s[-:-:-]", BlockKeyColor, prefix, key) + sepStr := fmt.Sprintf("[%s:-:-]%s[-:-:-]", BlockSeparatorColor, separator) + + var valueStr string + if valueColor != "" { + valueStr = fmt.Sprintf("[%s:-:-]%s[-:-:-]", valueColor, value) + } else { + valueStr = value + } + + str := fmt.Sprintf("%s%s %s\n", keyStr, sepStr, valueStr) + if padding { + return fmt.Sprintf("%4s%s", " ", str) + } + return str +} + +// FormatCmd formats a command with indentation +func FormatCmd(cmd string) string { + output := "" + scanner := bufio.NewScanner(strings.NewReader(cmd)) + for scanner.Scan() { + output += fmt.Sprintf("%4s%s\n", " ", scanner.Text()) + } + return output +} + +// BoolPtr returns a pointer to a bool +func BoolPtr(b bool) *bool { + return &b +} + +// SubTaskInfo holds info about a sub-task for display +type SubTaskInfo struct { + Name string + Desc string + Cmd string + Task string // reference to another task + IsRef bool // true if this is a task reference +} + +// FormatTaskBlock formats a task description in mani-style block format +func FormatTaskBlock(name, desc, cmd string, local bool, tty bool, attach bool, workDir string, shell string, envs []string, tags []string, subTasks []SubTaskInfo, spec, target, theme string) string { + output := "\n" + + output += FormatKeyValue(false, "", "name", ":", name, nil) + + if desc != "" { + output += FormatKeyValue(false, "", "description", ":", desc, nil) + } + + output += FormatKeyValue(false, "", "local", ":", strconv.FormatBool(local), BoolPtr(local)) + output += FormatKeyValue(false, "", "tty", ":", strconv.FormatBool(tty), BoolPtr(tty)) + output += FormatKeyValue(false, "", "attach", ":", strconv.FormatBool(attach), BoolPtr(attach)) + + if workDir != "" { + output += FormatKeyValue(false, "", "work_dir", ":", workDir, nil) + } + + if shell != "" { + output += FormatKeyValue(false, "", "shell", ":", shell, nil) + } + + if spec != "" { + output += FormatKeyValue(false, "", "spec", ":", spec, nil) + } + + if target != "" { + output += FormatKeyValue(false, "", "target", ":", target, nil) + } + + if theme != "" { + output += FormatKeyValue(false, "", "theme", ":", theme, nil) + } + + if len(tags) > 0 { + output += FormatKeyValue(false, "", "tags", ":", strings.Join(tags, ", "), nil) + } + + if len(envs) > 0 { + output += FormatKeyValue(false, "", "env", ":", "", nil) + for _, env := range envs { + parts := strings.SplitN(strings.TrimSuffix(env, "\n"), "=", 2) + if len(parts) == 2 { + output += FormatKeyValue(true, "", parts[0], ":", parts[1], nil) + } + } + } + + // Show cmd if it's a simple command task + if cmd != "" { + output += FormatKeyValue(false, "", "cmd", ":", "", nil) + output += FormatCmd(cmd) + } + + // Show sub-tasks/task references + if len(subTasks) > 0 { + output += FormatKeyValue(false, "", "tasks", ":", "", nil) + for _, st := range subTasks { + if st.IsRef && st.Task != "" { + // Task reference + if st.Name != "" { + output += FormatKeyValue(true, "- ", st.Name, ":", fmt.Sprintf("task: %s", st.Task), nil) + } else { + output += FormatKeyValue(true, "- ", "task", ":", st.Task, nil) + } + } else if st.Cmd != "" { + // Inline command + if st.Name != "" { + if st.Desc != "" { + output += FormatKeyValue(true, "- ", st.Name, ":", st.Desc, nil) + } else { + output += FormatKeyValue(true, "- ", st.Name, ":", "(cmd)", nil) + } + } else { + output += FormatKeyValue(true, "- ", "cmd", "", "", nil) + } + } + } + } + + output += "\n" + return output +} + +// FormatServerBlock formats a server description in mani-style block format +func FormatServerBlock(name, desc, host, user string, port uint16, local bool, tags []string, bastions []string, identityFile string, workDir string) string { + output := "\n" + + output += FormatKeyValue(false, "", "name", ":", name, nil) + + if desc != "" { + output += FormatKeyValue(false, "", "description", ":", desc, nil) + } + + output += FormatKeyValue(false, "", "local", ":", strconv.FormatBool(local), BoolPtr(local)) + + if !local { + output += FormatKeyValue(false, "", "host", ":", host, nil) + + if user != "" { + output += FormatKeyValue(false, "", "user", ":", user, nil) + } + + output += FormatKeyValue(false, "", "port", ":", strconv.FormatUint(uint64(port), 10), nil) + + if identityFile != "" { + output += FormatKeyValue(false, "", "identity_file", ":", identityFile, nil) + } + + if len(bastions) > 0 { + output += FormatKeyValue(false, "", "bastions", ":", strings.Join(bastions, ", "), nil) + } + } + + if workDir != "" { + output += FormatKeyValue(false, "", "work_dir", ":", workDir, nil) + } + + if len(tags) > 0 { + output += FormatKeyValue(false, "", "tags", ":", strings.Join(tags, ", "), nil) + } + + output += "\n" + return output +} diff --git a/core/tui/misc/tui_event.go b/core/tui/misc/tui_event.go new file mode 100644 index 0000000..7593a7e --- /dev/null +++ b/core/tui/misc/tui_event.go @@ -0,0 +1,55 @@ +package misc + +import ( + "sync" +) + +type Event struct { + Name string + Data interface{} +} + +type EventListener func(Event) + +type EventEmitter struct { + listeners map[string][]EventListener + mu sync.RWMutex +} + +func NewEventEmitter() *EventEmitter { + return &EventEmitter{ + listeners: make(map[string][]EventListener), + } +} + +func (ee *EventEmitter) Subscribe(eventName string, listener EventListener) { + ee.mu.Lock() + defer ee.mu.Unlock() + ee.listeners[eventName] = append(ee.listeners[eventName], listener) +} + +func (ee *EventEmitter) Publish(event Event) { + ee.mu.RLock() + defer ee.mu.RUnlock() + if listeners, ok := ee.listeners[event.Name]; ok { + for _, listener := range listeners { + go listener(event) + } + } +} + +func (ee *EventEmitter) PublishAndWait(event Event) { + ee.mu.RLock() + listeners := ee.listeners[event.Name] + ee.mu.RUnlock() + + var wg sync.WaitGroup + for _, listener := range listeners { + wg.Add(1) + go func(l EventListener) { + defer wg.Done() + l(event) + }(listener) + } + wg.Wait() +} diff --git a/core/tui/misc/tui_focus.go b/core/tui/misc/tui_focus.go new file mode 100644 index 0000000..272fdfd --- /dev/null +++ b/core/tui/misc/tui_focus.go @@ -0,0 +1,85 @@ +package misc + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type TItem struct { + Primitive tview.Primitive + Box *tview.Box +} + +func FocusNext(elements []*TItem) *tview.Primitive { + currentFocus := App.GetFocus() + nextIndex := -1 + var nextFocusItem TItem + for i, element := range elements { + if element.Primitive == currentFocus { + nextIndex = (i + 1) % len(elements) + nextFocusItem = *elements[nextIndex] + } + element.Box.SetBorderColor(STYLE_BORDER.Fg) + } + + // In-case no nextIndex is found, use the previous page as base to find nextFocusItem + if nextIndex < 0 { + for i, element := range elements { + if element.Primitive == PreviousPane { + nextIndex = (i + 1) % len(elements) + nextFocusItem = *elements[nextIndex] + } + } + } + + // Set border and focus + nextFocusItem.Box.SetBorderColor(STYLE_BORDER_FOCUS.Fg) + App.SetFocus(nextFocusItem.Primitive) + + return &nextFocusItem.Primitive +} + +func FocusPrevious(elements []*TItem) *tview.Primitive { + currentFocus := App.GetFocus() + var prevIndex int + var nextFocusItem TItem + for i, element := range elements { + if element.Primitive == currentFocus { + prevIndex = (i - 1 + len(elements)) % len(elements) + nextFocusItem = *elements[prevIndex] + } + element.Box.SetBorderColor(STYLE_BORDER.Fg) + } + + // In-case no prevIndex is found, use the previous page as base to find nextFocusItem + for i, element := range elements { + if element.Primitive == PreviousPane { + prevIndex = (i - 1 + len(elements)) % len(elements) + nextFocusItem = *elements[prevIndex] + } + } + + // Set border and focus + nextFocusItem.Box.SetBorderColor(STYLE_BORDER_FOCUS.Fg) + App.SetFocus(nextFocusItem.Primitive) + + return &nextFocusItem.Primitive +} + +func FocusPage(event *tcell.EventKey, focusable []*TItem) { + i := int(event.Rune()-'0') - 1 + if i < len(focusable) { + App.SetFocus(focusable[i].Box) + } +} + +func FocusPreviousPage() { + App.SetFocus(PreviousPane) +} + +func GetTUIItem(primitive tview.Primitive, box *tview.Box) *TItem { + return &TItem{ + Primitive: primitive, + Box: box, + } +} diff --git a/core/tui/misc/tui_global.go b/core/tui/misc/tui_global.go new file mode 100644 index 0000000..8e56a42 --- /dev/null +++ b/core/tui/misc/tui_global.go @@ -0,0 +1,30 @@ +package misc + +import ( + "github.com/alajmo/sake/core/dao" + "github.com/rivo/tview" +) + +var Config *dao.Config + +var App *tview.Application +var Pages *tview.Pages +var MainPage *tview.Pages +var PreviousPane tview.Primitive + +// Nav buttons +var ServerBtn *tview.Button +var TaskBtn *tview.Button +var RunBtn *tview.Button +var ExecBtn *tview.Button +var HelpBtn *tview.Button + +// Last focus per page +var ServersLastFocus *tview.Primitive +var TasksLastFocus *tview.Primitive +var RunLastFocus *tview.Primitive +var ExecLastFocus *tview.Primitive + +// Misc +var HelpModal *tview.Modal +var Search *tview.InputField diff --git a/core/tui/misc/tui_theme.go b/core/tui/misc/tui_theme.go new file mode 100644 index 0000000..fece77a --- /dev/null +++ b/core/tui/misc/tui_theme.go @@ -0,0 +1,261 @@ +package misc + +import ( + "fmt" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// StyleOption holds styling information for TUI elements +type StyleOption struct { + Fg tcell.Color + Bg tcell.Color + Attr tcell.AttrMask + Align int + + FgStr string + BgStr string + AttrStr string + + Style tcell.Style +} + +// Default styles +var STYLE_DEFAULT StyleOption + +// Border styles +var STYLE_BORDER StyleOption +var STYLE_BORDER_FOCUS StyleOption + +// Title styles +var STYLE_TITLE StyleOption +var STYLE_TITLE_ACTIVE StyleOption + +// Table Header +var STYLE_TABLE_HEADER StyleOption + +// Item styles +var STYLE_ITEM StyleOption +var STYLE_ITEM_FOCUSED StyleOption +var STYLE_ITEM_SELECTED StyleOption + +// Button styles +var STYLE_BUTTON StyleOption +var STYLE_BUTTON_ACTIVE StyleOption + +// Search styles +var STYLE_SEARCH_LABEL StyleOption +var STYLE_SEARCH_TEXT StyleOption + +// Filter styles +var STYLE_FILTER_LABEL StyleOption +var STYLE_FILTER_TEXT StyleOption + +// Shortcut styles +var STYLE_SHORTCUT_LABEL StyleOption +var STYLE_SHORTCUT_TEXT StyleOption + +// LoadStyles initializes all TUI styles with mani-style defaults +func LoadStyles() { + // Mani-style colors + magenta := tcell.GetColor("#d787ff") + darkGray := tcell.GetColor("#262626") + selectedBlue := tcell.GetColor("#5f87d7") + labelYellow := tcell.GetColor("#d7d75f") + shortcutGreen := tcell.GetColor("#00af5f") + nearBlack := tcell.GetColor("#080808") + + // Default + STYLE_DEFAULT = StyleOption{ + Fg: tcell.ColorDefault, + Bg: tcell.ColorDefault, + Attr: tcell.AttrNone, + Align: tview.AlignLeft, + FgStr: "", + BgStr: "", + Style: tcell.StyleDefault, + } + + // Border + STYLE_BORDER = StyleOption{ + Fg: tcell.ColorDefault, + Bg: tcell.ColorDefault, + Attr: tcell.AttrNone, + FgStr: "", + BgStr: "", + Style: tcell.StyleDefault, + } + + STYLE_BORDER_FOCUS = StyleOption{ + Fg: magenta, + Bg: tcell.ColorDefault, + Attr: tcell.AttrNone, + FgStr: "#d787ff", + BgStr: "", + Style: tcell.StyleDefault.Foreground(magenta), + } + + // Title + STYLE_TITLE = StyleOption{ + Fg: tcell.ColorDefault, + Bg: tcell.ColorDefault, + Attr: tcell.AttrNone, + Align: tview.AlignCenter, + FgStr: "", + BgStr: "", + Style: tcell.StyleDefault, + } + + STYLE_TITLE_ACTIVE = StyleOption{ + Fg: tcell.ColorBlack, + Bg: magenta, + Attr: tcell.AttrNone, + Align: tview.AlignCenter, + FgStr: "#000000", + BgStr: "#d787ff", + Style: tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(magenta), + } + + // Table Header + STYLE_TABLE_HEADER = StyleOption{ + Fg: magenta, + Bg: tcell.ColorDefault, + Attr: tcell.AttrBold, + Align: tview.AlignLeft, + FgStr: "#d787ff", + BgStr: "", + Style: tcell.StyleDefault.Foreground(magenta).Attributes(tcell.AttrBold), + } + + // Item + STYLE_ITEM = StyleOption{ + Fg: tcell.ColorDefault, + Bg: tcell.ColorDefault, + Attr: tcell.AttrNone, + FgStr: "", + BgStr: "", + Style: tcell.StyleDefault, + } + + STYLE_ITEM_FOCUSED = StyleOption{ + Fg: tcell.ColorWhite, + Bg: darkGray, + Attr: tcell.AttrNone, + FgStr: "#ffffff", + BgStr: "#262626", + Style: tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(darkGray), + } + + STYLE_ITEM_SELECTED = StyleOption{ + Fg: selectedBlue, + Bg: tcell.ColorDefault, + Attr: tcell.AttrNone, + FgStr: "#5f87d7", + BgStr: "", + Style: tcell.StyleDefault.Foreground(selectedBlue), + } + + // Button + STYLE_BUTTON = StyleOption{ + Fg: tcell.ColorDefault, + Bg: tcell.ColorDefault, + Attr: tcell.AttrNone, + FgStr: "", + BgStr: "", + Style: tcell.StyleDefault, + } + + STYLE_BUTTON_ACTIVE = StyleOption{ + Fg: nearBlack, + Bg: magenta, + Attr: tcell.AttrNone, + FgStr: "#080808", + BgStr: "#d787ff", + Style: tcell.StyleDefault.Foreground(nearBlack).Background(magenta), + } + + // Search + STYLE_SEARCH_LABEL = StyleOption{ + Fg: labelYellow, + Bg: tcell.ColorDefault, + Attr: tcell.AttrBold, + FgStr: "#d7d75f", + BgStr: "", + Style: tcell.StyleDefault.Foreground(labelYellow).Attributes(tcell.AttrBold), + } + + STYLE_SEARCH_TEXT = StyleOption{ + Fg: tcell.ColorDefault, + Bg: tcell.ColorDefault, + Attr: tcell.AttrNone, + FgStr: "", + BgStr: "", + Style: tcell.StyleDefault, + } + + // Filter + STYLE_FILTER_LABEL = StyleOption{ + Fg: labelYellow, + Bg: tcell.ColorDefault, + Attr: tcell.AttrBold, + FgStr: "#d7d75f", + BgStr: "", + Style: tcell.StyleDefault.Foreground(labelYellow).Attributes(tcell.AttrBold), + } + + STYLE_FILTER_TEXT = StyleOption{ + Fg: tcell.ColorDefault, + Bg: tcell.ColorDefault, + Attr: tcell.AttrNone, + FgStr: "", + BgStr: "", + Style: tcell.StyleDefault, + } + + // Shortcut + STYLE_SHORTCUT_LABEL = StyleOption{ + Fg: shortcutGreen, + Bg: tcell.ColorDefault, + Attr: tcell.AttrNone, + FgStr: "#00af5f", + BgStr: "", + Style: tcell.StyleDefault.Foreground(shortcutGreen), + } + + STYLE_SHORTCUT_TEXT = StyleOption{ + Fg: tcell.ColorDefault, + Bg: tcell.ColorDefault, + Attr: tcell.AttrNone, + FgStr: "", + BgStr: "", + Style: tcell.StyleDefault, + } +} + +// Colorize wraps a value with tview color tags +func Colorize(value, fg, bg, attr string) string { + return " [-:-:-]" + fmt.Sprintf("[%s:%s:%s]%s", fg, bg, attr, value) + "[-:-:-] " +} + +// ColorizeTitle wraps a title with tview color tags +func ColorizeTitle(value, fg, bg, attr string) string { + return " [-:-:-]" + fmt.Sprintf("[%s:%s:%s] %s ", fg, bg, attr, value) + "[-:-:-] " +} + +// PadString adds space padding around a string +func PadString(name string) string { + return " " + strings.TrimSpace(name) + " " +} + +// SetActive updates the title style based on active state +func SetActive(box *tview.Box, title string, active bool) { + if active { + box.SetTitle(ColorizeTitle(title, STYLE_TITLE_ACTIVE.FgStr, STYLE_TITLE_ACTIVE.BgStr, "b")) + box.SetBorderColor(STYLE_BORDER_FOCUS.Fg) + } else { + box.SetTitle(ColorizeTitle(title, STYLE_TITLE.FgStr, STYLE_TITLE.BgStr, "-")) + box.SetBorderColor(STYLE_BORDER.Fg) + } +} diff --git a/core/tui/misc/tui_utils.go b/core/tui/misc/tui_utils.go new file mode 100644 index 0000000..7f5db6e --- /dev/null +++ b/core/tui/misc/tui_utils.go @@ -0,0 +1,68 @@ +package misc + +import ( + "strings" + + "github.com/rivo/tview" +) + +// StyleFormat applies formatting to a string (uppercase, lowercase, title case) +func StyleFormat(value, format string) string { + switch strings.ToLower(format) { + case "upper", "uppercase": + return strings.ToUpper(value) + case "lower", "lowercase": + return strings.ToLower(value) + case "title", "titlecase": + return strings.Title(value) + default: + return value + } +} + +// GetModalSize calculates appropriate modal dimensions based on content +func GetModalSize(content string, minWidth, minHeight, maxWidth, maxHeight int) (int, int) { + lines := strings.Split(content, "\n") + height := len(lines) + 4 // padding + + width := minWidth + for _, line := range lines { + lineLen := len(line) + 4 + if lineLen > width { + width = lineLen + } + } + + if width > maxWidth { + width = maxWidth + } + if height > maxHeight { + height = maxHeight + } + if width < minWidth { + width = minWidth + } + if height < minHeight { + height = minHeight + } + + return width, height +} + +// SetupStyles configures global tview styles +func SetupStyles() { + // Foreground / Background + tview.Styles.PrimaryTextColor = STYLE_DEFAULT.Fg + tview.Styles.PrimitiveBackgroundColor = STYLE_DEFAULT.Bg + + // Borders Colors + tview.Styles.BorderColor = STYLE_BORDER.Fg + + // Border style + tview.Borders.HorizontalFocus = tview.BoxDrawingsLightHorizontal + tview.Borders.VerticalFocus = tview.BoxDrawingsLightVertical + tview.Borders.TopLeftFocus = tview.BoxDrawingsLightDownAndRight + tview.Borders.TopRightFocus = tview.BoxDrawingsLightDownAndLeft + tview.Borders.BottomLeftFocus = tview.BoxDrawingsLightUpAndRight + tview.Borders.BottomRightFocus = tview.BoxDrawingsLightUpAndLeft +} diff --git a/core/tui/misc/tui_writer.go b/core/tui/misc/tui_writer.go new file mode 100644 index 0000000..334b6d1 --- /dev/null +++ b/core/tui/misc/tui_writer.go @@ -0,0 +1,28 @@ +package misc + +import ( + "io" + "sync" + + "github.com/rivo/tview" +) + +// ThreadSafeWriter wraps a tview.ANSIWriter to make it thread-safe +type ThreadSafeWriter struct { + writer io.Writer + mutex sync.Mutex +} + +// NewThreadSafeWriter creates a new thread-safe writer for tview +func NewThreadSafeWriter(view *tview.TextView) *ThreadSafeWriter { + return &ThreadSafeWriter{ + writer: tview.ANSIWriter(view), + } +} + +// Write implements io.Writer interface in a thread-safe manner +func (w *ThreadSafeWriter) Write(p []byte) (n int, err error) { + w.mutex.Lock() + defer w.mutex.Unlock() + return w.writer.Write(p) +} diff --git a/core/tui/pages.go b/core/tui/pages.go new file mode 100644 index 0000000..0ee7a82 --- /dev/null +++ b/core/tui/pages.go @@ -0,0 +1,131 @@ +package tui + +import ( + "github.com/alajmo/sake/core/dao" + "github.com/alajmo/sake/core/tui/components" + "github.com/alajmo/sake/core/tui/misc" + "github.com/alajmo/sake/core/tui/pages" + "github.com/alajmo/sake/core/tui/views" + "github.com/rivo/tview" +) + +func createPages( + servers []dao.Server, + serverTags []string, + tasks []dao.Task, +) *tview.Pages { + appPages := tview.NewPages() + navPane := createNav() + search := components.CreateSearch() + misc.Search = search + + runPage := pages.CreateRunPage(tasks, servers, serverTags) + execPage := pages.CreateExecPage(servers, serverTags) + serversPage := pages.CreateServersPage(servers, serverTags) + tasksPage := pages.CreateTasksPage(tasks) + + misc.MainPage = tview.NewPages(). + AddPage("run", runPage, true, true). + AddPage("exec", execPage, true, false). + AddPage("servers", serversPage, true, false). + AddPage("tasks", tasksPage, true, false) + + mainLayout := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(navPane, 2, 1, false). + AddItem(misc.MainPage, 0, 1, true) + appPages.AddPage("main", mainLayout, true, true) + + SwitchToPage("run") + + return appPages +} + +func createNav() *tview.Flex { + // Buttons + misc.RunBtn = components.CreateButton("Run") + misc.RunBtn.SetSelectedFunc(func() { + SwitchToPage("run") + misc.App.SetFocus(*misc.RunLastFocus) + }) + + misc.ExecBtn = components.CreateButton("Exec") + misc.ExecBtn.SetSelectedFunc(func() { + SwitchToPage("exec") + misc.App.SetFocus(*misc.ExecLastFocus) + }) + + misc.ServerBtn = components.CreateButton("Servers") + misc.ServerBtn.SetSelectedFunc(func() { + SwitchToPage("servers") + misc.App.SetFocus(*misc.ServersLastFocus) + }) + + misc.TaskBtn = components.CreateButton("Tasks") + misc.TaskBtn.SetSelectedFunc(func() { + SwitchToPage("tasks") + misc.App.SetFocus(*misc.TasksLastFocus) + }) + + misc.HelpBtn = components.CreateButton("Help") + misc.HelpBtn.SetSelectedFunc(func() { + views.ShowHelpModal() + }) + + // Left + left := tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(misc.RunBtn, 7, 0, false). + AddItem(misc.ExecBtn, 8, 0, false). + AddItem(misc.ServerBtn, 11, 0, false). + AddItem(misc.TaskBtn, 9, 0, false) + + // Right + right := tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(misc.HelpBtn, 8, 0, false) + + // Nav + navPane := tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(left, 0, 1, false). + AddItem(nil, 0, 1, false). + AddItem(right, 8, 0, false) + navPane.SetBorderPadding(0, 1, 1, 1) + + return navPane +} + +func SwitchToPage(pageName string) { + misc.MainPage.SwitchToPage(pageName) + + switch pageName { + case "servers": + components.SetActiveButtonStyle(misc.ServerBtn) + components.SetInactiveButtonStyle(misc.TaskBtn) + components.SetInactiveButtonStyle(misc.RunBtn) + components.SetInactiveButtonStyle(misc.ExecBtn) + components.SetInactiveButtonStyle(misc.HelpBtn) + case "tasks": + components.SetActiveButtonStyle(misc.TaskBtn) + components.SetInactiveButtonStyle(misc.ServerBtn) + components.SetInactiveButtonStyle(misc.RunBtn) + components.SetInactiveButtonStyle(misc.ExecBtn) + components.SetInactiveButtonStyle(misc.HelpBtn) + case "run": + components.SetActiveButtonStyle(misc.RunBtn) + components.SetInactiveButtonStyle(misc.ServerBtn) + components.SetInactiveButtonStyle(misc.TaskBtn) + components.SetInactiveButtonStyle(misc.ExecBtn) + components.SetInactiveButtonStyle(misc.HelpBtn) + case "exec": + components.SetActiveButtonStyle(misc.ExecBtn) + components.SetInactiveButtonStyle(misc.ServerBtn) + components.SetInactiveButtonStyle(misc.TaskBtn) + components.SetInactiveButtonStyle(misc.RunBtn) + components.SetInactiveButtonStyle(misc.HelpBtn) + } + + _, page := misc.MainPage.GetFrontPage() + misc.App.SetFocus(page) +} diff --git a/core/tui/pages/tui_exec.go b/core/tui/pages/tui_exec.go new file mode 100644 index 0000000..36317c1 --- /dev/null +++ b/core/tui/pages/tui_exec.go @@ -0,0 +1,554 @@ +package pages + +import ( + "fmt" + "io" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/alajmo/sake/core" + "github.com/alajmo/sake/core/dao" + "github.com/alajmo/sake/core/run" + "github.com/alajmo/sake/core/tui/components" + "github.com/alajmo/sake/core/tui/misc" + "github.com/alajmo/sake/core/tui/views" +) + +type TExecPage struct { + focusable []*misc.TItem + serverData *views.TServer + commandArea *tview.TextArea + outputView *components.TOutput + spec *views.TSpec +} + +func CreateExecPage( + servers []dao.Server, + serverTags []string, +) *tview.Flex { + e := &TExecPage{} + + // Data + e.serverData = views.CreateServersData( + servers, + serverTags, + []string{"Server", "Host", "Tags"}, + 1, + true, + true, + true, + len(serverTags) > 0, + ) + + // Command input area + e.commandArea = tview.NewTextArea() + e.commandArea.SetBorder(true) + e.commandArea.SetTitle(" Command ") + e.commandArea.SetTitleAlign(tview.AlignLeft) + e.commandArea.SetBorderColor(misc.STYLE_BORDER.Fg) + e.commandArea.SetPlaceholder("Enter command to execute...") + + // Output view + e.outputView = &components.TOutput{Title: "Output"} + e.outputView.Create() + + // Spec options + e.spec = views.CreateSpecView() + + // Shortcut info view + execInfoView := views.CreateExecInfoView() + + // Left panel: Command input + Output + leftPanel := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(e.commandArea, 5, 0, true). + AddItem(e.outputView.Root, 0, 1, false) + + // Right panel: Servers with table/tree toggle + isServerTable := e.serverData.ServerStyle == "server-table" + serverPages := tview.NewPages(). + AddPage("server-table", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(e.serverData.ServerTableView.Root, 0, 1, true), true, isServerTable). + AddPage("server-tree", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(e.serverData.ServerTreeView.Root, 0, 1, false), true, !isServerTable) + + serverViewPane := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(serverPages, 0, 1, true) + + // Handle Ctrl+E for server view toggle + serverViewPane.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if misc.App.GetFocus() == misc.Search { + return event + } + + switch event.Key() { + case tcell.KeyCtrlE: + if e.serverData.ServerStyle == "server-table" { + e.serverData.ServerStyle = "server-tree" + } else { + e.serverData.ServerStyle = "server-table" + } + serverPages.SwitchToPage(e.serverData.ServerStyle) + e.focusable = e.updateExecFocusable() + // Find the server view in focusable and focus it + for _, item := range e.focusable { + if item.Primitive == e.serverData.ServerTableView.Table || + item.Primitive == e.serverData.ServerTreeView.Tree { + misc.App.SetFocus(item.Primitive) + misc.ExecLastFocus = &item.Primitive + break + } + } + return nil + } + return event + }) + + rightPanel := tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(serverViewPane, 0, 1, true) + + if e.serverData.TagView != nil && len(serverTags) > 0 { + rightPanel.AddItem(e.serverData.TagView.Root, 20, 0, false) + } + + // Main layout + mainLayout := tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(leftPanel, 0, 1, true). + AddItem(rightPanel, 0, 1, false) + + // Page with info at bottom + page := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(mainLayout, 0, 1, true). + AddItem(execInfoView, 1, 0, false). + AddItem(misc.Search, 1, 0, false) + + // Focus + e.focusable = e.updateExecFocusable() + misc.ExecLastFocus = &e.focusable[0].Primitive + + // Focus handlers for command area + e.commandArea.SetFocusFunc(func() { + misc.PreviousPane = e.commandArea + e.commandArea.SetBorderColor(misc.STYLE_BORDER_FOCUS.Fg) + }) + e.commandArea.SetBlurFunc(func() { + e.commandArea.SetBorderColor(misc.STYLE_BORDER.Fg) + }) + + // Shortcuts + page.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyCtrlR: + e.execCommand() + return nil + case tcell.KeyCtrlO: + components.OpenModal("spec-modal", "Options", e.spec.View, 35, 14) + return nil + case tcell.KeyTab: + // Don't switch focus if in command area and not at end + if misc.App.GetFocus() == e.commandArea { + // Allow Tab to cycle focus + } + nextPrimitive := misc.FocusNext(e.focusable) + misc.ExecLastFocus = nextPrimitive + return nil + case tcell.KeyBacktab: + nextPrimitive := misc.FocusPrevious(e.focusable) + misc.ExecLastFocus = nextPrimitive + return nil + case tcell.KeyCtrlX: + e.outputView.Clear() + return nil + case tcell.KeyRune: + // Allow typing in command area + if misc.App.GetFocus() == e.commandArea { + return event + } + switch event.Rune() { + case 'd': // Toggle describe modal + if components.CloseDescribeModal() { + return nil + } + e.describeServer() + return nil + case 'C': // Clear filters + e.serverData.Emitter.PublishAndWait(misc.Event{Name: "remove_tag_filter", Data: ""}) + e.serverData.Emitter.PublishAndWait(misc.Event{Name: "remove_tag_selections", Data: ""}) + e.serverData.Emitter.PublishAndWait(misc.Event{Name: "remove_server_filter", Data: ""}) + e.serverData.Emitter.PublishAndWait(misc.Event{Name: "remove_server_selections", Data: ""}) + e.serverData.Emitter.Publish(misc.Event{Name: "filter_servers", Data: ""}) + return nil + case '1', '2', '3', '4', '5', '6', '7', '8', '9': + misc.FocusPage(event, e.focusable) + return nil + } + } + + return event + }) + + return page +} + +func (e *TExecPage) updateExecFocusable() []*misc.TItem { + focusable := []*misc.TItem{ + misc.GetTUIItem(e.commandArea, e.commandArea.Box), + misc.GetTUIItem(e.outputView.Output, e.outputView.Output.Box), + } + + // Add server view based on current style + if e.serverData.ServerStyle == "server-table" { + focusable = append(focusable, misc.GetTUIItem( + e.serverData.ServerTableView.Table, + e.serverData.ServerTableView.Table.Box, + )) + } else { + focusable = append(focusable, misc.GetTUIItem( + e.serverData.ServerTreeView.Tree, + e.serverData.ServerTreeView.Tree.Box, + )) + } + + if e.serverData.TagView != nil && len(e.serverData.ServerTags) > 0 { + focusable = append( + focusable, + misc.GetTUIItem( + e.serverData.TagView.List, + e.serverData.TagView.List.Box, + ), + ) + } + + return focusable +} + +func (e *TExecPage) execCommand() { + // Get command + command := e.commandArea.GetText() + if command == "" { + e.outputView.Write("[yellow]No command entered[-]\n") + return + } + + // Get selected servers + selectedServers := e.serverData.GetSelectedServerObjects() + if len(selectedServers) == 0 { + e.outputView.Write("[yellow]No servers selected[-]\n") + return + } + + // Clear output if option is set + if e.spec.ClearBeforeRun { + e.outputView.Clear() + } + + // Get writer for output + writer := e.outputView.GetWriter() + + // Run command + go func() { + e.runCommand(command, selectedServers, writer) + misc.App.QueueUpdateDraw(func() {}) + }() +} + +func (e *TExecPage) describeServer() { + var serverName string + + if e.serverData.ServerStyle == "server-table" { + // Get currently focused server from table + row, _ := e.serverData.ServerTableView.Table.GetSelection() + if row < 1 { + return + } + + servers := e.serverData.GetFilteredServers() + if row-1 >= len(servers) { + return + } + serverName = servers[row-1].Name + } else { + // Get currently focused server from tree + node := e.serverData.ServerTreeView.Tree.GetCurrentNode() + if node == nil { + return + } + ref := node.GetReference() + if ref == nil { + return + } + serverName = ref.(string) + if serverName == "" { + return + } + } + + // Get full server from config for complete info + fullServer, err := misc.Config.GetServer(serverName) + if err != nil { + return + } + + // Get bastion hosts as strings + var bastions []string + for _, bastion := range fullServer.Bastions { + bastionStr := bastion.Host + if bastion.User != "" { + bastionStr = bastion.User + "@" + bastionStr + } + bastions = append(bastions, bastionStr) + } + + // Get identity file + identityFile := "" + if fullServer.IdentityFile != nil { + identityFile = *fullServer.IdentityFile + } + + description := misc.FormatServerBlock( + fullServer.Name, + fullServer.Desc, + fullServer.Host, + fullServer.User, + fullServer.Port, + fullServer.Local, + fullServer.Tags, + bastions, + identityFile, + fullServer.WorkDir, + ) + components.OpenTextModal("describe-modal", description, fullServer.Name) +} + +func (e *TExecPage) runCommand(command string, servers []dao.Server, writer io.Writer) { + config := misc.Config + spec := e.spec + + // Create a task for the command with spec options + task := &dao.Task{ + Name: "exec", + Tasks: []dao.TaskCmd{ + { + Name: "exec", + Cmd: command, + }, + }, + Spec: dao.DEFAULT_SPEC, + Theme: dao.DEFAULT_THEME, + } + + // Apply spec options + task.Spec.Strategy = spec.Strategy + task.Spec.Output = spec.Output + task.Spec.IgnoreErrors = spec.IgnoreErrors + task.Spec.IgnoreUnreachable = spec.IgnoreUnreachable + task.Spec.OmitEmptyRows = spec.OmitEmptyRows + task.Spec.OmitEmptyColumns = spec.OmitEmptyColumns + task.Spec.AnyErrorsFatal = spec.AnyErrorsFatal + + // Create run flags with spec options + runFlags := &core.RunFlags{ + Output: spec.Output, + Strategy: spec.Strategy, + } + setRunFlags := &core.SetRunFlags{} + + // Create Run struct + runner := &run.Run{ + LocalClients: make(map[string]run.Client), + RemoteClients: make(map[string]run.Client), + Servers: servers, + Task: task, + Config: *config, + } + + // Evaluate config env + configEnv, err := dao.EvaluateEnv(config.Envs) + if err != nil { + fmt.Fprintf(writer, "[red]Error evaluating config env: %s[-]\n", err.Error()) + return + } + + // Parse task + err = runner.ParseTask(configEnv, []string{}, runFlags, setRunFlags) + if err != nil { + fmt.Fprintf(writer, "[red]Error parsing task: %s[-]\n", err.Error()) + return + } + + // Parse servers + errConnects, err := run.ParseServers(config.SSHConfigFile, &runner.Servers, runFlags, task.Spec.Order) + if err != nil { + fmt.Fprintf(writer, "[red]Error parsing servers: %s[-]\n", err.Error()) + return + } + + if len(errConnects) > 0 { + for _, e := range errConnects { + fmt.Fprintf(writer, "[red]Parse error for %s: %s[-]\n", e.Name, e.Reason) + } + return + } + + // Set up clients + numClients := len(servers) * 2 + clientCh := make(chan run.Client, numClients) + errCh := make(chan run.ErrConnect, numClients) + + errConnect, err := runner.SetClients(task, runFlags, numClients, clientCh, errCh) + if err != nil { + fmt.Fprintf(writer, "[red]Error setting up clients: %s[-]\n", err.Error()) + return + } + + if len(errConnect) > 0 { + fmt.Fprintf(writer, "[yellow]Unreachable hosts:[-]\n") + for _, ec := range errConnect { + fmt.Fprintf(writer, " - %s (%s): %s\n", ec.Name, ec.Host, ec.Reason) + } + if !spec.IgnoreUnreachable { + return + } + } + + // Get reachable servers + var reachableServers []dao.Server + for _, server := range runner.Servers { + if server.Local { + reachableServers = append(reachableServers, server) + continue + } + + _, reachable := runner.RemoteClients[server.Name] + if reachable { + reachableServers = append(reachableServers, server) + } + } + runner.Servers = reachableServers + + if len(runner.Servers) == 0 { + fmt.Fprintf(writer, "[yellow]No reachable servers[-]\n") + runner.CleanupClients() + return + } + + // Execute command based on output type + if spec.Output == "table" { + // Table output + data, _, _ := runner.Table(false) + + // Format table output for TUI + if len(data.Headers) > 0 && len(data.Rows) > 0 { + e.writeTableOutput(writer, data) + } + } else { + // Text output (default) + runner.TextTUI(false, writer, writer) + } + + // Cleanup + runner.CleanupClients() +} + +// writeTableOutput formats table data for TUI display +func (e *TExecPage) writeTableOutput(writer io.Writer, data dao.TableOutput) { + // Calculate column widths + colWidths := make([]int, len(data.Headers)) + for i, header := range data.Headers { + colWidths[i] = len(header) + } + for _, row := range data.Rows { + for i, col := range row.Columns { + if i < len(colWidths) { + // Handle multi-line output - use first line for width calc + lines := splitLinesExec(col) + for _, line := range lines { + if len(line) > colWidths[i] { + colWidths[i] = len(line) + } + } + } + } + } + + // Cap column widths at reasonable max + maxColWidth := 60 + for i := range colWidths { + if colWidths[i] > maxColWidth { + colWidths[i] = maxColWidth + } + } + + // Print header + fmt.Fprintf(writer, "[#d787ff::b]") + for i, header := range data.Headers { + fmt.Fprintf(writer, "%-*s ", colWidths[i], header) + } + fmt.Fprintf(writer, "[-:-:-]\n") + + // Print separator + for i := range data.Headers { + fmt.Fprintf(writer, "%s ", repeatCharExec('-', colWidths[i])) + } + fmt.Fprintf(writer, "\n") + + // Print rows + for _, row := range data.Rows { + // Get max lines in this row + maxLines := 1 + rowLines := make([][]string, len(row.Columns)) + for i, col := range row.Columns { + rowLines[i] = splitLinesExec(col) + if len(rowLines[i]) > maxLines { + maxLines = len(rowLines[i]) + } + } + + // Print each line of the row + for lineIdx := 0; lineIdx < maxLines; lineIdx++ { + for i := range row.Columns { + var cellContent string + if lineIdx < len(rowLines[i]) { + cellContent = rowLines[i][lineIdx] + } + // Truncate if too long + if len(cellContent) > colWidths[i] { + cellContent = cellContent[:colWidths[i]-3] + "..." + } + fmt.Fprintf(writer, "%-*s ", colWidths[i], cellContent) + } + fmt.Fprintf(writer, "\n") + } + } +} + +func splitLinesExec(s string) []string { + if s == "" { + return []string{""} + } + var lines []string + current := "" + for _, r := range s { + if r == '\n' { + lines = append(lines, current) + current = "" + } else { + current += string(r) + } + } + if current != "" || len(lines) == 0 { + lines = append(lines, current) + } + return lines +} + +func repeatCharExec(c rune, n int) string { + result := "" + for i := 0; i < n; i++ { + result += string(c) + } + return result +} diff --git a/core/tui/pages/tui_run.go b/core/tui/pages/tui_run.go new file mode 100644 index 0000000..f8086a9 --- /dev/null +++ b/core/tui/pages/tui_run.go @@ -0,0 +1,720 @@ +package pages + +import ( + "fmt" + "io" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/alajmo/sake/core" + "github.com/alajmo/sake/core/dao" + "github.com/alajmo/sake/core/run" + "github.com/alajmo/sake/core/tui/components" + "github.com/alajmo/sake/core/tui/misc" + "github.com/alajmo/sake/core/tui/views" +) + +type TRunPage struct { + focusable []*misc.TItem + taskData *views.TTask + serverData *views.TServer + outputView *components.TOutput + spec *views.TSpec +} + +func CreateRunPage( + tasks []dao.Task, + servers []dao.Server, + serverTags []string, +) *tview.Flex { + r := &TRunPage{} + + // Data + r.taskData = views.CreateTasksData( + tasks, + []string{"Task", "Name", "Description"}, + 1, + true, + true, + true, + ) + + r.serverData = views.CreateServersData( + servers, + serverTags, + []string{"Server", "Host", "Tags"}, + 2, + true, + true, + true, + len(serverTags) > 0, + ) + + // Views + r.outputView = &components.TOutput{Title: "Output"} + r.outputView.Create() + + // Spec options + r.spec = views.CreateSpecView() + + // Shortcut info views + runInfoView := views.CreateRunInfoView() + execInfoView := views.CreateExecInfoView() + + // Selection page (tasks and servers) + selectionPage := r.createSelectionPage(runInfoView) + + // Output page with info + outputPage := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(r.outputView.Root, 0, 1, true). + AddItem(execInfoView, 1, 0, false) + + // Pages container + pages := tview.NewPages(). + AddPage("run-selection", selectionPage, true, true). + AddPage("run-output", outputPage, true, false) + + // Main page + page := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(pages, 0, 1, true). + AddItem(misc.Search, 1, 0, false) + + // Focus + r.focusable = r.updateSelectionFocusable() + misc.RunLastFocus = &r.focusable[0].Primitive + + // Shortcuts + page.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyCtrlS: + r.focusable = r.switchView(pages) + misc.App.SetFocus(r.focusable[0].Primitive) + misc.RunLastFocus = &r.focusable[0].Primitive + return nil + case tcell.KeyCtrlR: + r.focusable = r.switchBeforeRun(pages) + misc.App.SetFocus(r.focusable[0].Primitive) + misc.RunLastFocus = &r.focusable[0].Primitive + r.runTasks() + return nil + case tcell.KeyTab: + nextPrimitive := misc.FocusNext(r.focusable) + misc.RunLastFocus = nextPrimitive + return nil + case tcell.KeyBacktab: + nextPrimitive := misc.FocusPrevious(r.focusable) + misc.RunLastFocus = nextPrimitive + return nil + case tcell.KeyCtrlO: + components.OpenModal("spec-modal", "Options", r.spec.View, 35, 14) + return nil + case tcell.KeyCtrlX: + r.outputView.Clear() + return nil + case tcell.KeyRune: + if _, ok := misc.App.GetFocus().(*tview.InputField); ok { + return event + } + name, _ := pages.GetFrontPage() + if name == "run-selection" { + switch event.Rune() { + case 'd': // Toggle describe modal + if components.CloseDescribeModal() { + return nil + } + r.describeItem() + return nil + case 'C': // Clear filters + r.serverData.Emitter.PublishAndWait(misc.Event{Name: "remove_tag_filter", Data: ""}) + r.serverData.Emitter.PublishAndWait(misc.Event{Name: "remove_tag_selections", Data: ""}) + r.serverData.Emitter.PublishAndWait(misc.Event{Name: "remove_server_filter", Data: ""}) + r.serverData.Emitter.PublishAndWait(misc.Event{Name: "remove_server_selections", Data: ""}) + r.serverData.Emitter.Publish(misc.Event{Name: "filter_servers", Data: ""}) + + r.taskData.Emitter.PublishAndWait(misc.Event{Name: "remove_task_filter", Data: ""}) + r.taskData.Emitter.PublishAndWait(misc.Event{Name: "remove_task_selections", Data: ""}) + r.taskData.Emitter.Publish(misc.Event{Name: "filter_tasks", Data: ""}) + return nil + case '1', '2', '3', '4', '5', '6', '7', '8', '9': + misc.FocusPage(event, r.focusable) + return nil + } + } + } + + return event + }) + + return page +} + +func (r *TRunPage) createSelectionPage(info *tview.TextView) *tview.Flex { + // Left: Tasks with table/tree toggle + isTaskTable := r.taskData.TaskStyle == "task-table" + taskPages := tview.NewPages(). + AddPage("task-table", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(r.taskData.TaskTableView.Root, 0, 1, true), true, isTaskTable). + AddPage("task-tree", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(r.taskData.TaskTreeView.Root, 0, 1, false), true, !isTaskTable) + + taskPane := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(taskPages, 0, 1, true) + + // Handle Ctrl+E for task view toggle + taskPane.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if misc.App.GetFocus() == misc.Search { + return event + } + + switch event.Key() { + case tcell.KeyCtrlE: + if r.taskData.TaskStyle == "task-table" { + r.taskData.TaskStyle = "task-tree" + } else { + r.taskData.TaskStyle = "task-table" + } + taskPages.SwitchToPage(r.taskData.TaskStyle) + r.focusable = r.updateSelectionFocusable() + misc.App.SetFocus(r.focusable[0].Primitive) + misc.RunLastFocus = &r.focusable[0].Primitive + return nil + } + return event + }) + + // Right: Servers with table/tree toggle + isServerTable := r.serverData.ServerStyle == "server-table" + serverPages := tview.NewPages(). + AddPage("server-table", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(r.serverData.ServerTableView.Root, 0, 1, true), true, isServerTable). + AddPage("server-tree", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(r.serverData.ServerTreeView.Root, 0, 1, false), true, !isServerTable) + + serverViewPane := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(serverPages, 0, 1, true) + + // Handle Ctrl+E for server view toggle + serverViewPane.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if misc.App.GetFocus() == misc.Search { + return event + } + + switch event.Key() { + case tcell.KeyCtrlE: + if r.serverData.ServerStyle == "server-table" { + r.serverData.ServerStyle = "server-tree" + } else { + r.serverData.ServerStyle = "server-table" + } + serverPages.SwitchToPage(r.serverData.ServerStyle) + r.focusable = r.updateSelectionFocusable() + // Find the server view in focusable and focus it + for _, item := range r.focusable { + if item.Primitive == r.serverData.ServerTableView.Table || + item.Primitive == r.serverData.ServerTreeView.Tree { + misc.App.SetFocus(item.Primitive) + misc.RunLastFocus = &item.Primitive + break + } + } + return nil + } + return event + }) + + serverPane := tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(serverViewPane, 0, 1, true) + + if r.serverData.TagView != nil && len(r.serverData.ServerTags) > 0 { + serverPane.AddItem(r.serverData.TagView.Root, 20, 0, false) + } + + // Main layout + mainContent := tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(taskPane, 0, 1, true). + AddItem(serverPane, 0, 1, false) + + // Page with info at bottom + page := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(mainContent, 0, 1, true). + AddItem(info, 1, 0, false) + + return page +} + +func (r *TRunPage) updateSelectionFocusable() []*misc.TItem { + var focusable []*misc.TItem + + // Add task view based on current style + if r.taskData.TaskStyle == "task-table" { + focusable = append(focusable, misc.GetTUIItem( + r.taskData.TaskTableView.Table, + r.taskData.TaskTableView.Table.Box, + )) + } else { + focusable = append(focusable, misc.GetTUIItem( + r.taskData.TaskTreeView.Tree, + r.taskData.TaskTreeView.Tree.Box, + )) + } + + // Add server view based on current style + if r.serverData.ServerStyle == "server-table" { + focusable = append(focusable, misc.GetTUIItem( + r.serverData.ServerTableView.Table, + r.serverData.ServerTableView.Table.Box, + )) + } else { + focusable = append(focusable, misc.GetTUIItem( + r.serverData.ServerTreeView.Tree, + r.serverData.ServerTreeView.Tree.Box, + )) + } + + if r.serverData.TagView != nil && len(r.serverData.ServerTags) > 0 { + focusable = append( + focusable, + misc.GetTUIItem( + r.serverData.TagView.List, + r.serverData.TagView.List.Box, + ), + ) + } + + return focusable +} + +func (r *TRunPage) updateOutputFocusable() []*misc.TItem { + return []*misc.TItem{ + misc.GetTUIItem(r.outputView.Output, r.outputView.Output.Box), + } +} + +func (r *TRunPage) switchView(pages *tview.Pages) []*misc.TItem { + name, _ := pages.GetFrontPage() + if name == "run-output" { + pages.SwitchToPage("run-selection") + return r.updateSelectionFocusable() + } + pages.SwitchToPage("run-output") + return r.updateOutputFocusable() +} + +func (r *TRunPage) switchBeforeRun(pages *tview.Pages) []*misc.TItem { + name, _ := pages.GetFrontPage() + if name == "run-selection" { + pages.SwitchToPage("run-output") + return r.updateOutputFocusable() + } + return r.focusable +} + +func (r *TRunPage) runTasks() { + // Get selected servers + selectedServers := r.serverData.GetSelectedServerObjects() + if len(selectedServers) == 0 { + r.outputView.Write("[yellow]No servers selected[-]\n") + return + } + + // Get selected tasks + selectedTasks := r.taskData.GetSelectedTaskObjects() + if len(selectedTasks) == 0 { + r.outputView.Write("[yellow]No tasks selected[-]\n") + return + } + + // Clear output if option is set + if r.spec.ClearBeforeRun { + r.outputView.Clear() + } + + // Get writer for output + writer := r.outputView.GetWriter() + + // Run each task + go func() { + for _, task := range selectedTasks { + r.runSingleTask(&task, selectedServers, writer) + } + misc.App.QueueUpdateDraw(func() {}) + }() +} + +func (r *TRunPage) runSingleTask(task *dao.Task, servers []dao.Server, writer io.Writer) { + config := misc.Config + spec := r.spec + + // Create run flags with spec options + runFlags := &core.RunFlags{ + Output: spec.Output, + Strategy: spec.Strategy, + } + setRunFlags := &core.SetRunFlags{} + + // Create Run struct + runner := &run.Run{ + LocalClients: make(map[string]run.Client), + RemoteClients: make(map[string]run.Client), + Servers: servers, + Task: task, + Config: *config, + } + + // Evaluate config env + configEnv, err := dao.EvaluateEnv(config.Envs) + if err != nil { + fmt.Fprintf(writer, "[red]Error evaluating config env: %s[-]\n", err.Error()) + return + } + + // Parse task + err = runner.ParseTask(configEnv, []string{}, runFlags, setRunFlags) + if err != nil { + fmt.Fprintf(writer, "[red]Error parsing task: %s[-]\n", err.Error()) + return + } + + // Apply spec options to task + task.Spec.Strategy = spec.Strategy + task.Spec.Output = spec.Output + task.Spec.IgnoreErrors = spec.IgnoreErrors + task.Spec.IgnoreUnreachable = spec.IgnoreUnreachable + task.Spec.OmitEmptyRows = spec.OmitEmptyRows + task.Spec.OmitEmptyColumns = spec.OmitEmptyColumns + task.Spec.AnyErrorsFatal = spec.AnyErrorsFatal + + // Parse servers + errConnects, err := run.ParseServers(config.SSHConfigFile, &runner.Servers, runFlags, task.Spec.Order) + if err != nil { + fmt.Fprintf(writer, "[red]Error parsing servers: %s[-]\n", err.Error()) + return + } + + if len(errConnects) > 0 { + for _, e := range errConnects { + fmt.Fprintf(writer, "[red]Parse error for %s: %s[-]\n", e.Name, e.Reason) + } + return + } + + // Set up clients + numClients := len(servers) * 2 + clientCh := make(chan run.Client, numClients) + errCh := make(chan run.ErrConnect, numClients) + + errConnect, err := runner.SetClients(task, runFlags, numClients, clientCh, errCh) + if err != nil { + fmt.Fprintf(writer, "[red]Error setting up clients: %s[-]\n", err.Error()) + return + } + + if len(errConnect) > 0 { + fmt.Fprintf(writer, "[yellow]Unreachable hosts:[-]\n") + for _, e := range errConnect { + fmt.Fprintf(writer, " - %s (%s): %s\n", e.Name, e.Host, e.Reason) + } + if !spec.IgnoreUnreachable { + return + } + } + + // Get reachable servers + var reachableServers []dao.Server + for _, server := range runner.Servers { + if server.Local { + reachableServers = append(reachableServers, server) + continue + } + + _, reachable := runner.RemoteClients[server.Name] + if reachable { + reachableServers = append(reachableServers, server) + } + } + runner.Servers = reachableServers + + if len(runner.Servers) == 0 { + fmt.Fprintf(writer, "[yellow]No reachable servers[-]\n") + runner.CleanupClients() + return + } + + // Execute task based on output type + if spec.Output == "table" { + // Table output + data, _, _ := runner.Table(false) + + // Format table output for TUI + if len(data.Headers) > 0 && len(data.Rows) > 0 { + r.writeTableOutput(writer, data) + } + } else { + // Text output (default) + runner.TextTUI(false, writer, writer) + } + + // Cleanup + runner.CleanupClients() +} + +// writeTableOutput formats table data for TUI display +func (r *TRunPage) writeTableOutput(writer io.Writer, data dao.TableOutput) { + // Calculate column widths + colWidths := make([]int, len(data.Headers)) + for i, header := range data.Headers { + colWidths[i] = len(header) + } + for _, row := range data.Rows { + for i, col := range row.Columns { + if i < len(colWidths) { + // Handle multi-line output - use first line for width calc + lines := splitLines(col) + for _, line := range lines { + if len(line) > colWidths[i] { + colWidths[i] = len(line) + } + } + } + } + } + + // Cap column widths at reasonable max + maxColWidth := 60 + for i := range colWidths { + if colWidths[i] > maxColWidth { + colWidths[i] = maxColWidth + } + } + + // Print header + fmt.Fprintf(writer, "[#d787ff::b]") + for i, header := range data.Headers { + fmt.Fprintf(writer, "%-*s ", colWidths[i], header) + } + fmt.Fprintf(writer, "[-:-:-]\n") + + // Print separator + for i := range data.Headers { + fmt.Fprintf(writer, "%s ", repeatChar('-', colWidths[i])) + } + fmt.Fprintf(writer, "\n") + + // Print rows + for _, row := range data.Rows { + // Get max lines in this row + maxLines := 1 + rowLines := make([][]string, len(row.Columns)) + for i, col := range row.Columns { + rowLines[i] = splitLines(col) + if len(rowLines[i]) > maxLines { + maxLines = len(rowLines[i]) + } + } + + // Print each line of the row + for lineIdx := 0; lineIdx < maxLines; lineIdx++ { + for i := range row.Columns { + var cellContent string + if lineIdx < len(rowLines[i]) { + cellContent = rowLines[i][lineIdx] + } + // Truncate if too long + if len(cellContent) > colWidths[i] { + cellContent = cellContent[:colWidths[i]-3] + "..." + } + fmt.Fprintf(writer, "%-*s ", colWidths[i], cellContent) + } + fmt.Fprintf(writer, "\n") + } + } +} + +func splitLines(s string) []string { + if s == "" { + return []string{""} + } + var lines []string + current := "" + for _, r := range s { + if r == '\n' { + lines = append(lines, current) + current = "" + } else { + current += string(r) + } + } + if current != "" || len(lines) == 0 { + lines = append(lines, current) + } + return lines +} + +func repeatChar(c rune, n int) string { + result := "" + for i := 0; i < n; i++ { + result += string(c) + } + return result +} + +func (r *TRunPage) describeItem() { + currentFocus := misc.App.GetFocus() + + // Check if task table or tree is focused + if currentFocus == r.taskData.TaskTableView.Table || currentFocus == r.taskData.TaskTreeView.Tree { + r.describeTask() + return + } + + // Check if server table or tree is focused + if currentFocus == r.serverData.ServerTableView.Table || currentFocus == r.serverData.ServerTreeView.Tree { + r.describeServer() + return + } +} + +func (r *TRunPage) describeTask() { + var taskID string + + if r.taskData.TaskStyle == "task-table" { + row, _ := r.taskData.TaskTableView.Table.GetSelection() + if row < 1 { + return + } + + tasks := r.taskData.GetFilteredTasks() + if row-1 >= len(tasks) { + return + } + taskID = tasks[row-1].ID + } else { + node := r.taskData.TaskTreeView.Tree.GetCurrentNode() + if node == nil { + return + } + ref := node.GetReference() + if ref == nil { + return + } + taskID = ref.(string) + } + + // Get full task from config for complete info + fullTask, err := misc.Config.GetTask(taskID) + if err != nil { + return + } + + // Build sub-tasks info from TaskRefs + var subTasks []misc.SubTaskInfo + for _, ref := range fullTask.TaskRefs { + st := misc.SubTaskInfo{ + Name: ref.Name, + Desc: ref.Desc, + Cmd: ref.Cmd, + Task: ref.Task, + IsRef: ref.Task != "", + } + subTasks = append(subTasks, st) + } + + // Also include resolved Tasks (TaskCmd) + for _, tc := range fullTask.Tasks { + st := misc.SubTaskInfo{ + Name: tc.Name, + Desc: tc.Desc, + Cmd: tc.Cmd, + IsRef: false, + } + subTasks = append(subTasks, st) + } + + description := misc.FormatTaskBlock( + fullTask.Name, + fullTask.Desc, + fullTask.Cmd, + fullTask.Local, + fullTask.TTY, + fullTask.Attach, + fullTask.WorkDir, + fullTask.Shell, + fullTask.Envs, + fullTask.Target.Tags, + subTasks, + fullTask.Spec.Name, + fullTask.Target.Name, + fullTask.Theme.Name, + ) + components.OpenTextModal("describe-modal", description, fullTask.Name) +} + +func (r *TRunPage) describeServer() { + var serverName string + + if r.serverData.ServerStyle == "server-table" { + row, _ := r.serverData.ServerTableView.Table.GetSelection() + if row < 1 { + return + } + + servers := r.serverData.GetFilteredServers() + if row-1 >= len(servers) { + return + } + serverName = servers[row-1].Name + } else { + node := r.serverData.ServerTreeView.Tree.GetCurrentNode() + if node == nil { + return + } + ref := node.GetReference() + if ref == nil { + return + } + serverName = ref.(string) + if serverName == "" { + return + } + } + + // Get full server from config for complete info + fullServer, err := misc.Config.GetServer(serverName) + if err != nil { + return + } + + // Get bastion hosts as strings + var bastions []string + for _, bastion := range fullServer.Bastions { + bastionStr := bastion.Host + if bastion.User != "" { + bastionStr = bastion.User + "@" + bastionStr + } + bastions = append(bastions, bastionStr) + } + + // Get identity file + identityFile := "" + if fullServer.IdentityFile != nil { + identityFile = *fullServer.IdentityFile + } + + description := misc.FormatServerBlock( + fullServer.Name, + fullServer.Desc, + fullServer.Host, + fullServer.User, + fullServer.Port, + fullServer.Local, + fullServer.Tags, + bastions, + identityFile, + fullServer.WorkDir, + ) + components.OpenTextModal("describe-modal", description, fullServer.Name) +} diff --git a/core/tui/pages/tui_server.go b/core/tui/pages/tui_server.go new file mode 100644 index 0000000..2d937ff --- /dev/null +++ b/core/tui/pages/tui_server.go @@ -0,0 +1,156 @@ +package pages + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/alajmo/sake/core/dao" + "github.com/alajmo/sake/core/tui/misc" + "github.com/alajmo/sake/core/tui/views" +) + +type TServerPage struct { + focusable []*misc.TItem + serverData *views.TServer +} + +func CreateServersPage( + servers []dao.Server, + serverTags []string, +) *tview.Flex { + p := &TServerPage{} + + // Data + p.serverData = views.CreateServersData( + servers, + serverTags, + []string{"Server", "Host", "User", "Tags", "Description"}, + 1, + true, + true, + false, + len(serverTags) > 0, + ) + + // Shortcut info view + infoView := views.CreateServersInfoView() + + // Server view with table/tree toggle + serverViewPages := p.createServerViewPages() + + // Tags panel + serverPage := tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(serverViewPages, 0, 1, true) + + if p.serverData.TagView != nil && len(serverTags) > 0 { + serverPage.AddItem(p.serverData.TagView.Root, 25, 0, false) + } + + page := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(serverPage, 0, 1, true). + AddItem(infoView, 1, 0, false). + AddItem(misc.Search, 1, 0, false) + + // Focusable + p.focusable = p.updateServerFocusable() + misc.ServersLastFocus = &p.focusable[0].Primitive + + // Shortcuts + page.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if misc.App.GetFocus() == misc.Search { + return event + } + + switch event.Key() { + case tcell.KeyTab: + nextPrimitive := misc.FocusNext(p.focusable) + misc.ServersLastFocus = nextPrimitive + return nil + case tcell.KeyBacktab: + nextPrimitive := misc.FocusPrevious(p.focusable) + misc.ServersLastFocus = nextPrimitive + return nil + case tcell.KeyRune: + switch event.Rune() { + case 'C': // Clear filters + p.serverData.Emitter.PublishAndWait(misc.Event{Name: "remove_tag_filter", Data: ""}) + p.serverData.Emitter.PublishAndWait(misc.Event{Name: "remove_tag_selections", Data: ""}) + p.serverData.Emitter.PublishAndWait(misc.Event{Name: "remove_server_filter", Data: ""}) + p.serverData.Emitter.PublishAndWait(misc.Event{Name: "remove_server_selections", Data: ""}) + p.serverData.Emitter.Publish(misc.Event{Name: "filter_servers", Data: ""}) + return nil + case '1', '2', '3', '4', '5', '6', '7', '8', '9': + misc.FocusPage(event, p.focusable) + return nil + } + } + return event + }) + + return page +} + +func (p *TServerPage) createServerViewPages() *tview.Flex { + isTable := p.serverData.ServerStyle == "server-table" + + pages := tview.NewPages(). + AddPage("server-table", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(p.serverData.ServerTableView.Root, 0, 1, true), true, isTable). + AddPage("server-tree", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(p.serverData.ServerTreeView.Root, 0, 1, false), true, !isTable) + + page := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(pages, 0, 1, true) + + page.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if misc.App.GetFocus() == misc.Search { + return event + } + + switch event.Key() { + case tcell.KeyCtrlE: + if p.serverData.ServerStyle == "server-table" { + p.serverData.ServerStyle = "server-tree" + } else { + p.serverData.ServerStyle = "server-table" + } + pages.SwitchToPage(p.serverData.ServerStyle) + p.focusable = p.updateServerFocusable() + misc.App.SetFocus(p.focusable[0].Primitive) + misc.ServersLastFocus = &p.focusable[0].Primitive + return nil + } + return event + }) + + return page +} + +func (p *TServerPage) updateServerFocusable() []*misc.TItem { + var focusable []*misc.TItem + + if p.serverData.ServerStyle == "server-table" { + focusable = append(focusable, misc.GetTUIItem( + p.serverData.ServerTableView.Table, + p.serverData.ServerTableView.Table.Box, + )) + } else { + focusable = append(focusable, misc.GetTUIItem( + p.serverData.ServerTreeView.Tree, + p.serverData.ServerTreeView.Tree.Box, + )) + } + + if p.serverData.TagView != nil && len(p.serverData.ServerTags) > 0 { + focusable = append( + focusable, + misc.GetTUIItem( + p.serverData.TagView.List, + p.serverData.TagView.List.Box, + ), + ) + } + + return focusable +} diff --git a/core/tui/pages/tui_task.go b/core/tui/pages/tui_task.go new file mode 100644 index 0000000..d5ad3de --- /dev/null +++ b/core/tui/pages/tui_task.go @@ -0,0 +1,134 @@ +package pages + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/alajmo/sake/core/dao" + "github.com/alajmo/sake/core/tui/misc" + "github.com/alajmo/sake/core/tui/views" +) + +type TTaskPage struct { + focusable []*misc.TItem +} + +func CreateTasksPage(tasks []dao.Task) *tview.Flex { + p := &TTaskPage{} + + // Data + taskData := views.CreateTasksData( + tasks, + []string{"Task", "Name", "Description"}, + 1, + true, + true, + false, + ) + + // Shortcut info view + infoView := views.CreateTasksInfoView() + + // Pages for table/tree toggle + taskViewPages := p.createTaskViewPages(taskData) + + // Page + page := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(taskViewPages, 0, 1, true). + AddItem(infoView, 1, 0, false). + AddItem(misc.Search, 1, 0, false) + + // Focusable + p.focusable = p.updateTaskFocusable(taskData) + misc.TasksLastFocus = &p.focusable[0].Primitive + + // Shortcuts + page.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if misc.App.GetFocus() == misc.Search { + return event + } + + switch event.Key() { + case tcell.KeyTab: + nextPrimitive := misc.FocusNext(p.focusable) + misc.TasksLastFocus = nextPrimitive + return nil + case tcell.KeyBacktab: + nextPrimitive := misc.FocusPrevious(p.focusable) + misc.TasksLastFocus = nextPrimitive + return nil + case tcell.KeyRune: + switch event.Rune() { + case 'C': // Clear filters + taskData.Emitter.PublishAndWait(misc.Event{Name: "remove_task_filter", Data: ""}) + taskData.Emitter.PublishAndWait(misc.Event{Name: "remove_task_selections", Data: ""}) + taskData.Emitter.Publish(misc.Event{Name: "filter_tasks", Data: ""}) + return nil + case '1', '2', '3', '4', '5', '6', '7', '8', '9': + misc.FocusPage(event, p.focusable) + return nil + } + } + return event + }) + + return page +} + +func (p *TTaskPage) createTaskViewPages(taskData *views.TTask) *tview.Flex { + isTable := taskData.TaskStyle == "task-table" + + pages := tview.NewPages(). + AddPage("task-table", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(taskData.TaskTableView.Root, 0, 1, true), true, isTable). + AddPage("task-tree", tview.NewFlex().SetDirection(tview.FlexRow).AddItem(taskData.TaskTreeView.Root, 0, 1, false), true, !isTable) + + page := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(pages, 0, 1, true) + + page.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if misc.App.GetFocus() == misc.Search { + return event + } + + switch event.Key() { + case tcell.KeyCtrlE: + if taskData.TaskStyle == "task-table" { + taskData.TaskStyle = "task-tree" + } else { + taskData.TaskStyle = "task-table" + } + pages.SwitchToPage(taskData.TaskStyle) + p.focusable = p.updateTaskFocusable(taskData) + misc.App.SetFocus(p.focusable[0].Primitive) + misc.TasksLastFocus = &p.focusable[0].Primitive + return nil + } + return event + }) + + return page +} + +func (p *TTaskPage) updateTaskFocusable(data *views.TTask) []*misc.TItem { + focusable := []*misc.TItem{} + + if data.TaskStyle == "task-table" { + focusable = append( + focusable, + misc.GetTUIItem( + data.TaskTableView.Table, + data.TaskTableView.Table.Box, + )) + } else { + focusable = append( + focusable, + misc.GetTUIItem( + data.TaskTreeView.Tree, + data.TaskTreeView.Tree.Box, + )) + } + + return focusable +} diff --git a/core/tui/tui.go b/core/tui/tui.go new file mode 100644 index 0000000..a769a61 --- /dev/null +++ b/core/tui/tui.go @@ -0,0 +1,70 @@ +package tui + +import ( + "os" + + "github.com/alajmo/sake/core/dao" + "github.com/alajmo/sake/core/tui/misc" + "github.com/rivo/tview" +) + +func RunTui(config *dao.Config, reload bool) { + app := NewApp(config) + + if reload { + WatchFiles(app, config.Path) + } + + if err := app.Run(); err != nil { + os.Exit(1) + } +} + +type App struct { + App *tview.Application +} + +func NewApp(config *dao.Config) *App { + app := &App{ + App: tview.NewApplication(), + } + app.setupApp(config) + + return app +} + +func (app *App) Run() error { + return app.App.SetRoot(misc.Pages, true).EnableMouse(true).Run() +} + +func (app *App) Reload() { + config, configErr := dao.ReadConfig(misc.Config.Path, "", "", false) + if configErr != nil { + app.App.Stop() + return + } + + app.setupApp(&config) + app.App.SetRoot(misc.Pages, true) + app.App.Draw() +} + +func (app *App) setupApp(config *dao.Config) { + misc.Config = config + + // Load styles + misc.LoadStyles() + misc.SetupStyles() + + // Data + servers := config.Servers + tasks := config.Tasks + serverTags := config.GetTags() + + // Create pages + misc.App = app.App + misc.Pages = createPages(servers, serverTags, tasks) + + // Global input handling + HandleInput(app) +} diff --git a/core/tui/tui_input.go b/core/tui/tui_input.go new file mode 100644 index 0000000..054ecac --- /dev/null +++ b/core/tui/tui_input.go @@ -0,0 +1,173 @@ +package tui + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/alajmo/sake/core/tui/components" + "github.com/alajmo/sake/core/tui/misc" + "github.com/alajmo/sake/core/tui/views" +) + +func HandleInput(app *App) { + var lastSearchQuery string + var lastFoundRow, lastFoundCol int + searchDirection := 1 + + misc.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + currentFocus := misc.App.GetFocus() + + switch event.Key() { + case tcell.KeyF1: + SwitchToPage("run") + misc.App.SetFocus(*misc.RunLastFocus) + return nil + case tcell.KeyF2: + SwitchToPage("exec") + misc.App.SetFocus(*misc.ExecLastFocus) + return nil + case tcell.KeyF3: + SwitchToPage("servers") + misc.App.SetFocus(*misc.ServersLastFocus) + return nil + case tcell.KeyF4: + SwitchToPage("tasks") + misc.App.SetFocus(*misc.TasksLastFocus) + return nil + case tcell.KeyF5: + go app.Reload() + return nil + case tcell.KeyF6: + misc.App.Sync() + return nil + } + + // Modal + if components.IsModalOpen() { + switch event.Key() { + case tcell.KeyEscape: + components.CloseModal() + return nil + case tcell.KeyRune: + switch event.Rune() { + case 'q': + misc.App.Stop() + return nil + case 'd': + // Close describe modal with 'd' key + if components.CloseDescribeModal() { + return nil + } + } + } + return event + } + + // Search + if currentFocus == misc.Search { + lastFoundRow, lastFoundCol = -1, -1 + switch event.Key() { + case tcell.KeyEscape: + components.EmptySearch() + misc.FocusPreviousPage() + return nil + case tcell.KeyEnter: + return handleSearchInput(event, searchDirection, &lastFoundRow, &lastFoundCol) + } + return event + } + + // Input + if _, ok := currentFocus.(*tview.InputField); ok { + return event + } + // TextArea + if _, ok := currentFocus.(*tview.TextArea); ok { + return event + } + + // Main + switch event.Key() { + case tcell.KeyEscape: + components.EmptySearch() + return nil + case tcell.KeyRune: + switch event.Rune() { + case 'q': + misc.App.Stop() + return nil + case 'R': + misc.App.Sync() + return nil + case 's': + SwitchToPage("servers") + misc.App.SetFocus(*misc.ServersLastFocus) + return nil + case 't': + SwitchToPage("tasks") + misc.App.SetFocus(*misc.TasksLastFocus) + return nil + case 'r': + SwitchToPage("run") + misc.App.SetFocus(*misc.RunLastFocus) + return nil + case 'e': + SwitchToPage("exec") + misc.App.SetFocus(*misc.ExecLastFocus) + return nil + case '?': + views.ShowHelpModal() + return nil + case '/': + components.ShowSearch() + return nil + case 'n': + searchDirection = 1 + return handleSearchInput(event, searchDirection, &lastFoundRow, &lastFoundCol) + case 'N': + searchDirection = -1 + return handleSearchInput(event, searchDirection, &lastFoundRow, &lastFoundCol) + } + } + + return event + }) + + misc.Search.SetChangedFunc(func(query string) { + if query != lastSearchQuery { + lastSearchQuery = query + lastFoundRow, lastFoundCol = -1, -1 + searchDirection = 1 + + switch prevPage := misc.PreviousPane.(type) { + case *tview.Table: + components.SearchInTable(prevPage, query, &lastFoundRow, &lastFoundCol, searchDirection) + case *tview.List: + components.SearchInList(prevPage, query, &lastFoundRow, searchDirection) + case *tview.TreeView: + components.SearchInTree(prevPage, query, &lastFoundRow, searchDirection) + } + } + }) +} + +func handleSearchInput(_ *tcell.EventKey, searchDirection int, lastFoundRow *int, lastFoundCol *int) *tcell.EventKey { + query := misc.Search.GetText() + if query == "" { + return nil + } + + switch prevPage := misc.PreviousPane.(type) { + case *tview.Table: + misc.App.SetFocus(prevPage) + components.SearchInTable(prevPage, query, lastFoundRow, lastFoundCol, searchDirection) + case *tview.List: + misc.App.SetFocus(prevPage) + components.SearchInList(prevPage, query, lastFoundRow, searchDirection) + case *tview.TreeView: + misc.App.SetFocus(prevPage) + components.SearchInTree(prevPage, query, lastFoundRow, searchDirection) + } + + return nil +} diff --git a/core/tui/views/tui_help.go b/core/tui/views/tui_help.go new file mode 100644 index 0000000..abd123d --- /dev/null +++ b/core/tui/views/tui_help.go @@ -0,0 +1,148 @@ +package views + +import ( + "fmt" + + "github.com/alajmo/sake/core/tui/components" + "github.com/alajmo/sake/core/tui/misc" + "github.com/rivo/tview" +) + +var Version = "v0.15.1" + +func ShowHelpModal() { + t, table := createShortcutsTable() + components.OpenModal("help-modal", "Help", t, 65, 37) + misc.App.SetFocus(table) +} + +func shortcutRow(shortcut string, description string) (*tview.TableCell, *tview.TableCell) { + shortcut = fmt.Sprintf("[%s:%s:%s]%s[-:-:-]", + misc.STYLE_SHORTCUT_LABEL.FgStr, misc.STYLE_SHORTCUT_LABEL.BgStr, misc.STYLE_SHORTCUT_LABEL.AttrStr, shortcut, + ) + + description = fmt.Sprintf("[%s:%s:%s]%s[-:-:-]", + misc.STYLE_SHORTCUT_TEXT.FgStr, misc.STYLE_SHORTCUT_TEXT.BgStr, misc.STYLE_SHORTCUT_TEXT.AttrStr, description, + ) + + r1 := tview.NewTableCell(shortcut + " "). + SetTextColor(misc.STYLE_SHORTCUT_TEXT.Fg). + SetAlign(tview.AlignRight). + SetSelectable(false) + + r2 := tview.NewTableCell(description). + SetAlign(tview.AlignLeft). + SetSelectable(false) + + return r1, r2 +} + +func titleRow(title string) (*tview.TableCell, *tview.TableCell) { + r1 := tview.NewTableCell(""). + SetTextColor(misc.STYLE_SHORTCUT_TEXT.Fg). + SetAlign(tview.AlignRight). + SetSelectable(false) + + r2 := tview.NewTableCell(title). + SetTextColor(misc.STYLE_TABLE_HEADER.Fg). + SetAttributes(misc.STYLE_TABLE_HEADER.Attr). + SetAlign(tview.AlignLeft). + SetSelectable(false) + + return r1, r2 +} + +func createShortcutsTable() (*tview.Flex, *tview.Table) { + table := tview.NewTable() + table.SetEvaluateAllRows(true) + table.SetBackgroundColor(misc.STYLE_DEFAULT.Bg) + + sections := []struct { + title string + shortcuts [][2]string + }{ + { + title: "--- Global ---", + shortcuts: [][2]string{ + {"?", "Show this help"}, + {"q, Ctrl + c", "Quit program"}, + {"F5", "Reload app"}, + {"F6", "Re-sync screen buffer"}, + }, + }, + { + title: "--- Navigation ---", + shortcuts: [][2]string{ + {"r, F1", "Switch to run page"}, + {"e, F2", "Switch to exec page"}, + {"s, F3", "Switch to servers page"}, + {"t, F4", "Switch to tasks page"}, + {"1-9", "Focus specific pane"}, + {"Tab", "Focus next pane"}, + {"Shift + Tab", "Focus previous pane"}, + {"g", "Go to first item in the current pane"}, + {"G", "Go to last item in the current pane"}, + {"Ctrl + e", "Toggle Table/Tree view"}, + {"Ctrl + o", "Show task options"}, + {"Ctrl + s", "Toggle between selection and output view"}, + }, + }, + { + title: "--- Actions ---", + shortcuts: [][2]string{ + {"Escape", "Close modal/filter/search"}, + {"/", "Free text search"}, + {"n/N", "Next/previous search result"}, + {"f", "Filter items for the current pane"}, + {"F", "Clear filter for the current selected pane"}, + {"C", "Clear all filters and selections"}, + {"a", "Select all items in the current pane"}, + {"c", "Clear all selections in the current pane"}, + {"d", "Describe the selected item"}, + {"o", "Open the current selected item in $EDITOR"}, + {"Shift + S", "SSH to server (servers view)"}, + {"Space, Enter", "Toggle selection"}, + {"Ctrl + r", "Run tasks"}, + {"Ctrl + x", "Clear output"}, + }, + }, + } + + // Populate table with sections + currentRow := 0 + for i, section := range sections { + // Add spacing between sections except for the first one + if i > 0 { + r1, r2 := titleRow("") + table.SetCell(currentRow, 0, r1) + table.SetCell(currentRow, 1, r2) + currentRow++ + } + + // Add section title + r1, r2 := titleRow(section.title) + table.SetCell(currentRow, 0, r1) + table.SetCell(currentRow, 1, r2) + currentRow++ + + // Add shortcuts for this section + for _, shortcut := range section.shortcuts { + r1, r2 := shortcutRow(shortcut[0], shortcut[1]) + table.SetCell(currentRow, 0, r1) + table.SetCell(currentRow, 1, r2) + currentRow++ + } + } + + versionString := fmt.Sprintf("[-:-:b]Sake %s", Version) + text := tview.NewTextView() + text.SetDynamicColors(true) + text.SetText(versionString).SetTextAlign(tview.AlignRight) + + root := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(text, 1, 0, true). + AddItem(table, 0, 1, true) + + return root, table +} diff --git a/core/tui/views/tui_server_view.go b/core/tui/views/tui_server_view.go new file mode 100644 index 0000000..52fe426 --- /dev/null +++ b/core/tui/views/tui_server_view.go @@ -0,0 +1,678 @@ +package views + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/rivo/tview" + + "github.com/alajmo/sake/core/dao" + "github.com/alajmo/sake/core/tui/components" + "github.com/alajmo/sake/core/tui/misc" +) + +type TServer struct { + // UI + Page *tview.Flex + ServerTableView *components.TTable + ServerTreeView *components.TTree + TagView *components.TList + + // Server + Servers []dao.Server + ServersFiltered []dao.Server + ServersSelected map[string]bool + serverFilterValue *string + Headers []string + ShowHeaders bool + ServerStyle string + + // Tags + ServerTags []string + ServerTagsFiltered []string + ServerTagsSelected map[string]bool + serverTagFilterValue *string + + // Misc + Emitter *misc.EventEmitter +} + +func CreateServersData( + servers []dao.Server, + serverTags []string, + headers []string, + prefixNumber int, + showTitle bool, + showHeaders bool, + selectEnabled bool, + showTags bool, +) *TServer { + s := &TServer{ + Servers: servers, + ServersFiltered: servers, + ServersSelected: make(map[string]bool), + serverFilterValue: new(string), + + ServerTags: serverTags, + ServerTagsFiltered: serverTags, + ServerTagsSelected: make(map[string]bool), + serverTagFilterValue: new(string), + + ShowHeaders: showHeaders, + Headers: headers, + ServerStyle: "server-table", + + Emitter: misc.NewEventEmitter(), + } + + for _, server := range s.Servers { + s.ServersSelected[server.Name] = false + } + for _, tag := range s.ServerTags { + s.ServerTagsSelected[tag] = false + } + + title := "" + if showTitle && prefixNumber > 0 { + title = fmt.Sprintf("[%d] Servers (%d)", prefixNumber, len(servers)) + prefixNumber += 1 + } else if showTitle { + title = fmt.Sprintf("Servers (%d)", len(servers)) + } + + rows := s.getTableRows() + serverTable := s.CreateServersTable(selectEnabled, title, headers, rows) + s.ServerTableView = serverTable + + nodes := s.getServerTreeHierarchy() + serverTree := s.CreateServersTree(selectEnabled, title, nodes) + s.ServerTreeView = serverTree + + if showTags { + tagTitle := "" + if showTitle && prefixNumber > 0 { + tagTitle = fmt.Sprintf("[%d] Tags (%d)", prefixNumber, len(serverTags)) + } else { + tagTitle = fmt.Sprintf("Tags (%d)", len(serverTags)) + } + + tagsList := s.CreateServersTagsList(tagTitle) + s.TagView = tagsList + } + + // Events + s.Emitter.Subscribe("remove_tag_filter", func(e misc.Event) { + if s.TagView != nil { + s.TagView.ClearFilter() + } + }) + s.Emitter.Subscribe("remove_tag_selections", func(e misc.Event) { + s.unselectAllTags() + }) + s.Emitter.Subscribe("remove_server_filter", func(e misc.Event) { + s.ServerTableView.ClearFilter() + s.ServerTreeView.ClearFilter() + }) + s.Emitter.Subscribe("remove_server_selections", func(event misc.Event) { + s.unselectAllServers() + }) + s.Emitter.Subscribe("filter_servers", func(e misc.Event) { + s.filterServers() + }) + + return s +} + +func (s *TServer) CreateServersTable( + selectEnabled bool, + title string, + headers []string, + rows [][]string, +) *components.TTable { + table := &components.TTable{ + Title: title, + ToggleEnabled: selectEnabled, + ShowHeaders: s.ShowHeaders, + FilterValue: s.serverFilterValue, + } + table.Create() + table.Update(headers, rows) + + // Methods + table.IsRowSelected = func(name string) bool { + return s.ServersSelected[name] + } + table.ToggleSelectRow = func(name string) { + s.toggleSelectServer(name) + } + table.SelectAll = func() { + s.selectAllServers() + } + table.UnselectAll = func() { + s.unselectAllServers() + } + table.FilterRows = func() { + s.filterServers() + } + table.DescribeRow = func(serverName string) { + if serverName != "" { + s.showServerDescModal(serverName) + } + } + table.EditRow = func(serverName string) { + if serverName != "" { + s.editServer(serverName) + } + } + table.SSHRow = func(serverName string) { + if serverName != "" { + s.sshServer(serverName) + } + } + return table +} + +func (s *TServer) CreateServersTagsList(title string) *components.TList { + list := &components.TList{ + Title: title, + FilterValue: s.serverTagFilterValue, + } + list.Create() + list.Update(s.ServerTags) + + // Methods + list.IsItemSelected = func(name string) bool { + return s.ServerTagsSelected[name] + } + list.ToggleSelectItem = func(i int, tag string) { + s.ServerTagsSelected[tag] = !s.ServerTagsSelected[tag] + list.SetItemSelect(i, tag) + s.filterServers() + } + list.SelectAll = func() { + s.selectAllTags() + s.filterServers() + } + list.UnselectAll = func() { + s.unselectAllTags() + s.filterServers() + } + list.FilterItems = func() { + s.filterTags() + } + + return list +} + +func (s *TServer) CreateServersTree( + selectEnabled bool, + title string, + nodes []components.TNode, +) *components.TTree { + tree := &components.TTree{ + Title: title, + RootTitle: "", + SelectEnabled: selectEnabled, + FilterValue: s.serverFilterValue, + } + tree.Create() + tree.UpdateServers(nodes) + tree.UpdateServersStyle() + + tree.IsNodeSelected = func(name string) bool { + return s.ServersSelected[name] + } + tree.ToggleSelectNode = func(name string) { + s.toggleSelectServer(name) + } + tree.SelectAll = func() { + s.selectAllServers() + } + tree.UnselectAll = func() { + s.unselectAllServers() + } + tree.FilterNodes = func() { + s.filterServers() + } + tree.DescribeNode = func(serverName string) { + if serverName != "" { + s.showServerDescModal(serverName) + } + } + tree.EditNode = func(serverName string) { + if serverName != "" { + s.editServer(serverName) + } + } + + return tree +} + +func (s *TServer) getTableRows() [][]string { + var rows = make([][]string, len(s.ServersFiltered)) + for i, server := range s.ServersFiltered { + rows[i] = make([]string, len(s.Headers)) + for j, header := range s.Headers { + rows[i][j] = server.GetValue(header, 0) + } + } + return rows +} + +// getServerTreeHierarchy groups servers by common IP prefix or hostname domain +func (s *TServer) getServerTreeHierarchy() []components.TNode { + // Group servers by common prefix + groups := s.groupServersByCommonPrefix() + + var nodes []components.TNode + // Track seen servers to prevent duplicates + seen := make(map[string]bool) + + for groupName, servers := range groups { + if groupName == "" { + // Flat servers (no grouping) + for _, server := range servers { + if seen[server.Name] { + continue + } + seen[server.Name] = true + + displayName := server.Name + if server.Host != "" && server.Host != server.Name { + displayName = server.Host + " - " + server.Name + } + + node := components.TNode{ + DisplayName: displayName, + ID: server.Name, + Type: "server", + Children: &[]components.TNode{}, + } + nodes = append(nodes, node) + } + } else { + // Grouped servers + parentNode := components.TNode{ + DisplayName: groupName, + ID: "", + Type: "group", + Children: &[]components.TNode{}, + } + for _, server := range servers { + if seen[server.Name] { + continue + } + seen[server.Name] = true + + // Show "host - serverName" format + displayName := server.Host + " - " + server.Name + child := components.TNode{ + DisplayName: displayName, + ID: server.Name, + Type: "server", + } + *parentNode.Children = append(*parentNode.Children, child) + } + nodes = append(nodes, parentNode) + } + } + + return nodes +} + +// groupServersByCommonPrefix groups servers by IP prefix or hostname domain +func (s *TServer) groupServersByCommonPrefix() map[string][]dao.Server { + if len(s.ServersFiltered) == 0 { + return map[string][]dao.Server{} + } + + // Try to find common IP prefixes (e.g., 192.168.1.x) + ipGroups := make(map[string][]dao.Server) + hostnameGroups := make(map[string][]dao.Server) + ungrouped := []dao.Server{} + + // Track seen servers to prevent duplicates + seen := make(map[string]bool) + + for _, server := range s.ServersFiltered { + // Skip duplicates + if seen[server.Name] { + continue + } + seen[server.Name] = true + + host := server.Host + // Local servers or servers without host go to ungrouped + if host == "" || server.Local { + ungrouped = append(ungrouped, server) + continue + } + + // Check if it's an IP address + if isIPAddress(host) { + prefix := getIPPrefix(host) + if prefix != "" { + ipGroups[prefix] = append(ipGroups[prefix], server) + } else { + ungrouped = append(ungrouped, server) + } + } else { + // It's a hostname - group by domain + domain := getHostnameDomain(host) + if domain != "" { + hostnameGroups[domain] = append(hostnameGroups[domain], server) + } else { + ungrouped = append(ungrouped, server) + } + } + } + + // Decide which grouping to use based on which has more groups with multiple servers + result := make(map[string][]dao.Server) + + // Count meaningful groups (groups with more than 1 server) + ipMeaningful := 0 + for _, servers := range ipGroups { + if len(servers) > 1 { + ipMeaningful++ + } + } + hostnameMeaningful := 0 + for _, servers := range hostnameGroups { + if len(servers) > 1 { + hostnameMeaningful++ + } + } + + // Use the grouping that has more meaningful groups + if ipMeaningful > 0 && ipMeaningful >= hostnameMeaningful { + // Use IP grouping + for prefix, servers := range ipGroups { + if len(servers) > 1 { + result[prefix+".*"] = servers + } else { + ungrouped = append(ungrouped, servers...) + } + } + } else if hostnameMeaningful > 0 { + // Use hostname grouping + for domain, servers := range hostnameGroups { + if len(servers) > 1 { + result["*."+domain] = servers + } else { + ungrouped = append(ungrouped, servers...) + } + } + } + + // Add ungrouped servers as flat + if len(ungrouped) > 0 { + result[""] = ungrouped + } + + // If no meaningful grouping found, return all as flat + if len(result) == 0 || (len(result) == 1 && result[""] != nil) { + return map[string][]dao.Server{"": s.ServersFiltered} + } + + return result +} + +// isIPAddress checks if a string looks like an IP address +func isIPAddress(host string) bool { + parts := strings.Split(host, ".") + if len(parts) != 4 { + return false + } + for _, part := range parts { + if _, err := fmt.Sscanf(part, "%d", new(int)); err != nil { + return false + } + } + return true +} + +// getIPPrefix returns the first 3 octets of an IP address +func getIPPrefix(ip string) string { + parts := strings.Split(ip, ".") + if len(parts) >= 3 { + return strings.Join(parts[:3], ".") + } + return "" +} + +// getHostnameDomain returns the domain part of a hostname (last 2 parts) +func getHostnameDomain(hostname string) string { + parts := strings.Split(hostname, ".") + if len(parts) >= 2 { + return strings.Join(parts[len(parts)-2:], ".") + } + return "" +} + +func (s *TServer) toggleSelectServer(name string) { + s.ServersSelected[name] = !s.ServersSelected[name] + s.ServerTableView.ToggleSelectCurrentRow(name) + s.ServerTreeView.ToggleSelectCurrentNode(name) +} + +func (s *TServer) filterServers() { + serverTags := []string{} + for key, filtered := range s.ServerTagsSelected { + if filtered { + serverTags = append(serverTags, key) + } + } + + if len(serverTags) > 0 { + var filtered []dao.Server + for _, server := range s.Servers { + for _, tag := range serverTags { + for _, serverTag := range server.Tags { + if serverTag == tag { + filtered = append(filtered, server) + break + } + } + } + } + s.ServersFiltered = filtered + } else { + s.ServersFiltered = s.Servers + } + + var finalServers []dao.Server + for _, server := range s.ServersFiltered { + if strings.Contains(strings.ToLower(server.Name), strings.ToLower(*s.serverFilterValue)) { + finalServers = append(finalServers, server) + } + } + s.ServersFiltered = finalServers + + // Table + rows := s.getTableRows() + s.ServerTableView.Update(s.Headers, rows) + s.ServerTableView.Table.ScrollToBeginning() + s.ServerTableView.Table.Select(1, 0) + + // Tree + serverTree := s.getServerTreeHierarchy() + s.ServerTreeView.UpdateServers(serverTree) + s.ServerTreeView.UpdateServersStyle() + s.ServerTreeView.FocusFirst() +} + +func (s *TServer) filterTags() { + var finalTags []string + for _, tag := range s.ServerTags { + if strings.Contains(tag, *s.serverTagFilterValue) { + finalTags = append(finalTags, tag) + } + } + s.ServerTagsFiltered = finalTags + s.TagView.Update(s.ServerTagsFiltered) +} + +func (s *TServer) selectAllServers() { + for _, server := range s.ServersFiltered { + s.ServersSelected[server.Name] = true + } + s.ServerTableView.UpdateRowStyle() + s.ServerTreeView.UpdateServersStyle() +} + +func (s *TServer) selectAllTags() { + for _, tag := range s.ServerTagsFiltered { + s.ServerTagsSelected[tag] = true + } + s.TagView.Update(s.ServerTagsFiltered) +} + +func (s *TServer) unselectAllServers() { + for _, server := range s.ServersFiltered { + s.ServersSelected[server.Name] = false + } + s.ServerTableView.UpdateRowStyle() + s.ServerTreeView.UpdateServersStyle() +} + +func (s *TServer) unselectAllTags() { + for _, tag := range s.ServerTagsFiltered { + s.ServerTagsSelected[tag] = false + } + if s.TagView != nil { + s.TagView.Update(s.ServerTagsFiltered) + } +} + +func (s *TServer) showServerDescModal(name string) { + server, err := misc.Config.GetServer(name) + if err != nil { + return + } + + // Get bastion hosts as strings + var bastions []string + for _, bastion := range server.Bastions { + bastionStr := bastion.Host + if bastion.User != "" { + bastionStr = bastion.User + "@" + bastionStr + } + bastions = append(bastions, bastionStr) + } + + // Get identity file + identityFile := "" + if server.IdentityFile != nil { + identityFile = *server.IdentityFile + } + + description := misc.FormatServerBlock( + server.Name, + server.Desc, + server.Host, + server.User, + server.Port, + server.Local, + server.Tags, + bastions, + identityFile, + server.WorkDir, + ) + components.OpenTextModal("server-description-modal", description, server.Name) +} + +func (s *TServer) editServer(serverName string) { + misc.App.Suspend(func() { + err := misc.Config.EditServer(serverName) + if err != nil { + return + } + }) +} + +func (s *TServer) sshServer(serverName string) { + server, err := misc.Config.GetServer(serverName) + if err != nil { + return + } + + // Don't SSH to local servers + if server.Local { + return + } + + misc.App.Suspend(func() { + // Build SSH command args + args := []string{} + + // Add identity file if specified + if server.IdentityFile != nil && *server.IdentityFile != "" { + args = append(args, "-i", *server.IdentityFile) + } + + // Add port if not default + if server.Port != 0 && server.Port != 22 { + args = append(args, "-p", fmt.Sprintf("%d", server.Port)) + } + + // Add bastion/jump hosts if specified + if len(server.Bastions) > 0 { + var jumpHosts []string + for _, bastion := range server.Bastions { + jumpHost := bastion.Host + if bastion.User != "" { + jumpHost = bastion.User + "@" + jumpHost + } + if bastion.Port != 0 && bastion.Port != 22 { + jumpHost = fmt.Sprintf("%s:%d", jumpHost, bastion.Port) + } + jumpHosts = append(jumpHosts, jumpHost) + } + args = append(args, "-J", strings.Join(jumpHosts, ",")) + } + + // Add user@host + target := server.Host + if server.User != "" { + target = server.User + "@" + server.Host + } + args = append(args, target) + + // Run SSH command + cmd := exec.Command("ssh", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Run() + }) +} + +// GetSelectedServers returns names of selected servers +func (s *TServer) GetSelectedServers() []string { + var selected []string + for name, isSelected := range s.ServersSelected { + if isSelected { + selected = append(selected, name) + } + } + return selected +} + +// GetSelectedServerObjects returns the selected server objects +func (s *TServer) GetSelectedServerObjects() []dao.Server { + var selected []dao.Server + for _, server := range s.Servers { + if s.ServersSelected[server.Name] { + selected = append(selected, server) + } + } + return selected +} + +// GetFilteredServers returns the filtered server objects +func (s *TServer) GetFilteredServers() []dao.Server { + return s.ServersFiltered +} diff --git a/core/tui/views/tui_shortcut_info.go b/core/tui/views/tui_shortcut_info.go new file mode 100644 index 0000000..fef1074 --- /dev/null +++ b/core/tui/views/tui_shortcut_info.go @@ -0,0 +1,88 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/alajmo/sake/core/tui/misc" + "github.com/rivo/tview" +) + +type Shortcut struct { + label string + shortcut string +} + +func getShortcutInfo(shortcuts []Shortcut) string { + var formattedShortcuts []string + for _, s := range shortcuts { + value := fmt.Sprintf("[%s:%s:%s]%s[-:-:-] [%s:%s:%s]%s[-:-:-]", + misc.STYLE_SHORTCUT_TEXT.FgStr, misc.STYLE_SHORTCUT_TEXT.BgStr, misc.STYLE_SHORTCUT_TEXT.AttrStr, s.shortcut, + misc.STYLE_SHORTCUT_LABEL.FgStr, misc.STYLE_SHORTCUT_LABEL.BgStr, misc.STYLE_SHORTCUT_LABEL.AttrStr, s.label, + ) + formattedShortcuts = append(formattedShortcuts, value) + } + return strings.Join(formattedShortcuts, " ") +} + +func CreateRunInfoView() *tview.TextView { + shortcuts := []Shortcut{ + {"Ctrl-r", "Run"}, + {"Ctrl-s", "Toggle View"}, + {"Ctrl-e", "Toggle Table/Tree"}, + {"Ctrl-o", "Options"}, + } + text := getShortcutInfo(shortcuts) + + helpInfo := tview.NewTextView(). + SetDynamicColors(true). + SetText(text) + helpInfo.SetTextAlign(tview.AlignRight) + helpInfo.SetBorderPadding(0, 0, 0, 1) + return helpInfo +} + +func CreateExecInfoView() *tview.TextView { + shortcuts := []Shortcut{ + {"Ctrl-r", "Run"}, + {"Ctrl-x", "Clear"}, + {"Ctrl-o", "Options"}, + } + text := getShortcutInfo(shortcuts) + + helpInfo := tview.NewTextView(). + SetDynamicColors(true). + SetText(text) + helpInfo.SetTextAlign(tview.AlignRight) + helpInfo.SetBorderPadding(0, 0, 0, 1) + return helpInfo +} + +func CreateServersInfoView() *tview.TextView { + shortcuts := []Shortcut{ + {"Shift-S", "SSH to Server"}, + {"Ctrl-e", "Toggle Table/Tree"}, + } + text := getShortcutInfo(shortcuts) + + helpInfo := tview.NewTextView(). + SetDynamicColors(true). + SetText(text) + helpInfo.SetTextAlign(tview.AlignRight) + helpInfo.SetBorderPadding(0, 0, 0, 1) + return helpInfo +} + +func CreateTasksInfoView() *tview.TextView { + shortcuts := []Shortcut{ + {"Ctrl-e", "Toggle Table/Tree"}, + } + text := getShortcutInfo(shortcuts) + + helpInfo := tview.NewTextView(). + SetDynamicColors(true). + SetText(text) + helpInfo.SetTextAlign(tview.AlignRight) + helpInfo.SetBorderPadding(0, 0, 0, 1) + return helpInfo +} diff --git a/core/tui/views/tui_spec_view.go b/core/tui/views/tui_spec_view.go new file mode 100644 index 0000000..422fc36 --- /dev/null +++ b/core/tui/views/tui_spec_view.go @@ -0,0 +1,158 @@ +package views + +import ( + "github.com/alajmo/sake/core/dao" + "github.com/alajmo/sake/core/tui/components" + "github.com/alajmo/sake/core/tui/misc" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// TSpec holds the execution options for the TUI +type TSpec struct { + View *tview.Flex + items []*tview.Box + + // Options + Output string + Strategy string + ClearBeforeRun bool + IgnoreErrors bool + IgnoreUnreachable bool + OmitEmptyRows bool + OmitEmptyColumns bool + AnyErrorsFatal bool +} + +// CreateSpecView creates the options view with all spec options +func CreateSpecView() *TSpec { + defSpec := dao.DEFAULT_SPEC + + spec := &TSpec{ + Output: defSpec.Output, + Strategy: defSpec.Strategy, + ClearBeforeRun: false, + IgnoreErrors: defSpec.IgnoreErrors, + IgnoreUnreachable: defSpec.IgnoreUnreachable, + OmitEmptyRows: defSpec.OmitEmptyRows, + OmitEmptyColumns: defSpec.OmitEmptyColumns, + AnyErrorsFatal: defSpec.AnyErrorsFatal, + } + + view := tview.NewFlex().SetDirection(tview.FlexRow) + view.SetBorder(false).SetBorderPadding(0, 0, 0, 0) + view.SetBackgroundColor(misc.STYLE_DEFAULT.Bg) + spec.View = view + + // Output type toggle (text/table) + outputType := &components.TToggleText{ + Value: &spec.Output, + Option1: "text", + Option2: "table", + Label1: " Output: text ", + Label2: " Output: table ", + } + outputType.Create() + + // Strategy toggle (linear/free/host_pinned) + strategyType := &components.TToggleThree{ + Value: &spec.Strategy, + Option1: "linear", + Option2: "free", + Option3: "host_pinned", + Label1: " Strategy: linear ", + Label2: " Strategy: free ", + Label3: " Strategy: host_pinned ", + } + strategyType.Create() + + // Checkboxes + clearBeforeRun := spec.AddCheckbox("Clear Before Run", &spec.ClearBeforeRun) + ignoreErrors := spec.AddCheckbox("Ignore Errors", &spec.IgnoreErrors) + ignoreUnreachable := spec.AddCheckbox("Ignore Unreachable", &spec.IgnoreUnreachable) + omitEmptyRows := spec.AddCheckbox("Omit Empty Rows", &spec.OmitEmptyRows) + omitEmptyColumns := spec.AddCheckbox("Omit Empty Columns", &spec.OmitEmptyColumns) + anyErrorsFatal := spec.AddCheckbox("Any Errors Fatal", &spec.AnyErrorsFatal) + + // Add items to view + view.AddItem(outputType.TextView, 1, 0, false) + view.AddItem(strategyType.TextView, 1, 0, false) + view.AddItem(clearBeforeRun, 1, 0, false) + view.AddItem(ignoreErrors, 1, 0, false) + view.AddItem(ignoreUnreachable, 1, 0, false) + view.AddItem(omitEmptyRows, 1, 0, false) + view.AddItem(omitEmptyColumns, 1, 0, false) + view.AddItem(anyErrorsFatal, 1, 0, false) + + focusItems := []*tview.Box{ + outputType.TextView.Box, + strategyType.TextView.Box, + clearBeforeRun.Box, + ignoreErrors.Box, + ignoreUnreachable.Box, + omitEmptyRows.Box, + omitEmptyColumns.Box, + anyErrorsFatal.Box, + } + + // Input handling for navigation + currentFocus := 0 + view.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyDown: + if currentFocus < (len(focusItems) - 1) { + currentFocus += 1 + misc.App.SetFocus(focusItems[currentFocus]) + } + return nil + case tcell.KeyUp: + if currentFocus > 0 { + currentFocus -= 1 + misc.App.SetFocus(focusItems[currentFocus]) + } + return nil + case tcell.KeyRune: + switch event.Rune() { + case 'g': // top + currentFocus = 0 + misc.App.SetFocus(focusItems[currentFocus]) + return nil + case 'G': // bottom + currentFocus = len(focusItems) - 1 + misc.App.SetFocus(focusItems[currentFocus]) + return nil + case 'j': // down + if currentFocus < (len(focusItems) - 1) { + currentFocus += 1 + misc.App.SetFocus(focusItems[currentFocus]) + } + return nil + case 'k': // up + if currentFocus > 0 { + currentFocus -= 1 + misc.App.SetFocus(focusItems[currentFocus]) + } + return nil + } + } + + return event + }) + + view.SetFocusFunc(func() { + currentFocus = 0 + misc.App.SetFocus(outputType.TextView) + }) + + return spec +} + +// AddCheckbox adds a checkbox to the spec view +func (spec *TSpec) AddCheckbox(title string, checked *bool) *tview.Checkbox { + onFocus := func() {} + onBlur := func() {} + + checkbox := components.Checkbox(title, checked, onFocus, onBlur) + spec.items = append(spec.items, checkbox.Box) + return checkbox +} diff --git a/core/tui/views/tui_task_view.go b/core/tui/views/tui_task_view.go new file mode 100644 index 0000000..25a2f8f --- /dev/null +++ b/core/tui/views/tui_task_view.go @@ -0,0 +1,362 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/rivo/tview" + + "github.com/alajmo/sake/core/dao" + "github.com/alajmo/sake/core/tui/components" + "github.com/alajmo/sake/core/tui/misc" +) + +type TTask struct { + // UI + Page *tview.Flex + TaskTableView *components.TTable + TaskTreeView *components.TTree + + // Task + Tasks []dao.Task + TasksFiltered []dao.Task + TasksSelected map[string]bool + taskFilterValue *string + Headers []string + ShowHeaders bool + TaskStyle string + + // Misc + Emitter *misc.EventEmitter +} + +func CreateTasksData( + tasks []dao.Task, + headers []string, + prefixNumber int, + showTitle bool, + showHeaders bool, + selectEnabled bool, +) *TTask { + t := &TTask{ + Tasks: tasks, + TasksFiltered: tasks, + TasksSelected: make(map[string]bool), + taskFilterValue: new(string), + + ShowHeaders: showHeaders, + Headers: headers, + TaskStyle: "task-table", + + Emitter: misc.NewEventEmitter(), + } + + for _, task := range t.Tasks { + t.TasksSelected[task.ID] = false + } + + title := "" + if showTitle && prefixNumber > 0 { + title = fmt.Sprintf("[%d] Tasks (%d)", prefixNumber, len(tasks)) + } else if showTitle { + title = fmt.Sprintf("Tasks (%d)", len(tasks)) + } + + rows := t.getTableRows() + taskTable := t.CreateTasksTable(selectEnabled, title, headers, rows) + t.TaskTableView = taskTable + + nodes := t.getTreeHierarchy() + taskTree := t.CreateTasksTree(selectEnabled, title, nodes) + t.TaskTreeView = taskTree + + // Events + t.Emitter.Subscribe("remove_task_filter", func(e misc.Event) { + t.TaskTableView.ClearFilter() + t.TaskTreeView.ClearFilter() + }) + t.Emitter.Subscribe("remove_task_selections", func(event misc.Event) { + t.unselectAllTasks() + }) + t.Emitter.Subscribe("filter_tasks", func(e misc.Event) { + t.filterTasks() + }) + + return t +} + +func (t *TTask) CreateTasksTable( + selectEnabled bool, + title string, + headers []string, + rows [][]string, +) *components.TTable { + table := &components.TTable{ + Title: title, + ToggleEnabled: selectEnabled, + ShowHeaders: t.ShowHeaders, + FilterValue: t.taskFilterValue, + } + table.Create() + table.Update(headers, rows) + + // Methods + table.IsRowSelected = func(name string) bool { + return t.TasksSelected[name] + } + table.ToggleSelectRow = func(name string) { + t.toggleSelectTask(name) + } + table.SelectAll = func() { + t.selectAllTasks() + } + table.UnselectAll = func() { + t.unselectAllTasks() + } + table.FilterRows = func() { + t.filterTasks() + } + table.DescribeRow = func(taskName string) { + if taskName != "" { + t.showTaskDescModal(taskName) + } + } + table.EditRow = func(taskName string) { + if taskName != "" { + t.editTask(taskName) + } + } + return table +} + +func (t *TTask) CreateTasksTree( + selectEnabled bool, + title string, + nodes []components.TNode, +) *components.TTree { + tree := &components.TTree{ + Title: title, + RootTitle: "", + SelectEnabled: selectEnabled, + FilterValue: t.taskFilterValue, + } + tree.Create() + tree.UpdateTasks(nodes) + tree.UpdateTasksStyle() + + tree.IsNodeSelected = func(name string) bool { + return t.TasksSelected[name] + } + tree.ToggleSelectNode = func(name string) { + t.toggleSelectTask(name) + } + tree.SelectAll = func() { + t.selectAllTasks() + } + tree.UnselectAll = func() { + t.unselectAllTasks() + } + tree.FilterNodes = func() { + t.filterTasks() + } + tree.DescribeNode = func(taskName string) { + if taskName != "" { + t.showTaskDescModal(taskName) + } + } + tree.EditNode = func(taskName string) { + if taskName != "" { + t.editTask(taskName) + } + } + + return tree +} + +func (t *TTask) getTableRows() [][]string { + var rows = make([][]string, len(t.TasksFiltered)) + for i, task := range t.TasksFiltered { + rows[i] = make([]string, len(t.Headers)) + for j, header := range t.Headers { + rows[i][j] = task.GetValue(header, 0) + } + } + return rows +} + +func (t *TTask) getTreeHierarchy() []components.TNode { + var nodes = []components.TNode{} + for _, task := range t.TasksFiltered { + parentNode := &components.TNode{ + DisplayName: task.ID, + ID: task.ID, + Type: "task", + Children: &[]components.TNode{}, + } + + // Sub-tasks + for _, subTask := range task.Tasks { + var node *components.TNode + if subTask.Name != "" { + node = &components.TNode{ + DisplayName: subTask.Name, + ID: task.ID, + Type: "command", + Children: &[]components.TNode{}, + } + } else { + node = &components.TNode{ + DisplayName: "cmd", + ID: task.ID, + Type: "command", + Children: &[]components.TNode{}, + } + } + *parentNode.Children = append(*parentNode.Children, *node) + } + + // Task refs + for _, ref := range task.TaskRefs { + node := &components.TNode{ + DisplayName: ref.Task, + ID: task.ID, + Type: "task-ref", + Children: &[]components.TNode{}, + } + *parentNode.Children = append(*parentNode.Children, *node) + } + + nodes = append(nodes, *parentNode) + } + + return nodes +} + +func (t *TTask) toggleSelectTask(name string) { + t.TasksSelected[name] = !t.TasksSelected[name] + t.TaskTableView.ToggleSelectCurrentRow(name) + t.TaskTreeView.ToggleSelectCurrentNode(name) +} + +func (t *TTask) filterTasks() { + var finalTasks []dao.Task + for _, task := range t.Tasks { + if strings.Contains(strings.ToLower(task.ID), strings.ToLower(*t.taskFilterValue)) || + strings.Contains(strings.ToLower(task.Name), strings.ToLower(*t.taskFilterValue)) { + finalTasks = append(finalTasks, task) + } + } + t.TasksFiltered = finalTasks + + // Table + rows := t.getTableRows() + t.TaskTableView.Update(t.Headers, rows) + t.TaskTableView.Table.ScrollToBeginning() + t.TaskTableView.Table.Select(1, 0) + + // Tree + taskTree := t.getTreeHierarchy() + t.TaskTreeView.UpdateTasks(taskTree) + t.TaskTreeView.UpdateTasksStyle() + t.TaskTreeView.FocusFirst() +} + +func (t *TTask) selectAllTasks() { + for _, task := range t.TasksFiltered { + t.TasksSelected[task.ID] = true + } + t.TaskTableView.UpdateRowStyle() + t.TaskTreeView.UpdateTasksStyle() +} + +func (t *TTask) unselectAllTasks() { + for _, task := range t.TasksFiltered { + t.TasksSelected[task.ID] = false + } + t.TaskTableView.UpdateRowStyle() + t.TaskTreeView.UpdateTasksStyle() +} + +func (t *TTask) showTaskDescModal(name string) { + task, err := misc.Config.GetTask(name) + if err != nil { + return + } + + // Build sub-tasks info from TaskRefs + var subTasks []misc.SubTaskInfo + for _, ref := range task.TaskRefs { + st := misc.SubTaskInfo{ + Name: ref.Name, + Desc: ref.Desc, + Cmd: ref.Cmd, + Task: ref.Task, + IsRef: ref.Task != "", + } + subTasks = append(subTasks, st) + } + + // Also include resolved Tasks (TaskCmd) + for _, tc := range task.Tasks { + st := misc.SubTaskInfo{ + Name: tc.Name, + Desc: tc.Desc, + Cmd: tc.Cmd, + IsRef: false, + } + subTasks = append(subTasks, st) + } + + description := misc.FormatTaskBlock( + task.Name, + task.Desc, + task.Cmd, + task.Local, + task.TTY, + task.Attach, + task.WorkDir, + task.Shell, + task.Envs, + task.Target.Tags, + subTasks, + task.Spec.Name, + task.Target.Name, + task.Theme.Name, + ) + components.OpenTextModal("task-description-modal", description, task.Name) +} + +func (t *TTask) editTask(taskName string) { + misc.App.Suspend(func() { + err := misc.Config.EditTask(taskName) + if err != nil { + return + } + }) +} + +// GetSelectedTasks returns IDs of selected tasks +func (t *TTask) GetSelectedTasks() []string { + var selected []string + for id, isSelected := range t.TasksSelected { + if isSelected { + selected = append(selected, id) + } + } + return selected +} + +// GetSelectedTaskObjects returns the selected task objects +func (t *TTask) GetFilteredTasks() []dao.Task { + return t.TasksFiltered +} + +func (t *TTask) GetSelectedTaskObjects() []dao.Task { + var selected []dao.Task + for _, task := range t.Tasks { + if t.TasksSelected[task.ID] { + selected = append(selected, task) + } + } + return selected +} diff --git a/core/tui/watcher.go b/core/tui/watcher.go new file mode 100644 index 0000000..a554031 --- /dev/null +++ b/core/tui/watcher.go @@ -0,0 +1,41 @@ +package tui + +import ( + "log" + + "github.com/fsnotify/fsnotify" +) + +func WatchFiles(app *App, paths ...string) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Fatal(err) + } + + go func() { + defer watcher.Close() + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) { + app.Reload() + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Println("error:", err) + } + } + }() + + for _, path := range paths { + err = watcher.Add(path) + if err != nil { + log.Fatal(err) + } + } +} diff --git a/go.mod b/go.mod index 7dbb326..638133d 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,13 @@ module github.com/alajmo/sake go 1.26.3 require ( + github.com/fsnotify/fsnotify v1.9.0 + github.com/gdamore/tcell/v2 v2.13.7 github.com/gobwas/glob v0.2.3 github.com/jedib0t/go-pretty/v6 v6.6.5 github.com/kevinburke/ssh_config v1.2.0 github.com/kr/pretty v0.2.1 + github.com/rivo/tview v0.42.0 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/theckman/yacspin v0.13.12 @@ -20,16 +23,13 @@ require ( require ( github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/fatih/color v1.18.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect - github.com/gdamore/tcell/v2 v2.13.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/text v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/rivo/tview v0.42.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect golang.org/x/text v0.31.0 // indirect diff --git a/go.sum b/go.sum index df92cf6..27f1e82 100644 --- a/go.sum +++ b/go.sum @@ -76,15 +76,11 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -92,8 +88,6 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From c109bc307c6279b0ca7f94f75c728cc9616025bb Mon Sep 17 00:00:00 2001 From: samiralajmovic Date: Mon, 1 Jun 2026 01:07:01 +0200 Subject: [PATCH 2/2] Fix TUI concurrency, focus, and rendering bugs Address defects tracked in BUGS.md on the tui branch: - #1 Reload: marshal widget mutation onto the event loop via QueueUpdateDraw and debounce the fsnotify watcher (was a data race). - #2 EventEmitter: dispatch listeners synchronously so the widget-mutating filter handlers no longer race the draw loop. - #3 Focus: guard the PreviousPane fallback and never deref a nil Box in FocusNext/FocusPrevious; fix Shift-Tab focusing the wrong pane. - #4 Output streaming: ThreadSafeWriter schedules a throttled App.Draw() so run output renders as it arrives instead of only at the end. - #5 Tag filter: stop appending a server once per matching tag (dedupe). - #6 Server tree: iterate group maps in sorted order for stable layout. - #7 Re-entrant runs: add a running guard and snapshot the spec before launching the run goroutine. - #8 Reload error: surface a modal and keep the previous config instead of quitting on a transient bad save (ANSI-stripped for clean display). - #10 Table/modal sizing: measure widths and truncate by rune, not byte. Co-Authored-By: Claude Opus 4.8 (1M context) --- BUGS.md | 127 ++++++++++++++++++++++++++++++ core/tui/components/tui_modal.go | 3 +- core/tui/misc/tui_event.go | 32 ++++---- core/tui/misc/tui_focus.go | 46 +++++++---- core/tui/misc/tui_utils.go | 3 +- core/tui/misc/tui_writer.go | 42 ++++++++-- core/tui/pages/tui_exec.go | 42 +++++++--- core/tui/pages/tui_run.go | 42 +++++++--- core/tui/tui.go | 30 +++++-- core/tui/views/tui_server_view.go | 39 +++++++-- core/tui/watcher.go | 14 +++- 11 files changed, 351 insertions(+), 69 deletions(-) create mode 100644 BUGS.md diff --git a/BUGS.md b/BUGS.md new file mode 100644 index 0000000..88d5139 --- /dev/null +++ b/BUGS.md @@ -0,0 +1,127 @@ +# TUI bugs (`tui` branch) + +Defects in the `core/tui/**` implementation and the `TextTUI` backend it calls (`core/run/text.go`), ordered by impact. + +## ✅ 1. Config reload mutates the UI from non-UI goroutines - HIGH + +**Files:** `core/tui/tui.go:40-50`, `core/tui/tui_input.go:37-39`, `core/tui/watcher.go:18-25` + +`App.Reload()` rebuilds `misc.Pages` and calls `app.App.SetRoot(...)` + `app.App.Draw()`. It is invoked off the event loop from two places: F5 runs `go app.Reload()`, and the fsnotify watcher calls `app.Reload()` directly from its goroutine. tview is single-threaded — widget mutation must go through `App.QueueUpdate`/`QueueUpdateDraw`. As written, a reload can run concurrently with the event loop and with a second reload, racing on `misc.Pages`, the input handler, and the screen (data race, possible panic / corruption). + +**Fix:** Run the reload body inside `app.App.QueueUpdateDraw(...)` and serialize reloads (debounce the watcher). + +## ✅ 2. `EventEmitter` runs listeners in goroutines that mutate widgets - HIGH + +**Files:** `core/tui/misc/tui_event.go:31-39`, `core/tui/pages/tui_run.go:135`, `core/tui/pages/tui_exec.go:182`, `core/tui/pages/tui_server.go:82` + +`Publish` dispatches each listener with `go listener(event)`. The "clear all" (`C`) handlers call `Emitter.Publish(filter_servers)`, so `filterServers()` runs on a detached goroutine and calls `Table.Clear()`/`SetCell`/tree rebuild concurrently with drawing → data race. (`PublishAndWait` blocks the caller so it is borderline-safe, but still executes listeners off the event loop.) + +**Fix:** Make the emitter synchronous, or marshal UI mutations through `QueueUpdateDraw`. + +## ✅ 3. `FocusNext`/`FocusPrevious` nil-pointer panic and wrong target - HIGH + +**File:** `core/tui/misc/tui_focus.go:13-67` + +If neither the current focus nor `PreviousPane` is found in `elements`, `nextFocusItem` stays a zero `TItem{}` and `nextFocusItem.Box.SetBorderColor(...)` dereferences a nil `*Box` → panic (reachable via Tab/Shift-Tab right after a page switch). Separately, `FocusPrevious`'s second loop runs unconditionally (it lacks the `if prevIndex < 0` guard that `FocusNext` has), so when both the current focus and `PreviousPane` are in the list it overrides the correct result → Shift-Tab focuses the wrong pane. + +**Fix:** Guard the `PreviousPane` fallback behind "not found", and bail out when no match exists. + +## ✅ 4. Output never streams; it appears only after the whole run finishes - MEDIUM + +**Files:** `core/tui/components/tui_output.go`, `core/tui/pages/tui_run.go:340-345`, `core/tui/pages/tui_exec.go:252-255` + +The Output `TextView` has no `SetChangedFunc`, and tview's `TextView.write()` only repaints via that callback. The single redraw is `misc.App.QueueUpdateDraw(func(){})` emitted *after* the run loop completes. For any long-running task the Output pane stays blank/frozen until the run is done. + +**Fix:** Set a (throttled) changed func that calls `App.Draw()`/`QueueUpdateDraw` so output renders as it arrives. + +## ✅ 5. Tag filter produces duplicate servers - MEDIUM + +**File:** `core/tui/views/tui_server_view.go:469-484` + +With ≥2 tags selected, the inner `break` only exits the `serverTag` loop, so a server carrying multiple selected tags is appended once per matching tag → duplicate rows in the table and tree, and duplicated execution targets. + +**Fix:** Continue to the next server after the first tag match (label the `tag` loop, or dedupe with a `seen` set). + +## ✅ 6. Server-tree group order is nondeterministic - MEDIUM + +**File:** `core/tui/views/tui_server_view.go:265-323` + +`getServerTreeHierarchy` ranges over the `groups` map, so group ordering reshuffles randomly on every filter/redraw. + +**Fix:** Collect the group keys, sort them, then build nodes in sorted order. + +## ✅ 7. Re-entrant runs are not guarded - MEDIUM + +**Files:** `core/tui/pages/tui_run.go:316-346`, `core/tui/pages/tui_exec.go:228-256` + +Pressing Ctrl-r again mid-run spawns a second execution goroutine sharing the same Output writer and opening a second set of SSH clients; output interleaves and clients can collide. The spec fields (`spec.IgnoreErrors`, etc.) are also read from the run goroutine while the options modal can toggle them → data race. + +**Fix:** Add a "running" guard that ignores Ctrl-r while a run is in flight, and snapshot the spec before launching. + +## ✅ 8. `--reload` quits the app on a transient bad config - MEDIUM + +**File:** `core/tui/tui.go:41-45` + +On `configErr != nil`, `Reload()` calls `app.App.Stop()`. The first momentarily-invalid save while editing the watched file kills the TUI, defeating the purpose of live reload. + +**Fix:** Surface the parse error (e.g., a modal/status line) and keep the previous config loaded. + +## ✅ 10. Table rendering measures bytes but pads/truncates as runes - LOW + +**Files:** `core/tui/pages/tui_run.go:466-535`, `core/tui/pages/tui_exec.go:457-526` + +`writeTableOutput` computes widths with `len()` (bytes) but pads with `%-*s` (runes) and truncates via byte slicing `cellContent[:colWidths[i]-3]`, so non-ASCII output misaligns and can be cut mid-rune. Same byte-vs-rune issue in `getTextModalSize` (`tui_modal.go:171`) and `GetModalSize` (`misc/tui_utils.go:31`). + +**Fix:** Use `utf8.RuneCountInString` / `[]rune` slicing (or a width-aware helper) for measurement and truncation. + +## ⬜ 11. Editing via `o` does not refresh the in-memory config - LOW + +**Files:** `core/tui/views/tui_server_view.go:587-594`, `core/tui/views/tui_task_view.go:329-336` + +`editServer`/`editTask` only `Suspend` + edit the file. Without `--reload` the change is invisible, and the in-memory config used for subsequent runs stays stale. + +**Fix:** Re-read the config (or trigger the reload path) after the editor exits. + +## ⬜ 12. `isIPAddress` accepts non-IPs - LOW + +**File:** `core/tui/views/tui_server_view.go:424-435` + +`fmt.Sscanf(part, "%d", …)` treats `1abc` as `1` and there is no 0–255 range check, so `1abc.2.3.4` and `999.999.999.999` are classified as IPs. Only affects the tree-grouping heuristic. + +**Fix:** Use `net.ParseIP`. + +## ⬜ 13. Filter case-inconsistency - LOW + +**File:** `core/tui/views/tui_server_view.go:507-516` + +Server/task name filters are case-insensitive, but the tag filter (`filterTags`) uses case-sensitive `strings.Contains`. + +**Fix:** Lower-case both sides in `filterTags`. + +## ⬜ 14. `sshServer` swallows errors and does not expand `~` - LOW + +**File:** `core/tui/views/tui_server_view.go:596-651` + +`cmd.Run()`'s error is ignored, and `IdentityFile` is passed unexpanded, so a `~/...` path may not resolve. + +**Fix:** Surface the error and expand `~` (reuse the resolver used elsewhere in the codebase). + +## ⬜ 15. Single `misc.Search` primitive shared across four pages - LOW + +**Files:** `core/tui/pages.go`, all page constructors + +The same `misc.Search` `InputField` is added to four different page `Flex` layouts. It works only because one page draws at a time; it is fragile tview usage (shared rect/focus). + +**Fix:** Give each page its own search input, or host a single search bar above the page container. + +## ⬜ 16. `wg.Add(1)` after goroutine launch - LOW + +**File:** `core/run/text.go:1465`, `:1500` (TUI path); pre-existing at `:639`, `:676` + +`go func(){ defer wg.Done(); … }(); wg.Add(1)` calls `Add` after starting the goroutine; `Add` must precede it, otherwise `Wait` can race to a negative-counter panic. Practically rare because the goroutine blocks on I/O first, but still incorrect. + +**Fix:** Call `wg.Add(1)` before each `go`. + +## Not listed + +- Outside the TUI proper, the `tui` branch's import dedup in `core/dao/import_config.go:431-443` silently drops later same-named servers from imports (a behavior change) and is O(n²). diff --git a/core/tui/components/tui_modal.go b/core/tui/components/tui_modal.go index 589f809..3c6ce1b 100644 --- a/core/tui/components/tui_modal.go +++ b/core/tui/components/tui_modal.go @@ -2,6 +2,7 @@ package components import ( "strings" + "unicode/utf8" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -167,7 +168,7 @@ func getTextModalSize(text string) (int, int) { width := 40 for _, line := range lines { - lineLen := len(line) + 6 + lineLen := utf8.RuneCountInString(line) + 6 if lineLen > width { width = lineLen } diff --git a/core/tui/misc/tui_event.go b/core/tui/misc/tui_event.go index 7593a7e..2ee54cd 100644 --- a/core/tui/misc/tui_event.go +++ b/core/tui/misc/tui_event.go @@ -28,28 +28,26 @@ func (ee *EventEmitter) Subscribe(eventName string, listener EventListener) { ee.listeners[eventName] = append(ee.listeners[eventName], listener) } +// Publish invokes every listener for the event synchronously, in the caller's +// goroutine. The TUI handlers that publish all run on the tview event loop, and +// the listeners mutate widgets (tables, trees) — which tview requires to happen +// on that single loop. Dispatching on detached goroutines (the previous +// behaviour) raced with drawing, so listeners must run inline here. +// +// The listener slice is copied under the read lock and the lock released before +// any listener runs, so a listener is free to (un)subscribe without deadlocking. func (ee *EventEmitter) Publish(event Event) { - ee.mu.RLock() - defer ee.mu.RUnlock() - if listeners, ok := ee.listeners[event.Name]; ok { - for _, listener := range listeners { - go listener(event) - } - } -} - -func (ee *EventEmitter) PublishAndWait(event Event) { ee.mu.RLock() listeners := ee.listeners[event.Name] ee.mu.RUnlock() - var wg sync.WaitGroup for _, listener := range listeners { - wg.Add(1) - go func(l EventListener) { - defer wg.Done() - l(event) - }(listener) + listener(event) } - wg.Wait() +} + +// PublishAndWait is retained for call sites that document "wait for completion"; +// Publish is already synchronous, so it simply delegates. +func (ee *EventEmitter) PublishAndWait(event Event) { + ee.Publish(event) } diff --git a/core/tui/misc/tui_focus.go b/core/tui/misc/tui_focus.go index 272fdfd..0f10100 100644 --- a/core/tui/misc/tui_focus.go +++ b/core/tui/misc/tui_focus.go @@ -11,28 +11,36 @@ type TItem struct { } func FocusNext(elements []*TItem) *tview.Primitive { + if len(elements) == 0 { + return nil + } + currentFocus := App.GetFocus() nextIndex := -1 - var nextFocusItem TItem for i, element := range elements { if element.Primitive == currentFocus { nextIndex = (i + 1) % len(elements) - nextFocusItem = *elements[nextIndex] } element.Box.SetBorderColor(STYLE_BORDER.Fg) } - // In-case no nextIndex is found, use the previous page as base to find nextFocusItem + // Current focus isn't among these elements (e.g. right after a page switch): + // fall back to the previously focused pane as the anchor. if nextIndex < 0 { for i, element := range elements { if element.Primitive == PreviousPane { nextIndex = (i + 1) % len(elements) - nextFocusItem = *elements[nextIndex] } } } - // Set border and focus + // Neither anchor was found; default to the first element so Tab keeps + // working and we never dereference a nil Box. + if nextIndex < 0 { + nextIndex = 0 + } + + nextFocusItem := elements[nextIndex] nextFocusItem.Box.SetBorderColor(STYLE_BORDER_FOCUS.Fg) App.SetFocus(nextFocusItem.Primitive) @@ -40,26 +48,36 @@ func FocusNext(elements []*TItem) *tview.Primitive { } func FocusPrevious(elements []*TItem) *tview.Primitive { + if len(elements) == 0 { + return nil + } + currentFocus := App.GetFocus() - var prevIndex int - var nextFocusItem TItem + prevIndex := -1 for i, element := range elements { if element.Primitive == currentFocus { prevIndex = (i - 1 + len(elements)) % len(elements) - nextFocusItem = *elements[prevIndex] } element.Box.SetBorderColor(STYLE_BORDER.Fg) } - // In-case no prevIndex is found, use the previous page as base to find nextFocusItem - for i, element := range elements { - if element.Primitive == PreviousPane { - prevIndex = (i - 1 + len(elements)) % len(elements) - nextFocusItem = *elements[prevIndex] + // Only fall back to the previous pane when current focus wasn't found, + // otherwise this would override the correct result. + if prevIndex < 0 { + for i, element := range elements { + if element.Primitive == PreviousPane { + prevIndex = (i - 1 + len(elements)) % len(elements) + } } } - // Set border and focus + // Neither anchor was found; default to the first element so Shift-Tab keeps + // working and we never dereference a nil Box. + if prevIndex < 0 { + prevIndex = 0 + } + + nextFocusItem := elements[prevIndex] nextFocusItem.Box.SetBorderColor(STYLE_BORDER_FOCUS.Fg) App.SetFocus(nextFocusItem.Primitive) diff --git a/core/tui/misc/tui_utils.go b/core/tui/misc/tui_utils.go index 7f5db6e..c07d8b2 100644 --- a/core/tui/misc/tui_utils.go +++ b/core/tui/misc/tui_utils.go @@ -2,6 +2,7 @@ package misc import ( "strings" + "unicode/utf8" "github.com/rivo/tview" ) @@ -27,7 +28,7 @@ func GetModalSize(content string, minWidth, minHeight, maxWidth, maxHeight int) width := minWidth for _, line := range lines { - lineLen := len(line) + 4 + lineLen := utf8.RuneCountInString(line) + 4 if lineLen > width { width = lineLen } diff --git a/core/tui/misc/tui_writer.go b/core/tui/misc/tui_writer.go index 334b6d1..6352c50 100644 --- a/core/tui/misc/tui_writer.go +++ b/core/tui/misc/tui_writer.go @@ -3,14 +3,25 @@ package misc import ( "io" "sync" + "sync/atomic" + "time" "github.com/rivo/tview" ) -// ThreadSafeWriter wraps a tview.ANSIWriter to make it thread-safe +// writerDrawInterval throttles the repaints triggered by streamed output so a +// high-volume run coalesces its writes into at most ~10 redraws per second +// instead of one draw per chunk. +const writerDrawInterval = 100 * time.Millisecond + +// ThreadSafeWriter wraps a tview.ANSIWriter to make it thread-safe and, as the +// stream sink the TUI hands to the run engine, repaints the screen (throttled) +// as output arrives. This keeps core a plain io.Writer while output still +// streams live instead of appearing only after the run completes. type ThreadSafeWriter struct { - writer io.Writer - mutex sync.Mutex + writer io.Writer + mutex sync.Mutex + drawScheduled atomic.Bool } // NewThreadSafeWriter creates a new thread-safe writer for tview @@ -23,6 +34,27 @@ func NewThreadSafeWriter(view *tview.TextView) *ThreadSafeWriter { // Write implements io.Writer interface in a thread-safe manner func (w *ThreadSafeWriter) Write(p []byte) (n int, err error) { w.mutex.Lock() - defer w.mutex.Unlock() - return w.writer.Write(p) + n, err = w.writer.Write(p) + w.mutex.Unlock() + + w.scheduleDraw() + return n, err +} + +// scheduleDraw coalesces repaints: the first write in an interval arms a timer, +// writes within the interval are dropped, and the timer repaints the whole +// accumulated buffer. drawScheduled is cleared before App.Draw() so writes that +// arrive during the draw schedule the next one (no lost trailing output). +// App.Draw() routes through the event loop, so it is safe from the run goroutine. +func (w *ThreadSafeWriter) scheduleDraw() { + if !w.drawScheduled.CompareAndSwap(false, true) { + return + } + + time.AfterFunc(writerDrawInterval, func() { + w.drawScheduled.Store(false) + if App != nil { + App.Draw() + } + }) } diff --git a/core/tui/pages/tui_exec.go b/core/tui/pages/tui_exec.go index 36317c1..d41e82e 100644 --- a/core/tui/pages/tui_exec.go +++ b/core/tui/pages/tui_exec.go @@ -3,6 +3,8 @@ package pages import ( "fmt" "io" + "sync/atomic" + "unicode/utf8" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -21,6 +23,7 @@ type TExecPage struct { commandArea *tview.TextArea outputView *components.TOutput spec *views.TSpec + running atomic.Bool } func CreateExecPage( @@ -142,6 +145,10 @@ func CreateExecPage( page.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { case tcell.KeyCtrlR: + // Ignore the keypress entirely while a run is in flight. + if e.running.Load() { + return nil + } e.execCommand() return nil case tcell.KeyCtrlO: @@ -240,6 +247,12 @@ func (e *TExecPage) execCommand() { return } + // Re-entrancy guard: ignore overlapping runs (CAS fails if one is already + // in flight). Released when the run goroutine finishes. + if !e.running.CompareAndSwap(false, true) { + return + } + // Clear output if option is set if e.spec.ClearBeforeRun { e.outputView.Clear() @@ -248,9 +261,14 @@ func (e *TExecPage) execCommand() { // Get writer for output writer := e.outputView.GetWriter() + // Snapshot the spec on the event loop so the options modal toggling its + // fields mid-run cannot race with the run goroutine reading them. + specSnapshot := *e.spec + // Run command go func() { - e.runCommand(command, selectedServers, writer) + defer e.running.Store(false) + e.runCommand(command, selectedServers, writer, specSnapshot) misc.App.QueueUpdateDraw(func() {}) }() } @@ -323,9 +341,8 @@ func (e *TExecPage) describeServer() { components.OpenTextModal("describe-modal", description, fullServer.Name) } -func (e *TExecPage) runCommand(command string, servers []dao.Server, writer io.Writer) { +func (e *TExecPage) runCommand(command string, servers []dao.Server, writer io.Writer, spec views.TSpec) { config := misc.Config - spec := e.spec // Create a task for the command with spec options task := &dao.Task{ @@ -456,9 +473,11 @@ func (e *TExecPage) runCommand(command string, servers []dao.Server, writer io.W // writeTableOutput formats table data for TUI display func (e *TExecPage) writeTableOutput(writer io.Writer, data dao.TableOutput) { // Calculate column widths + // Widths are measured in runes to match fmt's %-*s padding (also rune-based); + // using len() (bytes) would over-count multi-byte text and misalign columns. colWidths := make([]int, len(data.Headers)) for i, header := range data.Headers { - colWidths[i] = len(header) + colWidths[i] = utf8.RuneCountInString(header) } for _, row := range data.Rows { for i, col := range row.Columns { @@ -466,8 +485,8 @@ func (e *TExecPage) writeTableOutput(writer io.Writer, data dao.TableOutput) { // Handle multi-line output - use first line for width calc lines := splitLinesExec(col) for _, line := range lines { - if len(line) > colWidths[i] { - colWidths[i] = len(line) + if w := utf8.RuneCountInString(line); w > colWidths[i] { + colWidths[i] = w } } } @@ -514,9 +533,14 @@ func (e *TExecPage) writeTableOutput(writer io.Writer, data dao.TableOutput) { if lineIdx < len(rowLines[i]) { cellContent = rowLines[i][lineIdx] } - // Truncate if too long - if len(cellContent) > colWidths[i] { - cellContent = cellContent[:colWidths[i]-3] + "..." + // Truncate if too long (rune-aware so we never cut mid-rune) + if utf8.RuneCountInString(cellContent) > colWidths[i] { + r := []rune(cellContent) + if colWidths[i] > 3 { + cellContent = string(r[:colWidths[i]-3]) + "..." + } else { + cellContent = string(r[:colWidths[i]]) + } } fmt.Fprintf(writer, "%-*s ", colWidths[i], cellContent) } diff --git a/core/tui/pages/tui_run.go b/core/tui/pages/tui_run.go index f8086a9..bb05ef2 100644 --- a/core/tui/pages/tui_run.go +++ b/core/tui/pages/tui_run.go @@ -3,6 +3,8 @@ package pages import ( "fmt" "io" + "sync/atomic" + "unicode/utf8" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -21,6 +23,7 @@ type TRunPage struct { serverData *views.TServer outputView *components.TOutput spec *views.TSpec + running atomic.Bool } func CreateRunPage( @@ -95,6 +98,10 @@ func CreateRunPage( misc.RunLastFocus = &r.focusable[0].Primitive return nil case tcell.KeyCtrlR: + // Ignore the keypress entirely while a run is in flight. + if r.running.Load() { + return nil + } r.focusable = r.switchBeforeRun(pages) misc.App.SetFocus(r.focusable[0].Primitive) misc.RunLastFocus = &r.focusable[0].Primitive @@ -328,6 +335,12 @@ func (r *TRunPage) runTasks() { return } + // Re-entrancy guard: ignore overlapping runs (CAS fails if one is already + // in flight). Released when the run goroutine finishes. + if !r.running.CompareAndSwap(false, true) { + return + } + // Clear output if option is set if r.spec.ClearBeforeRun { r.outputView.Clear() @@ -336,18 +349,22 @@ func (r *TRunPage) runTasks() { // Get writer for output writer := r.outputView.GetWriter() + // Snapshot the spec on the event loop so the options modal toggling its + // fields mid-run cannot race with the run goroutine reading them. + specSnapshot := *r.spec + // Run each task go func() { + defer r.running.Store(false) for _, task := range selectedTasks { - r.runSingleTask(&task, selectedServers, writer) + r.runSingleTask(&task, selectedServers, writer, specSnapshot) } misc.App.QueueUpdateDraw(func() {}) }() } -func (r *TRunPage) runSingleTask(task *dao.Task, servers []dao.Server, writer io.Writer) { +func (r *TRunPage) runSingleTask(task *dao.Task, servers []dao.Server, writer io.Writer, spec views.TSpec) { config := misc.Config - spec := r.spec // Create run flags with spec options runFlags := &core.RunFlags{ @@ -465,9 +482,11 @@ func (r *TRunPage) runSingleTask(task *dao.Task, servers []dao.Server, writer io // writeTableOutput formats table data for TUI display func (r *TRunPage) writeTableOutput(writer io.Writer, data dao.TableOutput) { // Calculate column widths + // Widths are measured in runes to match fmt's %-*s padding (also rune-based); + // using len() (bytes) would over-count multi-byte text and misalign columns. colWidths := make([]int, len(data.Headers)) for i, header := range data.Headers { - colWidths[i] = len(header) + colWidths[i] = utf8.RuneCountInString(header) } for _, row := range data.Rows { for i, col := range row.Columns { @@ -475,8 +494,8 @@ func (r *TRunPage) writeTableOutput(writer io.Writer, data dao.TableOutput) { // Handle multi-line output - use first line for width calc lines := splitLines(col) for _, line := range lines { - if len(line) > colWidths[i] { - colWidths[i] = len(line) + if w := utf8.RuneCountInString(line); w > colWidths[i] { + colWidths[i] = w } } } @@ -523,9 +542,14 @@ func (r *TRunPage) writeTableOutput(writer io.Writer, data dao.TableOutput) { if lineIdx < len(rowLines[i]) { cellContent = rowLines[i][lineIdx] } - // Truncate if too long - if len(cellContent) > colWidths[i] { - cellContent = cellContent[:colWidths[i]-3] + "..." + // Truncate if too long (rune-aware so we never cut mid-rune) + if utf8.RuneCountInString(cellContent) > colWidths[i] { + r := []rune(cellContent) + if colWidths[i] > 3 { + cellContent = string(r[:colWidths[i]-3]) + "..." + } else { + cellContent = string(r[:colWidths[i]]) + } } fmt.Fprintf(writer, "%-*s ", colWidths[i], cellContent) } diff --git a/core/tui/tui.go b/core/tui/tui.go index a769a61..9f635ce 100644 --- a/core/tui/tui.go +++ b/core/tui/tui.go @@ -3,7 +3,9 @@ package tui import ( "os" + "github.com/alajmo/sake/core" "github.com/alajmo/sake/core/dao" + "github.com/alajmo/sake/core/tui/components" "github.com/alajmo/sake/core/tui/misc" "github.com/rivo/tview" ) @@ -21,12 +23,14 @@ func RunTui(config *dao.Config, reload bool) { } type App struct { - App *tview.Application + App *tview.Application + Path string } func NewApp(config *dao.Config) *App { app := &App{ - App: tview.NewApplication(), + App: tview.NewApplication(), + Path: config.Path, } app.setupApp(config) @@ -38,15 +42,27 @@ func (app *App) Run() error { } func (app *App) Reload() { - config, configErr := dao.ReadConfig(misc.Config.Path, "", "", false) + // Read + parse the config off the event loop so disk I/O does not block the UI. + config, configErr := dao.ReadConfig(app.Path, "", "", false) if configErr != nil { - app.App.Stop() + // A transient bad save while editing the watched file must not kill the + // TUI. Surface the parse error and keep the previously loaded config. + // The error is ANSI-colorized; strip it (a tview TextView renders tview + // tags, not ANSI) and escape stray brackets so it shows as plain text. + errText := tview.Escape(core.Strip(configErr.Error())) + app.App.QueueUpdateDraw(func() { + components.OpenTextModal("reload-error-modal", errText, "Config Reload Error") + }) return } - app.setupApp(&config) - app.App.SetRoot(misc.Pages, true) - app.App.Draw() + // tview is single-threaded: all widget mutation (rebuilding misc.Pages, + // re-registering the input capture, SetRoot) must run on the event loop. + // QueueUpdateDraw is safe to call from F5's goroutine and the watcher goroutine. + app.App.QueueUpdateDraw(func() { + app.setupApp(&config) + app.App.SetRoot(misc.Pages, true) + }) } func (app *App) setupApp(config *dao.Config) { diff --git a/core/tui/views/tui_server_view.go b/core/tui/views/tui_server_view.go index 52fe426..f9ac2e6 100644 --- a/core/tui/views/tui_server_view.go +++ b/core/tui/views/tui_server_view.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "sort" "strings" "github.com/rivo/tview" @@ -270,7 +271,16 @@ func (s *TServer) getServerTreeHierarchy() []components.TNode { // Track seen servers to prevent duplicates seen := make(map[string]bool) - for groupName, servers := range groups { + // Iterate groups in sorted key order; ranging the map directly reshuffles + // the tree on every filter/redraw. "" (flat servers) sorts first. + groupNames := make([]string, 0, len(groups)) + for groupName := range groups { + groupNames = append(groupNames, groupName) + } + sort.Strings(groupNames) + + for _, groupName := range groupNames { + servers := groups[groupName] if groupName == "" { // Flat servers (no grouping) for _, server := range servers { @@ -386,10 +396,18 @@ func (s *TServer) groupServersByCommonPrefix() map[string][]dao.Server { } } - // Use the grouping that has more meaningful groups + // Use the grouping that has more meaningful groups. Iterate the group maps + // in sorted key order so demoted single-server groups land in `ungrouped` + // deterministically (ranging the map directly reshuffles the flat section). if ipMeaningful > 0 && ipMeaningful >= hostnameMeaningful { // Use IP grouping - for prefix, servers := range ipGroups { + prefixes := make([]string, 0, len(ipGroups)) + for prefix := range ipGroups { + prefixes = append(prefixes, prefix) + } + sort.Strings(prefixes) + for _, prefix := range prefixes { + servers := ipGroups[prefix] if len(servers) > 1 { result[prefix+".*"] = servers } else { @@ -398,7 +416,13 @@ func (s *TServer) groupServersByCommonPrefix() map[string][]dao.Server { } } else if hostnameMeaningful > 0 { // Use hostname grouping - for domain, servers := range hostnameGroups { + domains := make([]string, 0, len(hostnameGroups)) + for domain := range hostnameGroups { + domains = append(domains, domain) + } + sort.Strings(domains) + for _, domain := range domains { + servers := hostnameGroups[domain] if len(servers) > 1 { result["*."+domain] = servers } else { @@ -469,11 +493,16 @@ func (s *TServer) filterServers() { if len(serverTags) > 0 { var filtered []dao.Server for _, server := range s.Servers { + // Match on the first selected tag the server carries, then move to + // the next server. A plain break would only exit the inner loop, so + // a server with multiple selected tags would be appended once per + // match -> duplicate rows and duplicated execution targets. + matchTags: for _, tag := range serverTags { for _, serverTag := range server.Tags { if serverTag == tag { filtered = append(filtered, server) - break + break matchTags } } } diff --git a/core/tui/watcher.go b/core/tui/watcher.go index a554031..47743dc 100644 --- a/core/tui/watcher.go +++ b/core/tui/watcher.go @@ -2,10 +2,15 @@ package tui import ( "log" + "time" "github.com/fsnotify/fsnotify" ) +// reloadDebounce collapses the burst of fsnotify Write events that a single +// file save typically emits into one reload. +const reloadDebounce = 100 * time.Millisecond + func WatchFiles(app *App, paths ...string) { watcher, err := fsnotify.NewWatcher() if err != nil { @@ -14,6 +19,7 @@ func WatchFiles(app *App, paths ...string) { go func() { defer watcher.Close() + var debounce *time.Timer for { select { case event, ok := <-watcher.Events: @@ -21,7 +27,13 @@ func WatchFiles(app *App, paths ...string) { return } if event.Has(fsnotify.Write) { - app.Reload() + // Reset the timer on every write so rapid bursts collapse + // into a single Reload once writes settle. Reload itself + // marshals onto the event loop via QueueUpdateDraw. + if debounce != nil { + debounce.Stop() + } + debounce = time.AfterFunc(reloadDebounce, app.Reload) } case err, ok := <-watcher.Errors: if !ok {