add:send func

This commit is contained in:
2026-04-06 02:57:22 +09:00
parent 2760826b26
commit 5e44a3a35c
8 changed files with 342 additions and 11 deletions

47
build.sh Normal file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
#!/usr/bin/env bash
set -e
LOCAL_BIN="$HOME/.local/bin"
EXPORT_LINE='export PATH="$HOME/.local/bin:$PATH"'
# Files to check (macOS + Linux)
FILES=(
"$HOME/.bashrc"
"$HOME/.bash_profile"
"$HOME/.zshrc"
)
echo "Ensuring ~/.local/bin exists..."
mkdir -p "$LOCAL_BIN"
add_to_file() {
local file="$1"
# Create file if it doesn't exist
[ -f "$file" ] || touch "$file"
if grep -qxF "$EXPORT_LINE" "$file"; then
echo "✓ PATH already set in $(basename "$file")"
else
echo "→ Adding PATH to $(basename "$file")"
{
echo ""
echo "# Add local bin to PATH"
echo "$EXPORT_LINE"
} >> "$file"
fi
}
echo "Updating shell config files..."
for file in "${FILES[@]}"; do
add_to_file "$file"
done
echo ""
echo "Done!"
echo "Restart your shell or run:"
echo " source ~/.zshrc # for zsh"
echo " source ~/.bashrc # for bash"

5
internal/pages/send.go Normal file
View File

@@ -0,0 +1,5 @@
package pages
type SendPageMsg struct {
ServerName string
}

View File

