mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +02:00
699 lines
32 KiB
Python
699 lines
32 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, HumanSize, OpenExternally, Palette
|
|
from Sources import BaseName, 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=[],
|
|
)
|
|
self.ActionsToolbar = ft.Row(
|
|
[
|
|
self.NewFileButton,
|
|
self.NewDirectoryButton,
|
|
self.UploadButton,
|
|
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,
|
|
)
|
|
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.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()))
|
|
Items.append(ft.PopupMenuItem(content="Connection settings", icon=ft.Icons.SETTINGS_OUTLINED,
|
|
on_click=lambda e: self.OnShowSettings()))
|
|
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"
|
|
|
|
# --- 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.NavigateTo(Root, RelativePath)
|
|
return
|
|
if InApp is None:
|
|
InApp = self.Config.OpenFilesInApp
|
|
if not InApp:
|
|
self._OpenExternally(Root, RelativePath)
|
|
return
|
|
self.OpenView = FileView(
|
|
self.TheApp, Root, RelativePath, Item,
|
|
OnClose=self._CloseFile,
|
|
OnOpenExternally=lambda: self._OpenExternally(Root, RelativePath),
|
|
OnDownload=(lambda: self._Download(Name)) if self.TheApp.Server else None,
|
|
OnFocusChange=self._SetShortcutsPaused,
|
|
)
|
|
self.FileArea.content = self.OpenView
|
|
self.FileArea.update()
|
|
self.OnPathChanged(Root, RelativePath, False) # highlights the file in the tree
|
|
|
|
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
|
|
for LocalFile in Paths:
|
|
if Server:
|
|
self.TheApp.Notify(f"Uploading {LocalFile.name} ({HumanSize(LocalFile.stat().st_size)})...", "info")
|
|
try:
|
|
await asyncio.to_thread(self.TheApp.Source.Upload, self.Root, self.Path, LocalFile)
|
|
Saved += 1
|
|
except SourceError as Error:
|
|
if Error.Status == 401:
|
|
self.TheApp.HandleError(Error)
|
|
return
|
|
Failed.append(f"{LocalFile.name}: {Error}")
|
|
except OSError as Error:
|
|
Failed.append(f"{LocalFile.name}: {Error}")
|
|
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
|
|
self.TheApp.Notify(f"Downloading {FileName}...", "info")
|
|
try:
|
|
await asyncio.to_thread(Server.Download, Root, RelativePath, Path(Chosen))
|
|
except SourceError as Error:
|
|
self.TheApp.HandleError(Error, f"Couldn't download {Name}")
|
|
return
|
|
self.TheApp.Notify(f"Saved {Chosen}", "success")
|
|
|
|
# --- 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)
|