Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
- Das Schließen fordert den Abbruch an und hält den Dialog bis zum bestätigten Thread-Ende offen. Die Oberfläche bleibt dabei bedienbar; die Freigabe eines noch laufenden nativen Workers wird verhindert.
- Automatische zyklische Python-Bereinigung wird während des GUI-Betriebs im Anwendungsthread ausgeführt, damit Hintergrundthreads keine Qt-Objekte zerstören.

### Laufwerksbelegung / Drive capacity (2026-09-30)

- Die Ordner-Seitenleiste zeigt für jedes Laufwerk einen Belegungsbalken, den belegten Anteil in Prozent und freien, belegten sowie gesamten Speicher in binären Größen (GiB/TiB).
- Kapazitäten werden unabhängig im Hintergrund abgefragt, beim Öffnen und über „Laufwerksbelegung aktualisieren“ erneuert. Nicht verfügbare Laufwerke erhalten einen erklärenden Text statt falscher Nullwerte.
- Text und Balken sind auch in schmalen Seitenleisten lesbar; Pfadnavigation bleibt erhalten. Alle neuen Texte sind in DE/EN/ES/ZH/JA/RU vorhanden.
- Hintergrundabfragen liefern reine Python-Daten; ein Timer des Panels übernimmt fertige Ergebnisse im GUI-Thread. Dateisystem- und Sortiermodell gehören jetzt ihrem Dateibrowser und werden mit ihm gelöscht.
- Die Löschprüfungen bereinigen ausschließlich ihre eigenen Qt-Objekte; die Navigation wartet mit einem begrenzten Qt-Ereignislauf auf tatsächlich geladene Dateizeilen.
- Zyklische Python-Objekte werden während des GUI-Betriebs im Anwendungsthread bereinigt. Hintergrundabfragen werden vor dem Beenden abgewartet; Prüfsummen-Threads lösen keine automatische Löschung von Qt-GUI-Objekten mehr aus.
- Laufwerksabfragen laufen in eigenen Hilfsprozessen mit zehn Sekunden Zeitlimit. Hängende Datenträger werden als nicht verfügbar angezeigt; beim Beenden werden laufende Hilfsprozesse beendet und abgeholt.
- Source feature only: no EXE/MSIX release or Store submission performed.

### Repository-Lebenszyklus-Härtung & CI/CD-Parität (2026-09-30, Pfad A)
- **CI/CD Lifecycle Workflows & Label-Governance**:
- `.github/workflows/auto-assign.yml`: Automatisches Zuweisen von Pull Requests an Maintainer via `actions/github-script@v7`, `timeout-minutes: 5`, least-privilege permissions (`issues: write`, `pull-requests: write`) und Concurrency `cancel-in-progress: true`.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
<a id="hauptfunktionen"></a>
## 1. Features & Core Capabilities

The source version includes per-drive capacity bars in the folder sidebar, with used percentage and free/used/total space in GiB/TiB. Capacity requests run in the background; use **Refresh drive usage** to update them. Unavailable devices show an explanatory message. This addition is not yet part of a newly packaged release.

Standard operating system file managers are built for casual browsing and lack the heavy-lifting tools developers, researchers, and power users require daily. ExplorerPro addresses this gap by packaging pro-grade productivity utilities into a cohesive, responsive desktop interface with zero telemetry and 100% Local-First data isolation:

- **Unified Multi-Tab Experience:** Browse multiple directories concurrently with tab pinning, breadcrumb navigation, drag-and-drop, and intelligent context menus.
Expand Down
2 changes: 2 additions & 0 deletions README_de.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

# ExplorerPro Suite

Der aktuelle Quellcode zeigt in der Ordner-Seitenleiste die Speicherbelegung je Laufwerk: Balken, Prozentanteil und freier/belegter/gesamter Speicher in GiB/TiB. Die Abfrage läuft im Hintergrund; **Laufwerksbelegung aktualisieren** erneuert die Werte. Nicht verfügbare Geräte werden verständlich gekennzeichnet. Diese Ergänzung ist noch nicht Bestandteil eines neu gebauten Releases.

[English](README.md) | **[Deutsch](README_de.md)** | [Maschinenlesbarer Kontext (llms.txt)](llms.txt)

