// SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "log/slog" "strconv" "strings" "github.com/derailed/k9s/internal" "github.com/derailed/k9s/internal/config" "github.com/derailed/k9s/internal/model" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/k9s/internal/view/cmd" "github.com/derailed/tcell/v2" "github.com/derailed/tview" "github.com/sahilm/fuzzy" "k8s.io/apimachinery/pkg/labels" ) const ( liveViewTitleFmt = "[fg:bg:b] %s([hilite:bg:b]%s[fg:bg:-])[fg:bg:-] " yamlAction = "YAML" ) // LiveView represents a live text viewer. type LiveView struct { *tview.Flex title string model model.ResourceViewer text *tview.TextView actions *ui.KeyActions app *App cmdBuff *model.FishBuff currentRegion, maxRegions int cancel context.CancelFunc fullScreen bool managedField bool autoRefresh bool } // NewLiveView returns a live viewer. func NewLiveView(app *App, title string, m model.ResourceViewer) *LiveView { v := LiveView{ Flex: tview.NewFlex(), text: tview.NewTextView(), app: app, title: title, actions: ui.NewKeyActions(), currentRegion: 0, maxRegions: 0, cmdBuff: model.NewFishBuff('/', model.FilterBuffer), model: m, autoRefresh: app.Config.K9s.LiveViewAutoRefresh, } v.AddItem(v.text, 0, 1, true) return &v } func (*LiveView) SetCommand(*cmd.Interpreter) {} func (*LiveView) SetFilter(string) {} func (*LiveView) SetLabelSelector(labels.Selector) {} // Init initializes the viewer. func (v *LiveView) Init(_ context.Context) error { if v.title != "" { v.SetBorder(true) } v.text.SetScrollable(true).SetWrap(true).SetRegions(true) v.text.SetDynamicColors(true) v.text.SetHighlightColor(tcell.ColorOrange) v.SetTitleColor(tcell.ColorAqua) v.SetInputCapture(v.keyboard) v.SetBorderPadding(0, 0, 1, 1) v.updateTitle() v.app.Styles.AddListener(v) v.StylesChanged(v.app.Styles) v.setFullScreen(v.app.Config.K9s.UI.DefaultsToFullScreen) v.app.Prompt().SetModel(v.cmdBuff) v.cmdBuff.AddListener(v) v.bindKeys() v.SetInputCapture(v.keyboard) if v.model != nil { v.model.AddListener(v) } return nil } // InCmdMode checks if prompt is active. func (v *LiveView) InCmdMode() bool { return v.cmdBuff.InCmdMode() } // ResourceFailed notifies when there is an issue. func (v *LiveView) ResourceFailed(err error) { v.text.SetTextAlign(tview.AlignCenter) x, _, w, _ := v.GetRect() v.text.SetText(cowTalk(err.Error(), x+w)) } // ResourceChanged notifies when the filter changes. func (v *LiveView) ResourceChanged(lines []string, matches fuzzy.Matches) { v.app.QueueUpdateDraw(func() { v.text.SetTextAlign(tview.AlignLeft) v.currentRegion, v.maxRegions = 0, len(matches) if v.text.GetText(true) == "" { v.text.ScrollToBeginning() } lines = linesWithRegions(lines, matches) v.text.SetText(colorizeYAML(v.app.Styles.Views().Yaml, strings.Join(lines, "\n"))) v.text.Highlight() if v.currentRegion < v.maxRegions { v.text.Highlight("search_" + strconv.Itoa(v.currentRegion)) v.text.ScrollToHighlight() } v.updateTitle() }) } // BufferChanged indicates the buffer was changed. func (*LiveView) BufferChanged(_, _ string) {} // BufferCompleted indicates input was accepted. func (v *LiveView) BufferCompleted(text, _ string) { v.model.Filter(text) } // BufferActive indicates the buff activity changed. func (v *LiveView) BufferActive(state bool, k model.BufferKind) { v.app.BufferActive(state, k) } func (v *LiveView) bindKeys() { v.actions.Bulk(ui.KeyMap{ tcell.KeyEnter: ui.NewSharedKeyAction("Filter", v.filterCmd, false), tcell.KeyEscape: ui.NewKeyAction("Back", v.resetCmd, false), tcell.KeyCtrlS: ui.NewKeyAction("Save", v.saveCmd, false), ui.KeyC: ui.NewKeyAction("Copy", cpCmd(v.app.Flash(), v.text), true), ui.KeyF: ui.NewKeyAction("Toggle FullScreen", v.toggleFullScreenCmd, true), ui.KeyR: ui.NewKeyAction("Toggle Auto-Refresh", v.toggleRefreshCmd, true), ui.KeyN: ui.NewKeyAction("Next Match", v.nextCmd, true), ui.KeyShiftN: ui.NewKeyAction("Prev Match", v.prevCmd, true), ui.KeySlash: ui.NewSharedKeyAction("Filter Mode", v.activateCmd, false), tcell.KeyDelete: ui.NewSharedKeyAction("Erase", v.eraseCmd, false), }) if !v.app.Config.IsReadOnly() { v.actions.Add(ui.KeyE, ui.NewKeyAction("Edit", v.editCmd, true)) } if v.title == yamlAction { v.actions.Add(ui.KeyM, ui.NewKeyAction("Toggle ManagedFields", v.toggleManagedCmd, true)) } if _, ok := v.model.(model.EncDecResourceViewer); ok { v.actions.Add(ui.KeyX, ui.NewKeyAction("Toggle Decode", v.toggleEncodedDecodedCmd, true)) } } func (v *LiveView) toggleEncodedDecodedCmd(evt *tcell.EventKey) *tcell.EventKey { m, ok := v.model.(model.EncDecResourceViewer) if !ok { return evt } m.Toggle() v.Start() return nil } func (v *LiveView) editCmd(evt *tcell.EventKey) *tcell.EventKey { path := v.model.GetPath() if path == "" { return evt } v.Stop() defer v.Start() if err := editRes(v.app, v.model.GVR(), path); err != nil { v.app.Flash().Err(err) } return nil } // ToggleRefreshCmd is used for pausing the refreshing of data on config map and secrets. func (v *LiveView) toggleRefreshCmd(*tcell.EventKey) *tcell.EventKey { v.autoRefresh = !v.autoRefresh if v.autoRefresh { v.Start() v.app.Flash().Info("Auto-refresh is enabled") return nil } v.Stop() v.app.Flash().Info("Auto-refresh is disabled") return nil } func (v *LiveView) keyboard(evt *tcell.EventKey) *tcell.EventKey { if a, ok := v.actions.Get(ui.AsKey(evt)); ok { return a.Action(evt) } return evt } // StylesChanged notifies the skin changed. func (v *LiveView) StylesChanged(s *config.Styles) { v.SetBackgroundColor(s.BgColor()) v.text.SetTextColor(s.FgColor()) v.SetBorderFocusColor(s.Frame().Border.FocusColor.Color()) } // Actions returns menu actions. func (v *LiveView) Actions() *ui.KeyActions { return v.actions } // Name returns the component name. func (v *LiveView) Name() string { return v.title } // Start starts the view updater. func (v *LiveView) Start() { if v.autoRefresh { var ctx context.Context ctx, v.cancel = context.WithCancel(v.defaultCtx()) if err := v.model.Watch(ctx); err != nil { slog.Error("LiveView watcher failed", slogs.Error, err) } return } if err := v.model.Refresh(v.defaultCtx()); err != nil { slog.Error("LiveView refresh failed", slogs.Error, err) } } func (v *LiveView) defaultCtx() context.Context { return context.WithValue(context.Background(), internal.KeyFactory, v.app.factory) } // Stop terminates the updater. func (v *LiveView) Stop() { if v.cancel != nil { v.cancel() v.cancel = nil } v.app.Styles.RemoveListener(v) } // Hints returns menu hints. func (v *LiveView) Hints() model.MenuHints { return v.actions.Hints() } // ExtraHints returns additional hints. func (*LiveView) ExtraHints() map[string]string { return nil } func (v *LiveView) toggleManagedCmd(evt *tcell.EventKey) *tcell.EventKey { if v.app.InCmdMode() { return evt } v.managedField = !v.managedField v.model.SetOptions(v.defaultCtx(), map[string]bool{model.ManagedFieldsOpts: v.managedField}) return nil } func (v *LiveView) toggleFullScreenCmd(evt *tcell.EventKey) *tcell.EventKey { if v.app.InCmdMode() { return evt } v.setFullScreen(!v.fullScreen) return nil } func (v *LiveView) setFullScreen(isFullScreen bool) { v.fullScreen = isFullScreen v.SetFullScreen(isFullScreen) v.SetBorder(!isFullScreen) if isFullScreen { v.SetBorderPadding(0, 0, 0, 0) } else { v.SetBorderPadding(0, 0, 1, 1) } } func (v *LiveView) nextCmd(evt *tcell.EventKey) *tcell.EventKey { if v.cmdBuff.Empty() { return evt } v.currentRegion++ if v.currentRegion >= v.maxRegions { v.currentRegion = 0 } v.text.Highlight("search_" + strconv.Itoa(v.currentRegion)) v.text.ScrollToHighlight() v.updateTitle() return nil } func (v *LiveView) prevCmd(evt *tcell.EventKey) *tcell.EventKey { if v.cmdBuff.Empty() { return evt } v.currentRegion-- if v.currentRegion < 0 { v.currentRegion = v.maxRegions - 1 } v.text.Highlight("search_" + strconv.Itoa(v.currentRegion)) v.text.ScrollToHighlight() v.updateTitle() return nil } func (v *LiveView) filterCmd(*tcell.EventKey) *tcell.EventKey { v.model.Filter(v.cmdBuff.GetText()) v.cmdBuff.SetActive(false) v.updateTitle() return nil } func (v *LiveView) activateCmd(evt *tcell.EventKey) *tcell.EventKey { if v.app.InCmdMode() { return evt } v.app.ResetPrompt(v.cmdBuff) return nil } func (v *LiveView) eraseCmd(*tcell.EventKey) *tcell.EventKey { if !v.cmdBuff.IsActive() { return nil } v.cmdBuff.Delete() return nil } func (v *LiveView) resetCmd(evt *tcell.EventKey) *tcell.EventKey { if !v.cmdBuff.InCmdMode() { v.cmdBuff.Reset() return v.app.PrevCmd(evt) } if v.cmdBuff.GetText() != "" { v.model.ClearFilter() } v.cmdBuff.SetActive(false) v.cmdBuff.Reset() v.updateTitle() return nil } func (v *LiveView) saveCmd(*tcell.EventKey) *tcell.EventKey { name := fmt.Sprintf("%s--%s", strings.Replace(v.model.GetPath(), "/", "-", 1), strings.ToLower(v.title)) if _, err := saveYAML(v.app.Config.K9s.ContextScreenDumpDir(), name, sanitizeEsc(v.text.GetText(true))); err != nil { v.app.Flash().Err(err) } else { v.app.Flash().Infof("File %q saved successfully!", name) } return nil } func (v *LiveView) updateTitle() { if v.title == "" { return } var fmat string if v.model != nil { fmat = fmt.Sprintf(liveViewTitleFmt, v.title, v.model.GetPath()) } var ( buff = v.cmdBuff.GetText() styles = v.app.Styles.Frame() ) if buff == "" { v.SetTitle(ui.SkinTitle(fmat, &styles)) return } if v.maxRegions > 0 { buff += fmt.Sprintf("[%d:%d]", v.currentRegion+1, v.maxRegions) } fmat += fmt.Sprintf(ui.SearchFmt, buff) v.SetTitle(ui.SkinTitle(fmat, &styles)) }