ADB file manager
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

795 lines
18 KiB

package main
import (
"fmt"
"io/ioutil"
"log"
"os"
"path"
"path/filepath"
"sort"
"strings"
"code.rocketnine.space/tslocum/cbind"
"code.rocketnine.space/tslocum/cview"
"github.com/gdamore/tcell/v2"
"github.com/mattn/go-runewidth"
adb "github.com/zach-klippenstein/goadb"
)
var (
app *cview.Application
inputConfig = cbind.NewConfiguration()
appGrid *cview.Grid
appContainer *cview.Panels
modalPopup *cview.Modal
devicesDropDown *cview.DropDown
localBuffer *cview.List
remoteBuffer *cview.List
renameField *cview.InputField
renameRemote bool
renameItem int
deleteRemote bool
deleteItem int
newFolderField *cview.InputField
newFolderRemote bool
newFolderItem int
currentFocus = 0
currentMode = modeNormal
clipboardRemote bool
clipboardPath string
clipboardCut bool
)
const contextMenuTitleWidth = 16
const (
modeNormal = 0
modeWait = 1
modeRename = 2
modeDelete = 3
modeNewFolder = 4
)
func getMode() int {
return currentMode
}
func setMode(mode int) {
currentMode = mode
switch currentMode {
case modeNormal:
appContainer.HidePanel("modal")
case modeWait, modeRename, modeDelete, modeNewFolder:
appContainer.ShowPanel("modal")
}
focusUpdated()
}
func acceptRename(buttonIndex int, buttonLabel string) {
renameText := renameField.GetText()
if buttonIndex >= 0 && renameText != "" {
if renameRemote {
err := bridge.move(path.Join(remotePath, remoteEntriesShown[renameItem].Name), path.Join(remotePath, renameText))
if err != nil {
log.Fatal(err)
}
} else {
err := os.Rename(path.Join(localPath, localEntriesShown[renameItem].Name), path.Join(localPath, renameText))
if err != nil {
log.Fatal(err)
}
}
}
setMode(modeNormal)
if buttonIndex >= 0 {
go func() {
if renameRemote {
browseRemote(remotePath)
} else {
browseLocal(localPath)
}
focusUpdated()
app.Draw()
}()
return
}
app.Draw()
}
func acceptNewFolder(buttonIndex int, buttonLabel string) {
folderName := newFolderField.GetText()
if buttonIndex >= 0 && folderName != "" {
if newFolderRemote {
err := bridge.newDirectory(path.Join(remotePath, folderName))
if err != nil {
log.Fatal(err)
}
} else {
err := os.Mkdir(path.Join(localPath, folderName), 0655)
if err != nil {
log.Fatal(err)
}
}
}
setMode(modeNormal)
if buttonIndex >= 0 {
go func() {
if newFolderRemote {
browseRemote(remotePath)
} else {
browseLocal(localPath)
}
focusUpdated()
app.Draw()
}()
}
app.Draw()
}
func cancelDelete() {
setMode(modeNormal)
}
func confirmDelete() {
if deleteRemote {
err := bridge.delete(path.Join(remotePath, remoteEntriesShown[deleteItem].Name))
if err != nil {
log.Fatal(err)
}
} else {
err := os.RemoveAll(path.Join(localPath, localEntriesShown[deleteItem].Name))
if err != nil {
log.Fatal(err)
}
}
setMode(modeNormal)
if deleteRemote {
browseRemote(remotePath)
} else {
browseLocal(localPath)
}
focusUpdated()
app.Draw()
}
func previousField(ev *tcell.EventKey) *tcell.EventKey {
if currentMode == modeNormal && currentFocus > 0 {
currentFocus--
focusUpdated()
return nil
}
return ev
}
func nextField(ev *tcell.EventKey) *tcell.EventKey {
if currentMode == modeNormal && currentFocus < 2 {
currentFocus++
focusUpdated()
return nil
}
return ev
}
func exit(_ *tcell.EventKey) *tcell.EventKey {
return nil
}
func upload(filePath string) {
f, err := os.Open(filePath)
if err != nil {
log.Fatalf("failed to open local file: %s", err)
}
data, err := ioutil.ReadAll(f)
if err != nil {
log.Fatalf("failed to read local file: %s", err)
}
stat, err := f.Stat()
if err != nil {
log.Fatalf("failed to fetch info of local file: %s", err)
}
err = bridge.upload(path.Base(filePath), data, stat.ModTime())
if err != nil {
log.Fatalf("failed to upload file: %s", err)
}
}
func renameFunc(remote bool) func(index int) {
return func(index int) {
entries := localEntriesShown
if remote {
entries = remoteEntriesShown
}
if entries[index].Name == ".." {
return
}
renameField.SetText(entries[index].Name)
modalPopup.SetDoneFunc(acceptRename)
modalPopup.SetText(fmt.Sprintf("%s", entries[index].Name))
modalPopup.GetForm().Clear(true)
modalPopup.GetForm().AddFormItem(renameField)
modalPopup.GetForm().AddButton("Rename", func() {
acceptRename(0, "")
})
renameRemote = remote
renameItem = index
setMode(modeRename)
app.Draw()
}
}
func deleteFunc(remote bool) func(index int) {
return func(index int) {
entries := localEntriesShown
if remote {
entries = remoteEntriesShown
}
if entries[index].Name == ".." {
return
}
if entries[index].Mode&os.ModeDir != 0 {
modalPopup.SetText("Are you sure you want to delete this directory?\n\n" + entries[index].Name)
} else {
modalPopup.SetText("Are you sure you want to delete this file?\n\n" + entries[index].Name)
}
modalPopup.GetForm().Clear(true)
modalPopup.GetForm().AddButton("No", cancelDelete)
modalPopup.GetForm().AddButton("Yes", confirmDelete)
deleteRemote = remote
deleteItem = index
setMode(modeDelete)
}
}
func newFolderFunc(remote bool) func(index int) {
return func(index int) {
newFolderField.SetLabel("Folder name")
modalPopup.SetDoneFunc(acceptNewFolder)
modalPopup.SetText("Create new folder")
modalPopup.GetForm().Clear(true)
modalPopup.GetForm().AddFormItem(newFolderField)
modalPopup.GetForm().AddButton("Create", func() {
acceptNewFolder(0, "")
})
newFolderRemote = remote
newFolderItem = index
setMode(modeNewFolder)
app.Draw()
}
}
func initTUI() {
devices, err := bridge.listDevices()
if err != nil {
log.Fatalf("failed to list devices: %s", err)
}
if len(devices) == 0 {
if connectAddress != "" {
log.Fatalf("failed to connect to device %s", connectAddress)
}
log.Fatal("failed to start adbfm: please connect to a device via ADB before launching")
}
/*cview.Styles.TitleColor = tcell.ColorDefault
cview.Styles.BorderColor = tcell.ColorDefault
cview.Styles.PrimaryTextColor = tcell.ColorDefault
cview.Styles.PrimitiveBackgroundColor = tcell.ColorDefault*/
app = cview.NewApplication()
app.EnableMouse(true)
app.SetInputCapture(inputConfig.Capture)
// Devices
devicesDropDown = cview.NewDropDown()
devicesDropDown.SetLabel(" Device: ")
devicesDropDown.SetLabelColor(tcell.ColorDefault)
devicesDropDown.SetFieldTextColor(tcell.ColorWhite.TrueColor())
devicesDropDown.SetFieldTextColorFocused(tcell.ColorBlack.TrueColor())
devicesDropDown.SetFieldBackgroundColor(tcell.ColorBlack.TrueColor())
devicesDropDown.SetFieldBackgroundColorFocused(tcell.ColorWhite.TrueColor())
for _, device := range devices {
option := cview.NewDropDownOption(device.Serial)
option.SetSelectedFunc(setDeviceFunc(device))
devicesDropDown.AddOptions(option)
}
// Local
localBuffer = cview.NewList()
localBuffer.ShowSecondaryText(false)
localBuffer.SetScrollBarVisibility(cview.ScrollBarAlways)
localBuffer.SetTitleAlign(cview.AlignLeft)
localBuffer.SetBorder(true)
localBuffer.AddContextItem("Upload", 'k', func(index int) {
if localEntriesShown[index].Name == ".." {
return
}
// TODO Exists confirmation dialog
modalPopup.SetText(fmt.Sprintf("Uploading %s", localEntriesShown[index].Name))
focusUpdated()
app.Draw()
upload(path.Join(localPath, localEntriesShown[index].Name))
browseRemote(remotePath)
focusUpdated()
app.Draw()
})
localBuffer.AddContextItem("", 0, nil)
localBuffer.AddContextItem("Cut", 'x', func(index int) {
if localEntriesShown[index].Name == ".." {
return
}
clipboardRemote = false
clipboardCut = true
clipboardPath = path.Join(localPath, localEntriesShown[index].Name)
focusUpdated()
})
localBuffer.AddContextItem("Copy", 'c', func(index int) {
if localEntriesShown[index].Name == ".." {
return
}
clipboardRemote = false
clipboardCut = false
clipboardPath = path.Join(localPath, localEntriesShown[index].Name)
focusUpdated()
})
localBuffer.AddContextItem("Paste", 'v', func(index int) {
// TODO Exists confirmation dialog
if clipboardPath == "" {
return
}
modalPopup.SetText(fmt.Sprintf("Pasting %s", path.Base(clipboardPath)))
modalPopup.SetDoneFunc(nil)
setMode(modeWait)
app.Draw()
if clipboardRemote {
data, err := bridge.download(clipboardPath)
if err != nil {
log.Fatalf("failed to paste file: %s", err)
}
file, err := os.Create(path.Join(localPath, filepath.Base(clipboardPath)))
if err != nil {
log.Fatalf("failed to paste file: failed to create local file: %s", err)
}
file.Write(data)
file.Close()
} else {
// TODO warn exists
err = copyRecursive(clipboardPath, path.Join(localPath, filepath.Base(clipboardPath)))
if err != nil {
log.Fatalf("failed to paste file: %s", err)
}
if clipboardCut {
os.RemoveAll(clipboardPath)
}
}
clipboardPath = ""
if clipboardCut {
browseLocal(localPath)
}
browseRemote(remotePath)
setMode(modeNormal)
app.Draw()
})
localBuffer.AddContextItem("", 0, nil)
localBuffer.AddContextItem("Delete", 'd', deleteFunc(false))
localBuffer.AddContextItem("", 0, nil)
localBuffer.AddContextItem("Rename", 'r', renameFunc(false))
localBuffer.AddContextItem("", 0, nil)
localBuffer.AddContextItem("New folder", 'f', newFolderFunc(false))
localBuffer.SetSelectedFunc(func(i int, item *cview.ListItem) {
localLock.Lock()
if i < 0 || i > len(localEntriesShown) {
localLock.Unlock()
return
}
if localEntriesShown[i].Mode&os.ModeDir != 0 {
newPath := localEntriesShown[i].Name
localLock.Unlock()
go browseLocal(path.Join(localPath, newPath))
return
}
align := cview.AlignCenter
if runewidth.StringWidth(localEntriesShown[i].Name) > contextMenuTitleWidth {
align = cview.AlignLeft
}
localBuffer.ContextMenuList().SetTitle(fmt.Sprintf("%s", localEntriesShown[i].Name))
localBuffer.ContextMenuList().SetTitleAlign(align)
localBuffer.ShowContextMenu(localBuffer.GetCurrentItemIndex(), -1, -1, func(primitive cview.Primitive) {
app.SetFocus(primitive)
})
localLock.Unlock()
})
// Remote
renameField = cview.NewInputField()
renameField.SetDoneFunc(func(key tcell.Key) {
if key == tcell.KeyEnter {
acceptRename(0, "")
} else {
acceptRename(-1, "")
}
})
newFolderField = cview.NewInputField()
newFolderField.SetDoneFunc(func(key tcell.Key) {
if key == tcell.KeyEnter {
acceptNewFolder(0, "")
} else {
acceptNewFolder(-1, "")
}
})
remoteBuffer = cview.NewList()
remoteBuffer.ShowSecondaryText(false)
remoteBuffer.SetScrollBarVisibility(cview.ScrollBarAlways)
remoteBuffer.SetTitleAlign(cview.AlignLeft)
remoteBuffer.SetBorder(true)
remoteBuffer.AddContextItem("Download", 'j', func(index int) {
// TODO Exists confirmation dialog
if remoteEntriesShown[index].Name == ".." {
return
}
modalPopup.SetText(fmt.Sprintf("Downloading %s", remoteEntriesShown[index].Name))
modalPopup.SetDoneFunc(acceptRename)
focusUpdated()
app.Draw()
data, err := bridge.download(remoteEntriesShown[index].Name)
if err != nil {
log.Fatalf("failed to download file: %s", err)
}
file, err := os.Create(path.Join(localPath, remoteEntriesShown[index].Name))
if err != nil {
log.Fatalf("failed to download file: failed to create local file: %s", err)
}
file.Write(data)
file.Close()
focusUpdated()
app.Draw()
})
remoteBuffer.AddContextItem("", 0, nil)
remoteBuffer.AddContextItem("Cut", 'x', func(index int) {
if remoteEntriesShown[index].Name == ".." {
return
}
clipboardRemote = true
clipboardCut = true
clipboardPath = path.Join(remotePath, remoteEntriesShown[index].Name)
focusUpdated()
})
remoteBuffer.AddContextItem("Copy", 'c', func(index int) {
if remoteEntriesShown[index].Name == ".." {
return
}
clipboardRemote = true
clipboardCut = false
clipboardPath = path.Join(remotePath, remoteEntriesShown[index].Name)
focusUpdated()
})
remoteBuffer.AddContextItem("Paste", 'v', func(index int) {
// TODO Exists confirmation dialog
if clipboardPath == "" {
return
}
modalPopup.SetText(fmt.Sprintf("Pasting %s", path.Base(clipboardPath)))
modalPopup.SetDoneFunc(nil)
setMode(modeWait)
app.Draw()
if clipboardRemote {
if clipboardCut {
err = bridge.move(clipboardPath, path.Join(remotePath, path.Base(clipboardPath)))
if err != nil {
log.Fatalf("failed to paste file: %s", err)
}
} else {
bridge.copy(clipboardPath, path.Join(remotePath, path.Base(clipboardPath)))
if err != nil {
log.Fatalf("failed to paste file: %s", err)
}
}
} else {
// TODO recursively upload dir, check exists
fi, err := os.Stat(clipboardPath)
if err != nil {
log.Fatalf("failed to paste file: failed to read cut/copied file or directory: %s", err)
} else if fi.Mode()&os.ModeDir != 0 {
log.Fatal("copying directories not yet implemented")
}
upload(clipboardPath)
if clipboardCut {
os.RemoveAll(clipboardPath)
}
}
clipboardPath = ""
if clipboardCut {
browseLocal(localPath)
}
browseRemote(remotePath)
setMode(modeNormal)
app.Draw()
})
remoteBuffer.AddContextItem("", 0, nil)
remoteBuffer.AddContextItem("Delete", 'd', deleteFunc(true))
remoteBuffer.AddContextItem("", 0, nil)
remoteBuffer.AddContextItem("Rename", 'r', renameFunc(true))
remoteBuffer.AddContextItem("", 0, nil)
remoteBuffer.AddContextItem("New folder", 'f', newFolderFunc(true))
remoteBuffer.SetSelectedFunc(func(i int, item *cview.ListItem) {
remoteLock.Lock()
if i < 0 || i > len(remoteEntriesShown) {
remoteLock.Unlock()
return
}
if remoteEntriesShown[i].Mode&os.ModeDir != 0 {
newPath := remoteEntriesShown[i].Name
remoteLock.Unlock()
go browseRemote(path.Join(remotePath, newPath))
return
}
align := cview.AlignCenter
if runewidth.StringWidth(remoteEntriesShown[i].Name) > contextMenuTitleWidth {
align = cview.AlignLeft
}
remoteBuffer.ContextMenuList().SetTitle(fmt.Sprintf("%s", remoteEntriesShown[i].Name))
remoteBuffer.ContextMenuList().SetTitleAlign(align)
remoteBuffer.ShowContextMenu(remoteBuffer.GetCurrentItemIndex(), -1, -1, func(primitive cview.Primitive) {
app.SetFocus(primitive)
})
remoteLock.Unlock()
})
// Select first device
if len(devices) > 0 {
devicesDropDown.SetCurrentOption(0)
}
pad := cview.NewTextView()
appGrid = cview.NewGrid()
appGrid.SetRows(1, 1, -1)
appGrid.SetColumns(-1, -1)
appGrid.AddItem(devicesDropDown, 0, 0, 1, 2, 0, 0, true)
appGrid.AddItem(pad, 1, 0, 1, 2, 0, 0, true)
appGrid.AddItem(localBuffer, 2, 0, 1, 1, 0, 0, false)
appGrid.AddItem(remoteBuffer, 2, 1, 1, 1, 0, 0, false)
appContainer = cview.NewPanels()
modalPopup = cview.NewModal()
modalPopup.SetBackgroundColor(tcell.ColorBlack)
appContainer.AddPanel("main", appGrid, true, true)
appContainer.AddPanel("modal", modalPopup, true, true)
app.SetRoot(appContainer, true)
app.SetFocus(devicesDropDown)
currentMode = -1
setMode(modeNormal)
}
func focusUpdated() {
emptyClipboard := clipboardPath == ""
localBuffer.ContextMenuList().SetItemEnabled(4, !emptyClipboard)
remoteBuffer.ContextMenuList().SetItemEnabled(4, !emptyClipboard)
switch getMode() {
case modeRename:
app.SetFocus(renameField)
return
case modeNewFolder:
app.SetFocus(newFolderField)
return
case modeDelete:
app.SetFocus(modalPopup)
return
}
switch currentFocus {
case 0:
app.SetFocus(devicesDropDown)
case 1:
app.SetFocus(localBuffer)
case 2:
app.SetFocus(remoteBuffer)
}
}
func browseLocal(p string) {
localLock.Lock()
defer localLock.Unlock()
localEntries = nil
localPath = p
var skippedFirst bool
err := filepath.Walk(p, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
} else if !skippedFirst {
skippedFirst = true
return nil
} else if path == "." || path == ".." {
return nil
}
localEntries = append(localEntries, &adb.DirEntry{Name: info.Name(), Mode: info.Mode(), Size: int32(info.Size()), ModifiedAt: info.ModTime()})
// Do not search recursively
if info.IsDir() {
return filepath.SkipDir
}
return nil
})
if err != nil {
log.Fatal(err)
}
localEntries = append(localEntries, &adb.DirEntry{Name: "..", Mode: os.ModeDir})
sort.Slice(localEntries, func(i, j int) bool {
if localEntries[i].Name == ".." {
return true
} else if localEntries[i].Mode&os.ModeDir != localEntries[j].Mode&os.ModeDir {
return localEntries[i].Mode&os.ModeDir != 0
}
return strings.ToLower(localEntries[i].Name) < strings.ToLower(localEntries[j].Name)
})
app.QueueUpdateDraw(func() {
localLock.Lock()
defer localLock.Unlock()
localBuffer.SetTitle(" " + p + " ")
localBuffer.Clear()
localEntriesShown = nil
for _, entry := range localEntries {
if len(entry.Name) > 0 && entry.Name[0] == '.' && entry.Name != ".." && !showHidden {
continue
}
label := entry.Name
if entry.Mode&os.ModeDir != 0 {
label += "/"
}
localBuffer.AddItem(cview.NewListItem(cview.Escape(label)))
localEntriesShown = append(localEntriesShown, entry)
}
})
}
func browseRemote(p string) {
remoteLock.Lock()
defer remoteLock.Unlock()
previousSelection := -1
previousOffset := -1
if remotePath == p {
previousSelection = remoteBuffer.GetCurrentItemIndex()
previousOffset, _ = remoteBuffer.GetOffset()
}
oldPath := remotePath
app.QueueUpdateDraw(func() {
remoteBuffer.SetTitle(" " + oldPath + "... ")
})
remotePath = p
var err error
remoteEntries, err = bridge.directoryEntries(p)
if err != nil {
log.Fatalf("failed to list files: %s", err)
}
go app.QueueUpdateDraw(func() {
remoteLock.Lock()
defer remoteLock.Unlock()
remoteBuffer.SetTitle(" " + p + " ")
remoteBuffer.Clear()
remoteEntriesShown = nil
for _, entry := range remoteEntries {
if len(entry.Name) > 0 && entry.Name[0] == '.' && entry.Name != ".." && !showHidden {
continue
}
label := entry.Name
if entry.Mode&os.ModeDir != 0 {
label += "/"
}
remoteBuffer.AddItem(cview.NewListItem(cview.Escape(label)))
remoteEntriesShown = append(remoteEntriesShown, entry)
}
if previousSelection >= 0 {
remoteBuffer.SetCurrentItem(previousSelection)
remoteBuffer.SetOffset(previousOffset, 0)
}
})
}