diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..1771819 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,22 @@ +name: Validate + +on: + pull_request: + push: + branches: [main] + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Run repository validation + run: python scripts/validate_repo.py diff --git a/README.md b/README.md index bed58bd..1b6d253 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,14 @@ cd duplicate-file-cleaner pip install -r requirements.txt python main.py ``` + +## Resolução rápida de conflitos no `src/app.py` + +Se após um `git pull` aparecer `IndentationError` com texto como `codex/review-repository-code-...`, usa: + +```bash +python scripts/fix_conflict_artifacts.py src/app.py --write +python -m compileall main.py src +``` + +O script remove marcadores de conflito e artefactos de branch comuns e cria backup automático (`src/app.py.bak`). diff --git a/scripts/fix_conflict_artifacts.py b/scripts/fix_conflict_artifacts.py new file mode 100644 index 0000000..b47addd --- /dev/null +++ b/scripts/fix_conflict_artifacts.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from pathlib import Path +import argparse +import shutil +import sys + +CONFLICT_PREFIXES = ("<<<<<<<", "=======", ">>>>>>>") + + +def should_remove_line(raw_line: str) -> bool: + stripped = raw_line.strip() + if not stripped: + return False + if stripped.startswith(CONFLICT_PREFIXES): + return True + if stripped.startswith("codex/review-repository-code-"): + return True + if stripped == "main": + return True + return False + + +def sanitize_file(path: Path, write: bool) -> tuple[int, int]: + lines = path.read_text(encoding="utf-8", errors="ignore").splitlines() + cleaned: list[str] = [] + removed = 0 + + for line in lines: + if should_remove_line(line): + removed += 1 + continue + cleaned.append(line) + + if write and removed: + backup = path.with_suffix(path.suffix + ".bak") + shutil.copy2(path, backup) + path.write_text("\n".join(cleaned) + "\n", encoding="utf-8") + + return removed, len(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Remove artefactos de merge em ficheiros Python.") + parser.add_argument("path", nargs="?", default="src/app.py", help="Ficheiro alvo (default: src/app.py)") + parser.add_argument("--write", action="store_true", help="Aplicar alterações ao ficheiro") + args = parser.parse_args() + + target = Path(args.path) + if not target.exists(): + print(f"Ficheiro não encontrado: {target}") + return 1 + + removed, total = sanitize_file(target, write=args.write) + if args.write: + if removed: + print(f"Removidas {removed} linha(s) suspeita(s) de {total}. Backup criado em {target.with_suffix(target.suffix + '.bak')}") + else: + print("Nenhuma linha suspeita encontrada. Nada para alterar.") + else: + print(f"Pré-visualização: {removed} linha(s) suspeita(s) em {total}.") + print("Use --write para aplicar.") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate_repo.py b/scripts/validate_repo.py new file mode 100644 index 0000000..b263a19 --- /dev/null +++ b/scripts/validate_repo.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path +import compileall +import sys + +ROOT = Path(__file__).resolve().parents[1] +CONFLICT_TOKENS = ("<<<<<<<", "=======", ">>>>>>>") + + +def check_conflict_markers() -> list[str]: + problems: list[str] = [] + targets = [ROOT / "main.py", *(ROOT / "src").rglob("*.py")] + for path in targets: + text = path.read_text(encoding="utf-8", errors="ignore") + for idx, line in enumerate(text.splitlines(), start=1): + if any(token in line for token in CONFLICT_TOKENS): + problems.append(f"{path.relative_to(ROOT)}:{idx}: marcador de conflito encontrado") + if line.strip().startswith("codex/review-repository-code-"): + problems.append(f"{path.relative_to(ROOT)}:{idx}: artefacto de branch encontrado") + return problems + + +def main() -> int: + marker_errors = check_conflict_markers() + if marker_errors: + for error in marker_errors: + print(error) + return 1 + + ok = compileall.compile_dir(str(ROOT / "src"), quiet=1) + ok_main = compileall.compile_file(str(ROOT / "main.py"), quiet=1) + if not ok or not ok_main: + print("Falha de compilação Python.") + return 1 + + print("Validação concluída com sucesso.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/app.py b/src/app.py index 63c6f31..8a80758 100644 --- a/src/app.py +++ b/src/app.py @@ -1,5 +1,6 @@ import os import json +from pathlib import Path from datetime import datetime from PyQt6.QtWidgets import ( @@ -21,24 +22,67 @@ from src.utils import format_size, get_file_size, calculate_savings, COMMON_EXTENSIONS, IMAGE_EXTENSIONS -# Ficheiro de configuracao -CONFIG_FILE = "config.json" +# Ficheiros de configuracao +APP_CONFIG_DIR = Path.home() / ".duplicate-file-cleaner" +CONFIG_FILE = APP_CONFIG_DIR / "config.json" +LEGACY_CONFIG_FILE = Path("config.json") -def load_config(): - """Carrega a configuracao do ficheiro.""" +def _migrate_legacy_config_if_needed(): + """Migra config.json antigo da raiz para o perfil do utilizador sem remover o original.""" + if CONFIG_FILE.exists() or not LEGACY_CONFIG_FILE.exists(): + return + + codex/review-repository-code-hpjdax + + codex/review-repository-code-argiid try: - if os.path.exists(CONFIG_FILE): - with open(CONFIG_FILE, "r", encoding="utf-8") as f: + legacy_data = json.loads(LEGACY_CONFIG_FILE.read_text(encoding="utf-8")) + APP_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CONFIG_FILE.write_text(json.dumps(legacy_data, indent=2, ensure_ascii=False), encoding="utf-8") + except Exception: + pass + + +def _read_json_file(path: Path): + """Le JSON de um ficheiro devolvendo dict vazio em caso de erro.""" + try: + + try: + legacy_data = json.loads(LEGACY_CONFIG_FILE.read_text(encoding="utf-8")) + APP_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CONFIG_FILE.write_text(json.dumps(legacy_data, indent=2, ensure_ascii=False), encoding="utf-8") + except Exception: + pass + + +def _read_json_file(path: Path): + """Le JSON de um ficheiro devolvendo dict vazio em caso de erro.""" + try: + codex/review-repository-code-hpjdax + + if path.exists(): + with open(path, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return {} +def load_config(): + """Carrega a configuracao do ficheiro.""" + _migrate_legacy_config_if_needed() + + config = _read_json_file(CONFIG_FILE) + if config: + return config + return _read_json_file(LEGACY_CONFIG_FILE) + + def save_config(config): """Guarda a configuracao no ficheiro.""" try: + APP_CONFIG_DIR.mkdir(parents=True, exist_ok=True) with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(config, f, indent=2, ensure_ascii=False) except Exception: @@ -71,6 +115,19 @@ def run(self): self.error.emit(str(e)) +class SortableTreeWidgetItem(QTreeWidgetItem): + """Item de resultados com ordenacao numerica para a coluna de tamanho.""" + + def __lt__(self, other): + tree = self.treeWidget() + if tree and tree.sortColumn() == 1: + self_size = self.data(1, Qt.ItemDataRole.UserRole) + other_size = other.data(1, Qt.ItemDataRole.UserRole) + if isinstance(self_size, (int, float)) and isinstance(other_size, (int, float)): + return self_size < other_size + return super().__lt__(other) + + class DuplicateCleanerApp(QMainWindow): def __init__(self): super().__init__() @@ -82,6 +139,18 @@ def __init__(self): self.config = load_config() self.scan_paths = [] self._selection_loaded = False + codex/review-repository-code-hpjdax + self.filters_panel_visible = True + + codex/review-repository-code-argiid + self.filters_panel_visible = True + + codex/review-repository-code-l1cd2s + self.filters_panel_visible = True + + codex/review-repository-code-kf47vu + self.filters_panel_visible = True + self.setWindowTitle("Duplicate File Cleaner") self.setMinimumSize(900, 620) @@ -277,6 +346,9 @@ def _build_scan_tab(self): layout = QVBoxLayout(widget) layout.setSpacing(6) + scan_splitter = QSplitter(Qt.Orientation.Vertical) + scan_splitter.setChildrenCollapsible(False) + # Grupo de caminhos paths_group = QGroupBox("Caminhos de Varredura") paths_layout = QVBoxLayout(paths_group) @@ -299,9 +371,9 @@ def _build_scan_tab(self): # Lista de caminhos list_layout = QHBoxLayout() self.paths_list = QListWidget() - self.paths_list.setMaximumHeight(120) + self.paths_list.setMinimumHeight(120) self.paths_list.setAlternatingRowColors(True) - + list_btns = QVBoxLayout() remove_btn = QPushButton("Remover") remove_btn.setObjectName("danger") @@ -312,7 +384,7 @@ def _build_scan_tab(self): list_btns.addWidget(remove_btn) list_btns.addWidget(clear_btn) list_btns.addStretch() - + list_layout.addWidget(self.paths_list, 1) list_layout.addLayout(list_btns) paths_layout.addLayout(list_layout) @@ -322,7 +394,7 @@ def _build_scan_tab(self): self.paths_count_label.setStyleSheet("color: #6c7086; font-size: 11px;") paths_layout.addWidget(self.paths_count_label) - layout.addWidget(paths_group) + scan_splitter.addWidget(paths_group) filter_group = QGroupBox("Filtros de Ficheiros") filter_layout = QVBoxLayout(filter_group) @@ -339,22 +411,61 @@ def _build_scan_tab(self): self.scan_mode.currentIndexChanged.connect(self._on_mode_change) mode_layout.addWidget(self.scan_mode) mode_layout.addStretch() +codex/review-repository-code-hpjdax + + +codex/review-repository-code-argiid + + self.toggle_filters_btn = QPushButton("Esconder Selecao") + self.toggle_filters_btn.clicked.connect(self._toggle_filters_panel) + mode_layout.addWidget(self.toggle_filters_btn) filter_layout.addLayout(mode_layout) + self.filters_panel_widget = self._build_filters_panel() + filter_layout.addWidget(self.filters_panel_widget, 1) + + codex/review-repository-code-l1cd2s + + self.toggle_filters_btn = QPushButton("Esconder Selecao") + self.toggle_filters_btn.clicked.connect(self._toggle_filters_panel) + mode_layout.addWidget(self.toggle_filters_btn) + filter_layout.addLayout(mode_layout) + codex/review-repository-code-hpjdax + + self.filters_panel_widget = self._build_filters_panel() + filter_layout.addWidget(self.filters_panel_widget, 1) + + + + self.filters_panel_widget = self._build_filters_panel() + filter_layout.addWidget(self.filters_panel_widget, 1) + + + self.toggle_filters_btn = QPushButton("Esconder Selecao") + self.toggle_filters_btn.clicked.connect(self._toggle_filters_panel) + mode_layout.addWidget(self.toggle_filters_btn) + filter_layout.addLayout(mode_layout) + + self.filters_panel_widget = QWidget() + filters_panel_layout = QVBoxLayout(self.filters_panel_widget) + filters_panel_layout.setContentsMargins(0, 0, 0, 0) + filters_panel_layout.setSpacing(4) + # Scroll area para categorias e extensoes scroll = QScrollArea() scroll.setWidgetResizable(True) - scroll.setMinimumHeight(150) - scroll.setMaximumHeight(220) - + scroll.setMinimumHeight(230) + self.categories_container = QWidget() self.categories_layout = QVBoxLayout(self.categories_container) self.categories_layout.setSpacing(6) self.categories_layout.setContentsMargins(4, 4, 4, 4) self.categories_layout.addStretch() - + scroll.setWidget(self.categories_container) - filter_layout.addWidget(scroll) + codex/review-repository-code-kf47vu + filters_panel_layout.addWidget(scroll, 1) + filter_layout.addWidget(scroll, 1) quick_btn_layout = QHBoxLayout() select_all_btn = QPushButton("Selecionar Tudo") @@ -364,15 +475,21 @@ def _build_scan_tab(self): quick_btn_layout.addWidget(select_all_btn) quick_btn_layout.addWidget(deselect_all_btn) quick_btn_layout.addStretch() - filter_layout.addLayout(quick_btn_layout) + filters_panel_layout.addLayout(quick_btn_layout) custom_layout = QHBoxLayout() custom_layout.addWidget(QLabel("Extensoes personalizadas:")) self.custom_ext_input = QLineEdit() self.custom_ext_input.setPlaceholderText("ex: .xyz .abc .custom") custom_layout.addWidget(self.custom_ext_input) + codex/review-repository-code-kf47vu + filters_panel_layout.addLayout(custom_layout) + + filter_layout.addWidget(self.filters_panel_widget, 1) + filter_layout.addLayout(custom_layout) - layout.addWidget(filter_group) + + scan_splitter.addWidget(filter_group) progress_group = QGroupBox("Progresso") progress_layout = QVBoxLayout(progress_group) @@ -382,7 +499,14 @@ def _build_scan_tab(self): self.progress_label.setStyleSheet("color: #6c7086;") progress_layout.addWidget(self.progress_bar) progress_layout.addWidget(self.progress_label) - layout.addWidget(progress_group) + scan_splitter.addWidget(progress_group) + + scan_splitter.setStretchFactor(0, 0) + scan_splitter.setStretchFactor(1, 1) + scan_splitter.setStretchFactor(2, 0) + scan_splitter.setSizes([220, 360, 120]) + + layout.addWidget(scan_splitter, 1) btn_layout = QHBoxLayout() self.scan_btn = QPushButton("Iniciar Varredura") @@ -398,9 +522,48 @@ def _build_scan_tab(self): btn_layout.addWidget(self.scan_btn) btn_layout.addWidget(self.stop_btn) layout.addLayout(btn_layout) - layout.addStretch() return widget + def _build_filters_panel(self): + """Cria o painel de filtros reutilizável para reduzir conflitos de merge.""" + panel = QWidget() + filters_panel_layout = QVBoxLayout(panel) + filters_panel_layout.setContentsMargins(0, 0, 0, 0) + filters_panel_layout.setSpacing(4) + + # Scroll area para categorias e extensoes + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setMinimumHeight(230) + + self.categories_container = QWidget() + self.categories_layout = QVBoxLayout(self.categories_container) + self.categories_layout.setSpacing(6) + self.categories_layout.setContentsMargins(4, 4, 4, 4) + self.categories_layout.addStretch() + + scroll.setWidget(self.categories_container) + filters_panel_layout.addWidget(scroll, 1) + + quick_btn_layout = QHBoxLayout() + select_all_btn = QPushButton("Selecionar Tudo") + select_all_btn.clicked.connect(self._select_all_extensions) + deselect_all_btn = QPushButton("Desselecionar Tudo") + deselect_all_btn.clicked.connect(self._deselect_all_extensions) + quick_btn_layout.addWidget(select_all_btn) + quick_btn_layout.addWidget(deselect_all_btn) + quick_btn_layout.addStretch() + filters_panel_layout.addLayout(quick_btn_layout) + + custom_layout = QHBoxLayout() + custom_layout.addWidget(QLabel("Extensoes personalizadas:")) + self.custom_ext_input = QLineEdit() + self.custom_ext_input.setPlaceholderText("ex: .xyz .abc .custom") + custom_layout.addWidget(self.custom_ext_input) + filters_panel_layout.addLayout(custom_layout) + + return panel + def _populate_extension_categories(self): """Popula as categorias com extensoes lado a lado.""" self.ext_checkboxes = {} @@ -568,6 +731,8 @@ def _build_results_tab(self): layout.addWidget(self.summary_label) splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.setChildrenCollapsible(False) + splitter.setHandleWidth(8) left_widget = QWidget() left_layout = QVBoxLayout(left_widget) @@ -580,9 +745,11 @@ def _build_results_tab(self): self.results_tree.setAlternatingRowColors(True) self.results_tree.setSelectionMode(QTreeWidget.SelectionMode.MultiSelection) self.results_tree.setIndentation(12) - self.results_tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) - self.results_tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) - self.results_tree.header().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + self.results_tree.header().setSectionResizeMode(QHeaderView.ResizeMode.Interactive) + self.results_tree.header().setStretchLastSection(False) + self.results_tree.setColumnWidth(0, 320) + self.results_tree.setColumnWidth(1, 140) + self.results_tree.setColumnWidth(2, 520) self.results_tree.setSortingEnabled(True) self.results_tree.itemClicked.connect(self._on_item_clicked) self.results_tree.itemChanged.connect(self._on_item_changed) @@ -597,9 +764,16 @@ def _build_results_tab(self): sel_keep_btn = QPushButton("Manter 1/Grupo") sel_keep_btn.setObjectName("warning") sel_keep_btn.clicked.connect(self._select_keep_first) + expand_btn = QPushButton("Expandir Grupos") + expand_btn.clicked.connect(self._expand_all_groups) + collapse_btn = QPushButton("Encolher Grupos") + collapse_btn.clicked.connect(self._collapse_all_groups) + sel_layout.addWidget(sel_all_btn) sel_layout.addWidget(sel_none_btn) sel_layout.addWidget(sel_keep_btn) + sel_layout.addWidget(expand_btn) + sel_layout.addWidget(collapse_btn) left_layout.addLayout(sel_layout) action_layout = QHBoxLayout() @@ -650,6 +824,23 @@ def _build_results_tab(self): layout.addWidget(splitter, 1) return widget + def _toggle_filters_panel(self): + self.filters_panel_visible = not self.filters_panel_visible + self.filters_panel_widget.setVisible(self.filters_panel_visible) + self.toggle_filters_btn.setText( + "Esconder Selecao" if self.filters_panel_visible else "Mostrar Selecao" + ) + + def _expand_all_groups(self): + root = self.results_tree.invisibleRootItem() + for i in range(root.childCount()): + root.child(i).setExpanded(True) + + def _collapse_all_groups(self): + root = self.results_tree.invisibleRootItem() + for i in range(root.childCount()): + root.child(i).setExpanded(False) + def _build_log_tab(self): widget = QWidget() layout = QVBoxLayout(widget) @@ -781,7 +972,7 @@ def _populate_results(self, duplicates): group_size = sum(get_file_size(p) for p in paths) # Grupo com checkbox funcional - group_item = QTreeWidgetItem(self.results_tree) + group_item = SortableTreeWidgetItem(self.results_tree) group_item.setText(0, "Grupo {} ({} ficheiros)".format(idx, len(paths))) group_item.setText(1, format_size(group_size)) group_item.setText(2, "Hash: {}...".format(file_hash[:16])) @@ -793,7 +984,7 @@ def _populate_results(self, duplicates): # Ficheiros do grupo for i, path in enumerate(paths): - child = QTreeWidgetItem(group_item) + child = SortableTreeWidgetItem(group_item) filename = os.path.basename(path) size = get_file_size(path) label = "[ORIG]" if i == 0 else "[DUP]" @@ -1059,4 +1250,4 @@ def showEvent(self, event): def closeEvent(self, event): """Chamado quando a janela e fechada.""" self._save_selection() - event.accept() \ No newline at end of file + event.accept()