<commit_before>"""File manager operations (delete, rename etc) when right-clicking directory tree."""
from __future__ import annotations

import dataclasses
import enum
import logging
import shutil
import subprocess
import sys
import tkinter
from functools import partial
from pathlib import Path
from tkinter import messagebox, ttk
from typing import Callable, NamedTuple

from send2trash import send2trash

from porcupine import get_main_window, get_tab_manager, tabs, utils
from porcupine.plugins.directory_tree import DirectoryTree, get_directory_tree, get_path

setup_after = ["directory_tree"]
log = logging.getLogger(__name__)

if sys.platform == "win32":
    trash_name = "recycle bin"
else:
    trash_name = "trash"


class FilenameMode(enum.Enum):
    RENAME = ("Rename", "Enter a new name for {name}:")
    NEW_FILE = ("New file", "Enter a name for the new file. Remember the extension (e.g. .py).")
    NEW_DIRECTORY = ("New directory", "Enter a name for the new directory.")
    PASTE = ("File conflict", "There is already a file named {name} in {parent}.\n\n{plus}")


class PasteState(NamedTuple):
    is_cut: bool
    path: Path


paste_state: PasteState | None = None


def find_tabs_by_parent_path(path: Path) -> list[tabs.FileTab]:
    return [
        tab
        for tab in get_tab_manager().tabs()
        if isinstance(tab, tabs.FileTab)
        and tab.path is not None
        and (path == tab.path or path in tab.path.parents)
    ]


def show_error(title: str, message: str, error: Exception) -> None:
    log.exception(message)
    messagebox.showerror(title, message, detail=f"{type(error).__name__}: {error}")


def ask_file_name(
    target_dir: Path, old_name: str, mode: FilenameMode, can_overwrite: bool = False
) -> Path | None:
    dialog = tkinter.Toplevel()
    dialog.transient(get_main_window())

    big_frame = ttk.Frame(dialog, padding=10)
    big_frame.pack(fill="both", expand=True)

    for i in {0, 1}:
        big_frame.columnconfigure(i, weight=1)

    phrase_label = ttk.Label(big_frame, wraplength=400)
    phrase_label.grid(row=0, column=0, columnspan=2, pady=(0, 15), sticky="ew")

    file_name_var = tkinter.StringVar(value=old_name)
    overwrite_var = tkinter.BooleanVar(value=False)

    entry = ttk.Entry(big_frame, textvariable=file_name_var)
    entry.grid(row=3, column=0, columnspan=2, sticky="ew")

    new_path = None

    def select_name() -> None:
        nonlocal new_path
        if overwrite_var.get():
            new_path = target_dir / old_name
        else:
            new_path = (target_dir / "dummy").with_name(entry.get())
        dialog.destroy()

    cancel_button = ttk.Button(big_frame, text="Cancel", command=dialog.destroy, width=1)
    cancel_button.grid(row=4, column=0, padx=(0, 5), pady=(30, 0), sticky="ew")

    ok_button = ttk.Button(big_frame, text="OK", command=select_name, state="disabled", width=1)
    ok_button.grid(row=4, column=1, padx=(5, 0), pady=(30, 0), sticky="ew")

    if can_overwrite:
        r1 = ttk.Radiobutton(big_frame, text="Overwrite", variable=overwrite_var, value=True)
        r2 = ttk.Radiobutton(
            big_frame, text="Change name of destination", variable=overwrite_var, value=False
        )
        r1.grid(row=1, column=0, columnspan=2, sticky="ew")
        r2.grid(row=2, column=0, columnspan=2, pady=(5, 20), sticky="ew")

        r1.invoke()
        ok_button.config(state="normal")
        entry.config(state="disabled")

    assert mode in FilenameMode
    dialog_title, dialog_phrase = mode.value

    phrase_label.config(
        text=dialog_phrase.format(
            name=old_name,
            parent=target_dir,
            plus=(
                "What do you want to do with it?"
                if can_overwrite
                else "Choose a name that isn't in use."
            ),
        )
    )

    def update_dialog_state(*junk: object) -> None:
        if overwrite_var.get():
            ok_button.config(state="normal")
            entry.config(state="disabled")
            return

        entry.config(state="normal")
        name = entry.get()
        try:
            possible_new_path = (target_dir / "dummy").with_name(name)
        except ValueError:
            ok_button.config(state="disabled")
            return

        ok_button["state"] = "disabled" if possible_new_path.exists() else "normal"

    file_name_var.trace_add("write", update_dialog_state)
    overwrite_var.trace_add("write", update_dialog_state)
    entry.bind("<Return>", (lambda event: ok_button.invoke()), add=True)
    entry.bind("<Escape>", (lambda event: cancel_button.invoke()), add=True)
    entry.select_range(0, "end")
    entry.focus()

    dialog.title(dialog_title)
    dialog.resizable(False, False)
    dialog.wait_window()

    return new_path


