Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
00a600a
feat(sidebar): visualize free and used drive capacity
Sep 30, 2026
6ab63eb
fix(sidebar): bind queued row resizing to panel lifetime
Sep 30, 2026
07c1ca0
fix(gui): isolate capacity workers and own browser model lifetimes
Sep 30, 2026
4b85770
fix(gui): collect Qt reference cycles on application thread
Sep 30, 2026
b6c6a80
fix: bound drive queries and stop stalled capacity helpers
Oct 1, 2026
187ac9d
fix: keep checksum dialog alive until its native worker joins
Oct 1, 2026
f12bf5c
fix: finish pending checksum close exactly once
Oct 1, 2026
619b018
test: dispose checksum dialogs within their Qt test fixtures
Oct 1, 2026
cb2fde6
ci: install Qt test fixtures for checksum lifecycle regressions
Oct 1, 2026
2df3eb6
fix: collect Qt cycles on the application thread
Oct 1, 2026
47f236f
fix: honor delete confirmation and handle Windows readonly files
Oct 1, 2026
673ec27
fix: preserve readonly attributes on surviving hardlinks
Oct 1, 2026
3e4331d
Merge reviewed checksum lifecycle repair into delete confirmation
Oct 1, 2026
2d8120c
fix: harden checksum worker and GUI GC lifecycle
Oct 3, 2026
4145cc3
Merge PR #14 capacity feature onto PR15 lifecycle fixes
Oct 3, 2026
8a4f544
Merge PR #17 delete safeguards onto PR14 capacity integration
Oct 3, 2026
ba38b52
fix: keep silent deletes recoverable
Oct 3, 2026
42223ce
fix: prevent Windows terminal command injection
Oct 4, 2026
a752b34
fix: close terminal injection and query shutdown races
Oct 4, 2026
e159bc3
merge: include reviewed drive and terminal fixes
Oct 4, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt pytest pytest-xdist ruff Pillow
pip install -r requirements.txt pytest pytest-qt pytest-xdist ruff Pillow

- name: Compile sources
run: |
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).

## [Unreleased]

### Sicheres Löschen
- Die Löschbestätigung lässt sich über das Menü **Bearbeiten** oder die Einstellungen ein- und ausschalten.
- Wenn die Bestätigung ausgeschaltet ist, verschiebt ExplorerPro ausgewählte Elemente in den System-Papierkorb. Schlägt das Verschieben fehl, bleibt das Element erhalten und ExplorerPro zeigt den Fehler an.
- Die Windows-Fehler-5-Behandlung entfernt bei Bedarf das Read-only-Attribut regulärer Einzel-Link-Dateien vor einem einzelnen Löschversuch und stellt es bei Fehlschlag wieder her.
- Die rekursive Löschbehandlung verwendet ab Python 3.12 den `onexc`-Callback und bei älteren unterstützten Python-Versionen `onerror`.

### Datei-/Ordner-Eigenschaften, Pfad-Kopier-Suite & Terminal-Integration (2026-10-03, TW-EP-11)
- **Datei- & Ordner-Eigenschaften Dialog (`src/gui/properties_dialog.py`)**:
- Neuer standardkonformer Eigenschafts-Dialog mit Tabs für "Allgemein" und "Prüfsummen" (bzw. "Inhalt" für Textdateien).
Expand Down Expand Up @@ -41,6 +47,21 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
- Dialog-Lebenszyklus: Verhindert das automatische Schließen des modalen Dialogs bei `_do_rename`, sodass der Rollback-Button für den Benutzer erreichbar und nutzbar bleibt; `Accepted`-Status wird beim Schließen nach erfolgreicher Umbenennung an den Browser übermittelt.
- Eingabepfad-Deduplizierung unter Erhalt der Reihenfolge im Konstruktor.
- `tests/test_bugsweep_batch_rename_resilience_20261001.py`: 8 neue hermetische Regressionstests (100% grün).
### Prüfsummen-Dialog (2026-10-01)

