Files
SaraView/Client/Browser.py
T

812 lines
38 KiB
Python

"""The file list and everything around it, laid out like SaraFem's Browser:
breadcrumbs and the toolbar share one bar on top, the file list fills the
rest, and an open file takes the list's place (Viewer.py). Right-click menus
use SaraFem's one shared ContextMenu (see _RequestMenu)."""
import asyncio
import time
from pathlib import Path
from typing import Callable, Optional
import flet as ft
from App import App
from Core import ChipButtonStyle, FileIcons, HumanDuration, HumanSize, OpenExternally, Palette
from Sources import BaseName, ClassifyFile, Entry, JoinPath, MoveDestination, ParentPath, SourceError
from Graph import GraphView
from Viewer import FileView
DoubleClickWindow = 0.4 # seconds
TypeLabels = {"markdown": "Markdown", "text": "Text", "config": "Config", "html": "HTML", "image": "Image", "video": "Video"}
class Browser(ft.Column):
def __init__(self, TheApp: App, OnPathChanged: Callable[[Optional[str], str, bool], None],
OnShowSettings: Callable[..., None], OnToggleSidebar: Callable[[], None]):
super().__init__(expand=True, spacing=0, horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
self.TheApp = TheApp
self.Config = TheApp.Config
self.OnPathChanged = OnPathChanged
self.OnShowSettings = OnShowSettings
self.OnToggleSidebar = OnToggleSidebar
# Root None: the list of roots (a server with more than one). Path is
# the directory inside Root, "" for the root itself.
self.Root: Optional[str] = None
self.Path = ""
self.Entries: list[Entry] = []
self.ListError = "" # why the current directory couldn't be listed
self.SelectedName: Optional[str] = None
self.EntryRows: dict[str, ft.Container] = {}
self.BackStack: list[tuple[Optional[str], str]] = []
self.ForwardStack: list[tuple[Optional[str], str]] = []
self.OpenView: Optional[FileView] = None
self.GraphView: Optional[GraphView] = None
self.ShortcutsPaused = False # a text field has focus
self.LastClickName: Optional[str] = None
self.LastClickTime = 0.0
# Right-click menu state, see _RequestMenu.
self.MenuOpen = False
self.MenuOpenedAt = 0.0
self.PendingMenu: Optional[tuple[Optional[str], ft.Offset]] = None
self.Breadcrumbs = ft.Row(spacing=0, wrap=True)
self.NewFileButton = ft.OutlinedButton(content="New File", icon=ft.Icons.NOTE_ADD_OUTLINED,
style=ChipButtonStyle(), on_click=lambda e: self._ShowNewDialog(False))
self.NewDirectoryButton = ft.OutlinedButton(content="New Directory", icon=ft.Icons.CREATE_NEW_FOLDER_OUTLINED,
style=ChipButtonStyle(), on_click=lambda e: self._ShowNewDialog(True))
self.UploadButton = ft.OutlinedButton(content="Upload", icon=ft.Icons.UPLOAD_OUTLINED,
style=ChipButtonStyle(), on_click=self._Upload)
self.ConnectionButton = ft.PopupMenuButton(
content=ft.Container(
ft.Row([ft.Icon(ft.Icons.CLOUD_OUTLINED, size=16, color=Palette.Mauve), ft.Text("", size=13)], spacing=6, tight=True),
padding=ft.Padding.symmetric(horizontal=10, vertical=6),
border=ft.Border.all(1, Palette.Mauve),
border_radius=4,
bgcolor=Palette.Surface0,
),
items=[],
)
# Shown with the sidebar tree, above or below it (Filetree.SetEntryButtons).
self.EntryButtons = ft.Container(
ft.Row([self.NewFileButton, self.NewDirectoryButton, self.UploadButton], wrap=True, spacing=6, run_spacing=6),
padding=ft.Padding.symmetric(horizontal=8, vertical=8),
)
self.ActionsToolbar = ft.Row(
[
ft.IconButton(icon=ft.Icons.HUB_OUTLINED, icon_color=Palette.Mauve, tooltip="Graph (Ctrl+G)",
on_click=lambda e: self.ShowGraph()),
ft.IconButton(icon=ft.Icons.REFRESH, icon_color=Palette.Mauve, tooltip="Refresh (Ctrl+R)",
on_click=lambda e: self.Refresh()),
self.ConnectionButton,
ft.IconButton(icon=ft.Icons.SETTINGS_OUTLINED, icon_color=Palette.Mauve, tooltip="Settings",
on_click=lambda e: self.OnShowSettings()),
],
spacing=8,
tight=True,
)
# Shown while uploading or downloading: which file, how far, how long (ShowTransfer).
self.TransferText = ft.Text("", size=12, color=Palette.Overlay0)
self.TransferBar = ft.ProgressBar(value=0, color=Palette.Mauve, bgcolor=Palette.Surface0, bar_height=4)
self.TransferRow = ft.Container(ft.Column([self.TransferText, self.TransferBar], spacing=4),
padding=ft.Padding.symmetric(horizontal=8, vertical=6), visible=False)
self.TransferShown: Optional[tuple] = None # (text, percent) on screen
self.EntryList = ft.ListView(expand=True, spacing=2)
# One shared popup host, opened programmatically (see _RequestMenu).
# Its built-in triggers can't be switched off, so they're set to
# LONG_PRESS with no items, which leaves them inert (SaraFem's fix for
# the "right-click turns the whole app black" bug).
self.ContextMenu = ft.ContextMenu(
content=ft.Column([self._BuildListHeader(), self.EntryList], spacing=0, expand=True),
expand=True,
secondary_trigger=ft.ContextMenuTrigger.LONG_PRESS,
tertiary_trigger=ft.ContextMenuTrigger.LONG_PRESS,
on_select=self._OnMenuClosed,
on_dismiss=self._OnMenuClosed,
)
self.FileArea = ft.Container(expand=True)
self.controls = [
# Unlike SaraFem's 50/50 split, the buttons take what they need
# and the breadcrumbs get the rest (and wrap when it's tight).
ft.Row([ft.Container(self.Breadcrumbs, padding=8, expand=True), ft.Container(self.ActionsToolbar, padding=8)]),
ft.Divider(height=1, color=Palette.Surface1),
self.TransferRow,
self.FileArea,
]
self._ShowList(Update=False)
def did_mount(self) -> None:
self.page.on_keyboard_event = self._OnKeyboardEvent
# --- Source -----------------------------------------------------------
def SourceChanged(self) -> None:
"""The source was (re)connected, or someone logged in or out: what's
visible may have changed, so start over from the first root."""
self._RefreshConnectionButton()
self.BackStack.clear()
self.ForwardStack.clear()
self.OpenView = None
self.ShortcutsPaused = False
if self.TheApp.ConnectError:
self.Root, self.Path, self.Entries = None, "", []
self._ShowList()
return
try:
Roots = self.TheApp.Source.Roots()
except SourceError as Error:
self.TheApp.HandleError(Error)
Roots = []
self.NavigateTo(Roots[0] if len(Roots) == 1 else None, "", RecordHistory=False)
def _RefreshConnectionButton(self) -> None:
TheApp = self.TheApp
Server = TheApp.Server
Label: ft.Text = self.ConnectionButton.content.content.controls[1]
Icon: ft.Icon = self.ConnectionButton.content.content.controls[0]
Items: list[ft.PopupMenuItem] = []
if Server is None:
Label.value = "Local"
Icon.icon = ft.Icons.COMPUTER
Items.append(ft.PopupMenuItem(content=TheApp.Source.Describe(), disabled=True))
else:
Label.value = (Server.User or {}).get("Username") or ("Offline" if TheApp.ConnectError else "Guest")
Icon.icon = ft.Icons.CLOUD_OFF_OUTLINED if TheApp.ConnectError else ft.Icons.CLOUD_OUTLINED
Items.append(ft.PopupMenuItem(content=Server.Url or "No server address", disabled=True))
if TheApp.ConnectError:
Items.append(ft.PopupMenuItem(content="Reconnect", icon=ft.Icons.REFRESH, on_click=lambda e: TheApp.Connect()))
elif Server.User is None:
Items.append(ft.PopupMenuItem(content="Log in", icon=ft.Icons.LOGIN, on_click=lambda e: TheApp.ShowLoginDialog()))
else:
Items.append(ft.PopupMenuItem(content="Log out", icon=ft.Icons.LOGOUT, on_click=lambda e: TheApp.Logout()))
# Switch between this computer and the server, without going through Settings.
if Server is None:
Items.append(ft.PopupMenuItem(content="Switch to server", icon=ft.Icons.CLOUD_OUTLINED,
on_click=lambda e: self._SwitchMode("Server")))
else:
Items.append(ft.PopupMenuItem(content="Switch to local folder", icon=ft.Icons.COMPUTER,
on_click=lambda e: self._SwitchMode("Local")))
Items.append(ft.PopupMenuItem(content="Connection settings", icon=ft.Icons.SETTINGS_OUTLINED,
on_click=lambda e: self.OnShowSettings("Connection")))
self.ConnectionButton.items = Items
self.ConnectionButton.tooltip = TheApp.Source.Describe()
CanWrite = TheApp.Source.CanWrite and not TheApp.ConnectError
for Button in (self.NewFileButton, self.NewDirectoryButton, self.UploadButton):
Button.disabled = not CanWrite
Button.tooltip = None if CanWrite else "Log in to change files"
def _SwitchMode(self, Mode: str) -> None:
"""Local folder <-> server, from the connection button. Without a
server address yet, it opens the Connection settings instead."""
Config = self.TheApp.Config
if Mode == "Server" and not Config.ServerUrl.strip():
self.OnShowSettings("Connection")
self.TheApp.Notify("Set the server address first.", "info")
return
Config.Mode = Mode
Config.Save()
self.TheApp.Connect() # its listeners reload the tree and this browser
# --- Navigation -------------------------------------------------------
def NavigateTo(self, Root: Optional[str], RelativePath: str, RecordHistory: bool = True) -> None:
if self.OpenView is not None and self.OpenView.IsDirty:
self.OpenView.RequestClose(Then=lambda: self.NavigateTo(Root, RelativePath, RecordHistory))
return
if RecordHistory and (Root, RelativePath) != (self.Root, self.Path):
self.BackStack.append((self.Root, self.Path))
self.ForwardStack.clear()
self.Root, self.Path = Root, RelativePath
self.SelectedName = None
self.OpenView = None
self.GraphView = None
self.ShortcutsPaused = False
self._LoadEntries()
self._ShowList()
self.OnPathChanged(Root, RelativePath, False)
def GoBack(self) -> None:
if self.BackStack:
self.ForwardStack.append((self.Root, self.Path))
self.NavigateTo(*self.BackStack.pop(), RecordHistory=False)
def GoForward(self) -> None:
if self.ForwardStack:
self.BackStack.append((self.Root, self.Path))
self.NavigateTo(*self.ForwardStack.pop(), RecordHistory=False)
def GoUp(self) -> None:
if self.Root is None:
return
if self.Path:
self.NavigateTo(self.Root, ParentPath(self.Path))
elif len(self._Roots()) > 1:
self.NavigateTo(None, "")
def Refresh(self) -> None:
if self.OpenView is not None:
return # reloading would throw away edits; close the file first
if self.GraphView is not None:
self.ShowGraph() # a new one, so changed graph settings apply
return
self._LoadEntries()
self._ShowList()
def _Roots(self) -> list[str]:
try:
return self.TheApp.Source.Roots()
except SourceError:
return []
def _LoadEntries(self) -> None:
self.ListError = ""
if self.TheApp.ConnectError:
self.Entries = []
return
try:
if self.Root is None:
self.Entries = [Entry(Name, True) for Name in self.TheApp.Source.Roots()]
else:
self.Entries = self.TheApp.Source.List(self.Root, self.Path)
except SourceError as Error:
self.Entries = []
self.ListError = str(Error)
def _EntryPath(self, Name: str) -> tuple[str, str]:
"""(Root, Path) of an entry in the current directory."""
if self.Root is None:
return Name, ""
return self.Root, JoinPath(self.Path, Name)
def _Find(self, Name: Optional[str]) -> Optional[Entry]:
return next((Item for Item in self.Entries if Item.Name == Name), None)
# --- Rendering --------------------------------------------------------
def _ShowList(self, Update: bool = True) -> None:
self.Breadcrumbs.controls = self._BuildBreadcrumbs()
if self.TheApp.ConnectError:
self.FileArea.content = self._EmptyState(
"Can't reach the server", self.TheApp.ConnectError,
[("Reconnect", lambda e: self.TheApp.Connect()), ("Settings", lambda e: self.OnShowSettings())],
)
else:
self.EntryList.controls = self._BuildRows()
self.FileArea.content = ft.GestureDetector(
content=ft.Container(content=self.ContextMenu, bgcolor=Palette.Base, expand=True),
on_tap=lambda e: self.Select(None),
on_secondary_tap_down=lambda e: self._RequestMenu(None, e),
)
if Update:
self.update()
def _EmptyState(self, Title: str, Detail: str, Buttons: list[tuple[str, Callable]]) -> ft.Control:
return ft.Container(
ft.Column(
[
ft.Text(Title, color=Palette.Overlay0, size=16),
ft.Text(Detail, color=Palette.Overlay0, size=12, text_align=ft.TextAlign.CENTER),
ft.Row([ft.OutlinedButton(content=Text, style=ChipButtonStyle(), on_click=Handler) for Text, Handler in Buttons],
alignment=ft.MainAxisAlignment.CENTER),
],
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
tight=True,
),
alignment=ft.Alignment.CENTER,
expand=True,
padding=24,
)
def _BuildBreadcrumbs(self) -> list[ft.Control]:
Segments: list[tuple[str, Optional[str], str]] = [] # (label, root, path)
if self.Root is None or len(self._Roots()) > 1:
Segments.append(("Roots", None, ""))
if self.Root is not None:
Segments.append((self.Root, self.Root, ""))
Cumulative = ""
for Part in [Part for Part in self.Path.split("/") if Part]:
Cumulative = JoinPath(Cumulative, Part)
Segments.append((Part, self.Root, Cumulative))
Controls: list[ft.Control] = []
for Index, (Label, Root, PathText) in enumerate(Segments):
Controls.append(ft.TextButton(content=Label, on_click=lambda e, R=Root, P=PathText: self.NavigateTo(R, P)))
if Index < len(Segments) - 1:
Controls.append(ft.Text("/", color=Palette.Overlay0))
return Controls
def _BuildListHeader(self) -> ft.Control:
def Cell(Text: str, Width: Optional[int] = None, Expand: bool = False) -> ft.Container:
return ft.Container(ft.Text(Text, size=12, color=Palette.Overlay0), width=Width, expand=Expand,
padding=ft.Padding.only(left=12, right=8))
return ft.Container(
ft.Row([Cell("Name", Expand=True), Cell("Type", 110), Cell("Size", 100), ft.Container(width=48)], spacing=0),
padding=ft.Padding.symmetric(vertical=6),
bgcolor=Palette.Mantle,
border=ft.Border(bottom=ft.BorderSide(1, Palette.Surface1)),
)
def _BuildRows(self) -> list[ft.Control]:
self.EntryRows = {}
if self.ListError:
return [ft.Container(ft.Text(self.ListError, color=Palette.Red), padding=16)]
if not self.Entries:
return [ft.Container(ft.Text("This directory is empty.", color=Palette.Overlay0, italic=True), padding=16)]
return [self._BuildRow(Item) for Item in self.Entries]
def _BuildRow(self, Item: Entry) -> ft.Control:
FontSize = self.Config.FontSize
if self.Root is None:
Icon, IconColor, TypeText = ft.Icons.STORAGE_OUTLINED, Palette.Mauve, "Root"
elif Item.IsDirectory:
Icon, IconColor, TypeText = ft.Icons.FOLDER_OUTLINED, Palette.Mauve, "Directory"
else:
Icon = FileIcons.get(Item.FileType or "", ft.Icons.INSERT_DRIVE_FILE_OUTLINED)
IconColor = Palette.Text
TypeText = TypeLabels.get(Item.FileType or "", "File")
Row = ft.Container(
content=ft.Row(
[
ft.Container(
ft.Row([
ft.Icon(Icon, size=FontSize + 6, color=IconColor),
ft.Text(Item.Name, size=FontSize, color=Palette.Text, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1, expand=True),
], spacing=12),
expand=True,
padding=ft.Padding.symmetric(horizontal=12, vertical=8),
),
ft.Container(ft.Text(TypeText, size=FontSize - 2, color=Palette.Overlay0), width=110, padding=ft.Padding.only(left=12)),
ft.Container(ft.Text(HumanSize(Item.Size), size=FontSize - 2, color=Palette.Overlay0), width=100,
padding=ft.Padding.only(left=12)),
ft.PopupMenuButton(icon=ft.Icons.MORE_VERT, icon_color=Palette.Overlay0, items=self._MenuItems(Item.Name)),
],
spacing=0,
),
bgcolor=Palette.Surface0 if Item.Name == self.SelectedName else None,
border_radius=4,
)
self.EntryRows[Item.Name] = Row
return ft.GestureDetector(
content=Row,
on_tap=lambda e, N=Item.Name: self._HandleTap(N),
on_secondary_tap_down=lambda e, N=Item.Name: self._RequestMenu(N, e),
)
# --- Selection and opening --------------------------------------------
def _HandleTap(self, Name: str) -> None:
Now = time.monotonic()
IsDoubleClick = Name == self.LastClickName and (Now - self.LastClickTime) < DoubleClickWindow
self.LastClickName = None if IsDoubleClick else Name
self.LastClickTime = Now
if IsDoubleClick:
self.OpenEntry(Name)
else:
self.Select(Name)
def Select(self, Name: Optional[str]) -> None:
if Name == self.SelectedName:
return
for RowName in (self.SelectedName, Name):
if RowName in self.EntryRows:
self.EntryRows[RowName].bgcolor = Palette.Surface0 if RowName == Name else None
self.EntryRows[RowName].update()
self.SelectedName = Name
def ShowGraph(self) -> None:
"""The graph of the root being browsed, in place of the file list."""
if self.OpenView is not None:
self.OpenView.RequestClose(Then=self.ShowGraph)
return
Root = self.Root
if Root is None:
Roots = self._Roots()
if not Roots:
return
Root = Roots[0]
self.GraphView = GraphView(self.TheApp, Root, self.Path if Root == self.Root else "",
OnOpenNode=lambda Node: self._OpenGraphNode(Root, Node), OnClose=self.CloseGraph)
self.FileArea.content = self.GraphView
self.FileArea.update()
def CloseGraph(self) -> None:
self.GraphView = None
self._ShowList()
def _OpenGraphNode(self, Root: str, Node: dict) -> None:
if Node["IsDirectory"]:
self.NavigateTo(Root, Node["Id"])
else:
self.GraphView = None
self.OpenFile(Root, Node["Id"])
def OpenFile(self, Root: str, RelativePath: str) -> None:
"""Opens a file from anywhere (the sidebar tree): goes to its directory
first, so Back and Esc land in the list it's in."""
if self.OpenView is not None and self.OpenView.IsDirty:
self.OpenView.RequestClose(Then=lambda: self.OpenFile(Root, RelativePath))
return
Directory = ParentPath(RelativePath)
if (Root, Directory) != (self.Root, self.Path) or self.OpenView is not None:
self.NavigateTo(Root, Directory)
self.OpenEntry(BaseName(RelativePath), InApp=True)
def OpenEntry(self, Name: str, InApp: Optional[bool] = None) -> None:
Item = self._Find(Name)
if Item is None:
return
Root, RelativePath = self._EntryPath(Name)
if Item.IsDirectory:
self.OpenDirectory(Root, RelativePath, Item.Note)
return
if InApp is None:
InApp = self.Config.OpenFilesInApp
if not InApp:
self._OpenExternally(Root, RelativePath)
return
self._ShowFile(Root, RelativePath, Item)
def _ShowFile(self, Root: str, RelativePath: str, Item: Entry) -> None:
"""The file view, for a file in the current directory (it may be one
the list hides, like a directory's .sv_ note)."""
self.OpenView = FileView(
self.TheApp, Root, RelativePath, Item,
OnClose=self._CloseFile,
OnOpenExternally=lambda: self._OpenExternally(Root, RelativePath),
OnDownload=(lambda: self._Download(Item.Name)) if self.TheApp.Server else None,
OnFocusChange=self._SetShortcutsPaused,
OnOpenLink=self.OpenLink,
)
self.FileArea.content = self.OpenView
self.FileArea.update()
self.OnPathChanged(Root, RelativePath, False) # highlights the file in the tree
def OpenDirectory(self, Root: str, RelativePath: str, Note: Optional[dict] = None) -> None:
"""A directory clicked (in the list or the tree): go there, and with
Config.NavigateIntoDirectories open its .sv_ note too."""
if self.OpenView is not None and self.OpenView.IsDirty:
self.OpenView.RequestClose(Then=lambda: self.OpenDirectory(Root, RelativePath, Note))
return
self.NavigateTo(Root, RelativePath)
if self.Config.NavigateIntoDirectories and Note and RelativePath:
self._ShowFile(Root, JoinPath(RelativePath, Note["Name"]),
Entry(Note["Name"], False, Note.get("FileType") or ClassifyFile(Note["Name"])))
def OpenLink(self, Root: str, RelativePath: str) -> None:
"""A link in a note (or an image in one) clicked: open what it points at."""
if self.OpenView is not None and self.OpenView.IsDirty:
self.OpenView.RequestClose(Then=lambda: self.OpenLink(Root, RelativePath))
return
Directory, Name = ParentPath(RelativePath), BaseName(RelativePath)
if not Name:
self.NavigateTo(Root, "")
return
try:
Listed = {Item.Name: Item for Item in self.TheApp.Source.List(Root, Directory)}
except SourceError as Error:
self.TheApp.Notify(f"Couldn't open {RelativePath}: {Error}", "error")
return
Item = Listed.get(Name)
if Item is not None and Item.IsDirectory:
self.OpenDirectory(Root, RelativePath, Item.Note)
return
if (Root, Directory) != (self.Root, self.Path) or self.OpenView is not None:
self.NavigateTo(Root, Directory)
# Not listed may still be there (a hidden .sv_ note); the view says if not.
self._ShowFile(Root, RelativePath, Item or Entry(Name, False, ClassifyFile(Name)))
def _CloseFile(self) -> None:
self.OpenView = None
self.ShortcutsPaused = False
self._ShowList()
self.OnPathChanged(self.Root, self.Path, False)
def _OpenExternally(self, Root: str, RelativePath: str) -> None:
try:
if self.TheApp.Server:
self.TheApp.Notify(f"Downloading {BaseName(RelativePath)}...", "info")
OpenExternally(self.TheApp.Source.LocalFile(Root, RelativePath))
except SourceError as Error:
self.TheApp.HandleError(Error, f"Couldn't open {BaseName(RelativePath)}")
except OSError as Error:
self.TheApp.Notify(f"Couldn't open {BaseName(RelativePath)}: {Error}", "error")
# --- Menus ------------------------------------------------------------
def _MenuItems(self, Name: str) -> list[ft.PopupMenuItem]:
"""Shared by the triple-dot button and the right-click menu."""
if self.Root is None:
return [ft.PopupMenuItem(content="Open", on_click=lambda e: self.OpenEntry(Name))]
Item = self._Find(Name)
CanWrite = self.TheApp.Source.CanWrite
Items: list[ft.PopupMenuItem] = []
if Item is not None and not Item.IsDirectory:
OtherWay = "Open Externally" if self.Config.OpenFilesInApp else "View in SaraView"
Items += [
ft.PopupMenuItem(content="View" if self.Config.OpenFilesInApp else "Open Externally",
on_click=lambda e: self.OpenEntry(Name)),
ft.PopupMenuItem(content=OtherWay, on_click=lambda e: self.OpenEntry(Name, InApp=not self.Config.OpenFilesInApp)),
]
else:
Items.append(ft.PopupMenuItem(content="Open", on_click=lambda e: self.OpenEntry(Name)))
if self.TheApp.Server:
Items.append(ft.PopupMenuItem(content="Download", on_click=lambda e: self._Download(Name)))
Items += [
ft.PopupMenuItem(content="Copy Path", on_click=lambda e: self._CopyPath(Name)),
ft.PopupMenuItem(content="Rename / Move", disabled=not CanWrite, on_click=lambda e: self._ShowRenameDialog(Name)),
ft.PopupMenuItem(content="Delete", disabled=not CanWrite, on_click=lambda e: self._ConfirmDelete(Name)),
]
return Items
def _EmptySpaceMenuItems(self) -> list[ft.PopupMenuItem]:
CanWrite = self.TheApp.Source.CanWrite and self.Root is not None
return [
ft.PopupMenuItem(content="New File", disabled=not CanWrite, on_click=lambda e: self._ShowNewDialog(False)),
ft.PopupMenuItem(content="New Directory", disabled=not CanWrite, on_click=lambda e: self._ShowNewDialog(True)),
ft.PopupMenuItem(content="Upload", disabled=not CanWrite, on_click=self._Upload),
ft.PopupMenuItem(content="Refresh", on_click=lambda e: self.Refresh()),
]
def _RequestMenu(self, Name: Optional[str], Event: ft.TapEvent) -> None:
"""Right-click on an entry (Name) or the background (None). An entry
sits inside the background's GestureDetector and both report the
click, so this waits a moment and opens one menu, the entry's
winning (SaraFem's _RequestMenu)."""
if self.MenuOpen:
if time.monotonic() - self.MenuOpenedAt < 0.5:
return
self.MenuOpen = False
First = self.PendingMenu is None
if First or Name is not None:
self.PendingMenu = (Name, Event.global_position)
if First:
self.page.run_task(self._OpenPendingMenu)
async def _OpenPendingMenu(self) -> None:
await asyncio.sleep(0.08)
Name, Position = self.PendingMenu
self.PendingMenu = None
if Name is not None:
self.Select(Name)
self.ContextMenu.items = self._MenuItems(Name) if Name is not None else self._EmptySpaceMenuItems()
self.ContextMenu.update()
self.MenuOpen = True
self.MenuOpenedAt = time.monotonic()
try:
await self.ContextMenu.open(global_position=Position)
except Exception:
self.MenuOpen = False
raise
def _OnMenuClosed(self, Event) -> None:
if Event.button is None: # only menus opened through open()
self.MenuOpen = False
# --- File operations --------------------------------------------------
def _AfterChange(self) -> None:
"""Re-list, and let the sidebar tree catch up with moved directories."""
self.Refresh()
self.OnPathChanged(self.Root, self.Path, True)
def _ShowNewDialog(self, IsDirectory: bool) -> None:
if self.Root is None:
self.TheApp.Notify("Open a root first.", "warning")
return
Default = "New Directory" if IsDirectory else "New File.md"
def Create(NewName: str) -> None:
Root, RelativePath = self._EntryPath(NewName)
try:
self.TheApp.Source.Create(Root, RelativePath, IsDirectory)
except SourceError as Error:
self.TheApp.HandleError(Error, f"Couldn't create {NewName}")
return
self._AfterChange()
if not IsDirectory:
self.Select(NewName)
# Unlike Rename, Create also takes the suggested name as it is.
self.TheApp.AskName("New Directory" if IsDirectory else "New File", Default, len(Default.rsplit(".", 1)[0]),
Create, RequireChange=False)
def _ShowRenameDialog(self, Name: str) -> None:
Item = self._Find(Name)
Root, RelativePath = self._EntryPath(Name)
SelectEnd = len(Name) if (Item and Item.IsDirectory) or "." not in Name else len(Name.rsplit(".", 1)[0])
def Rename(NewName: str) -> None:
Destination = MoveDestination(RelativePath, NewName)
if not Destination:
self.TheApp.Notify("That would move it to the root itself.", "warning")
return
try:
self.TheApp.Source.Move(Root, RelativePath, Destination)
except SourceError as Error:
self.TheApp.HandleError(Error, f"Couldn't rename {Name}")
return
self._AfterChange()
self.TheApp.AskName("Rename", Name, SelectEnd, Rename, ActionText="Rename",
Note='A "/" moves it, e.g. ../Archive/' + Name)
def _ConfirmDelete(self, Name: str) -> None:
Item = self._Find(Name)
Root, RelativePath = self._EntryPath(Name)
def Delete() -> None:
try:
self.TheApp.Source.Delete(Root, RelativePath)
except SourceError as Error:
self.TheApp.HandleError(Error, f"Couldn't delete {Name}")
return
self.SelectedName = None
self._AfterChange()
self.TheApp.Notify(f"Deleted {Name}.", "success")
if not self.Config.ConfirmDelete:
Delete()
return
What = "directory and everything in it" if Item and Item.IsDirectory else "file"
Where = "moved to the trash" if self.TheApp.Server is None else "deleted from the server for good"
self.TheApp.Confirm(f"Delete {Name}?", f"This {What} will be {Where}.", Delete)
def _CopyPath(self, Name: str) -> None:
Root, RelativePath = self._EntryPath(Name)
Server = self.TheApp.Server
Text = f"{Root}/{RelativePath}" if Server else str(self.TheApp.Source.LocalFile(Root, RelativePath))
self.page.run_task(ft.Clipboard().set, Text)
self.TheApp.Notify(f"Copied {Text}", "success")
async def _Upload(self, e=None) -> None:
if self.Root is None or not self.TheApp.Source.CanWrite:
return
Picked = await self.TheApp.Picker.pick_files(dialog_title="Upload files", allow_multiple=True)
Paths = [Path(File.path) for File in Picked or [] if File.path]
if not Paths:
return
Saved, Failed = 0, []
Server = self.TheApp.Server
Sizes = [LocalFile.stat().st_size if LocalFile.is_file() else 0 for LocalFile in Paths]
Total = sum(Sizes)
Verb = "Uploading" if Server else "Copying in"
Started = time.monotonic()
def ShowProgress(Index: int, Fraction: float) -> None:
Of = f" ({Index + 1} of {len(Paths)})" if len(Paths) > 1 else ""
self._ShowTransfer(f"{Verb} {Paths[Index].name}{Of}", sum(Sizes[:Index]) + Sizes[Index] * Fraction, Total, Started)
self.TransferShown = None
self.TransferRow.visible = True
for Index, LocalFile in enumerate(Paths):
ShowProgress(Index, 0)
try:
# Large files go up in chunks and report each one; small ones in one go.
await asyncio.to_thread(self.TheApp.Source.Upload, self.Root, self.Path, LocalFile,
lambda Fraction, I=Index: ShowProgress(I, Fraction))
Saved += 1
except SourceError as Error:
if Error.Status == 401:
self.TransferRow.visible = False
self.TransferRow.update()
self.TheApp.HandleError(Error)
return
Failed.append(f"{LocalFile.name}: {Error}")
except OSError as Error:
Failed.append(f"{LocalFile.name}: {Error}")
self.TransferRow.visible = False
self.TransferRow.update()
self.Refresh()
if Failed:
self.TheApp.Notify("Couldn't upload " + "; ".join(Failed), "error")
else:
self.TheApp.Notify(f"{'Uploaded' if Server else 'Copied in'} {Saved} file{'s' if Saved != 1 else ''}.", "success")
def _Download(self, Name: str) -> None:
self.page.run_task(self._DoDownload, Name)
async def _DoDownload(self, Name: str) -> None:
Server = self.TheApp.Server
Item = self._Find(Name) or (self.OpenView.Item if self.OpenView else None)
if Server is None:
return
Root, RelativePath = self._EntryPath(Name)
FileName = f"{Name}.zip" if (Item and Item.IsDirectory) or self.Root is None else Name
Chosen = await self.TheApp.Picker.save_file(dialog_title="Download to", file_name=FileName,
initial_directory=str(Path.home() / "Downloads"))
if not Chosen:
return
Started = time.monotonic()
self.TransferShown = None
self.TransferText.value = f"Preparing {FileName}…" # a directory is zipped first
self.TransferBar.value = None # moving, until the size is known
self.TransferRow.visible = True
self.TransferRow.update()
try:
await asyncio.to_thread(Server.Download, Root, RelativePath, Path(Chosen),
lambda Done, Total: self._ShowTransfer(f"Downloading {FileName}", Done, Total, Started))
except SourceError as Error:
self.TheApp.HandleError(Error, f"Couldn't download {Name}")
return
finally:
self.TransferRow.visible = False
self.TransferRow.update()
self.TheApp.Notify(f"Saved {Chosen}", "success")
def _ShowTransfer(self, What: str, Done: float, Total: int, Started: float) -> None:
"""The transfer row: "Downloading a.zip: 45% (54.0 MB of 122.1 MB,
about 12 s left)". Redraws only when the percentage changes."""
Percent = int(Done / Total * 100) if Total else None
if self.TransferShown == (What, Percent):
return
self.TransferShown = (What, Percent)
Elapsed = time.monotonic() - Started
if Percent is None:
Detail = f"{HumanSize(int(Done))}"
else:
Detail = f"{Percent}% ({HumanSize(int(Done))} of {HumanSize(Total)}"
if Elapsed > 2 and 0 < Done < Total:
Detail += f", about {HumanDuration((Total - Done) * Elapsed / Done)} left"
Detail += ")"
self.TransferText.value = f"{What}: {Detail}"
self.TransferBar.value = Done / Total if Total else None
self.TransferRow.update()
# --- Keyboard ---------------------------------------------------------
def _SetShortcutsPaused(self, Paused: bool) -> None:
self.ShortcutsPaused = Paused
def _OnKeyboardEvent(self, Event: ft.KeyboardEvent) -> None:
if self.TheApp.DialogOpen:
return
View = self.OpenView
# These work while typing too.
if Event.key == "S" and Event.ctrl and View is not None:
View.Save()
return
if Event.key == "E" and Event.ctrl and View is not None:
View.ToggleEdit()
return
if Event.key == "Escape" and View is not None:
if not View.HandleEscape():
View.RequestClose()
return
if Event.key == "Escape" and self.GraphView is not None:
self.CloseGraph()
return
if Event.key == "G" and Event.ctrl and not self.ShortcutsPaused:
self.CloseGraph() if self.GraphView is not None else self.ShowGraph()
return
if self.ShortcutsPaused:
return
if Event.key == "B" and Event.ctrl:
self.OnToggleSidebar()
elif Event.key == "R" and Event.ctrl:
self.Refresh()
elif Event.key == "Arrow Left" and Event.alt:
self.GoBack()
elif Event.key == "Arrow Right" and Event.alt:
self.GoForward()
elif View is not None:
return # the rest act on the file list
elif Event.key == "N" and Event.ctrl and Event.shift:
self._ShowNewDialog(True)
elif Event.key == "N" and Event.ctrl:
self._ShowNewDialog(False)
elif Event.key == "Backspace":
self.GoUp()
elif Event.key == "Escape":
self.Select(None)
elif self.SelectedName is None:
return
elif Event.key == "Enter":
self.OpenEntry(self.SelectedName)
elif Event.key == "F2" and self.TheApp.Source.CanWrite and self.Root is not None:
self._ShowRenameDialog(self.SelectedName)
elif Event.key == "Delete" and self.TheApp.Source.CanWrite and self.Root is not None:
self._ConfirmDelete(self.SelectedName)