add:get and delete func

This commit is contained in:
2026-04-06 03:12:19 +09:00
parent 5e44a3a35c
commit e39ee1694d
7 changed files with 138 additions and 22 deletions

0
build.sh Normal file → Executable file
View File

2
debug.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/bash
tail -f /tmp/filepass-debug.log

View File

@@ -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.

View File

@@ -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")
} }

View File

@@ -41,7 +41,10 @@ type TUIInterface struct {
FileSelected int // cursor within StorageFiles FileSelected int // cursor within StorageFiles
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
} }

View File

@@ -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

View File

@@ -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 {