update:wip

This commit is contained in:
2026-04-06 01:39:29 +09:00
parent 09c78206a8
commit ed19e0ba4e
10 changed files with 475 additions and 58 deletions

View File

@@ -0,0 +1,3 @@
package pages
type AddServerPageMsg struct{}

12
internal/pages/config.go Normal file
View 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"},
}
}

View File

@@ -5,11 +5,12 @@ 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"},
} }

View File

@@ -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)
}

View File

@@ -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 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
}
} }

78
internal/tui/form.go Normal file
View 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 // 06: 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
}

View File

@@ -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{} },

View File

@@ -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(),
} }
} }

View File

@@ -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()}
}
}

View File

@@ -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),
)
var footerStr string
if m.Page == pageAddServer {
footerStr = footerHint("tab/↑↓", "navigate") +
footerSep() +
footerHint("enter", "confirm") +
footerSep() +
footerHint("esc", "back")
} else {
footerStr = footerHint("↑↓", "navigate") +
footerSep() + footerSep() +
footerHint("enter", "select") + footerHint("enter", "select") +
footerSep() + footerSep() +
footerHint("esc", "quit") footerHint("esc", "quit")
footer := styles.FooterStyle.Render(hints) }
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...)
}