mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +02:00
Add Flet desktop client
This commit is contained in:
1 parent
16787aba14
commit
0eecfba224
18 files changed
+2588
No files matched your search
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
build/
|
||||
.flet/
|
||||
dist/
|
||||
*.AppImage
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
"""What the window's parts share: the config, the current Source (local or
|
||||
server), toasts, dialogs and the login flow. Changing the source (Settings,
|
||||
logging in or out) tells every listener to reload."""
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from Core import AppConfig, ChipButton, LoadSessionToken, NotificationStyle, Palette, SaveSessionToken
|
||||
from Sources import MakeSource, ServerSource, Source, SourceError
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self, Page: ft.Page, Config: AppConfig):
|
||||
self.Page = Page
|
||||
self.Config = Config
|
||||
self.Picker = ft.FilePicker() # registers itself with the page
|
||||
self.Source: Source = MakeSource(Config)
|
||||
self.ConnectError = "" # why the server couldn't be reached, shown instead of the file list
|
||||
self.DialogOpen = False # keyboard shortcuts pause while a dialog is up
|
||||
self.Listeners: list[Callable[[], None]] = [] # run after the source changes
|
||||
|
||||
# --- Source -----------------------------------------------------------
|
||||
|
||||
def Connect(self) -> None:
|
||||
"""(Re)builds the source from Config and, for a server, checks it's
|
||||
there and whether the saved login still works. Listeners reload."""
|
||||
if isinstance(self.Source, ServerSource):
|
||||
self.Source.Close()
|
||||
Token = LoadSessionToken(self.Config.ServerUrl) if self.Config.Mode == "Server" and self.Config.RememberLogin else ""
|
||||
self.Source = MakeSource(self.Config, Token)
|
||||
self.ConnectError = ""
|
||||
if isinstance(self.Source, ServerSource):
|
||||
try:
|
||||
self.Source.Connect()
|
||||
except SourceError as Error:
|
||||
self.ConnectError = str(Error)
|
||||
if Token and self.Source.User is None and not self.ConnectError:
|
||||
SaveSessionToken(self.Config.ServerUrl, "")
|
||||
self.Notify("Your saved login ended (the server restarted?). Browsing as Guest.", "warning")
|
||||
self.Changed()
|
||||
|
||||
def Changed(self) -> None:
|
||||
for Listener in self.Listeners:
|
||||
Listener()
|
||||
|
||||
@property
|
||||
def Server(self) -> Optional[ServerSource]:
|
||||
return self.Source if isinstance(self.Source, ServerSource) else None
|
||||
|
||||
def HandleError(self, Error: SourceError, Doing: str = "") -> None:
|
||||
"""A toast, plus the login dialog when the server wants a login."""
|
||||
Text = f"{Doing}: {Error}" if Doing else str(Error)
|
||||
if Error.Status == 401 and self.Server is not None:
|
||||
if self.Server.User is not None: # the session ended under us
|
||||
self.Server.Logout()
|
||||
SaveSessionToken(self.Config.ServerUrl, "")
|
||||
self.Changed()
|
||||
self.ShowLoginDialog(Reason=Text)
|
||||
return
|
||||
self.Notify(Text, "error")
|
||||
|
||||
# --- Login ------------------------------------------------------------
|
||||
|
||||
def ShowLoginDialog(self, Reason: str = "") -> None:
|
||||
Server = self.Server
|
||||
if Server is None:
|
||||
return
|
||||
UsernameField = ft.TextField(label="Username", value=self.Config.Username, autofocus=not self.Config.Username)
|
||||
PasswordField = ft.TextField(label="Password", password=True, can_reveal_password=True,
|
||||
autofocus=bool(self.Config.Username))
|
||||
RememberSwitch = ft.Switch(label="Stay logged in", value=self.Config.RememberLogin)
|
||||
ErrorText = ft.Text("", color=Palette.Red, size=12, visible=False)
|
||||
|
||||
def DoLogin(e=None) -> None:
|
||||
Username = (UsernameField.value or "").strip()
|
||||
if not Username or not PasswordField.value:
|
||||
return
|
||||
try:
|
||||
Server.Login(Username, PasswordField.value)
|
||||
except SourceError as Error:
|
||||
ErrorText.value = str(Error)
|
||||
ErrorText.visible = True
|
||||
ErrorText.update()
|
||||
return
|
||||
self.Config.Username = Username
|
||||
self.Config.RememberLogin = RememberSwitch.value
|
||||
self.Config.Save()
|
||||
SaveSessionToken(self.Config.ServerUrl, Server.Token if RememberSwitch.value else "")
|
||||
self.Close()
|
||||
self.Notify(f"Logged in as {Username}.", "success")
|
||||
self.Changed()
|
||||
|
||||
UsernameField.on_submit = lambda e: PasswordField.focus()
|
||||
PasswordField.on_submit = DoLogin
|
||||
Content = [ft.Text(Server.Url, color=Palette.Overlay0, size=12)]
|
||||
if Reason:
|
||||
Content.append(ft.Text(Reason, color=Palette.Yellow, size=13))
|
||||
Content += [UsernameField, PasswordField, RememberSwitch, ErrorText]
|
||||
self.ShowDialog("Log in", ft.Column(Content, tight=True, spacing=12), [
|
||||
ChipButton("Cancel", lambda e: self.Close()),
|
||||
ChipButton("Log in", DoLogin),
|
||||
], Width=380)
|
||||
|
||||
def Logout(self) -> None:
|
||||
if self.Server is None:
|
||||
return
|
||||
self.Server.Logout()
|
||||
SaveSessionToken(self.Config.ServerUrl, "")
|
||||
self.Notify("Logged out. Browsing as Guest.", "info")
|
||||
self.Changed()
|
||||
|
||||
# --- UI helpers -------------------------------------------------------
|
||||
|
||||
def Notify(self, Text: str, Kind: str = "info") -> None:
|
||||
Background, Accent = NotificationStyle(Kind)
|
||||
self.Page.show_dialog(ft.SnackBar(
|
||||
content=ft.Text(Text, color=Accent),
|
||||
bgcolor=Background,
|
||||
shape=ft.RoundedRectangleBorder(radius=4, side=ft.BorderSide(1, Accent)),
|
||||
open=True,
|
||||
))
|
||||
|
||||
def ShowDialog(self, Title: str, Content: ft.Control, Actions: list[ft.Control], Width: int = 440) -> None:
|
||||
self.DialogOpen = True
|
||||
self.Page.show_dialog(ft.AlertDialog(
|
||||
title=Title,
|
||||
content=ft.Container(Content, width=Width),
|
||||
actions=Actions,
|
||||
on_dismiss=lambda e: setattr(self, "DialogOpen", False),
|
||||
))
|
||||
|
||||
def Close(self) -> None:
|
||||
self.DialogOpen = False
|
||||
self.Page.pop_dialog()
|
||||
|
||||
def Confirm(self, Title: str, Text: str, OnYes: Callable[[], None], YesText: str = "Delete") -> None:
|
||||
def Yes(e) -> None:
|
||||
self.Close()
|
||||
OnYes()
|
||||
|
||||
self.ShowDialog(Title, ft.Text(Text), [
|
||||
ChipButton("Cancel", lambda e: self.Close()),
|
||||
ChipButton(YesText, Yes, Accent=Palette.Red),
|
||||
])
|
||||
|
||||
def AskName(self, Title: str, Value: str, SelectEnd: int, OnDone: Callable[[str], None],
|
||||
ActionText: str = "Create", Note: str = "", RequireChange: bool = True) -> None:
|
||||
"""A one-field dialog with the name's stem preselected, like SaraFem's.
|
||||
RequireChange: an unchanged name does nothing (Rename)."""
|
||||
NameField = ft.TextField(value=Value, autofocus=True,
|
||||
selection=ft.TextSelection(base_offset=0, extent_offset=SelectEnd))
|
||||
|
||||
def Done(e=None) -> None:
|
||||
NewName = (NameField.value or "").strip()
|
||||
self.Close()
|
||||
if NewName and (NewName != Value or not RequireChange):
|
||||
OnDone(NewName)
|
||||
|
||||
NameField.on_submit = Done
|
||||
Content: ft.Control = NameField
|
||||
if Note:
|
||||
Content = ft.Column([NameField, ft.Text(Note, color=Palette.Overlay0, size=12)], tight=True, spacing=8)
|
||||
self.ShowDialog(Title, Content, [
|
||||
ChipButton("Cancel", lambda e: self.Close()),
|
||||
ChipButton(ActionText, Done),
|
||||
], Width=380)
|
||||
@@ -0,0 +1,651 @@
|
||||
"""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, HumanSize, OpenExternally, Palette
|
||||
from Sources import BaseName, Entry, JoinPath, MoveDestination, ParentPath, SourceError
|
||||
from Viewer import FileView
|
||||
|
||||
DoubleClickWindow = 0.4 # seconds
|
||||
|
||||
FileIcons = {
|
||||
"markdown": ft.Icons.DESCRIPTION_OUTLINED,
|
||||
"text": ft.Icons.ARTICLE_OUTLINED,
|
||||
"config": ft.Icons.SETTINGS_OUTLINED,
|
||||
"html": ft.Icons.CODE,
|
||||
"image": ft.Icons.IMAGE_OUTLINED,
|
||||
"video": ft.Icons.MOVIE_OUTLINED,
|
||||
}
|
||||
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.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.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.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
|
||||
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 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()
|
||||
|
||||
def _CloseFile(self) -> None:
|
||||
self.OpenView = None
|
||||
self.ShortcutsPaused = False
|
||||
self._ShowList()
|
||||
|
||||
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:
|
||||
View.RequestClose()
|
||||
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)
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
"""Shared app-wide constants: palette, theme, paths, SaraView client config.
|
||||
|
||||
Palette, theming and the shared SaraSuite settings mirror SaraFem's Core.py
|
||||
and read the same ~/.config/SaraSuite files, so a theme picked for one Sara
|
||||
program looks the same in all of them. The role names are SaraView's own
|
||||
(Backend/DefaultThemes/*.toml)."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import flet as ft
|
||||
import tomlkit
|
||||
|
||||
|
||||
class Palette:
|
||||
"""Live values for the active theme (see ApplyTheme). Starts as Catppuccin
|
||||
Mocha so anything constructed before ApplyTheme() runs still looks right.
|
||||
Same attribute names and role mapping as SaraFem's Palette."""
|
||||
|
||||
Mauve = "#cba6f7" # SaraView's "Purple" role: the one accent colour
|
||||
Red = "#f38ba8"
|
||||
Peach = "#fab387" # SaraView's "Orange"
|
||||
Yellow = "#f9e2af"
|
||||
Green = "#a6e3a1"
|
||||
Blue = "#89b4fa"
|
||||
Text = "#cdd6f4"
|
||||
White = "#cdd6f4"
|
||||
Overlay0 = "#6c7086" # SaraView's "TextMuted"
|
||||
Surface1 = "#45475a" # SaraView's "Border"
|
||||
Surface0 = "#313244" # SaraView's "SurfaceAlt"
|
||||
Base = "#1e1e2e" # SaraView's "Background"
|
||||
Mantle = "#181825" # SaraView's "Surface"
|
||||
Crust = "#11111b" # SaraView's "Black"
|
||||
|
||||
|
||||
AppName = "SaraView"
|
||||
# Bundled in assets/fonts and always registered, so it works as FontFamily
|
||||
# without being installed.
|
||||
BundledFontFamily = "Fixedsys Excelsior"
|
||||
ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme
|
||||
|
||||
Home = Path.home()
|
||||
SharedConfigDir = Home / ".config" / "SaraSuite"
|
||||
ThemesDir = SharedConfigDir / "Themes"
|
||||
SharedConfigPath = SharedConfigDir / "Config.toml"
|
||||
ConfigDir = SharedConfigDir / AppName
|
||||
ConfigPath = ConfigDir / "Config.toml"
|
||||
# The server's session token, kept out of Config.toml so a settings export
|
||||
# never carries it.
|
||||
SessionPath = ConfigDir / "Session.toml"
|
||||
# Server files opened in another program are downloaded here first.
|
||||
CacheDir = Home / ".cache" / AppName
|
||||
DataHome = Home / ".local" / "share"
|
||||
|
||||
# Inside a PyInstaller build, bundled files are unpacked to sys._MEIPASS.
|
||||
BundleDir = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
|
||||
AssetsDir = BundleDir / "assets"
|
||||
|
||||
_ThemeRoleMap = {
|
||||
"Background": "Base", "Surface": "Mantle", "SurfaceAlt": "Surface0", "Border": "Surface1",
|
||||
"Text": "Text", "TextMuted": "Overlay0", "Black": "Crust", "White": "White",
|
||||
"Red": "Red", "Orange": "Peach", "Yellow": "Yellow", "Green": "Green",
|
||||
"Blue": "Blue", "Purple": "Mauve",
|
||||
}
|
||||
|
||||
# Same as SaraFem's BuiltinThemes, copied from Backend/DefaultThemes/*.toml.
|
||||
BuiltinThemes: dict[str, dict[str, str]] = {
|
||||
"CatppuccinMocha": {
|
||||
"Background": "#1e1e2e", "Surface": "#181825", "SurfaceAlt": "#313244", "Border": "#45475a",
|
||||
"Text": "#cdd6f4", "TextMuted": "#6c7086", "Black": "#11111b", "White": "#cdd6f4",
|
||||
"Red": "#f38ba8", "Orange": "#fab387", "Yellow": "#f9e2af", "Green": "#a6e3a1",
|
||||
"Blue": "#89b4fa", "Purple": "#cba6f7",
|
||||
},
|
||||
"Forest": {
|
||||
"Background": "#1b2420", "Surface": "#141b17", "SurfaceAlt": "#243029", "Border": "#334539",
|
||||
"Text": "#d6e5d0", "TextMuted": "#7c9184", "Black": "#0d120f", "White": "#eef5ea",
|
||||
"Red": "#e0685a", "Orange": "#e0a458", "Yellow": "#e0c458", "Green": "#7fb069",
|
||||
"Blue": "#5c9e8f", "Purple": "#9b7fb0",
|
||||
},
|
||||
"Gruvbox": {
|
||||
"Background": "#282828", "Surface": "#1d2021", "SurfaceAlt": "#3c3836", "Border": "#504945",
|
||||
"Text": "#ebdbb2", "TextMuted": "#a89984", "Black": "#1d2021", "White": "#fbf1c7",
|
||||
"Red": "#fb4934", "Orange": "#fe8019", "Yellow": "#fabd2f", "Green": "#b8bb26",
|
||||
"Blue": "#83a598", "Purple": "#d3869b",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _ReadToml(FilePath: Path) -> dict:
|
||||
"""The file's data, or {} if it's missing or unreadable."""
|
||||
try:
|
||||
return tomlkit.parse(FilePath.read_text()).unwrap()
|
||||
except (OSError, tomlkit.exceptions.TOMLKitError):
|
||||
return {}
|
||||
|
||||
|
||||
def EnsureSharedThemes() -> None:
|
||||
"""Seed the shared Themes directory with the bundled defaults, like SaraFem."""
|
||||
ThemesDir.mkdir(parents=True, exist_ok=True)
|
||||
for Name, RoleValues in BuiltinThemes.items():
|
||||
ThemeFile = ThemesDir / f"{Name}.toml"
|
||||
if not ThemeFile.exists():
|
||||
ThemeFile.write_text(tomlkit.dumps({"Palette": RoleValues}))
|
||||
|
||||
|
||||
def AvailableThemes() -> list[str]:
|
||||
EnsureSharedThemes()
|
||||
return sorted(ThemeFile.stem for ThemeFile in ThemesDir.glob("*.toml"))
|
||||
|
||||
|
||||
def ApplyTheme(ThemeName: str) -> None:
|
||||
"""Mutate Palette in place to the named theme. Must run before any control
|
||||
that reads Palette.X is built."""
|
||||
EnsureSharedThemes()
|
||||
RoleValues = dict(BuiltinThemes.get(ThemeName, BuiltinThemes["CatppuccinMocha"]))
|
||||
RoleValues.update(_ReadToml(ThemesDir / f"{ThemeName}.toml").get("Palette", {}))
|
||||
for RoleName, AttrName in _ThemeRoleMap.items():
|
||||
if RoleName in RoleValues:
|
||||
setattr(Palette, AttrName, RoleValues[RoleName])
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
# True: Theme, FontFamily and HideWindowTitleBar come from the shared
|
||||
# ~/.config/SaraSuite/Config.toml. False: SaraView's own values below.
|
||||
UseSharedSettings: bool = True
|
||||
Theme: str = "CatppuccinMocha"
|
||||
FontFamily: str = BundledFontFamily
|
||||
HideWindowTitleBar: bool = False
|
||||
|
||||
# "Local" browses LocalRoot on this computer; "Server" talks to a
|
||||
# SaraView server (the one on SpineS), with its users, locks and blame.
|
||||
Mode: str = "Local"
|
||||
LocalRoot: str = "~"
|
||||
ServerUrl: str = "" # e.g. http://spines:8420
|
||||
Username: str = "" # last one logged in with, prefilled in the login dialog
|
||||
RememberLogin: bool = True # keep the session in Session.toml between starts
|
||||
|
||||
# Local mode only; on a server these come from your SaraView user settings.
|
||||
ShowHiddenFolders: bool = False
|
||||
ShowSvFiles: bool = False
|
||||
# Same as the server's [SaraTag] HideInternalFiles/InternalPatterns.
|
||||
HideInternalFiles: bool = True
|
||||
InternalPatterns: list[str] = field(default_factory=lambda: [".saratag", ".SaraTag*", "*.book.toml"])
|
||||
|
||||
ConfirmDelete: bool = True
|
||||
ShowSidebar: bool = True
|
||||
SidebarWidth: int = 240
|
||||
FontSize: int = 14 # file list and viewer text
|
||||
# Double-click on a file: True opens it in SaraView's own viewer, False
|
||||
# hands it to the system's default program. The menu always offers the other.
|
||||
OpenFilesInApp: bool = True
|
||||
|
||||
def Save(self) -> None:
|
||||
ConfigDir.mkdir(parents=True, exist_ok=True)
|
||||
ConfigPath.write_text(tomlkit.dumps(asdict(self)))
|
||||
|
||||
@property
|
||||
def LocalRootPath(self) -> Path:
|
||||
return Path(os.path.expanduser(self.LocalRoot.strip() or "~")).resolve()
|
||||
|
||||
|
||||
def LoadConfig() -> AppConfig:
|
||||
if not ConfigPath.exists():
|
||||
Config = AppConfig()
|
||||
Config.Save()
|
||||
return Config
|
||||
Data = _ReadToml(ConfigPath)
|
||||
return AppConfig(**{Key: Value for Key, Value in Data.items() if Key in AppConfig.__dataclass_fields__})
|
||||
|
||||
|
||||
def LoadSessionToken(ServerUrl: str) -> str:
|
||||
"""The saved session for ServerUrl, or "" (a session from another server is useless)."""
|
||||
Data = _ReadToml(SessionPath)
|
||||
return Data.get("Token", "") if Data.get("ServerUrl") == ServerUrl else ""
|
||||
|
||||
|
||||
def SaveSessionToken(ServerUrl: str, Token: str) -> None:
|
||||
if not Token:
|
||||
SessionPath.unlink(missing_ok=True)
|
||||
return
|
||||
ConfigDir.mkdir(parents=True, exist_ok=True)
|
||||
SessionPath.write_text(tomlkit.dumps({"ServerUrl": ServerUrl, "Token": Token}))
|
||||
SessionPath.chmod(0o600)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SharedSettings:
|
||||
"""~/.config/SaraSuite/Config.toml, shared by every SaraSuite program.
|
||||
Same schema and fallback rules as SaraFem's."""
|
||||
|
||||
Theme: str = "CatppuccinMocha"
|
||||
FontFamily: str = BundledFontFamily
|
||||
HideWindowTitleBar: bool = False
|
||||
|
||||
def Save(self) -> None:
|
||||
SharedConfigDir.mkdir(parents=True, exist_ok=True)
|
||||
SharedConfigPath.write_text(tomlkit.dumps(asdict(self)))
|
||||
|
||||
|
||||
def _OwnSettings(Config: AppConfig) -> SharedSettings:
|
||||
return SharedSettings(Config.Theme, Config.FontFamily, Config.HideWindowTitleBar)
|
||||
|
||||
|
||||
def LoadSharedSettings(Config: AppConfig) -> SharedSettings:
|
||||
"""The shared file, created from SaraView's own values if missing."""
|
||||
if not SharedConfigPath.exists():
|
||||
Shared = _OwnSettings(Config)
|
||||
Shared.Save()
|
||||
return Shared
|
||||
Result = _OwnSettings(Config) # fallback for keys the file doesn't have
|
||||
Data = _ReadToml(SharedConfigPath)
|
||||
for Key in SharedSettings.__dataclass_fields__:
|
||||
if Key in Data:
|
||||
setattr(Result, Key, Data[Key])
|
||||
return Result
|
||||
|
||||
|
||||
def EffectiveSettings(Config: AppConfig) -> SharedSettings:
|
||||
return LoadSharedSettings(Config) if Config.UseSharedSettings else _OwnSettings(Config)
|
||||
|
||||
|
||||
def ExportSettings(Config: AppConfig, DestPath: Path) -> None:
|
||||
"""One portable file in SaraFem's format: the shared settings under
|
||||
[SaraSuite] (with a custom theme's palette as ThemeData) and SaraView's
|
||||
own under [SaraView]. Other programs ignore [SaraView] and vice versa."""
|
||||
Shared = LoadSharedSettings(Config)
|
||||
SharedData = asdict(Shared)
|
||||
if Shared.Theme not in BuiltinThemes:
|
||||
ThemeData = _ReadToml(ThemesDir / f"{Shared.Theme}.toml").get("Palette")
|
||||
if ThemeData:
|
||||
SharedData["ThemeData"] = ThemeData
|
||||
DestPath.write_text(tomlkit.dumps({"SaraSuite": SharedData, AppName: asdict(Config)}))
|
||||
|
||||
|
||||
def ImportSettings(Config: AppConfig, SourcePath: Path) -> None:
|
||||
"""Applies [SaraSuite] to the shared file and [SaraView] onto Config, key
|
||||
by key; unknown sections and keys are skipped. Raises TOMLKitError on an
|
||||
unreadable file."""
|
||||
Data = tomlkit.parse(SourcePath.read_text()).unwrap()
|
||||
|
||||
SharedData = Data.get("SaraSuite")
|
||||
if isinstance(SharedData, dict):
|
||||
ThemeData = SharedData.pop("ThemeData", None)
|
||||
Merged = asdict(LoadSharedSettings(Config))
|
||||
Merged.update({Key: Value for Key, Value in SharedData.items() if Key in SharedSettings.__dataclass_fields__})
|
||||
ImportedTheme = Merged.get("Theme")
|
||||
if isinstance(ThemeData, dict) and ImportedTheme and ImportedTheme not in BuiltinThemes:
|
||||
ThemesDir.mkdir(parents=True, exist_ok=True)
|
||||
(ThemesDir / f"{ImportedTheme}.toml").write_text(tomlkit.dumps({"Palette": ThemeData}))
|
||||
SharedSettings(**Merged).Save()
|
||||
|
||||
OwnData = Data.get(AppName)
|
||||
if isinstance(OwnData, dict):
|
||||
for Key, Value in OwnData.items():
|
||||
if Key in AppConfig.__dataclass_fields__:
|
||||
setattr(Config, Key, Value)
|
||||
Config.Save()
|
||||
|
||||
|
||||
def ApplyAppearance(Settings: SharedSettings) -> None:
|
||||
"""Theme colours and font; must run before anything reads Palette.X."""
|
||||
global ActiveFontFamily
|
||||
ApplyTheme(Settings.Theme)
|
||||
ActiveFontFamily = Settings.FontFamily or BundledFontFamily
|
||||
|
||||
|
||||
def ChildEnvironment() -> dict[str, str]:
|
||||
"""The environment for child processes (xdg-open, fc-list). A PyInstaller
|
||||
build points LD_LIBRARY_PATH at its own bundled libraries; system tools
|
||||
need the original back. Same as SaraSort's."""
|
||||
Environment = dict(os.environ)
|
||||
if getattr(sys, "frozen", False):
|
||||
Original = Environment.pop("LD_LIBRARY_PATH_ORIG", None)
|
||||
if Original is not None:
|
||||
Environment["LD_LIBRARY_PATH"] = Original
|
||||
else:
|
||||
Environment.pop("LD_LIBRARY_PATH", None)
|
||||
for Key in ("PYTHONHOME", "PYTHONPATH"):
|
||||
Environment.pop(Key, None)
|
||||
return Environment
|
||||
|
||||
|
||||
def OpenExternally(FilePath: Path) -> None:
|
||||
"""Hands a file to the system's default program. Raises OSError."""
|
||||
subprocess.Popen(["xdg-open", str(FilePath)], env=ChildEnvironment(), start_new_session=True)
|
||||
|
||||
|
||||
def InstalledFontFamilies() -> list[str]:
|
||||
"""Font families fontconfig knows about, with the bundled font first."""
|
||||
try:
|
||||
Result = subprocess.run(
|
||||
["fc-list", ":", "family"], capture_output=True, text=True, timeout=5, env=ChildEnvironment(),
|
||||
)
|
||||
Families = {Line.split(",")[0].strip() for Line in Result.stdout.splitlines() if Line.strip()}
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
Families = set()
|
||||
Families.discard(BundledFontFamily)
|
||||
return [BundledFontFamily] + sorted(Families, key=str.lower)
|
||||
|
||||
|
||||
def BuildTheme() -> ft.Theme:
|
||||
ColorScheme = ft.ColorScheme(
|
||||
primary=Palette.Mauve,
|
||||
on_primary=Palette.Crust,
|
||||
primary_container=Palette.Surface0,
|
||||
on_primary_container=Palette.Mauve,
|
||||
secondary=Palette.Mauve,
|
||||
on_secondary=Palette.Crust,
|
||||
secondary_container=Palette.Surface0,
|
||||
on_secondary_container=Palette.Mauve,
|
||||
surface=Palette.Base,
|
||||
on_surface=Palette.Text,
|
||||
on_surface_variant=Palette.Overlay0,
|
||||
surface_container=Palette.Mantle,
|
||||
surface_container_low=Palette.Mantle,
|
||||
surface_container_high=Palette.Surface0,
|
||||
surface_container_highest=Palette.Surface1,
|
||||
outline=Palette.Surface1,
|
||||
outline_variant=Palette.Surface1,
|
||||
error=Palette.Red,
|
||||
on_error=Palette.Crust,
|
||||
)
|
||||
return ft.Theme(
|
||||
font_family=ActiveFontFamily,
|
||||
color_scheme=ColorScheme,
|
||||
use_material3=True,
|
||||
scaffold_bgcolor=Palette.Base,
|
||||
canvas_color=Palette.Base,
|
||||
dialog_theme=ft.DialogTheme(
|
||||
bgcolor=Palette.Mantle,
|
||||
shape=ft.RoundedRectangleBorder(radius=8, side=ft.BorderSide(1, Palette.Mauve)),
|
||||
title_text_style=ft.TextStyle(color=Palette.Mauve, size=18, font_family=ActiveFontFamily),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def ChipButtonStyle(Accent: str = "") -> ft.ButtonStyle:
|
||||
"""SaraView's button look: SurfaceAlt fill, 1px accent border, small radius.
|
||||
Disabled, it fades to the muted colours (plain colours would look enabled)."""
|
||||
return ft.ButtonStyle(
|
||||
color={ft.ControlState.DISABLED: Palette.Overlay0, ft.ControlState.DEFAULT: Palette.Text},
|
||||
icon_color={ft.ControlState.DISABLED: Palette.Overlay0, ft.ControlState.DEFAULT: Palette.Text},
|
||||
bgcolor={ft.ControlState.DISABLED: Palette.Mantle, ft.ControlState.DEFAULT: Palette.Surface0},
|
||||
side={
|
||||
ft.ControlState.DISABLED: ft.BorderSide(1, Palette.Surface1),
|
||||
ft.ControlState.DEFAULT: ft.BorderSide(1, Accent or Palette.Mauve),
|
||||
},
|
||||
shape=ft.RoundedRectangleBorder(radius=4),
|
||||
)
|
||||
|
||||
|
||||
def CodeStyleSheet() -> ft.MarkdownStyleSheet:
|
||||
"""Code in the app's own font rather than Flutter's default monospace."""
|
||||
return ft.MarkdownStyleSheet(code_text_style=ft.TextStyle(font_family=ActiveFontFamily))
|
||||
|
||||
|
||||
def ChipButton(Text: str, OnClick, Icon=None, Accent: str = "", Disabled: bool = False) -> ft.OutlinedButton:
|
||||
return ft.OutlinedButton(content=Text, icon=Icon, style=ChipButtonStyle(Accent), on_click=OnClick, disabled=Disabled)
|
||||
|
||||
|
||||
def BlendHex(BaseHex: str, AccentHex: str, Ratio: float) -> str:
|
||||
"""Solid colour mixing Base and Accent (0 = pure Base, 1 = pure Accent).
|
||||
Same as SaraFem's."""
|
||||
BaseRGB = tuple(int(BaseHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4))
|
||||
AccentRGB = tuple(int(AccentHex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4))
|
||||
Mixed = (round(B + (A - B) * Ratio) for B, A in zip(BaseRGB, AccentRGB))
|
||||
return "#" + "".join(f"{C:02x}" for C in Mixed)
|
||||
|
||||
|
||||
def NotificationStyle(Kind: str) -> tuple[str, str]:
|
||||
"""(Background, AccentColor) for a toast, from the theme's own colours like SaraFem's."""
|
||||
Accent = {"success": Palette.Green, "error": Palette.Red, "warning": Palette.Yellow}.get(Kind, Palette.Mauve)
|
||||
return BlendHex(Palette.Mantle, Accent, 0.12), Accent
|
||||
|
||||
|
||||
def ResizeHandle(OnDrag: Callable[[float], None], OnDragEnd: Callable[[], None]) -> ft.GestureDetector:
|
||||
"""A thin draggable divider between side-by-side panels (SaraFem's
|
||||
ResizeHandle, horizontal only). The parent Row must stretch its children
|
||||
vertically, or the handle has no height to drag."""
|
||||
return ft.GestureDetector(
|
||||
content=ft.Container(width=6, bgcolor=Palette.Surface1),
|
||||
mouse_cursor=ft.MouseCursor.RESIZE_COLUMN,
|
||||
on_horizontal_drag_update=lambda e: OnDrag(e.primary_delta or 0),
|
||||
on_horizontal_drag_end=lambda e: OnDragEnd(),
|
||||
)
|
||||
|
||||
|
||||
def HumanSize(NumBytes: Optional[int]) -> str:
|
||||
if NumBytes is None:
|
||||
return ""
|
||||
Size = float(NumBytes)
|
||||
for Unit in ("B", "KB", "MB", "GB"):
|
||||
if Size < 1024 or Unit == "GB":
|
||||
return f"{Size:.0f} {Unit}" if Unit == "B" else f"{Size:.1f} {Unit}"
|
||||
Size /= 1024
|
||||
return f"{Size:.1f} TB"
|
||||
|
||||
|
||||
def DisplayPath(PathText: str) -> str:
|
||||
"""/home/sarah/Notes -> ~/Notes, for labels."""
|
||||
HomeText = str(Home)
|
||||
if PathText == HomeText or PathText.startswith(HomeText + "/"):
|
||||
return "~" + PathText[len(HomeText):]
|
||||
return PathText
|
||||
|
||||
|
||||
def ClearCache() -> None:
|
||||
shutil.rmtree(CacheDir, ignore_errors=True)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""The sidebar's directory 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."""
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from App import App
|
||||
from Core import Palette
|
||||
from Sources import JoinPath, SourceError
|
||||
|
||||
Location = tuple[str, str] # (Root, Path)
|
||||
|
||||
|
||||
class _FiletreeNode:
|
||||
def __init__(self, TheApp: App, Root: str, RelativePath: str, OnNavigate: Callable[[str, str], None], Depth: int):
|
||||
self.TheApp = TheApp
|
||||
self.Root = Root
|
||||
self.RelativePath = RelativePath
|
||||
self.OnNavigate = OnNavigate
|
||||
self.Depth = Depth
|
||||
self.ChildrenLoaded = False
|
||||
self.ChildNodes: dict[str, "_FiletreeNode"] = {}
|
||||
|
||||
Name = RelativePath.rsplit("/", 1)[-1] if RelativePath else Root
|
||||
self.NameLabel = ft.Text(Name, size=13, color=Palette.Text, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1)
|
||||
self.NameContainer = ft.Container(
|
||||
content=ft.GestureDetector(content=self.NameLabel, on_tap=lambda e: self.OnNavigate(self.Root, self.RelativePath)),
|
||||
padding=4,
|
||||
border_radius=4,
|
||||
)
|
||||
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:
|
||||
Directories = [Item.Name for Item in self.TheApp.Source.List(self.Root, self.RelativePath) if Item.IsDirectory]
|
||||
except SourceError:
|
||||
Directories = [] # locked, gone or unreachable: just no children
|
||||
self.ChildNodes = {}
|
||||
for Name in Directories:
|
||||
ChildPath = JoinPath(self.RelativePath, Name)
|
||||
self.ChildNodes[Name] = _FiletreeNode(self.TheApp, self.Root, ChildPath, self.OnNavigate, self.Depth + 1)
|
||||
self.Tile.controls = [Node.Tile for Node in self.ChildNodes.values()]
|
||||
self.ChildrenLoaded = True
|
||||
if Update:
|
||||
self.Tile.update()
|
||||
|
||||
def SetSelected(self, IsSelected: bool) -> None:
|
||||
self.NameContainer.bgcolor = Palette.Surface1 if IsSelected else None
|
||||
self.NameLabel.color = Palette.Mauve if IsSelected else Palette.Text
|
||||
self.NameContainer.update()
|
||||
|
||||
|
||||
class Filetree(ft.Container):
|
||||
def __init__(self, TheApp: App, OnNavigate: Callable[[str, str], None]):
|
||||
self.TheApp = TheApp
|
||||
self.OnNavigate = OnNavigate
|
||||
self.RootNodes: dict[str, _FiletreeNode] = {}
|
||||
self.SelectedNode: Optional[_FiletreeNode] = None
|
||||
self.TreeList = ft.ListView(expand=True)
|
||||
self.SourceLabel = ft.Text("", size=12, color=Palette.Overlay0, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1)
|
||||
super().__init__(
|
||||
content=ft.Column([
|
||||
ft.Container(self.SourceLabel, padding=ft.Padding.only(left=12, right=12, top=10, bottom=4)),
|
||||
self.TreeList,
|
||||
], spacing=0, expand=True),
|
||||
width=TheApp.Config.SidebarWidth,
|
||||
bgcolor=Palette.Mantle,
|
||||
)
|
||||
|
||||
def Reload(self) -> None:
|
||||
"""Rebuilds the tree from the current source (after connecting,
|
||||
logging in or out, or a change that moved directories around)."""
|
||||
Source = self.TheApp.Source
|
||||
self.SourceLabel.value = "Not connected" if self.TheApp.ConnectError else Source.Describe()
|
||||
self.SelectedNode = None
|
||||
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, 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, loading as needed."""
|
||||
if self.SelectedNode:
|
||||
self.SelectedNode.SetSelected(False)
|
||||
self.SelectedNode = None
|
||||
Node = self.RootNodes.get(Root or "")
|
||||
if Node is None:
|
||||
return
|
||||
for Part in [Part for Part in RelativePath.split("/") if Part]:
|
||||
if not Node.ChildrenLoaded:
|
||||
Node.LoadChildren()
|
||||
NextNode = Node.ChildNodes.get(Part)
|
||||
if NextNode is None:
|
||||
break # hidden or not a directory: highlight the closest parent
|
||||
Node.Tile.expanded = True
|
||||
Node.Tile.update()
|
||||
Node = NextNode
|
||||
Node.SetSelected(True)
|
||||
self.SelectedNode = Node
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
"""SaraView's desktop client: browse and edit files in a local root directory,
|
||||
or on a SaraView server (the one on SpineS) through its API. Laid out like
|
||||
SaraFem: a directory tree on the left, the file list (or an open file) on
|
||||
the right."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from App import App
|
||||
from Browser import Browser
|
||||
from Core import (
|
||||
AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, EffectiveSettings, LoadConfig, Palette,
|
||||
ResizeHandle,
|
||||
)
|
||||
from Filetree import Filetree
|
||||
from Settings import ShowSettingsDialog
|
||||
|
||||
|
||||
def _SetCursorThemeFallback() -> None:
|
||||
"""GTK reads XCURSOR_THEME directly and doesn't always inherit it (shows as
|
||||
'Unable to load ... from the cursor theme'). Same fix as SaraFem's."""
|
||||
if os.environ.get("XCURSOR_THEME"):
|
||||
return
|
||||
try:
|
||||
Result = subprocess.run(["gsettings", "get", "org.gnome.desktop.interface", "cursor-theme"],
|
||||
capture_output=True, text=True, timeout=2)
|
||||
Theme = Result.stdout.strip().strip("'")
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
Theme = ""
|
||||
os.environ["XCURSOR_THEME"] = Theme or "Adwaita"
|
||||
|
||||
|
||||
def Main(Page: ft.Page) -> None:
|
||||
Config = LoadConfig()
|
||||
Appearance = EffectiveSettings(Config)
|
||||
ApplyAppearance(Appearance) # before anything reads Palette.X
|
||||
|
||||
Page.title = AppName
|
||||
Page.theme_mode = ft.ThemeMode.DARK
|
||||
Page.theme = BuildTheme()
|
||||
Page.dark_theme = BuildTheme()
|
||||
Page.fonts = {BundledFontFamily: "fonts/Fixedsys_Excelsior_Regular.ttf"}
|
||||
Page.window.title_bar_hidden = Appearance.HideWindowTitleBar
|
||||
Page.padding = 0
|
||||
Page.bgcolor = Palette.Base
|
||||
Page.window.width = 1180
|
||||
Page.window.height = 760
|
||||
Page.window.min_width = 720
|
||||
Page.window.min_height = 480
|
||||
|
||||
TheApp = App(Page, Config)
|
||||
|
||||
def OnPathChanged(Root: Optional[str], RelativePath: str, Reload: bool) -> None:
|
||||
if Reload: # directories may have moved, so the tree starts over
|
||||
Tree.Reload()
|
||||
Tree.RevealPath(Root, RelativePath)
|
||||
|
||||
def ShowSettings(Category: str = "General") -> None:
|
||||
ShowSettingsDialog(TheApp, OnSettingsSaved, Category)
|
||||
|
||||
def OnSettingsSaved(Reconnect: bool) -> None:
|
||||
Tree.visible = Divider.visible = Config.ShowSidebar
|
||||
if Reconnect:
|
||||
TheApp.Connect() # its listeners reload the tree and the browser
|
||||
else:
|
||||
Body.update()
|
||||
TheBrowser.Refresh()
|
||||
|
||||
def ToggleSidebar() -> None:
|
||||
Config.ShowSidebar = not Config.ShowSidebar
|
||||
Config.Save()
|
||||
Tree.visible = Divider.visible = Config.ShowSidebar
|
||||
Body.update()
|
||||
|
||||
def OnDrag(Delta: float) -> None:
|
||||
Tree.width = max(140, (Tree.width or Config.SidebarWidth) + Delta)
|
||||
Tree.update()
|
||||
|
||||
def OnDragEnd() -> None:
|
||||
Config.SidebarWidth = int(Tree.width)
|
||||
Config.Save()
|
||||
|
||||
TheBrowser = Browser(TheApp, OnPathChanged, lambda: ShowSettings("Connection" if TheApp.ConnectError else "General"),
|
||||
ToggleSidebar)
|
||||
Tree = Filetree(TheApp, TheBrowser.NavigateTo)
|
||||
Divider = ResizeHandle(OnDrag, OnDragEnd)
|
||||
Tree.visible = Divider.visible = Config.ShowSidebar
|
||||
# STRETCH so the divider gets the full height to be dragged along.
|
||||
Body = ft.Row([Tree, Divider, TheBrowser], expand=True, spacing=0, vertical_alignment=ft.CrossAxisAlignment.STRETCH)
|
||||
Page.add(Body)
|
||||
|
||||
def OnSourceChanged() -> None:
|
||||
Tree.Reload()
|
||||
TheBrowser.SourceChanged()
|
||||
|
||||
TheApp.Listeners.append(OnSourceChanged)
|
||||
TheApp.Connect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_SetCursorThemeFallback()
|
||||
ft.run(Main, assets_dir=str(AssetsDir))
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# Builds dist/SaraView-x86_64.AppImage: a PyInstaller one-file build (with
|
||||
# the font and icon bundled) wrapped by appimagetool, the same way SaraSort's,
|
||||
# SaraSuite's and SaraFem's AppImages are made. Needs appimagetool on PATH.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$(readlink -f "$0")")/.."
|
||||
|
||||
[ -d .venv ] || python3 -m venv .venv
|
||||
.venv/bin/pip install --quiet --disable-pip-version-check -r requirements.txt pyinstaller
|
||||
|
||||
# --collect-data flet: without it ft.Icons crashes when frozen.
|
||||
.venv/bin/pyinstaller --noconfirm --clean --log-level WARN \
|
||||
--onefile --windowed --name SaraView --collect-data flet \
|
||||
--add-data "$PWD/assets:assets" \
|
||||
--distpath build/PyInstaller/dist --workpath build/PyInstaller/work --specpath build/PyInstaller \
|
||||
"$PWD/Main.py"
|
||||
|
||||
AppDir=build/AppDir
|
||||
rm -rf "$AppDir"
|
||||
mkdir -p "$AppDir/usr/bin"
|
||||
cp build/PyInstaller/dist/SaraView "$AppDir/usr/bin/SaraView"
|
||||
ln -s usr/bin/SaraView "$AppDir/AppRun"
|
||||
cp assets/Icon.png "$AppDir/SaraView.png"
|
||||
ln -s SaraView.png "$AppDir/.DirIcon"
|
||||
cat > "$AppDir/SaraView.desktop" << DESKTOP
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=SaraView
|
||||
Comment=View and edit files, locally or on a SaraView server
|
||||
Exec=SaraView
|
||||
Icon=SaraView
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
DESKTOP
|
||||
|
||||
mkdir -p dist
|
||||
ARCH=x86_64 appimagetool --no-appstream "$AppDir" dist/SaraView-x86_64.AppImage
|
||||
echo "Built dist/SaraView-x86_64.AppImage"
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Desktop-entry launcher: resolves the venv relative to this script rather
|
||||
# than the caller's cwd, since .desktop Exec= lines are invoked with no
|
||||
# guaranteed working directory.
|
||||
cd "$(dirname "$(readlink -f "$0")")/.." || exit 1
|
||||
exec .venv/bin/python Main.py "$@"
|
||||
@@ -0,0 +1,37 @@
|
||||
# Kept in sync between SaraSuite/Programs/SaraView/ and SaraView/Client/Packaging/SaraSuite/.
|
||||
Name = "SaraView"
|
||||
Description = "SaraView's desktop client: view and edit files in a local folder, or on a SaraView server."
|
||||
|
||||
[Source]
|
||||
Git = "{GitHost}/SaraView"
|
||||
# Only the Flet client; the server (Backend/, Frontend/) runs in Docker on SpineS.
|
||||
Include = ["Client/*.py", "Client/requirements.txt", "Client/assets/**"]
|
||||
Exclude = ["Client/Packaging"]
|
||||
|
||||
[Python]
|
||||
RequirementsFile = "Client/requirements.txt"
|
||||
|
||||
[[Templates]]
|
||||
Source = "Launch.sh"
|
||||
Target = "{AppDir}/Client/Packaging/Launch.sh"
|
||||
Executable = true
|
||||
|
||||
[[Templates]]
|
||||
Source = "SaraView.desktop"
|
||||
Target = "{ApplicationsDir}/SaraView.desktop"
|
||||
|
||||
# Made by SaraView itself: Config.toml and Session.toml (your saved login).
|
||||
[[Leftovers]]
|
||||
Path = "{ConfigDir}"
|
||||
Kind = "Config"
|
||||
Note = "SaraView's settings and saved server login"
|
||||
|
||||
[[Leftovers]]
|
||||
Path = "{CacheHome}/SaraView"
|
||||
Kind = "Runtime"
|
||||
Note = "server files downloaded to open in other programs"
|
||||
|
||||
# Shared with the other Flet programs; see Programs/FletIdentity.py.
|
||||
[[Templates]]
|
||||
Source = "../../FletIdentity.py"
|
||||
Target = "{ProgramDir}/FletIdentity.py"
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Written by SaraSuite. Runs from the client's folder so Flet finds assets/.
|
||||
# A Flet client copy with the window app ID srhtnin.{Name}, so the desktop
|
||||
# matches the window to {Name}.desktop (StartupWMClass) instead of showing it
|
||||
# as "com.appveyor.flet". Empty if that fails: Flet then uses its own client.
|
||||
export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")"
|
||||
cd "{AppDir}/Client" || exit 1
|
||||
exec "{Python}" Main.py "$@"
|
||||
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=SaraView
|
||||
Comment=View and edit files, locally or on a SaraView server
|
||||
Exec={AppDir}/Client/Packaging/Launch.sh
|
||||
Icon={AppDir}/Client/assets/Icon.png
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
StartupNotify=true
|
||||
StartupWMClass=srhtnin.{Name}
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Settings dialog, laid out like SaraFem's and SaraSort's: a category list on
|
||||
the left (SaraSuite / General / Connection), the chosen category on the right."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import flet as ft
|
||||
import tomlkit
|
||||
|
||||
from App import App
|
||||
from Core import (
|
||||
AppConfig, AssetsDir, AvailableThemes, ChipButton, DataHome, ExportSettings, Home, ImportSettings,
|
||||
InstalledFontFamilies, LoadSharedSettings, Palette, SharedSettings,
|
||||
)
|
||||
|
||||
DialogWidth = 700
|
||||
DialogHeight = 520
|
||||
CategoryListWidth = 150
|
||||
RestartNote = "Theme, font and title bar changes apply the next time SaraView starts."
|
||||
|
||||
# When running as an AppImage, SaraView can put itself in the app menu (like SaraSort).
|
||||
AppImagePath = os.environ.get("APPIMAGE", "")
|
||||
MenuEntryPath = DataHome / "applications" / "SaraView.desktop"
|
||||
MenuIconPath = DataHome / "SaraView" / "SaraView.png"
|
||||
|
||||
|
||||
def SetMenuEntry(Enabled: bool) -> None:
|
||||
if not Enabled:
|
||||
MenuEntryPath.unlink(missing_ok=True)
|
||||
MenuIconPath.unlink(missing_ok=True)
|
||||
return
|
||||
MenuIconPath.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(AssetsDir / "Icon.png", MenuIconPath)
|
||||
MenuEntryPath.parent.mkdir(parents=True, exist_ok=True)
|
||||
MenuEntryPath.write_text(
|
||||
"[Desktop Entry]\nType=Application\nName=SaraView\n"
|
||||
"Comment=View and edit files, locally or on a SaraView server\n"
|
||||
f"Exec={AppImagePath}\nIcon={MenuIconPath}\nTerminal=false\n"
|
||||
"Categories=Utility;\nStartupNotify=true\n"
|
||||
)
|
||||
|
||||
|
||||
def _Note(Text: str) -> ft.Text:
|
||||
return ft.Text(Text, color=Palette.Overlay0, size=12)
|
||||
|
||||
|
||||
def _ReadInt(Field: ft.TextField, Fallback: int, Minimum: int, Maximum: int) -> int:
|
||||
try:
|
||||
return min(Maximum, max(Minimum, int(Field.value)))
|
||||
except (TypeError, ValueError):
|
||||
return Fallback # keep the previous value rather than crash on bad input
|
||||
|
||||
|
||||
def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCategory: str = "General") -> None:
|
||||
"""OnSaved(Reconnect): Reconnect is True when the source itself changed
|
||||
(mode, root, server or local listing options)."""
|
||||
Page = TheApp.Page
|
||||
Config: AppConfig = TheApp.Config
|
||||
Themes = AvailableThemes()
|
||||
Fonts = InstalledFontFamilies()
|
||||
|
||||
def ThemePicker(Value: str) -> ft.Dropdown:
|
||||
Names = Themes if Value in Themes else [Value] + Themes
|
||||
return ft.Dropdown(value=Value, label="Theme", options=[ft.DropdownOption(key=Name, text=Name) for Name in Names])
|
||||
|
||||
def FontPicker(Value: str) -> ft.Dropdown:
|
||||
Names = Fonts if Value in Fonts else [Value] + Fonts
|
||||
return ft.Dropdown(value=Value, label="Font", editable=True, enable_filter=True, menu_height=320,
|
||||
options=[ft.DropdownOption(key=Name, text=Name) for Name in Names])
|
||||
|
||||
# SaraSuite: ~/.config/SaraSuite/Config.toml, read by every SaraSuite program.
|
||||
Shared = LoadSharedSettings(Config)
|
||||
UseSharedSwitch = ft.Switch(label="SaraView uses these shared settings", value=Config.UseSharedSettings)
|
||||
SharedTheme = ThemePicker(Shared.Theme)
|
||||
SharedFont = FontPicker(Shared.FontFamily)
|
||||
SharedTitleBar = ft.Switch(label="Hide window title bar", value=Shared.HideWindowTitleBar)
|
||||
SettingsFileField = ft.TextField(value=str(Home / "SaraView-Settings.toml"), label="Settings file path")
|
||||
|
||||
# SaraView's own appearance, only used with shared settings off.
|
||||
OwnTheme = ThemePicker(Config.Theme)
|
||||
OwnFont = FontPicker(Config.FontFamily)
|
||||
OwnTitleBar = ft.Switch(label="Hide window title bar", value=Config.HideWindowTitleBar)
|
||||
OwnAppearance = ft.Column(
|
||||
[_Note("SaraView's own appearance, used when shared settings are off"), OwnTheme, OwnFont, OwnTitleBar],
|
||||
spacing=12, disabled=Config.UseSharedSettings, opacity=0.5 if Config.UseSharedSettings else 1,
|
||||
)
|
||||
|
||||
def OnUseSharedChanged(e) -> None:
|
||||
OwnAppearance.disabled = UseSharedSwitch.value
|
||||
OwnAppearance.opacity = 0.5 if UseSharedSwitch.value else 1
|
||||
OwnAppearance.update()
|
||||
|
||||
UseSharedSwitch.on_change = OnUseSharedChanged
|
||||
|
||||
# General.
|
||||
OpenInAppSwitch = ft.Switch(label="Double-click opens files in SaraView (off: in their default program)",
|
||||
value=Config.OpenFilesInApp)
|
||||
ConfirmDeleteSwitch = ft.Switch(label="Ask before deleting", value=Config.ConfirmDelete)
|
||||
SidebarSwitch = ft.Switch(label="Show the directory tree (Ctrl+B)", value=Config.ShowSidebar)
|
||||
FontSizeField = ft.TextField(label="Text size", value=str(Config.FontSize), keyboard_type=ft.KeyboardType.NUMBER, width=160)
|
||||
|
||||
def ToggleMenuEntry(e) -> None:
|
||||
SetMenuEntry(not MenuEntryPath.exists())
|
||||
MenuButton.content = "Remove from app menu" if MenuEntryPath.exists() else "Add to app menu"
|
||||
MenuButton.update()
|
||||
|
||||
MenuButton = ChipButton("Remove from app menu" if MenuEntryPath.exists() else "Add to app menu", ToggleMenuEntry)
|
||||
MenuButton.visible = bool(AppImagePath)
|
||||
|
||||
# Connection.
|
||||
ModePicker = ft.RadioGroup(
|
||||
ft.Row([ft.Radio(value="Local", label="Local files"), ft.Radio(value="Server", label="SaraView server")]),
|
||||
value=Config.Mode,
|
||||
)
|
||||
LocalRootField = ft.TextField(label="Root directory", value=Config.LocalRoot, expand=True)
|
||||
|
||||
async def BrowseRoot(e) -> None:
|
||||
Start = os.path.expanduser(LocalRootField.value or "~")
|
||||
Chosen = await TheApp.Picker.get_directory_path(dialog_title="Root directory",
|
||||
initial_directory=Start if os.path.isdir(Start) else None)
|
||||
if Chosen:
|
||||
LocalRootField.value = Chosen
|
||||
LocalRootField.update()
|
||||
|
||||
ShowHiddenSwitch = ft.Switch(label="Show hidden directories and files", value=Config.ShowHiddenFolders)
|
||||
ShowSvSwitch = ft.Switch(label="Show .sv_ directory notes", value=Config.ShowSvFiles)
|
||||
HideInternalSwitch = ft.Switch(label="Hide SaraTag's own files (Books, .saratag)", value=Config.HideInternalFiles)
|
||||
LocalOptions = ft.Column([
|
||||
ft.Row([LocalRootField, ft.IconButton(ft.Icons.FOLDER_OPEN_OUTLINED, tooltip="Browse", on_click=BrowseRoot)]),
|
||||
ShowHiddenSwitch, ShowSvSwitch, HideInternalSwitch,
|
||||
], spacing=12, visible=Config.Mode == "Local")
|
||||
|
||||
ServerUrlField = ft.TextField(label="Server address", value=Config.ServerUrl, hint_text="http://spines:8420")
|
||||
RememberSwitch = ft.Switch(label="Stay logged in between starts", value=Config.RememberLogin)
|
||||
ServerOptions = ft.Column([
|
||||
ServerUrlField,
|
||||
_Note("Over Tailscale, like the web view. Logged out, you browse as Guest; log in from the "
|
||||
"connection button in the toolbar to change files. Hidden files and the theme on the "
|
||||
"server follow your SaraView user settings."),
|
||||
RememberSwitch,
|
||||
], spacing=12, visible=Config.Mode == "Server")
|
||||
|
||||
def OnModeChanged(e) -> None:
|
||||
LocalOptions.visible = ModePicker.value == "Local"
|
||||
ServerOptions.visible = ModePicker.value == "Server"
|
||||
LocalOptions.update()
|
||||
ServerOptions.update()
|
||||
|
||||
ModePicker.on_change = OnModeChanged
|
||||
|
||||
def OnExport(e) -> None:
|
||||
PathText = (SettingsFileField.value or "").strip()
|
||||
if not PathText:
|
||||
return
|
||||
try:
|
||||
ExportSettings(Config, Path(PathText).expanduser())
|
||||
except OSError as Error:
|
||||
TheApp.Notify(f"Couldn't export settings: {Error}", "error")
|
||||
return
|
||||
TheApp.Notify("Settings exported.", "success")
|
||||
|
||||
def OnImport(e) -> None:
|
||||
PathText = (SettingsFileField.value or "").strip()
|
||||
if not PathText:
|
||||
return
|
||||
try:
|
||||
ImportSettings(Config, Path(PathText).expanduser())
|
||||
except (OSError, tomlkit.exceptions.TOMLKitError) as Error:
|
||||
TheApp.Notify(f"Couldn't import settings: {Error}", "error")
|
||||
return
|
||||
# The open dialog still shows the old values; close it rather than
|
||||
# leave a stale Save that would overwrite the import.
|
||||
TheApp.Close()
|
||||
OnSaved(True)
|
||||
TheApp.Notify("Settings imported. Restart for Theme/Font/Title bar.", "success")
|
||||
|
||||
Categories: dict[str, ft.Control] = {
|
||||
"SaraSuite": ft.Column([
|
||||
_Note("Shared by every SaraSuite program (~/.config/SaraSuite/Config.toml). "
|
||||
"Other programs pick changes up the next time they start."),
|
||||
UseSharedSwitch,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
SharedTheme, SharedFont, SharedTitleBar,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
_Note("Export/import settings as one file: this tab's shared settings plus everything else "
|
||||
"in Settings (never your login). Same format as SaraFem's."),
|
||||
SettingsFileField,
|
||||
ft.Row([ChipButton("Export", OnExport), ChipButton("Import", OnImport)]),
|
||||
], spacing=12, scroll=ft.ScrollMode.AUTO),
|
||||
"General": ft.Column([
|
||||
OpenInAppSwitch, ConfirmDeleteSwitch, SidebarSwitch, FontSizeField, MenuButton,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
OwnAppearance,
|
||||
], spacing=12, scroll=ft.ScrollMode.AUTO),
|
||||
"Connection": ft.Column([
|
||||
_Note("Browse a directory on this computer, or connect to a SaraView server."),
|
||||
ModePicker, LocalOptions, ServerOptions,
|
||||
], spacing=12, scroll=ft.ScrollMode.AUTO),
|
||||
}
|
||||
|
||||
DetailArea = ft.Container(Categories[StartCategory], expand=True, padding=ft.Padding.only(left=12))
|
||||
CategoryTiles: dict[str, ft.ListTile] = {}
|
||||
|
||||
def SelectCategory(Name: str) -> None:
|
||||
for CategoryName, Tile in CategoryTiles.items():
|
||||
Tile.selected = CategoryName == Name
|
||||
DetailArea.content = Categories[Name]
|
||||
DetailArea.update()
|
||||
CategoryList.update()
|
||||
|
||||
for Name in Categories:
|
||||
CategoryTiles[Name] = ft.ListTile(
|
||||
title=Name, selected=Name == StartCategory, selected_tile_color=Palette.Surface1,
|
||||
selected_color=Palette.Mauve, on_click=lambda e, N=Name: SelectCategory(N),
|
||||
)
|
||||
CategoryList = ft.Column(list(CategoryTiles.values()), spacing=0, scroll=ft.ScrollMode.AUTO)
|
||||
|
||||
def Save(e) -> None:
|
||||
SourceBefore = (Config.Mode, Config.LocalRoot, Config.ServerUrl, Config.ShowHiddenFolders,
|
||||
Config.ShowSvFiles, Config.HideInternalFiles)
|
||||
Config.UseSharedSettings = UseSharedSwitch.value
|
||||
Config.Theme = OwnTheme.value or Config.Theme
|
||||
Config.FontFamily = OwnFont.value or Config.FontFamily
|
||||
Config.HideWindowTitleBar = OwnTitleBar.value
|
||||
Config.OpenFilesInApp = OpenInAppSwitch.value
|
||||
Config.ConfirmDelete = ConfirmDeleteSwitch.value
|
||||
Config.ShowSidebar = SidebarSwitch.value
|
||||
Config.FontSize = _ReadInt(FontSizeField, Config.FontSize, 8, 32)
|
||||
Config.Mode = ModePicker.value or Config.Mode
|
||||
Config.LocalRoot = (LocalRootField.value or "").strip() or "~"
|
||||
Config.ServerUrl = (ServerUrlField.value or "").strip().rstrip("/")
|
||||
Config.RememberLogin = RememberSwitch.value
|
||||
Config.ShowHiddenFolders = ShowHiddenSwitch.value
|
||||
Config.ShowSvFiles = ShowSvSwitch.value
|
||||
Config.HideInternalFiles = HideInternalSwitch.value
|
||||
Config.Save()
|
||||
SharedSettings(
|
||||
Theme=SharedTheme.value,
|
||||
FontFamily=SharedFont.value or Shared.FontFamily,
|
||||
HideWindowTitleBar=SharedTitleBar.value,
|
||||
).Save()
|
||||
SourceAfter = (Config.Mode, Config.LocalRoot, Config.ServerUrl, Config.ShowHiddenFolders,
|
||||
Config.ShowSvFiles, Config.HideInternalFiles)
|
||||
TheApp.Close()
|
||||
OnSaved(SourceAfter != SourceBefore)
|
||||
TheApp.Notify("Settings saved.", "success")
|
||||
|
||||
TheApp.DialogOpen = True
|
||||
Page.show_dialog(ft.AlertDialog(
|
||||
title="Settings",
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Row(
|
||||
[ft.Container(CategoryList, width=CategoryListWidth, bgcolor=Palette.Surface0, border_radius=4, padding=4),
|
||||
DetailArea],
|
||||
expand=True, vertical_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||
),
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
_Note(RestartNote),
|
||||
],
|
||||
width=DialogWidth, height=DialogHeight,
|
||||
),
|
||||
actions=[ChipButton("Cancel", lambda e: TheApp.Close()), ChipButton("Save", Save)],
|
||||
on_dismiss=lambda e: setattr(TheApp, "DialogOpen", False),
|
||||
))
|
||||
@@ -0,0 +1,458 @@
|
||||
"""Where files come from: the local disk (one root folder, set in Settings) or
|
||||
a SaraView server's HTTP API. Both speak the same terms as the server: a
|
||||
Root name plus a "/"-separated Path inside it ("" is the root itself), and
|
||||
entries shaped like the server's /api/tree items, so the rest of the client
|
||||
never needs to know which one it's talking to."""
|
||||
|
||||
import fnmatch
|
||||
import shutil
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import httpx
|
||||
import send2trash
|
||||
|
||||
from Core import AppConfig, CacheDir
|
||||
|
||||
SessionCookieName = "SaraViewSession" # Backend/Auth.py
|
||||
|
||||
# Backend/Explorer.py's FileTypeMap and Backend/Preview.py's TextFileTypes,
|
||||
# so local files get the same types (and so the same viewer) as server ones.
|
||||
FileTypeMap = {
|
||||
".md": "markdown", ".markdown": "markdown", ".txt": "text",
|
||||
".toml": "config", ".yaml": "config", ".yml": "config", ".json": "config", ".cfg": "config", ".ini": "config",
|
||||
".html": "html", ".htm": "html",
|
||||
".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".webp": "image", ".svg": "image",
|
||||
".mp4": "video", ".webm": "video", ".mov": "video", ".mkv": "video",
|
||||
}
|
||||
TextFileTypes = {"markdown", "text", "config", "html"}
|
||||
|
||||
NoteFilePrefix = ".sv_" # a directory's own note: <Dir>/.sv_<Dir>.md
|
||||
DefaultNoteExtension = ".md"
|
||||
|
||||
# Backend/Explorer.py's chunked upload limits (and Frontend/src/Api.js's).
|
||||
UploadChunkSizeBytes = 20 * 1024 * 1024
|
||||
ChunkedUploadThresholdBytes = 40 * 1024 * 1024
|
||||
|
||||
|
||||
def ClassifyFile(Name: str) -> str:
|
||||
return FileTypeMap.get(Path(Name).suffix.lower(), "unknown")
|
||||
|
||||
|
||||
def JoinPath(DirectoryPath: str, Name: str) -> str:
|
||||
return f"{DirectoryPath}/{Name}" if DirectoryPath else Name
|
||||
|
||||
|
||||
def ParentPath(RelativePath: str) -> str:
|
||||
return RelativePath.rsplit("/", 1)[0] if "/" in RelativePath else ""
|
||||
|
||||
|
||||
def BaseName(RelativePath: str) -> str:
|
||||
return RelativePath.rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def NormalizeRelativePath(BaseDirectory: str, RawPath: str) -> str:
|
||||
"""Backend/Core.py's: joins and folds "." and ".." without leaving the root."""
|
||||
Parts: list[str] = []
|
||||
for Part in JoinPath(BaseDirectory, RawPath).split("/"):
|
||||
if Part in ("", "."):
|
||||
continue
|
||||
if Part == "..":
|
||||
if Parts:
|
||||
Parts.pop()
|
||||
continue
|
||||
Parts.append(Part)
|
||||
return "/".join(Parts)
|
||||
|
||||
|
||||
def MoveDestination(RelativePath: str, NewName: str) -> str:
|
||||
"""Where Rename sends an entry: a plain name renames it in place, a name
|
||||
with "/" in it moves it (relative to its directory), like SaraView's web
|
||||
Rename."""
|
||||
return NormalizeRelativePath(ParentPath(RelativePath), NewName)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entry:
|
||||
Name: str
|
||||
IsDirectory: bool
|
||||
FileType: Optional[str] = None
|
||||
Size: Optional[int] = None
|
||||
Note: Optional[dict] = None # a directory's .sv_ note: {"Name", "FileType"}
|
||||
|
||||
@classmethod
|
||||
def FromDict(cls, Data: dict[str, Any]) -> "Entry":
|
||||
return cls(Data["Name"], bool(Data.get("IsDirectory")), Data.get("FileType"), Data.get("Size"), Data.get("Note"))
|
||||
|
||||
|
||||
class SourceError(Exception):
|
||||
"""Status is the server's HTTP status (0 for local or network errors);
|
||||
401 means "log in first"."""
|
||||
|
||||
def __init__(self, Message: str, Status: int = 0):
|
||||
super().__init__(Message)
|
||||
self.Status = Status
|
||||
|
||||
|
||||
class Source:
|
||||
"""What the browser needs from a place files live."""
|
||||
|
||||
Kind = ""
|
||||
|
||||
def Describe(self) -> str: ...
|
||||
def Roots(self) -> list[str]: ...
|
||||
def List(self, Root: str, RelativePath: str) -> list[Entry]: ...
|
||||
def ReadText(self, Root: str, RelativePath: str) -> str: ...
|
||||
def ReadBytes(self, Root: str, RelativePath: str) -> bytes: ...
|
||||
def WriteText(self, Root: str, RelativePath: str, Content: str) -> None: ...
|
||||
def Create(self, Root: str, RelativePath: str, IsDirectory: bool) -> None: ...
|
||||
def Move(self, Root: str, RelativePath: str, Destination: str) -> None: ...
|
||||
def Delete(self, Root: str, RelativePath: str) -> None: ...
|
||||
def Upload(self, Root: str, DirectoryPath: str, LocalFile: Path, OnProgress: Optional[Callable[[float], None]] = None) -> str: ...
|
||||
def LocalFile(self, Root: str, RelativePath: str) -> Path: ...
|
||||
def Book(self, Root: str, RelativePath: str) -> dict: ...
|
||||
|
||||
@property
|
||||
def CanWrite(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class LocalSource(Source):
|
||||
"""One folder on this computer, shown as a single root named after it.
|
||||
Follows the server's rules (directory notes, hidden SaraTag files) so a
|
||||
folder looks the same whether SaraView serves it or not."""
|
||||
|
||||
Kind = "Local"
|
||||
|
||||
def __init__(self, Config: AppConfig):
|
||||
self.Config = Config
|
||||
self.RootPath = Config.LocalRootPath
|
||||
self.RootName = self.RootPath.name or str(self.RootPath)
|
||||
|
||||
def Describe(self) -> str:
|
||||
return f"Local: {self.RootPath}"
|
||||
|
||||
def Roots(self) -> list[str]:
|
||||
if not self.RootPath.is_dir():
|
||||
raise SourceError(f"The root directory {self.RootPath} doesn't exist. Pick another in Settings.")
|
||||
return [self.RootName]
|
||||
|
||||
def _Resolve(self, Root: str, RelativePath: str) -> Path:
|
||||
if Root != self.RootName:
|
||||
raise SourceError(f"Unknown root: {Root}")
|
||||
Candidate = (self.RootPath / RelativePath.lstrip("/")).resolve()
|
||||
if Candidate != self.RootPath and self.RootPath not in Candidate.parents:
|
||||
raise SourceError("Path escapes the root directory.")
|
||||
return Candidate
|
||||
|
||||
def _IsInternal(self, Name: str) -> bool:
|
||||
return self.Config.HideInternalFiles and any(fnmatch.fnmatch(Name, Pattern) for Pattern in self.Config.InternalPatterns)
|
||||
|
||||
@staticmethod
|
||||
def _FindNote(DirectoryPath: Path) -> Optional[Path]:
|
||||
Prefix = f"{NoteFilePrefix}{DirectoryPath.name}."
|
||||
try:
|
||||
return next((C for C in DirectoryPath.iterdir() if C.is_file() and C.name.startswith(Prefix)), None)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def List(self, Root: str, RelativePath: str) -> list[Entry]:
|
||||
DirectoryPath = self._Resolve(Root, RelativePath)
|
||||
try:
|
||||
Children = sorted(DirectoryPath.iterdir(), key=lambda P: (P.is_file(), P.name.casefold()))
|
||||
except NotADirectoryError:
|
||||
raise SourceError(f"{DirectoryPath.name} is not a directory.")
|
||||
except OSError as Error:
|
||||
raise SourceError(f"Can't read {DirectoryPath}: {Error.strerror or Error}")
|
||||
Entries = []
|
||||
for Child in Children:
|
||||
if self._IsInternal(Child.name):
|
||||
continue
|
||||
if Child.name.startswith(NoteFilePrefix):
|
||||
if not self.Config.ShowSvFiles:
|
||||
continue
|
||||
elif Child.name.startswith(".") and not self.Config.ShowHiddenFolders:
|
||||
continue
|
||||
try:
|
||||
IsDirectory = Child.is_dir()
|
||||
Size = None if IsDirectory else Child.stat().st_size
|
||||
except OSError:
|
||||
continue # a broken link or a file that vanished mid-listing
|
||||
Note = None
|
||||
if IsDirectory and (NotePath := self._FindNote(Child)):
|
||||
Note = {"Name": NotePath.name, "FileType": ClassifyFile(NotePath.name)}
|
||||
Entries.append(Entry(Child.name, IsDirectory, None if IsDirectory else ClassifyFile(Child.name), Size, Note))
|
||||
return Entries
|
||||
|
||||
def ReadText(self, Root: str, RelativePath: str) -> str:
|
||||
try:
|
||||
return self._Resolve(Root, RelativePath).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as Error:
|
||||
raise SourceError(f"Can't read it: {Error.strerror or Error}")
|
||||
|
||||
def ReadBytes(self, Root: str, RelativePath: str) -> bytes:
|
||||
try:
|
||||
return self._Resolve(Root, RelativePath).read_bytes()
|
||||
except OSError as Error:
|
||||
raise SourceError(f"Can't read it: {Error.strerror or Error}")
|
||||
|
||||
def WriteText(self, Root: str, RelativePath: str, Content: str) -> None:
|
||||
try:
|
||||
self._Resolve(Root, RelativePath).write_text(Content, encoding="utf-8")
|
||||
except OSError as Error:
|
||||
raise SourceError(f"Couldn't save: {Error.strerror or Error}")
|
||||
|
||||
def Create(self, Root: str, RelativePath: str, IsDirectory: bool) -> None:
|
||||
# Backend/Explorer.py's CreateEntry: a new directory gets its note.
|
||||
EntryPath = self._Resolve(Root, RelativePath)
|
||||
if EntryPath.exists():
|
||||
raise SourceError(f"{EntryPath.name} already exists.")
|
||||
try:
|
||||
if IsDirectory:
|
||||
EntryPath.mkdir()
|
||||
(EntryPath / f"{NoteFilePrefix}{EntryPath.name}{DefaultNoteExtension}").touch()
|
||||
else:
|
||||
EntryPath.parent.mkdir(parents=True, exist_ok=True)
|
||||
EntryPath.touch()
|
||||
except OSError as Error:
|
||||
raise SourceError(f"Couldn't create {EntryPath.name}: {Error.strerror or Error}")
|
||||
|
||||
def Move(self, Root: str, RelativePath: str, Destination: str) -> None:
|
||||
# Backend/Explorer.py's MoveEntry: a directory's note follows its new name.
|
||||
SourcePath = self._Resolve(Root, RelativePath)
|
||||
DestinationPath = self._Resolve(Root, Destination)
|
||||
if not SourcePath.exists():
|
||||
raise SourceError(f"{SourcePath.name} does not exist.")
|
||||
if DestinationPath.exists():
|
||||
raise SourceError(f"{DestinationPath.name} already exists.")
|
||||
if SourcePath.is_dir() and SourcePath in DestinationPath.parents:
|
||||
raise SourceError(f"Can't move {SourcePath.name} into itself.")
|
||||
OldName = SourcePath.name
|
||||
try:
|
||||
DestinationPath.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(SourcePath), str(DestinationPath))
|
||||
if DestinationPath.is_dir():
|
||||
OldNote = next((C for C in DestinationPath.iterdir()
|
||||
if C.is_file() and C.name.startswith(f"{NoteFilePrefix}{OldName}.")), None)
|
||||
if OldNote is not None:
|
||||
OldNote.rename(DestinationPath / f"{NoteFilePrefix}{DestinationPath.name}{OldNote.suffix}")
|
||||
except OSError as Error:
|
||||
raise SourceError(f"Couldn't move {OldName}: {Error.strerror or Error}")
|
||||
|
||||
def Delete(self, Root: str, RelativePath: str) -> None:
|
||||
# To the trash, unlike the server (which deletes for good): it's your own disk.
|
||||
EntryPath = self._Resolve(Root, RelativePath)
|
||||
if EntryPath == self.RootPath:
|
||||
raise SourceError("Can't delete the root directory.")
|
||||
try:
|
||||
send2trash.send2trash(EntryPath)
|
||||
except OSError as Error:
|
||||
raise SourceError(f"Couldn't delete {EntryPath.name}: {Error}")
|
||||
|
||||
def Upload(self, Root: str, DirectoryPath: str, LocalFile: Path, OnProgress=None) -> str:
|
||||
"""Copies a file in from elsewhere on this computer."""
|
||||
Target = self._Resolve(Root, JoinPath(DirectoryPath, LocalFile.name))
|
||||
if Target.exists():
|
||||
raise SourceError(f"{LocalFile.name} already exists.")
|
||||
try:
|
||||
shutil.copy2(LocalFile, Target)
|
||||
except OSError as Error:
|
||||
raise SourceError(f"Couldn't copy {LocalFile.name}: {Error.strerror or Error}")
|
||||
return Target.name
|
||||
|
||||
def LocalFile(self, Root: str, RelativePath: str) -> Path:
|
||||
return self._Resolve(Root, RelativePath)
|
||||
|
||||
def Book(self, Root: str, RelativePath: str) -> dict:
|
||||
return {} # SaraTag tags are only read through a server for now
|
||||
|
||||
|
||||
class ServerSource(Source):
|
||||
"""A SaraView server, through the same /api endpoints the web frontend
|
||||
uses (Frontend/src/Api.js). Logged out, it browses as Guest like the web
|
||||
view does; anything that changes files needs a login."""
|
||||
|
||||
Kind = "Server"
|
||||
|
||||
def __init__(self, Config: AppConfig, Token: str = ""):
|
||||
self.Config = Config
|
||||
self.Url = Config.ServerUrl.strip().rstrip("/")
|
||||
if self.Url and "://" not in self.Url:
|
||||
self.Url = "http://" + self.Url
|
||||
self.Client = httpx.Client(base_url=self.Url, timeout=httpx.Timeout(30, read=120), follow_redirects=True)
|
||||
if Token:
|
||||
self.Client.cookies.set(SessionCookieName, Token)
|
||||
self.User: Optional[dict] = None # the server's /api/auth/me, None as Guest
|
||||
|
||||
@property
|
||||
def Token(self) -> str:
|
||||
return self.Client.cookies.get(SessionCookieName) or ""
|
||||
|
||||
@property
|
||||
def CanWrite(self) -> bool:
|
||||
return self.User is not None
|
||||
|
||||
def Describe(self) -> str:
|
||||
Who = self.User["Username"] if self.User else "Guest"
|
||||
return f"{self.Url} as {Who}"
|
||||
|
||||
def _Request(self, Method: str, Endpoint: str, **Arguments) -> httpx.Response:
|
||||
if not self.Url:
|
||||
raise SourceError("No server address yet. Set it in Settings > Connection.")
|
||||
try:
|
||||
Response = self.Client.request(Method, f"/api{Endpoint}", **Arguments)
|
||||
except httpx.HTTPError as Error:
|
||||
raise SourceError(f"Can't reach {self.Url}: {Error}")
|
||||
if Response.is_success:
|
||||
return Response
|
||||
try:
|
||||
Detail = Response.json().get("detail") or Response.reason_phrase
|
||||
except (ValueError, AttributeError):
|
||||
Detail = Response.reason_phrase
|
||||
if Response.status_code == 401:
|
||||
Detail = "Log in to do that." if self.User is None else "Your session ended. Log in again."
|
||||
raise SourceError(str(Detail), Response.status_code)
|
||||
|
||||
def _Json(self, Method: str, Endpoint: str, **Arguments) -> Any:
|
||||
return self._Request(Method, Endpoint, **Arguments).json()
|
||||
|
||||
@staticmethod
|
||||
def _At(Root: str, RelativePath: str) -> dict[str, str]:
|
||||
return {"Root": Root, "Path": RelativePath}
|
||||
|
||||
# --- Sessions ---------------------------------------------------------
|
||||
|
||||
def Connect(self) -> None:
|
||||
"""Checks the server is there and whether the saved session still
|
||||
works (sessions live in the server's memory, so a restart ends them)."""
|
||||
self._Json("GET", "/auth/status")
|
||||
self.User = None
|
||||
if self.Token:
|
||||
try:
|
||||
self.User = self._Json("GET", "/auth/me")
|
||||
except SourceError as Error:
|
||||
if Error.Status != 401:
|
||||
raise
|
||||
self.Client.cookies.clear()
|
||||
|
||||
def Login(self, Username: str, Password: str) -> None:
|
||||
self.Client.cookies.clear()
|
||||
try:
|
||||
self.User = self._Json("POST", "/auth/login", json={"Username": Username, "Password": Password})
|
||||
except SourceError as Error:
|
||||
if Error.Status == 401:
|
||||
raise SourceError("Wrong username or password.", 401)
|
||||
raise
|
||||
if not self.Token:
|
||||
self.User = None
|
||||
raise SourceError("The server didn't hand out a session.")
|
||||
|
||||
def Logout(self) -> None:
|
||||
try:
|
||||
self._Request("POST", "/auth/logout")
|
||||
except SourceError:
|
||||
pass # logged out locally either way
|
||||
self.Client.cookies.clear()
|
||||
self.User = None
|
||||
|
||||
def Close(self) -> None:
|
||||
self.Client.close()
|
||||
|
||||
# --- Files ------------------------------------------------------------
|
||||
|
||||
def Roots(self) -> list[str]:
|
||||
return [Root["Name"] for Root in self._Json("GET", "/roots")]
|
||||
|
||||
def List(self, Root: str, RelativePath: str) -> list[Entry]:
|
||||
return [Entry.FromDict(Item) for Item in self._Json("GET", "/tree", params=self._At(Root, RelativePath))]
|
||||
|
||||
def ReadText(self, Root: str, RelativePath: str) -> str:
|
||||
return self._Request("GET", "/file", params=self._At(Root, RelativePath)).text
|
||||
|
||||
def ReadBytes(self, Root: str, RelativePath: str) -> bytes:
|
||||
return self._Request("GET", "/file", params=self._At(Root, RelativePath)).content
|
||||
|
||||
def WriteText(self, Root: str, RelativePath: str, Content: str) -> None:
|
||||
# The server records line blame for whoever saves.
|
||||
self._Request("PUT", "/file", params=self._At(Root, RelativePath), content=Content.encode("utf-8"))
|
||||
|
||||
def Create(self, Root: str, RelativePath: str, IsDirectory: bool) -> None:
|
||||
self._Request("POST", "/tree", params=self._At(Root, RelativePath), json={"IsDirectory": IsDirectory})
|
||||
|
||||
def Move(self, Root: str, RelativePath: str, Destination: str) -> None:
|
||||
# Destination is root-relative here, but the server reads
|
||||
# DestinationPath relative to the entry's own directory, so walk up
|
||||
# from there to the root first.
|
||||
Parent = ParentPath(RelativePath)
|
||||
if ParentPath(Destination) == Parent:
|
||||
Body = {"NewName": BaseName(Destination)}
|
||||
else:
|
||||
Depth = len(Parent.split("/")) if Parent else 0
|
||||
Body = {"DestinationPath": "/".join([".."] * Depth + [Destination])}
|
||||
self._Request("PATCH", "/tree", params=self._At(Root, RelativePath), json=Body)
|
||||
|
||||
def Delete(self, Root: str, RelativePath: str) -> None:
|
||||
self._Request("DELETE", "/tree", params=self._At(Root, RelativePath))
|
||||
|
||||
def Upload(self, Root: str, DirectoryPath: str, LocalFile: Path, OnProgress=None) -> str:
|
||||
Size = LocalFile.stat().st_size
|
||||
Params = self._At(Root, DirectoryPath)
|
||||
if Size <= ChunkedUploadThresholdBytes:
|
||||
with LocalFile.open("rb") as Handle:
|
||||
Result = self._Json("POST", "/upload", params=Params, files=[("Files", (LocalFile.name, Handle))])
|
||||
if Result.get("Errors"):
|
||||
raise SourceError("; ".join(Result["Errors"]))
|
||||
return Result["Saved"][0]
|
||||
# Large files go up in pieces, like the web view's UploadFileChunked.
|
||||
UploadId = str(uuid.uuid4())
|
||||
TotalChunks = max(1, -(-Size // UploadChunkSizeBytes))
|
||||
with LocalFile.open("rb") as Handle:
|
||||
for ChunkIndex in range(TotalChunks):
|
||||
ChunkParams = {**Params, "UploadId": UploadId, "ChunkIndex": ChunkIndex,
|
||||
"TotalChunks": TotalChunks, "FileName": LocalFile.name}
|
||||
self._Request("POST", "/upload/chunk", params=ChunkParams,
|
||||
files=[("Chunk", ("chunk", Handle.read(UploadChunkSizeBytes)))])
|
||||
if OnProgress:
|
||||
OnProgress((ChunkIndex + 1) / TotalChunks)
|
||||
Done = {**Params, "UploadId": UploadId, "TotalChunks": TotalChunks, "FileName": LocalFile.name}
|
||||
return self._Json("POST", "/upload/complete", params=Done)["Saved"]
|
||||
|
||||
def Download(self, Root: str, RelativePath: str, Destination: Path) -> None:
|
||||
"""A file as-is, a directory as a .zip (the server builds it)."""
|
||||
if not self.Url:
|
||||
raise SourceError("No server address yet.")
|
||||
Partial = Destination.with_name(Destination.name + ".part")
|
||||
try:
|
||||
with self.Client.stream("GET", "/api/download", params=self._At(Root, RelativePath)) as Response:
|
||||
if not Response.is_success:
|
||||
Response.read()
|
||||
raise SourceError(f"Download failed: {Response.status_code} {Response.reason_phrase}", Response.status_code)
|
||||
with Partial.open("wb") as Handle:
|
||||
for Chunk in Response.iter_bytes(1024 * 1024):
|
||||
Handle.write(Chunk)
|
||||
Partial.replace(Destination)
|
||||
except httpx.HTTPError as Error:
|
||||
Partial.unlink(missing_ok=True)
|
||||
raise SourceError(f"Download failed: {Error}")
|
||||
except OSError as Error:
|
||||
Partial.unlink(missing_ok=True)
|
||||
raise SourceError(f"Couldn't write {Destination}: {Error.strerror or Error}")
|
||||
|
||||
def LocalFile(self, Root: str, RelativePath: str) -> Path:
|
||||
"""A fresh copy in ~/.cache/SaraView, for opening in another program."""
|
||||
Target = CacheDir / "Server" / Root / RelativePath
|
||||
Target.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.Download(Root, RelativePath, Target)
|
||||
return Target
|
||||
|
||||
def Book(self, Root: str, RelativePath: str) -> dict:
|
||||
try:
|
||||
return self._Json("GET", "/book", params=self._At(Root, RelativePath)) or {}
|
||||
except SourceError:
|
||||
return {} # tags are extra; a file without them still opens
|
||||
|
||||
|
||||
def MakeSource(Config: AppConfig, Token: str = "") -> Source:
|
||||
return ServerSource(Config, Token) if Config.Mode == "Server" else LocalSource(Config)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""The file area while a file is open, in place of the file list (SaraFem's
|
||||
EditorView/ImageView idea, with SaraView's View/Edit/Save toolbar).
|
||||
|
||||
- Markdown is rendered in View; the other text types are shown as
|
||||
highlighted code. Edit is a plain monospace field with an explicit Save
|
||||
(Ctrl+S). On a server, saving records line blame for you, like the web view.
|
||||
- Images are shown fitted to the window.
|
||||
- Video and other files get their details and Open Externally (a server
|
||||
file is downloaded to ~/.cache/SaraView first). Flet has no built-in
|
||||
video player, so videos play in the system's own player.
|
||||
- SaraTag tags (from the server's Books) show as chips under the toolbar."""
|
||||
|
||||
import re
|
||||
from typing import Callable, Optional
|
||||
|
||||
import flet as ft
|
||||
|
||||
from App import App
|
||||
from Core import ChipButton, CodeStyleSheet, HumanSize, Palette
|
||||
from Sources import Entry, SourceError, TextFileTypes
|
||||
|
||||
LanguageByExtension = {
|
||||
".json": "json", ".toml": "toml", ".yaml": "yaml", ".yml": "yaml", ".cfg": "ini", ".ini": "ini",
|
||||
".html": "xml", ".htm": "xml",
|
||||
}
|
||||
|
||||
# Above this, highlighting the whole file on every render gets slow, so
|
||||
# large files stay readable (unhighlighted) instead. Same as SaraFem's.
|
||||
HighlightCharLimit = 500_000
|
||||
|
||||
|
||||
def FencedBlock(Content: str, Language: str) -> str:
|
||||
"""A fence longer than any run of backticks already in Content."""
|
||||
LongestRun = max((len(Run) for Run in re.findall(r"`+", Content)), default=0)
|
||||
Fence = "`" * max(LongestRun + 1, 3)
|
||||
return f"{Fence}{Language}\n{Content}\n{Fence}"
|
||||
|
||||
|
||||
class FileView(ft.Container):
|
||||
def __init__(
|
||||
self,
|
||||
TheApp: App,
|
||||
Root: str,
|
||||
RelativePath: str,
|
||||
Item: Entry,
|
||||
OnClose: Callable[[], None],
|
||||
OnOpenExternally: Callable[[], None],
|
||||
OnDownload: Optional[Callable[[], None]],
|
||||
OnFocusChange: Callable[[bool], None],
|
||||
):
|
||||
self.TheApp = TheApp
|
||||
self.Root = Root
|
||||
self.RelativePath = RelativePath
|
||||
self.Item = Item
|
||||
self.FileType = Item.FileType or "unknown"
|
||||
self.OnClose = OnClose
|
||||
self.OnFocusChange = OnFocusChange
|
||||
self.IsText = self.FileType in TextFileTypes
|
||||
self.IsEditing = False
|
||||
self.IsDirty = False
|
||||
self.Content = ""
|
||||
FontSize = TheApp.Config.FontSize
|
||||
|
||||
self.StatusText = ft.Text("", color=Palette.Overlay0, size=12)
|
||||
self.ToggleButton = ChipButton("Edit", self._ToggleEdit, Icon=ft.Icons.EDIT_OUTLINED)
|
||||
self.SaveButton = ChipButton("Save", lambda e: self.Save(), Icon=ft.Icons.SAVE_OUTLINED, Disabled=True)
|
||||
CanWrite = TheApp.Source.CanWrite
|
||||
if not CanWrite:
|
||||
self.ToggleButton.disabled = True
|
||||
self.ToggleButton.tooltip = "Log in to edit"
|
||||
|
||||
ToolbarItems: list[ft.Control] = [
|
||||
ft.IconButton(icon=ft.Icons.ARROW_BACK, tooltip="Back to the file list (Esc)", on_click=lambda e: self.RequestClose()),
|
||||
ft.Text(Item.Name, weight=ft.FontWeight.BOLD, expand=True, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1),
|
||||
self.StatusText,
|
||||
]
|
||||
if self.IsText:
|
||||
ToolbarItems += [self.ToggleButton, self.SaveButton]
|
||||
ToolbarItems.append(ft.IconButton(icon=ft.Icons.OPEN_IN_NEW, tooltip="Open Externally",
|
||||
on_click=lambda e: OnOpenExternally()))
|
||||
if OnDownload is not None:
|
||||
ToolbarItems.append(ft.IconButton(icon=ft.Icons.DOWNLOAD_OUTLINED, tooltip="Download",
|
||||
on_click=lambda e: OnDownload()))
|
||||
|
||||
CodeStyle = ft.TextStyle(size=FontSize - 1)
|
||||
self.TextField = ft.TextField(
|
||||
multiline=True,
|
||||
expand=True,
|
||||
border=ft.InputBorder.NONE,
|
||||
text_style=CodeStyle,
|
||||
on_change=self._OnTextChanged,
|
||||
on_focus=lambda e: self.OnFocusChange(True),
|
||||
on_blur=lambda e: self.OnFocusChange(False),
|
||||
)
|
||||
self.Body = ft.Container(content=self._BuildBody(), expand=True, padding=12)
|
||||
|
||||
Column: list[ft.Control] = [ft.Row(ToolbarItems, spacing=8), ft.Divider(height=1, color=Palette.Surface1)]
|
||||
Tags = self._BuildTags()
|
||||
if Tags is not None:
|
||||
Column.append(Tags)
|
||||
Column.append(self.Body)
|
||||
super().__init__(content=ft.Column(Column, expand=True, spacing=8), expand=True, bgcolor=Palette.Base, padding=12)
|
||||
|
||||
# --- Content ----------------------------------------------------------
|
||||
|
||||
def _BuildBody(self) -> ft.Control:
|
||||
Source = self.TheApp.Source
|
||||
try:
|
||||
if self.IsText:
|
||||
self.Content = Source.ReadText(self.Root, self.RelativePath)
|
||||
self.TextField.value = self.Content
|
||||
return self._RenderedView()
|
||||
if self.FileType == "image":
|
||||
return ft.Image(src=Source.ReadBytes(self.Root, self.RelativePath), fit=ft.BoxFit.CONTAIN, expand=True)
|
||||
except SourceError as Error:
|
||||
self.ToggleButton.disabled = True
|
||||
return ft.Text(f"Couldn't open this file: {Error}", color=Palette.Red)
|
||||
return self._InfoView()
|
||||
|
||||
def _RenderedView(self) -> ft.Control:
|
||||
Text = self.TextField.value or ""
|
||||
if self.FileType == "markdown" and len(Text) <= HighlightCharLimit:
|
||||
View: ft.Control = ft.Markdown(
|
||||
value=Text,
|
||||
selectable=True,
|
||||
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
|
||||
code_theme=ft.MarkdownCodeTheme.ATOM_ONE_DARK,
|
||||
code_style_sheet=CodeStyleSheet(),
|
||||
on_tap_link=self._OnTapLink,
|
||||
)
|
||||
elif self.FileType in ("config", "html") and len(Text) <= HighlightCharLimit:
|
||||
Extension = "." + self.Item.Name.rsplit(".", 1)[-1].lower()
|
||||
View = ft.Markdown(
|
||||
value=FencedBlock(Text, LanguageByExtension.get(Extension, "")),
|
||||
selectable=True,
|
||||
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
|
||||
code_theme=ft.MarkdownCodeTheme.ATOM_ONE_DARK,
|
||||
code_style_sheet=CodeStyleSheet(),
|
||||
)
|
||||
else:
|
||||
View = ft.Text(Text, selectable=True, size=self.TheApp.Config.FontSize)
|
||||
if not Text:
|
||||
View = ft.Text("This file is empty.", color=Palette.Overlay0, italic=True)
|
||||
return ft.Column([View], scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
|
||||
def _InfoView(self) -> ft.Control:
|
||||
Icon = ft.Icons.MOVIE_OUTLINED if self.FileType == "video" else ft.Icons.INSERT_DRIVE_FILE_OUTLINED
|
||||
Note = ("Videos play in your system's video player: use Open Externally."
|
||||
if self.FileType == "video" else "SaraView can't show this type of file. Use Open Externally.")
|
||||
return ft.Column(
|
||||
[
|
||||
ft.Icon(Icon, size=64, color=Palette.Mauve),
|
||||
ft.Text(self.Item.Name, weight=ft.FontWeight.BOLD, text_align=ft.TextAlign.CENTER),
|
||||
ft.Text(HumanSize(self.Item.Size), color=Palette.Overlay0),
|
||||
ft.Text(Note, color=Palette.Overlay0, size=12, text_align=ft.TextAlign.CENTER),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.CENTER,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
def _BuildTags(self) -> Optional[ft.Control]:
|
||||
Book = self.TheApp.Source.Book(self.Root, self.RelativePath)
|
||||
Tags = Book.get("Tags") or {}
|
||||
if not Tags:
|
||||
return None
|
||||
Chips = []
|
||||
for Key, Value in Tags.items():
|
||||
Text = Key if Value is True else f"{Key}: {', '.join(map(str, Value)) if isinstance(Value, list) else Value}"
|
||||
Chips.append(ft.Container(
|
||||
ft.Text(Text, size=12, color=Palette.Mauve),
|
||||
padding=ft.Padding.symmetric(horizontal=6, vertical=2),
|
||||
border=ft.Border.all(1, Palette.Surface1),
|
||||
border_radius=4,
|
||||
))
|
||||
return ft.Row(Chips, wrap=True, spacing=4, run_spacing=4)
|
||||
|
||||
def _OnTapLink(self, e) -> None:
|
||||
Target = str(e.data or "")
|
||||
if Target.startswith(("http://", "https://", "mailto:")):
|
||||
self.TheApp.Page.run_task(ft.UrlLauncher().launch_url, Target)
|
||||
|
||||
# --- Editing ----------------------------------------------------------
|
||||
|
||||
@property
|
||||
def CanSave(self) -> bool:
|
||||
return self.IsText and self.IsDirty
|
||||
|
||||
def _OnTextChanged(self, e) -> None:
|
||||
if not self.IsDirty:
|
||||
self.IsDirty = True
|
||||
self.SaveButton.disabled = False
|
||||
self.StatusText.value = "Unsaved changes"
|
||||
self.SaveButton.update()
|
||||
self.StatusText.update()
|
||||
|
||||
def _ToggleEdit(self, e=None) -> None:
|
||||
self.IsEditing = not self.IsEditing
|
||||
if self.IsEditing:
|
||||
self.Body.content = self.TextField
|
||||
self.ToggleButton.content = "View"
|
||||
self.ToggleButton.icon = ft.Icons.VISIBILITY_OUTLINED
|
||||
self.OnFocusChange(True) # swapping the field in doesn't reliably fire on_focus
|
||||
else:
|
||||
# Re-render from whatever's typed (even unsaved), so Edit -> View -> Edit keeps it.
|
||||
self.Body.content = self._RenderedView()
|
||||
self.ToggleButton.content = "Edit"
|
||||
self.ToggleButton.icon = ft.Icons.EDIT_OUTLINED
|
||||
self.OnFocusChange(False)
|
||||
self.Body.update()
|
||||
self.ToggleButton.update()
|
||||
|
||||
def ToggleEdit(self) -> None:
|
||||
if self.IsText and not self.ToggleButton.disabled:
|
||||
self._ToggleEdit()
|
||||
|
||||
def Save(self) -> None:
|
||||
if not self.CanSave:
|
||||
return
|
||||
try:
|
||||
self.TheApp.Source.WriteText(self.Root, self.RelativePath, self.TextField.value or "")
|
||||
except SourceError as Error:
|
||||
self.TheApp.HandleError(Error, f"Couldn't save {self.Item.Name}")
|
||||
return
|
||||
self.Content = self.TextField.value or ""
|
||||
self.IsDirty = False
|
||||
self.SaveButton.disabled = True
|
||||
self.StatusText.value = "Saved."
|
||||
self.SaveButton.update()
|
||||
self.StatusText.update()
|
||||
self.TheApp.Notify(f"Saved {self.Item.Name}.", "success")
|
||||
|
||||
def RequestClose(self, Then: Optional[Callable[[], None]] = None) -> None:
|
||||
"""Closes, asking first if there are unsaved changes. Then runs after
|
||||
closing (e.g. to go to another directory)."""
|
||||
|
||||
def DoClose() -> None:
|
||||
self.OnFocusChange(False)
|
||||
self.OnClose()
|
||||
if Then:
|
||||
Then()
|
||||
|
||||
if not self.IsDirty:
|
||||
DoClose()
|
||||
return
|
||||
self.TheApp.Confirm("Unsaved changes", f"{self.Item.Name} has changes that aren't saved. Close it anyway?",
|
||||
DoClose, YesText="Discard")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
flet>=1.0,<2.0
|
||||
flet-desktop>=1.0,<2.0 # the desktop client; otherwise Flet fetches it on first launch and quits once
|
||||
tomlkit
|
||||
httpx
|
||||
send2trash
|
||||
@@ -268,3 +268,43 @@ of this setting.
|
||||
|
||||
There's also a per-viewer toggle for showing avatars instead of usernames
|
||||
in the blame gutter, using each author's chosen colour either way.
|
||||
|
||||
---
|
||||
|
||||
## Desktop Client
|
||||
|
||||
`Client/` is SaraView as a desktop app, built with Flet and laid out like
|
||||
SaraFem: a directory tree on the left, the file list on the right, and an
|
||||
open file in place of the list. It has two modes, picked in Settings >
|
||||
Connection:
|
||||
|
||||
- **Local**: browses one root directory on your own computer (any path).
|
||||
Same rules as the server: `.sv_` directory notes, SaraTag's own files
|
||||
hidden, new directories get their note. Deleting goes to the trash.
|
||||
- **Server**: talks to a SaraView server (the one on SpineS) through the same
|
||||
`/api` the web view uses. Logged out you browse as Guest; log in from the
|
||||
connection button in the toolbar to create, rename/move, delete, upload
|
||||
(large files go up in chunks) and edit. Saving records blame for you,
|
||||
locks are enforced by the server as usual, and SaraTag tags show above
|
||||
the file. Download saves a file, or a directory as a .zip.
|
||||
|
||||
Markdown is rendered in View, config and HTML files are highlighted, and
|
||||
Edit/Save (Ctrl+E, Ctrl+S) work like the web view. Images show in the app;
|
||||
videos and other files open in their default program (Open Externally).
|
||||
|
||||
Rename takes a `/` to move, like the web view (`../Archive/Notes.md`).
|
||||
Shortcuts: Enter opens, Backspace goes up, F2 renames, Delete deletes,
|
||||
Ctrl+N / Ctrl+Shift+N new file / directory, Ctrl+R refreshes, Ctrl+B
|
||||
toggles the tree, Alt+Left/Right go back/forward, Esc closes the open file.
|
||||
|
||||
Settings live in `~/.config/SaraSuite/SaraView/Config.toml`, with the
|
||||
shared SaraSuite theme, font and title bar like every other Sara program.
|
||||
A saved login is kept apart in `Session.toml` (never in a settings export);
|
||||
server sessions end when the server restarts, and the client then falls
|
||||
back to Guest.
|
||||
|
||||
- From source: `cd Client && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`,
|
||||
then `Packaging/Launch.sh`.
|
||||
- AppImage: `Client/Packaging/BuildAppImage.sh` builds `dist/SaraView-x86_64.AppImage`.
|
||||
- SaraSuite: `Client/Packaging/SaraSuite/` is the manifest, kept in sync with
|
||||
SaraSuite's `Programs/SaraView/`. It installs only `Client/`.
|
||||
Reference in new issue
Block a user