[![CI](https://github.com/file-bricks/ExplorerPro/actions/workflows/ci.yml/badge.svg)](https://github.com/file-bricks/ExplorerPro/actions/workflows/ci.yml)
Expand Down
50 changes: 49 additions & 1 deletion locales/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -2846,5 +2846,53 @@
"zh": "打开选定的文件",
"ja": "選択したファイルを開く",
"ru": "Открывает выбранный файл"
},
"Speicherbelegung": {
"de": "Speicherbelegung",
"en": "Storage usage",
"es": "Uso del almacenamiento",
"zh": "存储使用情况",
"ja": "ストレージ使用量",
"ru": "Использование хранилища"
},
"Speicherbelegung wird ermittelt …": {
"de": "Speicherbelegung wird ermittelt …",
"en": "Checking storage usage …",
"es": "Calculando el uso del almacenamiento …",
"zh": "正在查询存储使用情况 …",
"ja": "ストレージ使用量を確認中 …",
"ru": "Проверка использования хранилища …"
},
"Speicherbelegung nicht verfügbar": {
"de": "Speicherbelegung nicht verfügbar",
"en": "Storage usage unavailable",
"es": "Uso del almacenamiento no disponible",
"zh": "存储使用情况不可用",
"ja": "ストレージ使用量を取得できません",
"ru": "Данные об использовании хранилища недоступны"
},
"{free} frei · {used} belegt · {total} gesamt": {
"de": "{free} frei · {used} belegt · {total} gesamt",
"en": "{free} free · {used} used · {total} total",
"es": "{free} libre · {used} usado · {total} total",
"zh": "{free} 可用 · {used} 已用 · {total} 总计",
"ja": "{free} 空き · {used} 使用中 · {total} 合計",
"ru": "{free} свободно · {used} занято · {total} всего"
},
"{percent}% belegt": {
"de": "{percent}% belegt",
"en": "{percent}% used",
"es": "{percent}% usado",
"zh": "已用 {percent}%",
"ja": "{percent}% 使用中",
"ru": "{percent}% занято"
},
"Laufwerksbelegung aktualisieren": {
"de": "Laufwerksbelegung aktualisieren",
"en": "Refresh drive usage",
"es": "Actualizar el uso de las unidades",
"zh": "刷新驱动器使用情况",
"ja": "ドライブ使用量を更新",
"ru": "Обновить использование дисков"
}
}
}
138 changes: 138 additions & 0 deletions src/core/drive_usage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Read capacity without scanning files or changing the filesystem."""

from dataclasses import dataclass
import os
import shutil
import json
from pathlib import Path
import subprocess
import sys
import threading
import tempfile

QUERY_TIMEOUT_SECONDS = 10
_query_lock = threading.Lock()
_query_processes = set()
_queries_stopped = False


def start_drive_queries():
global _queries_stopped
with _query_lock:
_queries_stopped = False


def stop_drive_queries():
"""Stop only our capacity helpers, including reads stuck inside the OS."""
global _queries_stopped
with _query_lock:
_queries_stopped = True
for process in tuple(_query_processes):
if process.poll() is None:
process.kill()


def _query_command(path, result_path):
if getattr(sys, 'frozen', False):
return [sys.executable, '--drive-capacity-query', path, result_path]
return [sys.executable, str(Path(__file__).resolve()), path, result_path]


def read_drive_usage_bounded(path):
"""Isolate disk_usage so stalled devices cannot retain pool slots forever."""
with tempfile.TemporaryDirectory(prefix='explorerpro-capacity-') as directory:
return _read_drive_usage_process(path, Path(directory) / 'result.json')


def _read_drive_usage_process(path, result_path):
with _query_lock:
if _queries_stopped:
raise OSError('Capacity queries stopped')
process = subprocess.Popen(
_query_command(path, str(result_path)),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0,
)
_query_processes.add(process)
try:
try:
process.wait(timeout=QUERY_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
raise OSError('Capacity query timed out') from None
if process.returncode:
raise OSError('Capacity query unavailable')
try:
values = json.loads(result_path.read_text(encoding='utf-8'))
return DriveUsage(**values)
except (OSError, ValueError, TypeError):
raise OSError('Invalid capacity query response') from None
finally:
with _query_lock:
_query_processes.discard(process)


def capacity_query_main(path, result_path):
"""Read-only measurement; write its result to the parent's private temp file."""
try:
usage = read_drive_usage(path)
except (OSError, ValueError):
return 1
# Windowed frozen executables may have no stdout, even with redirected pipes.
with open(result_path, 'x', encoding='utf-8') as result:
json.dump({'total': usage.total, 'used': usage.used, 'free': usage.free}, result)
return 0


@dataclass(frozen=True)
class DriveUsage:
total: int
used: int
free: int

def __post_init__(self):
if self.total <= 0 or not 0 <= self.used <= self.total or not 0 <= self.free <= self.total:
raise ValueError("Invalid drive capacity")

@property
def used_percent(self) -> float:
return self.used / self.total * 100


