Files
SaraView/Client/Filetree.py
T

206 lines
9.5 KiB
Python

"""The sidebar's tree, built like SaraFem's Filetree: one node per root,
children loaded lazily on expand (every expand on a server is a request).
Clicking a directory opens it in the browser; clicking a file opens it in
the viewer. Files can be hidden from the tree in Settings > General."""
from typing import Callable, Optional, Union
import flet as ft
from App import App
from Core import FileIcons, Palette
from Sources import JoinPath, SourceError
Location = tuple[str, str] # (Root, Path)
def _TreeLabel(Name: str, OnTap: Callable[[], None]) -> tuple[ft.Text, ft.Container]:
Label = ft.Text(Name, size=13, color=Palette.Text, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1)
Holder = ft.Container(content=ft.GestureDetector(content=Label, on_tap=lambda e: OnTap()), padding=4, border_radius=4)
return Label, Holder
def _SetHighlight(Label: ft.Text, Holder: ft.Container, IsSelected: bool) -> None:
Holder.bgcolor = Palette.Surface1 if IsSelected else None
Label.color = Palette.Mauve if IsSelected else Palette.Text
Holder.update()
def _SetOnPath(Holder: ft.Container, IsOnPath: bool) -> None:
"""The bar beside every directory you're in (the web view's TreeRowCurrentDirectory)."""
Holder.border = ft.Border.only(left=ft.BorderSide(2, Palette.Mauve)) if IsOnPath else None
Holder.update()
class _FileLeaf:
"""A file in the tree: a plain row (no expand arrow) indented like a
child tile, so names line up with the directories beside it."""
def __init__(self, Root: str, RelativePath: str, FileType: Optional[str],
OnOpenFile: Callable[[str, str], None], Depth: int):
Name = RelativePath.rsplit("/", 1)[-1]
self.NameLabel, self.NameContainer = _TreeLabel(Name, lambda: OnOpenFile(Root, RelativePath))
self.Tile = ft.Container(
content=ft.Row(
[ft.Icon(FileIcons.get(FileType or "", ft.Icons.INSERT_DRIVE_FILE_OUTLINED), size=16, color=Palette.Overlay0),
ft.Container(self.NameContainer, expand=True)],
spacing=12,
),
# Icon and name line up with a directory tile's at the same depth.
padding=ft.Padding.only(left=8 + Depth * 14, right=8, top=2, bottom=2),
)
def SetSelected(self, IsSelected: bool) -> None:
_SetHighlight(self.NameLabel, self.NameContainer, IsSelected)
class _FiletreeNode:
def __init__(self, TheApp: App, Root: str, RelativePath: str, OnNavigate: Callable[..., None],
OnOpenFile: Callable[[str, str], None], Depth: int, Note: Optional[dict] = None):
self.TheApp = TheApp
self.Root = Root
self.RelativePath = RelativePath
self.OnNavigate = OnNavigate
self.OnOpenFile = OnOpenFile
self.Depth = Depth
self.ChildrenLoaded = False
self.ChildNodes: dict[str, "_FiletreeNode"] = {}
self.FileLeaves: dict[str, _FileLeaf] = {}
Name = RelativePath.rsplit("/", 1)[-1] if RelativePath else Root
# OnNavigate(Root, Path, Note): the browser's OpenDirectory, which may open the .sv_ note too.
self.NameLabel, self.NameContainer = _TreeLabel(Name, lambda: self.OnNavigate(self.Root, self.RelativePath, Note))
self.Tile = ft.ExpansionTile(
title=self.NameContainer,
leading=ft.Icon(ft.Icons.STORAGE_OUTLINED if not RelativePath else ft.Icons.FOLDER_OUTLINED,
size=16, color=Palette.Mauve if not RelativePath else Palette.Overlay0),
controls=[],
tile_padding=ft.Padding.only(left=8 + Depth * 14, right=8),
icon_color=Palette.Overlay0,
collapsed_icon_color=Palette.Overlay0,
# No shape: the default draws divider lines above and below an open tile.
shape=ft.RoundedRectangleBorder(radius=4),
collapsed_shape=ft.RoundedRectangleBorder(radius=4),
on_change=self._OnExpansionChanged,
)
def _OnExpansionChanged(self, e) -> None:
if self.Tile.expanded and not self.ChildrenLoaded:
self.LoadChildren()
def LoadChildren(self, Update: bool = True) -> None:
try:
Items = self.TheApp.Source.List(self.Root, self.RelativePath)
except SourceError:
Items = [] # locked, gone or unreachable: just no children
self.ChildNodes = {}
self.FileLeaves = {}
for Item in Items:
ChildPath = JoinPath(self.RelativePath, Item.Name)
if Item.IsDirectory:
self.ChildNodes[Item.Name] = _FiletreeNode(self.TheApp, self.Root, ChildPath, self.OnNavigate,
self.OnOpenFile, self.Depth + 1, Item.Note)
elif self.TheApp.Config.ShowFilesInTree:
self.FileLeaves[Item.Name] = _FileLeaf(self.Root, ChildPath, Item.FileType, self.OnOpenFile, self.Depth + 1)
# Directories first, then files, like the file list.
self.Tile.controls = [Node.Tile for Node in self.ChildNodes.values()] + [Leaf.Tile for Leaf in self.FileLeaves.values()]
self.ChildrenLoaded = True
if Update:
self.Tile.update()
def SetSelected(self, IsSelected: bool) -> None:
_SetHighlight(self.NameLabel, self.NameContainer, IsSelected)
def SetOnPath(self, IsOnPath: bool) -> None:
_SetOnPath(self.NameContainer, IsOnPath)
class Filetree(ft.Container):
def __init__(self, TheApp: App, OnNavigate: Callable[[str, str], None], OnOpenFile: Callable[[str, str], None]):
self.TheApp = TheApp
self.OnNavigate = OnNavigate
self.OnOpenFile = OnOpenFile
self.RootNodes: dict[str, _FiletreeNode] = {}
self.SelectedNode: Optional[Union[_FiletreeNode, _FileLeaf]] = None
self.PathNodes: list[_FiletreeNode] = [] # the directories you're in, marked
self.TreeList = ft.ListView(expand=True)
self.SourceLabel = ft.Text("", size=12, color=Palette.Overlay0, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1)
self.Header = ft.Container(self.SourceLabel, padding=ft.Padding.only(left=12, right=12, top=10, bottom=4))
self.Layout = ft.Column([self.Header, self.TreeList], spacing=0, expand=True)
super().__init__(
content=self.Layout,
width=TheApp.Config.SidebarWidth,
bgcolor=Palette.Mantle,
)
def SetEntryButtons(self, Buttons: ft.Control, AtTop: bool, Update: bool = True) -> None:
"""New File / New Directory / Upload, above or below the tree (Config.NewEntryButtonsAtTop)."""
Divider = ft.Divider(height=1, color=Palette.Surface1)
self.Layout.controls = ([self.Header, Buttons, Divider, self.TreeList] if AtTop
else [self.Header, self.TreeList, Divider, Buttons])
if Update:
self.update()
def Reload(self) -> None:
"""Rebuilds the tree from the current source (after connecting,
logging in or out, or a change that moved things around)."""
Source = self.TheApp.Source
self.SourceLabel.value = "Not connected" if self.TheApp.ConnectError else Source.Describe()
self.SelectedNode = None
self.PathNodes = []
Roots: list[str] = []
if not self.TheApp.ConnectError:
try:
Roots = Source.Roots()
except SourceError:
Roots = []
Expanded = {Root: Node.Tile.expanded for Root, Node in self.RootNodes.items()}
self.RootNodes = {Root: _FiletreeNode(self.TheApp, Root, "", self.OnNavigate, self.OnOpenFile, 0) for Root in Roots}
for Root, Node in self.RootNodes.items():
# One root (always the case locally) starts open; otherwise keep what was open.
if Expanded.get(Root, len(Roots) == 1):
Node.LoadChildren(Update=False)
Node.Tile.expanded = True
self.TreeList.controls = [Node.Tile for Node in self.RootNodes.values()]
self.update()
def RevealPath(self, Root: Optional[str], RelativePath: str) -> None:
"""Expand and highlight the tree down to a directory or file, loading
as needed."""
if self.SelectedNode:
self.SelectedNode.SetSelected(False)
self.SelectedNode = None
for Marked in self.PathNodes:
Marked.SetOnPath(False)
self.PathNodes = []
Node = self.RootNodes.get(Root or "")
if Node is None:
return
Target: Union[_FiletreeNode, _FileLeaf] = Node
Parts = [Part for Part in RelativePath.split("/") if Part]
for Index, Part in enumerate(Parts):
if not Node.ChildrenLoaded:
Node.LoadChildren()
NextNode = Node.ChildNodes.get(Part)
Leaf = Node.FileLeaves.get(Part) if Index == len(Parts) - 1 else None
if NextNode is None and Leaf is None:
break # hidden: highlight the closest parent
Node.Tile.expanded = True
Node.Tile.update()
if Leaf is not None:
Target = Leaf
break
Node = Target = NextNode
# Every directory down to (and including) the one you're in, like the web view.
Walk = self.RootNodes.get(Root or "")
self.PathNodes = [Walk] if Walk else []
for Part in Parts:
Walk = Walk.ChildNodes.get(Part) if Walk else None
if Walk is None:
break
self.PathNodes.append(Walk)
for Marked in self.PathNodes:
Marked.SetOnPath(True)
Target.SetSelected(True)
self.SelectedNode = Target