Add Flet desktop client

This commit is contained in:
sarah committed 2026-10-08 23:25:41 +02:00
1 parent 16787aba14
commit 0eecfba224
18 files changed
+2588

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
build/
.flet/
dist/
*.AppImage
+167
View File
@@ -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)
+651
View File
@@ -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
View File
@@ -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)
+128
View File
@@ -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
View File
@@ -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))
+38
View File
@@ -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"
+6
View File
@@ -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 "$@"
+37
View File
@@ -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
View File
@@ -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}
+267
View File
@@ -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),
))
+458
View File
@@ -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)
+247
View File
@@ -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.
+5
View File
@@ -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
+40
View File
@@ -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/`.