Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
528
backend/internal/tui/app.go
Normal file
528
backend/internal/tui/app.go
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Tab identifiers
|
||||
const (
|
||||
tabDashboard = iota
|
||||
tabConfig
|
||||
tabAuthFiles
|
||||
tabAPIKeys
|
||||
tabOAuth
|
||||
tabLogs
|
||||
)
|
||||
|
||||
// App is the root bubbletea model that contains all tab sub-models.
|
||||
type App struct {
|
||||
activeTab int
|
||||
tabs []string
|
||||
|
||||
standalone bool
|
||||
logsEnabled bool
|
||||
|
||||
authenticated bool
|
||||
authInput textinput.Model
|
||||
authError string
|
||||
authConnecting bool
|
||||
|
||||
dashboard dashboardModel
|
||||
config configTabModel
|
||||
auth authTabModel
|
||||
keys keysTabModel
|
||||
oauth oauthTabModel
|
||||
logs logsTabModel
|
||||
|
||||
client *Client
|
||||
|
||||
width int
|
||||
height int
|
||||
ready bool
|
||||
|
||||
// Track which tabs have been initialized (fetched data)
|
||||
initialized [6]bool
|
||||
}
|
||||
|
||||
type authConnectMsg struct {
|
||||
cfg map[string]any
|
||||
err error
|
||||
}
|
||||
|
||||
// NewApp creates the root TUI application model.
|
||||
func NewApp(port int, secretKey string, hook *LogHook) App {
|
||||
standalone := hook != nil
|
||||
authRequired := !standalone
|
||||
ti := textinput.New()
|
||||
ti.CharLimit = 512
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
ti.EchoCharacter = '*'
|
||||
ti.SetValue(strings.TrimSpace(secretKey))
|
||||
ti.Focus()
|
||||
|
||||
client := NewClient(port, secretKey)
|
||||
app := App{
|
||||
activeTab: tabDashboard,
|
||||
standalone: standalone,
|
||||
logsEnabled: true,
|
||||
authenticated: !authRequired,
|
||||
authInput: ti,
|
||||
dashboard: newDashboardModel(client),
|
||||
config: newConfigTabModel(client),
|
||||
auth: newAuthTabModel(client),
|
||||
keys: newKeysTabModel(client),
|
||||
oauth: newOAuthTabModel(client),
|
||||
logs: newLogsTabModel(client, hook),
|
||||
client: client,
|
||||
initialized: [6]bool{
|
||||
tabDashboard: true,
|
||||
tabLogs: true,
|
||||
},
|
||||
}
|
||||
|
||||
app.refreshTabs()
|
||||
if authRequired {
|
||||
app.initialized = [6]bool{}
|
||||
}
|
||||
app.setAuthInputPrompt()
|
||||
return app
|
||||
}
|
||||
|
||||
func (a App) Init() tea.Cmd {
|
||||
if !a.authenticated {
|
||||
return textinput.Blink
|
||||
}
|
||||
cmds := []tea.Cmd{a.dashboard.Init()}
|
||||
if a.logsEnabled {
|
||||
cmds = append(cmds, a.logs.Init())
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
a.width = msg.Width
|
||||
a.height = msg.Height
|
||||
a.ready = true
|
||||
if a.width > 0 {
|
||||
a.authInput.Width = a.width - 6
|
||||
}
|
||||
contentH := a.height - 4 // tab bar + status bar
|
||||
if contentH < 1 {
|
||||
contentH = 1
|
||||
}
|
||||
contentW := a.width
|
||||
a.dashboard.SetSize(contentW, contentH)
|
||||
a.config.SetSize(contentW, contentH)
|
||||
a.auth.SetSize(contentW, contentH)
|
||||
a.keys.SetSize(contentW, contentH)
|
||||
a.oauth.SetSize(contentW, contentH)
|
||||
a.logs.SetSize(contentW, contentH)
|
||||
return a, nil
|
||||
|
||||
case authConnectMsg:
|
||||
a.authConnecting = false
|
||||
if msg.err != nil {
|
||||
a.authError = fmt.Sprintf(T("auth_gate_connect_fail"), msg.err.Error())
|
||||
return a, nil
|
||||
}
|
||||
a.authError = ""
|
||||
a.authenticated = true
|
||||
a.logsEnabled = a.standalone || isLogsEnabledFromConfig(msg.cfg)
|
||||
a.refreshTabs()
|
||||
a.initialized = [6]bool{}
|
||||
a.initialized[tabDashboard] = true
|
||||
cmds := []tea.Cmd{a.dashboard.Init()}
|
||||
if a.logsEnabled {
|
||||
a.initialized[tabLogs] = true
|
||||
cmds = append(cmds, a.logs.Init())
|
||||
}
|
||||
return a, tea.Batch(cmds...)
|
||||
|
||||
case configUpdateMsg:
|
||||
var cmdLogs tea.Cmd
|
||||
if !a.standalone && msg.err == nil && msg.path == "logging-to-file" {
|
||||
logsEnabledConfig, okConfig := msg.value.(bool)
|
||||
if okConfig {
|
||||
logsEnabledBefore := a.logsEnabled
|
||||
a.logsEnabled = logsEnabledConfig
|
||||
if logsEnabledBefore != a.logsEnabled {
|
||||
a.refreshTabs()
|
||||
}
|
||||
if !a.logsEnabled {
|
||||
a.initialized[tabLogs] = false
|
||||
}
|
||||
if !logsEnabledBefore && a.logsEnabled {
|
||||
a.initialized[tabLogs] = true
|
||||
cmdLogs = a.logs.Init()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmdConfig tea.Cmd
|
||||
a.config, cmdConfig = a.config.Update(msg)
|
||||
if cmdConfig != nil && cmdLogs != nil {
|
||||
return a, tea.Batch(cmdConfig, cmdLogs)
|
||||
}
|
||||
if cmdConfig != nil {
|
||||
return a, cmdConfig
|
||||
}
|
||||
return a, cmdLogs
|
||||
|
||||
case tea.KeyMsg:
|
||||
if !a.authenticated {
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
return a, tea.Quit
|
||||
case "L":
|
||||
ToggleLocale()
|
||||
a.refreshTabs()
|
||||
a.setAuthInputPrompt()
|
||||
return a, nil
|
||||
case "enter":
|
||||
if a.authConnecting {
|
||||
return a, nil
|
||||
}
|
||||
password := strings.TrimSpace(a.authInput.Value())
|
||||
if password == "" {
|
||||
a.authError = T("auth_gate_password_required")
|
||||
return a, nil
|
||||
}
|
||||
a.authError = ""
|
||||
a.authConnecting = true
|
||||
return a, a.connectWithPassword(password)
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
a.authInput, cmd = a.authInput.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return a, tea.Quit
|
||||
case "q":
|
||||
// Only quit if not in logs tab (where 'q' might be useful)
|
||||
if !a.logsEnabled || a.activeTab != tabLogs {
|
||||
return a, tea.Quit
|
||||
}
|
||||
case "L":
|
||||
ToggleLocale()
|
||||
a.refreshTabs()
|
||||
return a.broadcastToAllTabs(localeChangedMsg{})
|
||||
case "tab":
|
||||
if len(a.tabs) == 0 {
|
||||
return a, nil
|
||||
}
|
||||
prevTab := a.activeTab
|
||||
a.activeTab = (a.activeTab + 1) % len(a.tabs)
|
||||
return a, a.initTabIfNeeded(prevTab)
|
||||
case "shift+tab":
|
||||
if len(a.tabs) == 0 {
|
||||
return a, nil
|
||||
}
|
||||
prevTab := a.activeTab
|
||||
a.activeTab = (a.activeTab - 1 + len(a.tabs)) % len(a.tabs)
|
||||
return a, a.initTabIfNeeded(prevTab)
|
||||
}
|
||||
}
|
||||
|
||||
if !a.authenticated {
|
||||
var cmd tea.Cmd
|
||||
a.authInput, cmd = a.authInput.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
// Route msg to active tab
|
||||
var cmd tea.Cmd
|
||||
switch a.activeTab {
|
||||
case tabDashboard:
|
||||
a.dashboard, cmd = a.dashboard.Update(msg)
|
||||
case tabConfig:
|
||||
a.config, cmd = a.config.Update(msg)
|
||||
case tabAuthFiles:
|
||||
a.auth, cmd = a.auth.Update(msg)
|
||||
case tabAPIKeys:
|
||||
a.keys, cmd = a.keys.Update(msg)
|
||||
case tabOAuth:
|
||||
a.oauth, cmd = a.oauth.Update(msg)
|
||||
case tabLogs:
|
||||
a.logs, cmd = a.logs.Update(msg)
|
||||
}
|
||||
|
||||
// Keep logs polling alive even when logs tab is not active.
|
||||
if a.logsEnabled && a.activeTab != tabLogs {
|
||||
switch msg.(type) {
|
||||
case logsPollMsg, logsTickMsg, logLineMsg:
|
||||
var logCmd tea.Cmd
|
||||
a.logs, logCmd = a.logs.Update(msg)
|
||||
if logCmd != nil {
|
||||
cmd = logCmd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
// localeChangedMsg is broadcast to all tabs when the user toggles locale.
|
||||
type localeChangedMsg struct{}
|
||||
|
||||
func (a *App) refreshTabs() {
|
||||
names := TabNames()
|
||||
if a.logsEnabled {
|
||||
a.tabs = names
|
||||
} else {
|
||||
filtered := make([]string, 0, len(names)-1)
|
||||
for idx, name := range names {
|
||||
if idx == tabLogs {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, name)
|
||||
}
|
||||
a.tabs = filtered
|
||||
}
|
||||
|
||||
if len(a.tabs) == 0 {
|
||||
a.activeTab = tabDashboard
|
||||
return
|
||||
}
|
||||
if a.activeTab >= len(a.tabs) {
|
||||
a.activeTab = len(a.tabs) - 1
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) initTabIfNeeded(_ int) tea.Cmd {
|
||||
if a.initialized[a.activeTab] {
|
||||
return nil
|
||||
}
|
||||
a.initialized[a.activeTab] = true
|
||||
switch a.activeTab {
|
||||
case tabDashboard:
|
||||
return a.dashboard.Init()
|
||||
case tabConfig:
|
||||
return a.config.Init()
|
||||
case tabAuthFiles:
|
||||
return a.auth.Init()
|
||||
case tabAPIKeys:
|
||||
return a.keys.Init()
|
||||
case tabOAuth:
|
||||
return a.oauth.Init()
|
||||
case tabLogs:
|
||||
if !a.logsEnabled {
|
||||
return nil
|
||||
}
|
||||
return a.logs.Init()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) View() string {
|
||||
if !a.authenticated {
|
||||
return a.renderAuthView()
|
||||
}
|
||||
|
||||
if !a.ready {
|
||||
return T("initializing_tui")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Tab bar
|
||||
sb.WriteString(a.renderTabBar())
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Content
|
||||
switch a.activeTab {
|
||||
case tabDashboard:
|
||||
sb.WriteString(a.dashboard.View())
|
||||
case tabConfig:
|
||||
sb.WriteString(a.config.View())
|
||||
case tabAuthFiles:
|
||||
sb.WriteString(a.auth.View())
|
||||
case tabAPIKeys:
|
||||
sb.WriteString(a.keys.View())
|
||||
case tabOAuth:
|
||||
sb.WriteString(a.oauth.View())
|
||||
case tabLogs:
|
||||
if a.logsEnabled {
|
||||
sb.WriteString(a.logs.View())
|
||||
}
|
||||
}
|
||||
|
||||
// Status bar
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(a.renderStatusBar())
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (a App) renderAuthView() string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(titleStyle.Render(T("auth_gate_title")))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(helpStyle.Render(T("auth_gate_help")))
|
||||
sb.WriteString("\n\n")
|
||||
if a.authConnecting {
|
||||
sb.WriteString(warningStyle.Render(T("auth_gate_connecting")))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
if strings.TrimSpace(a.authError) != "" {
|
||||
sb.WriteString(errorStyle.Render(a.authError))
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
sb.WriteString(a.authInput.View())
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(helpStyle.Render(T("auth_gate_enter")))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (a App) renderTabBar() string {
|
||||
var tabs []string
|
||||
for i, name := range a.tabs {
|
||||
if i == a.activeTab {
|
||||
tabs = append(tabs, tabActiveStyle.Render(name))
|
||||
} else {
|
||||
tabs = append(tabs, tabInactiveStyle.Render(name))
|
||||
}
|
||||
}
|
||||
tabBar := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||
return tabBarStyle.Width(a.width).Render(tabBar)
|
||||
}
|
||||
|
||||
func (a App) renderStatusBar() string {
|
||||
left := strings.TrimRight(T("status_left"), " ")
|
||||
right := strings.TrimRight(T("status_right"), " ")
|
||||
|
||||
width := a.width
|
||||
if width < 1 {
|
||||
width = 1
|
||||
}
|
||||
|
||||
// statusBarStyle has left/right padding(1), so content area is width-2.
|
||||
contentWidth := width - 2
|
||||
if contentWidth < 0 {
|
||||
contentWidth = 0
|
||||
}
|
||||
|
||||
if lipgloss.Width(left) > contentWidth {
|
||||
left = fitStringWidth(left, contentWidth)
|
||||
right = ""
|
||||
}
|
||||
|
||||
remaining := contentWidth - lipgloss.Width(left)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
if lipgloss.Width(right) > remaining {
|
||||
right = fitStringWidth(right, remaining)
|
||||
}
|
||||
|
||||
gap := contentWidth - lipgloss.Width(left) - lipgloss.Width(right)
|
||||
if gap < 0 {
|
||||
gap = 0
|
||||
}
|
||||
return statusBarStyle.Width(width).Render(left + strings.Repeat(" ", gap) + right)
|
||||
}
|
||||
|
||||
func fitStringWidth(text string, maxWidth int) string {
|
||||
if maxWidth <= 0 {
|
||||
return ""
|
||||
}
|
||||
if lipgloss.Width(text) <= maxWidth {
|
||||
return text
|
||||
}
|
||||
|
||||
out := ""
|
||||
for _, r := range text {
|
||||
next := out + string(r)
|
||||
if lipgloss.Width(next) > maxWidth {
|
||||
break
|
||||
}
|
||||
out = next
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isLogsEnabledFromConfig(cfg map[string]any) bool {
|
||||
if cfg == nil {
|
||||
return true
|
||||
}
|
||||
value, ok := cfg["logging-to-file"]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
enabled, ok := value.(bool)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
func (a *App) setAuthInputPrompt() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.authInput.Prompt = fmt.Sprintf(" %s: ", T("auth_gate_password"))
|
||||
}
|
||||
|
||||
func (a App) connectWithPassword(password string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
a.client.SetSecretKey(password)
|
||||
cfg, errGetConfig := a.client.GetConfig()
|
||||
return authConnectMsg{cfg: cfg, err: errGetConfig}
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the TUI application.
|
||||
// output specifies where bubbletea renders. If nil, defaults to os.Stdout.
|
||||
func Run(port int, secretKey string, hook *LogHook, output io.Writer) error {
|
||||
if output == nil {
|
||||
output = os.Stdout
|
||||
}
|
||||
app := NewApp(port, secretKey, hook)
|
||||
p := tea.NewProgram(app, tea.WithAltScreen(), tea.WithOutput(output))
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
func (a App) broadcastToAllTabs(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
var cmd tea.Cmd
|
||||
|
||||
a.dashboard, cmd = a.dashboard.Update(msg)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
a.config, cmd = a.config.Update(msg)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
a.auth, cmd = a.auth.Update(msg)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
a.keys, cmd = a.keys.Update(msg)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
a.oauth, cmd = a.oauth.Update(msg)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
a.logs, cmd = a.logs.Update(msg)
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
Loading…
Reference in a new issue