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 os
import random
import re
import shutil
import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -47,6 +48,11 @@ def _icon(name: str) -> "QIcon":
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"]
@@ -3296,6 +3302,7 @@ class _PlaylistTabBar(QTabBar):
tab_renamed = pyqtSignal(int, str)
pin_toggle_requested = pyqtSignal(int)
tab_folder_toggle_requested = pyqtSignal(int)
duplicate_requested = pyqtSignal(int)
def mouseDoubleClickEvent(self, event):
idx = self.tabAt(event.pos())
@@ -3321,6 +3328,7 @@ class _PlaylistTabBar(QTabBar):
act_tabfolder.setCheckable(True)
act_tabfolder.setChecked(bool(getattr(pw, "_tab_folder", False)))
act_rename = menu.addAction("Rename…")
act_dup = menu.addAction("Duplicate tab")
chosen = menu.exec(event.globalPos())
if chosen == act_pin:
self.pin_toggle_requested.emit(idx)
@@ -3328,6 +3336,8 @@ class _PlaylistTabBar(QTabBar):
self.tab_folder_toggle_requested.emit(idx)
elif chosen == act_rename:
self._start_edit(idx)
elif chosen == act_dup:
self.duplicate_requested.emit(idx)
def _start_edit(self, idx: int) -> None:
editor = QLineEdit(self)
@@ -3400,6 +3410,7 @@ class PlaylistWidget(QListWidget):
self._missing: set[str] = set() # paths not present on disk
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._dest_folder: str = "" # per-tab export destination
self._label: str = "" # tab name (source of truth across views)
self._visible: list[str | None] = [] # rows shown; None = separator row
self._selected_path: str | None = None
@@ -3922,6 +3933,9 @@ class MainWindow(QMainWindow):
self._playlist_filter.setClearButtonEnabled(True)
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
# __init__ (the profile combo it needs doesn't exist yet).
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().tab_folder_toggle_requested.connect(
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.currentChanged.connect(self._on_tab_changed)
self._btn_add_tab = QPushButton("+")
@@ -4046,6 +4061,7 @@ class MainWindow(QMainWindow):
self._txt_folder.textChanged.connect(
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.setFixedWidth(30)
self._btn_folder.setToolTip("Browse for output folder")
@@ -4930,6 +4946,28 @@ class MainWindow(QMainWindow):
def _export_base_name(self) -> str:
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:
pw = self._playlist_tabs.widget(idx)
if pw is None:
@@ -4941,6 +4979,34 @@ class MainWindow(QMainWindow):
self._refresh_playlist_checks()
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 ───────────────────────────────────────────
def _wire_pw(self, pw: "PlaylistWidget") -> None:
pw.file_selected.connect(self._load_file)
@@ -4958,6 +5024,10 @@ class MainWindow(QMainWindow):
select: bool = True) -> "PlaylistWidget":
pw = PlaylistWidget()
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}"
self._pws.append(pw)
if separators:
@@ -5193,6 +5263,7 @@ class MainWindow(QMainWindow):
if w is not None:
self._active_pw = w
w.set_filter(self._playlist_filter.text())
self._sync_folder_field_to_tab()
self._apply_playlist_filters()
self._save_playlist_tabs()
@@ -5230,6 +5301,7 @@ class MainWindow(QMainWindow):
"separators": sorted(pw._separators_before),
"pinned": pw._pinned,
"tab_folder": pw._tab_folder,
"export_folder": pw._dest_folder,
} for pw in self._pws]
cur = self._pws.index(self._active_pw) if self._active_pw in self._pws else 0
data = {"tabs": tabs, "current": cur}
@@ -5273,6 +5345,8 @@ class MainWindow(QMainWindow):
separators=t.get("separators", []), select=False)
pw._pinned = bool(t.get("pinned"))
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)
finally:
self._loading_tabs = False
@@ -5282,6 +5356,7 @@ class MainWindow(QMainWindow):
if not self._active_pw._pinned:
self._playlist_tabs.setCurrentWidget(self._active_pw)
self._active_pw.set_filter(self._playlist_filter.text())
self._sync_folder_field_to_tab()
def _on_profile_activated(self, index: int) -> None:
text = self._cmb_profile.itemText(index)
@@ -5335,6 +5410,7 @@ class MainWindow(QMainWindow):
self._playlist._select(0)
self._refresh_markers()
self._update_status_perm()
self._sync_folder_field_to_tab()
_log(f"Profile switched: {text}")
self._show_status(f"Profile: {text}", 3000)
@@ -5508,7 +5584,10 @@ class MainWindow(QMainWindow):
return # ignore auto-selection while rebuilding tabs
# The list that emitted this becomes the active pane (side-by-side).
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
if not os.path.isfile(path):
self._show_status(f"File not found: {os.path.basename(path)}", 5000)
@@ -7337,6 +7416,24 @@ class MainWindow(QMainWindow):
folder = self._tab_export_folder()
if 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)
spread = self._spn_spread.value()
+30
View File
@@ -1,5 +1,15 @@
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.
# If construction fails for any environment reason, skip — this test is a
# 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
assert win._tab_crop._pinned is True
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"