From ed19e0ba4eba2b9783911d4af93dc5d928b060c9 Mon Sep 17 00:00:00 2001 From: kokopi-dev Date: Mon, 6 Apr 2026 01:39:29 +0900 Subject: [PATCH] update:wip --- internal/pages/add_server.go | 3 + internal/pages/config.go | 12 +++ internal/pages/home.go | 7 +- internal/services/config.go | 22 +++++ internal/styles/styles.go | 74 +++++++++++++- internal/tui/form.go | 78 +++++++++++++++ internal/tui/init.go | 6 -- internal/tui/tui.go | 13 +++ internal/tui/update.go | 183 +++++++++++++++++++++++++++++++++-- internal/tui/view.go | 135 +++++++++++++++++++------- 10 files changed, 475 insertions(+), 58 deletions(-) create mode 100644 internal/pages/add_server.go create mode 100644 internal/pages/config.go create mode 100644 internal/tui/form.go diff --git a/internal/pages/add_server.go b/internal/pages/add_server.go new file mode 100644 index 0000000..a239190 --- /dev/null +++ b/internal/pages/add_server.go @@ -0,0 +1,3 @@ +package pages + +type AddServerPageMsg struct{} diff --git a/internal/pages/config.go b/internal/pages/config.go new file mode 100644 index 0000000..3d4af5e --- /dev/null +++ b/internal/pages/config.go @@ -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"}, + } +} diff --git a/internal/pages/home.go b/internal/pages/home.go index 1e6a2f7..031a99d 100644 --- a/internal/pages/home.go +++ b/internal/pages/home.go @@ -3,13 +3,14 @@ package pages type HomePageMsg struct{} type MenuItem struct { - Label string - Key string + Label string + Key string + RequiresServers bool } func HomeMenuItems() []MenuItem { return []MenuItem{ - {Label: "Select Server", Key: "server"}, + {Label: "Select Server", Key: "server", RequiresServers: true}, {Label: "Config", Key: "config"}, {Label: "Exit", Key: "exit"}, } diff --git a/internal/services/config.go b/internal/services/config.go index 36ba879..93e9094 100644 --- a/internal/services/config.go +++ b/internal/services/config.go @@ -3,6 +3,7 @@ package services import ( "encoding/json" "errors" + "fmt" "os" "path/filepath" ) @@ -54,3 +55,24 @@ func NewConfigService() (*ConfigService, error) { func (c *ConfigService) Servers() map[string]Server { 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) +} diff --git a/internal/styles/styles.go b/internal/styles/styles.go index 53cc992..7b72694 100644 --- a/internal/styles/styles.go +++ b/internal/styles/styles.go @@ -34,7 +34,50 @@ var ( menuItemInactive = menuItemBase. 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 + StatusOKStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + MarginTop(1) + StatusWarnStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("221")). MarginTop(1) @@ -57,10 +100,33 @@ var ( FooterDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243")) ) -// MenuItemStyle returns the appropriate style for a menu row. -func MenuItemStyle(active bool) lipgloss.Style { - if active { +func MenuItemStyle(active, disabled bool) lipgloss.Style { + switch { + case disabled: + return menuItemDisabled + case active: 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 } diff --git a/internal/tui/form.go b/internal/tui/form.go new file mode 100644 index 0000000..9e788ac --- /dev/null +++ b/internal/tui/form.go @@ -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 +} diff --git a/internal/tui/init.go b/internal/tui/init.go index 746ea3b..e4a0d99 100644 --- a/internal/tui/init.go +++ b/internal/tui/init.go @@ -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{} }, diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 51ccde3..ddd96e2 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -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(), } } diff --git a/internal/tui/update.go b/internal/tui/update.go index 15eb906..e702d48 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -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()} + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index a5046f3..8dd793b 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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...) +}