diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..90f615d --- /dev/null +++ b/build.sh @@ -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" diff --git a/internal/pages/send.go b/internal/pages/send.go new file mode 100644 index 0000000..fa45b36 --- /dev/null +++ b/internal/pages/send.go @@ -0,0 +1,5 @@ +package pages + +type SendPageMsg struct { + ServerName string +} diff --git a/internal/styles/styles.go b/internal/styles/styles.go index 0e78e90..ba99429 100644 --- a/internal/styles/styles.go +++ b/internal/styles/styles.go @@ -146,6 +146,31 @@ var ( FilenameLabelStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("243")). 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 { @@ -187,6 +212,21 @@ func FileItemStyle(active bool) lipgloss.Style { 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. func ServerRowStyle(active bool, name string) string { if active { diff --git a/internal/tui/picker.go b/internal/tui/picker.go new file mode 100644 index 0000000..d1ccee9 --- /dev/null +++ b/internal/tui/picker.go @@ -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 +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4fbb5d9..b585e63 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -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, } } diff --git a/internal/tui/update.go b/internal/tui/update.go index 19ce88f..d517597 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -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() { diff --git a/internal/tui/view.go b/internal/tui/view.go index 55fd784..2609b0b 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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.") diff --git a/main.go b/main.go index 2721344..7256467 100644 --- a/main.go +++ b/main.go @@ -15,8 +15,13 @@ func main() { 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, "install it with your package manager, e.g.:") - fmt.Fprintln(os.Stderr, " brew install rsync") - fmt.Fprintln(os.Stderr, " apt install rsync") + fmt.Fprintln(os.Stderr, " sudo pacman -S rsync") + os.Exit(1) + } + + localDir, err := os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, "failed to get working directory:", err) os.Exit(1) } @@ -26,7 +31,7 @@ func main() { os.Exit(1) } - m := tui.NewTUIInterface(store) + m := tui.NewTUIInterface(store, localDir) p := tea.NewProgram(m) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, err)