Building a dual-pane file copy tool in Python with Robocopy under the hood

14 August 2025 ยท Luke Gillmore-White

Windows Explorer’s built-in copy dialog is fine for the occasional file, but it falls apart for anything serious โ€” no reliable retry on network hiccups, no proper multithreading, and no easy way to verify a large transfer actually completed correctly. I wanted something better for moving large batches of files between drives and network shares, so I built my own.

Why not just use Robocopy directly?

Robocopy is genuinely excellent โ€” it’s Microsoft’s own robust copy utility, built into every recent Windows version, and it handles retries, multithreading, and long paths far better than Explorer ever will. The problem is it’s a command-line tool with a syntax that’s easy to get wrong, and it gives you no visual feedback beyond a stream of text scrolling past.

The plan was to keep Robocopy doing the actual heavy lifting โ€” since there’s no point reinventing a reliable copy engine โ€” and build a proper GUI on top of it that shows real progress, lets you pick source and destination visually, and verifies the result afterwards.

Choosing the stack

Python for the application logic โ€” quick to iterate, and calling out to Robocopy as a subprocess is trivial.

CustomTkinter for the UI โ€” a modern-looking wrapper around Python’s built-in Tkinter. Standard Tkinter looks dated out of the box; CustomTkinter gives rounded corners, proper dark mode, and a much more polished feel without needing a heavier framework like PyQt.

PyInstaller to package the finished app into a single standalone .exe โ€” so it runs on any Windows machine without needing Python installed.

The dual-pane layout

The interface mirrors the classic dual-pane file manager style โ€” source on the left, destination on the right, with the transfer controls in between. Each pane has its own folder browser, and you can navigate independently on both sides before starting a copy.

import customtkinter as ctk

ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")

class FileCopyApp(ctk.CTk):
    def __init__(self):
        super().__init__()
        self.title("File Copy Tool")
        self.geometry("1000x600")

        self.source_pane = FilePane(self, label="Source")
        self.source_pane.grid(row=0, column=0, sticky="nsew", padx=10, pady=10)

        self.dest_pane = FilePane(self, label="Destination")
        self.dest_pane.grid(row=0, column=2, sticky="nsew", padx=10, pady=10)

        self.controls = ctk.CTkFrame(self)
        self.controls.grid(row=0, column=1, sticky="ns", padx=5)

Each FilePane is its own widget class handling its own folder tree, current path label, and selection state โ€” keeping the two sides completely decoupled until a copy operation ties them together.

Wrapping Robocopy

The core of the tool is a wrapper function that builds a Robocopy command from the UI’s selected source, destination, and options, then runs it as a subprocess and parses its output line by line to drive a progress bar.

import subprocess
import threading

def run_robocopy(source, destination, progress_callback):
    cmd = [
        "robocopy",
        source,
        destination,
        "/E",           # copy subdirectories, including empty ones
        "/MT:16",       # multithreaded, 16 threads
        "/R:3",         # retry 3 times on failed copies
        "/W:5",         # wait 5 seconds between retries
        "/NFL", "/NDL", # no file/dir list in output (cleaner parsing)
        "/BYTES",       # show sizes in bytes for consistent parsing
    ]

    process = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        universal_newlines=True,
        creationflags=subprocess.CREATE_NO_WINDOW,
    )

    for line in process.stdout:
        progress_callback(line.strip())

    process.wait()
    return process.returncode

Running this in a background thread (via Python’s threading module) keeps the UI responsive during large transfers โ€” without it, CustomTkinter’s main loop would freeze for the entire duration of the copy.

/MT:16 is doing a lot of the real work here โ€” Robocopy’s multithreaded mode dramatically speeds up transfers involving many small files, since it can copy several files in parallel rather than one at a time. For a handful of huge files it matters less, but for folders with thousands of small files it’s the difference between minutes and seconds.

Progress tracking

Robocopy’s console output includes percentage-complete lines for each file it copies. The wrapper parses these out with a regex and updates a CTkProgressBar in real time:

import re

progress_pattern = re.compile(r"(\d+)%")

def parse_progress_line(line):
    match = progress_pattern.search(line)
    if match:
        return int(match.group(1))
    return None

Combined with a running count of files copied versus total files (calculated up front by walking the source directory), the UI shows both an overall percentage and a “X of Y files” counter.

Size verification

Once Robocopy reports it’s finished, the tool doesn’t just trust the exit code โ€” it independently walks both the source and destination directories, sums the total byte size of each, and compares them.

import os

def get_directory_size(path):
    total = 0
    for dirpath, _, filenames in os.walk(path):
        for f in filenames:
            fp = os.path.join(dirpath, f)
            if os.path.exists(fp):
                total += os.path.getsize(fp)
    return total

def verify_copy(source, destination):
    source_size = get_directory_size(source)
    dest_size = get_directory_size(destination)
    return source_size == dest_size, source_size, dest_size

If the sizes don’t match, the app flags it clearly rather than silently reporting success โ€” this catches the rare case of a file that failed to copy but didn’t trigger a Robocopy error (for example, a file that was open and locked by another process at the exact moment of the transfer).

Packaging with PyInstaller

To make the tool usable without a Python environment installed, it’s bundled into a single executable:

pyinstaller --onefile --windowed --icon=app_icon.ico --name="FileCopyTool" main.py
  • --onefile bundles everything into a single .exe rather than a folder of dependencies
  • --windowed suppresses the console window, since this is a GUI app
  • --icon sets a proper taskbar/executable icon rather than the default Python one

The resulting executable is a few dozen megabytes (Python’s runtime and CustomTkinter’s dependencies add up) but runs standalone on any Windows 10/11 machine.

Result

The finished tool handles exactly the workflow I wanted: pick a source and destination visually, kick off a multithreaded Robocopy transfer with sane defaults, watch real progress rather than a spinning cursor, and get a clear confirmation that the copy actually completed correctly โ€” all wrapped in a single executable I can drop on any machine without installing anything.