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

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 (
"filepass/internal/pages"
"filepass/internal/services"
tea "charm.land/bubbletea/v2"
)
type configLoadedMsg struct {
servers map[string]services.Server
err error
}
func (m TUIInterface) Init() tea.Cmd {
return tea.Batch(
func() tea.Msg { return pages.HomePageMsg{} },

View File

@@ -5,13 +5,25 @@ import (
"filepass/internal/services"
)
type page int
const (
pageHome page = iota
pageConfig
pageAddServer
)
type TUIInterface struct {
Services *services.ServicesStore
Page page
MenuItems []pages.MenuItem
Selected int
Servers map[string]services.Server
NoServers bool
InitErr error
FlashMsg string
Form addServerForm
FormErr string // inline field error (e.g. duplicate name)
Quitting bool
WindowWidth int
WindowHeight int
@@ -20,6 +32,7 @@ type TUIInterface struct {
func NewTUIInterface(store *services.ServicesStore) TUIInterface {
return TUIInterface{
Services: store,
Page: pageHome,
MenuItems: pages.HomeMenuItems(),
}
}

View File

@@ -1,14 +1,69 @@
package tui
import (
"strings"
"time"
"filepass/internal/pages"
"filepass/internal/services"
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) {
switch msg := msg.(type) {
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
case configLoadedMsg:
@@ -20,28 +75,58 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.NoServers = len(msg.servers) == 0
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:
m.WindowWidth = msg.Width
m.WindowHeight = msg.Height
return m, nil
case tea.KeyPressMsg:
// add server form has its own key handling
if m.Page == pageAddServer {
return m.updateAddServer(msg)
}
switch msg.String() {
case "up", "k":
if m.Selected > 0 {
m.Selected--
}
m.Selected = m.nextSelectable(m.Selected, -1)
case "down", "j":
if m.Selected < len(m.MenuItems)-1 {
m.Selected++
}
m.Selected = m.nextSelectable(m.Selected, +1)
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
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
return m, tea.Quit
}
@@ -49,3 +134,83 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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(" · ")
}
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 {
if m.Quitting {
return tea.NewView("")
@@ -31,49 +42,41 @@ func (m TUIInterface) View() tea.View {
h = 24
}
// menu rows
var menuRows []string
for i, item := range m.MenuItems {
menuRows = append(menuRows, styles.MenuItemStyle(i == m.Selected).Render(item.Label))
}
menu := lipgloss.JoinVertical(lipgloss.Left, menuRows...)
// 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.")
var body string
switch m.Page {
case pageAddServer:
body = m.viewAddServer()
default:
body = m.viewMenu()
}
// top content
innerRows := []string{
header := lipgloss.JoinVertical(lipgloss.Left,
styles.CardTitleStyle.Render("✦ filepass"),
styles.CardSubtitleStyle.Render("Secure file transfer"),
menu,
}
if statusLine != "" {
innerRows = append(innerRows, statusLine)
}
topContent := styles.CardInnerStyle.Render(
lipgloss.JoinVertical(lipgloss.Left, innerRows...),
styles.CardSubtitleStyle.Render(m.subtitle()),
)
// footer
hints := footerHint("↑↓", "navigate") +
footerSep() +
footerHint("enter", "select") +
footerSep() +
footerHint("esc", "quit")
footer := styles.FooterStyle.Render(hints)
topContent := styles.CardInnerStyle.Render(
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() +
footerHint("enter", "select") +
footerSep() +
footerHint("esc", "quit")
}
footer := styles.FooterStyle.Render(footerStr)
// card
card := styles.CardStyle.Render(
lipgloss.JoinVertical(lipgloss.Left,
topContent,
footer,
),
lipgloss.JoinVertical(lipgloss.Left, topContent, footer),
)
cardHeight := lipgloss.Height(card)
@@ -89,3 +92,63 @@ func (m TUIInterface) View() tea.View {
v.AltScreen = true
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...)
}