diff --git a/Client/ServerSettings.py b/Client/ServerSettings.py new file mode 100644 index 0000000..a914e97 --- /dev/null +++ b/Client/ServerSettings.py @@ -0,0 +1,312 @@ +"""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) diff --git a/Client/Settings.py b/Client/Settings.py index c2a17eb..eb0d5d5 100644 --- a/Client/Settings.py +++ b/Client/Settings.py @@ -15,6 +15,8 @@ from Core import ( AppConfig, AssetsDir, AvailableThemes, ChipButton, DataHome, ExportSettings, Home, HumanSize, ImportSettings, InstalledFontFamilies, LoadSharedSettings, Palette, SharedSettings, ) +from ServerSettings import ServerSections +from Sources import SourceError DialogWidth = 700 DialogHeight = 520 @@ -252,6 +254,23 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego ], spacing=12, scroll=ft.ScrollMode.AUTO), } + # Logged in to a server: your account and server settings too, like the + # web view's Settings (ServerSettings.py). + Server = TheApp.Server + Extra = None + if Server is not None and Server.User is not None and not TheApp.ConnectError: + def LogOut() -> None: + TheApp.Close() + TheApp.Logout() + + try: + Extra = ServerSections(TheApp, LogOut) + Categories.update(Extra.Categories) + except SourceError as Error: + TheApp.Notify(f"Couldn't load your server settings: {Error}", "error") + if StartCategory not in Categories: + StartCategory = "General" + DetailArea = ft.Container(Categories[StartCategory], expand=True, padding=ft.Padding.only(left=12)) CategoryTiles: dict[str, ft.ListTile] = {} @@ -264,12 +283,16 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego for Name in Categories: CategoryTiles[Name] = ft.ListTile( - title=Name, selected=Name == StartCategory, selected_tile_color=Palette.Surface1, + title=Name, selected=Name == StartCategory, dense=True, # room for the server sections too 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: + ServerFilesBefore = Extra and (Extra.Values.get("ShowHiddenFolders"), Extra.Values.get("ShowSvFiles")) + if Extra is not None and not Extra.Save(): + return # stays open, so nothing's lost + ServerFilesAfter = Extra and (Extra.Switches["ShowHiddenFolders"].value, Extra.Switches["ShowSvFiles"].value) SourceBefore = (Config.Mode, Config.LocalRoot, Config.ServerUrl, Config.ShowHiddenFolders, Config.ShowSvFiles, Config.HideInternalFiles) Config.UseSharedSettings = UseSharedSwitch.value @@ -305,7 +328,8 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego SourceAfter = (Config.Mode, Config.LocalRoot, Config.ServerUrl, Config.ShowHiddenFolders, Config.ShowSvFiles, Config.HideInternalFiles) TheApp.Close() - OnSaved(SourceAfter != SourceBefore) + # A server's hidden-file settings change what it lists: reload like a new source. + OnSaved(SourceAfter != SourceBefore or ServerFilesAfter != ServerFilesBefore) TheApp.Notify("Settings saved.", "success") if RemoveDiagramTools: def RemoveTools() -> None: diff --git a/Client/Sources.py b/Client/Sources.py index 34f0178..7a1a529 100644 --- a/Client/Sources.py +++ b/Client/Sources.py @@ -480,6 +480,69 @@ class ServerSource(Source): # A big root takes the server a while to read through. return self._Json("GET", "/graph", params=self._At(Root, RelativePath), timeout=120) + # --- Your account and settings on the server (ServerSettings.py) ------ + # The same /api endpoints as the web view's Settings panel. + + def UserSettings(self) -> dict: + return self._Json("GET", "/settings") + + def SaveUserSettings(self, Settings: dict) -> dict: + return self._Json("PUT", "/settings", json=Settings) + + def ThemeNames(self) -> list[str]: + return self._Json("GET", "/themes") + + def Theme(self, Name: str) -> dict: + return self._Json("GET", "/theme", params={"Name": Name}) + + def SaveTheme(self, Name: str, Palette: dict) -> None: + self._Json("PUT", "/theme", params={"Name": Name}, json={"Palette": Palette}) + + def DeleteTheme(self, Name: str) -> None: + self._Json("DELETE", "/theme", params={"Name": Name}) + + def ColourOptions(self) -> list[str]: + return self._Json("GET", "/profile/colours") + + def SetColour(self, Colour: str) -> dict: + self.User = {**(self.User or {}), **self._Json("PUT", "/profile/colour", json={"Colour": Colour})} + return self.User + + def UploadAvatar(self, LocalFile: Path) -> None: + with LocalFile.open("rb") as Handle: + self._Json("POST", "/profile/avatar", files=[("Avatar", (LocalFile.name, Handle))]) + + def ChangeUsername(self, NewUsername: str) -> dict: + self.User = {**(self.User or {}), **self._Json("PUT", "/profile/username", json={"NewUsername": NewUsername})} + return self.User + + def ChangePassword(self, CurrentPassword: str, NewPassword: str) -> None: + self._Json("PUT", "/profile/password", json={"CurrentPassword": CurrentPassword, "NewPassword": NewPassword}) + + def Users(self) -> list[dict]: + return self._Json("GET", "/users") + + def CreateUser(self, Username: str, Password: str, IsAdmin: bool, Colour: str) -> None: + self._Json("POST", "/users", json={"Username": Username, "Password": Password, "IsAdmin": IsAdmin, "Colour": Colour}) + + def Groups(self) -> list[dict]: + return self._Json("GET", "/groups") + + def CreateGroup(self, Name: str) -> None: + self._Json("POST", "/groups", json={"Name": Name}) + + def DeleteGroup(self, Name: str) -> None: + self._Json("DELETE", "/groups", params={"Name": Name}) + + def SetGroupMembers(self, Name: str, Members: list[str]) -> None: + self._Json("PUT", "/groups/members", params={"Name": Name}, json={"Members": Members}) + + def Locks(self) -> list[dict]: + return self._Json("GET", "/locks") + + def RemoveLock(self, Root: str, RelativePath: str) -> None: + self._Json("DELETE", "/lock", params=self._At(Root, RelativePath)) + def Diagram(self, Source: str, ThemePalette: dict[str, str], FontSize: int) -> bytes: """A ```mermaid block as a PNG, drawn by the server (Diagrams.py).""" Body = {"Source": Source, "Palette": ThemePalette, "FontSize": FontSize}