From 1f3913bb56df16f36b1ed7a6e8281af5616952d1 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Tue, 13 Oct 2026 18:43:23 +0200 Subject: [PATCH] Fixed dead network mounts freezing folder listing in Browser.py Co-Authored-By: Claude Opus 5.5 --- Browser.py | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/Browser.py b/Browser.py index 1ffed47..b36de61 100644 --- a/Browser.py +++ b/Browser.py @@ -277,6 +277,8 @@ class Browser(ft.Row): # entries make the cache meaningless. self.ColumnCells: dict[str, list[ft.Container]] = {} self.MetadataCache: dict[Path, tuple[float, dict[str, str]]] = {} + # Filled by _BuildEntries from the directory listing itself, see _IsDir. + self.EntryIsDir: dict[Path, bool] = {} self.FolderIconOverrides: dict[Path, FolderIconOverride] = LoadFolderIconOverrides() # Bumped by _RefreshEntries every time it rebuilds the list, so a # background _PopulateEntriesAsync pass from a superseded build can @@ -1138,12 +1140,14 @@ class Browser(ft.Row): blocking on it. _PopulateEntriesAsync fills the real values in afterwards, off the UI thread. A cache hit (e.g. a same-directory refresh after a rename) still shows immediately, no flicker.""" + Cached = self.MetadataCache.get(Entry) + if Cached is None: + return {} # don't stat on a miss: a dead network mount blocks for ~20s try: Mtime = Entry.stat().st_mtime except OSError: return {} - Cached = self.MetadataCache.get(Entry) - if Cached is not None and Cached[0] == Mtime: + if Cached[0] == Mtime: return Cached[1] return {} @@ -1197,7 +1201,7 @@ class Browser(ft.Row): pass resolves the real icon. Identical to _LeadingFor's non- thumbnail branches, so only images visibly change once their thumbnail loads — everything else never flickers.""" - if Entry.is_dir(): + if self._IsDir(Entry): return self._ResolveFolderIconFast(Entry, Size) return ft.Icon(ft.Icons.INSERT_DRIVE_FILE_OUTLINED, size=Size, color=Palette.Overlay0) @@ -1273,7 +1277,7 @@ class Browser(ft.Row): NameKey = Entry.name.lower() if Field == "Size": try: - Primary = 0 if Entry.is_dir() else Entry.stat().st_size + Primary = 0 if self._IsDir(Entry) else Entry.stat().st_size except OSError: Primary = 0 elif Field == "Modified": @@ -1282,16 +1286,24 @@ class Browser(ft.Row): except OSError: Primary = 0.0 elif Field == "Type": - Primary = "" if Entry.is_dir() else Entry.suffix.lower() + Primary = "" if self._IsDir(Entry) else Entry.suffix.lower() else: # "Name" Primary = NameKey return (Primary, NameKey) Reverse = not self.Config.SortAscending - Dirs = sorted((E for E in Entries if E.is_dir()), key=KeyFn, reverse=Reverse) - Files = sorted((E for E in Entries if not E.is_dir()), key=KeyFn, reverse=Reverse) + Dirs = sorted((E for E in Entries if self._IsDir(E)), key=KeyFn, reverse=Reverse) + Files = sorted((E for E in Entries if not self._IsDir(E)), key=KeyFn, reverse=Reverse) return Dirs + Files + def _IsDir(self, Entry: Path) -> bool: + """Entry.is_dir() answered from the type _BuildEntries' scandir already + read, so the synchronous build never stats an entry. A dead network + mount (e.g. a CIFS share whose host is down) blocks every stat for + ~20s, which froze the whole listing for minutes.""" + IsDir = self.EntryIsDir.get(Entry) + return Entry.is_dir() if IsDir is None else IsDir + def _BuildEntries(self, DirPath: Path) -> list[ft.Control]: self.EntryTiles = {} self.ColumnCells = {} @@ -1300,7 +1312,15 @@ class Browser(ft.Row): # next refresh, same as any other entry list change. self.FolderIconOverrides = LoadFolderIconOverrides() try: - Entries = list(DirPath.iterdir()) + with os.scandir(DirPath) as Scan: + self.EntryIsDir = {} + for DirEntry in Scan: + try: + # Uses the listing's d_type, no stat (except for symlinks). + self.EntryIsDir[DirPath / DirEntry.name] = DirEntry.is_dir() + except OSError: + self.EntryIsDir[DirPath / DirEntry.name] = False + Entries = list(self.EntryIsDir) except (PermissionError, FileNotFoundError): self.CurrentEntries = [] return [ft.Text("Can't read this folder.", italic=True)] @@ -1445,7 +1465,7 @@ class Browser(ft.Row): ), expand=True, ) - if Entry.is_dir(): + if self._IsDir(Entry): # A directory row is both a drag source (like any other entry, so # it can itself be moved elsewhere) and a drop target (so other # entries can be dropped into it) — same MoveIntoDirectory Sidebar @@ -1544,7 +1564,7 @@ class Browser(ft.Row): on_secondary_tap_down=lambda e, p=Entry: self._RequestMenu(p, e), ), ) - if Entry.is_dir(): + if self._IsDir(Entry): # Same drop-target treatment as the list view's directory rows — # see _BuildListRow. return ft.DragTarget( @@ -1555,7 +1575,7 @@ class Browser(ft.Row): return GridDraggable def _LeadingFor(self, Entry: Path, Size: int = 32): - if Entry.is_dir(): + if self._IsDir(Entry): return self._ResolveFolderIconFull(Entry, Size) if IsThumbnailable(Entry): ThumbnailPath = GetThumbnail(Entry, Size=(Size * 2, Size * 2))