This commit is contained in:
2026-04-06 01:23:10 +09:00
commit 09c78206a8
12 changed files with 439 additions and 0 deletions

16
internal/pages/home.go Normal file
View File

@@ -0,0 +1,16 @@
package pages
type HomePageMsg struct{}
type MenuItem struct {
Label string
Key string
}
func HomeMenuItems() []MenuItem {
return []MenuItem{
{Label: "Select Server", Key: "server"},
{Label: "Config", Key: "config"},
{Label: "Exit", Key: "exit"},
}
}

View File

@@ -0,0 +1,56 @@
package services
import (
"encoding/json"
"errors"
"os"
"path/filepath"
)
type Server struct {
Host string `json:"host"`
User string `json:"user"`
PrivateKey string `json:"private_key"`
Port string `json:"port"`
}
type ConfigService struct {
path string
servers map[string]Server
}
func NewConfigService() (*ConfigService, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return nil, err
}
dir := filepath.Join(configDir, "filepass")
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
path := filepath.Join(dir, "servers.json")
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
return nil, err
}
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var servers map[string]Server
if err := json.Unmarshal(data, &servers); err != nil {
return nil, err
}
return &ConfigService{path: path, servers: servers}, nil
}
func (c *ConfigService) Servers() map[string]Server {
return c.servers
}

View File

@@ -0,0 +1,13 @@
package services
type ServicesStore struct {
Config *ConfigService
}
func NewServicesStore() (*ServicesStore, error) {
cfg, err := NewConfigService()
if err != nil {
return nil, err
}
return &ServicesStore{Config: cfg}, nil
}

66
internal/styles/styles.go Normal file
View File

@@ -0,0 +1,66 @@
package styles
import lipgloss "charm.land/lipgloss/v2"
var (
// Card / box
CardStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Width(52)
CardInnerStyle = lipgloss.NewStyle().
Padding(1, 3)
CardTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("86")).
Bold(true).
MarginBottom(1)
CardSubtitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("245")).
MarginBottom(1)
// Menu items
menuItemBase = lipgloss.NewStyle().
PaddingLeft(2).
Width(44)
menuItemActive = menuItemBase.
Foreground(lipgloss.Color("75")).
Bold(true).
SetString("▸ ")
menuItemInactive = menuItemBase.
Foreground(lipgloss.Color("245"))
// Status lines
StatusWarnStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("221")).
MarginTop(1)
StatusErrStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("203")).
MarginTop(1)
// Footer
FooterStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
BorderTop(true).
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("237")).
Padding(0, 1).
Width(50)
FooterKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true)
FooterSepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("237"))
FooterDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
)
// MenuItemStyle returns the appropriate style for a menu row.
func MenuItemStyle(active bool) lipgloss.Style {
if active {
return menuItemActive
}
return menuItemInactive
}

22
internal/tui/init.go Normal file
View File

@@ -0,0 +1,22 @@
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{} },
func() tea.Msg {
return configLoadedMsg{servers: m.Services.Config.Servers()}
},
)
}

25
internal/tui/tui.go Normal file
View File

@@ -0,0 +1,25 @@
package tui
import (
"filepass/internal/pages"
"filepass/internal/services"
)
type TUIInterface struct {
Services *services.ServicesStore
MenuItems []pages.MenuItem
Selected int
Servers map[string]services.Server
NoServers bool
InitErr error
Quitting bool
WindowWidth int
WindowHeight int
}
func NewTUIInterface(store *services.ServicesStore) TUIInterface {
return TUIInterface{
Services: store,
MenuItems: pages.HomeMenuItems(),
}
}

51
internal/tui/update.go Normal file
View File

@@ -0,0 +1,51 @@
package tui
import (
"filepass/internal/pages"
tea "charm.land/bubbletea/v2"
)
func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case pages.HomePageMsg:
return m, nil
case configLoadedMsg:
if msg.err != nil {
m.InitErr = msg.err
return m, nil
}
m.Servers = msg.servers
m.NoServers = len(msg.servers) == 0
return m, nil
case tea.WindowSizeMsg:
m.WindowWidth = msg.Width
m.WindowHeight = msg.Height
return m, nil
case tea.KeyPressMsg:
switch msg.String() {
case "up", "k":
if m.Selected > 0 {
m.Selected--
}
case "down", "j":
if m.Selected < len(m.MenuItems)-1 {
m.Selected++
}
case "enter":
if m.MenuItems[m.Selected].Key == "exit" {
m.Quitting = true
return m, tea.Quit
}
// TODO: dispatch to server/config pages
case "ctrl+c", "esc":
m.Quitting = true
return m, tea.Quit
}
}
return m, nil
}

91
internal/tui/view.go Normal file
View File

@@ -0,0 +1,91 @@
package tui
import (
"filepass/internal/styles"
tea "charm.land/bubbletea/v2"
lipgloss "charm.land/lipgloss/v2"
)
func footerHint(key, desc string) string {
return styles.FooterKeyStyle.Render(key) +
" " +
styles.FooterDescStyle.Render(desc)
}
func footerSep() string {
return styles.FooterSepStyle.Render(" · ")
}
func (m TUIInterface) View() tea.View {
if m.Quitting {
return tea.NewView("")
}
w := m.WindowWidth
h := m.WindowHeight
if w == 0 {
w = 80
}
if h == 0 {
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.")
}
// top content
innerRows := []string{
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...),
)
// footer
hints := footerHint("↑↓", "navigate") +
footerSep() +
footerHint("enter", "select") +
footerSep() +
footerHint("esc", "quit")
footer := styles.FooterStyle.Render(hints)
// card
card := styles.CardStyle.Render(
lipgloss.JoinVertical(lipgloss.Left,
topContent,
footer,
),
)
cardHeight := lipgloss.Height(card)
topPad := max((h-cardHeight)/2, 0)
centeredCard := lipgloss.NewStyle().
Width(w).
Align(lipgloss.Center).
PaddingTop(topPad).
Render(card)
v := tea.NewView(centeredCard)
v.AltScreen = true
return v
}