commit 09c78206a84eb78532e97b47265a25c621a61976 Author: kokopi-dev Date: Mon Apr 6 01:23:10 2026 +0900 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1432904 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module filepass + +go 1.26.1 + +require ( + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.2 + charm.land/lipgloss/v2 v2.0.2 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bb84f7 --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= +charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/pages/home.go b/internal/pages/home.go new file mode 100644 index 0000000..1e6a2f7 --- /dev/null +++ b/internal/pages/home.go @@ -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"}, + } +} diff --git a/internal/services/config.go b/internal/services/config.go new file mode 100644 index 0000000..36ba879 --- /dev/null +++ b/internal/services/config.go @@ -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 +} diff --git a/internal/services/services_store.go b/internal/services/services_store.go new file mode 100644 index 0000000..01990a4 --- /dev/null +++ b/internal/services/services_store.go @@ -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 +} diff --git a/internal/styles/styles.go b/internal/styles/styles.go new file mode 100644 index 0000000..53cc992 --- /dev/null +++ b/internal/styles/styles.go @@ -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 +} diff --git a/internal/tui/init.go b/internal/tui/init.go new file mode 100644 index 0000000..746ea3b --- /dev/null +++ b/internal/tui/init.go @@ -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()} + }, + ) +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..51ccde3 --- /dev/null +++ b/internal/tui/tui.go @@ -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(), + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go new file mode 100644 index 0000000..15eb906 --- /dev/null +++ b/internal/tui/update.go @@ -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 +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..a5046f3 --- /dev/null +++ b/internal/tui/view.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..b5cddad --- /dev/null +++ b/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + "os" + + "filepass/internal/services" + "filepass/internal/tui" + + tea "charm.land/bubbletea/v2" +) + +func main() { + store, err := services.NewServicesStore() + if err != nil { + fmt.Fprintln(os.Stderr, "failed to initialise config:", err) + os.Exit(1) + } + + m := tui.NewTUIInterface(store) + p := tea.NewProgram(m) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +}