add:get and delete func
This commit is contained in:
@@ -2,11 +2,18 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultPort = "22"
|
const defaultPort = "22"
|
||||||
const defaultStoragePath = "~/.filepass_storage"
|
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 {
|
func serverPort(s Server) string {
|
||||||
if s.Port == "" {
|
if s.Port == "" {
|
||||||
return defaultPort
|
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 {
|
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.
|
// RemoteStorageRoot returns the remote storage root for rsync operations.
|
||||||
|
|||||||
@@ -2,9 +2,23 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"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.
|
// StorageService executes file operations against a single server's storage.
|
||||||
type StorageService struct {
|
type StorageService struct {
|
||||||
server Server
|
server Server
|
||||||
@@ -30,6 +44,30 @@ func (s *StorageService) Check() ([]string, error) {
|
|||||||
return strings.Split(raw, "\n"), nil
|
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.
|
// Send transfers one or more local files to the remote storage.
|
||||||
// Multiple files are archived into a temp tarball first.
|
// Multiple files are archived into a temp tarball first.
|
||||||
func (s *StorageService) Send(localPaths []string) error {
|
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")
|
return fmt.Errorf("send: not yet implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get downloads one or more files from remote storage to destDir.
|
// CleanAll removes all files from remote storage.
|
||||||
// Multiple files are archived server-side, transferred, then extracted.
|
func (s *StorageService) CleanAll() error {
|
||||||
func (s *StorageService) Get(remoteFiles []string, destDir string) error {
|
|
||||||
// TODO: implement
|
// TODO: implement
|
||||||
return fmt.Errorf("get: not yet implemented")
|
return fmt.Errorf("clean all: 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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ type TUIInterface struct {
|
|||||||
FileFocused bool // true = ↑↓ drives file list, false = action menu
|
FileFocused bool // true = ↑↓ drives file list, false = action menu
|
||||||
// file action page
|
// file action page
|
||||||
ActiveFile string
|
ActiveFile string
|
||||||
|
FileOpLoading bool
|
||||||
|
FileOpErr error
|
||||||
|
FileOpSuccess string
|
||||||
// send / file picker page
|
// send / file picker page
|
||||||
Picker picker
|
Picker picker
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,38 @@ type storageFilesMsg struct {
|
|||||||
err error
|
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 {
|
func checkStorageCmd(store *services.ServicesStore, serverName string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
storage, err := store.NewStorageService(serverName)
|
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.ActiveFile = msg.Filename
|
||||||
m.MenuItems = pages.FileActionItems()
|
m.MenuItems = pages.FileActionItems()
|
||||||
m.Selected = 0
|
m.Selected = 0
|
||||||
|
m.FileOpLoading = false
|
||||||
|
m.FileOpErr = nil
|
||||||
|
m.FileOpSuccess = ""
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case pages.SendPageMsg:
|
case pages.SendPageMsg:
|
||||||
@@ -122,6 +157,19 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.Picker = newPicker(m.LocalDir)
|
m.Picker = newPicker(m.LocalDir)
|
||||||
return m, nil
|
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:
|
case storageFilesMsg:
|
||||||
m.StorageLoading = false
|
m.StorageLoading = false
|
||||||
m.StorageFiles = msg.files
|
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) {
|
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() {
|
switch msg.String() {
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if m.Selected > 0 {
|
if m.Selected > 0 {
|
||||||
@@ -294,13 +351,15 @@ func (m TUIInterface) updateFileAction(msg tea.KeyPressMsg) (tea.Model, tea.Cmd)
|
|||||||
file := m.ActiveFile
|
file := m.ActiveFile
|
||||||
switch m.MenuItems[m.Selected].Key {
|
switch m.MenuItems[m.Selected].Key {
|
||||||
case "get":
|
case "get":
|
||||||
_ = server
|
m.FileOpLoading = true
|
||||||
_ = file
|
m.FileOpErr = nil
|
||||||
// TODO: implement get
|
m.FileOpSuccess = ""
|
||||||
|
return m, getFileCmd(m.Services, server, file, m.LocalDir)
|
||||||
case "delete":
|
case "delete":
|
||||||
_ = server
|
m.FileOpLoading = true
|
||||||
_ = file
|
m.FileOpErr = nil
|
||||||
// TODO: implement delete
|
m.FileOpSuccess = ""
|
||||||
|
return m, deleteFileCmd(m.Services, server, file)
|
||||||
}
|
}
|
||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
m.Quitting = true
|
m.Quitting = true
|
||||||
|
|||||||
@@ -207,11 +207,25 @@ func (m TUIInterface) viewFileAction() string {
|
|||||||
|
|
||||||
var menuRows []string
|
var menuRows []string
|
||||||
for i, item := range m.MenuItems {
|
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...)
|
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 {
|
func (m TUIInterface) viewSend() string {
|
||||||
|
|||||||
Reference in New Issue
Block a user