# Not necessarily same concept as Porcupine's project root
def find_git_root(path: Path) -> Path | None:
    for parent in path.parents:
        if (parent / ".git").is_dir():
            return parent
    return None


def move_with_git_or_otherwise(old_path: Path, new_path: Path) -> bool:
    old_git = find_git_root(old_path)
    new_git = find_git_root(new_path)
    if old_git is not None and new_git is not None and old_git == new_git:
        log.info(f"attemting 'git mv' ({old_path} --> {new_path})")
        try:
            subprocess.check_call(
                ["git", "mv", "--", str(old_path), str(new_path)],
                cwd=old_path.parent,
                **utils.subprocess_kwargs,
            )
            return True
        except (OSError, subprocess.CalledProcessError):
            # Happens when:
            #   - git not installed
            #   - old_path is not 'git add'ed
            pass

    log.info(f"moving without git ({old_path} --> {new_path})")
    try:
        shutil.move(str(old_path), str(new_path))
    except OSError as e:
        show_error("Moving failed", f"Cannot move {old_path} to {new_path}.", e)
        return False

    for tab in find_tabs_by_parent_path(old_path):
        assert tab.path is not None
        tab.path = new_path / tab.path.relative_to(old_path)
    return True


def rename(old_path: Path) -> None:
    new_path = ask_file_name(old_path.parent, old_path.name, mode=FilenameMode.RENAME)
    if new_path is not None:
        if move_with_git_or_otherwise(old_path, new_path):
            get_tab_manager().event_generate("<<FileSystemChanged>>")
            get_directory_tree().select_file(new_path)


