From a20749d2974a71cd8fd6206470cfb8861c6d1b14 Mon Sep 17 00:00:00 2001 From: kokopi-dev Date: Tue, 7 Apr 2026 04:57:53 +0900 Subject: [PATCH] add:send functionality --- internal/services/storage.go | 16 +++++++--- internal/styles/styles.go | 4 +++ internal/tui/picker.go | 13 ++++---- internal/tui/update.go | 60 ++++++++++++++++++++++++++++++------ internal/tui/view.go | 28 ++++++++++++++--- 5 files changed, 96 insertions(+), 25 deletions(-) diff --git a/internal/services/storage.go b/internal/services/storage.go index 68b75ea..a750a2f 100644 --- a/internal/services/storage.go +++ b/internal/services/storage.go @@ -68,11 +68,17 @@ func (s *StorageService) Delete(filename string) error { return nil } -// Send transfers one or more local files to the remote storage. -// Multiple files are archived into a temp tarball first. -func (s *StorageService) Send(localPaths []string) error { - // TODO: implement - return fmt.Errorf("send: not yet implemented") +// Send transfers a single local file to the remote storage directory. +func (s *StorageService) Send(localPath string) error { + dst := RemoteStorageRoot(s.server) + cmd := RsyncCmd(s.server, localPath, dst) + debugLog("Send | args: %v", cmd.Args) + out, err := cmd.CombinedOutput() + debugLog("Send | exit_err: %v | output: %q", err, strings.TrimSpace(string(out))) + if err != nil { + return fmt.Errorf("send failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil } // CleanAll removes all files from remote storage. diff --git a/internal/styles/styles.go b/internal/styles/styles.go index f87fe6f..734a4a6 100644 --- a/internal/styles/styles.go +++ b/internal/styles/styles.go @@ -164,6 +164,10 @@ var ( Foreground(lipgloss.Color("75")). MarginBottom(1) + PickerQueryBlurredStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + MarginBottom(1) + pickerItemBase = lipgloss.NewStyle(). PaddingLeft(4). Width(44) diff --git a/internal/tui/picker.go b/internal/tui/picker.go index d1ccee9..fa258c3 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -14,15 +14,16 @@ type entry struct { // picker is the state for the send file picker page. type picker struct { - dir string // current directory being browsed - entries []entry // unfiltered entries in dir - filtered []entry // entries matching query - query string // current filter string - cursor int // index within filtered + dir string // current directory being browsed + entries []entry // unfiltered entries in dir + filtered []entry // entries matching query + query string // current filter string + cursor int // index within filtered + queryFocused bool // true = typing goes to query; false = list navigation only } func newPicker(startDir string) picker { - p := picker{dir: startDir} + p := picker{dir: startDir, queryFocused: false} p.entries = readDir(startDir) p.filtered = p.entries return p diff --git a/internal/tui/update.go b/internal/tui/update.go index 5670e8f..988f44f 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,6 +1,7 @@ package tui import ( + "path/filepath" "sort" "strings" "time" @@ -86,6 +87,25 @@ func cleanAllCmd(store *services.ServicesStore, serverName string) tea.Cmd { } } +type sendFileMsg struct { + filename string + err error +} + +func sendFileCmd(store *services.ServicesStore, serverName, localPath string) tea.Cmd { + return func() tea.Msg { + storage, err := store.NewStorageService(serverName) + if err != nil { + return sendFileMsg{err: err} + } + filename := filepath.Base(localPath) + if err := storage.Send(localPath); err != nil { + return sendFileMsg{err: err} + } + return sendFileMsg{filename: filename} + } +} + func newCleanInput() textinput.Model { ti := textinput.New() ti.Placeholder = "yes" @@ -261,6 +281,23 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { clearFlashAfter(0), // immediate clear of any stale flash ) + case sendFileMsg: + if msg.err != nil { + // return to server actions with error flash + server := m.ActiveServer + m.FlashMsg = "✗ send failed: " + msg.err.Error() + return m, tea.Batch( + func() tea.Msg { return pages.ServerActionsPageMsg{ServerName: server} }, + clearFlashAfter(4*time.Second), + ) + } + server := m.ActiveServer + m.FlashMsg = "✓ \"" + msg.filename + "\" sent." + return m, tea.Batch( + func() tea.Msg { return pages.ServerActionsPageMsg{ServerName: server} }, + clearFlashAfter(3*time.Second), + ) + case storageFilesMsg: m.StorageLoading = false m.StorageFiles = msg.files @@ -482,6 +519,11 @@ func (m TUIInterface) updateSend(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { p := m.Picker switch msg.String() { + case "tab": + p.queryFocused = !p.queryFocused + m.Picker = p + return m, nil + case "up", "k": if p.cursor > 0 { p.cursor-- @@ -506,16 +548,16 @@ func (m TUIInterface) updateSend(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } // file selected — send it - path := p.selectedPath() - _ = path // TODO: wire to send service + localPath := p.selectedPath() server := m.ActiveServer - return m, func() tea.Msg { return pages.ServerActionsPageMsg{ServerName: server} } + return m, sendFileCmd(m.Services, server, localPath) case "backspace": - newP, consumed := p.backspace() - m.Picker = newP - _ = consumed - return m, nil + if p.queryFocused { + newP, _ := p.backspace() + m.Picker = newP + return m, nil + } case "ctrl+c": m.Quitting = true @@ -526,8 +568,8 @@ func (m TUIInterface) updateSend(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return pages.ServerActionsPageMsg{ServerName: server} } default: - // printable single rune → append to query - if msg.Text != "" { + // printable rune → only append to query when query pane is focused + if msg.Text != "" && p.queryFocused { m.Picker = p.typeRune([]rune(msg.Text)[0]) return m, nil } diff --git a/internal/tui/view.go b/internal/tui/view.go index fae2228..6bb5e5f 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "strings" "filepass/internal/styles" @@ -141,12 +142,12 @@ func (m TUIInterface) View() tea.View { footerSep() + footerHint("esc", "back") case pageSend: - footerStr = footerHint("↑↓", "navigate") + + footerStr = footerHint("tab", "switch pane") + + footerSep() + + footerHint("↑↓", "navigate") + footerSep() + footerHint("enter", "open/send") + footerSep() + - footerHint("backspace", "up a level") + - footerSep() + footerHint("esc", "back") default: footerStr = footerHint("↑↓", "navigate") + @@ -191,6 +192,12 @@ func (m TUIInterface) viewMenu() string { statusLine = styles.StatusWarnStyle.Render("⚠ No servers configured. Select Config to add one.") case m.FlashMsg != "" && m.Page == pageConfig: statusLine = styles.StatusOKStyle.Render(m.FlashMsg) + case m.FlashMsg != "" && m.Page == pageServerActions: + if strings.HasPrefix(m.FlashMsg, "✗") { + statusLine = styles.StatusErrStyle.Render(m.FlashMsg) + } else { + statusLine = styles.StatusOKStyle.Render(m.FlashMsg) + } } if statusLine != "" { @@ -266,8 +273,19 @@ func (m TUIInterface) viewSend() string { // breadcrumb showing current directory crumb := styles.LocalDirStyle.Render(" " + p.dir) - // search input - queryLine := styles.PickerQueryStyle.Render(" / " + p.query + "█") + // search input — shows cursor block and accent colour when focused, dim when not + var queryLine string + if p.queryFocused { + queryLine = styles.PickerQueryStyle.Render(" / " + p.query + "█") + } else { + var queryHint string + if p.query != "" { + queryHint = " / " + p.query + } else { + queryHint = " / (tab to filter)" + } + queryLine = styles.PickerQueryBlurredStyle.Render(queryHint) + } // file/dir entries var rows []string