Files
homarr/tools/homarr-dev/internal/tui/keys.go

416 lines
10 KiB
Go

package tui
import (
"fmt"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/table"
tea "charm.land/bubbletea/v2"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/task"
)
// handleKey routes by mode. Modes are what let one letter mean several things
// without ambiguity: `d` switches screens normally and deletes data once manage
// mode is armed, and neither reading can leak into the other.
func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch m.mode {
case modeFilter:
return m.handleFilterKey(msg)
case modeConfirm:
return m.handleConfirmKey(msg)
case modeImageSelect:
return m.handleImageSelectKey(msg)
case modeManage:
return m.handleManageKey(msg)
case modeHelp:
return m.handleOverlayKey(msg)
case modeTasks:
return m.handleTasksKey(msg)
default:
return m.handleNormalKey(msg)
}
}
func (m Model) handleImageSelectKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Cancel):
m.mode = modeNormal
m.imageSelection = imageSelection{}
m.status, m.statusLevel = "launch canceled", levelInfo
m.relayout()
return m, nil
case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.Down):
if m.imageSelection.choice == imageChoiceLocal {
m.imageSelection.choice = imageChoiceRemote
} else {
m.imageSelection.choice = imageChoiceLocal
}
return m, nil
case key.Matches(msg, m.keys.Enter):
selection := m.imageSelection
row, found := m.selectedDev()
m.mode = modeNormal
m.imageSelection = imageSelection{}
if !found || row.key() != selection.rowKey {
m.status, m.statusLevel = "image selection expired; choose a row again", levelWarn
m.relayout()
return m, nil
}
m.relayout()
if selection.choice == imageChoiceRemote {
return m.deployRemotePR(row)
}
return m.startLocalPR(row)
}
return m, nil
}
func (m Model) handleNormalKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Quit):
m.tasks.CancelAll()
m.logs.Close()
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.mode = modeHelp
m.relayout()
return m, nil
case key.Matches(msg, m.keys.Tasks):
m.mode = modeTasks
m.taskList = m.tasks.Snapshots()
m.taskCursor = max(len(m.taskList)-1, 0)
m.relayout()
return m, nil
case key.Matches(msg, m.keys.Manage):
subject, found := m.currentTarget()
if !found {
m.status, m.statusLevel = "nothing selected to manage", levelWarn
return m, nil
}
m.mode = modeManage
m.status, m.statusLevel = "manage "+subject.label, levelInfo
m.relayout()
return m, nil
case key.Matches(msg, m.keys.Filter):
m.mode = modeFilter
m.relayout()
return m, m.filter.Focus()
// A submitted filter keeps narrowing the table after the input loses focus,
// so escape has to be able to clear it from normal mode too.
case key.Matches(msg, m.keys.Cancel):
if m.filter.Value() == "" {
return m, nil
}
m.filter.Reset()
m.applyFilter()
m.relayout()
cmd := m.refreshSidebar()
m.status, m.statusLevel = "filter cleared", levelInfo
return m, cmd
case key.Matches(msg, m.keys.Refresh):
m.generation++
m.loading = true
m.status, m.statusLevel = "refreshing…", levelInfo
clocks := m.animate()
return m, tea.Batch(loadAll(m.generation, m.includeBots, true), clocks)
case key.Matches(msg, m.keys.Dev):
return m.switchScreen(screenDev)
case key.Matches(msg, m.keys.Instances):
return m.switchScreen(screenInstances)
case key.Matches(msg, m.keys.Cycle):
if m.screen == screenDev {
return m.switchScreen(screenInstances)
}
return m.switchScreen(screenDev)
case key.Matches(msg, m.keys.Sidebar):
m.sidebar.visible = !m.sidebar.visible
m.relayout()
cmd := m.refreshSidebar()
return m, cmd
case key.Matches(msg, m.keys.SidebarTab):
switch m.sidebar.source {
case sourceLogs:
if m.tasks.Running() > 0 || len(m.taskList) > 0 {
m.sidebar.source = sourceTask
} else {
m.sidebar.source = sourceCI
}
case sourceTask:
m.sidebar.source = sourceCI
case sourceCI:
m.sidebar.source = sourceLogs
default:
m.sidebar.source = sourceLogs
}
m.sidebar.visible = true
m.relayout()
cmd := m.refreshSidebar()
return m, cmd
case key.Matches(msg, m.keys.Pin):
return m.togglePin()
case key.Matches(msg, m.keys.Follow):
m.sidebar.toggleFollow()
return m, nil
case key.Matches(msg, m.keys.PageUp):
m.sidebar.pageUp()
return m, nil
case key.Matches(msg, m.keys.PageDown):
m.sidebar.pageDown()
return m, nil
case key.Matches(msg, m.keys.Enter):
return m.primaryAction()
case key.Matches(msg, m.keys.Build):
subject, found := m.currentTarget()
if !found {
return m, nil
}
return m.buildLocally(subject)
case key.Matches(msg, m.keys.Up):
return m.moveSelection(-1)
case key.Matches(msg, m.keys.Down):
return m.moveSelection(1)
case key.Matches(msg, m.keys.Top):
m.activeTable().GotoTop()
if m.tasks.Running() == 0 && m.sidebar.source == sourceTask && m.focusTask == 0 {
m.sidebar.source = sourceLogs
}
m.relayout()
cmd := m.refreshSidebar()
return m, cmd
case key.Matches(msg, m.keys.Bottom):
m.activeTable().GotoBottom()
if m.tasks.Running() == 0 && m.sidebar.source == sourceTask && m.focusTask == 0 {
m.sidebar.source = sourceLogs
}
m.relayout()
cmd := m.refreshSidebar()
return m, cmd
case key.Matches(msg, m.keys.OpenApp):
return m.openApp()
}
if m.screen == screenDev {
return m.handleDevKey(msg)
}
return m.handleInstanceKey(msg)
}
func (m Model) handleDevKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Pull):
row, found := m.selectedDev()
if !found {
return m, nil
}
return m.pullRemote(row)
case key.Matches(msg, m.keys.Demo):
m.demo = !m.demo
if m.demo {
m.status, m.statusLevel = "demo mode on — the next launch mocks integrations", levelWarn
return m, nil
}
m.status, m.statusLevel = "demo mode off", levelInfo
return m, nil
case key.Matches(msg, m.keys.Bots):
m.includeBots = !m.includeBots
m.generation++
m.loading = true
if m.includeBots {
m.status, m.statusLevel = "including bot pull requests…", levelInfo
} else {
m.status, m.statusLevel = "hiding bot pull requests…", levelInfo
}
clocks := m.animate()
return m, tea.Batch(loadAll(m.generation, m.includeBots, false), clocks)
case key.Matches(msg, m.keys.OpenPR):
return m.openPullRequest()
}
return m, nil
}
func (m Model) handleInstanceKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
row, found := m.selectedInstance()
if !found {
return m, nil
}
switch {
case key.Matches(msg, m.keys.Stop):
return m.stopContainer(row.container.Name)
case key.Matches(msg, m.keys.Restart):
return m.restartContainer(row.container.Name)
case key.Matches(msg, m.keys.Remove):
return m.arm(actionRemoveContainer, m.instanceTarget(row))
case key.Matches(msg, m.keys.Copy):
return m.copyURL()
case key.Matches(msg, m.keys.OpenPR):
if row.container.Running() && row.container.HostPort() != "" {
return m.openApp()
}
if number := row.prNumber(); number > 0 {
return m.openPullRequestNumber(number)
}
return m.openApp()
}
return m, nil
}
func (m Model) handleFilterKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch msg.Key().Code {
case tea.KeyEscape:
m.mode = modeNormal
m.filter.Reset()
m.filter.Blur()
m.relayout()
cmd := m.refreshSidebar()
return m, cmd
case tea.KeyEnter:
m.mode = modeNormal
m.filter.Blur()
m.relayout()
return m, nil
}
var command tea.Cmd
m.filter, command = m.filter.Update(msg)
m.applyFilter()
cmd := m.refreshSidebar()
return m, tea.Batch(command, cmd)
}
func (m Model) handleOverlayKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if key.Matches(msg, m.keys.Quit) {
m.tasks.CancelAll()
m.logs.Close()
return m, tea.Quit
}
m.mode = modeNormal
m.relayout()
return m, nil
}
func (m Model) handleTasksKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Cancel), key.Matches(msg, m.keys.Tasks):
m.mode = modeNormal
m.relayout()
if key.Matches(msg, m.keys.Quit) {
m.tasks.CancelAll()
m.logs.Close()
return m, tea.Quit
}
return m, nil
case key.Matches(msg, m.keys.Up):
m.taskCursor = max(m.taskCursor-1, 0)
return m, nil
case key.Matches(msg, m.keys.Down):
m.taskCursor = min(m.taskCursor+1, max(len(m.taskList)-1, 0))
return m, nil
case key.Matches(msg, m.keys.Enter):
if snapshot, found := m.taskAt(m.taskCursor); found {
m.mode = modeNormal
return m.focusOn(snapshot.ID, snapshot.Title)
}
return m, nil
case key.Matches(msg, m.keys.Remove):
if snapshot, found := m.taskAt(m.taskCursor); found && snapshot.Cancellable {
m.tasks.Cancel(snapshot.ID)
m.status, m.statusLevel = "canceling "+snapshot.Title+"…", levelWarn
return m, nil
}
m.tasks.Prune()
m.taskList = m.tasks.Snapshots()
m.taskCursor = 0
m.status, m.statusLevel = "cleared finished tasks", levelInfo
return m, nil
}
return m, nil
}
func (m Model) taskAt(index int) (task.Snapshot, bool) {
if index < 0 || index >= len(m.taskList) {
return task.Snapshot{}, false
}
return m.taskList[index], true
}
func (m *Model) activeTable() *table.Model {
if m.screen == screenInstances {
return &m.instTable
}
return &m.devTable
}
func (m Model) moveSelection(delta int) (Model, tea.Cmd) {
if delta < 0 {
m.activeTable().MoveUp(-delta)
} else {
m.activeTable().MoveDown(delta)
}
if m.tasks.Running() == 0 && m.sidebar.source == sourceTask && m.focusTask == 0 {
m.sidebar.source = sourceLogs
}
cmd := m.refreshSidebar()
return m, cmd
}
func (m Model) switchScreen(next screen) (Model, tea.Cmd) {
if m.screen == next {
return m, nil
}
m.screen = next
if m.tasks.Running() == 0 && m.sidebar.source == sourceTask && m.focusTask == 0 {
m.sidebar.source = sourceLogs
}
m.relayout()
cmd := m.refreshSidebar()
return m, cmd
}
// togglePin locks the sidebar onto one container so the user can browse other
// rows while still watching the instance they care about.
func (m Model) togglePin() (Model, tea.Cmd) {
if m.sidebar.pinned != "" {
m.status, m.statusLevel = "unpinned "+m.sidebar.pinned, levelInfo
m.sidebar.pinned = ""
m.relayout()
cmd := m.refreshSidebar()
return m, cmd
}
name := m.logTarget()
if name == "" {
m.status, m.statusLevel = "select a container to pin its logs", levelWarn
return m, nil
}
m.sidebar.pinned = name
m.sidebar.source = sourceLogs
m.sidebar.visible = true
m.status, m.statusLevel = fmt.Sprintf("pinned logs of %s", name), levelOK
m.relayout()
cmd := m.refreshSidebar()
return m, cmd
}