- 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**:
Expand Down
4 changes: 3 additions & 1 deletion 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 Expand Up @@ -568,7 +570,7 @@ ExplorerPro provides comprehensive keyboard control designed for high-efficiency
| <kbd>Ctrl</kbd> + <kbd>Tab</kbd> | Browser | Cycle through open directory tabs |
| <kbd>Ctrl</kbd> + <kbd>F</kbd> | Global | Focus search bar and trigger FTS5 search |
| <kbd>F2</kbd> | Browser | Rename selected file or folder |
| <kbd>Delete</kbd> | Browser | Delete selected items (with confirmation dialog) |
| <kbd>Delete</kbd> | Browser | Delete selected items (with confirmation; without confirmation they go to the system trash) |
| <kbd>Ctrl</kbd> + <kbd>C</kbd> | Browser | Copy selected files/folders to clipboard |
| <kbd>Ctrl</kbd> + <kbd>V</kbd> | Browser | Paste files from clipboard (with collision-free auto-suffix) |
| <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>N</kbd> | Browser | Create a new folder in current directory |
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
66 changes: 65 additions & 1 deletion locales/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -2846,5 +2846,69 @@
"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": "Обновить использование дисков"
},
"Wenn deaktiviert, werden Elemente ohne Rückfrage in den Papierkorb verschoben.": {
"de": "Wenn deaktiviert, werden Elemente ohne Rückfrage in den Papierkorb verschoben.",
"en": "When disabled, items are moved to the Recycle Bin without asking.",
"es": "Si se desactiva, los elementos se mueven a la papelera sin pedir confirmación.",
"zh": "禁用后,项目会直接移至回收站,不再询问。",
"ja": "無効にすると、確認なしで項目をごみ箱に移動します。",
"ru": "Если отключить, элементы будут перемещаться в корзину без запроса подтверждения."
},
"Das Element konnte nicht in den Papierkorb verschoben werden.": {
"de": "Das Element konnte nicht in den Papierkorb verschoben werden.",
"en": "The item could not be moved to the Recycle Bin.",
"es": "No se pudo mover el elemento a la papelera.",
"zh": "无法将项目移至回收站。",
"ja": "項目をごみ箱に移動できませんでした。",
"ru": "Не удалось переместить элемент в корзину."
}
}
}
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,13 @@ Notice = "https://github.com/file-bricks/ExplorerPro/blob/master/NOTICE"
[project.optional-dependencies]
dev = [
"pytest>=9.1.1",
"pytest-qt>=4.5.0",
"ruff>=0.9.0",
"altgraph>=0.17.4",
]
test = [
"pytest>=9.1.1",
"pytest-qt>=4.5.0",
"Pillow>=10.0",
]
build = [
Expand Down
87 changes: 87 additions & 0 deletions src/core/delete_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Permanent deletion with a Windows read-only retry for single-link files."""

import os
import shutil
import stat
import sys

from translator import t


def _retry_readonly_file(operation, path, error):
"""Retry a failed file deletion once; never relax directory permissions."""
if (
sys.platform != "win32"
or getattr(error, "winerror", None) != 5
or operation not in (os.remove, os.unlink)
):
raise error
try:
original = os.lstat(path)
except OSError:
raise error
if not (
stat.S_ISREG(original.st_mode)
and original.st_file_attributes & stat.FILE_ATTRIBUTE_READONLY
and original.st_nlink == 1
):
# Attributes belong to the file, not its name. Clearing read-only on
# a hardlink would also modify aliases outside the selected tree.
raise error

os.chmod(path, original.st_mode | stat.S_IWRITE)
try:
operation(path)
except OSError:
# An ACL or open handle can still prevent deletion. Restore the user's
# read-only bit on the same file, and propagate the retry error.
try:
current = os.lstat(path)
if (
stat.S_ISREG(current.st_mode)
and (current.st_dev, current.st_ino) == (original.st_dev, original.st_ino)
):
os.chmod(path, original.st_mode)
except OSError:
pass
raise


def _rmtree_error(operation, path, exc_info):
_retry_readonly_file(operation, path, exc_info[1])


def _rmtree_exception(operation, path, error):
_retry_readonly_file(operation, path, error)


def _remove_tree(path):
if sys.version_info >= (3, 12):
shutil.rmtree(path, onexc=_rmtree_exception)
else:
shutil.rmtree(path, onerror=_rmtree_error)


def delete_path(path):
"""Delete one selected entry, without traversing symlink/junction targets."""
entry = os.lstat(path)
if stat.S_ISLNK(entry.st_mode):
os.unlink(path)
elif getattr(entry, "st_reparse_tag", None) == getattr(stat, "IO_REPARSE_TAG_MOUNT_POINT", 0xA0000003):
os.rmdir(path)
elif stat.S_ISDIR(entry.st_mode):
# Python 3.8+ also avoids descending into nested Windows junctions.
_remove_tree(path)
else:
try:
os.remove(path)
except OSError as error:
_retry_readonly_file(os.remove, path, error)


def move_to_trash(path):
"""Move one selected item to the operating system trash/recycle bin."""
from PySide6.QtCore import QFile

if not QFile.moveToTrash(path):
raise OSError(t("Das Element konnte nicht in den Papierkorb verschoben werden."))
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)
Loading
Loading