Compare commits

5 Commits

Author SHA1 Message Date
Ethanfel 1eb7de2a1a fix: duplicate-tab folder is a sibling, not a child, when source ends in /
".../AlexisCrystal/" + "_copy" was producing ".../AlexisCrystal/_copy"; rstrip
the trailing separator first → ".../AlexisCrystal_copy". Regression test uses a
trailing-slash source folder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 14:52:12 +02:00
Ethanfel d7680283a2 test: isolate QSettings in GUI tests so they never touch the real ~/.config/8cut
Constructing MainWindow loads and (on close) re-saves the playlist tabs; a test
that mutated tab state could persist into the user's real session. Redirect
QSettings to a temp dir at import time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 14:47:35 +02:00
Ethanfel bf4b6dad2d feat: right-click "Duplicate tab" — clone files into a new tab with adapted name + own folder
New tab copies the source tab's video list + separators, gets a unique
"<name> copy" label and an adapted own export folder ("<folder>_copy"), and
inherits the tab-named-folder flag. No files are moved or copied — you export
into the new tab's folder. Keeps Foley/variant datasets separate without the
file-shuffling that a misexport used to require.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-18 14:36:47 +02:00
Ethanfel 4715c0ce49 fix: sync export folder when selecting a file in a side-by-side list; tighten guardrail; rename per-tab attr
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 01:06:21 +02:00
Ethanfel e5ce59c065 feat: bind export folder to each file-list tab + export-folder mismatch guardrail
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 00:56:55 +02:00
2 changed files with 128 additions and 1 deletions
+98 -1
View File
@@ -5,6 +5,7 @@ locale.setlocale(locale.LC_NUMERIC, "C") # required by libmpv before any import
import sys import sys
import os import os
import random import random
import re
import shutil import shutil
import subprocess import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -47,6 +48,11 @@ def _icon(name: str) -> "QIcon":
return QIcon(str(_ASSET_DIR / "icons" / name)) return QIcon(str(_ASSET_DIR / "icons" / name))
def _norm_token(s: str) -> str:
"""Lowercase a string and strip everything but [a-z0-9] for fuzzy matching."""
return re.sub(r"[^a-z0-9]", "", s.lower())
_SELVA_CATEGORIES = ["", "Human", "Animal", "Vehicle", "Tool", "Music", "Nature", "Sport", "Other"] _SELVA_CATEGORIES = ["", "Human", "Animal", "Vehicle", "Tool", "Music", "Nature", "Sport", "Other"]
@@ -3296,6 +3302,7 @@ class _PlaylistTabBar(QTabBar):
tab_renamed = pyqtSignal(int, str) tab_renamed = pyqtSignal(int, str)
pin_toggle_requested = pyqtSignal(int) pin_toggle_requested = pyqtSignal(int)
tab_folder_toggle_requested = pyqtSignal(int) tab_folder_toggle_requested = pyqtSignal(int)
duplicate_requested = pyqtSignal(int)
def mouseDoubleClickEvent(self, event): def mouseDoubleClickEvent(self, event):
idx = self.tabAt(event.pos()) idx = self.tabAt(event.pos())
@@ -3321,6 +3328,7 @@ class _PlaylistTabBar(QTabBar):
act_tabfolder.setCheckable(True) act_tabfolder.setCheckable(True)
act_tabfolder.setChecked(bool(getattr(pw, "_tab_folder", False))) act_tabfolder.setChecked(bool(getattr(pw, "_tab_folder", False)))
act_rename = menu.addAction("Rename…") act_rename = menu.addAction("Rename…")
act_dup = menu.addAction("Duplicate tab")
chosen = menu.exec(event.globalPos()) chosen = menu.exec(event.globalPos())
if chosen == act_pin: if chosen == act_pin:
self.pin_toggle_requested.emit(idx) self.pin_toggle_requested.emit(idx)
@@ -3328,6 +3336,8 @@ class _PlaylistTabBar(QTabBar):
self.tab_folder_toggle_requested.emit(idx) self.tab_folder_toggle_requested.emit(idx)
elif chosen == act_rename: elif chosen == act_rename:
self._start_edit(idx) self._start_edit(idx)
elif chosen == act_dup:
self.duplicate_requested.emit(idx)
def _start_edit(self, idx: int) -> None: def _start_edit(self, idx: int) -> None:
editor = QLineEdit(self) editor = QLineEdit(self)
@@ -3400,6 +3410,7 @@ class PlaylistWidget(QListWidget):
self._missing: set[str] = set() # paths not present on disk self._missing: set[str] = set() # paths not present on disk
self._pinned: bool = False # shown in the side-by-side view self._pinned: bool = False # shown in the side-by-side view
self._tab_folder: bool = False # append this tab's name to export folder self._tab_folder: bool = False # append this tab's name to export folder
self._dest_folder: str = "" # per-tab export destination
self._label: str = "" # tab name (source of truth across views) self._label: str = "" # tab name (source of truth across views)
self._visible: list[str | None] = [] # rows shown; None = separator row self._visible: list[str | None] = [] # rows shown; None = separator row
self._selected_path: str | None = None self._selected_path: str | None = None
@@ -3922,6 +3933,9 @@ class MainWindow(QMainWindow):
self._playlist_filter.setClearButtonEnabled(True) self._playlist_filter.setClearButtonEnabled(True)
self._playlist_filter.textChanged.connect(self._on_filter_changed) self._playlist_filter.textChanged.connect(self._on_filter_changed)
# Guard against the textChanged→tab-save loop when we programmatically
# sync _txt_folder to the active tab's stored export folder.
self._syncing_folder = False
# Suppress tab persistence until _load_playlist_tabs runs at the end of # Suppress tab persistence until _load_playlist_tabs runs at the end of
# __init__ (the profile combo it needs doesn't exist yet). # __init__ (the profile combo it needs doesn't exist yet).
self._loading_tabs = True self._loading_tabs = True
@@ -3936,6 +3950,7 @@ class MainWindow(QMainWindow):
self._playlist_tabs.tabBar().pin_toggle_requested.connect(self._on_pin_toggle) self._playlist_tabs.tabBar().pin_toggle_requested.connect(self._on_pin_toggle)
self._playlist_tabs.tabBar().tab_folder_toggle_requested.connect( self._playlist_tabs.tabBar().tab_folder_toggle_requested.connect(
self._on_tab_folder_toggle) self._on_tab_folder_toggle)
self._playlist_tabs.tabBar().duplicate_requested.connect(self._on_duplicate_tab)
self._playlist_tabs.tabCloseRequested.connect(self._on_close_tab) self._playlist_tabs.tabCloseRequested.connect(self._on_close_tab)
self._playlist_tabs.currentChanged.connect(self._on_tab_changed) self._playlist_tabs.currentChanged.connect(self._on_tab_changed)
self._btn_add_tab = QPushButton("+") self._btn_add_tab = QPushButton("+")
@@ -4046,6 +4061,7 @@ class MainWindow(QMainWindow):
self._txt_folder.textChanged.connect( self._txt_folder.textChanged.connect(
lambda v: self._settings.setValue("export_folder", v) lambda v: self._settings.setValue("export_folder", v)
) )
self._txt_folder.textChanged.connect(self._on_export_folder_edited)
self._btn_folder = QPushButton("...") self._btn_folder = QPushButton("...")
self._btn_folder.setFixedWidth(30) self._btn_folder.setFixedWidth(30)
self._btn_folder.setToolTip("Browse for output folder") self._btn_folder.setToolTip("Browse for output folder")
@@ -4930,6 +4946,28 @@ class MainWindow(QMainWindow):
def _export_base_name(self) -> str: def _export_base_name(self) -> str:
return os.path.basename(self._tab_export_folder()) return os.path.basename(self._tab_export_folder())
def _on_export_folder_edited(self, text: str) -> None:
"""User edited the folder field → store it on the active tab."""
if self._syncing_folder:
return
pw = self._playlist
if pw is not None:
pw._dest_folder = text
self._save_playlist_tabs()
def _sync_folder_field_to_tab(self) -> None:
"""Reflect the active tab's stored export folder in the folder field."""
pw = self._playlist
if pw is None:
return
folder = getattr(pw, "_dest_folder", "") or self._settings.value(
"export_folder", str(Path.home()))
if folder != self._txt_folder.text():
self._syncing_folder = True
self._txt_folder.setText(folder)
self._syncing_folder = False
self._update_next_label()
def _on_tab_folder_toggle(self, idx: int) -> None: def _on_tab_folder_toggle(self, idx: int) -> None:
pw = self._playlist_tabs.widget(idx) pw = self._playlist_tabs.widget(idx)
if pw is None: if pw is None:
@@ -4941,6 +4979,34 @@ class MainWindow(QMainWindow):
self._refresh_playlist_checks() self._refresh_playlist_checks()
self._update_next_label() self._update_next_label()
def _on_duplicate_tab(self, idx: int) -> None:
"""Clone a tab's file list into a new tab with an adapted name and its
own (adapted) export folder. No files are moved or copied the new tab
just targets a separate dataset folder you export into."""
src = self._playlist_tabs.widget(idx)
if src is None:
return
base = f"{src._label} copy"
label, n = base, 2
existing = {pw._label for pw in self._pws}
while label in existing:
label = f"{base} {n}"
n += 1
pw = self._add_playlist_tab(
label=label,
files=list(src._paths),
separators=sorted(src._separators_before),
select=True,
)
src_folder = getattr(src, "_dest_folder", "")
# rstrip the trailing separator so ".../AlexisCrystal/" + "_copy" becomes
# a sibling ".../AlexisCrystal_copy", not a child ".../AlexisCrystal/_copy".
pw._dest_folder = (src_folder.rstrip("/" + os.sep) + "_copy") if src_folder else ""
pw._tab_folder = getattr(src, "_tab_folder", False)
self._sync_folder_field_to_tab()
self._save_playlist_tabs()
self._show_status(f"Duplicated tab → {label}", 4000)
# ── File-list tabs ─────────────────────────────────────────── # ── File-list tabs ───────────────────────────────────────────
def _wire_pw(self, pw: "PlaylistWidget") -> None: def _wire_pw(self, pw: "PlaylistWidget") -> None:
pw.file_selected.connect(self._load_file) pw.file_selected.connect(self._load_file)
@@ -4958,6 +5024,10 @@ class MainWindow(QMainWindow):
select: bool = True) -> "PlaylistWidget": select: bool = True) -> "PlaylistWidget":
pw = PlaylistWidget() pw = PlaylistWidget()
self._wire_pw(pw) self._wire_pw(pw)
# Inherit the current folder field (overwritten on load). _txt_folder may
# not exist yet during the bootstrap tab built before widgets are wired.
_fld = getattr(self, "_txt_folder", None)
pw._dest_folder = _fld.text() if _fld is not None else ""
pw._label = label or f"List {len(self._pws) + 1}" pw._label = label or f"List {len(self._pws) + 1}"
self._pws.append(pw) self._pws.append(pw)
if separators: if separators:
@@ -5193,6 +5263,7 @@ class MainWindow(QMainWindow):
if w is not None: if w is not None:
self._active_pw = w self._active_pw = w
w.set_filter(self._playlist_filter.text()) w.set_filter(self._playlist_filter.text())
self._sync_folder_field_to_tab()
self._apply_playlist_filters() self._apply_playlist_filters()
self._save_playlist_tabs() self._save_playlist_tabs()
@@ -5230,6 +5301,7 @@ class MainWindow(QMainWindow):
"separators": sorted(pw._separators_before), "separators": sorted(pw._separators_before),
"pinned": pw._pinned, "pinned": pw._pinned,
"tab_folder": pw._tab_folder, "tab_folder": pw._tab_folder,
"export_folder": pw._dest_folder,
} for pw in self._pws] } for pw in self._pws]
cur = self._pws.index(self._active_pw) if self._active_pw in self._pws else 0 cur = self._pws.index(self._active_pw) if self._active_pw in self._pws else 0
data = {"tabs": tabs, "current": cur} data = {"tabs": tabs, "current": cur}
@@ -5273,6 +5345,8 @@ class MainWindow(QMainWindow):
separators=t.get("separators", []), select=False) separators=t.get("separators", []), select=False)
pw._pinned = bool(t.get("pinned")) pw._pinned = bool(t.get("pinned"))
pw._tab_folder = bool(t.get("tab_folder")) pw._tab_folder = bool(t.get("tab_folder"))
pw._dest_folder = t.get("export_folder") or self._settings.value(
"export_folder", str(Path.home()))
cur = min(max(0, data.get("current", 0)), len(self._pws) - 1) cur = min(max(0, data.get("current", 0)), len(self._pws) - 1)
finally: finally:
self._loading_tabs = False self._loading_tabs = False
@@ -5282,6 +5356,7 @@ class MainWindow(QMainWindow):
if not self._active_pw._pinned: if not self._active_pw._pinned:
self._playlist_tabs.setCurrentWidget(self._active_pw) self._playlist_tabs.setCurrentWidget(self._active_pw)
self._active_pw.set_filter(self._playlist_filter.text()) self._active_pw.set_filter(self._playlist_filter.text())
self._sync_folder_field_to_tab()
def _on_profile_activated(self, index: int) -> None: def _on_profile_activated(self, index: int) -> None:
text = self._cmb_profile.itemText(index) text = self._cmb_profile.itemText(index)
@@ -5335,6 +5410,7 @@ class MainWindow(QMainWindow):
self._playlist._select(0) self._playlist._select(0)
self._refresh_markers() self._refresh_markers()
self._update_status_perm() self._update_status_perm()
self._sync_folder_field_to_tab()
_log(f"Profile switched: {text}") _log(f"Profile switched: {text}")
self._show_status(f"Profile: {text}", 3000) self._show_status(f"Profile: {text}", 3000)
@@ -5508,7 +5584,10 @@ class MainWindow(QMainWindow):
return # ignore auto-selection while rebuilding tabs return # ignore auto-selection while rebuilding tabs
# The list that emitted this becomes the active pane (side-by-side). # The list that emitted this becomes the active pane (side-by-side).
sender = self.sender() sender = self.sender()
if isinstance(sender, PlaylistWidget) and sender in self._pws: if isinstance(sender, PlaylistWidget) and sender in self._pws and sender is not self._active_pw:
self._active_pw = sender
self._sync_folder_field_to_tab()
elif isinstance(sender, PlaylistWidget) and sender in self._pws:
self._active_pw = sender self._active_pw = sender
if not os.path.isfile(path): if not os.path.isfile(path):
self._show_status(f"File not found: {os.path.basename(path)}", 5000) self._show_status(f"File not found: {os.path.basename(path)}", 5000)
@@ -7337,6 +7416,24 @@ class MainWindow(QMainWindow):
folder = self._tab_export_folder() folder = self._tab_export_folder()
if folder_suffix: if folder_suffix:
folder = folder.rstrip(os.sep) + "_" + folder_suffix folder = folder.rstrip(os.sep) + "_" + folder_suffix
# Guardrail: warn if the loaded video's parent folder name doesn't
# appear anywhere in the destination — likely a mismatched tab/folder.
vid_parent = os.path.basename(os.path.dirname(self._file_path))
vid_tok = _norm_token(vid_parent)
folder_tokens = [_norm_token(p) for p in folder.split(os.sep) if p]
if len(vid_tok) >= 3 and not any(vid_tok in ft for ft in folder_tokens):
resp = QMessageBox.question(
self, "Export folder mismatch",
f"The loaded video is under:\n {vid_parent}\n\n"
f"but you're exporting to:\n {folder}\n\nExport anyway?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if resp != QMessageBox.StandardButton.Yes:
self._show_status("Export cancelled (folder mismatch)", 4000)
return
os.makedirs(folder, exist_ok=True) os.makedirs(folder, exist_ok=True)
spread = self._spn_spread.value() spread = self._spn_spread.value()
+30
View File
@@ -1,5 +1,15 @@
import pytest import pytest
# Redirect QSettings to a throwaway dir BEFORE any MainWindow is constructed, so
# these GUI tests can never read or clobber the user's real ~/.config/8cut.conf
# (constructing MainWindow loads — and on window close re-saves — the playlist
# tabs; a test mutating tab state would otherwise persist into the real session).
import tempfile as _tempfile
from PyQt6.QtCore import QSettings as _QSettings
_QS_DIR = _tempfile.mkdtemp(prefix="8cut-test-qs-")
_QSettings.setPath(_QSettings.Format.NativeFormat, _QSettings.Scope.UserScope, _QS_DIR)
_QSettings.setPath(_QSettings.Format.IniFormat, _QSettings.Scope.UserScope, _QS_DIR)
# A real platform is needed because MpvWidget creates a GL context. # A real platform is needed because MpvWidget creates a GL context.
# If construction fails for any environment reason, skip — this test is a # If construction fails for any environment reason, skip — this test is a
# best-effort structural net, not a gate on core/ tests. # best-effort structural net, not a gate on core/ tests.
@@ -107,3 +117,23 @@ def test_side_by_side_menu_pins_third_panel(win):
win._deck_loading = False win._deck_loading = False
assert win._tab_crop._pinned is True assert win._tab_crop._pinned is True
assert len(_split_columns(win)) == 3 assert len(_split_columns(win)) == 3
def test_duplicate_tab(win):
# Right-click → Duplicate tab: clones files into a new tab with an adapted
# name + adapted own folder, no file moves. Suppress QSettings writes via
# _loading_tabs so the test can't touch the real session.
win._loading_tabs = True
try:
src = win._pws[0]
src._label = "AlexisCrystal"
src._dest_folder = "/data/alexis/" # trailing slash, like real folders
n_before = len(win._pws)
win._on_duplicate_tab(win._playlist_tabs.indexOf(src))
finally:
win._loading_tabs = False
assert len(win._pws) == n_before + 1
dup = win._pws[-1]
assert dup._label == "AlexisCrystal copy"
# sibling, not a child: ".../alexis/" -> ".../alexis_copy" (not ".../alexis/_copy")
assert dup._dest_folder == "/data/alexis_copy"