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