add:send func
This commit is contained in:
123
internal/tui/picker.go
Normal file
123
internal/tui/picker.go
Normal 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
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
pageSelectServer
|
||||
pageServerActions
|
||||
pageFileAction
|
||||
pageSend
|
||||
)
|
||||
|
||||
type TUIInterface struct {
|
||||
@@ -33,6 +34,7 @@ type TUIInterface struct {
|
||||
WindowHeight int
|
||||
// server actions page
|
||||
ActiveServer string
|
||||
LocalDir string // user's cwd, destination for received files
|
||||
StorageFiles []string
|
||||
StorageLoading bool
|
||||
StorageErr error
|
||||
@@ -40,12 +42,15 @@ type TUIInterface struct {
|
||||
FileFocused bool // true = ↑↓ drives file list, false = action menu
|
||||
// file action page
|
||||
ActiveFile string
|
||||
// send / file picker page
|
||||
Picker picker
|
||||
}
|
||||
|
||||
func NewTUIInterface(store *services.ServicesStore) TUIInterface {
|
||||
func NewTUIInterface(store *services.ServicesStore, localDir string) TUIInterface {
|
||||
return TUIInterface{
|
||||
Services: store,
|
||||
Page: pageHome,
|
||||
MenuItems: pages.HomeMenuItems(),
|
||||
LocalDir: localDir,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,11 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.Selected = 0
|
||||
return m, nil
|
||||
|
||||
case pages.SendPageMsg:
|
||||
m.Page = pageSend
|
||||
m.Picker = newPicker(m.LocalDir)
|
||||
return m, nil
|
||||
|
||||
case storageFilesMsg:
|
||||
m.StorageLoading = false
|
||||
m.StorageFiles = msg.files
|
||||
@@ -166,6 +171,9 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.Page == pageFileAction {
|
||||
return m.updateFileAction(msg)
|
||||
}
|
||||
if m.Page == pageSend {
|
||||
return m.updateSend(msg)
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
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) {
|
||||
switch msg.String() {
|
||||
case "tab":
|
||||
// toggle focus between action menu and file list
|
||||
if len(m.StorageFiles) > 0 {
|
||||
if len(m.StorageFiles) > 0 || !m.StorageLoading {
|
||||
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}
|
||||
}
|
||||
}
|
||||
server := m.ActiveServer
|
||||
switch m.MenuItems[m.Selected].Key {
|
||||
case "send":
|
||||
// TODO: navigate to send page
|
||||
return m, func() tea.Msg { return pages.SendPageMsg{ServerName: server} }
|
||||
case "clean":
|
||||
// TODO: navigate to clean all page
|
||||
}
|
||||
@@ -304,6 +312,64 @@ func (m TUIInterface) updateFileAction(msg tea.KeyPressMsg) (tea.Model, tea.Cmd)
|
||||
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) {
|
||||
last := len(m.ServerNames) - 1
|
||||
switch msg.String() {
|
||||
|
||||
@@ -32,6 +32,8 @@ func (m TUIInterface) subtitle() string {
|
||||
return "Server"
|
||||
case pageFileAction:
|
||||
return m.ActiveFile
|
||||
case pageSend:
|
||||
return "Send File"
|
||||
default:
|
||||
return "Secure file transfer"
|
||||
}
|
||||
@@ -61,6 +63,8 @@ func (m TUIInterface) View() tea.View {
|
||||
body = m.viewServerActions()
|
||||
case pageFileAction:
|
||||
body = m.viewFileAction()
|
||||
case pageSend:
|
||||
body = m.viewSend()
|
||||
default:
|
||||
body = m.viewMenu()
|
||||
}
|
||||
@@ -104,6 +108,14 @@ func (m TUIInterface) View() tea.View {
|
||||
footerHint("enter", "confirm") +
|
||||
footerSep() +
|
||||
footerHint("esc", "back")
|
||||
case pageSend:
|
||||
footerStr = footerHint("↑↓", "navigate") +
|
||||
footerSep() +
|
||||
footerHint("enter", "open/send") +
|
||||
footerSep() +
|
||||
footerHint("backspace", "up a level") +
|
||||
footerSep() +
|
||||
footerHint("esc", "back")
|
||||
default:
|
||||
footerStr = footerHint("↑↓", "navigate") +
|
||||
footerSep() +
|
||||
@@ -156,7 +168,7 @@ func (m TUIInterface) viewMenu() 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
|
||||
for i, item := range m.MenuItems {
|
||||
active := !m.FileFocused && i == m.Selected
|
||||
@@ -164,7 +176,10 @@ func (m TUIInterface) viewServerActions() string {
|
||||
}
|
||||
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
|
||||
switch {
|
||||
case m.StorageLoading:
|
||||
@@ -180,13 +195,14 @@ func (m TUIInterface) viewServerActions() string {
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func (m TUIInterface) viewFileAction() string {
|
||||
// filename shown as a dim label above the menu
|
||||
filenameLabel := styles.FilenameLabelStyle.Render(m.ActiveFile)
|
||||
|
||||
var menuRows []string
|
||||
@@ -198,6 +214,30 @@ func (m TUIInterface) viewFileAction() string {
|
||||
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 {
|
||||
if len(m.ServerNames) == 0 {
|
||||
return styles.StatusWarnStyle.Render("⚠ No servers configured.")
|
||||
|
||||
Reference in New Issue
Block a user