Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e1dce6082 | |||
| dc72658fe4 | |||
| 753329e2c8 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
.env
|
.env
|
||||||
dist
|
dist
|
||||||
|
.pi
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ package pages
|
|||||||
|
|
||||||
type FileActionPageMsg struct {
|
type FileActionPageMsg struct {
|
||||||
ServerName string
|
ServerName string
|
||||||
Filename string
|
Filename string // single-file mode
|
||||||
|
Filenames []string // multi-file mode
|
||||||
}
|
}
|
||||||
|
|
||||||
func FileActionItems() []MenuItem {
|
func FileActionItems() []MenuItem {
|
||||||
|
|||||||
@@ -10,16 +10,14 @@ var (
|
|||||||
Width(52)
|
Width(52)
|
||||||
|
|
||||||
CardInnerStyle = lipgloss.NewStyle().
|
CardInnerStyle = lipgloss.NewStyle().
|
||||||
Padding(1, 3)
|
Padding(0, 3)
|
||||||
|
|
||||||
CardTitleStyle = lipgloss.NewStyle().
|
CardTitleStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("86")).
|
Foreground(lipgloss.Color("86")).
|
||||||
Bold(true).
|
Bold(true)
|
||||||
MarginBottom(1)
|
|
||||||
|
|
||||||
CardSubtitleStyle = lipgloss.NewStyle().
|
CardSubtitleStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("245")).
|
Foreground(lipgloss.Color("245"))
|
||||||
MarginBottom(1)
|
|
||||||
|
|
||||||
// Menu items
|
// Menu items
|
||||||
menuItemBase = lipgloss.NewStyle().
|
menuItemBase = lipgloss.NewStyle().
|
||||||
@@ -41,55 +39,45 @@ var (
|
|||||||
// Form fields
|
// Form fields
|
||||||
fieldLabelRequired = lipgloss.NewStyle().
|
fieldLabelRequired = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("75")).
|
Foreground(lipgloss.Color("75")).
|
||||||
Bold(true).
|
Bold(true)
|
||||||
MarginTop(1)
|
|
||||||
|
|
||||||
fieldLabelOptional = lipgloss.NewStyle().
|
fieldLabelOptional = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("245")).
|
Foreground(lipgloss.Color("245"))
|
||||||
MarginTop(1)
|
|
||||||
|
|
||||||
FieldLegendStyle = lipgloss.NewStyle().
|
FieldLegendStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("240")).
|
Foreground(lipgloss.Color("240")).
|
||||||
Italic(true).
|
Italic(true)
|
||||||
MarginTop(1)
|
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
buttonActive = lipgloss.NewStyle().
|
buttonActive = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("232")).
|
Foreground(lipgloss.Color("232")).
|
||||||
Background(lipgloss.Color("75")).
|
Background(lipgloss.Color("75")).
|
||||||
Bold(true).
|
Bold(true).
|
||||||
Padding(0, 2).
|
Padding(0, 2)
|
||||||
MarginTop(1)
|
|
||||||
|
|
||||||
buttonInactive = lipgloss.NewStyle().
|
buttonInactive = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("232")).
|
Foreground(lipgloss.Color("232")).
|
||||||
Background(lipgloss.Color("240")).
|
Background(lipgloss.Color("240")).
|
||||||
Padding(0, 2).
|
Padding(0, 2)
|
||||||
MarginTop(1)
|
|
||||||
|
|
||||||
buttonLocked = lipgloss.NewStyle().
|
buttonLocked = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("238")).
|
Foreground(lipgloss.Color("238")).
|
||||||
Background(lipgloss.Color("235")).
|
Background(lipgloss.Color("235")).
|
||||||
Padding(0, 2).
|
Padding(0, 2)
|
||||||
MarginTop(1)
|
|
||||||
|
|
||||||
// Status lines
|
// Status lines
|
||||||
StatusOKStyle = lipgloss.NewStyle().
|
StatusOKStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("86")).
|
Foreground(lipgloss.Color("86"))
|
||||||
MarginTop(1)
|
|
||||||
|
|
||||||
StatusWarnStyle = lipgloss.NewStyle().
|
StatusWarnStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("221")).
|
Foreground(lipgloss.Color("221"))
|
||||||
MarginTop(1)
|
|
||||||
|
|
||||||
StatusErrStyle = lipgloss.NewStyle().
|
StatusErrStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("203")).
|
Foreground(lipgloss.Color("203"))
|
||||||
MarginTop(1)
|
|
||||||
|
|
||||||
CleanWarningStyle = lipgloss.NewStyle().
|
CleanWarningStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("203")).
|
Foreground(lipgloss.Color("203")).
|
||||||
Bold(true).
|
Bold(true).
|
||||||
MarginBottom(1).
|
|
||||||
Width(44)
|
Width(44)
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
@@ -129,8 +117,6 @@ var (
|
|||||||
BorderTop(true).
|
BorderTop(true).
|
||||||
BorderStyle(lipgloss.NormalBorder()).
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
BorderForeground(lipgloss.Color("237")).
|
BorderForeground(lipgloss.Color("237")).
|
||||||
MarginTop(1).
|
|
||||||
PaddingTop(1).
|
|
||||||
Width(44)
|
Width(44)
|
||||||
|
|
||||||
StorageEmptyStyle = lipgloss.NewStyle().
|
StorageEmptyStyle = lipgloss.NewStyle().
|
||||||
@@ -150,23 +136,19 @@ var (
|
|||||||
SetString("▸ ")
|
SetString("▸ ")
|
||||||
|
|
||||||
FilenameLabelStyle = lipgloss.NewStyle().
|
FilenameLabelStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("243")).
|
Foreground(lipgloss.Color("243"))
|
||||||
MarginBottom(1)
|
|
||||||
|
|
||||||
// Local directory label (above file list and in picker breadcrumb)
|
// Local directory label (above file list and in picker breadcrumb)
|
||||||
LocalDirStyle = lipgloss.NewStyle().
|
LocalDirStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("243")).
|
Foreground(lipgloss.Color("243")).
|
||||||
Italic(true).
|
Italic(true)
|
||||||
MarginBottom(1)
|
|
||||||
|
|
||||||
// File picker
|
// File picker
|
||||||
PickerQueryStyle = lipgloss.NewStyle().
|
PickerQueryStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("75")).
|
Foreground(lipgloss.Color("75"))
|
||||||
MarginBottom(1)
|
|
||||||
|
|
||||||
PickerQueryBlurredStyle = lipgloss.NewStyle().
|
PickerQueryBlurredStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("240")).
|
Foreground(lipgloss.Color("240"))
|
||||||
MarginBottom(1)
|
|
||||||
|
|
||||||
pickerItemBase = lipgloss.NewStyle().
|
pickerItemBase = lipgloss.NewStyle().
|
||||||
PaddingLeft(4).
|
PaddingLeft(4).
|
||||||
@@ -181,6 +163,9 @@ var (
|
|||||||
|
|
||||||
pickerDirColor = lipgloss.Color("75")
|
pickerDirColor = lipgloss.Color("75")
|
||||||
pickerFileColor = lipgloss.Color("252")
|
pickerFileColor = lipgloss.Color("252")
|
||||||
|
|
||||||
|
ScrollIndicatorStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("240"))
|
||||||
)
|
)
|
||||||
|
|
||||||
func MenuItemStyle(active, disabled bool) lipgloss.Style {
|
func MenuItemStyle(active, disabled bool) lipgloss.Style {
|
||||||
|
|||||||
88
internal/tui/scroll.go
Normal file
88
internal/tui/scroll.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
// visibleRange returns [start, end) indices of rows that should be rendered.
|
||||||
|
func visibleRange(total, selected, scrollOff, viewHeight int) (int, int) {
|
||||||
|
if total <= 0 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
maxVis := viewHeight
|
||||||
|
if maxVis <= 0 {
|
||||||
|
maxVis = total // unconstrained
|
||||||
|
}
|
||||||
|
if maxVis >= total {
|
||||||
|
return 0, total
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve lines for scroll indicators.
|
||||||
|
hasAbove := scrollOff > 0
|
||||||
|
hasBelow := scrollOff+maxVis < total
|
||||||
|
if hasAbove {
|
||||||
|
maxVis--
|
||||||
|
}
|
||||||
|
if hasBelow {
|
||||||
|
maxVis--
|
||||||
|
}
|
||||||
|
if maxVis < 1 {
|
||||||
|
maxVis = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
start := scrollOff
|
||||||
|
end := start + maxVis
|
||||||
|
|
||||||
|
if selected < start {
|
||||||
|
start = selected
|
||||||
|
end = start + maxVis
|
||||||
|
}
|
||||||
|
if selected >= end {
|
||||||
|
end = selected + 1
|
||||||
|
start = end - maxVis
|
||||||
|
}
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
end = min(maxVis, total)
|
||||||
|
}
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
start = max(0, end-maxVis)
|
||||||
|
}
|
||||||
|
|
||||||
|
return start, end
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncScroll adjusts scrollOff so selected stays inside the viewport.
|
||||||
|
func syncScroll(selected, total, viewHeight, scrollOff int) int {
|
||||||
|
if total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
maxVis := viewHeight
|
||||||
|
if maxVis <= 0 || maxVis >= total {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected < 0 {
|
||||||
|
selected = 0
|
||||||
|
}
|
||||||
|
if selected >= total {
|
||||||
|
selected = total - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if scrollOff < 0 {
|
||||||
|
scrollOff = 0
|
||||||
|
}
|
||||||
|
if scrollOff > total-1 {
|
||||||
|
scrollOff = total - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected < scrollOff {
|
||||||
|
scrollOff = selected
|
||||||
|
}
|
||||||
|
if selected >= scrollOff+maxVis {
|
||||||
|
scrollOff = selected - maxVis + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if scrollOff < 0 {
|
||||||
|
scrollOff = 0
|
||||||
|
}
|
||||||
|
return scrollOff
|
||||||
|
}
|
||||||
@@ -46,8 +46,12 @@ type TUIInterface struct {
|
|||||||
StorageErr error
|
StorageErr error
|
||||||
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
|
||||||
|
FileScrollOff int // first visible row in StorageFiles list
|
||||||
|
FileViewHeight int // available visible rows for StorageFiles list
|
||||||
|
FileMultiSelect map[int]bool // selected rows in StorageFiles
|
||||||
// file action page
|
// file action page
|
||||||
ActiveFile string
|
ActiveFile string
|
||||||
|
ActiveFiles []string
|
||||||
FileOpLoading bool
|
FileOpLoading bool
|
||||||
FileOpErr error
|
FileOpErr error
|
||||||
FileOpSuccess string
|
FileOpSuccess string
|
||||||
@@ -67,5 +71,35 @@ func NewTUIInterface(store *services.ServicesStore, localDir string) TUIInterfac
|
|||||||
Page: pageHome,
|
Page: pageHome,
|
||||||
MenuItems: pages.HomeMenuItems(),
|
MenuItems: pages.HomeMenuItems(),
|
||||||
LocalDir: localDir,
|
LocalDir: localDir,
|
||||||
|
FileMultiSelect: make(map[int]bool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fileListHeight calculates how many rows can fit in the server storage file list.
|
||||||
|
func (m TUIInterface) fileListHeight() int {
|
||||||
|
h := m.WindowHeight
|
||||||
|
if h == 0 {
|
||||||
|
return 0 // unconstrained until first WindowSizeMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card chrome overhead (rounded border + compact inner vertical padding)
|
||||||
|
const cardOverhead = 2
|
||||||
|
// Header (title + subtitle)
|
||||||
|
const headerLines = 2
|
||||||
|
// Footer (top border + content)
|
||||||
|
const footerLines = 2
|
||||||
|
// Server actions menu rows (Send / Clean All)
|
||||||
|
actionLines := len(m.MenuItems)
|
||||||
|
if actionLines < 1 {
|
||||||
|
actionLines = 2
|
||||||
|
}
|
||||||
|
// File section chrome: top border + local-dir label
|
||||||
|
const fileSectionOverhead = 2
|
||||||
|
|
||||||
|
used := cardOverhead + headerLines + footerLines + actionLines + fileSectionOverhead
|
||||||
|
available := h - used
|
||||||
|
if available < 1 {
|
||||||
|
available = 1
|
||||||
|
}
|
||||||
|
return available
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -73,6 +74,62 @@ func deleteFileCmd(store *services.ServicesStore, serverName, filename string) t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getFilesCmd(store *services.ServicesStore, serverName string, filenames []string, destDir string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
storage, err := store.NewStorageService(serverName)
|
||||||
|
if err != nil {
|
||||||
|
return fileOpMsg{op: "get", err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
success := 0
|
||||||
|
var failed []string
|
||||||
|
for _, name := range filenames {
|
||||||
|
if err := storage.Get(name, destDir); err != nil {
|
||||||
|
failed = append(failed, fmt.Sprintf("%s: %v", name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
success++
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(failed) > 0 {
|
||||||
|
if success == 0 {
|
||||||
|
return fileOpMsg{op: "get", err: fmt.Errorf("download failed for %d file(s): %s", len(failed), strings.Join(failed, "; "))}
|
||||||
|
}
|
||||||
|
return fileOpMsg{op: "get", err: fmt.Errorf("downloaded %d/%d file(s), failed %d: %s", success, len(filenames), len(failed), strings.Join(failed, "; "))}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileOpMsg{op: "get", success: fmt.Sprintf("✓ Downloaded %d file(s)", success)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteFilesCmd(store *services.ServicesStore, serverName string, filenames []string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
storage, err := store.NewStorageService(serverName)
|
||||||
|
if err != nil {
|
||||||
|
return fileOpMsg{op: "delete", err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
success := 0
|
||||||
|
var failed []string
|
||||||
|
for _, name := range filenames {
|
||||||
|
if err := storage.Delete(name); err != nil {
|
||||||
|
failed = append(failed, fmt.Sprintf("%s: %v", name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
success++
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(failed) > 0 {
|
||||||
|
if success == 0 {
|
||||||
|
return fileOpMsg{op: "delete", err: fmt.Errorf("delete failed for %d file(s): %s", len(failed), strings.Join(failed, "; "))}
|
||||||
|
}
|
||||||
|
return fileOpMsg{op: "delete", err: fmt.Errorf("deleted %d/%d file(s), failed %d: %s", success, len(filenames), len(failed), strings.Join(failed, "; "))}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileOpMsg{op: "delete", success: fmt.Sprintf("✓ Deleted %d file(s)", success)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type cleanAllMsg struct {
|
type cleanAllMsg struct {
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
@@ -160,6 +217,28 @@ func (m TUIInterface) nextSelectable(from, dir int) int {
|
|||||||
return from
|
return from
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m TUIInterface) selectedStorageFiles() []string {
|
||||||
|
if len(m.FileMultiSelect) == 0 || len(m.StorageFiles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
idxs := make([]int, 0, len(m.FileMultiSelect))
|
||||||
|
for idx, selected := range m.FileMultiSelect {
|
||||||
|
if !selected {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if idx < 0 || idx >= len(m.StorageFiles) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idxs = append(idxs, idx)
|
||||||
|
}
|
||||||
|
sort.Ints(idxs)
|
||||||
|
files := make([]string, 0, len(idxs))
|
||||||
|
for _, idx := range idxs {
|
||||||
|
files = append(files, m.StorageFiles[idx])
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
|
|
||||||
@@ -193,6 +272,11 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.Selected = 0
|
m.Selected = 0
|
||||||
m.FileSelected = 0
|
m.FileSelected = 0
|
||||||
m.FileFocused = true
|
m.FileFocused = true
|
||||||
|
m.FileViewHeight = m.fileListHeight()
|
||||||
|
m.FileScrollOff = 0
|
||||||
|
m.FileMultiSelect = make(map[int]bool)
|
||||||
|
m.ActiveFiles = nil
|
||||||
|
m.ActiveFile = ""
|
||||||
m.StorageFiles = nil
|
m.StorageFiles = nil
|
||||||
m.StorageErr = nil
|
m.StorageErr = nil
|
||||||
m.StorageLoading = true
|
m.StorageLoading = true
|
||||||
@@ -200,7 +284,16 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case pages.FileActionPageMsg:
|
case pages.FileActionPageMsg:
|
||||||
m.Page = pageFileAction
|
m.Page = pageFileAction
|
||||||
m.ActiveFile = msg.Filename
|
targets := msg.Filenames
|
||||||
|
if len(targets) == 0 && msg.Filename != "" {
|
||||||
|
targets = []string{msg.Filename}
|
||||||
|
}
|
||||||
|
m.ActiveFiles = append([]string(nil), targets...)
|
||||||
|
if len(m.ActiveFiles) > 0 {
|
||||||
|
m.ActiveFile = m.ActiveFiles[0]
|
||||||
|
} else {
|
||||||
|
m.ActiveFile = ""
|
||||||
|
}
|
||||||
m.MenuItems = pages.FileActionItems()
|
m.MenuItems = pages.FileActionItems()
|
||||||
m.Selected = 0
|
m.Selected = 0
|
||||||
m.FileOpLoading = false
|
m.FileOpLoading = false
|
||||||
@@ -303,6 +396,8 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.StorageFiles = msg.files
|
m.StorageFiles = msg.files
|
||||||
m.StorageErr = msg.err
|
m.StorageErr = msg.err
|
||||||
m.FileSelected = 0
|
m.FileSelected = 0
|
||||||
|
m.FileMultiSelect = make(map[int]bool)
|
||||||
|
m.FileScrollOff = syncScroll(m.FileSelected, len(m.StorageFiles), m.FileViewHeight, 0)
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case configLoadedMsg:
|
case configLoadedMsg:
|
||||||
@@ -332,6 +427,8 @@ func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.WindowWidth = msg.Width
|
m.WindowWidth = msg.Width
|
||||||
m.WindowHeight = msg.Height
|
m.WindowHeight = msg.Height
|
||||||
|
m.FileViewHeight = m.fileListHeight()
|
||||||
|
m.FileScrollOff = syncScroll(m.FileSelected, len(m.StorageFiles), m.FileViewHeight, m.FileScrollOff)
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
@@ -420,6 +517,9 @@ func (m TUIInterface) updateServerActions(msg tea.KeyPressMsg) (tea.Model, tea.C
|
|||||||
case "tab":
|
case "tab":
|
||||||
if len(m.StorageFiles) > 0 || !m.StorageLoading {
|
if len(m.StorageFiles) > 0 || !m.StorageLoading {
|
||||||
m.FileFocused = !m.FileFocused
|
m.FileFocused = !m.FileFocused
|
||||||
|
if m.FileFocused {
|
||||||
|
m.FileScrollOff = syncScroll(m.FileSelected, len(m.StorageFiles), m.FileViewHeight, m.FileScrollOff)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
@@ -427,6 +527,7 @@ func (m TUIInterface) updateServerActions(msg tea.KeyPressMsg) (tea.Model, tea.C
|
|||||||
if m.FileSelected > 0 {
|
if m.FileSelected > 0 {
|
||||||
m.FileSelected--
|
m.FileSelected--
|
||||||
}
|
}
|
||||||
|
m.FileScrollOff = syncScroll(m.FileSelected, len(m.StorageFiles), m.FileViewHeight, m.FileScrollOff)
|
||||||
} else {
|
} else {
|
||||||
if m.Selected > 0 {
|
if m.Selected > 0 {
|
||||||
m.Selected--
|
m.Selected--
|
||||||
@@ -438,18 +539,34 @@ func (m TUIInterface) updateServerActions(msg tea.KeyPressMsg) (tea.Model, tea.C
|
|||||||
if m.FileSelected < len(m.StorageFiles)-1 {
|
if m.FileSelected < len(m.StorageFiles)-1 {
|
||||||
m.FileSelected++
|
m.FileSelected++
|
||||||
}
|
}
|
||||||
|
m.FileScrollOff = syncScroll(m.FileSelected, len(m.StorageFiles), m.FileViewHeight, m.FileScrollOff)
|
||||||
} else {
|
} else {
|
||||||
if m.Selected < len(m.MenuItems)-1 {
|
if m.Selected < len(m.MenuItems)-1 {
|
||||||
m.Selected++
|
m.Selected++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "space":
|
||||||
|
if m.FileFocused && len(m.StorageFiles) > 0 {
|
||||||
|
if m.FileMultiSelect == nil {
|
||||||
|
m.FileMultiSelect = make(map[int]bool)
|
||||||
|
}
|
||||||
|
idx := m.FileSelected
|
||||||
|
m.FileMultiSelect[idx] = !m.FileMultiSelect[idx]
|
||||||
|
if !m.FileMultiSelect[idx] {
|
||||||
|
delete(m.FileMultiSelect, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "enter":
|
case "enter":
|
||||||
if m.FileFocused && len(m.StorageFiles) > 0 {
|
if m.FileFocused && len(m.StorageFiles) > 0 {
|
||||||
file := m.StorageFiles[m.FileSelected]
|
targets := m.selectedStorageFiles()
|
||||||
|
if len(targets) == 0 {
|
||||||
|
targets = []string{m.StorageFiles[m.FileSelected]}
|
||||||
|
}
|
||||||
server := m.ActiveServer
|
server := m.ActiveServer
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
return pages.FileActionPageMsg{ServerName: server, Filename: file}
|
return pages.FileActionPageMsg{ServerName: server, Filenames: targets}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
server := m.ActiveServer
|
server := m.ActiveServer
|
||||||
@@ -492,18 +609,30 @@ func (m TUIInterface) updateFileAction(msg tea.KeyPressMsg) (tea.Model, tea.Cmd)
|
|||||||
}
|
}
|
||||||
case "enter":
|
case "enter":
|
||||||
server := m.ActiveServer
|
server := m.ActiveServer
|
||||||
file := m.ActiveFile
|
targets := m.ActiveFiles
|
||||||
|
if len(targets) == 0 && m.ActiveFile != "" {
|
||||||
|
targets = []string{m.ActiveFile}
|
||||||
|
}
|
||||||
|
if len(targets) == 0 {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
switch m.MenuItems[m.Selected].Key {
|
switch m.MenuItems[m.Selected].Key {
|
||||||
case "get":
|
case "get":
|
||||||
m.FileOpLoading = true
|
m.FileOpLoading = true
|
||||||
m.FileOpErr = nil
|
m.FileOpErr = nil
|
||||||
m.FileOpSuccess = ""
|
m.FileOpSuccess = ""
|
||||||
return m, getFileCmd(m.Services, server, file, m.LocalDir)
|
if len(targets) == 1 {
|
||||||
|
return m, getFileCmd(m.Services, server, targets[0], m.LocalDir)
|
||||||
|
}
|
||||||
|
return m, getFilesCmd(m.Services, server, targets, m.LocalDir)
|
||||||
case "delete":
|
case "delete":
|
||||||
m.FileOpLoading = true
|
m.FileOpLoading = true
|
||||||
m.FileOpErr = nil
|
m.FileOpErr = nil
|
||||||
m.FileOpSuccess = ""
|
m.FileOpSuccess = ""
|
||||||
return m, deleteFileCmd(m.Services, server, file)
|
if len(targets) == 1 {
|
||||||
|
return m, deleteFileCmd(m.Services, server, targets[0])
|
||||||
|
}
|
||||||
|
return m, deleteFilesCmd(m.Services, server, targets)
|
||||||
}
|
}
|
||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
m.Quitting = true
|
m.Quitting = true
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ func (m TUIInterface) subtitle() string {
|
|||||||
}
|
}
|
||||||
return "Server"
|
return "Server"
|
||||||
case pageFileAction:
|
case pageFileAction:
|
||||||
|
if len(m.ActiveFiles) > 1 {
|
||||||
|
return fmt.Sprintf("%d files selected", len(m.ActiveFiles))
|
||||||
|
}
|
||||||
return m.ActiveFile
|
return m.ActiveFile
|
||||||
case pageSend:
|
case pageSend:
|
||||||
return "Send File"
|
return "Send File"
|
||||||
@@ -116,7 +119,9 @@ func (m TUIInterface) View() tea.View {
|
|||||||
footerSep() +
|
footerSep() +
|
||||||
footerHint("↑↓", "navigate") +
|
footerHint("↑↓", "navigate") +
|
||||||
footerSep() +
|
footerSep() +
|
||||||
footerHint("enter", "select") +
|
footerHint("space", "select") +
|
||||||
|
footerSep() +
|
||||||
|
footerHint("enter", "actions") +
|
||||||
footerSep() +
|
footerSep() +
|
||||||
footerHint("esc", "back")
|
footerHint("esc", "back")
|
||||||
case pageFileAction:
|
case pageFileAction:
|
||||||
@@ -220,6 +225,8 @@ func (m TUIInterface) viewServerActions() string {
|
|||||||
|
|
||||||
// file list rows
|
// file list rows
|
||||||
var fileRows []string
|
var fileRows []string
|
||||||
|
showScrollUp := false
|
||||||
|
showScrollDown := false
|
||||||
switch {
|
switch {
|
||||||
case m.StorageLoading:
|
case m.StorageLoading:
|
||||||
fileRows = append(fileRows, styles.StatusWarnStyle.Render(" loading…"))
|
fileRows = append(fileRows, styles.StatusWarnStyle.Render(" loading…"))
|
||||||
@@ -228,12 +235,27 @@ func (m TUIInterface) viewServerActions() string {
|
|||||||
case len(m.StorageFiles) == 0:
|
case len(m.StorageFiles) == 0:
|
||||||
fileRows = append(fileRows, styles.StorageEmptyStyle.Render(" no files in storage"))
|
fileRows = append(fileRows, styles.StorageEmptyStyle.Render(" no files in storage"))
|
||||||
default:
|
default:
|
||||||
for i, f := range m.StorageFiles {
|
start, end := visibleRange(len(m.StorageFiles), m.FileSelected, m.FileScrollOff, m.FileViewHeight)
|
||||||
|
showScrollUp = start > 0
|
||||||
|
showScrollDown = end < len(m.StorageFiles)
|
||||||
|
for i := start; i < end; i++ {
|
||||||
|
f := m.StorageFiles[i]
|
||||||
active := m.FileFocused && i == m.FileSelected
|
active := m.FileFocused && i == m.FileSelected
|
||||||
fileRows = append(fileRows, styles.FileItemStyle(active).Render(f))
|
checked := m.FileMultiSelect[i]
|
||||||
|
mark := "[ ] "
|
||||||
|
if checked {
|
||||||
|
mark = "[✓] "
|
||||||
|
}
|
||||||
|
fileRows = append(fileRows, styles.FileItemStyle(active).Render(mark+f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fileList := lipgloss.JoinVertical(lipgloss.Left, fileRows...)
|
fileList := lipgloss.JoinVertical(lipgloss.Left, fileRows...)
|
||||||
|
if showScrollUp {
|
||||||
|
fileList = styles.ScrollIndicatorStyle.Render(" ↑") + "\n" + fileList
|
||||||
|
}
|
||||||
|
if showScrollDown {
|
||||||
|
fileList = fileList + "\n" + styles.ScrollIndicatorStyle.Render(" ↓")
|
||||||
|
}
|
||||||
fileSection := styles.StorageFileSectionStyle.Render(
|
fileSection := styles.StorageFileSectionStyle.Render(
|
||||||
lipgloss.JoinVertical(lipgloss.Left, localDirLabel, fileList),
|
lipgloss.JoinVertical(lipgloss.Left, localDirLabel, fileList),
|
||||||
)
|
)
|
||||||
@@ -242,7 +264,11 @@ func (m TUIInterface) viewServerActions() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m TUIInterface) viewFileAction() string {
|
func (m TUIInterface) viewFileAction() string {
|
||||||
filenameLabel := styles.FilenameLabelStyle.Render(m.ActiveFile)
|
label := m.ActiveFile
|
||||||
|
if len(m.ActiveFiles) > 1 {
|
||||||
|
label = fmt.Sprintf("%d file(s) selected", len(m.ActiveFiles))
|
||||||
|
}
|
||||||
|
filenameLabel := styles.FilenameLabelStyle.Render(label)
|
||||||
|
|
||||||
var menuRows []string
|
var menuRows []string
|
||||||
for i, item := range m.MenuItems {
|
for i, item := range m.MenuItems {
|
||||||
|
|||||||
Reference in New Issue
Block a user