Support sending folders, not just single files
The sender only handled regular files: a staged directory would fail at upload time, so users could only send individual files. Now a staged directory is walked recursively and each file is advertised with a name relative to the folder's parent (e.g. "Trip/day1/img.jpg"), which is the LocalSend-compatible way to carry structure. The receiver recreates those subdirectories under the receive dir, creating parents as needed. The path-traversal guard is preserved: names are cleaned against a leading "/" to collapse "..", and a containment check ensures the result stays within the receive dir. In the TUI send picker, "a" stages the folder currently being browsed; staged folders are tagged in the panel and the help text is updated. Tests: directory expansion produces relative names; an end-to-end folder send recreates the structure on the receiver; destPath preserves subdirs, rejects traversal, and de-duplicates within subfolders. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f69d3464a7
commit
beb6b9040e
6 changed files with 301 additions and 32 deletions
|
|
@ -12,8 +12,9 @@ LocalSend mobile and desktop apps on the same LAN, including their default
|
||||||
`/register` handshake, with peer aging.
|
`/register` handshake, with peer aging.
|
||||||
- **Receive** — incoming files are accepted via a prompt (or auto-accepted) and
|
- **Receive** — incoming files are accepted via a prompt (or auto-accepted) and
|
||||||
written to the receive directory, with live progress.
|
written to the receive directory, with live progress.
|
||||||
- **Send** — pick a peer, stage files with a built-in file picker, and upload
|
- **Send** — pick a peer, stage files (or whole folders) with a built-in file
|
||||||
them with progress, rate and ETA.
|
picker, and upload them with progress, rate and ETA. Folders are sent
|
||||||
|
recursively with their structure preserved on the receiver.
|
||||||
- **Manage** — browse the receive folder, mark received files (or whole folders)
|
- **Manage** — browse the receive folder, mark received files (or whole folders)
|
||||||
and delete the ones you no longer want, behind a confirmation prompt.
|
and delete the ones you no longer want, behind a confirmation prompt.
|
||||||
- **HTTPS** — generates a self-signed certificate whose fingerprint matches the
|
- **HTTPS** — generates a self-signed certificate whose fingerprint matches the
|
||||||
|
|
@ -92,7 +93,7 @@ omarchy-send --auto-accept --pin 2468
|
||||||
|
|
||||||
- `1`/`2`/`3`/`4` or `tab` — switch between Devices / Transfers / Manage / Settings
|
- `1`/`2`/`3`/`4` or `tab` — switch between Devices / Transfers / Manage / Settings
|
||||||
- Peers: `enter` send to the selected peer · `r` refresh · `/` filter
|
- Peers: `enter` send to the selected peer · `r` refresh · `/` filter
|
||||||
- Send picker: `enter` stage a file · `backspace` unstage · `S` send · `esc` back
|
- Send picker: `enter` stage a file · `a` add the current folder · `backspace` unstage · `S` send · `esc` back
|
||||||
- Incoming prompt: `y` accept · `n` reject
|
- Incoming prompt: `y` accept · `n` reject
|
||||||
- Transfers: `c` clear finished
|
- Transfers: `c` clear finished
|
||||||
- Manage: `space` mark file/folder · `a` mark all · `d` delete marked (or the one
|
- Manage: `space` mark file/folder · `a` mark all · `d` delete marked (or the one
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"mime"
|
"mime"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -72,23 +73,21 @@ func (s *Sender) Send(peer discovery.Peer, paths []string, pin string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
|
func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
|
||||||
// Build file metadata keyed by a generated fileId.
|
// Expand any directories into their files, then build metadata keyed by a
|
||||||
files := make(map[string]protocol.FileMetadata, len(paths))
|
// generated fileId. A directory's files carry a relative FileName (e.g.
|
||||||
pathByID := make(map[string]string, len(paths))
|
// "Trip/day1/img.jpg") so the receiver can recreate the folder structure.
|
||||||
for _, p := range paths {
|
items := s.expand(paths)
|
||||||
fi, err := os.Stat(p)
|
files := make(map[string]protocol.FileMetadata, len(items))
|
||||||
if err != nil {
|
pathByID := make(map[string]string, len(items))
|
||||||
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(p), Err: err})
|
for _, it := range items {
|
||||||
continue
|
|
||||||
}
|
|
||||||
id := randID()
|
id := randID()
|
||||||
files[id] = protocol.FileMetadata{
|
files[id] = protocol.FileMetadata{
|
||||||
ID: id,
|
ID: id,
|
||||||
FileName: filepath.Base(p),
|
FileName: it.name,
|
||||||
Size: fi.Size(),
|
Size: it.size,
|
||||||
FileType: mimeType(p),
|
FileType: mimeType(it.path),
|
||||||
}
|
}
|
||||||
pathByID[id] = p
|
pathByID[id] = it.path
|
||||||
}
|
}
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
return
|
return
|
||||||
|
|
@ -124,6 +123,57 @@ func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fileItem is one concrete file to upload: its path on disk, the relative name
|
||||||
|
// advertised to the peer (carries folder structure), and its size.
|
||||||
|
type fileItem struct {
|
||||||
|
path string
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// expand turns the selected paths into a flat list of files. A regular file is
|
||||||
|
// passed through with its base name. A directory is walked recursively; each
|
||||||
|
// contained file's advertised name is its path relative to the directory's
|
||||||
|
// parent, so the selected folder itself is recreated on the receiver (selecting
|
||||||
|
// "Trip" yields "Trip/day1/img.jpg", …). Unreadable entries are skipped with an
|
||||||
|
// error event rather than aborting the whole transfer.
|
||||||
|
func (s *Sender) expand(paths []string) []fileItem {
|
||||||
|
var items []fileItem
|
||||||
|
for _, p := range paths {
|
||||||
|
fi, err := os.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(p), Err: err})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !fi.IsDir() {
|
||||||
|
items = append(items, fileItem{path: p, name: filepath.Base(p), size: fi.Size()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
root := filepath.Dir(filepath.Clean(p)) // parent, so the folder name is kept
|
||||||
|
_ = filepath.WalkDir(p, func(fp string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(fp), Err: err})
|
||||||
|
return nil // skip this entry, keep walking the rest
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(fp), Err: err})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, fp)
|
||||||
|
if err != nil {
|
||||||
|
rel = filepath.Base(fp)
|
||||||
|
}
|
||||||
|
items = append(items, fileItem{path: fp, name: filepath.ToSlash(rel), size: info.Size()})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Sender) prepareUpload(base string, files map[string]protocol.FileMetadata, pin string) (protocol.PrepareUploadResponse, error) {
|
func (s *Sender) prepareUpload(base string, files map[string]protocol.FileMetadata, pin string) (protocol.PrepareUploadResponse, error) {
|
||||||
reqBody, _ := json.Marshal(protocol.PrepareUploadRequest{Info: s.selfCopy(), Files: files})
|
reqBody, _ := json.Marshal(protocol.PrepareUploadRequest{Info: s.selfCopy(), Files: files})
|
||||||
url := base + protocol.PathPrepareUpload
|
url := base + protocol.PathPrepareUpload
|
||||||
|
|
|
||||||
124
internal/client/folder_test.go
Normal file
124
internal/client/folder_test.go
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"omarchy-send/internal/discovery"
|
||||||
|
"omarchy-send/internal/protocol"
|
||||||
|
"omarchy-send/internal/server"
|
||||||
|
"omarchy-send/internal/transfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// expand must walk a directory and advertise each file with a name relative to
|
||||||
|
// the selected folder's parent, so the folder itself is part of the path.
|
||||||
|
func TestExpandDirectory(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "Trip")
|
||||||
|
mustWrite(t, filepath.Join(dir, "cover.jpg"), "a")
|
||||||
|
mustWrite(t, filepath.Join(dir, "day1", "img.jpg"), "bb")
|
||||||
|
mustWrite(t, filepath.Join(dir, "day1", "notes.txt"), "ccc")
|
||||||
|
|
||||||
|
s := New(protocol.DeviceInfo{})
|
||||||
|
items := s.expand([]string{dir})
|
||||||
|
|
||||||
|
got := make([]string, len(items))
|
||||||
|
for i, it := range items {
|
||||||
|
got[i] = it.name
|
||||||
|
}
|
||||||
|
sort.Strings(got)
|
||||||
|
want := []string{"Trip/cover.jpg", "Trip/day1/img.jpg", "Trip/day1/notes.txt"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("expand returned %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("expand[%d] = %q, want %q (all: %v)", i, got[i], want[i], got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A regular file path passes through with just its base name.
|
||||||
|
func TestExpandFile(t *testing.T) {
|
||||||
|
p := filepath.Join(t.TempDir(), "single.bin")
|
||||||
|
mustWrite(t, p, "x")
|
||||||
|
items := New(protocol.DeviceInfo{}).expand([]string{p})
|
||||||
|
if len(items) != 1 || items[0].name != "single.bin" {
|
||||||
|
t.Fatalf("expand file = %+v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// End-to-end: sending a directory recreates its structure on the receiver.
|
||||||
|
func TestSendDirectoryPreservesStructure(t *testing.T) {
|
||||||
|
recvDir := t.TempDir()
|
||||||
|
recvInfo := protocol.DeviceInfo{
|
||||||
|
Alias: "recv", Version: protocol.ProtocolVersion, Port: 53998, Protocol: "http",
|
||||||
|
}
|
||||||
|
srv := server.New(server.Options{Info: recvInfo, ReceiveDir: recvDir, AutoAccept: true})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Start(ctx); err != nil {
|
||||||
|
t.Fatalf("server start: %v", err)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
for range srv.Transfers() {
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
srcRoot := t.TempDir()
|
||||||
|
dir := filepath.Join(srcRoot, "Trip")
|
||||||
|
files := map[string]string{
|
||||||
|
"cover.jpg": "cover-bytes",
|
||||||
|
"day1/img.jpg": "day1-image",
|
||||||
|
"day2/clip.mov": "day2-clip",
|
||||||
|
"day2/sub/note.md": "nested-note",
|
||||||
|
}
|
||||||
|
for rel, body := range files {
|
||||||
|
mustWrite(t, filepath.Join(dir, filepath.FromSlash(rel)), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
sender := New(protocol.DeviceInfo{Alias: "sender", Fingerprint: "snd1", Version: "2.1", Protocol: "http"})
|
||||||
|
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"}
|
||||||
|
sender.Send(peer, []string{dir}, "")
|
||||||
|
|
||||||
|
done := 0
|
||||||
|
deadline := time.After(5 * time.Second)
|
||||||
|
for done < len(files) {
|
||||||
|
select {
|
||||||
|
case ev := <-sender.Events():
|
||||||
|
if ev.Kind == transfer.Error {
|
||||||
|
t.Fatalf("send error: %v", ev.Err)
|
||||||
|
}
|
||||||
|
if ev.Kind == transfer.FileDone {
|
||||||
|
done++
|
||||||
|
}
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("timed out after %d/%d files", done, len(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for rel, body := range files {
|
||||||
|
got, err := os.ReadFile(filepath.Join(recvDir, "Trip", filepath.FromSlash(rel)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("missing received %q: %v", rel, err)
|
||||||
|
}
|
||||||
|
if string(got) != body {
|
||||||
|
t.Fatalf("%q content = %q, want %q", rel, got, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustWrite(t *testing.T, path, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
internal/server/destpath_test.go
Normal file
64
internal/server/destpath_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDestPathPreservesSubdirs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
got, err := destPath(dir, "Trip/day1/img.jpg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(dir, "Trip", "day1", "img.jpg")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("destPath = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
// Parent directories must already exist so the writer can create the file.
|
||||||
|
if _, err := os.Stat(filepath.Dir(got)); err != nil {
|
||||||
|
t.Fatalf("parent dir not created: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traversal attempts must be neutralised — the result always stays under dir.
|
||||||
|
func TestDestPathRejectsTraversal(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, name := range []string{
|
||||||
|
"../escape.txt",
|
||||||
|
"../../etc/passwd",
|
||||||
|
"a/../../../oops.txt",
|
||||||
|
"/abs/path.txt",
|
||||||
|
} {
|
||||||
|
got, err := destPath(dir, name)
|
||||||
|
if err != nil {
|
||||||
|
continue // rejected outright — also acceptable
|
||||||
|
}
|
||||||
|
if got != dir && !strings.HasPrefix(got, dir+string(os.PathSeparator)) {
|
||||||
|
t.Fatalf("destPath(%q) = %q escaped %q", name, got, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A name colliding with an existing file gets a " (n)" suffix; subdir collisions
|
||||||
|
// are resolved within their own directory.
|
||||||
|
func TestDestPathDeDup(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
first, _ := destPath(dir, "sub/file.txt")
|
||||||
|
if err := os.WriteFile(first, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := destPath(dir, "sub/file.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if second == first {
|
||||||
|
t.Fatalf("expected a de-duplicated path, got %q twice", second)
|
||||||
|
}
|
||||||
|
want := filepath.Join(dir, "sub", "file (1).txt")
|
||||||
|
if second != want {
|
||||||
|
t.Fatalf("dedup = %q, want %q", second, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -251,10 +252,10 @@ func (s *Server) writeFile(sess *session, fe *fileEntry, key string, r io.Reader
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
dir := s.receiveDir
|
dir := s.receiveDir
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
dest, err := destPath(dir, fe.meta.FileName)
|
||||||
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
dest := uniquePath(dir, fe.meta.FileName)
|
|
||||||
tmp := dest + ".part"
|
tmp := dest + ".part"
|
||||||
|
|
||||||
f, err := os.Create(tmp)
|
f, err := os.Create(tmp)
|
||||||
|
|
@ -297,22 +298,40 @@ func (s *Server) handleCancel(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// uniquePath returns a non-colliding path in dir for the (sanitised) filename.
|
// destPath resolves a safe, non-colliding path under dir for the (possibly
|
||||||
func uniquePath(dir, name string) string {
|
// nested) filename, creating parent directories. Sub-paths are honoured so a
|
||||||
base := filepath.Base(filepath.Clean("/" + name)) // strip any path components / traversal
|
// folder send recreates its structure, but any traversal is neutralised:
|
||||||
if base == "." || base == "/" || base == "" {
|
// cleaning against a leading "/" collapses ".." at the root, and a final
|
||||||
base = "file"
|
// containment check guarantees the result stays within dir.
|
||||||
|
func destPath(dir, name string) (string, error) {
|
||||||
|
rel := strings.TrimPrefix(filepath.Clean("/"+filepath.ToSlash(name)), "/")
|
||||||
|
if rel == "" || rel == "." {
|
||||||
|
rel = "file"
|
||||||
}
|
}
|
||||||
candidate := filepath.Join(dir, base)
|
full := filepath.Join(dir, filepath.FromSlash(rel))
|
||||||
if _, err := os.Stat(candidate); os.IsNotExist(err) {
|
if full != dir && !strings.HasPrefix(full, dir+string(os.PathSeparator)) {
|
||||||
return candidate
|
return "", fmt.Errorf("unsafe destination for %q", name)
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return uniqueAt(full), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueAt returns full if free, otherwise inserts " (n)" before the extension
|
||||||
|
// until it finds an unused name in the same directory.
|
||||||
|
func uniqueAt(full string) string {
|
||||||
|
if _, err := os.Stat(full); os.IsNotExist(err) {
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
d := filepath.Dir(full)
|
||||||
|
base := filepath.Base(full)
|
||||||
ext := filepath.Ext(base)
|
ext := filepath.Ext(base)
|
||||||
stem := base[:len(base)-len(ext)]
|
stem := base[:len(base)-len(ext)]
|
||||||
for i := 1; ; i++ {
|
for i := 1; ; i++ {
|
||||||
candidate = filepath.Join(dir, fmt.Sprintf("%s (%d)%s", stem, i, ext))
|
cand := filepath.Join(d, fmt.Sprintf("%s (%d)%s", stem, i, ext))
|
||||||
if _, err := os.Stat(candidate); os.IsNotExist(err) {
|
if _, err := os.Stat(cand); os.IsNotExist(err) {
|
||||||
return candidate
|
return cand
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -358,6 +358,13 @@ func (m Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
case "ctrl+c", "q":
|
case "ctrl+c", "q":
|
||||||
m.quitting = true
|
m.quitting = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
case "a":
|
||||||
|
// Stage the folder currently being browsed; it is expanded into its
|
||||||
|
// files (structure preserved) when the transfer starts.
|
||||||
|
if dir := m.picker.CurrentDirectory; dir != "" && !contains(m.staged, dir) {
|
||||||
|
m.staged = append(m.staged, dir)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
case "backspace":
|
case "backspace":
|
||||||
if len(m.staged) > 0 {
|
if len(m.staged) > 0 {
|
||||||
m.staged = m.staged[:len(m.staged)-1]
|
m.staged = m.staged[:len(m.staged)-1]
|
||||||
|
|
@ -686,12 +693,16 @@ func (m Model) stagedPanel() string {
|
||||||
BorderForeground(muted).
|
BorderForeground(muted).
|
||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
if len(m.staged) == 0 {
|
if len(m.staged) == 0 {
|
||||||
return border.Render(headerStyle.Render("No files staged — press enter on a file to add it."))
|
return border.Render(headerStyle.Render("Nothing staged — enter adds a file, a adds the current folder."))
|
||||||
}
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(titleStyle.Render(fmt.Sprintf("Staged · %d", len(m.staged))))
|
b.WriteString(titleStyle.Render(fmt.Sprintf("Staged · %d", len(m.staged))))
|
||||||
for _, p := range m.staged {
|
for _, p := range m.staged {
|
||||||
b.WriteString("\n" + valueStyle.Render("• "+collapseHome(p)))
|
label := collapseHome(p)
|
||||||
|
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
|
||||||
|
label += "/ (folder)"
|
||||||
|
}
|
||||||
|
b.WriteString("\n" + valueStyle.Render("• "+label))
|
||||||
}
|
}
|
||||||
return border.Render(b.String())
|
return border.Render(b.String())
|
||||||
}
|
}
|
||||||
|
|
@ -961,7 +972,7 @@ func (m Model) footerText() string {
|
||||||
case m.editing:
|
case m.editing:
|
||||||
return "tab/↑↓ move · enter next · ctrl+s save · esc cancel"
|
return "tab/↑↓ move · enter next · ctrl+s save · esc cancel"
|
||||||
case m.screen == screenPicker:
|
case m.screen == screenPicker:
|
||||||
return "enter stage · backspace unstage · S send · esc back"
|
return "enter stage file · a add folder · backspace unstage · S send · esc back"
|
||||||
case m.screen == screenPeers:
|
case m.screen == screenPeers:
|
||||||
return "enter send-to · r refresh · / filter · 1-4 switch · q quit"
|
return "enter send-to · r refresh · / filter · 1-4 switch · q quit"
|
||||||
case m.screen == screenTransfers:
|
case m.screen == screenTransfers:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue