diff --git a/dialog/file.go b/dialog/file.go index 19c03796ee..2b023c796d 100644 --- a/dialog/file.go +++ b/dialog/file.go @@ -1,7 +1,7 @@ package dialog import ( - "io/ioutil" + "fmt" "os" "path/filepath" "strings" @@ -29,7 +29,7 @@ type fileDialog struct { win *widget.PopUp selected *fileDialogItem - dir string + dir fyne.ListableURI } // FileDialog is a dialog containing a file picker for use in opening or saving files. @@ -41,6 +41,10 @@ type FileDialog struct { parent fyne.Window dialog *fileDialog dismissText string + + // StartingLocation allows overriding the default location where the + // file dialog should "view" when it is first opened. + StartingLocation fyne.ListableURI } // Declare conformity to Dialog interface @@ -77,17 +81,21 @@ func (f *fileDialog) makeUI() fyne.CanvasObject { if f.file.save { callback := f.file.callback.(func(fyne.URIWriteCloser, error)) name := f.fileName.(*widget.Entry).Text - path := filepath.Join(f.dir, name) + path, _ := storage.Child(f.dir, name) + + exists, _ := storage.Exists(path) - info, err := os.Stat(path) - if os.IsNotExist(err) { + _, err := storage.ListerForURI(path) + + if !exists { f.win.Hide() if f.file.onClosedCallback != nil { f.file.onClosedCallback(true) } - callback(storage.SaveFileToURI(storage.NewURI("file://" + path))) + callback(storage.SaveFileToURI(path)) return - } else if info.IsDir() { + } else if err != nil { + // check if the directory exists ShowInformation("Cannot overwrite", "Files cannot replace a directory,\ncheck the file name and try again", f.file.parent) return @@ -101,7 +109,7 @@ func (f *fileDialog) makeUI() fyne.CanvasObject { return } - callback(storage.SaveFileToURI(storage.NewURI("file://" + path))) + callback(storage.SaveFileToURI(path)) if f.file.onClosedCallback != nil { f.file.onClosedCallback(true) } @@ -112,7 +120,9 @@ func (f *fileDialog) makeUI() fyne.CanvasObject { if f.file.onClosedCallback != nil { f.file.onClosedCallback(true) } - callback(storage.OpenFileFromURI(storage.NewURI("file://" + f.selected.path))) + path := f.selected.location + // On windows replace '\\' with '/' + callback(storage.OpenFileFromURI(path)) } }) f.open.Style = widget.PrimaryButton @@ -151,54 +161,113 @@ func (f *fileDialog) makeUI() fyne.CanvasObject { body := fyne.NewContainerWithLayout(layout.NewBorderLayout(scrollBread, nil, nil, nil), scrollBread, f.fileScroll) header := widget.NewLabelWithStyle(label+" File", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - favorites := widget.NewGroup("Favorites", f.loadFavorites()...) - return fyne.NewContainerWithLayout(layout.NewBorderLayout(header, footer, favorites, nil), - favorites, header, footer, body) + + favorites, err := f.loadFavorites() + if err != nil { + // only generate the Favorites group if we were able to load + // them successfully + favorites = []fyne.CanvasObject{} + fyne.LogError("Unable to load favorites", err) + } + + favoritesGroup := widget.NewGroup("Favorites", favorites...) + return fyne.NewContainerWithLayout(layout.NewBorderLayout(header, footer, favoritesGroup, nil), + favoritesGroup, header, footer, body) + } -func (f *fileDialog) loadFavorites() []fyne.CanvasObject { - home, _ := os.UserHomeDir() - places := []fyne.CanvasObject{ - makeFavoriteButton("Home", theme.HomeIcon(), func() { +func (f *fileDialog) loadFavorites() ([]fyne.CanvasObject, error) { + var home fyne.ListableURI + var documents fyne.ListableURI + var downloads fyne.ListableURI + var osHome string + var err, err1 error + + osHome, err = os.UserHomeDir() + + if err == nil { + home, err1 = storage.ListerForURI(storage.NewURI("file://" + osHome)) + if err1 == nil { + var documentsURI fyne.URI + documentsURI, err1 = storage.Child(home, "Documents") + if err1 == nil { + documents, err1 = storage.ListerForURI(documentsURI) + if err1 != nil { + err = err1 + } + } else { + err = err1 + } + var downloadsURI fyne.URI + downloadsURI, err1 = storage.Child(home, "Downloads") + if err1 == nil { + downloads, err1 = storage.ListerForURI(downloadsURI) + if err1 != nil { + err = err1 + } + } else { + err = err1 + } + } else { + err = err1 + } + } + + var places []fyne.CanvasObject + + if home != nil { + places = append(places, makeFavoriteButton("Home", theme.HomeIcon(), func() { f.setDirectory(home) - }), - makeFavoriteButton("Documents", theme.DocumentIcon(), func() { - f.setDirectory(filepath.Join(home, "Documents")) - }), - makeFavoriteButton("Downloads", theme.DownloadIcon(), func() { - f.setDirectory(filepath.Join(home, "Downloads")) - }), + })) + } + if documents != nil { + places = append(places, makeFavoriteButton("Documents", theme.DocumentIcon(), func() { + f.setDirectory(documents) + })) + } + if downloads != nil { + places = append(places, makeFavoriteButton("Downloads", theme.DownloadIcon(), func() { + f.setDirectory(downloads) + })) } places = append(places, f.loadPlaces()...) - return places + return places, err } -func (f *fileDialog) refreshDir(dir string) { +func (f *fileDialog) refreshDir(dir fyne.ListableURI) { f.files.Objects = nil - files, err := ioutil.ReadDir(dir) + files, err := dir.List() if err != nil { - fyne.LogError("Unable to read path "+dir, err) + fyne.LogError("Unable to read path "+dir.String(), err) return } var icons []fyne.CanvasObject - parent := filepath.Dir(dir) - if parent != dir { - fi := &fileDialogItem{picker: f, name: "(Parent)", path: filepath.Dir(dir), dir: true} + parent, err := storage.Parent(dir) + if err != nil && err != storage.URIRootError { + fyne.LogError("Unable to get parent of "+dir.String(), err) + return + } + if parent != nil && parent.String() != dir.String() { + fi := &fileDialogItem{picker: f, + name: "(Parent)", location: parent, dir: true} fi.ExtendBaseWidget(fi) icons = append(icons, fi) } for _, file := range files { - if isHidden(file.Name(), dir) { + if isHidden(file.Name(), dir.Name()) { continue } - itemPath := filepath.Join(dir, file.Name()) - if file.IsDir() { - icons = append(icons, f.newFileItem(itemPath, true)) - } else if f.file.filter == nil || f.file.filter.Matches(storage.NewURI("file://"+itemPath)) { - icons = append(icons, f.newFileItem(itemPath, false)) + + _, err := storage.ListerForURI(file) + if err == nil { + // URI points to a directory + icons = append(icons, f.newFileItem(file, true)) + + } else if f.file.filter == nil || f.file.filter.Matches(file) { + icons = append(icons, f.newFileItem(file, false)) } } @@ -208,13 +277,20 @@ func (f *fileDialog) refreshDir(dir string) { f.fileScroll.Refresh() } -func (f *fileDialog) setDirectory(dir string) { +func (f *fileDialog) setDirectory(dir fyne.ListableURI) error { + if dir == nil { + return fmt.Errorf("failed to open nil directory") + } + f.setSelected(nil) f.dir = dir f.breadcrumb.Children = nil - buildDir := filepath.VolumeName(dir) - for i, d := range strings.Split(dir, string(filepath.Separator)) { + + localdir := dir.String()[len(dir.Scheme())+3:] + + buildDir := filepath.VolumeName(localdir) + for i, d := range strings.Split(localdir, "/") { if d == "" { if i > 0 { // what we get if we split "/" break @@ -228,15 +304,23 @@ func (f *fileDialog) setDirectory(dir string) { buildDir = d + string(os.PathSeparator) } - newDir := buildDir + newDir, err := storage.ListerForURI(storage.NewURI("file://" + buildDir)) + if err != nil { + return err + } f.breadcrumb.Append( widget.NewButton(d, func() { - f.setDirectory(newDir) + err := f.setDirectory(newDir) + if err != nil { + fyne.LogError("Failed to set directory", err) + } }), ) } f.refreshDir(dir) + + return nil } func (f *fileDialog) setSelected(file *fileDialogItem) { @@ -245,39 +329,85 @@ func (f *fileDialog) setSelected(file *fileDialogItem) { f.selected.Refresh() } if file != nil && file.isDirectory() { - f.setDirectory(file.path) + lister, err := storage.ListerForURI(file.location) + if err != nil { + fyne.LogError("Failed to create lister for URI"+file.location.String(), err) + } + f.setDirectory(lister) return } f.selected = file - if file == nil || file.path == "" { + if file == nil || file.location.String()[len(file.location.Scheme())+3:] == "" { f.fileName.SetText("") f.open.Disable() } else { file.isCurrent = true - f.fileName.SetText(filepath.Base(file.path)) + f.fileName.SetText(file.location.Name()) f.open.Enable() } } -// effectiveStartingDir calculates the directory at which the file dialog -// should open, based on the values of CWD, home, and any error conditions -// which occur. +// effectiveStartingDir calculates the directory at which the file dialog should +// open, based on the values of StartingDirectory, CWD, home, and any error +// conditions which occur. // // Order of precedence is: // +// * file.StartingDirectory if non-empty, os.Stat()-able, and uses the file:// +// URI scheme // * os.UserHomeDir() +// * os.Getwd() // * "/" (should be filesystem root on all supported platforms) -func (f *FileDialog) effectiveStartingDir() string { +// +func (f *FileDialog) effectiveStartingDir() fyne.ListableURI { + var startdir fyne.ListableURI = nil + + if f.StartingLocation != nil { + startdir = f.StartingLocation + } + + if startdir != nil { + if startdir.Scheme() == "file" { + path := startdir.String()[len(startdir.Scheme())+3:] + + // the starting directory is set explicitly + if _, err := os.Stat(path); err != nil { + fyne.LogError("Error with StartingLocation", err) + } else { + return startdir + } + } + + } // Try home dir dir, err := os.UserHomeDir() if err == nil { - return dir + lister, err := storage.ListerForURI(storage.NewURI("file://" + dir)) + if err == nil { + return lister + } + fyne.LogError("Could not create lister for user home dir", err) } fyne.LogError("Could not load user home dir", err) - return "/" + // Try to get ./ + wd, err := os.Getwd() + if err == nil { + lister, err := storage.ListerForURI(storage.NewURI("file://" + wd)) + if err == nil { + return lister + } + fyne.LogError("Could not create lister for working dir", err) + } + + lister, err := storage.ListerForURI(storage.NewURI("file:///")) + if err != nil { + fyne.LogError("could not create lister for /", err) + return nil + } + return lister } func showFile(file *FileDialog) *fileDialog { diff --git a/dialog/file_other.go b/dialog/file_other.go index 368737befd..27359d4e49 100644 --- a/dialog/file_other.go +++ b/dialog/file_other.go @@ -4,12 +4,18 @@ package dialog import ( "fyne.io/fyne" + "fyne.io/fyne/storage" "fyne.io/fyne/theme" ) func (f *fileDialog) loadPlaces() []fyne.CanvasObject { return []fyne.CanvasObject{makeFavoriteButton("Computer", theme.ComputerIcon(), func() { - f.setDirectory("/") + lister, err := storage.ListerForURI(storage.NewURI("file:///")) + if err != nil { + fyne.LogError("could not create lister for /", err) + return + } + f.setDirectory(lister) })} } diff --git a/dialog/file_test.go b/dialog/file_test.go index bbfbfff6fd..afe899e7f8 100644 --- a/dialog/file_test.go +++ b/dialog/file_test.go @@ -4,6 +4,7 @@ import ( "log" "os" "path/filepath" + "runtime" "strings" "testing" @@ -27,7 +28,10 @@ import ( // abstracts out the requisite error handling. // // You should only call this function on paths that you expect to be valid. -func comparePaths(t *testing.T, p1, p2 string) bool { +func comparePaths(t *testing.T, u1, u2 fyne.ListableURI) bool { + p1 := u1.String()[len(u1.Scheme())+3:] + p2 := u2.String()[len(u2.Scheme())+3:] + a1, err := filepath.Abs(p1) if err != nil { t.Fatalf("Failed to normalize path '%s'", p1) @@ -42,14 +46,31 @@ func comparePaths(t *testing.T, p1, p2 string) bool { } func TestEffectiveStartingDir(t *testing.T) { - home, err := os.UserHomeDir() + + homeString, err := os.UserHomeDir() + if err != nil { + t.Skipf("os.Gethome() failed, cannot run this test on this system (error stat()-ing ../) error was '%s'", err) + } + home, err := storage.ListerForURI(storage.NewURI("file://" + homeString)) if err != nil { - t.Skipf("os.UserHomeDir) failed, cannot run this test on this system, error was '%s'", err) + t.Skipf("could not get lister for working directory: %s", err) + } + + parentURI, err := storage.Parent(home) + if err != nil { + t.Skipf("Could not get parent of working directory: %s", err) + } + + parent, err := storage.ListerForURI(parentURI) + t.Log(parentURI) + t.Log(parent) + if err != nil { + t.Skipf("Could not get lister for parent of working directory: %s", err) } dialog := &FileDialog{} - // test that we get $HOME when running with the default struct values + // test that we get wd when running with the default struct values res := dialog.effectiveStartingDir() expect := home if !comparePaths(t, res, expect) { @@ -57,6 +78,36 @@ func TestEffectiveStartingDir(t *testing.T) { expect, res) } + // this should always be equivalent to the preceding test + dialog.StartingLocation = nil + res = dialog.effectiveStartingDir() + expect = home + if !comparePaths(t, res, expect) { + t.Errorf("Expected effectiveStartingDir() to be '%s', but it was '%s'", + expect, res) + } + + // check using StartingDirectory with some other directory + dialog.StartingLocation = parent + res = dialog.effectiveStartingDir() + expect = parent + if res != expect { + t.Errorf("Expected effectiveStartingDir() to be '%s', but it was '%s'", + expect, res) + } + + // make sure we fail over if the specified directory does not exist + dialog.StartingLocation, err = storage.ListerForURI(storage.NewURI("file:///some/file/that/does/not/exist")) + if err == nil { + t.Errorf("Should have failed to create lister for nonexistant file") + } + res = dialog.effectiveStartingDir() + expect = home + if res.String() != expect.String() { + t.Errorf("Expected effectiveStartingDir() to be '%s', but it was '%s'", + expect, res) + } + } func TestFileDialogResize(t *testing.T) { @@ -130,6 +181,27 @@ func TestShowFileOpen(t *testing.T) { buttons := ui.Objects[2].(*fyne.Container).Objects[0].(*widget.Box) open := buttons.Children[1].(*widget.Button) + breadcrumb := ui.Objects[3].(*fyne.Container).Objects[0].(*widget.ScrollContainer).Content.(*widget.Box) + assert.Greater(t, len(breadcrumb.Children), 0) + + home, err := os.UserHomeDir() + if runtime.GOOS == "windows" { + // on windows os.Gethome() returns '\' + home = strings.ReplaceAll(home, "\\", "/") + } + t.Logf("home='%s'", home) + assert.Nil(t, err) + components := strings.Split(home, "/") + // note that normally when we do a string split, it's going to come up + // "", but we actually want the path bar to show "/". + components[0] = "/" + if assert.Equal(t, len(components), len(breadcrumb.Children)) { + for i := range components { + t.Logf("i=%d components[i]='%s' breadcrumb...Text[i]='%s'", i, components[i], breadcrumb.Children[i].(*widget.Button).Text) + assert.Equal(t, components[i], breadcrumb.Children[i].(*widget.Button).Text) + } + } + files := ui.Objects[3].(*fyne.Container).Objects[1].(*widget.ScrollContainer).Content.(*fyne.Container) assert.Greater(t, len(files.Objects), 0) @@ -152,15 +224,16 @@ func TestShowFileOpen(t *testing.T) { // This will only execute if we have a file in the home path. // Until we have a way to set the directory of an open file dialog. test.Tap(target) - assert.Equal(t, filepath.Base(target.path), nameLabel.Text) + assert.Equal(t, target.location.Name(), nameLabel.Text) assert.False(t, open.Disabled()) test.Tap(open) assert.Nil(t, win.Canvas().Overlays().Top()) assert.Nil(t, openErr) - assert.Equal(t, "file://"+target.path, chosen.URI().String()) - err := chosen.Close() + assert.Equal(t, target.location.String(), chosen.URI().String()) + + err = chosen.Close() assert.Nil(t, err) } @@ -207,7 +280,7 @@ func TestShowFileSave(t *testing.T) { // This will only execute if we have a file in the home path. // Until we have a way to set the directory of an open file dialog. test.Tap(target) - assert.Equal(t, filepath.Base(target.path), nameEntry.Text) + assert.Equal(t, target.location.Name(), nameEntry.Text) assert.False(t, save.Disabled()) // we are about to overwrite, a warning will show @@ -221,12 +294,17 @@ func TestShowFileSave(t *testing.T) { test.Tap(save) assert.Nil(t, win.Canvas().Overlays().Top()) assert.Nil(t, saveErr) - expectedPath := filepath.Join(filepath.Dir(target.path), "v2_"+filepath.Base(target.path)) - assert.Equal(t, "file://"+expectedPath, chosen.URI().String()) + targetParent, err := storage.Parent(target.location) + if err != nil { + t.Error(err) + } + expectedPath, _ := storage.Child(targetParent, "v2_"+target.location.Name()) + assert.Equal(t, expectedPath.String(), chosen.URI().String()) - err := chosen.Close() + err = chosen.Close() assert.Nil(t, err) - err = os.Remove(expectedPath) + pathString := expectedPath.String()[len(expectedPath.Scheme())+3:] + err = os.Remove(pathString) assert.Nil(t, err) } @@ -243,14 +321,18 @@ func TestFileFilters(t *testing.T) { fyne.LogError("Could not get current working directory", err) t.FailNow() } - testDataDir := filepath.Join(workingDir, "testdata") + testDataDir := storage.NewURI("file://" + filepath.Join(workingDir, "testdata")) + testDataLister, err := storage.ListerForURI(testDataDir) + if err != nil { + t.Error(err) + } - f.dialog.setDirectory(testDataDir) + f.dialog.setDirectory(testDataLister) count := 0 for _, icon := range f.dialog.files.Objects { if icon.(*fileDialogItem).dir == false { - uri := storage.NewURI("file://" + icon.(*fileDialogItem).path) + uri := icon.(*fileDialogItem).location assert.Equal(t, uri.Extension(), ".png") count++ } @@ -262,7 +344,7 @@ func TestFileFilters(t *testing.T) { count = 0 for _, icon := range f.dialog.files.Objects { if icon.(*fileDialogItem).dir == false { - uri := storage.NewURI("file://" + icon.(*fileDialogItem).path) + uri := icon.(*fileDialogItem).location assert.Equal(t, uri.MimeType(), "image/jpeg") count++ } @@ -274,7 +356,7 @@ func TestFileFilters(t *testing.T) { count = 0 for _, icon := range f.dialog.files.Objects { if icon.(*fileDialogItem).dir == false { - uri := storage.NewURI("file://" + icon.(*fileDialogItem).path) + uri := icon.(*fileDialogItem).location mimeType := strings.Split(uri.MimeType(), "/")[0] assert.Equal(t, mimeType, "image") count++ diff --git a/dialog/file_windows.go b/dialog/file_windows.go index f257dfeb5f..bae024c8d1 100644 --- a/dialog/file_windows.go +++ b/dialog/file_windows.go @@ -6,6 +6,7 @@ import ( "syscall" "fyne.io/fyne" + "fyne.io/fyne/storage" "fyne.io/fyne/theme" ) @@ -50,8 +51,9 @@ func (f *fileDialog) loadPlaces() []fyne.CanvasObject { for _, drive := range listDrives() { driveRoot := drive + string(os.PathSeparator) // capture loop var + driveRootURI, _ := storage.ListerForURI(storage.NewURI("file://" + driveRoot)) places = append(places, makeFavoriteButton(drive, theme.StorageIcon(), func() { - f.setDirectory(driveRoot) + f.setDirectory(driveRootURI) })) } return places diff --git a/dialog/fileitem.go b/dialog/fileitem.go index a796837e06..afe31b109b 100644 --- a/dialog/fileitem.go +++ b/dialog/fileitem.go @@ -6,7 +6,6 @@ import ( "fyne.io/fyne" "fyne.io/fyne/canvas" - "fyne.io/fyne/storage" "fyne.io/fyne/theme" "fyne.io/fyne/widget" ) @@ -22,9 +21,9 @@ type fileDialogItem struct { picker *fileDialog isCurrent bool - name string - path string - dir bool + name string + location fyne.URI + dir bool } func (i *fileDialogItem) Tapped(_ *fyne.PointEvent) { @@ -43,15 +42,16 @@ func (i *fileDialogItem) CreateRenderer() fyne.WidgetRenderer { if i.dir { icon = canvas.NewImageFromResource(theme.FolderIcon()) } else { - icon = NewFileIcon(storage.NewURI("file://" + i.path)) + icon = NewFileIcon(i.location) } return &fileItemRenderer{item: i, icon: icon, text: text, objects: []fyne.CanvasObject{icon, text}} } -func fileName(path string) (name string) { - name = filepath.Base(path) +func fileName(path fyne.URI) (name string) { + pathstr := path.String()[len(path.Scheme())+3:] + name = filepath.Base(pathstr) ext := filepath.Ext(name[1:]) name = name[:len(name)-len(ext)] @@ -62,19 +62,19 @@ func (i *fileDialogItem) isDirectory() bool { return i.dir } -func (f *fileDialog) newFileItem(path string, dir bool) *fileDialogItem { +func (f *fileDialog) newFileItem(location fyne.URI, dir bool) *fileDialogItem { var name string if dir { - name = filepath.Base(path) + name = location.Name() } else { - name = fileName(path) + name = fileName(location) } ret := &fileDialogItem{ - picker: f, - name: name, - path: path, - dir: dir, + picker: f, + name: name, + location: location, + dir: dir, } ret.ExtendBaseWidget(ret) return ret diff --git a/dialog/fileitem_test.go b/dialog/fileitem_test.go index d7a68deffc..9f1c968bfd 100644 --- a/dialog/fileitem_test.go +++ b/dialog/fileitem_test.go @@ -4,6 +4,8 @@ import ( "path/filepath" "testing" + "fyne.io/fyne/storage" + "fyne.io/fyne/test" "github.com/stretchr/testify/assert" ) @@ -12,13 +14,13 @@ func TestFileItem_Name(t *testing.T) { f := &fileDialog{file: &FileDialog{}} _ = f.makeUI() - item := f.newFileItem("/path/to/filename.txt", false) + item := f.newFileItem(storage.NewURI("file:///path/to/filename.txt"), false) assert.Equal(t, "filename", item.name) - item = f.newFileItem("/path/to/MyFile.jpeg", false) + item = f.newFileItem(storage.NewURI("file:///path/to/MyFile.jpeg"), false) assert.Equal(t, "MyFile", item.name) - item = f.newFileItem("/path/to/.maybeHidden.txt", false) + item = f.newFileItem(storage.NewURI("file:///path/to/.maybeHidden.txt"), false) assert.Equal(t, ".maybeHidden", item.name) } @@ -26,20 +28,20 @@ func TestFileItem_FolderName(t *testing.T) { f := &fileDialog{file: &FileDialog{}} _ = f.makeUI() - item := f.newFileItem("/path/to/foldername/", true) + item := f.newFileItem(storage.NewURI("file:///path/to/foldername/"), true) assert.Equal(t, "foldername", item.name) - item = f.newFileItem("/path/to/myapp.app/", true) + item = f.newFileItem(storage.NewURI("file:///path/to/myapp.app/"), true) assert.Equal(t, "myapp.app", item.name) - item = f.newFileItem("/path/to/.maybeHidden/", true) + item = f.newFileItem(storage.NewURI("file:///path/to/.maybeHidden/"), true) assert.Equal(t, ".maybeHidden", item.name) } func TestNewFileItem(t *testing.T) { f := &fileDialog{file: &FileDialog{}} _ = f.makeUI() - item := f.newFileItem("/path/to/filename.txt", false) + item := f.newFileItem(storage.NewURI("file:///path/to/filename.txt"), false) assert.Equal(t, "filename", item.name) @@ -52,26 +54,39 @@ func TestNewFileItem_Folder(t *testing.T) { f := &fileDialog{file: &FileDialog{}} _ = f.makeUI() currentDir, _ := filepath.Abs(".") - parentDir := filepath.Dir(currentDir) - f.setDirectory(parentDir) - item := f.newFileItem(currentDir, true) + currentLister, err := storage.ListerForURI(storage.NewURI("file://" + currentDir)) + if err != nil { + t.Error(err) + } + + parentDir := storage.NewURI("file://" + filepath.Dir(currentDir)) + parentLister, err := storage.ListerForURI(parentDir) + if err != nil { + t.Error(err) + } + f.setDirectory(parentLister) + item := f.newFileItem(currentLister, true) assert.Equal(t, filepath.Base(currentDir), item.name) test.Tap(item) assert.False(t, item.isCurrent) assert.Equal(t, (*fileDialogItem)(nil), f.selected) - assert.Equal(t, currentDir, f.dir) + assert.Equal(t, storage.NewURI("file://"+currentDir).String(), f.dir.String()) } func TestNewFileItem_ParentFolder(t *testing.T) { f := &fileDialog{file: &FileDialog{}} _ = f.makeUI() currentDir, _ := filepath.Abs(".") - parentDir := filepath.Dir(currentDir) - f.setDirectory(currentDir) - - item := &fileDialogItem{picker: f, name: "(Parent)", path: parentDir, dir: true} + currentLister, err := storage.ListerForURI(storage.NewURI("file://" + currentDir)) + if err != nil { + t.Error(err) + } + parentDir := storage.NewURI("file://" + filepath.Dir(currentDir)) + f.setDirectory(currentLister) + + item := &fileDialogItem{picker: f, name: "(Parent)", location: parentDir, dir: true} item.ExtendBaseWidget(item) assert.Equal(t, "(Parent)", item.name) @@ -79,5 +94,5 @@ func TestNewFileItem_ParentFolder(t *testing.T) { test.Tap(item) assert.False(t, item.isCurrent) assert.Equal(t, (*fileDialogItem)(nil), f.selected) - assert.Equal(t, parentDir, f.dir) + assert.Equal(t, parentDir.String(), f.dir.String()) } diff --git a/storage/uri.go b/storage/uri.go index 60789c2ec4..ffcce322b7 100644 --- a/storage/uri.go +++ b/storage/uri.go @@ -2,9 +2,11 @@ package storage import ( "bufio" + "fmt" "mime" + "os" "path/filepath" - "regexp" + "runtime" "strings" "unicode/utf8" @@ -18,6 +20,16 @@ type uri struct { // NewURI creates a new URI from the given string representation. // This could be a URI from an external source or one saved from URI.String() func NewURI(u string) fyne.URI { + // URIs are supposed to use forward slashes. On Windows, it + // should be OK to use the platform native filepath with UNIX + // or NT sytle paths, with / or \, but when we reconstruct + // the URI, we want to have / only. + if runtime.GOOS == "windows" { + // seems that sometimes we end up with + // double-backslashes + u = strings.ReplaceAll(u, "\\\\", "/") + u = strings.ReplaceAll(u, "\\", "/") + } return &uri{raw: u} } @@ -59,6 +71,41 @@ func (u *uri) String() string { return u.raw } +// parentGeneric is a generic function that returns the last element of a +// path after splitting it on "/". It should be suitable for most URIs. +func parentGeneric(location string) (string, error) { + + // trim leading forward slashes + trimmed := 0 + for location[0] == '/' { + location = location[1:] + trimmed++ + + // if all we have left is an empty string, than this URI + // pointed to a UNIX-style root + if len(location) == 0 { + return "", URIRootError + } + } + + components := strings.Split(location, "/") + + if len(components) == 1 { + return "", URIRootError + } + + parent := "" + if trimmed > 2 && len(components) > 1 { + // Because we trimmed all the leading '/' characters, for UNIX + // style paths we want to insert one back in. Presumably we + // trimmed two instances of / for the scheme. + parent = parent + "/" + } + parent = parent + strings.Join(components[0:len(components)-1], "/") + "/" + + return parent, nil +} + // Parent gets the parent of a URI by splitting it along '/' separators and // removing the last item. func Parent(u fyne.URI) (fyne.URI, error) { @@ -69,43 +116,71 @@ func Parent(u fyne.URI) (fyne.URI, error) { s = s[0 : len(s)-1] } - // trim the scheme (and +1 for the :) - s = s[len(u.Scheme())+1:] + // trim the scheme + s = s[len(u.Scheme())+3:] // Completely empty URI with just a scheme if len(s) == 0 { return nil, URIRootError } - // trim leading forward slashes - trimmed := 0 - for s[0] == '/' { - s = s[1:] - trimmed++ + parent := "" + if u.Scheme() == "file" { + // use the system native path resolution + parent = filepath.Dir(s) + if parent[len(parent)-1] != filepath.Separator { + parent += "/" + } - // if all we have left is an empty string, than this URI - // pointed to a UNIX-style root - if len(s) == 0 { + // only root is it's own parent + if filepath.Clean(parent) == filepath.Clean(s) { return nil, URIRootError } + + } else { + var err error + parent, err = parentGeneric(s) + if err != nil { + return nil, err + } } - // handle Windows drive letters - r := regexp.MustCompile("[A-Za-z][:]") - components := strings.Split(s, "/") - if len(components) == 1 && r.MatchString(components[0]) && trimmed <= 2 { - // trimmed <= 2 makes sure we handle UNIX-style paths on - // Windows correctly - return nil, URIRootError + return NewURI(u.Scheme() + "://" + parent), nil +} + +// Child appends a new path element to a URI, separated by a '/' character. +func Child(u fyne.URI, component string) (fyne.URI, error) { + // While as implemented this does not need to return an error, it is + // reasonable to expect that future implementations of this, especially + // once it gets moved into the URI interface will need to do so. This + // also brings it in line with Parent(). + + s := u.String() + + // guarantee that there will be a path separator + if s[len(s)-1:] != "/" { + s += "/" } - parent := u.Scheme() + "://" - if trimmed > 2 && len(components) > 1 { - // Because we trimmed all the leading '/' characters, for UNIX - // style paths we want to insert one back in. Presumably we - // trimmed two instances of / for the scheme. - parent = parent + "/" + return NewURI(s + component), nil +} + +// Exists will return true if the resource the URI refers to exists, and false +// otherwise. If an error occurs while checking, false is returned as the first +// return. +func Exists(u fyne.URI) (bool, error) { + if u.Scheme() != "file" { + return false, fmt.Errorf("don't know how to check existence of %s scheme", u.Scheme()) } - parent = parent + strings.Join(components[0:len(components)-1], "/") + "/" - return NewURI(parent), nil + + _, err := os.Stat(u.String()[len(u.Scheme())+3:]) + if os.IsNotExist(err) { + return false, nil + } + + if err != nil { + return false, err + } + + return true, nil } diff --git a/storage/uri_test.go b/storage/uri_test.go index 3665d0d76d..01d1034b0c 100644 --- a/storage/uri_test.go +++ b/storage/uri_test.go @@ -1,6 +1,7 @@ package storage_test import ( + "runtime" "testing" "fyne.io/fyne/storage" @@ -62,14 +63,49 @@ func TestURI_Parent(t *testing.T) { assert.Nil(t, err) assert.Equal(t, "file://C:/foo/bar/", parent.String()) - _, err = storage.Parent(storage.NewURI("file:///")) + parent, err = storage.Parent(storage.NewURI("http://foo/bar/baz/")) + assert.Nil(t, err) + assert.Equal(t, "http://foo/bar/", parent.String()) + + parent, err = storage.Parent(storage.NewURI("http:////foo/bar/baz/")) + assert.Nil(t, err) + assert.Equal(t, "http://foo/bar/", parent.String()) + + _, err = storage.Parent(storage.NewURI("http://foo")) assert.Equal(t, storage.URIRootError, err) - // This should cause an error, since this is a Windows-style path and - // thus we can't get the parent of a drive letter. - _, err = storage.Parent(storage.NewURI("file://C:/")) + _, err = storage.Parent(storage.NewURI("http:///")) assert.Equal(t, storage.URIRootError, err) + parent, err = storage.Parent(storage.NewURI("https://///foo/bar/")) + assert.Nil(t, err) + assert.Equal(t, "https:///foo/", parent.String()) + + if runtime.GOOS == "windows" { + // Only the Windows version of filepath will know how to handle + // backslashes. + uri := storage.NewURI("file://C:\\foo\\bar\\baz\\") + assert.Equal(t, "file://C:/foo/bar/baz/", uri.String()) + + parent, err = storage.Parent(uri) + assert.Nil(t, err) + assert.Equal(t, "file://C:/foo/bar/", parent.String()) + } + + _, err = storage.Parent(storage.NewURI("file:///")) + assert.Equal(t, storage.URIRootError, err) + + if runtime.GOOS == "windows" { + // This is only an error under Windows, on *NIX this is a + // relative path to a directory named "C:", which is completely + // valid. + + // This should cause an error, since this is a Windows-style + // path and thus we can't get the parent of a drive letter. + _, err = storage.Parent(storage.NewURI("file://C:/")) + assert.Equal(t, storage.URIRootError, err) + } + // Windows supports UNIX-style paths. /C:/ is also a valid path. parent, err = storage.Parent(storage.NewURI("file:///C:/")) assert.Nil(t, err) @@ -82,3 +118,21 @@ func TestURI_Extension(t *testing.T) { assert.Equal(t, ".JPEG", storage.NewURI("file://C:/image.JPEG").Extension()) assert.Equal(t, "", storage.NewURI("file://C:/directory/").Extension()) } + +func TestURI_Child(t *testing.T) { + p, _ := storage.Child(storage.NewURI("file:///foo/bar"), "baz") + assert.Equal(t, "file:///foo/bar/baz", p.String()) + + p, _ = storage.Child(storage.NewURI("file:///foo/bar/"), "baz") + assert.Equal(t, "file:///foo/bar/baz", p.String()) + + if runtime.GOOS == "windows" { + // Only the Windows version of filepath will know how to handle + // backslashes. + uri := storage.NewURI("file://C:\\foo\\bar\\") + assert.Equal(t, "file://C:/foo/bar/", uri.String()) + + p, _ = storage.Child(uri, "baz") + assert.Equal(t, "file://C:/foo/bar/baz", p.String()) + } +} diff --git a/test/testfile.go b/test/testfile.go index 59f1564a9d..eaa7d8045a 100644 --- a/test/testfile.go +++ b/test/testfile.go @@ -3,6 +3,7 @@ package test import ( "fmt" "io" + "io/ioutil" "os" "path/filepath" @@ -15,6 +16,13 @@ type file struct { path string } +type directory struct { + fyne.URI +} + +// Declare conformity to the ListableURI interface +var _ fyne.ListableURI = (*directory)(nil) + func (f *file) Open() (io.ReadCloser, error) { return os.Open(f.path) } @@ -57,5 +65,40 @@ func (d *testDriver) FileWriterForURI(uri fyne.URI) (fyne.URIWriteCloser, error) } func (d *testDriver) ListerForURI(uri fyne.URI) (fyne.ListableURI, error) { - return nil, fmt.Errorf("test driver does support creating listable URIs yet") + if uri.Scheme() != "file" { + return nil, fmt.Errorf("unsupported URL protocol") + } + + path := uri.String()[len(uri.Scheme())+3 : len(uri.String())] + s, err := os.Stat(path) + if err != nil { + return nil, err + } + + if !s.IsDir() { + return nil, fmt.Errorf("path '%s' is not a directory, cannot convert to listable URI", path) + } + + return &directory{URI: uri}, nil +} + +func (d *directory) List() ([]fyne.URI, error) { + if d.Scheme() != "file" { + return nil, fmt.Errorf("unsupported URL protocol") + } + + path := d.String()[len(d.Scheme())+3 : len(d.String())] + files, err := ioutil.ReadDir(path) + if err != nil { + return nil, err + } + + urilist := []fyne.URI{} + + for _, f := range files { + uri := storage.NewURI("file://" + filepath.Join(path, f.Name())) + urilist = append(urilist, uri) + } + + return urilist, nil }