def paste(new_path: Path) -> None:<commit_msg>fix annoying directory tree rename bug (#1109)<commit_after>"""File manager operations (delete, rename etc) when right-clicking directory tree."""
from __future__ import annotations

import dataclasses
import enum
import logging
import shutil
import subprocess
import sys
import tkinter
from functools import partial
from pathlib import Path
from tkinter import messagebox, ttk
from typing import Callable, NamedTuple

from send2trash import send2trash

from porcupine import get_main_window, get_tab_manager, tabs, utils
from porcupine.plugins.directory_tree import DirectoryTree, get_directory_tree, get_path

setup_after = ["directory_tree"]
log = logging.getLogger(__name__)

if sys.platform == "win32":
    trash_name = "recycle bin"
else:
    trash_name = "trash"


class FilenameMode(enum.Enum):
    RENAME = ("Rename", "Enter a new name for {name}:")
    NEW_FILE = ("New file", "Enter a name for the new file. Remember the extension (e.g. .py).")
    NEW_DIRECTORY = ("New directory", "Enter a name for the new directory.")
    PASTE = ("File conflict", "There is already a file named {name} in {parent}.\n\n{plus}")


class PasteState(NamedTuple):
    is_cut: bool
    path: Path


paste_state: PasteState | None = None


def find_tabs_by_parent_path(path: Path) -> list[tabs.FileTab]:
    return [
        tab
        for tab in get_tab_manager().tabs()
        if isinstance(tab, tabs.FileTab)
        and tab.path is not None
        and (path == tab.path or path in tab.path.parents)
    ]


def show_error(title: str, message: str, error: Exception) -> None:
    log.exception(message)
    messagebox.showerror(title, message, detail=f"{type(error).__name__}: {error}")


def ask_file_name(
    target_dir: Path, old_name: str, mode: FilenameMode, can_overwrite: bool = False
) -> Path | None:
    dialog = tkinter.Toplevel()
    dialog.transient(get_main_window())

    big_frame = ttk.Frame(dialog, padding=10)
    big_frame.pack(fill="both", expand=True)

    for i in {0, 1}:
        big_frame.columnconfigure(i, weight=1)

    phrase_label = ttk.Label(big_frame, wraplength=400)
    phrase_label.grid(row=0, column=0, columnspan=2, pady=(0, 15), sticky="ew")

    file_name_var = tkinter.StringVar(value=old_name)
    overwrite_var = tkinter.BooleanVar(value=False)

    entry = ttk.Entry(big_frame, textvariable=file_name_var)
    entry.grid(row=3, column=0, columnspan=2, sticky="ew")

    new_path = None

    def select_name() -> None:
        nonlocal new_path
        if overwrite_var.get():
            new_path = target_dir / old_name
        else:
            new_path = (target_dir / "dummy").with_name(entry.get())
        dialog.destroy()

    cancel_button = ttk.Button(big_frame, text="Cancel", command=dialog.destroy, width=1)
    cancel_button.grid(row=4, column=0, padx=(0, 5), pady=(30, 0), sticky="ew")

    ok_button = ttk.Button(big_frame, text="OK", command=select_name, state="disabled", width=1)
    ok_button.grid(row=4, column=1, padx=(5, 0), pady=(30, 0), sticky="ew")

    if can_overwrite:
        r1 = ttk.Radiobutton(big_frame, text="Overwrite", variable=overwrite_var, value=True)
        r2 = ttk.Radiobutton(
            big_frame, text="Change name of destination", variable=overwrite_var, value=False
        )
        r1.grid(row=1, column=0, columnspan=2, sticky="ew")
        r2.grid(row=2, column=0, columnspan=2, pady=(5, 20), sticky="ew")

        r1.invoke()
        ok_button.config(state="normal")
        entry.config(state="disabled")

    assert mode in FilenameMode
    dialog_title, dialog_phrase = mode.value

    phrase_label.config(
        text=dialog_phrase.format(
            name=old_name,
            parent=target_dir,
            plus=(
                "What do you want to do with it?"
                if can_overwrite
                else "Choose a name that isn't in use."
            ),
        )
    )

    def update_dialog_state(*junk: object) -> None:
        if overwrite_var.get():
            ok_button.config(state="normal")
            entry.config(state="disabled")
            return

        entry.config(state="normal")
        name = entry.get()
        try:
            possible_new_path = (target_dir / "dummy").with_name(name)
        except ValueError:
            ok_button.config(state="disabled")
            return

        ok_button["state"] = "disabled" if possible_new_path.exists() else "normal"

    file_name_var.trace_add("write", update_dialog_state)
    overwrite_var.trace_add("write", update_dialog_state)
    entry.bind("<Return>", (lambda event: ok_button.invoke()), add=True)
    entry.bind("<Escape>", (lambda event: cancel_button.invoke()), add=True)
    entry.select_range(0, "end")
    entry.focus()

    dialog.title(dialog_title)
    dialog.resizable(False, False)
    dialog.wait_window()

    return new_path


# Not necessarily same concept as Porcupine's project root
def find_git_root(path: Path) -> Path | None:
    for parent in path.parents:
        if (parent / ".git").is_dir():
            return parent
    return None


def move_with_git_or_otherwise(old_path: Path, new_path: Path) -> bool:
    old_git = find_git_root(old_path)
    new_git = find_git_root(new_path)

    git_mv_worked = False
    if old_git is not None and new_git is not None and old_git == new_git:
        log.info(f"attemting 'git mv' ({old_path} --> {new_path})")
        try:
            subprocess.check_call(
                ["git", "mv", "--", str(old_path), str(new_path)],
                cwd=old_path.parent,
                **utils.subprocess_kwargs,
            )
            git_mv_worked = True
        except (OSError, subprocess.CalledProcessError):
            # Happens when:
            #   - git not installed
            #   - old_path is not 'git add'ed
            pass

    if not git_mv_worked:
        log.info(f"moving without git ({old_path} --> {new_path})")
        try:
            shutil.move(str(old_path), str(new_path))
        except OSError as e:
            show_error("Moving failed", f"Cannot move {old_path} to {new_path}.", e)
            return False  # give up

    for tab in find_tabs_by_parent_path(old_path):
        assert tab.path is not None
        tab.path = new_path / tab.path.relative_to(old_path)
    return True


def rename(old_path: Path) -> None:
    new_path = ask_file_name(old_path.parent, old_path.name, mode=FilenameMode.RENAME)
    if new_path is not None:
        if move_with_git_or_otherwise(old_path, new_path):
            get_tab_manager().event_generate("<<FileSystemChanged>>")
            get_directory_tree().select_file(new_path)


def paste(new_path: Path) -> None:
{'fraction_non_alphanumeric': 0.11676783004552352, 'fraction_numerical': 0.00819423368740516, 'mean_word_length': 2.980972515856237, 'pattern_counts': {'":': 2, '<': 11, '<?xml version=': 0, '>': 33, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '3200761', 'n_tokens_mistral': 4334, 'n_tokens_neox': 4174, 'n_words': 1018}