"""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)