diff --git a/internal/pages/select_server.go b/internal/pages/select_server.go new file mode 100644 index 0000000..3ae129e --- /dev/null +++ b/internal/pages/select_server.go @@ -0,0 +1,3 @@ +package pages + +type SelectServerPageMsg struct{} diff --git a/internal/styles/styles.go b/internal/styles/styles.go index 7b72694..677d758 100644 --- a/internal/styles/styles.go +++ b/internal/styles/styles.go @@ -100,6 +100,31 @@ var ( FooterDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243")) ) +var ( + serverRowBase = lipgloss.NewStyle(). + PaddingLeft(2). + PaddingTop(0). + Width(44) + + serverRowBaseActive = lipgloss.NewStyle(). + PaddingLeft(2). + Width(44) + + serverRowNameStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("255")) + + serverRowNameActiveStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("75")) + + serverRowDetailStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("243")) + + serverRowDetailActiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("117")) +) + func MenuItemStyle(active, disabled bool) lipgloss.Style { switch { case disabled: @@ -130,3 +155,23 @@ func ButtonStyle(focused, enabled bool) lipgloss.Style { return buttonInactive } } + +// ServerRowStyle renders a two-line server list entry: bold name on top, +// dim "user@host[:port]" detail below. +func ServerRowStyle(active bool, name, detail string) string { + nameStyle := serverRowNameStyle + detailStyle := serverRowDetailStyle + base := serverRowBase + prefix := " " + if active { + nameStyle = serverRowNameActiveStyle + detailStyle = serverRowDetailActiveStyle + base = serverRowBaseActive + prefix = "▸ " + } + row := lipgloss.JoinVertical(lipgloss.Left, + nameStyle.Render(prefix+name), + detailStyle.Render(" "+detail), + ) + return base.Render(row) +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index ddd96e2..811d7dd 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -11,22 +11,24 @@ const ( pageHome page = iota pageConfig pageAddServer + pageSelectServer ) 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 + Services *services.ServicesStore + Page page + MenuItems []pages.MenuItem + Selected int + Servers map[string]services.Server + ServerNames []string // sorted, stable order for list rendering + NoServers bool + InitErr error + FlashMsg string + Form addServerForm + FormErr string // inline field error (e.g. duplicate name) + Quitting bool + WindowWidth int + WindowHeight int } func NewTUIInterface(store *services.ServicesStore) TUIInterface { diff --git a/internal/tui/update.go b/internal/tui/update.go index e702d48..f699fec 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,6 +1,7 @@ package tui import ( + "sort" "strings" "time" @@ -28,6 +29,16 @@ func clearFlashAfter(d time.Duration) tea.Cmd { }) } +// sortedServerNames returns the keys of servers sorted alphabetically. +func sortedServerNames(servers map[string]services.Server) []string { + names := make([]string, 0, len(servers)) + for name := range servers { + names = append(names, name) + } + sort.Strings(names) + return names +} + // 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 @@ -66,17 +77,24 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.FormErr = "" return m, nil + case pages.SelectServerPageMsg: + m.Page = pageSelectServer + m.Selected = 0 + return m, nil + case configLoadedMsg: if msg.err != nil { m.InitErr = msg.err return m, nil } m.Servers = msg.servers + m.ServerNames = sortedServerNames(msg.servers) m.NoServers = len(msg.servers) == 0 return m, nil case serverAddedMsg: m.Servers = msg.servers + m.ServerNames = sortedServerNames(msg.servers) m.NoServers = len(msg.servers) == 0 m.Page = pageConfig m.MenuItems = pages.ConfigMenuItems() @@ -98,6 +116,10 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.Page == pageAddServer { return m.updateAddServer(msg) } + // server list has its own key handling + if m.Page == pageSelectServer { + return m.updateSelectServer(msg) + } switch msg.String() { case "up", "k": @@ -118,7 +140,9 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return pages.HomePageMsg{} } case "add": return m, func() tea.Msg { return pages.AddServerPageMsg{} } - // TODO: "server", "edit", "remove" + case "server": + return m, func() tea.Msg { return pages.SelectServerPageMsg{} } + // TODO: "edit", "remove" } case "ctrl+c": m.Quitting = true @@ -130,6 +154,16 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Quitting = true return m, tea.Quit } + + case tea.PasteMsg: + if m.Page == pageAddServer { + return m.updateAddServerPaste(msg.Content) + } + + case tea.ClipboardMsg: + if m.Page == pageAddServer { + return m.updateAddServerPaste(msg.Content) + } } return m, nil @@ -166,6 +200,13 @@ func (m TUIInterface) updateAddServer(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) m.Quitting = true return m, tea.Quit + case "ctrl+v": + // OSC52 clipboard read; result arrives as tea.ClipboardMsg + if f.focused < len(f.inputs) { + return m, tea.ReadClipboard + } + return m, nil + case "esc": return m, func() tea.Msg { return pages.ConfigPageMsg{} } } @@ -185,6 +226,42 @@ func (m TUIInterface) updateAddServer(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) return m, nil } +func (m TUIInterface) updateAddServerPaste(text string) (tea.Model, tea.Cmd) { + f := m.Form + if f.focused >= len(f.inputs) { + return m, nil + } + var cmd tea.Cmd + f.inputs[f.focused], cmd = f.inputs[f.focused].Update(tea.PasteMsg{Content: text}) + if f.focused == fieldName { + m.FormErr = "" + } + m.Form = f + return m, cmd +} + +func (m TUIInterface) updateSelectServer(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + last := len(m.ServerNames) - 1 + switch msg.String() { + case "up", "k": + if m.Selected > 0 { + m.Selected-- + } + case "down", "j": + if m.Selected < last { + m.Selected++ + } + case "enter": + // TODO: connect to selected server + case "ctrl+c": + m.Quitting = true + return m, tea.Quit + case "esc": + return m, func() tea.Msg { return pages.HomePageMsg{} } + } + return m, nil +} + func (m TUIInterface) submitAddServer() (tea.Model, tea.Cmd) { f := m.Form name := strings.TrimSpace(f.inputs[fieldName].Value()) diff --git a/internal/tui/view.go b/internal/tui/view.go index 8dd793b..95970b9 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -23,6 +23,8 @@ func (m TUIInterface) subtitle() string { return "Configuration" case pageAddServer: return "Add Server" + case pageSelectServer: + return "Select Server" default: return "Secure file transfer" } @@ -46,6 +48,8 @@ func (m TUIInterface) View() tea.View { switch m.Page { case pageAddServer: body = m.viewAddServer() + case pageSelectServer: + body = m.viewSelectServer() default: body = m.viewMenu() } @@ -60,13 +64,22 @@ func (m TUIInterface) View() tea.View { ) var footerStr string - if m.Page == pageAddServer { + switch m.Page { + case pageAddServer: footerStr = footerHint("tab/↑↓", "navigate") + footerSep() + footerHint("enter", "confirm") + footerSep() + + footerHint("ctrl+v", "paste") + + footerSep() + footerHint("esc", "back") - } else { + case pageSelectServer: + footerStr = footerHint("↑↓", "navigate") + + footerSep() + + footerHint("enter", "connect") + + footerSep() + + footerHint("esc", "back") + default: footerStr = footerHint("↑↓", "navigate") + footerSep() + footerHint("enter", "select") + @@ -117,7 +130,23 @@ func (m TUIInterface) viewMenu() string { return menu } -func (m TUIInterface) viewAddServer() string { +func (m TUIInterface) viewSelectServer() string { + if len(m.ServerNames) == 0 { + return styles.StatusWarnStyle.Render("⚠ No servers configured.") + } + + var rows []string + for i, name := range m.ServerNames { + srv := m.Servers[name] + detail := srv.User + "@" + srv.Host + if srv.Port != "" { + detail += ":" + srv.Port + } + row := styles.ServerRowStyle(i == m.Selected, name, detail) + rows = append(rows, row) + } + return lipgloss.JoinVertical(lipgloss.Left, rows...) +} f := m.Form labels := []string{"Name", "Host", "User", "Private Key Path", "Port"} required := []bool{true, true, true, true, false}