def read_drive_usage(path: str) -> DriveUsage:
if os.name == "nt":
# Empty removable drives must return an error rather than opening an
# operating-system critical-error dialog in this background thread.
import ctypes
from ctypes import wintypes
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
set_mode = kernel.SetThreadErrorMode
set_mode.argtypes = (wintypes.DWORD, ctypes.POINTER(wintypes.DWORD))
set_mode.restype = wintypes.BOOL
get_mode = kernel.GetThreadErrorMode
get_mode.argtypes = ()
get_mode.restype = wintypes.DWORD
previous = wintypes.DWORD()
if not set_mode(get_mode() | 0x0001, ctypes.byref(previous)): # SEM_FAILCRITICALERRORS
raise ctypes.WinError(ctypes.get_last_error())
try:
usage = shutil.disk_usage(path)
finally:
set_mode(previous.value, None)
else:
usage = shutil.disk_usage(path)
return DriveUsage(usage.total, usage.used, usage.free)


def format_capacity(size: int) -> str:
"""Use explicit binary units, retaining useful precision for small drives."""
value = float(size)
for unit in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"):
if value < 1024 or unit == "PiB":
return f"{int(value)} B" if unit == "B" else f"{value:.1f} {unit}"
value /= 1024


if __name__ == '__main__':
sys.exit(capacity_query_main(*sys.argv[1:]) if len(sys.argv) == 3 else 2)
4 changes: 2 additions & 2 deletions src/gui/browser/file_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,15 @@ def _setup_ui(self):
layout.setContentsMargins(0, 0, 0, 0)

# Datei-System-Model
self.model = QFileSystemModel()
self.model = QFileSystemModel(self)
self.model.setFilter(
QDir.Filter.AllEntries |
QDir.Filter.NoDotAndDotDot
)
self.model.directoryLoaded.connect(self._on_directory_loaded)

# Sortier-Proxy
self.proxy = QSortFilterProxyModel()
self.proxy = QSortFilterProxyModel(self)
self.proxy.setSourceModel(self.model)
self.proxy.setSortCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)

Expand Down
79 changes: 79 additions & 0 deletions src/gui/sidebar/drive_capacity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Capacity widgets and independent background requests for drive rows."""

from concurrent.futures import ThreadPoolExecutor

from PySide6.QtCore import Qt
from PySide6.QtWidgets import QLabel, QProgressBar, QVBoxLayout, QWidget

from core.drive_usage import format_capacity, start_drive_queries, stop_drive_queries
from translator import t

_executor = None


def capacity_executor():
"""Workers only return Python data; no Qt objects cross thread boundaries.

OS reads run in bounded helpers, so failed drives release worker slots.
"""
global _executor
if _executor is None:
start_drive_queries()
_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="drive-capacity")
return _executor


def shutdown_capacity_executor():
"""Stop capacity helpers, then drain Python workers before Qt teardown."""
global _executor
if _executor is not None:
stop_drive_queries()
_executor.shutdown(wait=True, cancel_futures=True)
_executor = None


class DriveCapacityWidget(QWidget):
def __init__(self, path, parent=None):
super().__init__(parent)
self.path = path
# Tree selection and double-click navigation still receive mouse events.
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
layout = QVBoxLayout(self)
layout.setContentsMargins(2, 3, 2, 3)
layout.setSpacing(2)
self.title = QLabel(path)
self.details = QLabel()
self.details.setWordWrap(True)
self.bar = QProgressBar()
self.bar.setRange(0, 1000)
self.bar.setFixedHeight(18)
self.bar.setAccessibleName(t("Speicherbelegung"))
layout.addWidget(self.title)
layout.addWidget(self.details)
layout.addWidget(self.bar)
self.set_loading()

def set_loading(self):
self.details.setText(t("Speicherbelegung wird ermittelt …"))
self.bar.hide()
self.setAccessibleName(self.path)
self.setAccessibleDescription(self.details.text())

def set_usage(self, usage):
if usage is None:
self.details.setText(t("Speicherbelegung nicht verfügbar"))
self.bar.hide()
self.bar.setValue(0)
else:
self.details.setText(t("{free} frei · {used} belegt · {total} gesamt").format(
free=format_capacity(usage.free), used=format_capacity(usage.used),
total=format_capacity(usage.total)))
self.bar.setValue(round(usage.used_percent * 10))
self.bar.setFormat(t("{percent}% belegt").format(percent=f"{usage.used_percent:.1f}"))
self.bar.show()
description = self.details.text()
if usage is not None:
description += " · " + self.bar.format()
self.setToolTip(self.path + "\n" + description)
self.setAccessibleDescription(description)
self.bar.setAccessibleDescription(description)
Loading
Loading