Files
tunnel-please/session/interaction/interaction.go
T
bagas f20e5668ff
SonarQube Scan / SonarQube Trigger (push) Successful in 3m44s
Docker Build and Push / Run Tests (push) Successful in 2m2s
Docker Build and Push / Build and Push Docker Image (push) Successful in 17m52s
feat: upgrade bubbletea, bubbles, and lipgloss to v2 (#118)
Co-authored-by: Renovate-Clanker <renovate-bot@fossy.my.id>
Reviewed-on: #118
2026-03-20 14:28:43 +07:00

300 lines
6.0 KiB
Go

package interaction
import (
"context"
"log"
"sync"
"tunnel_pls/internal/config"
"tunnel_pls/internal/random"
"tunnel_pls/session/slug"
"tunnel_pls/types"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/colorprofile"
"golang.org/x/crypto/ssh"
)
type Interaction interface {
Mode() types.InteractiveMode
SetChannel(channel ssh.Channel)
SetMode(m types.InteractiveMode)
SetWH(w, h int)
Start()
Redraw()
Send(message string) error
}
type SessionRegistry interface {
Update(user string, oldKey, newKey types.SessionKey) error
}
type Forwarder interface {
Close() error
TunnelType() types.TunnelType
ForwardedPort() uint16
}
type CloseFunc func() error
type interaction struct {
randomizer random.Random
config config.Config
channel ssh.Channel
slug slug.Slug
forwarder Forwarder
closeFunc CloseFunc
user string
sessionRegistry SessionRegistry
program *tea.Program
ctx context.Context
cancel context.CancelFunc
mode types.InteractiveMode
programMu sync.Mutex
width int
height int
}
func (i *interaction) SetMode(m types.InteractiveMode) {
i.mode = m
}
func (i *interaction) Mode() types.InteractiveMode {
return i.mode
}
func (i *interaction) Send(message string) error {
if i.channel != nil {
_, err := i.channel.Write([]byte(message))
return err
}
return nil
}
func (i *interaction) SetWH(w, h int) {
i.programMu.Lock()
i.width = w
i.height = h
prog := i.program
i.programMu.Unlock()
if prog != nil {
prog.Send(tea.WindowSizeMsg{
Width: w,
Height: h,
})
}
}
func New(randomizer random.Random, config config.Config, slug slug.Slug, forwarder Forwarder, sessionRegistry SessionRegistry, user string, closeFunc CloseFunc) Interaction {
ctx, cancel := context.WithCancel(context.Background())
return &interaction{
randomizer: randomizer,
config: config,
channel: nil,
slug: slug,
forwarder: forwarder,
closeFunc: closeFunc,
user: user,
sessionRegistry: sessionRegistry,
program: nil,
ctx: ctx,
cancel: cancel,
}
}
func (i *interaction) SetChannel(channel ssh.Channel) {
i.channel = channel
}
func (i *interaction) Stop() {
if i.cancel != nil {
i.cancel()
}
i.programMu.Lock()
defer i.programMu.Unlock()
if i.program != nil {
i.program.Kill()
i.program = nil
}
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tickMsg:
m.showingComingSoon = false
return m, textinput.Blink
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.commandList.SetWidth(msg.Width)
m.commandList.SetHeight(msg.Height - 4)
if msg.Width < 80 {
m.slugInput.SetWidth(msg.Width - 10)
} else {
m.slugInput.SetWidth(50)
}
return m, nil
case tea.QuitMsg:
m.quitting = true
return m, tea.Batch(textinput.Blink, tea.Quit)
case tea.KeyPressMsg:
if m.showingComingSoon {
return m.comingSoonUpdate(msg)
}
if m.editingSlug {
return m.slugUpdate(msg)
}
if m.showingCommands {
return m.commandsUpdate(msg)
}
return m.dashboardUpdate(msg)
}
return m, nil
}
func (i *interaction) Redraw() {
i.programMu.Lock()
prog := i.program
w, h := i.width, i.height
i.programMu.Unlock()
if prog != nil {
prog.Send(tea.WindowSizeMsg{Width: w, Height: h})
}
}
func (m *model) View() tea.View {
if m.quitting {
return tea.NewView("")
}
if m.showingComingSoon {
return tea.NewView(m.comingSoonView())
}
if m.editingSlug {
return tea.NewView(m.slugView())
}
if m.showingCommands {
return tea.NewView(m.commandsView())
}
v := tea.NewView(m.dashboardView())
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
return v
}
func (i *interaction) Start() {
if i.mode == types.InteractiveModeHEADLESS {
return
}
protocol := "http"
if i.config.TLSEnabled() {
protocol = "https"
}
tunnelType := i.forwarder.TunnelType()
port := i.forwarder.ForwardedPort()
items := []list.Item{
commandItem{name: "slug", desc: "Set custom subdomain"},
commandItem{name: "tunnel-type", desc: "Change tunnel type (Coming Soon)"},
}
delegate := list.NewDefaultDelegate()
delegate.ShowDescription = true
delegate.SetHeight(2)
commandList := list.New(items, delegate, 80, 20)
commandList.Title = "Select a command"
commandList.SetShowStatusBar(false)
commandList.SetFilteringEnabled(false)
commandList.SetShowHelp(false)
ti := textinput.New()
ti.Placeholder = "my-custom-slug"
ti.CharLimit = 20
ti.SetWidth(50)
m := &model{
randomizer: i.randomizer,
domain: i.config.Domain(),
protocol: protocol,
tunnelType: tunnelType,
port: port,
commandList: commandList,
slugInput: ti,
interaction: i,
keymap: keymap{
quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
command: key.NewBinding(
key.WithKeys("c"),
key.WithHelp("c", "commands"),
),
random: key.NewBinding(
key.WithKeys("ctrl+r"),
key.WithHelp("ctrl+r", "random"),
),
},
help: help.New(),
}
i.programMu.Lock()
w, h := i.width, i.height
i.programMu.Unlock()
opts := []tea.ProgramOption{
tea.WithInput(i.channel),
tea.WithOutput(i.channel),
tea.WithColorProfile(colorprofile.TrueColor),
tea.WithoutSignals(),
tea.WithoutSignalHandler(),
tea.WithFPS(30),
tea.WithEnvironment([]string{"TERM=xterm-256color", "COLORTERM=truecolor"}),
}
if w > 0 && h > 0 {
opts = append(opts, tea.WithWindowSize(w, h))
}
prog := tea.NewProgram(m, opts...)
i.programMu.Lock()
i.program = prog
i.programMu.Unlock()
_, err := prog.Run()
if err != nil {
log.Printf("Cannot close tea: %s \n", err)
}
i.programMu.Lock()
if i.program != nil {
i.program.Kill()
i.program = nil
}
i.programMu.Unlock()
if i.closeFunc != nil {
_ = i.closeFunc()
}
}