Files
SaraView/Client/ServerSettings.py

313 lines
16 KiB
Python

"""Your SaraView account and settings on the server, in Settings while you're
logged in to one: the web view's Settings panel (Frontend/src/Components/
SettingsPanel.jsx), section for section, through the same /api endpoints.
- Web view: your settings on the server (saved with Settings' Save). Most
are how the web view looks; hidden files and .sv_ notes also apply here.
- Profile (colour, avatar), Account (username, password, log out) and, for
admins, Users, Groups and Locked directories: these take effect at once,
like the web view's."""
from pathlib import Path
from typing import Callable
import flet as ft
from Core import ChipButton, Palette
from Diagrams import ThemePalette
from Sources import ServerSource, SourceError
def _Note(Text: str) -> ft.Text:
return ft.Text(Text, size=12, color=Palette.Overlay0)
def _Heading(Text: str) -> ft.Text:
return ft.Text(Text, color=Palette.Mauve)
def _Divider() -> ft.Divider:
return ft.Divider(height=1, color=Palette.Surface1)
class ServerSections:
"""Builds the sections (Categories) and saves the Web view ones (Save)."""
def __init__(self, TheApp, OnLoggedOut: Callable[[], None]):
self.TheApp = TheApp
self.Server: ServerSource = TheApp.Server
self.OnLoggedOut = OnLoggedOut
self.Values = self.Server.UserSettings() # raises SourceError: then there are no sections
self.ThemeNames = self.Server.ThemeNames()
self.IsAdmin = bool((self.Server.User or {}).get("IsAdmin"))
self.Switches: dict[str, ft.Switch] = {}
self.Categories: dict[str, ft.Control] = {
"Web view": self._WebView(),
"Profile": self._Profile(),
"Account": self._Account(),
}
if self.IsAdmin:
self.UsersList = ft.Column(spacing=6)
self.GroupsList = ft.Column(spacing=10)
self.LocksList = ft.Column(spacing=6)
self.Categories["Users"] = self._Users()
self.Categories["Groups"] = self._Groups()
self.Categories["Locked directories"] = self._Locks()
self._RefreshAdmin(Update=False)
def _Try(self, Action: Callable[[], None], Failure: str) -> bool:
try:
Action()
return True
except SourceError as Error:
self.TheApp.Notify(f"{Failure}: {Error}", "error")
return False
# --- Web view (saved with Settings' Save) -------------------------------
def _Switch(self, Key: str, Label: str) -> ft.Switch:
self.Switches[Key] = ft.Switch(label=Label, value=bool(self.Values.get(Key)))
return self.Switches[Key]
def _WebView(self) -> ft.Control:
V = self.Values
self.MarkdownEditing = ft.Dropdown(label="Markdown editing", value=V.get("MarkdownEditing", "Live"), options=[
ft.DropdownOption(key="Live", text="Live: edit the Markdown where you click in the rendered file"),
ft.DropdownOption(key="Separate", text="Separate: View and Edit are two modes")])
self.ThemePicker = ft.Dropdown(label="Colour theme", value=V.get("ThemeName"),
options=[ft.DropdownOption(key=Name, text=Name) for Name in self.ThemeNames])
self.NewThemeName = ft.TextField(label="New theme name (a copy of the selected one)", dense=True, expand=True)
self.FontFamily = ft.TextField(label="Font family", value=V.get("FontFamily", ""))
self.FontSize = ft.TextField(label="Font size (px)", value=str(V.get("FontSize", 14)),
keyboard_type=ft.KeyboardType.NUMBER)
self.PanButton = ft.Dropdown(label="Graph: pan with mouse button", value=V.get("GraphPanButton", "Right"), options=[
ft.DropdownOption(key="Right", text="Right (left-click opens a dot)"),
ft.DropdownOption(key="Left", text="Left (right-click opens a dot)")])
return ft.Column([
_Note("Your settings on the server, the same as the web view's. Most change the web view; "
"hidden directories and .sv_ notes also apply here while connected."),
_Heading("Files"),
self._Switch("ShowHiddenFolders", "Show hidden directories"),
self._Switch("ShowSvFiles", "Show directory note files (.sv_*)"),
self._Switch("ShowBreadcrumbs", "Show breadcrumbs"),
self._Switch("UseHyphenBullets", "Hyphen file icons (-)"),
self._Switch("UseAsciiFolderIcons", "ASCII directory icons (>/v)"),
self._Switch("NavigateIntoDirectories", "Open directory file on click"),
self._Switch("NewEntryButtonsAtTop", "New File/Directory buttons at top of sidebar"),
self._Switch("ConfirmBeforeDelete", "Confirm before delete"),
_Divider(), _Heading("Editor"),
self.MarkdownEditing,
self._Switch("StickyToolbar", "Sticky edit/view/save toolbar"),
self._Switch("ShowGitBlame", "Show blame gutter (who last changed each line)"),
self._Switch("ShowBlameAvatars", "Show avatars instead of names in blame"),
self._Switch("VisibleInBlame", "Show my edits in others' blame view"),
_Divider(), _Heading("Appearance"),
self.ThemePicker,
ft.Row([self.NewThemeName, ChipButton("Create", lambda e: self._CreateTheme())]),
ft.Row([ChipButton("Delete selected theme", lambda e: self._DeleteTheme(), Accent=Palette.Red)]),
self.FontFamily, self.FontSize,
_Divider(), _Heading("Graph"),
self._Switch("GraphShowDirectories", "Show directories (off: only files and the links between them)"),
self._Switch("GraphColourByDepth", "Colour directories by depth (off: all purple)"),
self._Switch("GraphShrinkByDepth", "Smaller directory dots the deeper they are"),
self.PanButton,
], spacing=12, scroll=ft.ScrollMode.AUTO)
def _RefreshThemes(self, Selected: str) -> None:
self.ThemeNames = self.Server.ThemeNames()
self.ThemePicker.options = [ft.DropdownOption(key=Name, text=Name) for Name in self.ThemeNames]
self.ThemePicker.value = Selected if Selected in self.ThemeNames else self.ThemeNames[0]
self.ThemePicker.update()
def _CreateTheme(self) -> None:
Name = (self.NewThemeName.value or "").strip()
Base = self.ThemePicker.value
if not Name or not Base:
return
if self._Try(lambda: self.Server.SaveTheme(Name, self.Server.Theme(Base)), "Couldn't create the theme"):
self.NewThemeName.value = ""
self.NewThemeName.update()
self._RefreshThemes(Name)
def _DeleteTheme(self) -> None:
Name = self.ThemePicker.value
if not Name:
return
if len(self.ThemeNames) <= 1:
self.TheApp.Notify("Can't delete the only remaining theme.", "error")
return
if self._Try(lambda: self.Server.DeleteTheme(Name), "Couldn't delete the theme"):
self._RefreshThemes("")
def Save(self) -> bool:
"""Saves the Web view section; False (after saying why) if it couldn't."""
Values = dict(self.Values)
for Key, Switch in self.Switches.items():
Values[Key] = bool(Switch.value)
Values["MarkdownEditing"] = self.MarkdownEditing.value or Values.get("MarkdownEditing", "Live")
Values["ThemeName"] = self.ThemePicker.value or Values.get("ThemeName")
Values["FontFamily"] = (self.FontFamily.value or "").strip() or Values.get("FontFamily")
try:
Values["FontSize"] = max(8, min(40, int(self.FontSize.value)))
except (TypeError, ValueError):
pass
Values["GraphPanButton"] = self.PanButton.value or Values.get("GraphPanButton", "Right")
return self._Try(lambda: self.Server.SaveUserSettings(Values), "Couldn't save your server settings")
# --- Profile and Account (at once) ---------------------------------------
def _Profile(self) -> ft.Control:
Colours = ThemePalette()
Current = (self.Server.User or {}).get("Colour", "Purple")
self.Swatches = ft.Row(wrap=True, spacing=8, run_spacing=8)
def Pick(Colour: str) -> None:
if self._Try(lambda: self.Server.SetColour(Colour), "Couldn't change your colour"):
Draw(Colour)
self.Swatches.update()
def Draw(Selected: str) -> None:
self.Swatches.controls = [
ft.Container(width=28, height=28, border_radius=14, bgcolor=Colours.get(Name, Palette.Mauve),
border=ft.Border.all(3, Palette.Text) if Name == Selected else None,
tooltip=Name, on_click=lambda e, N=Name: Pick(N))
for Name in self.Server.ColourOptions()
]
Draw(Current)
async def PickAvatar(e) -> None:
Picked = await self.TheApp.Picker.pick_files(dialog_title="Avatar", allow_multiple=False,
file_type=ft.FilePickerFileType.IMAGE)
if Picked and Picked[0].path:
if self._Try(lambda: self.Server.UploadAvatar(Path(Picked[0].path)), "Couldn't upload the avatar"):
self.TheApp.Notify("Avatar updated.", "success")
return ft.Column([
ft.Text("Your colour"), _Note("Shown on your edits in blame, and on your badge."), self.Swatches,
_Divider(), ft.Text("Avatar"), ChipButton("Choose an image…", PickAvatar, Icon=ft.Icons.IMAGE_OUTLINED),
], spacing=12, scroll=ft.ScrollMode.AUTO)
def _Account(self) -> ft.Control:
User = self.Server.User or {}
UsernameField = ft.TextField(label="Username", value=User.get("Username", ""))
CurrentPassword = ft.TextField(label="Current password", password=True, can_reveal_password=True)
NewPassword = ft.TextField(label="New password", password=True, can_reveal_password=True)
def ChangeName(e) -> None:
Name = (UsernameField.value or "").strip()
if Name and Name != (self.Server.User or {}).get("Username") and \
self._Try(lambda: self.Server.ChangeUsername(Name), "Couldn't change your username"):
self.TheApp.Notify(f"You're now {Name}.", "success")
def ChangePassword(e) -> None:
if self._Try(lambda: self.Server.ChangePassword(CurrentPassword.value or "", NewPassword.value or ""),
"Couldn't change your password"):
CurrentPassword.value = NewPassword.value = ""
CurrentPassword.update()
NewPassword.update()
self.TheApp.Notify("Password changed.", "success")
return ft.Column([
_Note(f"Logged in to {self.Server.Url} as {User.get('Username', '?')}"
+ (" (admin)." if self.IsAdmin else ".")),
ft.Row([ft.Container(UsernameField, expand=True), ChipButton("Change", ChangeName)]),
_Divider(),
CurrentPassword, NewPassword, ft.Row([ChipButton("Change password", ChangePassword)]),
_Divider(),
ft.Row([ChipButton("Log out", lambda e: self.OnLoggedOut(), Icon=ft.Icons.LOGOUT, Accent=Palette.Red)]),
], spacing=12, scroll=ft.ScrollMode.AUTO)
# --- Admin (at once) ------------------------------------------------------
def _RefreshAdmin(self, Update: bool = True) -> None:
try:
Users, Groups, Locks = self.Server.Users(), self.Server.Groups(), self.Server.Locks()
except SourceError as Error:
self.TheApp.Notify(f"Couldn't load the admin lists: {Error}", "error")
return
Colours = ThemePalette()
self.UsersList.controls = [
ft.Row([ft.Icon(ft.Icons.CIRCLE, size=12, color=Colours.get(U.get("Colour", "Purple"), Palette.Mauve)),
ft.Text(U["Username"]), ft.Text("admin", color=Palette.Overlay0, size=12) if U.get("IsAdmin") else ft.Container()],
spacing=8)
for U in Users
]
Names = [U["Username"] for U in Users]
self.GroupsList.controls = [self._GroupRow(G, Names) for G in Groups] or [_Note("No groups yet.")]
self.LocksList.controls = [
ft.Row([ft.Text(f"{L.get('Root')}/{L.get('Path')}", expand=True),
ft.Text(", ".join([*(L.get("Users") or []), *(f"@{G}" for G in L.get("Groups") or [])]),
color=Palette.Overlay0, size=12),
ChipButton("Unlock", lambda e, L=L: self._Admin(lambda: self.Server.RemoveLock(L["Root"], L["Path"]),
"Couldn't unlock"))])
for L in Locks
] or [_Note("Nothing is locked.")]
if Update:
for Listing in (self.UsersList, self.GroupsList, self.LocksList):
try:
Listing.update()
except RuntimeError:
pass # its section isn't open: it shows the new list when it is
def _Admin(self, Action: Callable[[], None], Failure: str) -> None:
if self._Try(Action, Failure):
self._RefreshAdmin()
def _GroupRow(self, Group: dict, Usernames: list[str]) -> ft.Control:
Members = Group.get("Members") or []
def Toggle(Name: str) -> None:
New = [M for M in Members if M != Name] if Name in Members else [*Members, Name]
self._Admin(lambda: self.Server.SetGroupMembers(Group["Name"], New), "Couldn't change the group")
Chips = [ft.Container(ft.Text(Name, size=12, color=Palette.Mauve if Name in Members else Palette.Overlay0),
bgcolor=Palette.Surface0 if Name in Members else None,
border=ft.Border.all(1, Palette.Surface1), border_radius=4,
padding=ft.Padding.symmetric(horizontal=6, vertical=2),
tooltip="Click to add or remove", on_click=lambda e, N=Name: Toggle(N))
for Name in Usernames]
return ft.Column([
ft.Row([ft.Text(Group["Name"], expand=True),
ChipButton("Delete", lambda e: self.TheApp.Confirm(
"Delete group", f'Delete group "{Group["Name"]}"? This can\'t be undone.',
lambda: self._Admin(lambda: self.Server.DeleteGroup(Group["Name"]), "Couldn't delete the group")),
Accent=Palette.Red)]),
ft.Row(Chips, wrap=True, spacing=4, run_spacing=4),
], spacing=4)
def _Users(self) -> ft.Control:
Name = ft.TextField(label="Username", dense=True)
Password = ft.TextField(label="Password", password=True, can_reveal_password=True, dense=True)
IsAdmin = ft.Switch(label="Admin", value=False)
Colour = ft.Dropdown(label="Colour", value="Purple", dense=True,
options=[ft.DropdownOption(key=C, text=C) for C in self.Server.ColourOptions()])
def Create(e) -> None:
if (Name.value or "").strip():
self._Admin(lambda: self.Server.CreateUser(Name.value.strip(), Password.value or "", bool(IsAdmin.value),
Colour.value or "Purple"), "Couldn't create the user")
Name.value = Password.value = ""
Name.update()
Password.update()
return ft.Column([self.UsersList, _Divider(), ft.Text("New user"), Name, Password, ft.Row([IsAdmin, Colour]),
ft.Row([ChipButton("Create user", Create)])], spacing=12, scroll=ft.ScrollMode.AUTO)
def _Groups(self) -> ft.Control:
Name = ft.TextField(label="New group name", dense=True, expand=True)
def Create(e) -> None:
if (Name.value or "").strip():
self._Admin(lambda: self.Server.CreateGroup(Name.value.strip()), "Couldn't create the group")
Name.value = ""
Name.update()
return ft.Column([_Note("Click a name to add it to the group or take it out."), self.GroupsList,
_Divider(), ft.Row([Name, ChipButton("Create group", Create)])],
spacing=12, scroll=ft.ScrollMode.AUTO)
def _Locks(self) -> ft.Control:
return ft.Column([_Note("Directories only some users or groups can see."), self.LocksList],
spacing=12, scroll=ft.ScrollMode.AUTO)