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
17 changes: 17 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 @@ -46,6 +52,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
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": "Не удалось переместить элемент в корзину."
}
}
}
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