Add Refresh button/Ctrl+R; add custom folder icons (per-directory override + global fullness-variant system)

This commit is contained in:
sarah committed 2026-10-07 17:28:58 +02:00
1 parent 1c2acf3f16
commit c542134f62
7 files changed
+581 -48

No files matched your search

+47 -9
View File
@@ -46,6 +46,11 @@ class Device:
MountPoint: Optional[Path]
DrivePath: str = ""
CanPowerOff: bool = False
# True only for an internal drive mounted at a system path ("/", "/home",
# ...) shown because of Config.ShowSystemDrive — _BuildTiles never shows
# an eject button for one of these, no matter what CanPowerOff says.
# Unmounting your own running root filesystem would crash the system.
IsSystemMount: bool = False
def _HumanSize(Bytes: int) -> str:
@@ -95,7 +100,7 @@ class UDisksClient:
raise OSError(Reply.body[0] if Reply.body else Reply.error_name)
return Reply.body
async def ListDevices(self, ShowInternal: bool) -> list[Device]:
async def ListDevices(self, ShowInternal: bool, ShowSystemDrive: bool = False) -> list[Device]:
Objects = (await self._Call(UDisksPath, "org.freedesktop.DBus.ObjectManager", "GetManagedObjects"))[0]
Devices = []
for ObjectPath, Interfaces in Objects.items():
@@ -110,11 +115,21 @@ class UDisksClient:
for Raw in _Value(Interfaces[FilesystemInterface], "MountPoints", [])
]
MountPoint = MountPoints[0] if MountPoints else None
IsSystemMount = False
if _Value(Block, "HintSystem", False):
# Internal disks: only unmounted ones or ones mounted like
# removable media, never "/", "/home", "/boot" and friends.
if not ShowInternal or (MountPoint is not None and not _UserMount(MountPoint)):
# Internal disks: unmounted ones or ones mounted like
# removable media always count as "internal". One mounted at
# a system path only counts when ShowSystemDrive is also on,
# and even then only the root "/" itself — udisks2 reports
# /boot, /boot/efi, etc. as their own separate block devices,
# and showing every one of those as its own non-ejectable
# entry was more than "the main drive" (singular) implied.
if not ShowInternal:
continue
if MountPoint is not None and not _UserMount(MountPoint):
if not ShowSystemDrive or MountPoint != Path("/"):
continue
IsSystemMount = True
Drive = Objects.get(DrivePath, {}).get(DriveInterface, {})
Label = _Value(Block, "IdLabel", "") or _Value(Block, "HintName", "")
if not Label:
@@ -126,6 +141,7 @@ class UDisksClient:
MountPoint=MountPoint,
DrivePath=DrivePath,
CanPowerOff=bool(_Value(Drive, "CanPowerOff", False)),
IsSystemMount=IsSystemMount,
))
return sorted(Devices, key=lambda D: D.Label.lower())
@@ -156,9 +172,19 @@ class UDisksClient:
class DevicesPanel(ft.Container):
def __init__(self, OnNavigate: Callable[[Path], None], Config: AppConfig):
def __init__(
self,
OnNavigate: Callable[[Path], None],
Config: AppConfig,
OnEmptyChanged: Optional[Callable[[bool], None]] = None,
):
self.OnNavigate = OnNavigate
self.Config = Config
# Features/Minor: "Option to hide devices section if there are none,
# but show if there are" — fired from _Show only on an empty/non-
# empty transition (not every device-list change), so Sidebar isn't
# re-toggling visibility on every plug/unplug of an already-nonempty list.
self.OnEmptyChanged = OnEmptyChanged
self.Client: Optional[UDisksClient] = None
self.CurrentDevices: list[Device] = []
self.CurrentPath: Optional[Path] = None
@@ -212,7 +238,7 @@ class DevicesPanel(ft.Container):
async def _Refresh(self, IsStartup: bool = False) -> None:
try:
Devices = await self.Client.ListDevices(self.Config.ShowInternalDrives)
Devices = await self.Client.ListDevices(self.Config.ShowInternalDrives, self.Config.ShowSystemDrive)
except Exception:
return
Known = {D.Key for D in self.CurrentDevices}
@@ -224,6 +250,7 @@ class DevicesPanel(ft.Container):
await self._MountDevice(NewDevice, Open=False)
def _Show(self, Devices: list[Device], Force: bool = False) -> None:
WasEmpty = not self.CurrentDevices
if Devices == self.CurrentDevices and not Force:
return
self.CurrentDevices = Devices
@@ -231,6 +258,14 @@ class DevicesPanel(ft.Container):
if self.CurrentPath is not None:
self._ApplySelection()
self.update()
IsEmpty = not Devices
# Force (startup) always fires too, not just real transitions: an
# empty list right from the start is still "WasEmpty == IsEmpty",
# which looks like a no-op transition but is actually the one time
# Sidebar's listener most needs the initial state explicitly, since
# DevicesBox otherwise defaults to visible with nothing to show.
if self.OnEmptyChanged is not None and (IsEmpty != WasEmpty or Force):
self.OnEmptyChanged(IsEmpty)
def _BuildTiles(self) -> list[ft.Control]:
self.DeviceTiles = {}
@@ -250,7 +285,10 @@ class DevicesPanel(ft.Container):
icon=ft.Icons.EJECT,
icon_color=Palette.Overlay0,
tooltip="Eject",
visible=self.Client is not None and (Mounted or Current.CanPowerOff),
# Never offer to eject the system/boot drive — unmounting
# your own running root filesystem would crash it.
visible=self.Client is not None and not Current.IsSystemMount
and (Mounted or Current.CanPowerOff),
on_click=lambda e, D=Current: self.page.run_task(self._EjectDevice, D),
),
tooltip=str(Current.MountPoint) if Mounted else "Not mounted: click to mount",
@@ -284,8 +322,8 @@ class DevicesPanel(ft.Container):
self.OnNavigate(MountPoint)
async def _EjectDevice(self, Target: Device) -> None:
if self.Client is None or Target.Key in self.Busy:
return
if self.Client is None or Target.Key in self.Busy or Target.IsSystemMount:
return # never unmount the system drive, regardless of how this got called
self.Busy.add(Target.Key)
try:
if Target.MountPoint is not None: