diff --git a/build.sh b/build.sh old mode 100644 new mode 100755 diff --git a/debug.sh b/debug.sh new file mode 100755 index 0000000..1015310 --- /dev/null +++ b/debug.sh @@ -0,0 +1,2 @@ +#!/bin/bash +tail -f /tmp/filepass-debug.log diff --git a/internal/services/commands.go b/internal/services/commands.go index a4851a1..174435c 100644 --- a/internal/services/commands.go +++ b/internal/services/commands.go @@ -2,11 +2,18 @@ package services import ( "os/exec" + "strings" ) const defaultPort = "22" const defaultStoragePath = "~/.filepass_storage" +// shellQuote wraps s in single quotes, escaping any single quotes within it. +// This is safe for use in remote shell commands passed over SSH. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + func serverPort(s Server) string { if s.Port == "" { return defaultPort @@ -42,9 +49,10 @@ func RsyncCmd(s Server, src, dst string) *exec.Cmd { ) } -// RemotePath returns the full remote path for a filename inside storage. +// RemotePath returns the full remote rsync path for a filename inside storage. +// Only the filename is shell-quoted so ~ expands correctly on the remote shell. func RemotePath(s Server, filename string) string { - return s.User + "@" + s.Host + ":" + defaultStoragePath + "/" + filename + return s.User + "@" + s.Host + ":" + defaultStoragePath + "/" + shellQuote(filename) } // RemoteStorageRoot returns the remote storage root for rsync operations. diff --git a/internal/services/storage.go b/internal/services/storage.go index 989f596..466204d 100644 --- a/internal/services/storage.go +++ b/internal/services/storage.go @@ -2,9 +2,23 @@ package services import ( "fmt" + "os" + "path/filepath" "strings" + "time" ) +// debugLog appends a timestamped line to /tmp/filepass-debug.log. +// Safe to call from any goroutine; errors are silently ignored. +func debugLog(format string, args ...any) { + f, err := os.OpenFile("/tmp/filepass-debug.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + fmt.Fprintf(f, "[%s] %s\n", time.Now().Format("15:04:05.000"), fmt.Sprintf(format, args...)) +} + // StorageService executes file operations against a single server's storage. type StorageService struct { server Server @@ -30,6 +44,30 @@ func (s *StorageService) Check() ([]string, error) { return strings.Split(raw, "\n"), nil } +// Get downloads a single file from remote storage into destDir. +func (s *StorageService) Get(filename, destDir string) error { + src := RemotePath(s.server, filename) + dst := filepath.Join(destDir, filename) + cmd := RsyncCmd(s.server, src, dst) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("get failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil +} + +// Delete removes a single file from remote storage. +func (s *StorageService) Delete(filename string) error { + remoteCmd := "rm -f " + defaultStoragePath + "/" + shellQuote(filename) + cmd := SSHCmd(s.server, remoteCmd) + debugLog("Delete | args: %v", cmd.Args) + out, err := cmd.CombinedOutput() + debugLog("Delete | exit_err: %v | output: %q", err, strings.TrimSpace(string(out))) + if err != nil { + return fmt.Errorf("delete failed: %w\n%s", err, strings.TrimSpace(string(out))) + } + 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 { @@ -37,16 +75,8 @@ func (s *StorageService) Send(localPaths []string) error { return fmt.Errorf("send: not yet implemented") } -// Get downloads one or more files from remote storage to destDir. -// Multiple files are archived server-side, transferred, then extracted. -func (s *StorageService) Get(remoteFiles []string, destDir string) error { +// CleanAll removes all files from remote storage. +func (s *StorageService) CleanAll() error { // TODO: implement - return fmt.Errorf("get: not yet implemented") -} - -// Clean removes specific files from remote storage. -// Pass a nil or empty slice to remove all files. -func (s *StorageService) Clean(remoteFiles []string) error { - // TODO: implement - return fmt.Errorf("clean: not yet implemented") + return fmt.Errorf("clean all: not yet implemented") } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index b585e63..a2ead9e 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -41,7 +41,10 @@ type TUIInterface struct { FileSelected int // cursor within StorageFiles FileFocused bool // true = ↑↓ drives file list, false = action menu // file action page - ActiveFile string + ActiveFile string + FileOpLoading bool + FileOpErr error + FileOpSuccess string // send / file picker page Picker picker } diff --git a/internal/tui/update.go b/internal/tui/update.go index d517597..b801c78 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -28,6 +28,38 @@ type storageFilesMsg struct { err error } +type fileOpMsg struct { + op string // "get" or "delete" + err error + success string +} + +func getFileCmd(store *services.ServicesStore, serverName, filename, destDir string) tea.Cmd { + return func() tea.Msg { + storage, err := store.NewStorageService(serverName) + if err != nil { + return fileOpMsg{op: "get", err: err} + } + if err := storage.Get(filename, destDir); err != nil { + return fileOpMsg{op: "get", err: err} + } + return fileOpMsg{op: "get", success: "✓ Downloaded \"" + filename + "\""} + } +} + +func deleteFileCmd(store *services.ServicesStore, serverName, filename string) tea.Cmd { + return func() tea.Msg { + storage, err := store.NewStorageService(serverName) + if err != nil { + return fileOpMsg{op: "delete", err: err} + } + if err := storage.Delete(filename); err != nil { + return fileOpMsg{op: "delete", err: err} + } + return fileOpMsg{op: "delete", success: "✓ Deleted \"" + filename + "\""} + } +} + func checkStorageCmd(store *services.ServicesStore, serverName string) tea.Cmd { return func() tea.Msg { storage, err := store.NewStorageService(serverName) @@ -115,6 +147,9 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ActiveFile = msg.Filename m.MenuItems = pages.FileActionItems() m.Selected = 0 + m.FileOpLoading = false + m.FileOpErr = nil + m.FileOpSuccess = "" return m, nil case pages.SendPageMsg: @@ -122,6 +157,19 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Picker = newPicker(m.LocalDir) return m, nil + case fileOpMsg: + m.FileOpLoading = false + if msg.err != nil { + m.FileOpErr = msg.err + return m, nil + } + // on success: return to server actions and refresh file list + server := m.ActiveServer + return m, tea.Batch( + func() tea.Msg { return pages.ServerActionsPageMsg{ServerName: server} }, + clearFlashAfter(0), // immediate clear of any stale flash + ) + case storageFilesMsg: m.StorageLoading = false m.StorageFiles = msg.files @@ -280,6 +328,15 @@ func (m TUIInterface) updateServerActions(msg tea.KeyPressMsg) (tea.Model, tea.C } func (m TUIInterface) updateFileAction(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + // block input while an operation is running + if m.FileOpLoading { + if msg.String() == "ctrl+c" { + m.Quitting = true + return m, tea.Quit + } + return m, nil + } + switch msg.String() { case "up", "k": if m.Selected > 0 { @@ -294,13 +351,15 @@ func (m TUIInterface) updateFileAction(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) file := m.ActiveFile switch m.MenuItems[m.Selected].Key { case "get": - _ = server - _ = file - // TODO: implement get + m.FileOpLoading = true + m.FileOpErr = nil + m.FileOpSuccess = "" + return m, getFileCmd(m.Services, server, file, m.LocalDir) case "delete": - _ = server - _ = file - // TODO: implement delete + m.FileOpLoading = true + m.FileOpErr = nil + m.FileOpSuccess = "" + return m, deleteFileCmd(m.Services, server, file) } case "ctrl+c": m.Quitting = true diff --git a/internal/tui/view.go b/internal/tui/view.go index 2609b0b..bda3dc0 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -207,11 +207,25 @@ func (m TUIInterface) viewFileAction() string { var menuRows []string for i, item := range m.MenuItems { - menuRows = append(menuRows, styles.MenuItemStyle(i == m.Selected, false).Render(item.Label)) + disabled := m.FileOpLoading + active := !disabled && i == m.Selected + menuRows = append(menuRows, styles.MenuItemStyle(active, disabled).Render(item.Label)) } menu := lipgloss.JoinVertical(lipgloss.Left, menuRows...) - return lipgloss.JoinVertical(lipgloss.Left, filenameLabel, menu) + var statusLine string + switch { + case m.FileOpLoading: + statusLine = styles.StatusWarnStyle.Render(" working…") + case m.FileOpErr != nil: + statusLine = styles.StatusErrStyle.Render("✗ " + m.FileOpErr.Error()) + } + + parts := []string{filenameLabel, menu} + if statusLine != "" { + parts = append(parts, statusLine) + } + return lipgloss.JoinVertical(lipgloss.Left, parts...) } func (m TUIInterface) viewSend() string {