update:wip
This commit is contained in:
3
internal/pages/add_server.go
Normal file
3
internal/pages/add_server.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
type AddServerPageMsg struct{}
|
||||||
12
internal/pages/config.go
Normal file
12
internal/pages/config.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
type ConfigPageMsg struct{}
|
||||||
|
|
||||||
|
func ConfigMenuItems() []MenuItem {
|
||||||
|
return []MenuItem{
|
||||||
|
{Label: "Add Server", Key: "add"},
|
||||||
|
{Label: "Edit Server", Key: "edit", RequiresServers: true},
|
||||||
|
{Label: "Remove Server", Key: "remove", RequiresServers: true},
|
||||||
|
{Label: "Back", Key: "back"},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,14 @@ package pages
|
|||||||
type HomePageMsg struct{}
|
type HomePageMsg struct{}
|
||||||
|
|
||||||
type MenuItem struct {
|
type MenuItem struct {
|
||||||
Label string
|
Label string
|
||||||
Key string
|
Key string
|
||||||
|
RequiresServers bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func HomeMenuItems() []MenuItem {
|
func HomeMenuItems() []MenuItem {
|
||||||
return []MenuItem{
|
return []MenuItem{
|
||||||
{Label: "Select Server", Key: "server"},
|
{Label: "Select Server", Key: "server", RequiresServers: true},
|
||||||
{Label: "Config", Key: "config"},
|
{Label: "Config", Key: "config"},
|
||||||
{Label: "Exit", Key: "exit"},
|
{Label: "Exit", Key: "exit"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
@@ -54,3 +55,24 @@ func NewConfigService() (*ConfigService, error) {
|
|||||||
func (c *ConfigService) Servers() map[string]Server {
|
func (c *ConfigService) Servers() map[string]Server {
|
||||||
return c.servers
|
return c.servers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *ConfigService) HasServer(name string) bool {
|
||||||
|
_, ok := c.servers[name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ConfigService) AddServer(name string, s Server) error {
|
||||||
|
if c.HasServer(name) {
|
||||||
|
return fmt.Errorf("server %q already exists", name)
|
||||||
|
}
|
||||||
|
c.servers[name] = s
|
||||||
|
return c.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ConfigService) flush() error {
|
||||||
|
data, err := json.MarshalIndent(c.servers, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(c.path, data, 0o600)
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,50 @@ var (
|
|||||||
menuItemInactive = menuItemBase.
|
menuItemInactive = menuItemBase.
|
||||||
Foreground(lipgloss.Color("245"))
|
Foreground(lipgloss.Color("245"))
|
||||||
|
|
||||||
|
menuItemDisabled = menuItemBase.
|
||||||
|
Foreground(lipgloss.Color("240")).
|
||||||
|
PaddingLeft(4)
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
fieldLabelRequired = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("75")).
|
||||||
|
Bold(true).
|
||||||
|
MarginTop(1)
|
||||||
|
|
||||||
|
fieldLabelOptional = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("245")).
|
||||||
|
MarginTop(1)
|
||||||
|
|
||||||
|
FieldLegendStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("240")).
|
||||||
|
Italic(true).
|
||||||
|
MarginTop(1)
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
buttonActive = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("232")).
|
||||||
|
Background(lipgloss.Color("75")).
|
||||||
|
Bold(true).
|
||||||
|
Padding(0, 2).
|
||||||
|
MarginTop(1)
|
||||||
|
|
||||||
|
buttonInactive = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("232")).
|
||||||
|
Background(lipgloss.Color("240")).
|
||||||
|
Padding(0, 2).
|
||||||
|
MarginTop(1)
|
||||||
|
|
||||||
|
buttonLocked = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("238")).
|
||||||
|
Background(lipgloss.Color("235")).
|
||||||
|
Padding(0, 2).
|
||||||
|
MarginTop(1)
|
||||||
|
|
||||||
// Status lines
|
// Status lines
|
||||||
|
StatusOKStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("86")).
|
||||||
|
MarginTop(1)
|
||||||
|
|
||||||
StatusWarnStyle = lipgloss.NewStyle().
|
StatusWarnStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("221")).
|
Foreground(lipgloss.Color("221")).
|
||||||
MarginTop(1)
|
MarginTop(1)
|
||||||
@@ -57,10 +100,33 @@ var (
|
|||||||
FooterDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
|
FooterDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
|
||||||
)
|
)
|
||||||
|
|
||||||
// MenuItemStyle returns the appropriate style for a menu row.
|
func MenuItemStyle(active, disabled bool) lipgloss.Style {
|
||||||
func MenuItemStyle(active bool) lipgloss.Style {
|
switch {
|
||||||
if active {
|
case disabled:
|
||||||
|
return menuItemDisabled
|
||||||
|
case active:
|
||||||
return menuItemActive
|
return menuItemActive
|
||||||
|
default:
|
||||||
|
return menuItemInactive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FieldLabelStyle(required bool) lipgloss.Style {
|
||||||
|
if required {
|
||||||
|
return fieldLabelRequired
|
||||||
|
}
|
||||||
|
return fieldLabelOptional
|
||||||
|
}
|
||||||
|
|
||||||
|
// ButtonStyle returns the style for a button.
|
||||||
|
// focused: cursor is on this button. enabled: button is interactive.
|
||||||
|
func ButtonStyle(focused, enabled bool) lipgloss.Style {
|
||||||
|
switch {
|
||||||
|
case !enabled:
|
||||||
|
return buttonLocked
|
||||||
|
case focused:
|
||||||
|
return buttonActive
|
||||||
|
default:
|
||||||
|
return buttonInactive
|
||||||
}
|
}
|
||||||
return menuItemInactive
|
|
||||||
}
|
}
|
||||||
|
|||||||
78
internal/tui/form.go
Normal file
78
internal/tui/form.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/textinput"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fieldName = iota
|
||||||
|
fieldHost
|
||||||
|
fieldUser
|
||||||
|
fieldPrivateKey
|
||||||
|
fieldPort
|
||||||
|
fieldSave
|
||||||
|
fieldBack
|
||||||
|
fieldCount
|
||||||
|
)
|
||||||
|
|
||||||
|
type addServerForm struct {
|
||||||
|
inputs [5]textinput.Model
|
||||||
|
focused int // 0–6: inputs 0-4, save 5, back 6
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAddServerForm() addServerForm {
|
||||||
|
mkInput := func(placeholder string, limit int) textinput.Model {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Prompt = ""
|
||||||
|
ti.CharLimit = limit
|
||||||
|
ti.SetWidth(40)
|
||||||
|
ti.Placeholder = placeholder
|
||||||
|
return ti
|
||||||
|
}
|
||||||
|
|
||||||
|
f := addServerForm{}
|
||||||
|
f.inputs[fieldName] = mkInput("production-web", 64)
|
||||||
|
f.inputs[fieldHost] = mkInput("192.168.1.1 or example.com", 253)
|
||||||
|
f.inputs[fieldUser] = mkInput("deploy", 64)
|
||||||
|
f.inputs[fieldPrivateKey] = mkInput("~/.ssh/id_rsa", 512)
|
||||||
|
f.inputs[fieldPort] = mkInput("22 (optional)", 5)
|
||||||
|
f.inputs[fieldName].Focus()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *addServerForm) focusField(i int) {
|
||||||
|
for j := range f.inputs {
|
||||||
|
f.inputs[j].Blur()
|
||||||
|
}
|
||||||
|
if i < len(f.inputs) {
|
||||||
|
f.inputs[i].Focus()
|
||||||
|
}
|
||||||
|
f.focused = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f addServerForm) canSave() bool {
|
||||||
|
return strings.TrimSpace(f.inputs[fieldName].Value()) != "" &&
|
||||||
|
strings.TrimSpace(f.inputs[fieldHost].Value()) != "" &&
|
||||||
|
strings.TrimSpace(f.inputs[fieldUser].Value()) != "" &&
|
||||||
|
strings.TrimSpace(f.inputs[fieldPrivateKey].Value()) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f addServerForm) focusNext() addServerForm {
|
||||||
|
next := f.focused + 1
|
||||||
|
if next >= fieldCount {
|
||||||
|
next = fieldCount - 1
|
||||||
|
}
|
||||||
|
f.focusField(next)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f addServerForm) focusPrev() addServerForm {
|
||||||
|
prev := f.focused - 1
|
||||||
|
if prev < 0 {
|
||||||
|
prev = 0
|
||||||
|
}
|
||||||
|
f.focusField(prev)
|
||||||
|
return f
|
||||||
|
}
|
||||||
@@ -2,16 +2,10 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"filepass/internal/pages"
|
"filepass/internal/pages"
|
||||||
"filepass/internal/services"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type configLoadedMsg struct {
|
|
||||||
servers map[string]services.Server
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m TUIInterface) Init() tea.Cmd {
|
func (m TUIInterface) Init() tea.Cmd {
|
||||||
return tea.Batch(
|
return tea.Batch(
|
||||||
func() tea.Msg { return pages.HomePageMsg{} },
|
func() tea.Msg { return pages.HomePageMsg{} },
|
||||||
|
|||||||
@@ -5,13 +5,25 @@ import (
|
|||||||
"filepass/internal/services"
|
"filepass/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type page int
|
||||||
|
|
||||||
|
const (
|
||||||
|
pageHome page = iota
|
||||||
|
pageConfig
|
||||||
|
pageAddServer
|
||||||
|
)
|
||||||
|
|
||||||
type TUIInterface struct {
|
type TUIInterface struct {
|
||||||
Services *services.ServicesStore
|
Services *services.ServicesStore
|
||||||
|
Page page
|
||||||
MenuItems []pages.MenuItem
|
MenuItems []pages.MenuItem
|
||||||
Selected int
|
Selected int
|
||||||
Servers map[string]services.Server
|
Servers map[string]services.Server
|
||||||
NoServers bool
|
NoServers bool
|
||||||
InitErr error
|
InitErr error
|
||||||
|
FlashMsg string
|
||||||
|
Form addServerForm
|
||||||
|
FormErr string // inline field error (e.g. duplicate name)
|
||||||
Quitting bool
|
Quitting bool
|
||||||
WindowWidth int
|
WindowWidth int
|
||||||
WindowHeight int
|
WindowHeight int
|
||||||
@@ -20,6 +32,7 @@ type TUIInterface struct {
|
|||||||
func NewTUIInterface(store *services.ServicesStore) TUIInterface {
|
func NewTUIInterface(store *services.ServicesStore) TUIInterface {
|
||||||
return TUIInterface{
|
return TUIInterface{
|
||||||
Services: store,
|
Services: store,
|
||||||
|
Page: pageHome,
|
||||||
MenuItems: pages.HomeMenuItems(),
|
MenuItems: pages.HomeMenuItems(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,69 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"filepass/internal/pages"
|
"filepass/internal/pages"
|
||||||
|
"filepass/internal/services"
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type configLoadedMsg struct {
|
||||||
|
servers map[string]services.Server
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type serverAddedMsg struct {
|
||||||
|
name string
|
||||||
|
servers map[string]services.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
type clearFlashMsg struct{}
|
||||||
|
|
||||||
|
func clearFlashAfter(d time.Duration) tea.Cmd {
|
||||||
|
return tea.Tick(d, func(time.Time) tea.Msg {
|
||||||
|
return clearFlashMsg{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDisabled reports whether a menu item is non-interactive given current state.
|
||||||
|
func (m TUIInterface) isDisabled(i int) bool {
|
||||||
|
return m.MenuItems[i].RequiresServers && m.NoServers
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextSelectable finds the next non-disabled index in direction (+1 or -1).
|
||||||
|
func (m TUIInterface) nextSelectable(from, dir int) int {
|
||||||
|
i := from + dir
|
||||||
|
for i >= 0 && i < len(m.MenuItems) {
|
||||||
|
if !m.isDisabled(i) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i += dir
|
||||||
|
}
|
||||||
|
return from
|
||||||
|
}
|
||||||
|
|
||||||
func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
|
||||||
case pages.HomePageMsg:
|
case pages.HomePageMsg:
|
||||||
|
m.Page = pageHome
|
||||||
|
m.MenuItems = pages.HomeMenuItems()
|
||||||
|
m.Selected = 0
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case pages.ConfigPageMsg:
|
||||||
|
m.Page = pageConfig
|
||||||
|
m.MenuItems = pages.ConfigMenuItems()
|
||||||
|
m.Selected = 0
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case pages.AddServerPageMsg:
|
||||||
|
m.Page = pageAddServer
|
||||||
|
m.Form = newAddServerForm()
|
||||||
|
m.FormErr = ""
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case configLoadedMsg:
|
case configLoadedMsg:
|
||||||
@@ -20,28 +75,58 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.NoServers = len(msg.servers) == 0
|
m.NoServers = len(msg.servers) == 0
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case serverAddedMsg:
|
||||||
|
m.Servers = msg.servers
|
||||||
|
m.NoServers = len(msg.servers) == 0
|
||||||
|
m.Page = pageConfig
|
||||||
|
m.MenuItems = pages.ConfigMenuItems()
|
||||||
|
m.Selected = 0
|
||||||
|
m.FlashMsg = "✓ \"" + msg.name + "\" added successfully."
|
||||||
|
return m, clearFlashAfter(2 * time.Second)
|
||||||
|
|
||||||
|
case clearFlashMsg:
|
||||||
|
m.FlashMsg = ""
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.WindowWidth = msg.Width
|
m.WindowWidth = msg.Width
|
||||||
m.WindowHeight = msg.Height
|
m.WindowHeight = msg.Height
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
|
// add server form has its own key handling
|
||||||
|
if m.Page == pageAddServer {
|
||||||
|
return m.updateAddServer(msg)
|
||||||
|
}
|
||||||
|
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if m.Selected > 0 {
|
m.Selected = m.nextSelectable(m.Selected, -1)
|
||||||
m.Selected--
|
|
||||||
}
|
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
if m.Selected < len(m.MenuItems)-1 {
|
m.Selected = m.nextSelectable(m.Selected, +1)
|
||||||
m.Selected++
|
|
||||||
}
|
|
||||||
case "enter":
|
case "enter":
|
||||||
if m.MenuItems[m.Selected].Key == "exit" {
|
if m.isDisabled(m.Selected) {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
switch m.MenuItems[m.Selected].Key {
|
||||||
|
case "exit":
|
||||||
m.Quitting = true
|
m.Quitting = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
case "config":
|
||||||
|
return m, func() tea.Msg { return pages.ConfigPageMsg{} }
|
||||||
|
case "back":
|
||||||
|
return m, func() tea.Msg { return pages.HomePageMsg{} }
|
||||||
|
case "add":
|
||||||
|
return m, func() tea.Msg { return pages.AddServerPageMsg{} }
|
||||||
|
// TODO: "server", "edit", "remove"
|
||||||
|
}
|
||||||
|
case "ctrl+c":
|
||||||
|
m.Quitting = true
|
||||||
|
return m, tea.Quit
|
||||||
|
case "esc":
|
||||||
|
if m.Page == pageConfig {
|
||||||
|
return m, func() tea.Msg { return pages.HomePageMsg{} }
|
||||||
}
|
}
|
||||||
// TODO: dispatch to server/config pages
|
|
||||||
case "ctrl+c", "esc":
|
|
||||||
m.Quitting = true
|
m.Quitting = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
@@ -49,3 +134,83 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m TUIInterface) updateAddServer(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
f := m.Form
|
||||||
|
|
||||||
|
switch msg.String() {
|
||||||
|
case "tab", "down":
|
||||||
|
m.Form = f.focusNext()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "shift+tab", "up":
|
||||||
|
m.Form = f.focusPrev()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "enter":
|
||||||
|
// on an input field, advance to next
|
||||||
|
if f.focused < fieldSave {
|
||||||
|
m.Form = f.focusNext()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
// on save button
|
||||||
|
if f.focused == fieldSave {
|
||||||
|
return m.submitAddServer()
|
||||||
|
}
|
||||||
|
// on back button
|
||||||
|
if f.focused == fieldBack {
|
||||||
|
return m, func() tea.Msg { return pages.ConfigPageMsg{} }
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ctrl+c":
|
||||||
|
m.Quitting = true
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case "esc":
|
||||||
|
return m, func() tea.Msg { return pages.ConfigPageMsg{} }
|
||||||
|
}
|
||||||
|
|
||||||
|
// route keystrokes to the focused input
|
||||||
|
if f.focused < len(f.inputs) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
f.inputs[f.focused], cmd = f.inputs[f.focused].Update(msg)
|
||||||
|
// clear duplicate-name error when user edits the name field
|
||||||
|
if f.focused == fieldName {
|
||||||
|
m.FormErr = ""
|
||||||
|
}
|
||||||
|
m.Form = f
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m TUIInterface) submitAddServer() (tea.Model, tea.Cmd) {
|
||||||
|
f := m.Form
|
||||||
|
name := strings.TrimSpace(f.inputs[fieldName].Value())
|
||||||
|
|
||||||
|
if !f.canSave() {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Services.Config.HasServer(name) {
|
||||||
|
m.FormErr = "✗ \"" + name + "\" already exists."
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s := services.Server{
|
||||||
|
Host: strings.TrimSpace(f.inputs[fieldHost].Value()),
|
||||||
|
User: strings.TrimSpace(f.inputs[fieldUser].Value()),
|
||||||
|
PrivateKey: strings.TrimSpace(f.inputs[fieldPrivateKey].Value()),
|
||||||
|
Port: strings.TrimSpace(f.inputs[fieldPort].Value()),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.Services.Config.AddServer(name, s); err != nil {
|
||||||
|
m.FormErr = "✗ " + err.Error()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
return serverAddedMsg{name: name, servers: m.Services.Config.Servers()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ func footerSep() string {
|
|||||||
return styles.FooterSepStyle.Render(" · ")
|
return styles.FooterSepStyle.Render(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m TUIInterface) subtitle() string {
|
||||||
|
switch m.Page {
|
||||||
|
case pageConfig:
|
||||||
|
return "Configuration"
|
||||||
|
case pageAddServer:
|
||||||
|
return "Add Server"
|
||||||
|
default:
|
||||||
|
return "Secure file transfer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m TUIInterface) View() tea.View {
|
func (m TUIInterface) View() tea.View {
|
||||||
if m.Quitting {
|
if m.Quitting {
|
||||||
return tea.NewView("")
|
return tea.NewView("")
|
||||||
@@ -31,49 +42,41 @@ func (m TUIInterface) View() tea.View {
|
|||||||
h = 24
|
h = 24
|
||||||
}
|
}
|
||||||
|
|
||||||
// menu rows
|
var body string
|
||||||
var menuRows []string
|
switch m.Page {
|
||||||
for i, item := range m.MenuItems {
|
case pageAddServer:
|
||||||
menuRows = append(menuRows, styles.MenuItemStyle(i == m.Selected).Render(item.Label))
|
body = m.viewAddServer()
|
||||||
}
|
default:
|
||||||
menu := lipgloss.JoinVertical(lipgloss.Left, menuRows...)
|
body = m.viewMenu()
|
||||||
|
|
||||||
// status line — error takes priority over no-servers hint
|
|
||||||
var statusLine string
|
|
||||||
switch {
|
|
||||||
case m.InitErr != nil:
|
|
||||||
statusLine = styles.StatusErrStyle.Render("✗ " + m.InitErr.Error())
|
|
||||||
case m.NoServers:
|
|
||||||
statusLine = styles.StatusWarnStyle.Render("⚠ No servers configured. Select Config to add one.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// top content
|
header := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
innerRows := []string{
|
|
||||||
styles.CardTitleStyle.Render("✦ filepass"),
|
styles.CardTitleStyle.Render("✦ filepass"),
|
||||||
styles.CardSubtitleStyle.Render("Secure file transfer"),
|
styles.CardSubtitleStyle.Render(m.subtitle()),
|
||||||
menu,
|
|
||||||
}
|
|
||||||
if statusLine != "" {
|
|
||||||
innerRows = append(innerRows, statusLine)
|
|
||||||
}
|
|
||||||
topContent := styles.CardInnerStyle.Render(
|
|
||||||
lipgloss.JoinVertical(lipgloss.Left, innerRows...),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// footer
|
topContent := styles.CardInnerStyle.Render(
|
||||||
hints := footerHint("↑↓", "navigate") +
|
lipgloss.JoinVertical(lipgloss.Left, header, body),
|
||||||
footerSep() +
|
)
|
||||||
footerHint("enter", "select") +
|
|
||||||
footerSep() +
|
var footerStr string
|
||||||
footerHint("esc", "quit")
|
if m.Page == pageAddServer {
|
||||||
footer := styles.FooterStyle.Render(hints)
|
footerStr = footerHint("tab/↑↓", "navigate") +
|
||||||
|
footerSep() +
|
||||||
|
footerHint("enter", "confirm") +
|
||||||
|
footerSep() +
|
||||||
|
footerHint("esc", "back")
|
||||||
|
} else {
|
||||||
|
footerStr = footerHint("↑↓", "navigate") +
|
||||||
|
footerSep() +
|
||||||
|
footerHint("enter", "select") +
|
||||||
|
footerSep() +
|
||||||
|
footerHint("esc", "quit")
|
||||||
|
}
|
||||||
|
footer := styles.FooterStyle.Render(footerStr)
|
||||||
|
|
||||||
// card
|
|
||||||
card := styles.CardStyle.Render(
|
card := styles.CardStyle.Render(
|
||||||
lipgloss.JoinVertical(lipgloss.Left,
|
lipgloss.JoinVertical(lipgloss.Left, topContent, footer),
|
||||||
topContent,
|
|
||||||
footer,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cardHeight := lipgloss.Height(card)
|
cardHeight := lipgloss.Height(card)
|
||||||
@@ -89,3 +92,63 @@ func (m TUIInterface) View() tea.View {
|
|||||||
v.AltScreen = true
|
v.AltScreen = true
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m TUIInterface) viewMenu() string {
|
||||||
|
var menuRows []string
|
||||||
|
for i, item := range m.MenuItems {
|
||||||
|
disabled := m.isDisabled(i)
|
||||||
|
menuRows = append(menuRows, styles.MenuItemStyle(i == m.Selected, disabled).Render(item.Label))
|
||||||
|
}
|
||||||
|
menu := lipgloss.JoinVertical(lipgloss.Left, menuRows...)
|
||||||
|
|
||||||
|
var statusLine string
|
||||||
|
switch {
|
||||||
|
case m.InitErr != nil:
|
||||||
|
statusLine = styles.StatusErrStyle.Render("✗ " + m.InitErr.Error())
|
||||||
|
case m.NoServers && m.Page == pageHome:
|
||||||
|
statusLine = styles.StatusWarnStyle.Render("⚠ No servers configured. Select Config to add one.")
|
||||||
|
case m.FlashMsg != "" && m.Page == pageConfig:
|
||||||
|
statusLine = styles.StatusOKStyle.Render(m.FlashMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusLine != "" {
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, menu, statusLine)
|
||||||
|
}
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m TUIInterface) viewAddServer() string {
|
||||||
|
f := m.Form
|
||||||
|
labels := []string{"Name", "Host", "User", "Private Key Path", "Port"}
|
||||||
|
required := []bool{true, true, true, true, false}
|
||||||
|
|
||||||
|
var rows []string
|
||||||
|
for i, label := range labels {
|
||||||
|
lbl := styles.FieldLabelStyle(required[i]).Render(label)
|
||||||
|
input := f.inputs[i].View()
|
||||||
|
rows = append(rows, lipgloss.JoinVertical(lipgloss.Left, lbl, input))
|
||||||
|
}
|
||||||
|
form := lipgloss.JoinVertical(lipgloss.Left, rows...)
|
||||||
|
|
||||||
|
// required legend
|
||||||
|
legend := styles.FieldLegendStyle.Render("* required")
|
||||||
|
|
||||||
|
// form error (duplicate name, etc.)
|
||||||
|
var errLine string
|
||||||
|
if m.FormErr != "" {
|
||||||
|
errLine = styles.StatusErrStyle.Render(m.FormErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// save / back buttons
|
||||||
|
saveBtn := styles.ButtonStyle(f.focused == fieldSave, f.canSave()).Render("Save")
|
||||||
|
backBtn := styles.ButtonStyle(f.focused == fieldBack, true).Render("Back")
|
||||||
|
buttons := lipgloss.JoinHorizontal(lipgloss.Top, saveBtn, " ", backBtn)
|
||||||
|
|
||||||
|
parts := []string{form, legend}
|
||||||
|
if errLine != "" {
|
||||||
|
parts = append(parts, errLine)
|
||||||
|
}
|
||||||
|
parts = append(parts, buttons)
|
||||||
|
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user