@@ -146,6 +146,31 @@ var (
FilenameLabelStyle = lipgloss.NewStyle(). FilenameLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("243")). Foreground(lipgloss.Color("243")).
MarginBottom(1) MarginBottom(1)
// Local directory label (above file list and in picker breadcrumb)
LocalDirStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("243")).
Italic(true).
MarginBottom(1)
// File picker
PickerQueryStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("75")).
MarginBottom(1)
pickerItemBase = lipgloss.NewStyle().
PaddingLeft(4).
Width(44)
pickerItemActive = lipgloss.NewStyle().
PaddingLeft(2).
Foreground(lipgloss.Color("75")).
Bold(true).
Width(44).
SetString("▸ ")
pickerDirColor = lipgloss.Color("75")
pickerFileColor = lipgloss.Color("252")
) )
func MenuItemStyle(active, disabled bool) lipgloss.Style { func MenuItemStyle(active, disabled bool) lipgloss.Style {
@@ -187,6 +212,21 @@ func FileItemStyle(active bool) lipgloss.Style {
return fileItemInactive return fileItemInactive
} }
// PickerItemStyle returns the style for a file picker entry.
// Directories are coloured differently from files.
func PickerItemStyle(active, isDir bool) lipgloss.Style {
if active {
if isDir {
return pickerItemActive.Foreground(pickerDirColor)
}
return pickerItemActive.Foreground(lipgloss.Color("255"))
}
if isDir {
return pickerItemBase.Foreground(pickerDirColor)
}
return pickerItemBase.Foreground(pickerFileColor)
}
// ServerRowStyle renders a single-line server list entry showing only the server name. // ServerRowStyle renders a single-line server list entry showing only the server name.
func ServerRowStyle(active bool, name string) string { func ServerRowStyle(active bool, name string) string {
if active { if active {

123
internal/tui/picker.go Normal file
View File

@@ -0,0 +1,123 @@
package tui
import (
"os"
"path/filepath"
"strings"
)
// entry is a single item in the file picker list.
type entry struct {
name string
isDir bool
}
// 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
}
func newPicker(startDir string) picker {
p := picker{dir: startDir}
p.entries = readDir(startDir)
p.filtered = p.entries
return p
}
// readDir lists the entries of a directory, dirs first then files.
func readDir(dir string) []entry {
infos, err := os.ReadDir(dir)
if err != nil {
return nil
}
var dirs, files []entry
for _, d := range infos {
name := d.Name()
if strings.HasPrefix(name, ".") {
continue // skip hidden
}
if d.IsDir() {
dirs = append(dirs, entry{name: name + "/", isDir: true})
} else {
files = append(files, entry{name: name, isDir: false})
}
}
return append(dirs, files...)
}
// applyFilter rebuilds filtered from entries using query.
func (p picker) applyFilter() picker {
if p.query == "" {
p.filtered = p.entries
} else {
q := strings.ToLower(p.query)
var out []entry
for _, e := range p.entries {
if strings.Contains(strings.ToLower(e.name), q) {
out = append(out, e)
}
}
p.filtered = out
}
p.cursor = 0
return p
}
// descend enters a subdirectory.
func (p picker) descend(name string) picker {
// strip trailing slash added for display
name = strings.TrimSuffix(name, "/")
next := filepath.Join(p.dir, name)
p.dir = next
p.entries = readDir(next)
p.query = ""
p.filtered = p.entries
p.cursor = 0
return p
}
// ascend goes up one directory level.
func (p picker) ascend() picker {
parent := filepath.Dir(p.dir)
if parent == p.dir {
return p // already at root
}
p.dir = parent
p.entries = readDir(parent)
p.query = ""
p.filtered = p.entries
p.cursor = 0
return p
}
// selectedPath returns the full path of the currently highlighted entry,
// or empty string if the list is empty.
func (p picker) selectedPath() string {
if len(p.filtered) == 0 || p.cursor < 0 || p.cursor >= len(p.filtered) {
return ""
}
e := p.filtered[p.cursor]
name := strings.TrimSuffix(e.name, "/")
return filepath.Join(p.dir, name)
}
// typeRune appends a rune to the query and re-filters.
func (p picker) typeRune(r rune) picker {
p.query += string(r)
return p.applyFilter()
}
// backspace removes the last rune from the query.
// If query is already empty, ascend instead.
func (p picker) backspace() (picker, bool) {
if p.query == "" {
return p.ascend(), false // false = did not consume (went up)
}
runes := []rune(p.query)
p.query = string(runes[:len(runes)-1])
return p.applyFilter(), true
}

View File

@@ -14,6 +14,7 @@ const (
pageSelectServer pageSelectServer
pageServerActions pageServerActions
pageFileAction pageFileAction
pageSend
) )
type TUIInterface struct { type TUIInterface struct {
@@ -33,6 +34,7 @@ type TUIInterface struct {
WindowHeight int WindowHeight int
// server actions page // server actions page
ActiveServer string ActiveServer string
LocalDir string // user's cwd, destination for received files
StorageFiles []string StorageFiles []string
StorageLoading bool StorageLoading bool
StorageErr error StorageErr error
@@ -40,12 +42,15 @@ 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
// send / file picker page
Picker picker
} }
func NewTUIInterface(store *services.ServicesStore) TUIInterface { func NewTUIInterface(store *services.ServicesStore, localDir string) TUIInterface {
return TUIInterface{ return TUIInterface{
Services: store, Services: store,
Page: pageHome, Page: pageHome,
MenuItems: pages.HomeMenuItems(), MenuItems: pages.HomeMenuItems(),
LocalDir: localDir,
} }
} }

View File

@@ -117,6 +117,11 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.Selected = 0 m.Selected = 0
return m, nil return m, nil
case pages.SendPageMsg:
m.Page = pageSend
m.Picker = newPicker(m.LocalDir)
return m, nil
case storageFilesMsg: case storageFilesMsg:
m.StorageLoading = false m.StorageLoading = false
m.StorageFiles = msg.files m.StorageFiles = msg.files
@@ -166,6 +171,9 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.Page == pageFileAction { if m.Page == pageFileAction {
return m.updateFileAction(msg) return m.updateFileAction(msg)
} }
if m.Page == pageSend {
return m.updateSend(msg)
}
switch msg.String() { switch msg.String() {
case "up", "k": case "up", "k":
@@ -218,8 +226,7 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m TUIInterface) updateServerActions(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func (m TUIInterface) updateServerActions(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "tab": case "tab":
// toggle focus between action menu and file list if len(m.StorageFiles) > 0 || !m.StorageLoading {
if len(m.StorageFiles) > 0 {
m.FileFocused = !m.FileFocused m.FileFocused = !m.FileFocused
} }
@@ -253,9 +260,10 @@ func (m TUIInterface) updateServerActions(msg tea.KeyPressMsg) (tea.Model, tea.C
return pages.FileActionPageMsg{ServerName: server, Filename: file} return pages.FileActionPageMsg{ServerName: server, Filename: file}
} }
} }
server := m.ActiveServer
switch m.MenuItems[m.Selected].Key { switch m.MenuItems[m.Selected].Key {
case "send": case "send":
// TODO: navigate to send page return m, func() tea.Msg { return pages.SendPageMsg{ServerName: server} }
case "clean": case "clean":
// TODO: navigate to clean all page // TODO: navigate to clean all page
} }
@@ -304,6 +312,64 @@ func (m TUIInterface) updateFileAction(msg tea.KeyPressMsg) (tea.Model, tea.Cmd)
return m, nil return m, nil
} }
func (m TUIInterface) updateSend(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
p := m.Picker
switch msg.String() {
case "up", "k":
if p.cursor > 0 {
p.cursor--
}
m.Picker = p
return m, nil
case "down", "j":
if p.cursor < len(p.filtered)-1 {
p.cursor++
}
m.Picker = p
return m, nil
case "enter":
if len(p.filtered) == 0 {
return m, nil
}
selected := p.filtered[p.cursor]
if selected.isDir {
m.Picker = p.descend(selected.name)
return m, nil
}
// file selected — send it
path := p.selectedPath()
_ = path // TODO: wire to send service
server := m.ActiveServer
return m, func() tea.Msg { return pages.ServerActionsPageMsg{ServerName: server} }
case "backspace":
newP, consumed := p.backspace()
m.Picker = newP
_ = consumed
return m, nil
case "ctrl+c":
m.Quitting = true
return m, tea.Quit
case "esc":
server := m.ActiveServer
return m, func() tea.Msg { return pages.ServerActionsPageMsg{ServerName: server} }
default:
// printable single rune → append to query
if msg.Text != "" {
m.Picker = p.typeRune([]rune(msg.Text)[0])
return m, nil
}
}
return m, nil
}
func (m TUIInterface) updateSelectServer(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func (m TUIInterface) updateSelectServer(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
last := len(m.ServerNames) - 1 last := len(m.ServerNames) - 1
switch msg.String() { switch msg.String() {

View File

@@ -32,6 +32,8 @@ func (m TUIInterface) subtitle() string {
return "Server" return "Server"
case pageFileAction: case pageFileAction:
return m.ActiveFile return m.ActiveFile
case pageSend:
return "Send File"
default: default:
return "Secure file transfer" return "Secure file transfer"
} }
@@ -61,6 +63,8 @@ func (m TUIInterface) View() tea.View {
body = m.viewServerActions() body = m.viewServerActions()
case pageFileAction: case pageFileAction:
body = m.viewFileAction() body = m.viewFileAction()
case pageSend:
body = m.viewSend()
default: default:
body = m.viewMenu() body = m.viewMenu()
} }
@@ -104,6 +108,14 @@ func (m TUIInterface) View() tea.View {
footerHint("enter", "confirm") + footerHint("enter", "confirm") +
footerSep() + footerSep() +
footerHint("esc", "back") footerHint("esc", "back")
case pageSend:
footerStr = footerHint("↑↓", "navigate") +
footerSep() +
footerHint("enter", "open/send") +
footerSep() +
footerHint("backspace", "up a level") +
footerSep() +
footerHint("esc", "back")
default: default:
footerStr = footerHint("↑↓", "navigate") + footerStr = footerHint("↑↓", "navigate") +
footerSep() + footerSep() +
@@ -156,7 +168,7 @@ func (m TUIInterface) viewMenu() string {
} }
func (m TUIInterface) viewServerActions() string { func (m TUIInterface) viewServerActions() string {
// action menu — single column, unfocused when file pane is active // action menu — single column, cursor only shown when pane is focused
var actionRows []string var actionRows []string
for i, item := range m.MenuItems { for i, item := range m.MenuItems {
active := !m.FileFocused && i == m.Selected active := !m.FileFocused && i == m.Selected
@@ -164,7 +176,10 @@ func (m TUIInterface) viewServerActions() string {
} }
actions := lipgloss.JoinVertical(lipgloss.Left, actionRows...) actions := lipgloss.JoinVertical(lipgloss.Left, actionRows...)
// file list section below, separated by a top border // static local dir label — always visible above the file list
localDirLabel := styles.LocalDirStyle.Render(" ↓ " + m.LocalDir)
// file list rows
var fileRows []string var fileRows []string
switch { switch {
case m.StorageLoading: case m.StorageLoading:
@@ -180,13 +195,14 @@ func (m TUIInterface) viewServerActions() string {
} }
} }
fileList := lipgloss.JoinVertical(lipgloss.Left, fileRows...) fileList := lipgloss.JoinVertical(lipgloss.Left, fileRows...)
fileSection := styles.StorageFileSectionStyle.Render(fileList) fileSection := styles.StorageFileSectionStyle.Render(
lipgloss.JoinVertical(lipgloss.Left, localDirLabel, fileList),
)
return lipgloss.JoinVertical(lipgloss.Left, actions, fileSection) return lipgloss.JoinVertical(lipgloss.Left, actions, fileSection)
} }
func (m TUIInterface) viewFileAction() string { func (m TUIInterface) viewFileAction() string {
// filename shown as a dim label above the menu
filenameLabel := styles.FilenameLabelStyle.Render(m.ActiveFile) filenameLabel := styles.FilenameLabelStyle.Render(m.ActiveFile)
var menuRows []string var menuRows []string
@@ -198,6 +214,30 @@ func (m TUIInterface) viewFileAction() string {
return lipgloss.JoinVertical(lipgloss.Left, filenameLabel, menu) return lipgloss.JoinVertical(lipgloss.Left, filenameLabel, menu)
} }
func (m TUIInterface) viewSend() string {
p := m.Picker
// breadcrumb showing current directory
crumb := styles.LocalDirStyle.Render(" " + p.dir)
// search input
queryLine := styles.PickerQueryStyle.Render(" / " + p.query + "█")
// file/dir entries
var rows []string
if len(p.filtered) == 0 {
rows = append(rows, styles.StorageEmptyStyle.Render(" no matches"))
} else {
for i, e := range p.filtered {
active := i == p.cursor
rows = append(rows, styles.PickerItemStyle(active, e.isDir).Render(e.name))
}
}
list := lipgloss.JoinVertical(lipgloss.Left, rows...)
return lipgloss.JoinVertical(lipgloss.Left, crumb, queryLine, list)
}
func (m TUIInterface) viewSelectServer() string { func (m TUIInterface) viewSelectServer() string {
if len(m.ServerNames) == 0 { if len(m.ServerNames) == 0 {
return styles.StatusWarnStyle.Render("⚠ No servers configured.") return styles.StatusWarnStyle.Render("⚠ No servers configured.")

11
main.go
View File

@@ -15,8 +15,13 @@ func main() {
if _, err := exec.LookPath("rsync"); err != nil { if _, err := exec.LookPath("rsync"); err != nil {
fmt.Fprintln(os.Stderr, "error: rsync is required but was not found in PATH") fmt.Fprintln(os.Stderr, "error: rsync is required but was not found in PATH")
fmt.Fprintln(os.Stderr, "install it with your package manager, e.g.:") fmt.Fprintln(os.Stderr, "install it with your package manager, e.g.:")
fmt.Fprintln(os.Stderr, " brew install rsync") fmt.Fprintln(os.Stderr, " sudo pacman -S rsync")
fmt.Fprintln(os.Stderr, " apt install rsync") os.Exit(1)
}
localDir, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, "failed to get working directory:", err)
os.Exit(1) os.Exit(1)
} }
@@ -26,7 +31,7 @@ func main() {
os.Exit(1) os.Exit(1)
} }
m := tui.NewTUIInterface(store) m := tui.NewTUIInterface(store, localDir)
p := tea.NewProgram(m) p := tea.NewProgram(m)
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)