feat: tfiles — native GTK4 file manager
Trash via Gio.File.trash(), confirmed permanent delete, zip-slip-guarded extraction, two-phase batch rename, freedesktop thumbnail cache, async volume/clipboard handling. Known issues (from review, to fix next): directory listing runs on the main thread; undo of a move can overwrite a file re-created at the origin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
"""TANINUX Files — nativer Dateimanager im Apple-Look von TANINUX.
|
||||||
|
|
||||||
|
Schwesternapp zu TANINUX Music (`tmusic`): teilt sich dieselbe libadwaita-freie
|
||||||
|
UI-Naht (``gui/ui/native`` + ``style_apple.css``) und Akzent-/Hell-Dunkel-Logik
|
||||||
|
(``gui/style``). Aufbau analog zur Music-Shell:
|
||||||
|
|
||||||
|
places.py – Sidebar-Orte (XDG-Verzeichnisse + eingehängte Laufwerke)
|
||||||
|
window.py – Shell (Sidebar · Toolbar/Breadcrumb · Datei-Raster)
|
||||||
|
app.py – Einstiegspunkt `tfiles`
|
||||||
|
"""
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from taninux.files.app import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""TANINUX Files — Einstiegspunkt (`tfiles`).
|
||||||
|
|
||||||
|
Nativer Dateimanager im Apple-Look von TANINUX. Lädt dasselbe Apple-Stylesheet
|
||||||
|
und die Akzent-/Hell-Dunkel-Logik wie TANINUX Music, öffnet dann die Datei-Shell.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gio, Gtk # noqa: E402
|
||||||
|
|
||||||
|
from taninux.gui import APP_ID, style
|
||||||
|
from taninux.gui.ui import native
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FilesApp(Gtk.Application):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(
|
||||||
|
application_id=f"{APP_ID}.Files",
|
||||||
|
flags=Gio.ApplicationFlags.DEFAULT_FLAGS,
|
||||||
|
)
|
||||||
|
self._window: Gtk.Window | None = None
|
||||||
|
|
||||||
|
def do_activate(self) -> None:
|
||||||
|
if self._window is not None:
|
||||||
|
self._window.present()
|
||||||
|
return
|
||||||
|
|
||||||
|
native.ensure_css()
|
||||||
|
try:
|
||||||
|
style.apply_persisted() # Akzentfarbe + Hell/Dunkel wie der Rest von TANINUX
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("style.apply_persisted() übersprungen", exc_info=True)
|
||||||
|
|
||||||
|
from taninux.files.window import FilesWindow
|
||||||
|
|
||||||
|
self._window = FilesWindow(self)
|
||||||
|
self._window.present()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
app = FilesApp()
|
||||||
|
return app.run(argv if argv is not None else sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""Archiv-Modul: Komprimieren und Entpacken von Dateien.
|
||||||
|
|
||||||
|
Selbst-enthaltenes Modul fuer den GTK4-Dateimanager. Verwendet ausschliesslich
|
||||||
|
die Python-Standardbibliothek (zipfile, tarfile, threading) sowie GLib, um
|
||||||
|
Callbacks an die Hauptschleife zu uebergeben. Die eigentliche Arbeit laeuft in
|
||||||
|
einem Hintergrund-Thread, damit die Oberflaeche nicht blockiert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tarfile
|
||||||
|
import threading
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Iterable, List, Tuple
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import GLib # noqa: E402
|
||||||
|
|
||||||
|
# Supported create formats, in menu order: (id, human_label)
|
||||||
|
FORMATS: List[Tuple[str, str]] = [
|
||||||
|
("zip", "Zip"),
|
||||||
|
("tar.gz", "Tar.gz"),
|
||||||
|
("tar.xz", "Tar.xz"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Map create-format id -> (file extension, tarfile mode or None for zip)
|
||||||
|
_CREATE_EXT = {
|
||||||
|
"zip": ".zip",
|
||||||
|
"tar.gz": ".tar.gz",
|
||||||
|
"tar.xz": ".tar.xz",
|
||||||
|
}
|
||||||
|
_TAR_MODE = {
|
||||||
|
"tar.gz": "w:gz",
|
||||||
|
"tar.xz": "w:xz",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Known double / compound extensions (longest first matters).
|
||||||
|
_ARCHIVE_EXTS = (
|
||||||
|
".tar.gz",
|
||||||
|
".tar.bz2",
|
||||||
|
".tar.xz",
|
||||||
|
".tgz",
|
||||||
|
".tbz2",
|
||||||
|
".txz",
|
||||||
|
".tar",
|
||||||
|
".zip",
|
||||||
|
)
|
||||||
|
|
||||||
|
_ARCHIVE_CONTENT_TYPES = {
|
||||||
|
"application/zip",
|
||||||
|
"application/x-zip",
|
||||||
|
"application/x-zip-compressed",
|
||||||
|
"application/x-tar",
|
||||||
|
"application/gzip",
|
||||||
|
"application/x-gzip",
|
||||||
|
"application/x-xz",
|
||||||
|
"application/x-bzip2",
|
||||||
|
"application/x-bzip",
|
||||||
|
"application/x-compressed-tar",
|
||||||
|
"application/x-gtar",
|
||||||
|
"application/x-bzip-compressed-tar",
|
||||||
|
"application/x-xz-compressed-tar",
|
||||||
|
"application/x-lzma-compressed-tar",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_archive(path: Path, content_type: str = "") -> bool:
|
||||||
|
"""True if `path` looks like a supported, extractable archive.
|
||||||
|
|
||||||
|
Cheap check, no IO: matches by file extension or by content_type.
|
||||||
|
"""
|
||||||
|
name = path.name.lower()
|
||||||
|
for ext in _ARCHIVE_EXTS:
|
||||||
|
if name.endswith(ext):
|
||||||
|
return True
|
||||||
|
if content_type and content_type.lower() in _ARCHIVE_CONTENT_TYPES:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_archive_ext(name: str) -> str:
|
||||||
|
"""Return the archive stem, stripping known (double) extensions."""
|
||||||
|
lower = name.lower()
|
||||||
|
for ext in _ARCHIVE_EXTS:
|
||||||
|
if lower.endswith(ext):
|
||||||
|
return name[: -len(ext)]
|
||||||
|
# Fall back to a single-suffix strip.
|
||||||
|
return Path(name).stem
|
||||||
|
|
||||||
|
|
||||||
|
def _collision_free(base_dir: Path, stem: str, ext: str) -> Path:
|
||||||
|
"""Return a path in `base_dir` that does not yet exist.
|
||||||
|
|
||||||
|
`ext` is "" for directories or a full extension like ".tar.gz" for files.
|
||||||
|
Appends " 2", " 3", ... before the extension on collision.
|
||||||
|
"""
|
||||||
|
candidate = base_dir / f"{stem}{ext}"
|
||||||
|
if not candidate.exists():
|
||||||
|
return candidate
|
||||||
|
n = 2
|
||||||
|
while True:
|
||||||
|
candidate = base_dir / f"{stem} {n}{ext}"
|
||||||
|
if not candidate.exists():
|
||||||
|
return candidate
|
||||||
|
n += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _run_in_thread(target) -> None:
|
||||||
|
threading.Thread(target=target, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _finish(on_done: Callable[["Path | None", str], None], result, message: str) -> None:
|
||||||
|
"""Marshal the callback onto the GLib main loop. Runs once."""
|
||||||
|
def _cb():
|
||||||
|
on_done(result, message)
|
||||||
|
return False
|
||||||
|
|
||||||
|
GLib.idle_add(_cb)
|
||||||
|
|
||||||
|
|
||||||
|
def compress(
|
||||||
|
paths: Iterable[Path],
|
||||||
|
dest_dir: Path,
|
||||||
|
fmt: str,
|
||||||
|
on_done: Callable[["Path | None", str], None],
|
||||||
|
) -> None:
|
||||||
|
"""Create one archive in `dest_dir` containing all `paths`.
|
||||||
|
|
||||||
|
Files and directories are stored with their basenames as top-level entries.
|
||||||
|
Runs in a background thread; never raises.
|
||||||
|
"""
|
||||||
|
items = [Path(p) for p in paths]
|
||||||
|
dest_dir = Path(dest_dir)
|
||||||
|
|
||||||
|
def work():
|
||||||
|
try:
|
||||||
|
if fmt not in _CREATE_EXT:
|
||||||
|
_finish(on_done, None, f"Unbekanntes Format: {fmt}")
|
||||||
|
return
|
||||||
|
if not items:
|
||||||
|
_finish(on_done, None, "Keine Dateien angegeben.")
|
||||||
|
return
|
||||||
|
|
||||||
|
ext = _CREATE_EXT[fmt]
|
||||||
|
if len(items) == 1:
|
||||||
|
stem = items[0].name
|
||||||
|
else:
|
||||||
|
stem = "Archive"
|
||||||
|
archive_path = _collision_free(dest_dir, stem, ext)
|
||||||
|
|
||||||
|
if fmt == "zip":
|
||||||
|
_make_zip(items, archive_path)
|
||||||
|
else:
|
||||||
|
_make_tar(items, archive_path, _TAR_MODE[fmt])
|
||||||
|
|
||||||
|
_finish(on_done, archive_path, "")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
_finish(on_done, None, str(exc))
|
||||||
|
|
||||||
|
_run_in_thread(work)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_zip(items: List[Path], archive_path: Path) -> None:
|
||||||
|
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for item in items:
|
||||||
|
arcbase = item.name
|
||||||
|
if item.is_dir():
|
||||||
|
# Ensure an entry for the directory itself, then walk it.
|
||||||
|
zf.write(item, arcbase)
|
||||||
|
for root, dirs, files in os.walk(item):
|
||||||
|
root_p = Path(root)
|
||||||
|
rel = root_p.relative_to(item)
|
||||||
|
for d in dirs:
|
||||||
|
dpath = root_p / d
|
||||||
|
arcname = os.path.join(arcbase, rel.as_posix(), d) if rel.as_posix() != "." else os.path.join(arcbase, d)
|
||||||
|
zf.write(dpath, arcname)
|
||||||
|
for f in files:
|
||||||
|
fpath = root_p / f
|
||||||
|
arcname = os.path.join(arcbase, rel.as_posix(), f) if rel.as_posix() != "." else os.path.join(arcbase, f)
|
||||||
|
zf.write(fpath, arcname)
|
||||||
|
else:
|
||||||
|
zf.write(item, arcbase)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_tar(items: List[Path], archive_path: Path, mode: str) -> None:
|
||||||
|
with tarfile.open(archive_path, mode) as tf:
|
||||||
|
for item in items:
|
||||||
|
tf.add(item, arcname=item.name)
|
||||||
|
|
||||||
|
|
||||||
|
def extract(
|
||||||
|
archive: Path,
|
||||||
|
dest_dir: Path,
|
||||||
|
on_done: Callable[["Path | None", str], None],
|
||||||
|
) -> None:
|
||||||
|
"""Extract `archive` into a new collision-free subfolder of `dest_dir`.
|
||||||
|
|
||||||
|
The subfolder is named after the archive's stem. Members whose resolved path
|
||||||
|
escapes the target dir (zip-slip / path traversal), absolute members, and
|
||||||
|
symlinks pointing outside are skipped. Runs in a background thread; never
|
||||||
|
raises.
|
||||||
|
"""
|
||||||
|
archive = Path(archive)
|
||||||
|
dest_dir = Path(dest_dir)
|
||||||
|
|
||||||
|
def work():
|
||||||
|
try:
|
||||||
|
stem = _strip_archive_ext(archive.name)
|
||||||
|
if not stem:
|
||||||
|
stem = archive.name
|
||||||
|
extract_dir = _collision_free(dest_dir, stem, "")
|
||||||
|
extract_dir.mkdir(parents=True, exist_ok=False)
|
||||||
|
|
||||||
|
if zipfile.is_zipfile(archive):
|
||||||
|
_extract_zip(archive, extract_dir)
|
||||||
|
elif tarfile.is_tarfile(archive):
|
||||||
|
_extract_tar(archive, extract_dir)
|
||||||
|
else:
|
||||||
|
# Last resort: let tarfile try to open it.
|
||||||
|
try:
|
||||||
|
_extract_tar(archive, extract_dir)
|
||||||
|
except Exception:
|
||||||
|
_finish(on_done, None, "Unbekanntes oder beschaedigtes Archiv.")
|
||||||
|
return
|
||||||
|
|
||||||
|
_finish(on_done, extract_dir, "")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
_finish(on_done, None, str(exc))
|
||||||
|
|
||||||
|
_run_in_thread(work)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe(extract_dir: Path, member_name: str) -> "Path | None":
|
||||||
|
"""Return a resolved, safe target path inside extract_dir, or None."""
|
||||||
|
if not member_name:
|
||||||
|
return None
|
||||||
|
if os.path.isabs(member_name) or member_name.startswith(("/", "\\")):
|
||||||
|
return None
|
||||||
|
base = extract_dir.resolve()
|
||||||
|
target = (extract_dir / member_name).resolve()
|
||||||
|
try:
|
||||||
|
if not target.is_relative_to(base):
|
||||||
|
return None
|
||||||
|
except AttributeError: # Python < 3.9 fallback
|
||||||
|
if os.path.commonpath([str(base), str(target)]) != str(base):
|
||||||
|
return None
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_zip(archive: Path, extract_dir: Path) -> None:
|
||||||
|
with zipfile.ZipFile(archive) as zf:
|
||||||
|
for info in zf.infolist():
|
||||||
|
target = _is_safe(extract_dir, info.filename)
|
||||||
|
if target is None:
|
||||||
|
continue
|
||||||
|
if info.is_dir():
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
continue
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zf.open(info) as src, open(target, "wb") as dst:
|
||||||
|
while True:
|
||||||
|
chunk = src.read(1024 * 64)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
dst.write(chunk)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_tar(archive: Path, extract_dir: Path) -> None:
|
||||||
|
base = extract_dir.resolve()
|
||||||
|
with tarfile.open(archive) as tf:
|
||||||
|
for member in tf.getmembers():
|
||||||
|
target = _is_safe(extract_dir, member.name)
|
||||||
|
if target is None:
|
||||||
|
continue
|
||||||
|
# Reject symlinks/hardlinks pointing outside the target dir.
|
||||||
|
if member.issym() or member.islnk():
|
||||||
|
link_target = (target.parent / member.linkname).resolve()
|
||||||
|
try:
|
||||||
|
safe = link_target.is_relative_to(base)
|
||||||
|
except AttributeError:
|
||||||
|
safe = os.path.commonpath([str(base), str(link_target)]) == str(base)
|
||||||
|
if not safe:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
tf.extract(member, extract_dir, filter="data")
|
||||||
|
except TypeError:
|
||||||
|
# Python < 3.12 has no `filter` parameter.
|
||||||
|
tf.extract(member, extract_dir)
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Stapelweise Umbenennung (Batch-Rename) Engine.
|
||||||
|
|
||||||
|
Reine Logik zum Berechnen neuer Dateinamen (für eine Live-Vorschau) und zum
|
||||||
|
Anwenden der Umbenennung im Hintergrund-Thread. Der Dialog wird anderswo gebaut.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, List, Tuple
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenameRule:
|
||||||
|
find: str = "" # text (or regex if use_regex) to replace in the NAME STEM
|
||||||
|
replace: str = ""
|
||||||
|
use_regex: bool = False
|
||||||
|
prefix: str = "" # prepended to the stem
|
||||||
|
suffix: str = "" # appended to the stem (before the extension)
|
||||||
|
numbering: bool = False # append a sequence number to the stem
|
||||||
|
start: int = 1
|
||||||
|
digits: int = 1 # zero-pad the number to this many digits
|
||||||
|
case: str = "keep" # "keep" | "lower" | "upper" | "title"
|
||||||
|
|
||||||
|
|
||||||
|
def _split_stem_ext(path: Path) -> Tuple[str, str]:
|
||||||
|
"""Return (stem, ext) for a path.
|
||||||
|
|
||||||
|
For a directory (or a name without a dot) treat the whole name as the stem
|
||||||
|
and ext="". For a file use the last suffix as ext.
|
||||||
|
"""
|
||||||
|
name = path.name
|
||||||
|
try:
|
||||||
|
is_dir = path.is_dir()
|
||||||
|
except OSError:
|
||||||
|
is_dir = False
|
||||||
|
if is_dir:
|
||||||
|
return name, ""
|
||||||
|
ext = path.suffix
|
||||||
|
if not ext:
|
||||||
|
return name, ""
|
||||||
|
return name[: -len(ext)], ext
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_case(stem: str, case: str) -> str:
|
||||||
|
if case == "lower":
|
||||||
|
return stem.lower()
|
||||||
|
if case == "upper":
|
||||||
|
return stem.upper()
|
||||||
|
if case == "title":
|
||||||
|
return stem.title()
|
||||||
|
return stem
|
||||||
|
|
||||||
|
|
||||||
|
def _transform_stem(stem: str, rule: RenameRule, index: int) -> str:
|
||||||
|
# 1) find/replace
|
||||||
|
if rule.find:
|
||||||
|
if rule.use_regex:
|
||||||
|
try:
|
||||||
|
stem = re.sub(rule.find, rule.replace, stem)
|
||||||
|
except re.error:
|
||||||
|
pass # leave stem unchanged on regex error
|
||||||
|
else:
|
||||||
|
stem = stem.replace(rule.find, rule.replace)
|
||||||
|
|
||||||
|
# 2) case
|
||||||
|
stem = _apply_case(stem, rule.case)
|
||||||
|
|
||||||
|
# 3) prefix + stem + suffix
|
||||||
|
stem = f"{rule.prefix}{stem}{rule.suffix}"
|
||||||
|
|
||||||
|
# 4) numbering
|
||||||
|
if rule.numbering:
|
||||||
|
n = rule.start + index
|
||||||
|
digits = rule.digits if rule.digits and rule.digits > 0 else 1
|
||||||
|
stem = f"{stem}{n:0{digits}d}"
|
||||||
|
|
||||||
|
return stem
|
||||||
|
|
||||||
|
|
||||||
|
def preview(paths: List[Path], rule: RenameRule) -> List[Tuple[Path, str]]:
|
||||||
|
"""Compute the new basename for each path (see module/API docs)."""
|
||||||
|
result: List[Tuple[Path, str]] = []
|
||||||
|
used: set = set()
|
||||||
|
|
||||||
|
for index, path in enumerate(paths):
|
||||||
|
stem, ext = _split_stem_ext(path)
|
||||||
|
new_stem = _transform_stem(stem, rule, index)
|
||||||
|
new_name = f"{new_stem}{ext}"
|
||||||
|
|
||||||
|
# Ensure uniqueness within the batch (case-sensitive).
|
||||||
|
if new_name in used:
|
||||||
|
counter = 2
|
||||||
|
candidate = f"{new_stem}-{counter}{ext}"
|
||||||
|
while candidate in used:
|
||||||
|
counter += 1
|
||||||
|
candidate = f"{new_stem}-{counter}{ext}"
|
||||||
|
new_name = candidate
|
||||||
|
|
||||||
|
used.add(new_name)
|
||||||
|
result.append((path, new_name))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _do_apply(paths: List[Path], rule: RenameRule,
|
||||||
|
on_done: Callable[[List[Tuple[Path, Path]], List[Tuple[Path, str]]], None]) -> None:
|
||||||
|
renamed: List[Tuple[Path, Path]] = []
|
||||||
|
errors: List[Tuple[Path, str]] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
plan = preview(paths, rule)
|
||||||
|
except Exception as exc: # never raise
|
||||||
|
GLib.idle_add(on_done, renamed, [(p, str(exc)) for p in paths])
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build (old_path, final_path) list, skipping no-ops.
|
||||||
|
targets: List[Tuple[Path, Path]] = []
|
||||||
|
final_names_by_dir: dict = {}
|
||||||
|
for old_path, new_name in plan:
|
||||||
|
final_path = old_path.parent / new_name
|
||||||
|
final_names_by_dir.setdefault(str(old_path.parent), set()).add(new_name)
|
||||||
|
if final_path == old_path:
|
||||||
|
continue
|
||||||
|
targets.append((old_path, final_path))
|
||||||
|
|
||||||
|
# Phase 1: move each source to a unique temp name in the same dir.
|
||||||
|
# This avoids intermediate collisions (e.g. swaps / shifted numbering).
|
||||||
|
temp_map: List[Tuple[Path, Path, Path]] = [] # (old, temp, final)
|
||||||
|
for old_path, final_path in targets:
|
||||||
|
parent = old_path.parent
|
||||||
|
batch_names = final_names_by_dir.get(str(parent), set())
|
||||||
|
# If the final target already exists OUTSIDE the batch, record an error.
|
||||||
|
try:
|
||||||
|
target_exists = final_path.exists()
|
||||||
|
except OSError:
|
||||||
|
target_exists = False
|
||||||
|
if target_exists and final_path.name not in batch_names:
|
||||||
|
errors.append((old_path, f"Ziel existiert bereits: {final_path.name}"))
|
||||||
|
continue
|
||||||
|
# Also guard: target exists, is in batch, but is not itself a source
|
||||||
|
# being moved away -> still a real collision.
|
||||||
|
if target_exists and final_path != old_path:
|
||||||
|
source_set = {o for o, _ in targets}
|
||||||
|
if final_path not in source_set:
|
||||||
|
errors.append((old_path, f"Ziel existiert bereits: {final_path.name}"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create a unique temp name in the same dir.
|
||||||
|
temp_path = None
|
||||||
|
attempt = 0
|
||||||
|
while True:
|
||||||
|
candidate = parent / f".{old_path.name}.tanin-rename-{os.getpid()}-{attempt}.tmp"
|
||||||
|
if not candidate.exists():
|
||||||
|
temp_path = candidate
|
||||||
|
break
|
||||||
|
attempt += 1
|
||||||
|
try:
|
||||||
|
os.rename(old_path, temp_path)
|
||||||
|
except OSError as exc:
|
||||||
|
errors.append((old_path, str(exc)))
|
||||||
|
continue
|
||||||
|
temp_map.append((old_path, temp_path, final_path))
|
||||||
|
|
||||||
|
# Phase 2: move temp -> final.
|
||||||
|
for old_path, temp_path, final_path in temp_map:
|
||||||
|
try:
|
||||||
|
if final_path.exists():
|
||||||
|
# Collision appeared (something outside our control); roll back this one.
|
||||||
|
os.rename(temp_path, old_path)
|
||||||
|
errors.append((old_path, f"Ziel existiert bereits: {final_path.name}"))
|
||||||
|
continue
|
||||||
|
os.rename(temp_path, final_path)
|
||||||
|
renamed.append((old_path, final_path))
|
||||||
|
except OSError as exc:
|
||||||
|
# Try to roll back to the original name.
|
||||||
|
try:
|
||||||
|
os.rename(temp_path, old_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
errors.append((old_path, str(exc)))
|
||||||
|
|
||||||
|
GLib.idle_add(on_done, renamed, errors)
|
||||||
|
|
||||||
|
|
||||||
|
def apply(paths: List[Path], rule: RenameRule,
|
||||||
|
on_done: Callable[[List[Tuple[Path, Path]], List[Tuple[Path, str]]], None]) -> None:
|
||||||
|
"""Apply the rename in a background thread; call on_done via GLib.idle_add."""
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=_do_apply, args=(paths, rule, on_done), daemon=True
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""System-Zwischenablage für Dateien (Copy/Cut/Paste).
|
||||||
|
|
||||||
|
Interoperiert mit GNOME Files (Nautilus) und anderen Anwendungen über die
|
||||||
|
De-facto-Standardformate:
|
||||||
|
|
||||||
|
- ``x-special/gnome-copied-files``: erste Zeile ``copy`` oder ``cut``, danach
|
||||||
|
je eine ``file://``-URI pro Zeile (ohne abschließenden Zeilenumbruch).
|
||||||
|
- ``text/uri-list``: die URIs durch ``\\r\\n`` getrennt (für generische Apps).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, List
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gdk", "4.0")
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
|
||||||
|
from gi.repository import Gdk, Gio, GLib, Gtk # noqa: E402
|
||||||
|
|
||||||
|
# MIME-Typen, die wir anbieten und lesen.
|
||||||
|
_MIME_GNOME = "x-special/gnome-copied-files"
|
||||||
|
_MIME_URILIST = "text/uri-list"
|
||||||
|
|
||||||
|
|
||||||
|
def _paths_to_uris(paths: List[Path]) -> List[str]:
|
||||||
|
"""Wandelt Pfade in ``file://``-URIs um (überspringt unkonvertierbare)."""
|
||||||
|
uris: List[str] = []
|
||||||
|
for p in paths:
|
||||||
|
uri = Gio.File.new_for_path(str(p)).get_uri()
|
||||||
|
if uri:
|
||||||
|
uris.append(uri)
|
||||||
|
return uris
|
||||||
|
|
||||||
|
|
||||||
|
def _uri_to_path(uri: str) -> Path | None:
|
||||||
|
"""Wandelt eine ``file://``-URI zurück in einen Pfad (oder ``None``)."""
|
||||||
|
path = Gio.File.new_for_uri(uri).get_path()
|
||||||
|
return Path(path) if path else None
|
||||||
|
|
||||||
|
|
||||||
|
class FileClipboard:
|
||||||
|
"""Kapselt eine ``Gdk.Clipboard`` für Datei-Copy/Cut/Paste."""
|
||||||
|
|
||||||
|
def __init__(self, clipboard: Gdk.Clipboard) -> None:
|
||||||
|
# Die vom Aufrufer übergebene Zwischenablage merken
|
||||||
|
# (z. B. ``display.get_clipboard()``).
|
||||||
|
self._clipboard = clipboard
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Schreiben
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _set(self, operation: str, paths: List[Path]) -> None:
|
||||||
|
"""Legt die Pfade als ``operation`` (copy/cut) in beiden Formaten ab."""
|
||||||
|
uris = _paths_to_uris(paths)
|
||||||
|
|
||||||
|
# x-special/gnome-copied-files: Op-Zeile + URIs, durch \n getrennt,
|
||||||
|
# ohne abschließenden Zeilenumbruch.
|
||||||
|
gnome_payload = "\n".join([operation, *uris]).encode("utf-8")
|
||||||
|
|
||||||
|
# text/uri-list: URIs durch \r\n getrennt.
|
||||||
|
urilist_payload = "\r\n".join(uris).encode("utf-8")
|
||||||
|
|
||||||
|
prov_gnome = Gdk.ContentProvider.new_for_bytes(
|
||||||
|
_MIME_GNOME, GLib.Bytes.new(gnome_payload)
|
||||||
|
)
|
||||||
|
prov_urilist = Gdk.ContentProvider.new_for_bytes(
|
||||||
|
_MIME_URILIST, GLib.Bytes.new(urilist_payload)
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = Gdk.ContentProvider.new_union([prov_gnome, prov_urilist])
|
||||||
|
self._clipboard.set_content(provider)
|
||||||
|
|
||||||
|
def copy(self, paths: List[Path]) -> None:
|
||||||
|
"""Legt die Pfade als COPY-Operation in die Zwischenablage."""
|
||||||
|
self._set("copy", paths)
|
||||||
|
|
||||||
|
def cut(self, paths: List[Path]) -> None:
|
||||||
|
"""Legt die Pfade als CUT/MOVE-Operation in die Zwischenablage."""
|
||||||
|
self._set("cut", paths)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Lesen
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def has_files(self) -> bool:
|
||||||
|
"""True, wenn die Zwischenablage eines unserer Datei-Formate anbietet."""
|
||||||
|
try:
|
||||||
|
formats = self._clipboard.get_formats()
|
||||||
|
return formats.contain_mime_type(_MIME_GNOME) or formats.contain_mime_type(
|
||||||
|
_MIME_URILIST
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self, callback: Callable[[List[Path], bool], None]) -> None:
|
||||||
|
"""Liest die Zwischenablage asynchron und ruft ``callback(paths, is_cut)``.
|
||||||
|
|
||||||
|
Bevorzugt ``x-special/gnome-copied-files`` (liefert Operation + Pfade),
|
||||||
|
fällt auf ``text/uri-list`` zurück (Operation = copy). Liefert nichts
|
||||||
|
Brauchbares vor, wird ``callback([], False)`` aufgerufen. Wirft nie.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def deliver(paths: List[Path], is_cut: bool) -> None:
|
||||||
|
try:
|
||||||
|
callback(paths, is_cut)
|
||||||
|
except Exception:
|
||||||
|
# Fehler im Nutzer-Callback nicht weiterreichen.
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_read(clipboard: Gdk.Clipboard, result: Gio.AsyncResult) -> None:
|
||||||
|
try:
|
||||||
|
stream, mime = clipboard.read_finish(result)
|
||||||
|
except GLib.Error:
|
||||||
|
# Nichts Brauchbares in der Zwischenablage.
|
||||||
|
deliver([], False)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
deliver([], False)
|
||||||
|
return
|
||||||
|
|
||||||
|
if stream is None:
|
||||||
|
deliver([], False)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Den Stream asynchron auslesen, damit der Mainloop nicht
|
||||||
|
# blockiert (synchrones Lesen der eigenen Auswahl kann unter
|
||||||
|
# Wayland deadlocken).
|
||||||
|
self._read_all_async(
|
||||||
|
stream, lambda data: deliver(*self._parse(data, mime))
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._clipboard.read_async(
|
||||||
|
[_MIME_GNOME, _MIME_URILIST],
|
||||||
|
GLib.PRIORITY_DEFAULT,
|
||||||
|
None,
|
||||||
|
on_read,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Asynchrone Anforderung über den Mainloop nachreichen.
|
||||||
|
GLib.idle_add(lambda: (deliver([], False), False)[1])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_all_async(
|
||||||
|
stream: Gio.InputStream, done: Callable[[bytes], None]
|
||||||
|
) -> None:
|
||||||
|
"""Liest den gesamten Stream asynchron, ruft ``done(bytes)``."""
|
||||||
|
chunks: List[bytes] = []
|
||||||
|
|
||||||
|
def on_chunk(s: Gio.InputStream, result: Gio.AsyncResult) -> None:
|
||||||
|
try:
|
||||||
|
buf = s.read_bytes_finish(result)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
s.close(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
done(b"".join(chunks))
|
||||||
|
return
|
||||||
|
|
||||||
|
size = buf.get_size() if buf is not None else 0
|
||||||
|
if size == 0:
|
||||||
|
# Ende des Streams.
|
||||||
|
try:
|
||||||
|
s.close(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
done(b"".join(chunks))
|
||||||
|
return
|
||||||
|
|
||||||
|
chunks.append(buf.get_data() or b"")
|
||||||
|
s.read_bytes_async(65536, GLib.PRIORITY_DEFAULT, None, on_chunk)
|
||||||
|
|
||||||
|
stream.read_bytes_async(65536, GLib.PRIORITY_DEFAULT, None, on_chunk)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse(data: bytes, mime: str) -> tuple[List[Path], bool]:
|
||||||
|
"""Parst die Rohdaten je nach ausgewähltem MIME-Typ."""
|
||||||
|
text = data.decode("utf-8", errors="replace")
|
||||||
|
is_cut = False
|
||||||
|
uri_lines: List[str]
|
||||||
|
|
||||||
|
if mime == _MIME_GNOME:
|
||||||
|
# Erste nicht-leere Zeile ist die Operation.
|
||||||
|
lines = text.split("\n")
|
||||||
|
op = ""
|
||||||
|
rest_start = 0
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.strip():
|
||||||
|
op = line.strip().lower()
|
||||||
|
rest_start = i + 1
|
||||||
|
break
|
||||||
|
is_cut = op == "cut"
|
||||||
|
uri_lines = lines[rest_start:]
|
||||||
|
else:
|
||||||
|
# text/uri-list: durch \r\n (oder \n) getrennt, evtl. #-Kommentare.
|
||||||
|
uri_lines = text.replace("\r\n", "\n").split("\n")
|
||||||
|
|
||||||
|
paths: List[Path] = []
|
||||||
|
for line in uri_lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
p = _uri_to_path(line)
|
||||||
|
if p is not None:
|
||||||
|
paths.append(p)
|
||||||
|
return paths, is_cut
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Favoriten-Verwaltung für die Dateimanager-Seitenleiste.
|
||||||
|
|
||||||
|
Speichert benutzererstellte Lesezeichen ("Favoriten") als JSON und
|
||||||
|
benachrichtigt registrierte Beobachter, sobald sich die Liste ändert,
|
||||||
|
damit die Seitenleiste neu aufgebaut werden kann.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import GLib # noqa: E402
|
||||||
|
|
||||||
|
_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _config_path() -> Path:
|
||||||
|
"""Pfad zur JSON-Datei mit den gespeicherten Lesezeichen."""
|
||||||
|
return Path(GLib.get_user_config_dir()) / "taninux" / "files-bookmarks.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(path: Path) -> Path:
|
||||||
|
"""Pfad auf eine vergleichbare, absolute Form bringen.
|
||||||
|
|
||||||
|
Verwendet expanduser().resolve(); bei kaputten Pfaden wird auf eine
|
||||||
|
einfache absolute Form zurückgegriffen, ohne eine Ausnahme auszulösen.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return Path(path).expanduser().resolve()
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
try:
|
||||||
|
return Path(path).expanduser().absolute()
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
return Path(path)
|
||||||
|
|
||||||
|
|
||||||
|
class Favorites:
|
||||||
|
"""Persistente Sammlung von Seitenleisten-Lesezeichen."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._paths: list[Path] = []
|
||||||
|
self._callbacks: list[Callable[[], None]] = []
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Persistenz
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _load(self) -> None:
|
||||||
|
"""Lesezeichen von der Platte laden; bei Fehlern leer starten."""
|
||||||
|
path = _config_path()
|
||||||
|
try:
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
self._paths = []
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
self._paths = []
|
||||||
|
return
|
||||||
|
|
||||||
|
entries = data.get("paths")
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
self._paths = []
|
||||||
|
return
|
||||||
|
|
||||||
|
result: list[Path] = []
|
||||||
|
seen: set[Path] = set()
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, str):
|
||||||
|
continue
|
||||||
|
p = _norm(Path(entry))
|
||||||
|
if p in seen:
|
||||||
|
continue
|
||||||
|
seen.add(p)
|
||||||
|
result.append(p)
|
||||||
|
self._paths = result
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
"""Aktuellen Zustand als JSON schreiben (Verzeichnis bei Bedarf anlegen)."""
|
||||||
|
path = _config_path()
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"version": _VERSION,
|
||||||
|
"paths": [str(p) for p in self._paths],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
text = json.dumps(payload, indent=2, ensure_ascii=False)
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
try:
|
||||||
|
tmp.write_text(text, encoding="utf-8")
|
||||||
|
tmp.replace(path)
|
||||||
|
except OSError:
|
||||||
|
# Fallback: einfacher direkter Schreibvorgang.
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Beobachter
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def connect(self, callback: Callable[[], None]) -> None:
|
||||||
|
"""Beobachter registrieren, der nach jedem add/remove aufgerufen wird."""
|
||||||
|
self._callbacks.append(callback)
|
||||||
|
|
||||||
|
def _notify(self) -> None:
|
||||||
|
for cb in list(self._callbacks):
|
||||||
|
try:
|
||||||
|
cb()
|
||||||
|
except Exception:
|
||||||
|
# Ein fehlerhafter Beobachter darf die anderen nicht brechen.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Öffentliche API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def list(self) -> list[Path]:
|
||||||
|
"""Aktuelle Lesezeichen in Reihenfolge; nur existierende Verzeichnisse."""
|
||||||
|
result: list[Path] = []
|
||||||
|
for p in self._paths:
|
||||||
|
try:
|
||||||
|
if p.is_dir():
|
||||||
|
result.append(p)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
|
def contains(self, path: Path) -> bool:
|
||||||
|
target = _norm(path)
|
||||||
|
return any(p == target for p in self._paths)
|
||||||
|
|
||||||
|
def add(self, path: Path) -> bool:
|
||||||
|
"""Lesezeichen anhängen, falls noch nicht vorhanden und ein Verzeichnis."""
|
||||||
|
target = _norm(path)
|
||||||
|
try:
|
||||||
|
if not target.is_dir():
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if any(p == target for p in self._paths):
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._paths.append(target)
|
||||||
|
self._save()
|
||||||
|
self._notify()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove(self, path: Path) -> bool:
|
||||||
|
"""Lesezeichen entfernen, falls vorhanden."""
|
||||||
|
target = _norm(path)
|
||||||
|
for i, p in enumerate(self._paths):
|
||||||
|
if p == target:
|
||||||
|
del self._paths[i]
|
||||||
|
self._save()
|
||||||
|
self._notify()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Dateioperationen für den Dateimanager.
|
||||||
|
|
||||||
|
Kopieren/Verschieben laufen in einem Hintergrund-Thread; der Abschluss wird
|
||||||
|
über GLib.idle_add zurück auf die GLib-Hauptschleife marshallt, damit UI-Code
|
||||||
|
sicher ausgeführt werden kann. Papierkorb, Umbenennen und Ordner-Erstellung
|
||||||
|
sind synchron.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gio, GLib # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def unique_target(dest_dir: Path, name: str) -> Path:
|
||||||
|
"""Liefert einen kollisionsfreien Pfad innerhalb von ``dest_dir``.
|
||||||
|
|
||||||
|
Existiert ``name`` bereits, wird vor der Erweiterung ein Suffix eingefügt:
|
||||||
|
``file.txt`` -> ``file copy.txt`` -> ``file copy 2.txt`` -> ...
|
||||||
|
Für Verzeichnisse / Namen ohne Erweiterung wird `` copy``, `` copy 2`` usw.
|
||||||
|
angehängt. Gibt niemals einen bereits existierenden Pfad zurück.
|
||||||
|
"""
|
||||||
|
dest_dir = Path(dest_dir)
|
||||||
|
candidate = dest_dir / name
|
||||||
|
if not candidate.exists():
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
p = Path(name)
|
||||||
|
suffix = p.suffix
|
||||||
|
stem = p.name[: len(p.name) - len(suffix)] if suffix else p.name
|
||||||
|
|
||||||
|
n = 1
|
||||||
|
while True:
|
||||||
|
if n == 1:
|
||||||
|
new_stem = f"{stem} copy"
|
||||||
|
else:
|
||||||
|
new_stem = f"{stem} copy {n}"
|
||||||
|
candidate = dest_dir / f"{new_stem}{suffix}"
|
||||||
|
if not candidate.exists():
|
||||||
|
return candidate
|
||||||
|
n += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _is_into_subtree(src: Path, dest_dir: Path) -> bool:
|
||||||
|
"""True, wenn ``dest_dir`` gleich ``src`` ist oder innerhalb von ``src`` liegt."""
|
||||||
|
src_r = src.resolve()
|
||||||
|
dest_r = dest_dir.resolve()
|
||||||
|
if src_r == dest_r:
|
||||||
|
return True
|
||||||
|
return src_r in dest_r.parents
|
||||||
|
|
||||||
|
|
||||||
|
def _do_copy(
|
||||||
|
sources: list[Path],
|
||||||
|
dest_dir: Path,
|
||||||
|
move: bool,
|
||||||
|
on_done: Callable[[list[Path], list[tuple[Path, str]]], None] | None,
|
||||||
|
) -> None:
|
||||||
|
succeeded: list[Path] = []
|
||||||
|
errors: list[tuple[Path, str]] = []
|
||||||
|
|
||||||
|
for src in sources:
|
||||||
|
try:
|
||||||
|
if not src.exists():
|
||||||
|
errors.append((src, "Quelle existiert nicht"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# No-op move: src already lives in dest_dir.
|
||||||
|
if move and src.parent.resolve() == dest_dir.resolve():
|
||||||
|
continue
|
||||||
|
|
||||||
|
if src.is_dir() and _is_into_subtree(src, dest_dir):
|
||||||
|
raise ValueError(
|
||||||
|
"Verzeichnis kann nicht in sich selbst kopiert werden"
|
||||||
|
)
|
||||||
|
|
||||||
|
target = unique_target(dest_dir, src.name)
|
||||||
|
|
||||||
|
if move:
|
||||||
|
shutil.move(str(src), str(target))
|
||||||
|
elif src.is_dir():
|
||||||
|
shutil.copytree(str(src), str(target))
|
||||||
|
else:
|
||||||
|
shutil.copy2(str(src), str(target))
|
||||||
|
|
||||||
|
succeeded.append(target)
|
||||||
|
except Exception as exc: # noqa: BLE001 - record, never crash
|
||||||
|
errors.append((src, str(exc)))
|
||||||
|
|
||||||
|
if on_done is not None:
|
||||||
|
GLib.idle_add(lambda: (on_done(succeeded, errors), False)[1])
|
||||||
|
|
||||||
|
|
||||||
|
def copy_paths(
|
||||||
|
sources: Iterable[Path],
|
||||||
|
dest_dir: Path,
|
||||||
|
*,
|
||||||
|
move: bool,
|
||||||
|
on_done: Callable[[list[Path], list[tuple[Path, str]]], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Kopiert/verschiebt ``sources`` nach ``dest_dir`` im Hintergrund-Thread.
|
||||||
|
|
||||||
|
Für jede Quelle wird ein kollisionssicheres Ziel via
|
||||||
|
:func:`unique_target` bestimmt. ``on_done(succeeded, errors)`` wird nach
|
||||||
|
Abschluss über ``GLib.idle_add`` auf der Hauptschleife aufgerufen.
|
||||||
|
"""
|
||||||
|
srcs = [Path(s) for s in sources]
|
||||||
|
dest = Path(dest_dir)
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=_do_copy,
|
||||||
|
args=(srcs, dest, move, on_done),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def trash_paths(paths: Iterable[Path]) -> list[tuple[Path, str]]:
|
||||||
|
"""Verschiebt jeden Pfad synchron in den Papierkorb.
|
||||||
|
|
||||||
|
Gibt eine Liste von ``(path, message)`` für Fehlschläge zurück
|
||||||
|
(leere Liste = alles ok).
|
||||||
|
"""
|
||||||
|
errors: list[tuple[Path, str]] = []
|
||||||
|
for p in paths:
|
||||||
|
p = Path(p)
|
||||||
|
try:
|
||||||
|
Gio.File.new_for_path(str(p)).trash(None)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
errors.append((p, str(exc)))
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def delete_paths(paths: Iterable[Path]) -> list[tuple[Path, str]]:
|
||||||
|
"""Löscht jeden Pfad endgültig (rekursiv für Ordner).
|
||||||
|
|
||||||
|
Gibt eine Liste von ``(path, message)`` für Fehlschläge zurück
|
||||||
|
(leere Liste = alles ok).
|
||||||
|
"""
|
||||||
|
errors: list[tuple[Path, str]] = []
|
||||||
|
for p in paths:
|
||||||
|
p = Path(p)
|
||||||
|
try:
|
||||||
|
if p.is_dir() and not p.is_symlink():
|
||||||
|
shutil.rmtree(str(p))
|
||||||
|
else:
|
||||||
|
p.unlink()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
errors.append((p, str(exc)))
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def rename_path(path: Path, new_name: str) -> tuple[bool, str]:
|
||||||
|
"""Benennt ``path`` innerhalb desselben Elternverzeichnisses um.
|
||||||
|
|
||||||
|
Leere Namen oder Namen mit ``/`` werden abgelehnt.
|
||||||
|
Gibt ``(True, "")`` bei Erfolg, sonst ``(False, message)`` zurück.
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
name = (new_name or "").strip()
|
||||||
|
if not name:
|
||||||
|
return (False, "Leerer Name")
|
||||||
|
if "/" in name:
|
||||||
|
return (False, 'Name darf kein "/" enthalten')
|
||||||
|
try:
|
||||||
|
target = path.parent / name
|
||||||
|
if target.exists():
|
||||||
|
return (False, "Ziel existiert bereits")
|
||||||
|
path.rename(target)
|
||||||
|
return (True, "")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return (False, str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def make_dir(parent: Path, name: str) -> tuple[bool, str]:
|
||||||
|
"""Erstellt ``parent/name`` (ohne Eltern, darf nicht existieren).
|
||||||
|
|
||||||
|
Gibt ``(True, "")`` / ``(False, message)`` zurück.
|
||||||
|
"""
|
||||||
|
parent = Path(parent)
|
||||||
|
nm = (name or "").strip()
|
||||||
|
if not nm:
|
||||||
|
return (False, "Leerer Name")
|
||||||
|
if "/" in nm:
|
||||||
|
return (False, 'Name darf kein "/" enthalten')
|
||||||
|
try:
|
||||||
|
(parent / nm).mkdir(parents=False, exist_ok=False)
|
||||||
|
return (True, "")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return (False, str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def make_file(parent: Path, name: str) -> tuple[bool, str]:
|
||||||
|
"""Erstellt eine leere Datei ``parent/name`` (darf nicht existieren).
|
||||||
|
|
||||||
|
Gibt ``(True, "")`` / ``(False, message)`` zurück.
|
||||||
|
"""
|
||||||
|
parent = Path(parent)
|
||||||
|
nm = (name or "").strip()
|
||||||
|
if not nm:
|
||||||
|
return (False, "Leerer Name")
|
||||||
|
if "/" in nm:
|
||||||
|
return (False, 'Name darf kein "/" enthalten')
|
||||||
|
try:
|
||||||
|
(parent / nm).touch(exist_ok=False)
|
||||||
|
return (True, "")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return (False, str(exc))
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Datenmodell für den Dateimanager.
|
||||||
|
|
||||||
|
Stellt :class:`FileItem`-Objekte bereit, an die eine ``Gtk.GridView``- bzw.
|
||||||
|
``Gtk.ColumnView``-Factory binden kann, sowie ``list_directory`` zum
|
||||||
|
Auflisten eines Verzeichnisses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gi.repository import Gio, GLib, GObject
|
||||||
|
|
||||||
|
# Gio-Abfrageattribute, die beim Enumerieren angefordert werden.
|
||||||
|
ATTRS = ",".join(
|
||||||
|
[
|
||||||
|
"standard::name",
|
||||||
|
"standard::display-name",
|
||||||
|
"standard::icon",
|
||||||
|
"standard::type",
|
||||||
|
"standard::is-hidden",
|
||||||
|
"standard::is-backup",
|
||||||
|
"standard::content-type",
|
||||||
|
"standard::size",
|
||||||
|
"time::modified",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
_UNITS = ("bytes", "KB", "MB", "GB", "TB")
|
||||||
|
|
||||||
|
|
||||||
|
class FileItem(GObject.Object):
|
||||||
|
"""Ein einzelner Eintrag (Datei oder Ordner) im Dateimanager."""
|
||||||
|
|
||||||
|
__gtype_name__ = "TaninFileItem"
|
||||||
|
|
||||||
|
name = GObject.Property(type=str, default="")
|
||||||
|
path = GObject.Property(type=str, default="")
|
||||||
|
is_dir = GObject.Property(type=bool, default=False)
|
||||||
|
size = GObject.Property(type=GObject.TYPE_INT64, default=0)
|
||||||
|
mtime = GObject.Property(type=GObject.TYPE_INT64, default=0)
|
||||||
|
content_type = GObject.Property(type=str, default="")
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
# Kein GObject.Property: Gio.Icon lässt sich bequemer direkt setzen.
|
||||||
|
self.icon = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_info(cls, info: Gio.FileInfo, parent: Path) -> "FileItem":
|
||||||
|
name = info.get_name()
|
||||||
|
display_name = info.get_display_name() or name
|
||||||
|
is_dir = info.get_file_type() == Gio.FileType.DIRECTORY
|
||||||
|
|
||||||
|
dt = info.get_modification_date_time()
|
||||||
|
mtime = dt.to_unix() if dt is not None else 0
|
||||||
|
|
||||||
|
item = cls(
|
||||||
|
name=display_name,
|
||||||
|
path=str(parent / name),
|
||||||
|
is_dir=is_dir,
|
||||||
|
size=0 if is_dir else info.get_size(),
|
||||||
|
mtime=mtime,
|
||||||
|
content_type=info.get_content_type() or "",
|
||||||
|
)
|
||||||
|
item.icon = info.get_icon()
|
||||||
|
return item
|
||||||
|
|
||||||
|
def as_path(self) -> Path:
|
||||||
|
return Path(self.path)
|
||||||
|
|
||||||
|
def size_text(self) -> str:
|
||||||
|
if self.is_dir:
|
||||||
|
return "—"
|
||||||
|
value = float(self.size)
|
||||||
|
unit_index = 0
|
||||||
|
while value >= 1024 and unit_index < len(_UNITS) - 1:
|
||||||
|
value /= 1024
|
||||||
|
unit_index += 1
|
||||||
|
if unit_index == 0:
|
||||||
|
return f"{int(value)} {_UNITS[0]}"
|
||||||
|
if value >= 10:
|
||||||
|
return f"{value:.0f} {_UNITS[unit_index]}"
|
||||||
|
return f"{value:.1f} {_UNITS[unit_index]}"
|
||||||
|
|
||||||
|
def mtime_text(self) -> str:
|
||||||
|
if self.mtime <= 0:
|
||||||
|
return ""
|
||||||
|
dt = GLib.DateTime.new_from_unix_local(self.mtime)
|
||||||
|
if dt is None:
|
||||||
|
return ""
|
||||||
|
return dt.format("%d %b %Y %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def list_directory(path: Path, show_hidden: bool) -> list[FileItem]:
|
||||||
|
"""Listet die Kinder von ``path`` auf.
|
||||||
|
|
||||||
|
Versteckte/Backup-Einträge werden ohne ``show_hidden`` übersprungen.
|
||||||
|
Sortierung: Verzeichnisse zuerst, danach alphabetisch (case-insensitive).
|
||||||
|
Bei nicht lesbarem Verzeichnis wird ``[]`` zurückgegeben.
|
||||||
|
"""
|
||||||
|
gfile = Gio.File.new_for_path(str(path))
|
||||||
|
try:
|
||||||
|
enumerator = gfile.enumerate_children(
|
||||||
|
ATTRS, Gio.FileQueryInfoFlags.NONE, None
|
||||||
|
)
|
||||||
|
except GLib.Error:
|
||||||
|
return []
|
||||||
|
|
||||||
|
items: list[FileItem] = []
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
info = enumerator.next_file(None)
|
||||||
|
except GLib.Error:
|
||||||
|
break
|
||||||
|
if info is None:
|
||||||
|
break
|
||||||
|
if not show_hidden and (info.get_is_hidden() or info.get_is_backup()):
|
||||||
|
continue
|
||||||
|
items.append(FileItem.from_info(info, path))
|
||||||
|
finally:
|
||||||
|
enumerator.close(None)
|
||||||
|
|
||||||
|
items.sort(key=lambda i: (not i.is_dir, i.name.casefold()))
|
||||||
|
return items
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Sidebar-Orte: XDG-Standardverzeichnisse + eingehängte Laufwerke.
|
||||||
|
|
||||||
|
Liefert reine Datenstrukturen (Icon, Label, Pfad) — die Window-Schicht baut
|
||||||
|
daraus die Sidebar-Zeilen. So bleibt die Ortelogik testbar und GTK-arm.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gio, GLib # noqa: E402
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Place:
|
||||||
|
icon: str
|
||||||
|
label: str
|
||||||
|
path: Path
|
||||||
|
|
||||||
|
|
||||||
|
# (XDG-Enum, Icon-Name, Anzeigename) — fehlende/auf $HOME zeigende werden gefiltert.
|
||||||
|
_SPECIALS = [
|
||||||
|
(GLib.UserDirectory.DIRECTORY_DESKTOP, "user-desktop-symbolic", "Desktop"),
|
||||||
|
(GLib.UserDirectory.DIRECTORY_DOCUMENTS, "folder-documents-symbolic", "Documents"),
|
||||||
|
(GLib.UserDirectory.DIRECTORY_DOWNLOAD, "folder-download-symbolic", "Downloads"),
|
||||||
|
(GLib.UserDirectory.DIRECTORY_MUSIC, "folder-music-symbolic", "Music"),
|
||||||
|
(GLib.UserDirectory.DIRECTORY_PICTURES, "folder-pictures-symbolic", "Pictures"),
|
||||||
|
(GLib.UserDirectory.DIRECTORY_VIDEOS, "folder-videos-symbolic", "Videos"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def user_places() -> List[Place]:
|
||||||
|
"""Home + vorhandene XDG-Sonderverzeichnisse (ohne Dubletten zu Home)."""
|
||||||
|
home = Path.home()
|
||||||
|
out = [Place("user-home-symbolic", "Home", home)]
|
||||||
|
for directory, icon, label in _SPECIALS:
|
||||||
|
raw = GLib.get_user_special_dir(directory)
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
path = Path(raw)
|
||||||
|
if path == home or not path.is_dir():
|
||||||
|
continue
|
||||||
|
out.append(Place(icon, label, path))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def mounted_places() -> List[Place]:
|
||||||
|
"""Eingehängte, vom Nutzer entfernbare/sichtbare Laufwerke (USB, Platten…)."""
|
||||||
|
out: List[Place] = []
|
||||||
|
try:
|
||||||
|
monitor = Gio.VolumeMonitor.get()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("VolumeMonitor nicht verfügbar", exc_info=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
for mount in monitor.get_mounts():
|
||||||
|
try:
|
||||||
|
if mount.is_shadowed():
|
||||||
|
continue
|
||||||
|
root = mount.get_root()
|
||||||
|
path = root.get_path() if root is not None else None
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
out.append(Place("drive-removable-media-symbolic", mount.get_name(), Path(path)))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("Mount übersprungen", exc_info=True)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""Quick-Look-Vorschaufläche für den TANINUX-Dateimanager.
|
||||||
|
|
||||||
|
Zeigt eine einzelne Datei je nach Typ groß an (Bild / Text / PDF / generisch).
|
||||||
|
Schweres Laden geschieht in Hintergrund-Threads; ein Token-Guard sorgt dafür,
|
||||||
|
dass ein langsamer Ladevorgang für einen alten Pfad niemals einen neueren
|
||||||
|
Request überschreibt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("Gdk", "4.0")
|
||||||
|
gi.require_version("GdkPixbuf", "2.0")
|
||||||
|
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk
|
||||||
|
|
||||||
|
try:
|
||||||
|
gi.require_version("Poppler", "0.18")
|
||||||
|
from gi.repository import Poppler
|
||||||
|
except (ValueError, ImportError):
|
||||||
|
Poppler = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cairo
|
||||||
|
except ImportError:
|
||||||
|
cairo = None
|
||||||
|
|
||||||
|
|
||||||
|
# Allowlist textueller Content-Types (zusätzlich zu allem unter "text/").
|
||||||
|
_TEXTUAL_TYPES = {
|
||||||
|
"application/json",
|
||||||
|
"application/xml",
|
||||||
|
"application/javascript",
|
||||||
|
"application/x-shellscript",
|
||||||
|
"application/x-yaml",
|
||||||
|
"application/toml",
|
||||||
|
"application/x-desktop",
|
||||||
|
"application/x-python",
|
||||||
|
"application/x-perl",
|
||||||
|
"application/x-ruby",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dateiendungen, die wir als Text behandeln, auch wenn der Content-Type generisch ist.
|
||||||
|
_TEXTUAL_SUFFIXES = {
|
||||||
|
".py", ".js", ".ts", ".rs", ".c", ".h", ".cpp", ".go", ".sh", ".md",
|
||||||
|
".txt", ".json", ".toml", ".yaml", ".yml", ".xml", ".html", ".css",
|
||||||
|
".ini", ".conf", ".log",
|
||||||
|
}
|
||||||
|
|
||||||
|
_MAX_TEXT_BYTES = 100_000
|
||||||
|
_IMG_MAX = 1600
|
||||||
|
_PDF_TARGET = 1200
|
||||||
|
|
||||||
|
|
||||||
|
def _is_textual(content_type: str, path: Path) -> bool:
|
||||||
|
"""True, wenn der Inhalt sinnvoll als Text vorschaubar ist."""
|
||||||
|
ct = (content_type or "").lower()
|
||||||
|
if ct.startswith("text/"):
|
||||||
|
return True
|
||||||
|
if ct in _TEXTUAL_TYPES:
|
||||||
|
return True
|
||||||
|
if "+xml" in ct or "+json" in ct:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
if path.suffix.lower() in _TEXTUAL_SUFFIXES:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _humanize_size(num: int) -> str:
|
||||||
|
"""Größe in 1024er-Schritten (B/KB/MB/GB/TB)."""
|
||||||
|
size = float(num)
|
||||||
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||||
|
if abs(size) < 1024.0 or unit == "TB":
|
||||||
|
if unit == "B":
|
||||||
|
return f"{int(size)} {unit}"
|
||||||
|
return f"{size:.1f} {unit}"
|
||||||
|
size /= 1024.0
|
||||||
|
return f"{size:.1f} TB"
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewPane(Gtk.Box):
|
||||||
|
"""Quick-Look-Vorschaufläche: zeigt eine Datei je nach Typ groß an."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
self.add_css_class("tn-preview")
|
||||||
|
self.set_hexpand(True)
|
||||||
|
self.set_vexpand(True)
|
||||||
|
|
||||||
|
self._token = 0
|
||||||
|
|
||||||
|
self._stack = Gtk.Stack()
|
||||||
|
self._stack.set_hexpand(True)
|
||||||
|
self._stack.set_vexpand(True)
|
||||||
|
self.append(self._stack)
|
||||||
|
|
||||||
|
# --- "image" ---------------------------------------------------------
|
||||||
|
self._picture = Gtk.Picture()
|
||||||
|
self._picture.set_content_fit(Gtk.ContentFit.CONTAIN)
|
||||||
|
self._picture.set_hexpand(True)
|
||||||
|
self._picture.set_vexpand(True)
|
||||||
|
self._stack.add_named(self._picture, "image")
|
||||||
|
|
||||||
|
# --- "text" ----------------------------------------------------------
|
||||||
|
self._scroller = Gtk.ScrolledWindow()
|
||||||
|
self._scroller.set_hexpand(True)
|
||||||
|
self._scroller.set_vexpand(True)
|
||||||
|
self._scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||||
|
self._textview = Gtk.TextView()
|
||||||
|
self._textview.set_editable(False)
|
||||||
|
self._textview.set_monospace(True)
|
||||||
|
self._textview.set_cursor_visible(False)
|
||||||
|
self._textview.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
|
||||||
|
self._scroller.set_child(self._textview)
|
||||||
|
self._stack.add_named(self._scroller, "text")
|
||||||
|
|
||||||
|
# --- "generic" -------------------------------------------------------
|
||||||
|
generic = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||||
|
generic.set_hexpand(True)
|
||||||
|
generic.set_vexpand(True)
|
||||||
|
generic.set_halign(Gtk.Align.CENTER)
|
||||||
|
generic.set_valign(Gtk.Align.CENTER)
|
||||||
|
|
||||||
|
self._gen_icon = Gtk.Image()
|
||||||
|
self._gen_icon.set_pixel_size(128)
|
||||||
|
generic.append(self._gen_icon)
|
||||||
|
|
||||||
|
self._gen_name = Gtk.Label()
|
||||||
|
self._gen_name.add_css_class("tn-preview-name")
|
||||||
|
self._gen_name.set_wrap(True)
|
||||||
|
self._gen_name.set_justify(Gtk.Justification.CENTER)
|
||||||
|
generic.append(self._gen_name)
|
||||||
|
|
||||||
|
self._gen_type = Gtk.Label()
|
||||||
|
self._gen_type.add_css_class("tn-preview-meta")
|
||||||
|
generic.append(self._gen_type)
|
||||||
|
|
||||||
|
self._gen_size = Gtk.Label()
|
||||||
|
self._gen_size.add_css_class("tn-preview-meta")
|
||||||
|
generic.append(self._gen_size)
|
||||||
|
|
||||||
|
self._stack.add_named(generic, "generic")
|
||||||
|
|
||||||
|
self._stack.set_visible_child_name("generic")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ public
|
||||||
|
def show_path(self, path: Path, content_type: str) -> None:
|
||||||
|
"""Aktualisiert die Vorschau auf `path`. Niemals werfend."""
|
||||||
|
try:
|
||||||
|
path = Path(path)
|
||||||
|
self._token += 1
|
||||||
|
token = self._token
|
||||||
|
content_type = content_type or ""
|
||||||
|
|
||||||
|
if content_type.startswith("image/"):
|
||||||
|
self._load_image_async(path, token)
|
||||||
|
elif content_type == "application/pdf" and Poppler is not None and cairo is not None:
|
||||||
|
self._load_pdf_async(path, token)
|
||||||
|
elif _is_textual(content_type, path):
|
||||||
|
self._load_text_async(path, token)
|
||||||
|
else:
|
||||||
|
self._show_generic(path, content_type)
|
||||||
|
except Exception:
|
||||||
|
# Defensive: niemals nach außen werfen.
|
||||||
|
try:
|
||||||
|
self._show_generic(Path(path), content_type or "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Zurück in einen neutralen Leerzustand; In-Flight-Loads verwerfen."""
|
||||||
|
self._token += 1
|
||||||
|
self._gen_icon.clear()
|
||||||
|
self._gen_name.set_text("")
|
||||||
|
self._gen_type.set_text("")
|
||||||
|
self._gen_size.set_text("")
|
||||||
|
self._stack.set_visible_child_name("generic")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- image
|
||||||
|
def _load_image_async(self, path: Path, token: int) -> None:
|
||||||
|
def work() -> None:
|
||||||
|
try:
|
||||||
|
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||||
|
str(path), _IMG_MAX, _IMG_MAX, True
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
GLib.idle_add(self._apply_generic_fallback, path, token)
|
||||||
|
return
|
||||||
|
GLib.idle_add(self._apply_pixbuf, pixbuf, token)
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
def _apply_pixbuf(self, pixbuf: GdkPixbuf.Pixbuf, token: int) -> bool:
|
||||||
|
if token != self._token:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
texture = Gdk.Texture.new_for_pixbuf(pixbuf)
|
||||||
|
self._picture.set_paintable(texture)
|
||||||
|
self._stack.set_visible_child_name("image")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- text
|
||||||
|
def _load_text_async(self, path: Path, token: int) -> None:
|
||||||
|
def work() -> None:
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
raw = fh.read(_MAX_TEXT_BYTES)
|
||||||
|
text = raw.decode("utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
GLib.idle_add(self._apply_generic_fallback, path, token)
|
||||||
|
return
|
||||||
|
GLib.idle_add(self._apply_text, text, token)
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
def _apply_text(self, text: str, token: int) -> bool:
|
||||||
|
if token != self._token:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self._textview.get_buffer().set_text(text)
|
||||||
|
self._stack.set_visible_child_name("text")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- pdf
|
||||||
|
def _load_pdf_async(self, path: Path, token: int) -> None:
|
||||||
|
def work() -> None:
|
||||||
|
try:
|
||||||
|
uri = Gio.File.new_for_path(str(path)).get_uri()
|
||||||
|
doc = Poppler.Document.new_from_file(uri, None)
|
||||||
|
page = doc.get_page(0)
|
||||||
|
if page is None:
|
||||||
|
raise ValueError("no page 0")
|
||||||
|
p_w, p_h = page.get_size()
|
||||||
|
if p_w <= 0 or p_h <= 0:
|
||||||
|
raise ValueError("bad page size")
|
||||||
|
scale = _PDF_TARGET / max(p_w, p_h)
|
||||||
|
w = max(1, int(p_w * scale))
|
||||||
|
h = max(1, int(p_h * scale))
|
||||||
|
|
||||||
|
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, w, h)
|
||||||
|
ctx = cairo.Context(surface)
|
||||||
|
# weißer Hintergrund (PDF-Seiten sind oft transparent)
|
||||||
|
ctx.set_source_rgb(1, 1, 1)
|
||||||
|
ctx.paint()
|
||||||
|
ctx.scale(scale, scale)
|
||||||
|
page.render(ctx)
|
||||||
|
surface.flush()
|
||||||
|
|
||||||
|
data = bytes(surface.get_data())
|
||||||
|
stride = surface.get_stride()
|
||||||
|
except Exception:
|
||||||
|
GLib.idle_add(self._apply_generic_fallback, path, token)
|
||||||
|
return
|
||||||
|
GLib.idle_add(self._apply_pdf, data, w, h, stride, token)
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
def _apply_pdf(self, data: bytes, w: int, h: int, stride: int, token: int) -> bool:
|
||||||
|
if token != self._token:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
gbytes = GLib.Bytes.new(data)
|
||||||
|
texture = Gdk.MemoryTexture.new(
|
||||||
|
w, h, Gdk.MemoryFormat.B8G8R8A8_PREMULTIPLIED, gbytes, stride
|
||||||
|
)
|
||||||
|
self._picture.set_paintable(texture)
|
||||||
|
self._stack.set_visible_child_name("image")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- generic
|
||||||
|
def _apply_generic_fallback(self, path: Path, token: int) -> bool:
|
||||||
|
if token != self._token:
|
||||||
|
return False
|
||||||
|
self._show_generic(path, "")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _show_generic(self, path: Path, content_type: str) -> None:
|
||||||
|
# Icon
|
||||||
|
try:
|
||||||
|
gfile = Gio.File.new_for_path(str(path))
|
||||||
|
info = gfile.query_info(
|
||||||
|
"standard::icon", Gio.FileQueryInfoFlags.NONE, None
|
||||||
|
)
|
||||||
|
icon = info.get_icon()
|
||||||
|
if icon is not None:
|
||||||
|
self._gen_icon.set_from_gicon(icon)
|
||||||
|
else:
|
||||||
|
self._gen_icon.set_from_icon_name("text-x-generic")
|
||||||
|
except Exception:
|
||||||
|
self._gen_icon.set_from_icon_name("text-x-generic")
|
||||||
|
|
||||||
|
# Name
|
||||||
|
try:
|
||||||
|
self._gen_name.set_text(path.name)
|
||||||
|
except Exception:
|
||||||
|
self._gen_name.set_text("")
|
||||||
|
|
||||||
|
# Typbeschreibung
|
||||||
|
desc = ""
|
||||||
|
try:
|
||||||
|
if content_type:
|
||||||
|
desc = Gio.content_type_get_description(content_type) or ""
|
||||||
|
except Exception:
|
||||||
|
desc = ""
|
||||||
|
self._gen_type.set_text(desc)
|
||||||
|
|
||||||
|
# Größe
|
||||||
|
size_text = ""
|
||||||
|
try:
|
||||||
|
size_text = _humanize_size(path.stat().st_size)
|
||||||
|
except Exception:
|
||||||
|
size_text = ""
|
||||||
|
self._gen_size.set_text(size_text)
|
||||||
|
|
||||||
|
self._stack.set_visible_child_name("generic")
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
"""Eigenschaften-Dialog für eine Datei oder einen Ordner.
|
||||||
|
|
||||||
|
Zeigt Typ, Größe, Ort, Änderungsdatum und Rechte in einem macOS-artigen
|
||||||
|
Karten-Layout (System-Settings-Optik). Nutzt das native, libadwaita-freie
|
||||||
|
Widget-Vokabular aus ``taninux.gui.ui.native`` (``Group`` / ``Row`` / die
|
||||||
|
``.tn-*``-CSS-Klassen) für visuelle Konsistenz mit der restlichen App.
|
||||||
|
|
||||||
|
Öffentliche API::
|
||||||
|
|
||||||
|
present_properties(parent: Gtk.Widget, path: Path) -> None
|
||||||
|
|
||||||
|
Die Ordnergröße wird rekursiv in einem Hintergrund-Thread berechnet (os.walk),
|
||||||
|
damit der Dialog nie blockiert; das Ergebnis wird per ``GLib.idle_add`` live in
|
||||||
|
die Zeile geschrieben.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat as _stat
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gio, GLib, Gtk # noqa: E402
|
||||||
|
|
||||||
|
from taninux.gui.ui import native # noqa: E402
|
||||||
|
|
||||||
|
# pwd/grp gibt es nur auf POSIX — defensiv importieren, damit der Modul-Import
|
||||||
|
# auf anderen Plattformen nicht scheitert.
|
||||||
|
try: # pragma: no cover - plattformabhängig
|
||||||
|
import pwd as _pwd
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_pwd = None
|
||||||
|
try: # pragma: no cover - plattformabhängig
|
||||||
|
import grp as _grp
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_grp = None
|
||||||
|
|
||||||
|
_DASH = "—" # Platzhalter, wenn ein Wert nicht ermittelbar ist.
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Hilfsfunktionen
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _humanize(size: int) -> str:
|
||||||
|
"""Bytes in eine lesbare Größe (1024er-Schritte, bytes/KB/MB/GB/TB)."""
|
||||||
|
value = float(size)
|
||||||
|
for unit in ("bytes", "KB", "MB", "GB", "TB"):
|
||||||
|
if value < 1024.0 or unit == "TB":
|
||||||
|
if unit == "bytes":
|
||||||
|
return f"{int(value)} {unit}"
|
||||||
|
return f"{value:.1f} {unit}"
|
||||||
|
value /= 1024.0
|
||||||
|
return f"{int(size)} bytes" # unerreichbar, aber robust
|
||||||
|
|
||||||
|
|
||||||
|
def _size_label(size: int) -> str:
|
||||||
|
"""„1.2 MB (1,234,567 bytes)" — humanisiert plus exakte Byte-Zahl."""
|
||||||
|
if size < 1024:
|
||||||
|
return _humanize(size)
|
||||||
|
return f"{_humanize(size)} ({size:,} bytes)"
|
||||||
|
|
||||||
|
|
||||||
|
def _icon_for(gfile: Gio.File) -> "Gio.Icon | None":
|
||||||
|
"""Gio.Icon aus ``standard::icon`` abfragen (oder None bei Fehler)."""
|
||||||
|
try:
|
||||||
|
info = gfile.query_info(
|
||||||
|
"standard::icon", Gio.FileQueryInfoFlags.NONE, None
|
||||||
|
)
|
||||||
|
return info.get_icon()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _content_type(path: Path, is_dir: bool) -> tuple[str, str]:
|
||||||
|
"""(content_type, beschreibung) für den Pfad ermitteln."""
|
||||||
|
if is_dir:
|
||||||
|
ctype = "inode/directory"
|
||||||
|
else:
|
||||||
|
guess = Gio.content_type_guess(str(path), None)
|
||||||
|
ctype = guess[0] if guess and guess[0] else "application/octet-stream"
|
||||||
|
try:
|
||||||
|
desc = Gio.content_type_get_description(ctype)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
desc = ctype
|
||||||
|
return ctype, desc or ctype
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_group(st: os.stat_result) -> str:
|
||||||
|
"""„user · group" aus uid/gid (Namen, wenn auflösbar)."""
|
||||||
|
user = str(st.st_uid)
|
||||||
|
group = str(st.st_gid)
|
||||||
|
if _pwd is not None:
|
||||||
|
try:
|
||||||
|
user = _pwd.getpwuid(st.st_uid).pw_name
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
if _grp is not None:
|
||||||
|
try:
|
||||||
|
group = _grp.getgrgid(st.st_gid).gr_name
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return f"{user} · {group}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_time(epoch: float) -> str:
|
||||||
|
"""Lokalisiertes Datum/Uhrzeit aus einem Unix-Timestamp."""
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(epoch).strftime("%c")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return _DASH
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Wert-Zeile (Titel links, Wert rechts) — auf Basis der nativen Row
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _value_row(title: str, value: str) -> tuple[native.Row, Gtk.Label]:
|
||||||
|
"""Eine Zeile mit fixem Titel und rechtsbündigem Wert-Label.
|
||||||
|
|
||||||
|
Gibt (Row, Wert-Label) zurück, damit der Aufrufer den Wert später (z. B.
|
||||||
|
nach der Ordnergröße-Berechnung) aktualisieren kann.
|
||||||
|
"""
|
||||||
|
row = native.Row(title=title)
|
||||||
|
row.set_activatable(False)
|
||||||
|
val = Gtk.Label(label=value, xalign=1.0)
|
||||||
|
val.add_css_class("tn-subtitle")
|
||||||
|
val.set_selectable(True)
|
||||||
|
val.set_wrap(True)
|
||||||
|
val.set_max_width_chars(34)
|
||||||
|
val.set_justify(Gtk.Justification.RIGHT)
|
||||||
|
row.add_suffix(val)
|
||||||
|
return row, val
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Öffentliche API
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def present_properties(parent: Gtk.Widget, path: Path) -> None:
|
||||||
|
"""Baut ein modales Eigenschaften-Fenster für ``path`` und zeigt es.
|
||||||
|
|
||||||
|
Transient für das Toplevel von ``parent``; übernimmt dessen Dunkel-Zustand.
|
||||||
|
Robust gegen fehlende Dateien / Rechtefehler (zeigt „—" statt zu crashen).
|
||||||
|
"""
|
||||||
|
native.ensure_css()
|
||||||
|
path = Path(path)
|
||||||
|
|
||||||
|
# -- stat (defensiv) ----------------------------------------------------
|
||||||
|
try:
|
||||||
|
st = path.lstat()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
st = None
|
||||||
|
|
||||||
|
is_dir = bool(st is not None and _stat.S_ISDIR(st.st_mode))
|
||||||
|
if st is None: # Fallback, falls lstat scheiterte
|
||||||
|
is_dir = path.is_dir()
|
||||||
|
|
||||||
|
gfile = Gio.File.new_for_path(str(path))
|
||||||
|
ctype, type_desc = _content_type(path, is_dir)
|
||||||
|
|
||||||
|
# -- Fenster ------------------------------------------------------------
|
||||||
|
win = Gtk.Window(modal=True, resizable=True)
|
||||||
|
win.set_default_size(380, 520)
|
||||||
|
win.set_title(f"{path.name or path} — Eigenschaften")
|
||||||
|
|
||||||
|
# Toplevel ermitteln: transient + Dunkel-Zustand übernehmen.
|
||||||
|
root = parent.get_root() if hasattr(parent, "get_root") else None
|
||||||
|
if isinstance(root, Gtk.Window):
|
||||||
|
win.set_transient_for(root)
|
||||||
|
dark = bool(isinstance(root, Gtk.Widget) and root.has_css_class("tn-dark"))
|
||||||
|
native.set_dark(win, dark)
|
||||||
|
|
||||||
|
# -- Scrollender Inhalt -------------------------------------------------
|
||||||
|
scroller = Gtk.ScrolledWindow(hexpand=True, vexpand=True)
|
||||||
|
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||||
|
scroller.add_css_class("tn-page")
|
||||||
|
win.set_child(scroller)
|
||||||
|
|
||||||
|
column = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24)
|
||||||
|
column.set_margin_top(24)
|
||||||
|
column.set_margin_bottom(24)
|
||||||
|
column.set_margin_start(20)
|
||||||
|
column.set_margin_end(20)
|
||||||
|
scroller.set_child(column)
|
||||||
|
|
||||||
|
# -- Kopf: großes Icon + Name (fett) + Typbeschreibung ------------------
|
||||||
|
header = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
|
header.set_halign(Gtk.Align.CENTER)
|
||||||
|
|
||||||
|
icon = _icon_for(gfile)
|
||||||
|
img = (
|
||||||
|
Gtk.Image.new_from_gicon(icon)
|
||||||
|
if icon is not None
|
||||||
|
else Gtk.Image.new_from_icon_name(
|
||||||
|
"folder" if is_dir else "text-x-generic"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
img.set_pixel_size(64)
|
||||||
|
img.set_halign(Gtk.Align.CENTER)
|
||||||
|
header.append(img)
|
||||||
|
|
||||||
|
name_lbl = Gtk.Label(label=path.name or str(path))
|
||||||
|
name_lbl.add_css_class("title-3")
|
||||||
|
name_lbl.set_wrap(True)
|
||||||
|
name_lbl.set_justify(Gtk.Justification.CENTER)
|
||||||
|
name_lbl.set_max_width_chars(36)
|
||||||
|
header.append(name_lbl)
|
||||||
|
|
||||||
|
type_hdr = Gtk.Label(label=type_desc)
|
||||||
|
type_hdr.add_css_class("tn-subtitle")
|
||||||
|
type_hdr.set_wrap(True)
|
||||||
|
type_hdr.set_justify(Gtk.Justification.CENTER)
|
||||||
|
header.append(type_hdr)
|
||||||
|
|
||||||
|
column.append(header)
|
||||||
|
|
||||||
|
# -- Detail-Karte -------------------------------------------------------
|
||||||
|
group = native.Group()
|
||||||
|
|
||||||
|
# Typ
|
||||||
|
type_row, _ = _value_row("Type", type_desc)
|
||||||
|
group.add(type_row)
|
||||||
|
|
||||||
|
# Größe — Datei sofort, Ordner per Hintergrund-Thread.
|
||||||
|
if is_dir:
|
||||||
|
size_row, size_val = _value_row("Size", "Calculating…")
|
||||||
|
else:
|
||||||
|
size_bytes = st.st_size if st is not None else 0
|
||||||
|
size_row, size_val = _value_row(
|
||||||
|
"Size", _size_label(size_bytes) if st is not None else _DASH
|
||||||
|
)
|
||||||
|
group.add(size_row)
|
||||||
|
|
||||||
|
# Ort (Elternverzeichnis)
|
||||||
|
parent_dir = str(path.parent) if str(path.parent) else _DASH
|
||||||
|
loc_row, _ = _value_row("Location", parent_dir)
|
||||||
|
group.add(loc_row)
|
||||||
|
|
||||||
|
# Geändert
|
||||||
|
mod_row, _ = _value_row(
|
||||||
|
"Modified", _format_time(st.st_mtime) if st is not None else _DASH
|
||||||
|
)
|
||||||
|
group.add(mod_row)
|
||||||
|
|
||||||
|
# Zugegriffen (optional)
|
||||||
|
acc_row, _ = _value_row(
|
||||||
|
"Accessed", _format_time(st.st_atime) if st is not None else _DASH
|
||||||
|
)
|
||||||
|
group.add(acc_row)
|
||||||
|
|
||||||
|
# Rechte: drwxr-xr-x · user · group
|
||||||
|
if st is not None:
|
||||||
|
try:
|
||||||
|
modestr = _stat.filemode(st.st_mode)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
modestr = _DASH
|
||||||
|
perms = f"{modestr}\n{_owner_group(st)}"
|
||||||
|
else:
|
||||||
|
perms = _DASH
|
||||||
|
perm_row, _ = _value_row("Permissions", perms)
|
||||||
|
group.add(perm_row)
|
||||||
|
|
||||||
|
column.append(group)
|
||||||
|
|
||||||
|
# -- Schließen-Button ---------------------------------------------------
|
||||||
|
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||||
|
actions.set_halign(Gtk.Align.CENTER)
|
||||||
|
close_btn = Gtk.Button(label="Close")
|
||||||
|
close_btn.add_css_class("pill")
|
||||||
|
close_btn.connect("clicked", lambda _b: win.close())
|
||||||
|
actions.append(close_btn)
|
||||||
|
column.append(actions)
|
||||||
|
|
||||||
|
# -- Ordnergröße im Hintergrund -----------------------------------------
|
||||||
|
if is_dir:
|
||||||
|
_spawn_dir_size(win, size_val, path)
|
||||||
|
|
||||||
|
win.present()
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn_dir_size(win: Gtk.Window, label: Gtk.Label, path: Path) -> None:
|
||||||
|
"""Berechnet die rekursive Ordnergröße in einem Thread und füllt ``label``.
|
||||||
|
|
||||||
|
Aktualisiert live (Zwischenstände alle paar tausend Einträge) und schreibt
|
||||||
|
am Ende „X MB (N items)". Alle UI-Updates laufen über ``GLib.idle_add``.
|
||||||
|
"""
|
||||||
|
cancelled = {"flag": False}
|
||||||
|
win.connect("destroy", lambda *_: cancelled.__setitem__("flag", True))
|
||||||
|
|
||||||
|
def _push(total: int, items: int, done: bool) -> bool:
|
||||||
|
if cancelled["flag"]:
|
||||||
|
return False
|
||||||
|
if done:
|
||||||
|
label.set_label(f"{_humanize(total)} ({items:,} items)")
|
||||||
|
else:
|
||||||
|
label.set_label(f"Calculating… {_humanize(total)}")
|
||||||
|
return False # einmalig
|
||||||
|
|
||||||
|
def _work() -> None:
|
||||||
|
total = 0
|
||||||
|
items = 0
|
||||||
|
last_push = 0.0
|
||||||
|
try:
|
||||||
|
for dirpath, dirnames, filenames in os.walk(str(path)):
|
||||||
|
if cancelled["flag"]:
|
||||||
|
return
|
||||||
|
for name in dirnames + filenames:
|
||||||
|
items += 1
|
||||||
|
try:
|
||||||
|
total += os.lstat(os.path.join(dirpath, name)).st_size
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
# Gelegentlicher Zwischenstand (nicht zu oft → wenig Overhead).
|
||||||
|
now = GLib.get_monotonic_time()
|
||||||
|
if now - last_push > 200_000: # ~200 ms
|
||||||
|
last_push = now
|
||||||
|
GLib.idle_add(_push, total, items, False)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
GLib.idle_add(_push, total, items, True)
|
||||||
|
|
||||||
|
threading.Thread(target=_work, daemon=True).start()
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Einstellungen fuer den TANINUX-Dateimanager.
|
||||||
|
|
||||||
|
Speichert ein paar Praeferenzen sitzungsuebergreifend als JSON unter
|
||||||
|
``$XDG_CONFIG_HOME/taninux/files-settings.json``. Robust gegen fehlende
|
||||||
|
Datei, kaputtes JSON und falsche Typen -- es wird niemals eine Ausnahme
|
||||||
|
ausgeloest, im Zweifel gelten die Defaults.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import GLib # noqa: E402
|
||||||
|
|
||||||
|
DEFAULTS = {
|
||||||
|
"view_mode": "grid", # "grid" | "list"
|
||||||
|
"sort_key": "name", # name|size|mtime|type
|
||||||
|
"sort_descending": False,
|
||||||
|
"folders_first": True,
|
||||||
|
"show_hidden": False,
|
||||||
|
"width": 1120,
|
||||||
|
"height": 740,
|
||||||
|
}
|
||||||
|
|
||||||
|
_CONFIG_FILENAME = "files-settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _config_path() -> Path:
|
||||||
|
return Path(GLib.get_user_config_dir()) / "taninux" / _CONFIG_FILENAME
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce(key: str, value: Any) -> Any:
|
||||||
|
"""Light type-coercion. Return the coerced value, or DEFAULTS[key] on
|
||||||
|
a clearly wrong type."""
|
||||||
|
default = DEFAULTS[key]
|
||||||
|
|
||||||
|
# Booleans must be checked before int (bool is a subclass of int).
|
||||||
|
if isinstance(default, bool):
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
if isinstance(default, int):
|
||||||
|
try:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return default
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
if isinstance(default, str):
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._values: dict[str, Any] = dict(DEFAULTS)
|
||||||
|
self._path = _config_path()
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
try:
|
||||||
|
with open(self._path, "r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
for key, raw in data.items():
|
||||||
|
if key not in DEFAULTS:
|
||||||
|
continue
|
||||||
|
self._values[key] = _coerce(key, raw)
|
||||||
|
|
||||||
|
def get(self, key: str) -> Any:
|
||||||
|
return self._values.get(key, DEFAULTS.get(key))
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any) -> None:
|
||||||
|
if key not in DEFAULTS:
|
||||||
|
return
|
||||||
|
self._values[key] = _coerce(key, value)
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
try:
|
||||||
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(self._values, fh, indent=2)
|
||||||
|
fh.flush()
|
||||||
|
os.fsync(fh.fileno())
|
||||||
|
os.replace(tmp, self._path)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
def update(self, **kwargs: Any) -> None:
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
self.set(key, value)
|
||||||
|
self.save()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Sortierung von Dateieinträgen.
|
||||||
|
|
||||||
|
Dieses Modul stellt einen :class:`Gtk.Sorter` bereit, der eine Liste von
|
||||||
|
``FileItem``-Objekten (siehe :mod:`taninux.files.model`) ordnet. Die Sortierung
|
||||||
|
ist rein und seiteneffektfrei: ein :class:`SortSpec` beschreibt den Schlüssel,
|
||||||
|
die Richtung und ob Ordner zuerst erscheinen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gtk
|
||||||
|
|
||||||
|
# sort keys
|
||||||
|
KEY_NAME = "name"
|
||||||
|
KEY_SIZE = "size"
|
||||||
|
KEY_MTIME = "mtime"
|
||||||
|
KEY_TYPE = "type"
|
||||||
|
KEYS = (KEY_NAME, KEY_SIZE, KEY_MTIME, KEY_TYPE) # iteration order for a dropdown
|
||||||
|
|
||||||
|
LABELS = {KEY_NAME: "Name", KEY_SIZE: "Size", KEY_MTIME: "Modified", KEY_TYPE: "Type"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SortSpec:
|
||||||
|
key: str = KEY_NAME # one of KEYS
|
||||||
|
descending: bool = False
|
||||||
|
folders_first: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def _cmp(a, b) -> int:
|
||||||
|
"""Vergleicht zwei vergleichbare Werte und liefert -1/0/1."""
|
||||||
|
if a < b:
|
||||||
|
return -1
|
||||||
|
if a > b:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _name_key(item) -> str:
|
||||||
|
return (item.name or "").casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def make_sorter(spec: SortSpec) -> Gtk.Sorter:
|
||||||
|
"""Erzeugt einen :class:`Gtk.CustomSorter` gemäß ``spec``."""
|
||||||
|
|
||||||
|
key = spec.key
|
||||||
|
descending = spec.descending
|
||||||
|
folders_first = spec.folders_first
|
||||||
|
|
||||||
|
def compare(a, b, *args) -> int:
|
||||||
|
# 1) Ordner vor Dateien -- gewinnt immer, unabhängig von `descending`.
|
||||||
|
if folders_first and a.is_dir != b.is_dir:
|
||||||
|
return -1 if a.is_dir else 1
|
||||||
|
|
||||||
|
# 2) Vergleich nach Schlüssel.
|
||||||
|
if key == KEY_SIZE:
|
||||||
|
result = _cmp(a.size, b.size)
|
||||||
|
elif key == KEY_MTIME:
|
||||||
|
result = _cmp(a.mtime, b.mtime)
|
||||||
|
elif key == KEY_TYPE:
|
||||||
|
result = _cmp((a.content_type or "").casefold(),
|
||||||
|
(b.content_type or "").casefold())
|
||||||
|
if result == 0:
|
||||||
|
# Gleichstand beim Typ -> nach Name (immer aufsteigend).
|
||||||
|
result = _cmp(_name_key(a), _name_key(b))
|
||||||
|
else: # KEY_NAME (Standard)
|
||||||
|
result = _cmp(_name_key(a), _name_key(b))
|
||||||
|
|
||||||
|
# Nur den Schlüsselvergleich invertieren.
|
||||||
|
if descending:
|
||||||
|
result = -result
|
||||||
|
|
||||||
|
if result != 0:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 3) Finaler Tie-Break: Name aufsteigend, nie von `descending` betroffen.
|
||||||
|
return _cmp(_name_key(a), _name_key(b))
|
||||||
|
|
||||||
|
return Gtk.CustomSorter.new(compare, None)
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""Asynchroner Thumbnail-Generator fuer den Dateimanager.
|
||||||
|
|
||||||
|
Laedt und skaliert Bilder in einem Hintergrund-Thread und liefert eine
|
||||||
|
``Gdk.Texture`` auf der GLib-Hauptschleife zurueck. Die Texture-Erzeugung
|
||||||
|
und jeglicher GTK-Zugriff finden ausschliesslich im Hauptthread statt; im
|
||||||
|
Worker-Thread wird lediglich der ``GdkPixbuf.Pixbuf`` erzeugt.
|
||||||
|
|
||||||
|
Zusaetzlich wird ein persistenter On-Disk-Cache nach der freedesktop
|
||||||
|
Thumbnail-Spezifikation gefuehrt (``$XDG_CACHE_HOME/thumbnails/large``),
|
||||||
|
damit Thumbnails Neustarts ueberleben und mit anderen Dateimanagern
|
||||||
|
interoperabel sind. Saemtliche Disk-IO laeuft im Worker-Thread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from collections import OrderedDict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gdk", "4.0")
|
||||||
|
gi.require_version("GdkPixbuf", "2.0")
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gdk, GdkPixbuf, Gio, GLib # noqa: E402
|
||||||
|
|
||||||
|
# Maximale Anzahl gecachter Textures, bevor die aeltesten verworfen werden.
|
||||||
|
_CACHE_CAP = 512
|
||||||
|
|
||||||
|
|
||||||
|
class Thumbnailer:
|
||||||
|
"""Erzeugt und cached Bild-Thumbnails als ``Gdk.Texture``."""
|
||||||
|
|
||||||
|
def __init__(self, size: int = 256, max_bytes: int = 32 * 1024 * 1024) -> None:
|
||||||
|
# size = maximale Breite/Hoehe des Thumbnails (Seitenverhaeltnis bleibt erhalten).
|
||||||
|
# max_bytes = Bilddateien groesser als dieser Wert werden uebersprungen.
|
||||||
|
self.size = int(size)
|
||||||
|
self.max_bytes = int(max_bytes)
|
||||||
|
# Cache: (str(path), mtime_int) -> Gdk.Texture, aeltester Eintrag zuerst.
|
||||||
|
self._cache: "OrderedDict[tuple[str, int], Gdk.Texture]" = OrderedDict()
|
||||||
|
# Schluessel, fuer die gerade ein Worker laeuft (Doppel-Requests vermeiden).
|
||||||
|
self._inflight: set[tuple[str, int]] = set()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
# On-Disk-Cache-Verzeichnis (freedesktop Thumbnail-Spec, 256px-Bucket).
|
||||||
|
self._disk_dir = Path(GLib.get_user_cache_dir()) / "thumbnails" / "large"
|
||||||
|
|
||||||
|
# -- Hilfsfunktionen ----------------------------------------------------
|
||||||
|
|
||||||
|
def _mtime_int(self, path: Path) -> "int | None":
|
||||||
|
try:
|
||||||
|
return int(path.stat().st_mtime)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _cache_get(self, key: tuple[str, int]) -> "Gdk.Texture | None":
|
||||||
|
with self._lock:
|
||||||
|
tex = self._cache.get(key)
|
||||||
|
if tex is not None:
|
||||||
|
# Als zuletzt benutzt markieren.
|
||||||
|
self._cache.move_to_end(key)
|
||||||
|
return tex
|
||||||
|
|
||||||
|
def _cache_put(self, key: tuple[str, int], tex: "Gdk.Texture") -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._cache[key] = tex
|
||||||
|
self._cache.move_to_end(key)
|
||||||
|
while len(self._cache) > _CACHE_CAP:
|
||||||
|
self._cache.popitem(last=False)
|
||||||
|
|
||||||
|
# -- On-Disk-Cache (freedesktop) ---------------------------------------
|
||||||
|
|
||||||
|
def _file_uri(self, path: Path) -> str:
|
||||||
|
# Kanonische file://-URI fuer den gegebenen Pfad.
|
||||||
|
return Gio.File.new_for_path(str(path)).get_uri()
|
||||||
|
|
||||||
|
def _disk_path(self, uri: str) -> Path:
|
||||||
|
# md5(uri).hexdigest() + ".png" gemaess Spezifikation.
|
||||||
|
digest = hashlib.md5(uri.encode("utf-8")).hexdigest()
|
||||||
|
return self._disk_dir / f"{digest}.png"
|
||||||
|
|
||||||
|
def _disk_load(self, path: Path, mtime: int) -> "GdkPixbuf.Pixbuf | None":
|
||||||
|
# Laedt das On-Disk-Thumbnail nur, wenn Thumb::MTime mit der Quelle
|
||||||
|
# uebereinstimmt. Laeuft im Worker-Thread. Wirft niemals.
|
||||||
|
try:
|
||||||
|
uri = self._file_uri(path)
|
||||||
|
disk = self._disk_path(uri)
|
||||||
|
if not disk.exists():
|
||||||
|
return None
|
||||||
|
pixbuf = GdkPixbuf.Pixbuf.new_from_file(str(disk))
|
||||||
|
stored = pixbuf.get_option("tEXt::Thumb::MTime")
|
||||||
|
if stored is None:
|
||||||
|
stored = pixbuf.get_option("Thumb::MTime")
|
||||||
|
if stored is None or str(stored) != str(int(mtime)):
|
||||||
|
return None
|
||||||
|
return pixbuf
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _disk_save(self, path: Path, mtime: int, pixbuf: "GdkPixbuf.Pixbuf") -> None:
|
||||||
|
# Schreibt das Thumbnail mit eingebetteten Thumb::URI/Thumb::MTime-Keys
|
||||||
|
# atomar in den Cache. Laeuft im Worker-Thread. Wirft niemals.
|
||||||
|
try:
|
||||||
|
uri = self._file_uri(path)
|
||||||
|
disk = self._disk_path(uri)
|
||||||
|
try:
|
||||||
|
self._disk_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
fd, tmp_name = tempfile.mkstemp(suffix=".png", dir=str(self._disk_dir))
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
pixbuf.savev(
|
||||||
|
tmp_name,
|
||||||
|
"png",
|
||||||
|
["tEXt::Thumb::URI", "tEXt::Thumb::MTime"],
|
||||||
|
[uri, str(int(mtime))],
|
||||||
|
)
|
||||||
|
os.replace(tmp_name, str(disk))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_name)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- Oeffentliche API ---------------------------------------------------
|
||||||
|
|
||||||
|
def can_thumbnail(self, path: Path, content_type: str) -> bool:
|
||||||
|
# Nur fuer Raster-Bild-Content-Types, deren Dateigroesse <= max_bytes ist.
|
||||||
|
if not content_type or not content_type.startswith("image/"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return path.stat().st_size <= self.max_bytes
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_cached(self, path: Path) -> "Gdk.Texture | None":
|
||||||
|
# Liefert eine gecachte Texture fuer (path, mtime), sonst None.
|
||||||
|
# SYNCHRON, nur Speicher -- keinerlei Disk-IO.
|
||||||
|
mtime = self._mtime_int(path)
|
||||||
|
if mtime is None:
|
||||||
|
return None
|
||||||
|
return self._cache_get((str(path), mtime))
|
||||||
|
|
||||||
|
def request(
|
||||||
|
self,
|
||||||
|
path: Path,
|
||||||
|
content_type: str,
|
||||||
|
callback: Callable[["Gdk.Texture | None"], None],
|
||||||
|
) -> None:
|
||||||
|
# Cache-Treffer -> sofort (synchron) zuruecksenden.
|
||||||
|
cached = self.get_cached(path)
|
||||||
|
if cached is not None:
|
||||||
|
try:
|
||||||
|
callback(cached)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.can_thumbnail(path, content_type):
|
||||||
|
try:
|
||||||
|
callback(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
mtime = self._mtime_int(path)
|
||||||
|
if mtime is None:
|
||||||
|
try:
|
||||||
|
callback(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
key = (str(path), mtime)
|
||||||
|
|
||||||
|
# Bereits in Arbeit -> nicht erneut starten, aber Callback bedienen.
|
||||||
|
with self._lock:
|
||||||
|
if key in self._inflight:
|
||||||
|
start = False
|
||||||
|
else:
|
||||||
|
self._inflight.add(key)
|
||||||
|
start = True
|
||||||
|
|
||||||
|
if not start:
|
||||||
|
# Ein anderer Request laeuft schon; Cache wird gefuellt, hier nur
|
||||||
|
# bestmoeglich nachreichen.
|
||||||
|
GLib.idle_add(self._deliver_later, key, callback)
|
||||||
|
return
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=self._worker,
|
||||||
|
args=(path, mtime, key, callback),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
# -- Interna ------------------------------------------------------------
|
||||||
|
|
||||||
|
def _worker(
|
||||||
|
self,
|
||||||
|
path: Path,
|
||||||
|
mtime: int,
|
||||||
|
key: tuple[str, int],
|
||||||
|
callback: Callable[["Gdk.Texture | None"], None],
|
||||||
|
) -> None:
|
||||||
|
pixbuf = None
|
||||||
|
from_disk = False
|
||||||
|
try:
|
||||||
|
# Zuerst den On-Disk-Cache pruefen (Thumb::MTime muss passen).
|
||||||
|
pixbuf = self._disk_load(path, mtime)
|
||||||
|
if pixbuf is not None:
|
||||||
|
from_disk = True
|
||||||
|
else:
|
||||||
|
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||||
|
str(path), self.size, self.size, True
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pixbuf = None
|
||||||
|
# Frisch generierte Thumbnails persistent ablegen (best-effort).
|
||||||
|
if pixbuf is not None and not from_disk:
|
||||||
|
self._disk_save(path, mtime, pixbuf)
|
||||||
|
# Finisher im Hauptthread: Texture erzeugen, cachen, Callback aufrufen.
|
||||||
|
GLib.idle_add(self._finish, key, pixbuf, callback)
|
||||||
|
|
||||||
|
def _finish(
|
||||||
|
self,
|
||||||
|
key: tuple[str, int],
|
||||||
|
pixbuf: "GdkPixbuf.Pixbuf | None",
|
||||||
|
callback: Callable[["Gdk.Texture | None"], None],
|
||||||
|
) -> bool:
|
||||||
|
tex = None
|
||||||
|
try:
|
||||||
|
if pixbuf is not None:
|
||||||
|
tex = Gdk.Texture.new_for_pixbuf(pixbuf)
|
||||||
|
self._cache_put(key, tex)
|
||||||
|
except Exception:
|
||||||
|
tex = None
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self._inflight.discard(key)
|
||||||
|
try:
|
||||||
|
callback(tex)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False # nur einmal ausfuehren
|
||||||
|
|
||||||
|
def _deliver_later(
|
||||||
|
self,
|
||||||
|
key: tuple[str, int],
|
||||||
|
callback: Callable[["Gdk.Texture | None"], None],
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
callback(self._cache_get(key))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""Trash-Ansicht für den Dateimanager.
|
||||||
|
|
||||||
|
Listet Einträge aus dem Papierkorb (``trash:///``) auf und erlaubt das
|
||||||
|
Wiederherstellen an den ursprünglichen Ort, das endgültige Löschen einzelner
|
||||||
|
Einträge sowie das vollständige Leeren des Papierkorbs. Alle schreibenden
|
||||||
|
Operationen laufen in einem Hintergrund-Thread und melden ihr Ergebnis über
|
||||||
|
``GLib.idle_add`` zurück an den GTK-Mainloop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Iterable, List
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gio, GLib # noqa: E402
|
||||||
|
|
||||||
|
_ATTRS = (
|
||||||
|
"standard::name,standard::display-name,standard::icon,standard::type,"
|
||||||
|
"standard::size,standard::content-type,trash::orig-path,trash::deletion-date"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrashEntry:
|
||||||
|
name: str
|
||||||
|
icon: object
|
||||||
|
size: int
|
||||||
|
is_dir: bool
|
||||||
|
content_type: str
|
||||||
|
orig_path: str
|
||||||
|
deletion_date: str
|
||||||
|
gfile: object
|
||||||
|
|
||||||
|
|
||||||
|
def _idle(on_done: Callable[[int, List[str]], None], count: int, errors: List[str]) -> None:
|
||||||
|
GLib.idle_add(lambda: (on_done(count, errors), False)[1])
|
||||||
|
|
||||||
|
|
||||||
|
def list_trash() -> List[TrashEntry]:
|
||||||
|
"""Enumeriert den Papierkorb. Wirft nie -> bei Fehler leere Liste."""
|
||||||
|
entries: List[TrashEntry] = []
|
||||||
|
try:
|
||||||
|
tdir = Gio.File.new_for_uri("trash:///")
|
||||||
|
en = tdir.enumerate_children(_ATTRS, Gio.FileQueryInfoFlags.NONE, None)
|
||||||
|
except GLib.Error:
|
||||||
|
return entries
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
info = en.next_file(None)
|
||||||
|
except GLib.Error:
|
||||||
|
break
|
||||||
|
if info is None:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
name = info.get_name()
|
||||||
|
display = info.get_display_name() or name
|
||||||
|
icon = info.get_icon()
|
||||||
|
try:
|
||||||
|
size = info.get_size()
|
||||||
|
except Exception:
|
||||||
|
size = 0
|
||||||
|
is_dir = info.get_file_type() == Gio.FileType.DIRECTORY
|
||||||
|
content_type = info.get_content_type() or ""
|
||||||
|
orig = info.get_attribute_byte_string("trash::orig-path") or ""
|
||||||
|
ddate = info.get_attribute_string("trash::deletion-date") or ""
|
||||||
|
gfile = tdir.get_child(name)
|
||||||
|
entries.append(
|
||||||
|
TrashEntry(
|
||||||
|
name=display,
|
||||||
|
icon=icon,
|
||||||
|
size=size or 0,
|
||||||
|
is_dir=is_dir,
|
||||||
|
content_type=content_type,
|
||||||
|
orig_path=orig,
|
||||||
|
deletion_date=ddate,
|
||||||
|
gfile=gfile,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
en.close(None)
|
||||||
|
except GLib.Error:
|
||||||
|
pass
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_parent(orig_path: str) -> None:
|
||||||
|
"""Legt das Elternverzeichnis von *orig_path* an (ignoriert "existiert")."""
|
||||||
|
try:
|
||||||
|
parent = Gio.File.new_for_path(orig_path).get_parent()
|
||||||
|
if parent is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
parent.make_directory_with_parents(None)
|
||||||
|
except GLib.Error as exc:
|
||||||
|
if not exc.matches(Gio.io_error_quark(), Gio.IOErrorEnum.EXISTS):
|
||||||
|
raise
|
||||||
|
except GLib.Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def restore(entries: Iterable[TrashEntry],
|
||||||
|
on_done: Callable[[int, List[str]], None]) -> None:
|
||||||
|
"""Stellt Einträge an ihren Ursprungsort wieder her (Hintergrund-Thread)."""
|
||||||
|
items = list(entries)
|
||||||
|
|
||||||
|
def work() -> None:
|
||||||
|
restored = 0
|
||||||
|
errors: List[str] = []
|
||||||
|
for e in items:
|
||||||
|
try:
|
||||||
|
orig = e.orig_path
|
||||||
|
if not orig:
|
||||||
|
errors.append(f"{e.name}: unbekannter Ursprungsort")
|
||||||
|
continue
|
||||||
|
_ensure_parent(orig)
|
||||||
|
dest = Gio.File.new_for_path(orig)
|
||||||
|
e.gfile.move(
|
||||||
|
dest,
|
||||||
|
Gio.FileCopyFlags.NOFOLLOW_SYMLINKS | Gio.FileCopyFlags.ALL_METADATA,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
restored += 1
|
||||||
|
except GLib.Error as exc:
|
||||||
|
errors.append(f"{e.name}: {exc.message}")
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
errors.append(f"{e.name}: {exc}")
|
||||||
|
_idle(on_done, restored, errors)
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def delete(entries: Iterable[TrashEntry],
|
||||||
|
on_done: Callable[[int, List[str]], None]) -> None:
|
||||||
|
"""Löscht Einträge endgültig aus dem Papierkorb (Hintergrund-Thread)."""
|
||||||
|
items = list(entries)
|
||||||
|
|
||||||
|
def work() -> None:
|
||||||
|
deleted = 0
|
||||||
|
errors: List[str] = []
|
||||||
|
for e in items:
|
||||||
|
try:
|
||||||
|
e.gfile.delete(None)
|
||||||
|
deleted += 1
|
||||||
|
except GLib.Error as exc:
|
||||||
|
errors.append(f"{e.name}: {exc.message}")
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
errors.append(f"{e.name}: {exc}")
|
||||||
|
_idle(on_done, deleted, errors)
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def empty(on_done: Callable[[int, List[str]], None]) -> None:
|
||||||
|
"""Leert den gesamten Papierkorb (Hintergrund-Thread)."""
|
||||||
|
|
||||||
|
def work() -> None:
|
||||||
|
deleted = 0
|
||||||
|
errors: List[str] = []
|
||||||
|
try:
|
||||||
|
items = list_trash()
|
||||||
|
except Exception:
|
||||||
|
items = []
|
||||||
|
for e in items:
|
||||||
|
try:
|
||||||
|
e.gfile.delete(None)
|
||||||
|
deleted += 1
|
||||||
|
except GLib.Error as exc:
|
||||||
|
errors.append(f"{e.name}: {exc.message}")
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
errors.append(f"{e.name}: {exc}")
|
||||||
|
_idle(on_done, deleted, errors)
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Undo-Stack fuer Dateioperationen.
|
||||||
|
|
||||||
|
Selbststaendiges Modul: zeichnet ausgefuehrte Operationen auf und kehrt sie
|
||||||
|
auf Ctrl+Z um. Verschieben wird im Hintergrund-Thread rueckgaengig gemacht;
|
||||||
|
das Ergebnis wird ueber GLib.idle_add im Hauptloop zurueckgemeldet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, List, Tuple
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gio, GLib # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Op:
|
||||||
|
kind: str # "move" | "copy" | "rename" | "create"
|
||||||
|
label: str
|
||||||
|
pairs: List[Tuple[Path, Path]] = field(default_factory=list) # move/rename
|
||||||
|
paths: List[Path] = field(default_factory=list) # copy/create
|
||||||
|
|
||||||
|
|
||||||
|
def _trash_or_delete(path: Path) -> None:
|
||||||
|
"""Send a path to trash; fall back to permanent delete. May raise."""
|
||||||
|
try:
|
||||||
|
Gio.File.new_for_path(str(path)).trash(None)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback: permanent delete.
|
||||||
|
if path.is_dir() and not path.is_symlink():
|
||||||
|
shutil.rmtree(str(path))
|
||||||
|
else:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
class UndoStack:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._stack: List[_Op] = []
|
||||||
|
|
||||||
|
# -- recording (called AFTER an operation succeeds) --
|
||||||
|
def record_move(self, pairs: List[Tuple[Path, Path]]) -> None:
|
||||||
|
if not pairs:
|
||||||
|
return
|
||||||
|
self._stack.append(
|
||||||
|
_Op(kind="move", label="Undo Move", pairs=[(Path(o), Path(n)) for o, n in pairs])
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_copy(self, created: List[Path]) -> None:
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
self._stack.append(
|
||||||
|
_Op(kind="copy", label="Undo Copy", paths=[Path(p) for p in created])
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_rename(self, old_path: Path, new_path: Path) -> None:
|
||||||
|
self._stack.append(
|
||||||
|
_Op(kind="rename", label="Undo Rename", pairs=[(Path(old_path), Path(new_path))])
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_create(self, path: Path) -> None:
|
||||||
|
self._stack.append(
|
||||||
|
_Op(kind="create", label="Undo Create", paths=[Path(path)])
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- query --
|
||||||
|
def can_undo(self) -> bool:
|
||||||
|
return bool(self._stack)
|
||||||
|
|
||||||
|
def describe_next(self) -> str:
|
||||||
|
return self._stack[-1].label if self._stack else ""
|
||||||
|
|
||||||
|
# -- perform --
|
||||||
|
def undo(self, on_done: Callable[[str], None]) -> None:
|
||||||
|
if not self._stack:
|
||||||
|
try:
|
||||||
|
on_done("")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
op = self._stack.pop()
|
||||||
|
|
||||||
|
def _finish(message: str) -> None:
|
||||||
|
def _cb() -> bool:
|
||||||
|
try:
|
||||||
|
on_done(message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
GLib.idle_add(_cb)
|
||||||
|
|
||||||
|
def _work() -> None:
|
||||||
|
errors = 0
|
||||||
|
try:
|
||||||
|
if op.kind == "move":
|
||||||
|
for original, new in op.pairs:
|
||||||
|
try:
|
||||||
|
if not new.exists():
|
||||||
|
continue
|
||||||
|
shutil.move(str(new), str(original))
|
||||||
|
except Exception:
|
||||||
|
errors += 1
|
||||||
|
elif op.kind == "rename":
|
||||||
|
for old_path, new_path in op.pairs:
|
||||||
|
try:
|
||||||
|
if not new_path.exists():
|
||||||
|
continue
|
||||||
|
os.rename(str(new_path), str(old_path))
|
||||||
|
except Exception:
|
||||||
|
errors += 1
|
||||||
|
elif op.kind in ("copy", "create"):
|
||||||
|
for p in op.paths:
|
||||||
|
try:
|
||||||
|
if not (p.exists() or p.is_symlink()):
|
||||||
|
continue
|
||||||
|
_trash_or_delete(p)
|
||||||
|
except Exception:
|
||||||
|
errors += 1
|
||||||
|
except Exception:
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
_finish(f"Undo failed for {errors} item(s)")
|
||||||
|
else:
|
||||||
|
_finish("")
|
||||||
|
|
||||||
|
threading.Thread(target=_work, daemon=True).start()
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Eingehängte Laufwerke für die Sidebar — reich aufgelöst und auswerfbar.
|
||||||
|
|
||||||
|
Anders als ``places.py`` behält dieses Modul das ``Gio.Mount``-Handle, damit
|
||||||
|
die UI ein Laufwerk später aushängen oder auswerfen kann.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, List
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gio, GLib # noqa: E402
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MountedVolume:
|
||||||
|
name: str
|
||||||
|
path: Path
|
||||||
|
icon: str # icon name, e.g. "drive-removable-media-symbolic" / "drive-harddisk-symbolic"
|
||||||
|
can_eject: bool # True if the mount can be ejected or unmounted
|
||||||
|
mount: object # the Gio.Mount handle (kept for unmount/eject)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_removable(mount) -> bool:
|
||||||
|
"""Best-effort: ist das zugrundeliegende Laufwerk entfernbar? Alles None-fest."""
|
||||||
|
try:
|
||||||
|
vol = mount.get_volume()
|
||||||
|
if vol is None:
|
||||||
|
return False
|
||||||
|
drive = vol.get_drive()
|
||||||
|
if drive is None:
|
||||||
|
return False
|
||||||
|
if drive.is_removable():
|
||||||
|
return True
|
||||||
|
if drive.is_media_removable():
|
||||||
|
return True
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("Removable-Erkennung fehlgeschlagen", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def list_volumes() -> List[MountedVolume]:
|
||||||
|
"""Eingehängte Laufwerke mit lokalem Pfad — reich aufgelöst. Wirft nie."""
|
||||||
|
out: List[MountedVolume] = []
|
||||||
|
try:
|
||||||
|
monitor = Gio.VolumeMonitor.get()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("VolumeMonitor nicht verfügbar", exc_info=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
try:
|
||||||
|
mounts = monitor.get_mounts()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("get_mounts() fehlgeschlagen", exc_info=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
for mount in mounts:
|
||||||
|
try:
|
||||||
|
if mount.is_shadowed():
|
||||||
|
continue
|
||||||
|
root = mount.get_root()
|
||||||
|
path = root.get_path() if root is not None else None
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
can_eject = bool(mount.can_eject() or mount.can_unmount())
|
||||||
|
icon = (
|
||||||
|
"drive-removable-media-symbolic"
|
||||||
|
if _is_removable(mount)
|
||||||
|
else "drive-harddisk-symbolic"
|
||||||
|
)
|
||||||
|
out.append(
|
||||||
|
MountedVolume(
|
||||||
|
name=mount.get_name(),
|
||||||
|
path=Path(path),
|
||||||
|
icon=icon,
|
||||||
|
can_eject=can_eject,
|
||||||
|
mount=mount,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.debug("Mount übersprungen", exc_info=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def eject_or_unmount(mount, on_done: Callable[[bool, str], None]) -> None:
|
||||||
|
"""Auswerfen (falls möglich), sonst aushängen — asynchron. Wirft nie.
|
||||||
|
|
||||||
|
``on_done(ok, message)`` wird in der GLib-Hauptschleife aufgerufen; bei
|
||||||
|
Erfolg ist ``message`` "".
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _eject_finish(_mount, result) -> None:
|
||||||
|
try:
|
||||||
|
mount.eject_with_operation_finish(result)
|
||||||
|
on_done(True, "")
|
||||||
|
except GLib.Error as err:
|
||||||
|
on_done(False, err.message)
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
on_done(False, str(err))
|
||||||
|
|
||||||
|
def _unmount_finish(_mount, result) -> None:
|
||||||
|
try:
|
||||||
|
mount.unmount_with_operation_finish(result)
|
||||||
|
on_done(True, "")
|
||||||
|
except GLib.Error as err:
|
||||||
|
on_done(False, err.message)
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
on_done(False, str(err))
|
||||||
|
|
||||||
|
try:
|
||||||
|
if mount.can_eject():
|
||||||
|
mount.eject_with_operation(
|
||||||
|
Gio.MountUnmountFlags.NONE,
|
||||||
|
Gio.MountOperation(),
|
||||||
|
None,
|
||||||
|
_eject_finish,
|
||||||
|
)
|
||||||
|
elif mount.can_unmount():
|
||||||
|
mount.unmount_with_operation(
|
||||||
|
Gio.MountUnmountFlags.NONE,
|
||||||
|
Gio.MountOperation(),
|
||||||
|
None,
|
||||||
|
_unmount_finish,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
on_done(False, "Cannot eject")
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
logger.debug("eject_or_unmount fehlgeschlagen", exc_info=True)
|
||||||
|
on_done(False, str(err))
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user