Skip to content

API Reference

This page provides documentation for Recursivist's Python API, automatically generated from the source code's docstrings, which can be used to integrate directory visualization and analysis capabilities into your own Python applications.

Core Module

recursivist.core

Core functionality for the Recursivist directory visualization tool.

This module provides the fundamental components for building, filtering, displaying, and exporting directory structures. It handles directory traversal, pattern matching, color coding, file statistics calculation, and tree construction.

Key components: - Directory structure parsing and representation - Pattern-based filtering (gitignore, glob, regex) - File extension color coding - Tree visualization with rich formatting - Lines of code counting - File size calculation and formatting - Modification time retrieval and formatting - Maximum depth limiting

build_tree(structure, tree, color_map, parent_name='Root', show_full_path=False, sort_by_loc=False, sort_by_size=False, sort_by_mtime=False, show_git_status=False, icon_style='emoji')

Build the tree structure with colored file names.

Recursively builds a rich.Tree representation of the directory structure with files color-coded by extension.

When sort_by_loc is True, displays lines of code counts for files and directories. When sort_by_size is True, displays file sizes for files and directories. When sort_by_mtime is True, displays file modification times. When show_git_status is True, appends a coloured Git status marker to each file: [U] untracked (grey), [M] modified (yellow), [A] added (green), [D] deleted (red). Deleted files that no longer exist on disk are shown with a strikethrough style.

Parameters:

Name Type Description Default
structure dict[str, Any]

Dictionary representation of the directory structure

required
tree Tree

Rich Tree object to build upon

required
color_map dict[str, str]

Mapping of file extensions to colors

required
parent_name str

Name of the parent directory

'Root'
show_full_path bool

Whether to show full paths instead of just filenames

False
sort_by_loc bool

Whether to display lines of code counts

False
sort_by_size bool

Whether to display file sizes

False
sort_by_mtime bool

Whether to display file modification times

False
show_git_status bool

Whether to display Git status markers

False
Source code in recursivist/core.py
def build_tree(
    structure: dict[str, Any],
    tree: Tree,
    color_map: dict[str, str],
    parent_name: str = "Root",
    show_full_path: bool = False,
    sort_by_loc: bool = False,
    sort_by_size: bool = False,
    sort_by_mtime: bool = False,
    show_git_status: bool = False,
    icon_style: str = "emoji",
) -> None:
    """Build the tree structure with colored file names.

    Recursively builds a rich.Tree representation of the directory structure with files color-coded by extension.

    When sort_by_loc is True, displays lines of code counts for files and directories.
    When sort_by_size is True, displays file sizes for files and directories.
    When sort_by_mtime is True, displays file modification times.
    When show_git_status is True, appends a coloured Git status marker to each file:
    ``[U]`` untracked (grey), ``[M]`` modified (yellow), ``[A]`` added (green),
    ``[D]`` deleted (red). Deleted files that no longer exist on disk are shown
    with a strikethrough style.

    Args:
        structure: Dictionary representation of the directory structure
        tree: Rich Tree object to build upon
        color_map: Mapping of file extensions to colors
        parent_name: Name of the parent directory
        show_full_path: Whether to show full paths instead of just filenames
        sort_by_loc: Whether to display lines of code counts
        sort_by_size: Whether to display file sizes
        sort_by_mtime: Whether to display file modification times
        show_git_status: Whether to display Git status markers
    """
    _GIT_MARKER_STYLES = {
        "U": ("dim", "[U]"),
        "M": ("yellow", "[M]"),
        "A": ("green", "[A]"),
        "D": ("red", "[D]"),
    }
    git_markers_dict: dict[str, str] = (
        structure.get("_git_markers", {}) if show_git_status else {}
    )
    for folder, content in sorted(structure.items()):
        if folder == "_files":
            for file_item in sort_files_by_type(
                content, sort_by_loc, sort_by_size, sort_by_mtime
            ):
                file_name = ""
                full_path = ""
                loc = 0
                size = 0
                mtime = 0.0
                if isinstance(file_item, tuple):
                    file_name = file_item[0]
                    if len(file_item) > 1:
                        full_path = file_item[1]
                    else:
                        full_path = file_name
                    if len(file_item) > 2:
                        if (
                            sort_by_loc
                            and sort_by_size
                            and sort_by_mtime
                            and len(file_item) > 4
                        ):
                            loc = file_item[2]
                            size = file_item[3]
                            mtime = file_item[4]
                        elif sort_by_loc and sort_by_size and len(file_item) > 3:
                            loc = file_item[2]
                            size = file_item[3]
                        elif sort_by_loc and sort_by_mtime and len(file_item) > 4:
                            loc = file_item[2]
                            mtime = file_item[4]
                        elif sort_by_size and sort_by_mtime and len(file_item) > 4:
                            size = file_item[3]
                            mtime = file_item[4]
                        elif sort_by_loc and len(file_item) > 2:
                            loc = file_item[2]
                        elif sort_by_size and len(file_item) > 2:
                            size = file_item[2]
                        elif sort_by_mtime and len(file_item) > 2:
                            mtime = file_item[2]
                else:
                    file_name = file_item
                    full_path = file_name
                display_path = full_path if show_full_path else file_name
                ext = os.path.splitext(file_name)[1].lower()
                color = color_map.get(ext, "#FFFFFF")

                git_marker = git_markers_dict.get(file_name, "")
                is_deleted = git_marker == "D"

                name_style = f"{color} strike" if is_deleted else color

                colored_text = Text()
                icon = get_icon(file_name, is_dir=False, style=icon_style)
                colored_text.append(f"{icon} ", style=color)

                if sort_by_loc and sort_by_size and sort_by_mtime and loc > 0:
                    colored_text.append(
                        f"{display_path} ({loc} lines, {format_size(size)}, {format_timestamp(mtime)})",
                        style=name_style,
                    )
                elif sort_by_loc and sort_by_mtime and loc > 0:
                    colored_text.append(
                        f"{display_path} ({loc} lines, {format_timestamp(mtime)})",
                        style=name_style,
                    )
                elif sort_by_size and sort_by_mtime and size > 0:
                    colored_text.append(
                        f"{display_path} ({format_size(size)}, {format_timestamp(mtime)})",
                        style=name_style,
                    )
                elif sort_by_loc and sort_by_size and loc > 0:
                    colored_text.append(
                        f"{display_path} ({loc} lines, {format_size(size)})",
                        style=name_style,
                    )
                elif sort_by_loc and loc > 0:
                    colored_text.append(
                        f"{display_path} ({loc} lines)",
                        style=name_style,
                    )
                elif sort_by_size and size > 0:
                    colored_text.append(
                        f"{display_path} ({format_size(size)})",
                        style=name_style,
                    )
                elif sort_by_mtime and mtime > 0:
                    colored_text.append(
                        f"{display_path} ({format_timestamp(mtime)})",
                        style=name_style,
                    )
                else:
                    colored_text.append(display_path, style=name_style)

                if show_git_status and git_marker:
                    marker_style, badge = _GIT_MARKER_STYLES.get(
                        git_marker, ("dim", f"[{git_marker}]")
                    )
                    colored_text.append(f" {badge}", style=marker_style)

                tree.add(colored_text)
        elif (
            folder == "_loc"
            or folder == "_size"
            or folder == "_mtime"
            or folder == "_max_depth_reached"
            or folder == "_git_markers"
        ):
            pass
        else:
            folder_icon = get_icon(folder, is_dir=True, style=icon_style)
            folder_display = f"{folder_icon} {folder}"
            if (
                sort_by_loc
                and sort_by_size
                and sort_by_mtime
                and isinstance(content, dict)
            ):
                if "_loc" in content and "_size" in content and "_mtime" in content:
                    folder_loc = content["_loc"]
                    folder_size = content["_size"]
                    folder_mtime = content["_mtime"]
                    folder_display = f"{folder_icon} {folder} ({folder_loc} lines, {format_size(folder_size)}, {format_timestamp(folder_mtime)})"
            elif sort_by_loc and sort_by_size and isinstance(content, dict):
                if "_loc" in content and "_size" in content:
                    folder_loc = content["_loc"]
                    folder_size = content["_size"]
                    folder_display = f"{folder_icon} {folder} ({folder_loc} lines, {format_size(folder_size)})"
            elif sort_by_loc and sort_by_mtime and isinstance(content, dict):
                if "_loc" in content and "_mtime" in content:
                    folder_loc = content["_loc"]
                    folder_mtime = content["_mtime"]
                    folder_display = f"{folder_icon} {folder} ({folder_loc} lines, {format_timestamp(folder_mtime)})"
            elif sort_by_size and sort_by_mtime and isinstance(content, dict):
                if "_size" in content and "_mtime" in content:
                    folder_size = content["_size"]
                    folder_mtime = content["_mtime"]
                    folder_display = f"{folder_icon} {folder} ({format_size(folder_size)}, {format_timestamp(folder_mtime)})"
            elif sort_by_loc and isinstance(content, dict) and "_loc" in content:
                folder_loc = content["_loc"]
                folder_display = f"{folder_icon} {folder} ({folder_loc} lines)"
            elif sort_by_size and isinstance(content, dict) and "_size" in content:
                folder_size = content["_size"]
                folder_display = f"{folder_icon} {folder} ({format_size(folder_size)})"
            elif sort_by_mtime and isinstance(content, dict) and "_mtime" in content:
                folder_mtime = content["_mtime"]
                folder_display = (
                    f"{folder_icon} {folder} ({format_timestamp(folder_mtime)})"
                )
            subtree = tree.add(folder_display)
            if isinstance(content, dict) and content.get("_max_depth_reached"):
                subtree.add(Text("⋯ (max depth reached)", style="dim"))
            else:
                build_tree(
                    content,
                    subtree,
                    color_map,
                    folder,
                    show_full_path,
                    sort_by_loc,
                    sort_by_size,
                    sort_by_mtime,
                    show_git_status,
                    icon_style,
                )

color_distance(color1, color2)

Calculate the perceptual distance between two RGB colors.

Uses a weighted Euclidean distance formula that approximates human color perception by emphasising the green channel over red and blue.

Parameters:

Name Type Description Default
color1 tuple[int, int, int]

First color as an (r, g, b) tuple with component values in the range 0255.

required
color2 tuple[int, int, int]

Second color as an (r, g, b) tuple with component values in the range 0255.

required

Returns:

Type Description
float

A non-negative float representing the perceptual distance; 0.0

float

means the colors are identical and larger values indicate greater

float

visual difference.

Source code in recursivist/core.py
def color_distance(color1: tuple[int, int, int], color2: tuple[int, int, int]) -> float:
    """Calculate the perceptual distance between two RGB colors.

    Uses a weighted Euclidean distance formula that approximates human color
    perception by emphasising the green channel over red and blue.

    Args:
        color1: First color as an ``(r, g, b)`` tuple with component values
            in the range ``0``\u2013``255``.
        color2: Second color as an ``(r, g, b)`` tuple with component values
            in the range ``0``\u2013``255``.

    Returns:
        A non-negative float representing the perceptual distance; ``0.0``
        means the colors are identical and larger values indicate greater
        visual difference.
    """
    r1, g1, b1 = [x / 255 for x in color1]
    r2, g2, b2 = [x / 255 for x in color2]
    r_weight, g_weight, b_weight = 0.3, 0.59, 0.11
    dist = math.sqrt(
        r_weight * (r1 - r2) ** 2
        + g_weight * (g1 - g2) ** 2
        + b_weight * (b1 - b2) ** 2
    )
    return dist

compile_regex_patterns(patterns, is_regex=False)

Convert patterns to compiled regex objects when appropriate.

When is_regex is True, compiles string patterns into regex pattern objects for efficient matching. For invalid regex patterns, logs a warning and keeps them as strings.

Parameters:

Name Type Description Default
patterns Sequence[str]

List of patterns to compile

required
is_regex bool

Whether the patterns should be treated as regex (True) or glob patterns (False)

False

Returns:

Type Description
list[Union[str, Pattern[str]]]

List of patterns (strings for glob patterns or compiled regex objects)

Source code in recursivist/core.py
def compile_regex_patterns(
    patterns: Sequence[str], is_regex: bool = False
) -> list[Union[str, Pattern[str]]]:
    """Convert patterns to compiled regex objects when appropriate.

    When is_regex is True, compiles string patterns into regex pattern objects for efficient matching. For invalid regex patterns, logs a warning and keeps them as strings.

    Args:
        patterns: List of patterns to compile
        is_regex: Whether the patterns should be treated as regex (True) or glob patterns (False)

    Returns:
        List of patterns (strings for glob patterns or compiled regex objects)
    """
    if not is_regex:
        return cast(list[Union[str, Pattern[str]]], patterns)
    compiled_patterns: list[Union[str, Pattern[str]]] = []
    for pattern in patterns:
        try:
            compiled_patterns.append(re.compile(pattern))
        except re.error as e:
            logger.warning(f"Invalid regex pattern '{pattern}': {e}")
            compiled_patterns.append(pattern)
    return compiled_patterns

count_lines_of_code(file_path)

Count the number of lines in a file.

Counts lines in text files while handling encoding issues and skipping binary files. Properly distinguishes between binary files with null bytes and UTF-16 encoded text files.

Parameters:

Name Type Description Default
file_path str

Path to the file

required

Returns:

Type Description
int

Number of lines in the file, or 0 if the file cannot be read or is binary

Source code in recursivist/core.py
def count_lines_of_code(file_path: str) -> int:
    """Count the number of lines in a file.

    Counts lines in text files while handling encoding issues and skipping binary files.
    Properly distinguishes between binary files with null bytes and UTF-16 encoded text files.

    Args:
        file_path: Path to the file

    Returns:
        Number of lines in the file, or 0 if the file cannot be read or is binary
    """
    if file_path.lower().endswith(".bin"):
        return 0
    try:
        with open(file_path, "rb") as binary_file:
            sample = binary_file.read(4096)
            if not sample:
                return 0
            utf16_le_bom = sample.startswith(b"\xff\xfe")
            utf16_be_bom = sample.startswith(b"\xfe\xff")
            if utf16_le_bom or utf16_be_bom:
                encoding = "utf-16-le" if utf16_le_bom else "utf-16-be"
                with open(file_path, encoding=encoding, errors="replace") as text_file:
                    return sum(1 for _ in text_file)
            potential_utf16le: bool = False
            potential_utf16be: bool = False
            if len(sample) >= 16:
                potential_utf16le = all(
                    sample[i] == 0 for i in range(1, min(32, len(sample)), 2)
                )
                potential_utf16be = all(
                    sample[i] == 0 for i in range(0, min(32, len(sample)), 2)
                )
                if potential_utf16le or potential_utf16be:
                    encoding = "utf-16-le" if potential_utf16le else "utf-16-be"
                    try:
                        with open(
                            file_path, encoding=encoding, errors="replace"
                        ) as text_file:
                            return sum(1 for _ in text_file)
                    except Exception:
                        pass
            if b"\x00" in sample and not (potential_utf16le or potential_utf16be):
                return 0
    except Exception as e:
        logger.debug(f"Could not analyze file: {file_path}: {e}")
        return 0
    try:
        with open(file_path, encoding="utf-8", errors="strict") as text_file:
            return sum(1 for _ in text_file)
    except UnicodeDecodeError:
        try:
            with open(file_path, encoding="utf-16", errors="strict") as text_file:
                return sum(1 for _ in text_file)
        except Exception:
            pass
    except Exception as e:
        logger.debug(f"Could not read file as UTF-8: {file_path}: {e}")
        return 0
    try:
        with open(file_path, encoding="utf-8", errors="replace") as text_file:
            return sum(1 for _ in text_file)
    except Exception as e:
        logger.debug(f"Could not analyze file with replacement: {file_path}: {e}")
        return 0

display_tree(root_dir, exclude_dirs=None, ignore_file=None, exclude_extensions=None, exclude_patterns=None, include_patterns=None, use_regex=False, max_depth=0, show_full_path=False, sort_by_loc=False, sort_by_size=False, sort_by_mtime=False, show_git_status=False, icon_style='emoji')

Display a directory tree in the terminal with rich formatting.

Presents a directory structure as a tree with: - Color-coded file extensions - Optional statistics (lines of code, sizes, modification times) - Filtered content based on exclusion patterns - Depth limitations if specified - Optional Git status markers (when show_git_status is True)

This function handles the entire process from scanning the directory to displaying the final tree visualization.

Parameters:

Name Type Description Default
root_dir str

Root directory path to display

required
exclude_dirs Optional[list[str]]

List of directory names to exclude

None
ignore_file Optional[str]

Name of ignore file (like .gitignore)

None
exclude_extensions Optional[set[str]]

Set of file extensions to exclude

None
exclude_patterns Optional[list[str]]

List of patterns to exclude

None
include_patterns Optional[list[str]]

List of patterns to include (overrides exclusions)

None
use_regex bool

Whether to treat patterns as regex instead of glob patterns

False
max_depth int

Maximum depth to display (0 for unlimited)

0
show_full_path bool

Whether to show full paths instead of just filenames

False
sort_by_loc bool

Whether to show and sort by lines of code

False
sort_by_size bool

Whether to show and sort by file size

False
sort_by_mtime bool

Whether to show and sort by modification time

False
show_git_status bool

Whether to annotate files with Git status markers

False
icon_style str

Style for displaying icons ("emoji" or "nerd")

'emoji'
Source code in recursivist/core.py
def display_tree(
    root_dir: str,
    exclude_dirs: Optional[list[str]] = None,
    ignore_file: Optional[str] = None,
    exclude_extensions: Optional[set[str]] = None,
    exclude_patterns: Optional[list[str]] = None,
    include_patterns: Optional[list[str]] = None,
    use_regex: bool = False,
    max_depth: int = 0,
    show_full_path: bool = False,
    sort_by_loc: bool = False,
    sort_by_size: bool = False,
    sort_by_mtime: bool = False,
    show_git_status: bool = False,
    icon_style: str = "emoji",
) -> None:
    """Display a directory tree in the terminal with rich formatting.

    Presents a directory structure as a tree with:
    - Color-coded file extensions
    - Optional statistics (lines of code, sizes, modification times)
    - Filtered content based on exclusion patterns
    - Depth limitations if specified
    - Optional Git status markers (when show_git_status is True)

    This function handles the entire process from scanning the directory to displaying the final tree visualization.

    Args:
        root_dir: Root directory path to display
        exclude_dirs: List of directory names to exclude
        ignore_file: Name of ignore file (like .gitignore)
        exclude_extensions: Set of file extensions to exclude
        exclude_patterns: List of patterns to exclude
        include_patterns: List of patterns to include (overrides exclusions)
        use_regex: Whether to treat patterns as regex instead of glob patterns
        max_depth: Maximum depth to display (0 for unlimited)
        show_full_path: Whether to show full paths instead of just filenames
        sort_by_loc: Whether to show and sort by lines of code
        sort_by_size: Whether to show and sort by file size
        sort_by_mtime: Whether to show and sort by modification time
        show_git_status: Whether to annotate files with Git status markers
        icon_style: Style for displaying icons ("emoji" or "nerd")
    """
    if exclude_dirs is None:
        exclude_dirs = []
    if exclude_extensions is None:
        exclude_extensions = set()
    if exclude_patterns is None:
        exclude_patterns = []
    if include_patterns is None:
        include_patterns = []
    exclude_extensions = {
        ext.lower() if ext.startswith(".") else f".{ext.lower()}"
        for ext in exclude_extensions
    }
    compiled_exclude = compile_regex_patterns(exclude_patterns, use_regex)
    compiled_include = compile_regex_patterns(include_patterns, use_regex)

    git_status_map: Optional[dict[str, str]] = None
    if show_git_status:
        git_status_map = get_git_status(root_dir)
        if not git_status_map:
            logger.debug(
                "Git status requested but no data returned — "
                "directory may not be inside a Git repository, or there are no changes."
            )

    structure, extensions = get_directory_structure(
        root_dir=root_dir,
        exclude_dirs=exclude_dirs,
        ignore_file=ignore_file,
        exclude_extensions=exclude_extensions,
        parent_ignore_patterns=None,
        exclude_patterns=compiled_exclude,
        include_patterns=compiled_include,
        max_depth=max_depth,
        show_full_path=show_full_path,
        sort_by_loc=sort_by_loc,
        sort_by_size=sort_by_size,
        sort_by_mtime=sort_by_mtime,
        show_git_status=show_git_status,
        git_status_map=git_status_map,
    )
    color_map = {ext: generate_color_for_extension(ext) for ext in extensions}
    console = Console()

    root_base = os.path.basename(root_dir)
    root_icon = get_icon(root_base, is_dir=True, style=icon_style)
    root_label = f"{root_icon} {root_base}"
    if (
        sort_by_loc
        and sort_by_size
        and sort_by_mtime
        and "_loc" in structure
        and "_size" in structure
        and "_mtime" in structure
    ):
        root_label = f"{root_icon} {root_base} ({structure['_loc']} lines, {format_size(structure['_size'])}, {format_timestamp(structure['_mtime'])})"
    elif sort_by_loc and sort_by_size and "_loc" in structure and "_size" in structure:
        root_label = f"{root_icon} {root_base} ({structure['_loc']} lines, {format_size(structure['_size'])})"
    elif (
        sort_by_loc and sort_by_mtime and "_loc" in structure and "_mtime" in structure
    ):
        root_label = f"{root_icon} {root_base} ({structure['_loc']} lines, {format_timestamp(structure['_mtime'])})"
    elif (
        sort_by_size
        and sort_by_mtime
        and "_size" in structure
        and "_mtime" in structure
    ):
        root_label = f"{root_icon} {root_base} ({format_size(structure['_size'])}, {format_timestamp(structure['_mtime'])})"
    elif sort_by_loc and "_loc" in structure:
        root_label = f"{root_icon} {root_base} ({structure['_loc']} lines)"
    elif sort_by_size and "_size" in structure:
        root_label = f"{root_icon} {root_base} ({format_size(structure['_size'])})"
    elif sort_by_mtime and "_mtime" in structure:
        root_label = (
            f"{root_icon} {root_base} ({format_timestamp(structure['_mtime'])})"
        )
    tree = Tree(root_label)
    build_tree(
        structure,
        tree,
        color_map,
        show_full_path=show_full_path,
        sort_by_loc=sort_by_loc,
        sort_by_size=sort_by_size,
        sort_by_mtime=sort_by_mtime,
        show_git_status=show_git_status,
        icon_style=icon_style,
    )
    console.print(tree)

export_structure(structure, root_dir, format_type, output_path, show_full_path=False, sort_by_loc=False, sort_by_size=False, sort_by_mtime=False, show_git_status=False, icon_style='emoji')

Export the directory structure to various formats.

Maps the requested format to the appropriate export method using DirectoryExporter. Handles txt, json, html, md, and jsx formats with consistent styling.

Parameters:

Name Type Description Default
structure dict[str, Any]

Directory structure dictionary

required
root_dir str

Root directory name

required
format_type str

Export format ('txt', 'json', 'html', 'md', 'jsx')

required
output_path str

Path where the export file will be saved

required
show_full_path bool

Whether to show full paths instead of just filenames

False
sort_by_loc bool

Whether to include lines of code counts in the export

False
sort_by_size bool

Whether to include file size information in the export

False
sort_by_mtime bool

Whether to include file modification times in the export

False
show_git_status bool

Whether to annotate files with Git status markers

False

Raises:

Type Description
ValueError

If the format_type is not supported

Source code in recursivist/core.py
def export_structure(
    structure: dict[str, Any],
    root_dir: str,
    format_type: str,
    output_path: str,
    show_full_path: bool = False,
    sort_by_loc: bool = False,
    sort_by_size: bool = False,
    sort_by_mtime: bool = False,
    show_git_status: bool = False,
    icon_style: str = "emoji",
) -> None:
    """Export the directory structure to various formats.

    Maps the requested format to the appropriate export method using DirectoryExporter. Handles txt, json, html, md, and jsx formats with consistent styling.

    Args:
        structure: Directory structure dictionary
        root_dir: Root directory name
        format_type: Export format ('txt', 'json', 'html', 'md', 'jsx')
        output_path: Path where the export file will be saved
        show_full_path: Whether to show full paths instead of just filenames
        sort_by_loc: Whether to include lines of code counts in the export
        sort_by_size: Whether to include file size information in the export
        sort_by_mtime: Whether to include file modification times in the export
        show_git_status: Whether to annotate files with Git status markers

    Raises:
        ValueError: If the format_type is not supported
    """
    if format_type.lower() == "svg":
        export_to_svg(
            structure=structure,
            root_dir=root_dir,
            output_path=output_path,
            show_full_path=show_full_path,
            sort_by_loc=sort_by_loc,
            sort_by_size=sort_by_size,
            sort_by_mtime=sort_by_mtime,
            show_git_status=show_git_status,
            icon_style=icon_style,
        )
        return

    from recursivist.exports import DirectoryExporter

    exporter = DirectoryExporter(
        structure,
        os.path.basename(root_dir),
        root_dir if show_full_path else None,
        sort_by_loc,
        sort_by_size,
        sort_by_mtime,
        show_git_status,
        icon_style=icon_style,
    )
    format_map = {
        "txt": exporter.to_txt,
        "json": exporter.to_json,
        "html": exporter.to_html,
        "md": exporter.to_markdown,
        "jsx": exporter.to_jsx,
    }
    if format_type.lower() not in format_map:
        raise ValueError(f"Unsupported format: {format_type}")
    export_func = format_map[format_type.lower()]
    export_func(output_path)

export_to_svg(structure, root_dir, output_path, show_full_path=False, sort_by_loc=False, sort_by_size=False, sort_by_mtime=False, show_git_status=False, icon_style='emoji')

Export the directory structure to an SVG image.

Renders the directory tree using Rich's SVG export capability, producing a self-contained SVG file suitable for embedding in documentation or sharing as a standalone image. The tree is rendered with the same color coding and statistics annotations as the terminal output.

Parameters:

Name Type Description Default
structure dict[str, Any]

Directory structure dictionary produced by get_directory_structure.

required
root_dir str

Absolute path to the root directory; its basename is used as the tree root label and the SVG title.

required
output_path str

Destination file path for the exported SVG.

required
show_full_path bool

When True, display absolute file paths instead of bare filenames.

False
sort_by_loc bool

When True, annotate files and directories with lines- of-code counts and sort files by LOC descending.

False
sort_by_size bool

When True, annotate files and directories with their sizes and sort files by size descending.

False
sort_by_mtime bool

When True, annotate files and directories with their modification timestamps and sort files by newest first.

False
show_git_status bool

When True, append Git status markers to each file ([U] untracked, [M] modified, [A] added, [D] deleted).

False
Source code in recursivist/core.py
def export_to_svg(
    structure: dict[str, Any],
    root_dir: str,
    output_path: str,
    show_full_path: bool = False,
    sort_by_loc: bool = False,
    sort_by_size: bool = False,
    sort_by_mtime: bool = False,
    show_git_status: bool = False,
    icon_style: str = "emoji",
) -> None:
    """Export the directory structure to an SVG image.

    Renders the directory tree using Rich's SVG export capability, producing
    a self-contained SVG file suitable for embedding in documentation or
    sharing as a standalone image. The tree is rendered with the same color
    coding and statistics annotations as the terminal output.

    Args:
        structure: Directory structure dictionary produced by
            ``get_directory_structure``.
        root_dir: Absolute path to the root directory; its basename is used
            as the tree root label and the SVG title.
        output_path: Destination file path for the exported SVG.
        show_full_path: When ``True``, display absolute file paths instead of
            bare filenames.
        sort_by_loc: When ``True``, annotate files and directories with lines-
            of-code counts and sort files by LOC descending.
        sort_by_size: When ``True``, annotate files and directories with their
            sizes and sort files by size descending.
        sort_by_mtime: When ``True``, annotate files and directories with
            their modification timestamps and sort files by newest first.
        show_git_status: When ``True``, append Git status markers to each
            file (``[U]`` untracked, ``[M]`` modified, ``[A]`` added,
            ``[D]`` deleted).
    """

    def extract_extensions(struct: dict[str, Any]) -> set[str]:
        exts = set()
        for k, v in struct.items():
            if k == "_files":
                for f in v:
                    name = f[0] if isinstance(f, tuple) else f
                    exts.add(os.path.splitext(name)[1].lower())
            elif isinstance(v, dict):
                exts.update(extract_extensions(v))
        return exts

    extensions = extract_extensions(structure)
    color_map = {ext: generate_color_for_extension(ext) for ext in extensions}

    root_name = os.path.basename(root_dir)
    root_icon = get_icon(root_name, is_dir=True, style=icon_style)
    root_label = f"{root_icon} {root_name}"

    if (
        sort_by_loc
        and sort_by_size
        and sort_by_mtime
        and "_loc" in structure
        and "_size" in structure
        and "_mtime" in structure
    ):
        root_label = f"{root_icon} {root_name} ({structure['_loc']} lines, {format_size(structure['_size'])}, {format_timestamp(structure['_mtime'])})"
    elif sort_by_loc and sort_by_size and "_loc" in structure and "_size" in structure:
        root_label = f"{root_icon} {root_name} ({structure['_loc']} lines, {format_size(structure['_size'])})"
    elif (
        sort_by_loc and sort_by_mtime and "_loc" in structure and "_mtime" in structure
    ):
        root_label = f"{root_icon} {root_name} ({structure['_loc']} lines, {format_timestamp(structure['_mtime'])})"
    elif (
        sort_by_size
        and sort_by_mtime
        and "_size" in structure
        and "_mtime" in structure
    ):
        root_label = f"{root_icon} {root_name} ({format_size(structure['_size'])}, {format_timestamp(structure['_mtime'])})"
    elif sort_by_loc and "_loc" in structure:
        root_label = f"{root_icon} {root_name} ({structure['_loc']} lines)"
    elif sort_by_size and "_size" in structure:
        root_label = f"{root_icon} {root_name} ({format_size(structure['_size'])})"
    elif sort_by_mtime and "_mtime" in structure:
        root_label = (
            f"{root_icon} {root_name} ({format_timestamp(structure['_mtime'])})"
        )

    tree = Tree(root_label)

    build_tree(
        structure,
        tree,
        color_map,
        show_full_path=show_full_path,
        sort_by_loc=sort_by_loc,
        sort_by_size=sort_by_size,
        sort_by_mtime=sort_by_mtime,
        show_git_status=show_git_status,
        icon_style=icon_style,
    )
    console = Console(record=True, width=120)
    console.print(tree)
    console.save_svg(output_path, title=f"Directory Structure - {root_name}")

format_size(size_in_bytes)

Format a size in bytes to a human-readable string.

Converts raw byte counts to appropriate units (B, KB, MB, GB) with consistent formatting.

Parameters:

Name Type Description Default
size_in_bytes int

Size in bytes

required

Returns:

Type Description
str

Human-readable size string (e.g., "4.2 MB")

Source code in recursivist/core.py
def format_size(size_in_bytes: int) -> str:
    """Format a size in bytes to a human-readable string.

    Converts raw byte counts to appropriate units (B, KB, MB, GB) with consistent formatting.

    Args:
        size_in_bytes: Size in bytes

    Returns:
        Human-readable size string (e.g., "4.2 MB")
    """
    if size_in_bytes < 1024:
        return f"{size_in_bytes} B"
    elif size_in_bytes < 1024 * 1024:
        return f"{size_in_bytes / 1024:.1f} KB"
    elif size_in_bytes < 1024 * 1024 * 1024:
        return f"{size_in_bytes / (1024 * 1024):.1f} MB"
    else:
        return f"{size_in_bytes / (1024 * 1024 * 1024):.1f} GB"

format_timestamp(timestamp)

Format a Unix timestamp to a human-readable string.

Intelligently formats timestamps with different representations based on recency: - Today: "Today HH:MM" - Yesterday: "Yesterday HH:MM" - Last week: "Day HH:MM" (e.g., "Mon 14:30") - This year: "Month Day" (e.g., "Mar 15") - Older: "YYYY-MM-DD"

Parameters:

Name Type Description Default
timestamp float

Unix timestamp (seconds since epoch)

required

Returns:

Type Description
str

Human-readable date/time string

Source code in recursivist/core.py
def format_timestamp(timestamp: float) -> str:
    """Format a Unix timestamp to a human-readable string.

    Intelligently formats timestamps with different representations based on recency:
    - Today: "Today HH:MM"
    - Yesterday: "Yesterday HH:MM"
    - Last week: "Day HH:MM" (e.g., "Mon 14:30")
    - This year: "Month Day" (e.g., "Mar 15")
    - Older: "YYYY-MM-DD"

    Args:
        timestamp: Unix timestamp (seconds since epoch)

    Returns:
        Human-readable date/time string
    """
    dt_object = dt.fromtimestamp(timestamp)
    current_dt = dt.now()
    current_date = current_dt.date()
    if dt_object.date() == current_date:
        return f"Today {dt_object.strftime('%H:%M')}"
    elif dt_object.date() == current_date - datetime.timedelta(days=1):
        return f"Yesterday {dt_object.strftime('%H:%M')}"
    elif current_date - dt_object.date() < datetime.timedelta(days=7):
        return dt_object.strftime("%a %H:%M")
    elif dt_object.year == current_dt.year:
        return dt_object.strftime("%b %d")
    else:
        return dt_object.strftime("%Y-%m-%d")

generate_color_for_extension(extension)

Generate a consistent color for a file extension with collision detection.

Creates a deterministic color based on the extension string using a hash function. The same extension will always get the same color within a session, and different extensions get visually distinct colors.

Parameters:

Name Type Description Default
extension str

File extension (with or without leading dot)

required

Returns:

Type Description
str

Hex color code (e.g., "#FF5733")

Source code in recursivist/core.py
def generate_color_for_extension(extension: str) -> str:
    """Generate a consistent color for a file extension with collision detection.

    Creates a deterministic color based on the extension string using a hash function. The same extension will always get the same color within a session, and different extensions get visually distinct colors.

    Args:
        extension: File extension (with or without leading dot)

    Returns:
        Hex color code (e.g., "#FF5733")
    """
    global _EXTENSION_COLORS
    if not extension:
        return "#FFFFFF"
    normalized_ext = extension
    if not extension.startswith("."):
        normalized_ext = "." + extension
    if extension in _EXTENSION_COLORS:
        return _EXTENSION_COLORS[extension]
    if extension != normalized_ext and normalized_ext in _EXTENSION_COLORS:
        color = _EXTENSION_COLORS[normalized_ext]
        _EXTENSION_COLORS[extension] = color
        return color
    hash_bytes = hashlib.md5(normalized_ext.encode()).digest()
    hue_int = int.from_bytes(hash_bytes[0:4], byteorder="big")
    hue = (hue_int % 360) / 360.0
    sat_int = hash_bytes[4]
    saturation = 0.65 + (sat_int % 26) / 100.0
    val_int = hash_bytes[5]
    value = 0.85 + (val_int % 16) / 100.0
    min_acceptable_distance = 0.15
    max_attempts = 15
    rgb = colorsys.hsv_to_rgb(hue, saturation, value)
    initial_color = (int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255))
    if not _EXTENSION_COLORS:
        hex_color = "#{:02x}{:02x}{:02x}".format(*initial_color)
        _EXTENSION_COLORS[extension] = hex_color
        if extension != normalized_ext:
            _EXTENSION_COLORS[normalized_ext] = hex_color
        return hex_color
    best_color = initial_color
    best_min_distance = 0
    for attempt in range(max_attempts):
        test_hue = (hue + (attempt * 0.1)) % 1.0
        test_sat = min(1.0, saturation + (attempt * 0.02))
        test_val = max(0.8, value - (attempt * 0.01))
        rgb = colorsys.hsv_to_rgb(test_hue, test_sat, test_val)
        test_color = (int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255))
        min_distance = float("inf")
        for existing_color in _EXTENSION_COLORS.values():
            existing_rgb = hex_to_rgb(existing_color)
            distance = color_distance(test_color, existing_rgb)
            min_distance = min(min_distance, distance)
        if min_distance > best_min_distance:
            best_min_distance = int(min_distance)
            best_color = test_color
        if min_distance >= min_acceptable_distance:
            break
    hex_color = "#{:02x}{:02x}{:02x}".format(*best_color)
    _EXTENSION_COLORS[extension] = hex_color
    if extension != normalized_ext:
        _EXTENSION_COLORS[normalized_ext] = hex_color
    return hex_color

get_directory_structure(root_dir, exclude_dirs=None, ignore_file=None, exclude_extensions=None, parent_ignore_patterns=None, exclude_patterns=None, include_patterns=None, max_depth=0, current_depth=0, current_path='', show_full_path=False, sort_by_loc=False, sort_by_size=False, sort_by_mtime=False, show_git_status=False, git_status_map=None)

Build a nested dictionary representing a directory structure.

Recursively traverses the file system applying filters and collecting statistics.

The resulting structure contains: - Hierarchical representation of directories and files - Optional statistics (lines of code, sizes, modification times) - Filtered entries based on various exclusion patterns

Special dictionary keys: - "_files": List of files in the directory - "_loc": Total lines of code (if sort_by_loc is True) - "_size": Total size in bytes (if sort_by_size is True) - "_mtime": Latest modification timestamp (if sort_by_mtime is True) - "_max_depth_reached": Flag indicating max depth was reached - "_git_markers": Dict mapping filename to Git status char (if show_git_status is True)

Parameters:

Name Type Description Default
root_dir str

Root directory path to start from

required
exclude_dirs Optional[Sequence[str]]

List of directory names to exclude

None
ignore_file Optional[str]

Name of ignore file (like .gitignore)

None
exclude_extensions Optional[set[str]]

Set of file extensions to exclude

None
parent_ignore_patterns Optional[Sequence[str]]

Patterns from parent directories' ignore files

None
exclude_patterns Optional[Sequence[Union[str, Pattern[str]]]]

List of patterns (glob or regex) to exclude

None
include_patterns Optional[Sequence[Union[str, Pattern[str]]]]

List of patterns (glob or regex) to include (overrides exclusions)

None
max_depth int

Maximum depth to traverse (0 for unlimited)

0
current_depth int

Current depth in the directory tree (for internal recursion)

0
current_path str

Current path for full path display (for internal recursion)

''
show_full_path bool

Whether to show full paths instead of just filenames

False
sort_by_loc bool

Whether to calculate and track lines of code counts

False
sort_by_size bool

Whether to calculate and track file sizes

False
sort_by_mtime bool

Whether to track file modification times

False
show_git_status bool

Whether to annotate files with Git status markers

False
git_status_map Optional[dict[str, str]]

Pre-computed {rel_path: status_char} mapping (from get_git_status)

None

Returns:

Type Description
tuple[dict[str, Any], set[str]]

Tuple of (structure dictionary, set of file extensions found)

Source code in recursivist/core.py
def get_directory_structure(
    root_dir: str,
    exclude_dirs: Optional[Sequence[str]] = None,
    ignore_file: Optional[str] = None,
    exclude_extensions: Optional[set[str]] = None,
    parent_ignore_patterns: Optional[Sequence[str]] = None,
    exclude_patterns: Optional[Sequence[Union[str, Pattern[str]]]] = None,
    include_patterns: Optional[Sequence[Union[str, Pattern[str]]]] = None,
    max_depth: int = 0,
    current_depth: int = 0,
    current_path: str = "",
    show_full_path: bool = False,
    sort_by_loc: bool = False,
    sort_by_size: bool = False,
    sort_by_mtime: bool = False,
    show_git_status: bool = False,
    git_status_map: Optional[dict[str, str]] = None,
) -> tuple[dict[str, Any], set[str]]:
    """Build a nested dictionary representing a directory structure.

    Recursively traverses the file system applying filters and collecting statistics.

    The resulting structure contains:
    - Hierarchical representation of directories and files
    - Optional statistics (lines of code, sizes, modification times)
    - Filtered entries based on various exclusion patterns

    Special dictionary keys:
    - "_files": List of files in the directory
    - "_loc": Total lines of code (if sort_by_loc is True)
    - "_size": Total size in bytes (if sort_by_size is True)
    - "_mtime": Latest modification timestamp (if sort_by_mtime is True)
    - "_max_depth_reached": Flag indicating max depth was reached
    - "_git_markers": Dict mapping filename to Git status char (if show_git_status is True)

    Args:
        root_dir: Root directory path to start from
        exclude_dirs: List of directory names to exclude
        ignore_file: Name of ignore file (like .gitignore)
        exclude_extensions: Set of file extensions to exclude
        parent_ignore_patterns: Patterns from parent directories' ignore files
        exclude_patterns: List of patterns (glob or regex) to exclude
        include_patterns: List of patterns (glob or regex) to include (overrides exclusions)
        max_depth: Maximum depth to traverse (0 for unlimited)
        current_depth: Current depth in the directory tree (for internal recursion)
        current_path: Current path for full path display (for internal recursion)
        show_full_path: Whether to show full paths instead of just filenames
        sort_by_loc: Whether to calculate and track lines of code counts
        sort_by_size: Whether to calculate and track file sizes
        sort_by_mtime: Whether to track file modification times
        show_git_status: Whether to annotate files with Git status markers
        git_status_map: Pre-computed {rel_path: status_char} mapping (from get_git_status)

    Returns:
        Tuple of (structure dictionary, set of file extensions found)
    """
    if exclude_dirs is None:
        exclude_dirs = []
    if exclude_extensions is None:
        exclude_extensions = set()
    if exclude_patterns is None:
        exclude_patterns = []
    if include_patterns is None:
        include_patterns = []
    ignore_patterns = list(parent_ignore_patterns) if parent_ignore_patterns else []
    if ignore_file and os.path.exists(os.path.join(root_dir, ignore_file)):
        current_ignore_patterns = parse_ignore_file(os.path.join(root_dir, ignore_file))
        ignore_patterns.extend(current_ignore_patterns)
    ignore_context = {"patterns": ignore_patterns, "current_dir": root_dir}
    structure: dict[str, Any] = {}
    extensions_set: set[str] = set()
    total_loc = 0
    total_size = 0
    latest_mtime = 0.0

    git_markers: dict[str, str] = {}
    if show_git_status and git_status_map is not None:
        current_prefix = current_path.replace(os.sep, "/") if current_path else ""
        for git_path, status in git_status_map.items():
            slash_idx = git_path.rfind("/")
            if slash_idx == -1:
                file_dir, fname = "", git_path
            else:
                file_dir, fname = git_path[:slash_idx], git_path[slash_idx + 1 :]
            if file_dir == current_prefix:
                git_markers[fname] = status
    if max_depth > 0 and current_depth >= max_depth:
        return {"_max_depth_reached": True}, extensions_set
    try:
        items = os.listdir(root_dir)
    except PermissionError:
        logger.warning(f"Permission denied: {root_dir}")
        return structure, extensions_set
    except Exception as e:
        logger.error(f"Error reading directory {root_dir}: {e}")
        return structure, extensions_set
    for item in items:
        item_path = os.path.join(root_dir, item)
        if item in exclude_dirs or should_exclude(
            item_path,
            ignore_context,
            exclude_extensions,
            exclude_patterns,
            include_patterns,
        ):
            continue
        if not os.path.isdir(item_path):
            _, ext = os.path.splitext(item)
            if ext.lower() not in exclude_extensions:
                if "_files" not in structure:
                    structure["_files"] = []
                file_loc = 0
                file_size = 0
                file_mtime = 0.0
                if sort_by_loc:
                    file_loc = count_lines_of_code(item_path)
                    total_loc += file_loc
                if sort_by_size:
                    file_size = get_file_size(item_path)
                    total_size += file_size
                if sort_by_mtime:
                    file_mtime = get_file_mtime(item_path)
                    latest_mtime = max(latest_mtime, file_mtime)
                if show_full_path:
                    abs_path = os.path.abspath(item_path)
                    abs_path = abs_path.replace(os.sep, "/")
                    if sort_by_loc and sort_by_size and sort_by_mtime:
                        structure["_files"].append(
                            (item, abs_path, file_loc, file_size, file_mtime)
                        )
                    elif sort_by_loc and sort_by_size:
                        structure["_files"].append(
                            (item, abs_path, file_loc, file_size)
                        )
                    elif sort_by_loc and sort_by_mtime:
                        structure["_files"].append(
                            (item, abs_path, file_loc, 0, file_mtime)
                        )
                    elif sort_by_size and sort_by_mtime:
                        structure["_files"].append(
                            (item, abs_path, 0, file_size, file_mtime)
                        )
                    elif sort_by_loc:
                        structure["_files"].append((item, abs_path, file_loc))
                    elif sort_by_size:
                        structure["_files"].append((item, abs_path, file_size))
                    elif sort_by_mtime:
                        structure["_files"].append((item, abs_path, file_mtime))
                    else:
                        structure["_files"].append((item, abs_path))
                else:
                    if sort_by_loc and sort_by_size and sort_by_mtime:
                        structure["_files"].append(
                            (item, item, file_loc, file_size, file_mtime)
                        )
                    elif sort_by_loc and sort_by_size:
                        structure["_files"].append((item, item, file_loc, file_size))
                    elif sort_by_loc and sort_by_mtime:
                        structure["_files"].append(
                            (item, item, file_loc, 0, file_mtime)
                        )
                    elif sort_by_size and sort_by_mtime:
                        structure["_files"].append(
                            (item, item, 0, file_size, file_mtime)
                        )
                    elif sort_by_loc:
                        structure["_files"].append((item, item, file_loc))
                    elif sort_by_size:
                        structure["_files"].append((item, item, file_size))
                    elif sort_by_mtime:
                        structure["_files"].append((item, item, file_mtime))
                    else:
                        structure["_files"].append(item)
                if ext:
                    extensions_set.add(ext.lower())
    for item in items:
        item_path = os.path.join(root_dir, item)
        if item in exclude_dirs or should_exclude(
            item_path,
            ignore_context,
            exclude_extensions,
            exclude_patterns,
            include_patterns,
        ):
            continue
        if os.path.isdir(item_path):
            next_path = os.path.join(current_path, item) if current_path else item
            substructure, sub_extensions = get_directory_structure(
                item_path,
                exclude_dirs,
                ignore_file,
                exclude_extensions,
                ignore_patterns,
                exclude_patterns,
                include_patterns,
                max_depth,
                current_depth + 1,
                next_path,
                show_full_path,
                sort_by_loc,
                sort_by_size,
                sort_by_mtime,
                show_git_status,
                git_status_map,
            )
            structure[item] = substructure
            extensions_set.update(sub_extensions)
            if sort_by_loc and "_loc" in substructure:
                total_loc += substructure["_loc"]
            if sort_by_size and "_size" in substructure:
                total_size += substructure["_size"]
            if sort_by_mtime and "_mtime" in substructure:
                latest_mtime = max(latest_mtime, substructure["_mtime"])
    if sort_by_loc:
        structure["_loc"] = total_loc
    if sort_by_size:
        structure["_size"] = total_size
    if sort_by_mtime:
        structure["_mtime"] = latest_mtime

    if show_git_status and git_markers:
        existing_names: set[str] = set()
        for f in structure.get("_files", []):
            existing_names.add(f[0] if isinstance(f, tuple) else f)

        for fname, status in git_markers.items():
            if status == "D" and fname not in existing_names:
                _, ext = os.path.splitext(fname)
                if ext:
                    extensions_set.add(ext.lower())
                if "_files" not in structure:
                    structure["_files"] = []
                abs_deleted = os.path.abspath(os.path.join(root_dir, fname)).replace(
                    os.sep, "/"
                )
                if show_full_path:
                    if sort_by_loc and sort_by_size and sort_by_mtime:
                        entry: Any = (fname, abs_deleted, 0, 0, 0.0)
                    elif sort_by_loc and sort_by_size:
                        entry = (fname, abs_deleted, 0, 0)
                    elif sort_by_loc and sort_by_mtime:
                        entry = (fname, abs_deleted, 0, 0, 0.0)
                    elif sort_by_size and sort_by_mtime:
                        entry = (fname, abs_deleted, 0, 0, 0.0)
                    elif sort_by_loc:
                        entry = (fname, abs_deleted, 0)
                    elif sort_by_size:
                        entry = (fname, abs_deleted, 0)
                    elif sort_by_mtime:
                        entry = (fname, abs_deleted, 0.0)
                    else:
                        entry = (fname, abs_deleted)
                else:
                    if sort_by_loc and sort_by_size and sort_by_mtime:
                        entry = (fname, fname, 0, 0, 0.0)
                    elif sort_by_loc and sort_by_size:
                        entry = (fname, fname, 0, 0)
                    elif sort_by_loc and sort_by_mtime:
                        entry = (fname, fname, 0, 0, 0.0)
                    elif sort_by_size and sort_by_mtime:
                        entry = (fname, fname, 0, 0, 0.0)
                    elif sort_by_loc:
                        entry = (fname, fname, 0)
                    elif sort_by_size:
                        entry = (fname, fname, 0)
                    elif sort_by_mtime:
                        entry = (fname, fname, 0.0)
                    else:
                        entry = fname
                structure["_files"].append(entry)

        structure["_git_markers"] = git_markers

    return structure, extensions_set

get_file_mtime(file_path)

Get the modification time of a file in seconds since epoch.

Parameters:

Name Type Description Default
file_path str

Path to the file

required

Returns:

Type Description
float

Modification time as a float (seconds since epoch), or 0 if the file cannot be accessed

Source code in recursivist/core.py
def get_file_mtime(file_path: str) -> float:
    """Get the modification time of a file in seconds since epoch.

    Args:
        file_path: Path to the file

    Returns:
        Modification time as a float (seconds since epoch), or 0 if the file cannot be accessed
    """
    try:
        return os.path.getmtime(file_path)
    except Exception as e:
        logger.debug(f"Could not get modification time for {file_path}: {e}")
        return 0.0

get_file_size(file_path)

Return the size of a file in bytes.

Parameters:

Name Type Description Default
file_path str

Path to the file whose size should be retrieved.

required

Returns:

Type Description
int

Size of the file in bytes, or 0 when the file cannot be

int

accessed (e.g., permission error or the path no longer exists).

Source code in recursivist/core.py
def get_file_size(file_path: str) -> int:
    """Return the size of a file in bytes.

    Args:
        file_path: Path to the file whose size should be retrieved.

    Returns:
        Size of the file in bytes, or ``0`` when the file cannot be
        accessed (e.g., permission error or the path no longer exists).
    """
    try:
        return os.path.getsize(file_path)
    except Exception as e:
        logger.debug(f"Could not get size for {file_path}: {e}")
        return 0

get_git_status(directory)

Get Git status for files relative to a given directory.

Runs git status --porcelain from the repository root and maps every changed/untracked path back to a path relative to directory, filtering out files that live outside of it.

Status characters returned: - 'U': Untracked (?? in porcelain output) - 'M': Modified (working-tree or staged modification) - 'A': Added / staged for the first time (includes renames) - 'D': Deleted (working-tree or staged deletion)

Parameters:

Name Type Description Default
directory str

Absolute path to the directory being visualised. Must be inside a Git repository.

required

Returns:

Type Description
dict[str, str]

{relative_path: status_char} where relative_path uses forward

dict[str, str]

slashes regardless of OS, or an empty dict when Git is unavailable or

dict[str, str]

the directory is not tracked.

Source code in recursivist/core.py
def get_git_status(directory: str) -> dict[str, str]:
    """Get Git status for files relative to a given directory.

    Runs ``git status --porcelain`` from the repository root and maps every
    changed/untracked path back to a path relative to *directory*, filtering
    out files that live outside of it.

    Status characters returned:
    - ``'U'``: Untracked (``??`` in porcelain output)
    - ``'M'``: Modified (working-tree or staged modification)
    - ``'A'``: Added / staged for the first time (includes renames)
    - ``'D'``: Deleted (working-tree or staged deletion)

    Args:
        directory: Absolute path to the directory being visualised. Must be
            inside a Git repository.

    Returns:
        ``{relative_path: status_char}`` where *relative_path* uses forward
        slashes regardless of OS, or an empty dict when Git is unavailable or
        the directory is not tracked.
    """
    import subprocess

    try:
        root_result = subprocess.run(
            ["git", "rev-parse", "--show-toplevel"],
            cwd=directory,
            capture_output=True,
            text=True,
        )
        if root_result.returncode != 0:
            return {}
        git_root = root_result.stdout.strip()

        status_result = subprocess.run(
            ["git", "status", "--porcelain"],
            cwd=git_root,
            capture_output=True,
            text=True,
        )
        if status_result.returncode != 0:
            return {}

        status_map: dict[str, str] = {}
        for line in status_result.stdout.splitlines():
            if len(line) < 4:
                continue
            xy = line[:2]
            path = line[3:]
            if " -> " in path:
                path = path.split(" -> ", 1)[1]
            path = path.strip().strip('"')

            x, y = xy[0], xy[1]
            if x == "?" and y == "?":
                status = "U"
            elif x == "D" or y == "D":
                status = "D"
            elif x == "A" or x == "R":
                status = "A"
            elif x == "M" or y == "M":
                status = "M"
            else:
                status = "M"

            abs_file = os.path.normpath(
                os.path.join(git_root, path.replace("/", os.sep))
            )
            try:
                rel = os.path.relpath(abs_file, directory)
                if not rel.startswith(".."):
                    status_map[rel.replace(os.sep, "/")] = status
            except ValueError:
                pass

        return status_map
    except Exception as e:
        logger.debug(f"Could not get git status for {directory}: {e}")
        return {}

hex_to_rgb(hex_color)

Convert a CSS hex color string to an (r, g, b) tuple.

Parameters:

Name Type Description Default
hex_color str

Six-digit hex color string, optionally prefixed with '#' (e.g., "#FF5733" or "FF5733").

required

Returns:

Type Description
int

A three-tuple of integers (red, green, blue) in the range

int

0255.

Source code in recursivist/core.py
def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
    """Convert a CSS hex color string to an ``(r, g, b)`` tuple.

    Args:
        hex_color: Six-digit hex color string, optionally prefixed with
            ``'#'`` (e.g., ``"#FF5733"`` or ``"FF5733"``).

    Returns:
        A three-tuple of integers ``(red, green, blue)`` in the range
        ``0``–``255``.
    """
    hex_color = hex_color.lstrip("#")
    return cast(
        tuple[int, int, int], tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
    )

parse_ignore_file(ignore_file_path)

Parse an ignore file (like .gitignore) and return patterns.

Reads an ignore file and extracts patterns for excluding files and directories. Handles comments and trailing slashes in directories.

Parameters:

Name Type Description Default
ignore_file_path str

Path to the ignore file

required

Returns:

Type Description
list[str]

List of patterns to ignore

Source code in recursivist/core.py
def parse_ignore_file(ignore_file_path: str) -> list[str]:
    """Parse an ignore file (like .gitignore) and return patterns.

    Reads an ignore file and extracts patterns for excluding files and directories. Handles comments and trailing slashes in directories.

    Args:
        ignore_file_path: Path to the ignore file

    Returns:
        List of patterns to ignore
    """
    if not os.path.exists(ignore_file_path):
        return []
    patterns = []
    with open(ignore_file_path) as f:
        for line in f:
            line = line.strip()
            if line and not line.startswith("#"):
                if line.endswith("/"):
                    line = line[:-1]
                patterns.append(line)
    return patterns

should_exclude(path, ignore_context, exclude_extensions=None, exclude_patterns=None, include_patterns=None)

Determine if a path should be excluded based on filtering rules.

Logic to handle priority between include and exclude patterns: 1. If include_patterns exist and NONE match, EXCLUDE the path 2. If exclude_patterns match, EXCLUDE the path (overrides include patterns) 3. If file extension is in exclude_extensions, EXCLUDE the path 4. If a matching include pattern exists, INCLUDE the path (overrides gitignore patterns) 5. If gitignore-style patterns match, follow their rules (including negations)

Parameters:

Name Type Description Default
path str

Path to check for exclusion

required
ignore_context dict[str, Any]

Dictionary with 'patterns' and 'current_dir' keys

required
exclude_extensions Optional[set[str]]

Set of file extensions to exclude

None
exclude_patterns Optional[Sequence[Union[str, Pattern[str]]]]

List of patterns (glob or regex) to exclude

None
include_patterns Optional[Sequence[Union[str, Pattern[str]]]]

List of patterns (glob or regex) to include (overrides gitignore exclusions)

None

Returns: True if path should be excluded, False otherwise

Source code in recursivist/core.py
def should_exclude(
    path: str,
    ignore_context: dict[str, Any],
    exclude_extensions: Optional[set[str]] = None,
    exclude_patterns: Optional[Sequence[Union[str, Pattern[str]]]] = None,
    include_patterns: Optional[Sequence[Union[str, Pattern[str]]]] = None,
) -> bool:
    """Determine if a path should be excluded based on filtering rules.

    Logic to handle priority between include and exclude patterns:
    1. If include_patterns exist and NONE match, EXCLUDE the path
    2. If exclude_patterns match, EXCLUDE the path (overrides include patterns)
    3. If file extension is in exclude_extensions, EXCLUDE the path
    4. If a matching include pattern exists, INCLUDE the path (overrides gitignore patterns)
    5. If gitignore-style patterns match, follow their rules (including negations)

    Args:
        path: Path to check for exclusion
        ignore_context: Dictionary with 'patterns' and 'current_dir' keys
        exclude_extensions: Set of file extensions to exclude
        exclude_patterns: List of patterns (glob or regex) to exclude
        include_patterns: List of patterns (glob or regex) to include (overrides gitignore exclusions)
    Returns:
        True if path should be excluded, False otherwise
    """
    patterns = ignore_context.get("patterns", [])
    current_dir = ignore_context.get("current_dir", os.path.dirname(path))
    rel_path = os.path.relpath(path, current_dir)
    if os.name == "nt":
        rel_path = rel_path.replace("\\", "/")
    basename = os.path.basename(path)
    if include_patterns:
        included = False
        for pattern in include_patterns:
            if isinstance(pattern, Pattern):
                if pattern.search(rel_path) or pattern.search(basename):
                    included = True
                    break
            else:
                if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(
                    basename, pattern
                ):
                    included = True
                    break
        if not included:
            return True
    if exclude_patterns:
        for pattern in exclude_patterns:
            if isinstance(pattern, Pattern):
                if pattern.search(rel_path) or pattern.search(basename):
                    return True
            else:
                if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(
                    basename, pattern
                ):
                    return True
    if exclude_extensions and os.path.isfile(path):
        _, ext = os.path.splitext(path)
        if ext.lower() in exclude_extensions:
            return True
    if include_patterns:
        return False
    if not patterns:
        return False
    for pattern in patterns:
        if isinstance(pattern, str) and pattern.startswith("!"):
            if fnmatch.fnmatch(rel_path, pattern[1:]):
                return False
    for pattern in patterns:
        if isinstance(pattern, str) and not pattern.startswith("!"):
            if fnmatch.fnmatch(rel_path, pattern):
                return True
    return False

sort_files_by_type(files, sort_by_loc=False, sort_by_size=False, sort_by_mtime=False)

Sort files by extension and then by name, or by LOC/size/mtime if requested.

The sort precedence follows: LOC > size > mtime > extension/name

Parameters:

Name Type Description Default
files Sequence[Union[str, tuple[str, str], tuple[str, str, int], tuple[str, str, int, int], tuple[str, str, int, int, float]]]

List of file items, which can be strings or tuples of various forms

required
sort_by_loc bool

Whether to sort by lines of code

False
sort_by_size bool

Whether to sort by file size

False
sort_by_mtime bool

Whether to sort by modification time

False

Returns:

Type Description
list[Union[str, tuple[str, str], tuple[str, str, int], tuple[str, str, int, int], tuple[str, str, int, int, float]]]

Sorted list of file items

Source code in recursivist/core.py
def sort_files_by_type(
    files: Sequence[
        Union[
            str,
            tuple[str, str],
            tuple[str, str, int],
            tuple[str, str, int, int],
            tuple[str, str, int, int, float],
        ]
    ],
    sort_by_loc: bool = False,
    sort_by_size: bool = False,
    sort_by_mtime: bool = False,
) -> list[
    Union[
        str,
        tuple[str, str],
        tuple[str, str, int],
        tuple[str, str, int, int],
        tuple[str, str, int, int, float],
    ]
]:
    """Sort files by extension and then by name, or by LOC/size/mtime if requested.

    The sort precedence follows: LOC > size > mtime > extension/name

    Args:
        files: List of file items, which can be strings or tuples of various forms
        sort_by_loc: Whether to sort by lines of code
        sort_by_size: Whether to sort by file size
        sort_by_mtime: Whether to sort by modification time

    Returns:
        Sorted list of file items
    """
    if not files:
        return []
    has_loc = any(isinstance(item, tuple) and len(item) > 2 for item in files)
    has_size = any(isinstance(item, tuple) and len(item) > 3 for item in files)
    has_mtime = any(isinstance(item, tuple) and len(item) > 4 for item in files)
    has_simple_size = sort_by_size and not sort_by_loc and has_loc
    has_simple_mtime = (
        sort_by_mtime and not sort_by_loc and not sort_by_size and (has_loc or has_size)
    )

    def get_size(
        item: Union[
            str,
            tuple[str, str],
            tuple[str, str, int],
            tuple[str, str, int, int],
            tuple[str, str, int, int, float],
        ],
    ) -> int:
        if not isinstance(item, tuple):
            return 0
        if len(item) > 3:
            if sort_by_loc and sort_by_size:
                return item[3]
            elif sort_by_size and not sort_by_loc:
                return item[3]
        elif len(item) == 3 and sort_by_size:
            return item[2]
        return 0

    def get_loc(
        item: Union[
            str,
            tuple[str, str],
            tuple[str, str, int],
            tuple[str, str, int, int],
            tuple[str, str, int, int, float],
        ],
    ) -> int:
        if not isinstance(item, tuple) or len(item) <= 2:
            return 0
        return item[2] if sort_by_loc else 0

    def get_mtime(
        item: Union[
            str,
            tuple[str, str],
            tuple[str, str, int],
            tuple[str, str, int, int],
            tuple[str, str, int, int, float],
        ],
    ) -> float:
        if not isinstance(item, tuple):
            return 0
        if len(item) > 4:
            return item[4]
        elif len(item) > 3 and (
            (sort_by_loc and sort_by_mtime and not sort_by_size)
            or (sort_by_size and sort_by_mtime and not sort_by_loc)
        ):
            return item[3]
        elif len(item) > 2 and sort_by_mtime and not sort_by_loc and not sort_by_size:
            return item[2]
        return 0

    if sort_by_loc and sort_by_size and sort_by_mtime and has_mtime:
        return sorted(files, key=lambda f: (-get_loc(f), -get_size(f), -get_mtime(f)))
    elif sort_by_loc and sort_by_size and (has_size or has_simple_size) and has_loc:
        return sorted(files, key=lambda f: (-get_loc(f), -get_size(f)))
    elif sort_by_loc and sort_by_mtime and has_mtime:
        return sorted(files, key=lambda f: (-get_loc(f), -get_mtime(f)))
    elif sort_by_size and sort_by_mtime and has_mtime:
        return sorted(files, key=lambda f: (-get_size(f), -get_mtime(f)))
    elif sort_by_loc and has_loc:
        return sorted(files, key=lambda f: -get_loc(f))
    elif sort_by_size and (has_size or has_simple_size):
        return sorted(files, key=lambda f: -get_size(f))
    elif sort_by_mtime and (has_mtime or has_simple_mtime):
        return sorted(files, key=lambda f: -get_mtime(f))

    def get_filename(
        item: Union[
            str,
            tuple[str, str],
            tuple[str, str, int],
            tuple[str, str, int, int],
            tuple[str, str, int, int, float],
        ],
    ) -> str:
        if isinstance(item, tuple):
            return item[0]
        return item

    return sorted(
        files,
        key=lambda f: (
            os.path.splitext(get_filename(f))[1].lower(),
            get_filename(f).lower(),
        ),
    )

Exports Module

recursivist.exports

Export functionality for the Recursivist directory visualization tool.

This module handles the export of directory structures to various formats through the DirectoryExporter class, which provides a unified interface for transforming directory structures into different output formats.

Supported export formats: - TXT: ASCII tree representation - JSON: Structured data for programmatic use - HTML: Interactive web page with styling - Markdown: Clean representation for documentation - JSX: React component for web integration

Each format maintains consistent styling and organization, with support for showing lines of code, file sizes, and modification times.

DirectoryExporter

Export directory structures to various formats.

Provides a unified interface for transforming directory structures into different output formats with consistent styling and organization.

Supported formats: - TXT: ASCII tree representation - JSON: Structured data for programmatic use - HTML: Interactive web page with styling - Markdown: Clean representation for documentation - JSX: React component for web integration

Each format maintains consistent features for: - Directory and file hierarchical representation - Optional statistics (lines of code, sizes, modification times) - Path display options (full or relative paths)

Source code in recursivist/exports.py
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
class DirectoryExporter:
    """Export directory structures to various formats.

    Provides a unified interface for transforming directory structures into different output formats with consistent styling and organization.

    Supported formats:
    - TXT: ASCII tree representation
    - JSON: Structured data for programmatic use
    - HTML: Interactive web page with styling
    - Markdown: Clean representation for documentation
    - JSX: React component for web integration

    Each format maintains consistent features for:
    - Directory and file hierarchical representation
    - Optional statistics (lines of code, sizes, modification times)
    - Path display options (full or relative paths)
    """

    def __init__(
        self,
        structure: dict[str, Any],
        root_name: str,
        base_path: Optional[str] = None,
        sort_by_loc: bool = False,
        sort_by_size: bool = False,
        sort_by_mtime: bool = False,
        show_git_status: bool = False,
        icon_style: str = "emoji",
    ):
        """Initialize the exporter with directory structure and root name.

        Args:
            structure: The directory structure dictionary
            root_name: Name of the root directory
            base_path: Base path for full path display (if None, only show filenames)
            sort_by_loc: Whether to include lines of code counts in exports
            sort_by_size: Whether to include file size information in exports
            sort_by_mtime: Whether to include modification time information in exports
            show_git_status: Whether to annotate files with Git status markers
        """

        self.structure = structure
        self.root_name = root_name
        self.base_path = base_path
        self.show_full_path = base_path is not None
        self.sort_by_loc = sort_by_loc
        self.sort_by_size = sort_by_size
        self.sort_by_mtime = sort_by_mtime
        self.show_git_status = show_git_status
        self.icon_style = icon_style

    _GIT_MARKER_LABELS = {"U": "[U]", "M": "[M]", "A": "[A]", "D": "[D]"}
    _GIT_TXT_SUFFIX = {"U": " [U]", "M": " [M]", "A": " [A]", "D": " [D]"}

    def to_txt(self, output_path: str) -> None:
        """Export directory structure to a text file with ASCII tree representation.

        Creates a text file containing an ASCII tree representation of the directory structure using standard box-drawing characters and indentation.

        Args:
            output_path: Path where the txt file will be saved
        """

        def _build_txt_tree(
            structure: dict[str, Any],
            prefix: str = "",
            path_prefix: str = "",
        ) -> list[str]:
            lines = []
            items = sorted(structure.items())
            for i, (name, content) in enumerate(items):
                if name == "_files":
                    file_items = sort_files_by_type(
                        content, self.sort_by_loc, self.sort_by_size, self.sort_by_mtime
                    )
                    for j, file_item in enumerate(file_items):
                        is_last_file = j == len(file_items) - 1
                        is_last_item = is_last_file and i == len(items) - 1
                        item_prefix = prefix + ("└── " if is_last_item else "├── ")

                        _fname = (
                            file_item[0]
                            if isinstance(file_item, tuple) and len(file_item) > 0
                            else (
                                file_item if isinstance(file_item, str) else "unknown"
                            )
                        )
                        _git_markers = structure.get("_git_markers", {})
                        _git_marker = (
                            _git_markers.get(_fname, "") if self.show_git_status else ""
                        )
                        _git_suffix = (
                            f" {self._GIT_TXT_SUFFIX.get(_git_marker, _git_marker)}"
                            if _git_marker
                            else ""
                        )

                        file_icon = get_icon(
                            _fname, is_dir=False, style=self.icon_style
                        )

                        if (
                            self.sort_by_loc
                            and self.sort_by_size
                            and self.sort_by_mtime
                            and isinstance(file_item, tuple)
                            and len(file_item) > 4
                        ):
                            _, display_path, loc, size, mtime = file_item
                            lines.append(
                                f"{item_prefix}{file_icon} {display_path} ({loc} lines, {format_size(size)}, {format_timestamp(mtime)}){_git_suffix}"
                            )
                        elif (
                            self.sort_by_loc
                            and self.sort_by_mtime
                            and isinstance(file_item, tuple)
                            and len(file_item) > 4
                        ):
                            _, display_path, loc, _, mtime = file_item
                            lines.append(
                                f"{item_prefix}{file_icon} {display_path} ({loc} lines, {format_timestamp(mtime)}){_git_suffix}"
                            )
                        elif (
                            self.sort_by_size
                            and self.sort_by_mtime
                            and isinstance(file_item, tuple)
                            and len(file_item) > 4
                        ):
                            _, display_path, _, size, mtime = file_item
                            lines.append(
                                f"{item_prefix}{file_icon} {display_path} ({format_size(size)}, {format_timestamp(mtime)}){_git_suffix}"
                            )
                        elif (
                            self.sort_by_loc
                            and self.sort_by_size
                            and isinstance(file_item, tuple)
                            and len(file_item) > 3
                        ):
                            if len(file_item) > 4:
                                _, display_path, loc, size, _ = file_item
                            else:
                                _, display_path, loc, size = file_item
                            lines.append(
                                f"{item_prefix}{file_icon} {display_path} ({loc} lines, {format_size(size)}){_git_suffix}"
                            )
                        elif (
                            self.sort_by_mtime
                            and isinstance(file_item, tuple)
                            and len(file_item) > 2
                        ):
                            if len(file_item) > 4:
                                _, display_path, _, _, mtime = file_item
                            elif len(file_item) > 3:
                                _, display_path, _, mtime = file_item
                            else:
                                _, display_path, mtime = file_item
                            lines.append(
                                f"{item_prefix}{file_icon} {display_path} ({format_timestamp(mtime)}){_git_suffix}"
                            )
                        elif (
                            self.sort_by_size
                            and isinstance(file_item, tuple)
                            and len(file_item) > 2
                        ):
                            if len(file_item) > 4:
                                _, display_path, _, size, _ = file_item
                            elif len(file_item) > 3:
                                _, display_path, _, size = file_item
                            else:
                                _, display_path, size = file_item
                            lines.append(
                                f"{item_prefix}{file_icon} {display_path} ({format_size(size)}){_git_suffix}"
                            )
                        elif (
                            self.sort_by_loc
                            and isinstance(file_item, tuple)
                            and len(file_item) > 2
                        ):
                            if len(file_item) > 4:
                                _, display_path, loc, _, _ = file_item
                            elif len(file_item) > 3:
                                _, display_path, loc, _ = file_item
                            else:
                                _, display_path, loc = file_item
                            lines.append(
                                f"{item_prefix}{file_icon} {display_path} ({loc} lines){_git_suffix}"
                            )
                        elif self.show_full_path and isinstance(file_item, tuple):
                            if len(file_item) > 4:
                                _, full_path, _, _, _ = file_item
                            elif len(file_item) > 3:
                                _, full_path, _, _ = file_item
                            elif len(file_item) > 2:
                                _, full_path, _ = file_item
                            elif len(file_item) > 1:
                                _, full_path = file_item
                            else:
                                full_path = (
                                    file_item[0] if len(file_item) > 0 else "unknown"
                                )
                            lines.append(
                                f"{item_prefix}{file_icon} {full_path}{_git_suffix}"
                            )
                        else:
                            if isinstance(file_item, tuple):
                                file_name = (
                                    file_item[0] if len(file_item) > 0 else "unknown"
                                )
                            else:
                                file_name = file_item
                            lines.append(
                                f"{item_prefix}{file_icon} {file_name}{_git_suffix}"
                            )
                        if not is_last_item:
                            next_prefix = prefix + "│   "
                        else:
                            next_prefix = prefix + "    "
                elif (
                    name == "_loc"
                    or name == "_size"
                    or name == "_mtime"
                    or name == "_max_depth_reached"
                    or name == "_git_markers"
                ):
                    continue
                else:
                    is_last_dir = True
                    for j in range(i + 1, len(items)):
                        next_name, _ = items[j]
                        if next_name not in [
                            "_files",
                            "_max_depth_reached",
                            "_loc",
                            "_size",
                            "_mtime",
                            "_git_markers",
                        ]:
                            is_last_dir = False
                            break
                    is_last_item = is_last_dir and (
                        i == len(items) - 1
                        or all(
                            key
                            in [
                                "_files",
                                "_max_depth_reached",
                                "_loc",
                                "_size",
                                "_mtime",
                                "_git_markers",
                            ]
                            for key, _ in items[i + 1 :]
                        )
                    )
                    item_prefix = prefix + ("└── " if is_last_item else "├── ")
                    next_path = os.path.join(path_prefix, name) if path_prefix else name
                    folder_icon = get_icon(name, is_dir=True, style=self.icon_style)
                    if isinstance(content, dict):
                        if (
                            self.sort_by_loc
                            and self.sort_by_size
                            and self.sort_by_mtime
                            and "_loc" in content
                            and "_size" in content
                            and "_mtime" in content
                        ):
                            folder_loc = content["_loc"]
                            folder_size = content["_size"]
                            folder_mtime = content["_mtime"]
                            lines.append(
                                f"{item_prefix}{folder_icon} {name} ({folder_loc} lines, {format_size(folder_size)}, {format_timestamp(folder_mtime)})"
                            )
                        elif (
                            self.sort_by_loc
                            and self.sort_by_size
                            and "_loc" in content
                            and "_size" in content
                        ):
                            folder_loc = content["_loc"]
                            folder_size = content["_size"]
                            lines.append(
                                f"{item_prefix}{folder_icon} {name} ({folder_loc} lines, {format_size(folder_size)})"
                            )
                        elif (
                            self.sort_by_loc
                            and self.sort_by_mtime
                            and "_loc" in content
                            and "_mtime" in content
                        ):
                            folder_loc = content["_loc"]
                            folder_mtime = content["_mtime"]
                            lines.append(
                                f"{item_prefix}{folder_icon} {name} ({folder_loc} lines, {format_timestamp(folder_mtime)})"
                            )
                        elif (
                            self.sort_by_size
                            and self.sort_by_mtime
                            and "_size" in content
                            and "_mtime" in content
                        ):
                            folder_size = content["_size"]
                            folder_mtime = content["_mtime"]
                            lines.append(
                                f"{item_prefix}{folder_icon} {name} ({format_size(folder_size)}, {format_timestamp(folder_mtime)})"
                            )
                        elif self.sort_by_loc and "_loc" in content:
                            folder_loc = content["_loc"]
                            lines.append(
                                f"{item_prefix}{folder_icon} {name} ({folder_loc} lines)"
                            )
                        elif self.sort_by_size and "_size" in content:
                            folder_size = content["_size"]
                            lines.append(
                                f"{item_prefix}{folder_icon} {name} ({format_size(folder_size)})"
                            )
                        elif self.sort_by_mtime and "_mtime" in content:
                            folder_mtime = content["_mtime"]
                            lines.append(
                                f"{item_prefix}{folder_icon} {name} ({format_timestamp(folder_mtime)})"
                            )
                        else:
                            lines.append(f"{item_prefix}{folder_icon} {name}")
                        if content.get("_max_depth_reached"):
                            next_prefix = prefix + ("    " if is_last_item else "│   ")
                            lines.append(f"{next_prefix}└── ⋯ (max depth reached)")
                        else:
                            next_prefix = prefix + ("    " if is_last_item else "│   ")
                            sublines = _build_txt_tree(content, next_prefix, next_path)
                            lines.extend(sublines)
                    else:
                        lines.append(f"{item_prefix}{folder_icon} {name}")
            return lines

        root_icon = get_icon(self.root_name, is_dir=True, style=self.icon_style)
        root_label = f"{root_icon} {self.root_name}"
        if (
            self.sort_by_loc
            and self.sort_by_size
            and self.sort_by_mtime
            and "_loc" in self.structure
            and "_size" in self.structure
            and "_mtime" in self.structure
        ):
            root_label = f"{root_icon} {self.root_name} ({self.structure['_loc']} lines, {format_size(self.structure['_size'])}, {format_timestamp(self.structure['_mtime'])})"
        elif (
            self.sort_by_loc
            and self.sort_by_size
            and "_loc" in self.structure
            and "_size" in self.structure
        ):
            root_label = f"{root_icon} {self.root_name} ({self.structure['_loc']} lines, {format_size(self.structure['_size'])})"
        elif (
            self.sort_by_loc
            and self.sort_by_mtime
            and "_loc" in self.structure
            and "_mtime" in self.structure
        ):
            root_label = f"{root_icon} {self.root_name} ({self.structure['_loc']} lines, {format_timestamp(self.structure['_mtime'])})"
        elif (
            self.sort_by_size
            and self.sort_by_mtime
            and "_size" in self.structure
            and "_mtime" in self.structure
        ):
            root_label = f"{root_icon} {self.root_name} ({format_size(self.structure['_size'])}, {format_timestamp(self.structure['_mtime'])})"
        elif self.sort_by_loc and "_loc" in self.structure:
            root_label = (
                f"{root_icon} {self.root_name} ({self.structure['_loc']} lines)"
            )
        elif self.sort_by_size and "_size" in self.structure:
            root_label = (
                f"{root_icon} {self.root_name} ({format_size(self.structure['_size'])})"
            )
        elif self.sort_by_mtime and "_mtime" in self.structure:
            root_label = f"{root_icon} {self.root_name} ({format_timestamp(self.structure['_mtime'])})"
        tree_lines = [root_label]
        tree_lines.extend(
            _build_txt_tree(
                self.structure, "", self.root_name if self.show_full_path else ""
            )
        )
        try:
            with open(output_path, "w", encoding="utf-8") as f:
                f.write("\n".join(tree_lines))
        except Exception as e:
            logger.error(f"Error exporting to TXT: {e}")
            raise

    def to_json(self, output_path: str) -> None:
        """Export directory structure to a JSON file.

        Creates a JSON file containing the directory structure with options for including full paths, LOC counts, file sizes, and modification times. The JSON structure includes a root name and the hierarchical structure of directories and files.

        Args:
            output_path: Path where the JSON file will be saved
        """

        if (
            self.show_full_path
            or self.sort_by_loc
            or self.sort_by_size
            or self.sort_by_mtime
        ):

            def convert_structure_for_json(structure: dict[str, Any]) -> dict[str, Any]:
                result: dict[str, Any] = {}
                _git_markers_here = structure.get("_git_markers", {})
                for k, v in structure.items():
                    if k == "_files":
                        result[k] = []
                        for item in v:
                            if not isinstance(item, tuple):
                                if self.show_git_status:
                                    _gs = _git_markers_here.get(item, "")
                                    entry: Any = {"name": item}
                                    if _gs:
                                        entry["git_status"] = _gs
                                    result[k].append(entry)
                                else:
                                    result[k].append(item)
                                continue

                            file_name = "unknown"
                            full_path = ""
                            loc = 0
                            size = 0
                            mtime = 0

                            if len(item) > 0:
                                file_name = item[0]
                            if len(item) > 1:
                                full_path = item[1]

                            _git_status = (
                                _git_markers_here.get(file_name, "")
                                if self.show_git_status
                                else ""
                            )

                            def _maybe_git(
                                d: dict[str, Any], _gs: str = _git_status
                            ) -> dict[str, Any]:
                                if _gs:
                                    d["git_status"] = _gs
                                return d

                            if (
                                self.sort_by_loc
                                and self.sort_by_size
                                and self.sort_by_mtime
                                and len(item) > 4
                            ):
                                loc = item[2]
                                size = item[3]
                                mtime = item[4]
                                result[k].append(
                                    _maybe_git(
                                        {
                                            "name": file_name,
                                            "path": full_path,
                                            "loc": loc,
                                            "size": size,
                                            "size_formatted": format_size(size),
                                            "mtime": mtime,
                                            "mtime_formatted": format_timestamp(mtime),
                                        }
                                    )
                                )
                            elif (
                                self.sort_by_loc
                                and self.sort_by_mtime
                                and len(item) > 4
                            ):
                                loc = item[2]
                                mtime = item[4]
                                result[k].append(
                                    _maybe_git(
                                        {
                                            "name": file_name,
                                            "path": full_path,
                                            "loc": loc,
                                            "mtime": mtime,
                                            "mtime_formatted": format_timestamp(mtime),
                                        }
                                    )
                                )
                            elif (
                                self.sort_by_size
                                and self.sort_by_mtime
                                and len(item) > 4
                            ):
                                size = item[3]
                                mtime = item[4]
                                result[k].append(
                                    _maybe_git(
                                        {
                                            "name": file_name,
                                            "path": full_path,
                                            "size": size,
                                            "size_formatted": format_size(size),
                                            "mtime": mtime,
                                            "mtime_formatted": format_timestamp(mtime),
                                        }
                                    )
                                )
                            elif (
                                self.sort_by_loc and self.sort_by_size and len(item) > 3
                            ):
                                loc = item[2] if len(item) > 2 else 0
                                size = item[3] if len(item) > 3 else 0
                                result[k].append(
                                    _maybe_git(
                                        {
                                            "name": file_name,
                                            "path": full_path,
                                            "loc": loc,
                                            "size": size,
                                            "size_formatted": format_size(size),
                                        }
                                    )
                                )
                            elif self.sort_by_mtime and len(item) > 2:
                                mtime = item[2] if len(item) > 2 else 0
                                result[k].append(
                                    _maybe_git(
                                        {
                                            "name": file_name,
                                            "path": full_path,
                                            "mtime": mtime,
                                            "mtime_formatted": format_timestamp(mtime),
                                        }
                                    )
                                )
                            elif self.sort_by_size and len(item) > 2:
                                size = item[2] if len(item) > 2 else 0
                                result[k].append(
                                    _maybe_git(
                                        {
                                            "name": file_name,
                                            "path": full_path,
                                            "size": size,
                                            "size_formatted": format_size(size),
                                        }
                                    )
                                )
                            elif self.sort_by_loc and len(item) > 2:
                                loc = item[2] if len(item) > 2 else 0
                                result[k].append(
                                    _maybe_git(
                                        {
                                            "name": file_name,
                                            "path": full_path,
                                            "loc": loc,
                                        }
                                    )
                                )
                            elif self.show_full_path and len(item) > 1:
                                result[k].append(
                                    _maybe_git({"name": file_name, "path": full_path})
                                )
                            else:
                                if _git_status:
                                    result[k].append(
                                        {"name": file_name, "git_status": _git_status}
                                    )
                                else:
                                    result[k].append(file_name)
                    elif k == "_loc":
                        if self.sort_by_loc:
                            result[k] = v
                    elif k == "_size":
                        if self.sort_by_size:
                            result[k] = v
                            result["_size_formatted"] = format_size(v)
                    elif k == "_mtime":
                        if self.sort_by_mtime:
                            result[k] = v
                            result["_mtime_formatted"] = format_timestamp(v)
                    elif k == "_git_markers":
                        pass
                    elif k == "_max_depth_reached":
                        result[k] = v
                    elif isinstance(v, dict):
                        result[k] = convert_structure_for_json(v)
                    else:
                        result[k] = v
                return result

            export_structure = convert_structure_for_json(self.structure)
        else:
            export_structure = self.structure
        try:
            with open(output_path, "w", encoding="utf-8") as f:
                json.dump(
                    {
                        "root": self.root_name,
                        "structure": export_structure,
                        "show_loc": self.sort_by_loc,
                        "show_size": self.sort_by_size,
                        "show_mtime": self.sort_by_mtime,
                        "show_git_status": self.show_git_status,
                    },
                    f,
                    indent=2,
                )
        except Exception as e:
            logger.error(f"Error exporting to JSON: {e}")
            raise

    def to_html(self, output_path: str) -> None:
        """Export directory structure to an HTML file.

        Creates a standalone HTML file with a styled representation of the directory structure using nested unordered lists with CSS styling for colors and indentation.

        Args:
            output_path: Path where the HTML file will be saved
        """

        def _build_html_tree(
            structure: dict[str, Any],
            path_prefix: str = "",
        ) -> str:
            html_content = ["<ul>"]
            if "_files" in structure:
                for file_item in sort_files_by_type(
                    structure["_files"],
                    self.sort_by_loc,
                    self.sort_by_size,
                    self.sort_by_mtime,
                ):
                    file_name = "unknown"
                    display_path = "unknown"
                    loc = 0
                    size = 0
                    mtime = 0

                    if isinstance(file_item, tuple):
                        if len(file_item) > 0:
                            file_name = file_item[0]
                        if len(file_item) > 1:
                            display_path = file_item[1]
                        else:
                            display_path = file_name

                        if (
                            self.sort_by_loc
                            and self.sort_by_size
                            and self.sort_by_mtime
                            and len(file_item) > 4
                        ):
                            loc = file_item[2]
                            size = file_item[3]
                            mtime = int(file_item[4])
                        elif (
                            self.sort_by_loc
                            and self.sort_by_mtime
                            and len(file_item) > 4
                        ):
                            loc = file_item[2]
                            mtime = int(file_item[4])
                        elif (
                            self.sort_by_size
                            and self.sort_by_mtime
                            and len(file_item) > 4
                        ):
                            size = file_item[3]
                            mtime = int(file_item[4])
                        elif (
                            self.sort_by_loc
                            and self.sort_by_size
                            and len(file_item) > 3
                        ):
                            loc = file_item[2]
                            size = file_item[3]
                        elif self.sort_by_loc and len(file_item) > 2:
                            loc = file_item[2]
                        elif self.sort_by_size and len(file_item) > 2:
                            size = file_item[2]
                        elif self.sort_by_mtime and len(file_item) > 2:
                            mtime = file_item[2]
                    else:
                        file_name = file_item
                        display_path = file_name

                    ext = os.path.splitext(file_name)[1].lower()
                    color = generate_color_for_extension(ext)

                    file_icon = get_icon(file_name, is_dir=False, style=self.icon_style)

                    _git_markers_here = structure.get("_git_markers", {})
                    _git_marker = (
                        _git_markers_here.get(file_name, "")
                        if self.show_git_status
                        else ""
                    )
                    _GIT_HTML_STYLES = {
                        "U": ("#999999", "dim"),
                        "M": ("#d4a017", ""),
                        "A": ("#28a745", ""),
                        "D": ("#dc3545", "line-through"),
                    }
                    if _git_marker and _git_marker in _GIT_HTML_STYLES:
                        _badge_color, _file_style_extra = _GIT_HTML_STYLES[_git_marker]
                        _git_badge = f' <span class="git-badge git-{_git_marker.lower()}" style="color:{_badge_color};font-size:0.8em;font-weight:bold;">[{_git_marker}]</span>'
                        _name_style = (
                            ' style="text-decoration: line-through;"'
                            if _file_style_extra == "line-through"
                            else ""
                        )
                        _name_open = f"<span{_name_style}>"
                        _name_close = "</span>"
                        _file_style = f"color: {color};"
                    else:
                        _git_badge = ""
                        _name_open = ""
                        _name_close = ""
                        _file_style = f"color: {color};"

                    if self.sort_by_loc and self.sort_by_size and self.sort_by_mtime:
                        html_content.append(
                            f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close} ({loc} lines, {format_size(size)}, {format_timestamp(mtime)}){_git_badge}</li>'
                        )
                    elif self.sort_by_loc and self.sort_by_mtime:
                        html_content.append(
                            f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close} ({loc} lines, {format_timestamp(mtime)}){_git_badge}</li>'
                        )
                    elif self.sort_by_size and self.sort_by_mtime:
                        html_content.append(
                            f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close} ({format_size(size)}, {format_timestamp(mtime)}){_git_badge}</li>'
                        )
                    elif self.sort_by_loc and self.sort_by_size:
                        html_content.append(
                            f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close} ({loc} lines, {format_size(size)}){_git_badge}</li>'
                        )
                    elif self.sort_by_mtime:
                        html_content.append(
                            f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close} ({format_timestamp(mtime)}){_git_badge}</li>'
                        )
                    elif self.sort_by_size:
                        html_content.append(
                            f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close} ({format_size(size)}){_git_badge}</li>'
                        )
                    elif self.sort_by_loc:
                        html_content.append(
                            f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close} ({loc} lines){_git_badge}</li>'
                        )
                    else:
                        html_content.append(
                            f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close}{_git_badge}</li>'
                        )
            for name, content in sorted(structure.items()):
                if (
                    name == "_files"
                    or name == "_max_depth_reached"
                    or name == "_loc"
                    or name == "_size"
                    or name == "_mtime"
                    or name == "_git_markers"
                ):
                    continue

                folder_icon = get_icon(name, is_dir=True, style=self.icon_style)

                if (
                    self.sort_by_loc
                    and self.sort_by_size
                    and self.sort_by_mtime
                    and isinstance(content, dict)
                    and "_loc" in content
                    and "_size" in content
                    and "_mtime" in content
                ):
                    loc_count = content["_loc"]
                    size_count = content["_size"]
                    mtime_count = content["_mtime"]
                    html_content.append(
                        f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span> '
                        f'<span class="metric-count">({loc_count} lines, {format_size(size_count)}, {format_timestamp(mtime_count)})</span>'
                    )
                elif (
                    self.sort_by_loc
                    and self.sort_by_size
                    and isinstance(content, dict)
                    and "_loc" in content
                    and "_size" in content
                ):
                    loc_count = content["_loc"]
                    size_count = content["_size"]
                    html_content.append(
                        f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span> '
                        f'<span class="metric-count">({loc_count} lines, {format_size(size_count)})</span>'
                    )
                elif (
                    self.sort_by_loc
                    and self.sort_by_mtime
                    and isinstance(content, dict)
                    and "_loc" in content
                    and "_mtime" in content
                ):
                    loc_count = content["_loc"]
                    mtime_count = content["_mtime"]
                    html_content.append(
                        f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span> '
                        f'<span class="metric-count">({loc_count} lines, {format_timestamp(mtime_count)})</span>'
                    )
                elif (
                    self.sort_by_size
                    and self.sort_by_mtime
                    and isinstance(content, dict)
                    and "_size" in content
                    and "_mtime" in content
                ):
                    size_count = content["_size"]
                    mtime_count = content["_mtime"]
                    html_content.append(
                        f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span> '
                        f'<span class="metric-count">({format_size(size_count)}, {format_timestamp(mtime_count)})</span>'
                    )
                elif (
                    self.sort_by_loc and isinstance(content, dict) and "_loc" in content
                ):
                    loc_count = content["_loc"]
                    html_content.append(
                        f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span> '
                        f'<span class="loc-count">({loc_count} lines)</span>'
                    )
                elif (
                    self.sort_by_size
                    and isinstance(content, dict)
                    and "_size" in content
                ):
                    size_count = content["_size"]
                    html_content.append(
                        f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span> '
                        f'<span class="size-count">({format_size(size_count)})</span>'
                    )
                elif (
                    self.sort_by_mtime
                    and isinstance(content, dict)
                    and "_mtime" in content
                ):
                    mtime_count = content["_mtime"]
                    html_content.append(
                        f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span> '
                        f'<span class="mtime-count">({format_timestamp(mtime_count)})</span>'
                    )
                else:
                    html_content.append(
                        f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span>'
                    )
                next_path = os.path.join(path_prefix, name) if path_prefix else name
                if isinstance(content, dict):
                    if content.get("_max_depth_reached"):
                        html_content.append(
                            '<ul><li class="max-depth">⋯ (max depth reached)</li></ul>'
                        )
                    else:
                        html_content.append(_build_html_tree(content, next_path))
                html_content.append("</li>")
            html_content.append("</ul>")
            return "\n".join(html_content)

        root_icon = get_icon(self.root_name, is_dir=True, style=self.icon_style)

        title = f"{root_icon} {html.escape(self.root_name)}"
        if (
            self.sort_by_loc
            and self.sort_by_size
            and self.sort_by_mtime
            and "_loc" in self.structure
            and "_size" in self.structure
            and "_mtime" in self.structure
        ):
            title = f"{root_icon} {html.escape(self.root_name)} ({self.structure['_loc']} lines, {format_size(self.structure['_size'])}, {format_timestamp(self.structure['_mtime'])})"
        elif (
            self.sort_by_loc
            and self.sort_by_size
            and "_loc" in self.structure
            and "_size" in self.structure
        ):
            title = f"{root_icon} {html.escape(self.root_name)} ({self.structure['_loc']} lines, {format_size(self.structure['_size'])})"
        elif (
            self.sort_by_loc
            and self.sort_by_mtime
            and "_loc" in self.structure
            and "_mtime" in self.structure
        ):
            title = f"{root_icon} {html.escape(self.root_name)} ({self.structure['_loc']} lines, {format_timestamp(self.structure['_mtime'])})"
        elif (
            self.sort_by_size
            and self.sort_by_mtime
            and "_size" in self.structure
            and "_mtime" in self.structure
        ):
            title = f"{root_icon} {html.escape(self.root_name)} ({format_size(self.structure['_size'])}, {format_timestamp(self.structure['_mtime'])})"
        elif self.sort_by_loc and "_loc" in self.structure:
            title = f"{root_icon} {html.escape(self.root_name)} ({self.structure['_loc']} lines)"
        elif self.sort_by_size and "_size" in self.structure:
            title = f"{root_icon} {html.escape(self.root_name)} ({format_size(self.structure['_size'])})"
        elif self.sort_by_mtime and "_mtime" in self.structure:
            title = f"{root_icon} {html.escape(self.root_name)} ({format_timestamp(self.structure['_mtime'])})"
        loc_styles = (
            """
            .loc-count {
                color: #666;
                font-size: 0.9em;
                font-weight: normal;
            }
        """
            if self.sort_by_loc
            else ""
        )
        size_styles = (
            """
            .size-count {
                color: #666;
                font-size: 0.9em;
                font-weight: normal;
            }
        """
            if self.sort_by_size
            else ""
        )
        mtime_styles = (
            """
            .mtime-count {
                color: #666;
                font-size: 0.9em;
                font-weight: normal;
            }
        """
            if self.sort_by_mtime
            else ""
        )
        metric_styles = (
            """
            .metric-count {
                color: #666;
                font-size: 0.9em;
                font-weight: normal;
            }
        """
            if (self.sort_by_size and self.sort_by_loc)
            or (self.sort_by_mtime and (self.sort_by_loc or self.sort_by_size))
            else ""
        )
        git_styles = (
            """
            .git-badge {
                font-size: 0.78em;
                font-weight: bold;
                font-family: monospace;
                margin-left: 4px;
                vertical-align: middle;
            }
            .git-u { color: #999999; }
            .git-m { color: #d4a017; }
            .git-a { color: #28a745; }
            .git-d { color: #dc3545; }
        """
            if self.show_git_status
            else ""
        )
        html_template = f"""
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="utf-8">
            <title>Directory Structure - {html.escape(self.root_name)}</title>
            <style>
                body {{
                    font-family: Arial, sans-serif;
                    margin: 20px;
                }}
                ul {{
                    list-style-type: none;
                    padding-left: 20px;
                }}
                .directory {{
                    color: #2c3e50;
                }}
                .dir-name {{
                    font-weight: bold;
                }}
                .file {{
                    color: #34495e;
                }}
                .max-depth {{
                    color: #999;
                    font-style: italic;
                }}
                .path-info {{
                    margin-bottom: 20px;
                    font-style: italic;
                    color: #666;
                }}
                {loc_styles}
                {size_styles}
                {mtime_styles}
                {metric_styles}
                {git_styles}
            </style>
        </head>
        <body>
            <h1>{title}</h1>
            {_build_html_tree(self.structure, self.root_name if self.show_full_path else "")}
        </body>
        </html>
        """

        try:
            with open(output_path, "w", encoding="utf-8") as f:
                f.write(html_template)
        except Exception as e:
            logger.error(f"Error exporting to HTML: {e}")
            raise

    def to_markdown(self, output_path: str) -> None:
        """Export directory structure to a Markdown file.

        Creates a Markdown file with a structured representation of the directory hierarchy using headings, lists, and formatting to distinguish between files and directories.

        Args:
            output_path: Path where the Markdown file will be saved
        """

        def _build_md_tree(
            structure: dict[str, Any],
            level: int = 0,
            path_prefix: str = "",
        ) -> list[str]:
            lines = []
            indent = "    " * level
            if "_files" in structure:
                for file_item in sort_files_by_type(
                    structure["_files"],
                    self.sort_by_loc,
                    self.sort_by_size,
                    self.sort_by_mtime,
                ):
                    display_path = ""
                    loc = 0
                    size = 0
                    mtime = 0

                    if isinstance(file_item, tuple):
                        if len(file_item) <= 0:
                            continue

                        if len(file_item) > 1:
                            display_path = file_item[1]
                        else:
                            display_path = file_item[0]

                        if (
                            self.sort_by_loc
                            and self.sort_by_size
                            and self.sort_by_mtime
                            and len(file_item) > 4
                        ):
                            loc = file_item[2]
                            size = file_item[3]
                            mtime = int(file_item[4])
                        elif (
                            self.sort_by_loc
                            and self.sort_by_mtime
                            and len(file_item) > 4
                        ):
                            loc = file_item[2]
                            mtime = int(file_item[4])
                        elif (
                            self.sort_by_size
                            and self.sort_by_mtime
                            and len(file_item) > 4
                        ):
                            size = file_item[3]
                            mtime = int(file_item[4])
                        elif (
                            self.sort_by_loc
                            and self.sort_by_size
                            and len(file_item) > 3
                        ):
                            loc = file_item[2]
                            size = file_item[3]
                        elif self.sort_by_loc and len(file_item) > 2:
                            loc = file_item[2]
                        elif self.sort_by_size and len(file_item) > 2:
                            size = file_item[2]
                        elif self.sort_by_mtime and len(file_item) > 2:
                            mtime = file_item[2]
                    else:
                        display_path = file_item

                    _fname_md = (
                        file_item[0]
                        if isinstance(file_item, tuple) and len(file_item) > 0
                        else (file_item if isinstance(file_item, str) else "unknown")
                    )

                    file_icon = get_icon(_fname_md, is_dir=False, style=self.icon_style)

                    _git_markers_md = structure.get("_git_markers", {})
                    _git_marker_md = (
                        _git_markers_md.get(_fname_md, "")
                        if self.show_git_status
                        else ""
                    )
                    _GIT_MD_BADGE = {
                        "U": "**[U]**",
                        "M": "**[M]**",
                        "A": "**[A]**",
                        "D": "**[D]**",
                    }
                    if _git_marker_md == "D":
                        _md_display = f"~~`{display_path}`~~"
                    else:
                        _md_display = f"`{display_path}`"
                    _md_git_suffix = (
                        f" {_GIT_MD_BADGE[_git_marker_md]}"
                        if _git_marker_md in _GIT_MD_BADGE
                        else ""
                    )

                    if self.sort_by_loc and self.sort_by_size and self.sort_by_mtime:
                        lines.append(
                            f"{indent}- {file_icon} {_md_display} ({loc} lines, {format_size(size)}, {format_timestamp(mtime)}){_md_git_suffix}"
                        )
                    elif self.sort_by_loc and self.sort_by_mtime:
                        lines.append(
                            f"{indent}- {file_icon} {_md_display} ({loc} lines, {format_timestamp(mtime)}){_md_git_suffix}"
                        )
                    elif self.sort_by_size and self.sort_by_mtime:
                        lines.append(
                            f"{indent}- {file_icon} {_md_display} ({format_size(size)}, {format_timestamp(mtime)}){_md_git_suffix}"
                        )
                    elif self.sort_by_loc and self.sort_by_size:
                        lines.append(
                            f"{indent}- {file_icon} {_md_display} ({loc} lines, {format_size(size)}){_md_git_suffix}"
                        )
                    elif self.sort_by_mtime:
                        lines.append(
                            f"{indent}- {file_icon} {_md_display} ({format_timestamp(mtime)}){_md_git_suffix}"
                        )
                    elif self.sort_by_size:
                        lines.append(
                            f"{indent}- {file_icon} {_md_display} ({format_size(size)}){_md_git_suffix}"
                        )
                    elif self.sort_by_loc:
                        lines.append(
                            f"{indent}- {file_icon} {_md_display} ({loc} lines){_md_git_suffix}"
                        )
                    else:
                        lines.append(
                            f"{indent}- {file_icon} {_md_display}{_md_git_suffix}"
                        )
            for name, content in sorted(structure.items()):
                if (
                    name == "_files"
                    or name == "_max_depth_reached"
                    or name == "_loc"
                    or name == "_size"
                    or name == "_mtime"
                    or name == "_git_markers"
                ):
                    continue

                folder_icon = get_icon(name, is_dir=True, style=self.icon_style)

                if (
                    self.sort_by_loc
                    and self.sort_by_size
                    and self.sort_by_mtime
                    and isinstance(content, dict)
                    and "_loc" in content
                    and "_size" in content
                    and "_mtime" in content
                ):
                    loc_count = content["_loc"]
                    size_count = content["_size"]
                    mtime_count = content["_mtime"]
                    lines.append(
                        f"{indent}- {folder_icon} **{name}** ({loc_count} lines, {format_size(size_count)}, {format_timestamp(mtime_count)})"
                    )
                elif (
                    self.sort_by_loc
                    and self.sort_by_size
                    and isinstance(content, dict)
                    and "_loc" in content
                    and "_size" in content
                ):
                    loc_count = content["_loc"]
                    size_count = content["_size"]
                    lines.append(
                        f"{indent}- {folder_icon} **{name}** ({loc_count} lines, {format_size(size_count)})"
                    )
                elif (
                    self.sort_by_loc
                    and self.sort_by_mtime
                    and isinstance(content, dict)
                    and "_loc" in content
                    and "_mtime" in content
                ):
                    loc_count = content["_loc"]
                    mtime_count = content["_mtime"]
                    lines.append(
                        f"{indent}- {folder_icon} **{name}** ({loc_count} lines, {format_timestamp(mtime_count)})"
                    )
                elif (
                    self.sort_by_size
                    and self.sort_by_mtime
                    and isinstance(content, dict)
                    and "_size" in content
                    and "_mtime" in content
                ):
                    size_count = content["_size"]
                    mtime_count = content["_mtime"]
                    lines.append(
                        f"{indent}- {folder_icon} **{name}** ({format_size(size_count)}, {format_timestamp(mtime_count)})"
                    )
                elif (
                    self.sort_by_loc and isinstance(content, dict) and "_loc" in content
                ):
                    loc_count = content["_loc"]
                    lines.append(
                        f"{indent}- {folder_icon} **{name}** ({loc_count} lines)"
                    )
                elif (
                    self.sort_by_size
                    and isinstance(content, dict)
                    and "_size" in content
                ):
                    size_count = content["_size"]
                    lines.append(
                        f"{indent}- {folder_icon} **{name}** ({format_size(size_count)})"
                    )
                elif (
                    self.sort_by_mtime
                    and isinstance(content, dict)
                    and "_mtime" in content
                ):
                    mtime_count = content["_mtime"]
                    lines.append(
                        f"{indent}- {folder_icon} **{name}** ({format_timestamp(mtime_count)})"
                    )
                else:
                    lines.append(f"{indent}- {folder_icon} **{name}**")
                next_path = os.path.join(path_prefix, name) if path_prefix else name
                if isinstance(content, dict):
                    if content.get("_max_depth_reached"):
                        lines.append(f"{indent}    - ⋯ *(max depth reached)*")
                    else:
                        lines.extend(_build_md_tree(content, level + 1, next_path))
            return lines

        root_icon = get_icon(self.root_name, is_dir=True, style=self.icon_style)

        if (
            self.sort_by_loc
            and self.sort_by_size
            and self.sort_by_mtime
            and "_loc" in self.structure
            and "_size" in self.structure
            and "_mtime" in self.structure
        ):
            md_content = [
                f"# {root_icon} {self.root_name} ({self.structure['_loc']} lines, {format_size(self.structure['_size'])}, {format_timestamp(self.structure['_mtime'])})",
                "",
            ]
        elif (
            self.sort_by_loc
            and self.sort_by_size
            and "_loc" in self.structure
            and "_size" in self.structure
        ):
            md_content = [
                f"# {root_icon} {self.root_name} ({self.structure['_loc']} lines, {format_size(self.structure['_size'])})",
                "",
            ]
        elif (
            self.sort_by_loc
            and self.sort_by_mtime
            and "_loc" in self.structure
            and "_mtime" in self.structure
        ):
            md_content = [
                f"# {root_icon} {self.root_name} ({self.structure['_loc']} lines, {format_timestamp(self.structure['_mtime'])})",
                "",
            ]
        elif (
            self.sort_by_size
            and self.sort_by_mtime
            and "_size" in self.structure
            and "_mtime" in self.structure
        ):
            md_content = [
                f"# {root_icon} {self.root_name} ({format_size(self.structure['_size'])}, {format_timestamp(self.structure['_mtime'])})",
                "",
            ]
        elif self.sort_by_loc and "_loc" in self.structure:
            md_content = [
                f"# {root_icon} {self.root_name} ({self.structure['_loc']} lines)",
                "",
            ]
        elif self.sort_by_size and "_size" in self.structure:
            md_content = [
                f"# {root_icon} {self.root_name} ({format_size(self.structure['_size'])})",
                "",
            ]
        elif self.sort_by_mtime and "_mtime" in self.structure:
            md_content = [
                f"# {root_icon} {self.root_name} ({format_timestamp(self.structure['_mtime'])})",
                "",
            ]
        else:
            md_content = [f"# {root_icon} {self.root_name}", ""]
        md_content.extend(
            _build_md_tree(
                self.structure, 0, self.root_name if self.show_full_path else ""
            )
        )
        try:
            with open(output_path, "w", encoding="utf-8") as f:
                f.write("\n".join(md_content))
        except Exception as e:
            logger.error(f"Error exporting to Markdown: {e}")
            raise

    def to_jsx(self, output_path: str) -> None:
        """Export directory structure to a React component (JSX file).

        Creates a JSX file containing a React component for interactive visualization of the directory structure with collapsible folders and styling.

        Args:
            output_path: Path where the React component file will be saved
        """

        try:
            generate_jsx_component(
                self.structure,
                self.root_name,
                output_path,
                self.show_full_path,
                self.sort_by_loc,
                self.sort_by_size,
                self.sort_by_mtime,
                self.show_git_status,
            )
        except Exception as e:
            logger.error(f"Error exporting to React component: {e}")
            raise

__init__(structure, root_name, base_path=None, sort_by_loc=False, sort_by_size=False, sort_by_mtime=False, show_git_status=False, icon_style='emoji')

Initialize the exporter with directory structure and root name.

Parameters:

Name Type Description Default
structure dict[str, Any]

The directory structure dictionary

required
root_name str

Name of the root directory

required
base_path Optional[str]

Base path for full path display (if None, only show filenames)

None
sort_by_loc bool

Whether to include lines of code counts in exports

False
sort_by_size bool

Whether to include file size information in exports

False
sort_by_mtime bool

Whether to include modification time information in exports

False
show_git_status bool

Whether to annotate files with Git status markers

False
Source code in recursivist/exports.py
def __init__(
    self,
    structure: dict[str, Any],
    root_name: str,
    base_path: Optional[str] = None,
    sort_by_loc: bool = False,
    sort_by_size: bool = False,
    sort_by_mtime: bool = False,
    show_git_status: bool = False,
    icon_style: str = "emoji",
):
    """Initialize the exporter with directory structure and root name.

    Args:
        structure: The directory structure dictionary
        root_name: Name of the root directory
        base_path: Base path for full path display (if None, only show filenames)
        sort_by_loc: Whether to include lines of code counts in exports
        sort_by_size: Whether to include file size information in exports
        sort_by_mtime: Whether to include modification time information in exports
        show_git_status: Whether to annotate files with Git status markers
    """

    self.structure = structure
    self.root_name = root_name
    self.base_path = base_path
    self.show_full_path = base_path is not None
    self.sort_by_loc = sort_by_loc
    self.sort_by_size = sort_by_size
    self.sort_by_mtime = sort_by_mtime
    self.show_git_status = show_git_status
    self.icon_style = icon_style

to_html(output_path)

Export directory structure to an HTML file.

Creates a standalone HTML file with a styled representation of the directory structure using nested unordered lists with CSS styling for colors and indentation.

Parameters:

Name Type Description Default
output_path str

Path where the HTML file will be saved

required
Source code in recursivist/exports.py
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
def to_html(self, output_path: str) -> None:
    """Export directory structure to an HTML file.

    Creates a standalone HTML file with a styled representation of the directory structure using nested unordered lists with CSS styling for colors and indentation.

    Args:
        output_path: Path where the HTML file will be saved
    """

    def _build_html_tree(
        structure: dict[str, Any],
        path_prefix: str = "",
    ) -> str:
        html_content = ["<ul>"]
        if "_files" in structure:
            for file_item in sort_files_by_type(
                structure["_files"],
                self.sort_by_loc,
                self.sort_by_size,
                self.sort_by_mtime,
            ):
                file_name = "unknown"
                display_path = "unknown"
                loc = 0
                size = 0
                mtime = 0

                if isinstance(file_item, tuple):
                    if len(file_item) > 0:
                        file_name = file_item[0]
                    if len(file_item) > 1:
                        display_path = file_item[1]
                    else:
                        display_path = file_name

                    if (
                        self.sort_by_loc
                        and self.sort_by_size
                        and self.sort_by_mtime
                        and len(file_item) > 4
                    ):
                        loc = file_item[2]
                        size = file_item[3]
                        mtime = int(file_item[4])
                    elif (
                        self.sort_by_loc
                        and self.sort_by_mtime
                        and len(file_item) > 4
                    ):
                        loc = file_item[2]
                        mtime = int(file_item[4])
                    elif (
                        self.sort_by_size
                        and self.sort_by_mtime
                        and len(file_item) > 4
                    ):
                        size = file_item[3]
                        mtime = int(file_item[4])
                    elif (
                        self.sort_by_loc
                        and self.sort_by_size
                        and len(file_item) > 3
                    ):
                        loc = file_item[2]
                        size = file_item[3]
                    elif self.sort_by_loc and len(file_item) > 2:
                        loc = file_item[2]
                    elif self.sort_by_size and len(file_item) > 2:
                        size = file_item[2]
                    elif self.sort_by_mtime and len(file_item) > 2:
                        mtime = file_item[2]
                else:
                    file_name = file_item
                    display_path = file_name

                ext = os.path.splitext(file_name)[1].lower()
                color = generate_color_for_extension(ext)

                file_icon = get_icon(file_name, is_dir=False, style=self.icon_style)

                _git_markers_here = structure.get("_git_markers", {})
                _git_marker = (
                    _git_markers_here.get(file_name, "")
                    if self.show_git_status
                    else ""
                )
                _GIT_HTML_STYLES = {
                    "U": ("#999999", "dim"),
                    "M": ("#d4a017", ""),
                    "A": ("#28a745", ""),
                    "D": ("#dc3545", "line-through"),
                }
                if _git_marker and _git_marker in _GIT_HTML_STYLES:
                    _badge_color, _file_style_extra = _GIT_HTML_STYLES[_git_marker]
                    _git_badge = f' <span class="git-badge git-{_git_marker.lower()}" style="color:{_badge_color};font-size:0.8em;font-weight:bold;">[{_git_marker}]</span>'
                    _name_style = (
                        ' style="text-decoration: line-through;"'
                        if _file_style_extra == "line-through"
                        else ""
                    )
                    _name_open = f"<span{_name_style}>"
                    _name_close = "</span>"
                    _file_style = f"color: {color};"
                else:
                    _git_badge = ""
                    _name_open = ""
                    _name_close = ""
                    _file_style = f"color: {color};"

                if self.sort_by_loc and self.sort_by_size and self.sort_by_mtime:
                    html_content.append(
                        f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close} ({loc} lines, {format_size(size)}, {format_timestamp(mtime)}){_git_badge}</li>'
                    )
                elif self.sort_by_loc and self.sort_by_mtime:
                    html_content.append(
                        f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close} ({loc} lines, {format_timestamp(mtime)}){_git_badge}</li>'
                    )
                elif self.sort_by_size and self.sort_by_mtime:
                    html_content.append(
                        f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close} ({format_size(size)}, {format_timestamp(mtime)}){_git_badge}</li>'
                    )
                elif self.sort_by_loc and self.sort_by_size:
                    html_content.append(
                        f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close} ({loc} lines, {format_size(size)}){_git_badge}</li>'
                    )
                elif self.sort_by_mtime:
                    html_content.append(
                        f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close} ({format_timestamp(mtime)}){_git_badge}</li>'
                    )
                elif self.sort_by_size:
                    html_content.append(
                        f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close} ({format_size(size)}){_git_badge}</li>'
                    )
                elif self.sort_by_loc:
                    html_content.append(
                        f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close} ({loc} lines){_git_badge}</li>'
                    )
                else:
                    html_content.append(
                        f'<li class="file" style="{_file_style}">{file_icon} {_name_open}{html.escape(display_path)}{_name_close}{_git_badge}</li>'
                    )
        for name, content in sorted(structure.items()):
            if (
                name == "_files"
                or name == "_max_depth_reached"
                or name == "_loc"
                or name == "_size"
                or name == "_mtime"
                or name == "_git_markers"
            ):
                continue

            folder_icon = get_icon(name, is_dir=True, style=self.icon_style)

            if (
                self.sort_by_loc
                and self.sort_by_size
                and self.sort_by_mtime
                and isinstance(content, dict)
                and "_loc" in content
                and "_size" in content
                and "_mtime" in content
            ):
                loc_count = content["_loc"]
                size_count = content["_size"]
                mtime_count = content["_mtime"]
                html_content.append(
                    f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span> '
                    f'<span class="metric-count">({loc_count} lines, {format_size(size_count)}, {format_timestamp(mtime_count)})</span>'
                )
            elif (
                self.sort_by_loc
                and self.sort_by_size
                and isinstance(content, dict)
                and "_loc" in content
                and "_size" in content
            ):
                loc_count = content["_loc"]
                size_count = content["_size"]
                html_content.append(
                    f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span> '
                    f'<span class="metric-count">({loc_count} lines, {format_size(size_count)})</span>'
                )
            elif (
                self.sort_by_loc
                and self.sort_by_mtime
                and isinstance(content, dict)
                and "_loc" in content
                and "_mtime" in content
            ):
                loc_count = content["_loc"]
                mtime_count = content["_mtime"]
                html_content.append(
                    f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span> '
                    f'<span class="metric-count">({loc_count} lines, {format_timestamp(mtime_count)})</span>'
                )
            elif (
                self.sort_by_size
                and self.sort_by_mtime
                and isinstance(content, dict)
                and "_size" in content
                and "_mtime" in content
            ):
                size_count = content["_size"]
                mtime_count = content["_mtime"]
                html_content.append(
                    f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span> '
                    f'<span class="metric-count">({format_size(size_count)}, {format_timestamp(mtime_count)})</span>'
                )
            elif (
                self.sort_by_loc and isinstance(content, dict) and "_loc" in content
            ):
                loc_count = content["_loc"]
                html_content.append(
                    f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span> '
                    f'<span class="loc-count">({loc_count} lines)</span>'
                )
            elif (
                self.sort_by_size
                and isinstance(content, dict)
                and "_size" in content
            ):
                size_count = content["_size"]
                html_content.append(
                    f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span> '
                    f'<span class="size-count">({format_size(size_count)})</span>'
                )
            elif (
                self.sort_by_mtime
                and isinstance(content, dict)
                and "_mtime" in content
            ):
                mtime_count = content["_mtime"]
                html_content.append(
                    f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span> '
                    f'<span class="mtime-count">({format_timestamp(mtime_count)})</span>'
                )
            else:
                html_content.append(
                    f'<li class="directory">{folder_icon} <span class="dir-name">{html.escape(name)}</span>'
                )
            next_path = os.path.join(path_prefix, name) if path_prefix else name
            if isinstance(content, dict):
                if content.get("_max_depth_reached"):
                    html_content.append(
                        '<ul><li class="max-depth">⋯ (max depth reached)</li></ul>'
                    )
                else:
                    html_content.append(_build_html_tree(content, next_path))
            html_content.append("</li>")
        html_content.append("</ul>")
        return "\n".join(html_content)

    root_icon = get_icon(self.root_name, is_dir=True, style=self.icon_style)

    title = f"{root_icon} {html.escape(self.root_name)}"
    if (
        self.sort_by_loc
        and self.sort_by_size
        and self.sort_by_mtime
        and "_loc" in self.structure
        and "_size" in self.structure
        and "_mtime" in self.structure
    ):
        title = f"{root_icon} {html.escape(self.root_name)} ({self.structure['_loc']} lines, {format_size(self.structure['_size'])}, {format_timestamp(self.structure['_mtime'])})"
    elif (
        self.sort_by_loc
        and self.sort_by_size
        and "_loc" in self.structure
        and "_size" in self.structure
    ):
        title = f"{root_icon} {html.escape(self.root_name)} ({self.structure['_loc']} lines, {format_size(self.structure['_size'])})"
    elif (
        self.sort_by_loc
        and self.sort_by_mtime
        and "_loc" in self.structure
        and "_mtime" in self.structure
    ):
        title = f"{root_icon} {html.escape(self.root_name)} ({self.structure['_loc']} lines, {format_timestamp(self.structure['_mtime'])})"
    elif (
        self.sort_by_size
        and self.sort_by_mtime
        and "_size" in self.structure
        and "_mtime" in self.structure
    ):
        title = f"{root_icon} {html.escape(self.root_name)} ({format_size(self.structure['_size'])}, {format_timestamp(self.structure['_mtime'])})"
    elif self.sort_by_loc and "_loc" in self.structure:
        title = f"{root_icon} {html.escape(self.root_name)} ({self.structure['_loc']} lines)"
    elif self.sort_by_size and "_size" in self.structure:
        title = f"{root_icon} {html.escape(self.root_name)} ({format_size(self.structure['_size'])})"
    elif self.sort_by_mtime and "_mtime" in self.structure:
        title = f"{root_icon} {html.escape(self.root_name)} ({format_timestamp(self.structure['_mtime'])})"
    loc_styles = (
        """
        .loc-count {
            color: #666;
            font-size: 0.9em;
            font-weight: normal;
        }
    """
        if self.sort_by_loc
        else ""
    )
    size_styles = (
        """
        .size-count {
            color: #666;
            font-size: 0.9em;
            font-weight: normal;
        }
    """
        if self.sort_by_size
        else ""
    )
    mtime_styles = (
        """
        .mtime-count {
            color: #666;
            font-size: 0.9em;
            font-weight: normal;
        }
    """
        if self.sort_by_mtime
        else ""
    )
    metric_styles = (
        """
        .metric-count {
            color: #666;
            font-size: 0.9em;
            font-weight: normal;
        }
    """
        if (self.sort_by_size and self.sort_by_loc)
        or (self.sort_by_mtime and (self.sort_by_loc or self.sort_by_size))
        else ""
    )
    git_styles = (
        """
        .git-badge {
            font-size: 0.78em;
            font-weight: bold;
            font-family: monospace;
            margin-left: 4px;
            vertical-align: middle;
        }
        .git-u { color: #999999; }
        .git-m { color: #d4a017; }
        .git-a { color: #28a745; }
        .git-d { color: #dc3545; }
    """
        if self.show_git_status
        else ""
    )
    html_template = f"""
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8">
        <title>Directory Structure - {html.escape(self.root_name)}</title>
        <style>
            body {{
                font-family: Arial, sans-serif;
                margin: 20px;
            }}
            ul {{
                list-style-type: none;
                padding-left: 20px;
            }}
            .directory {{
                color: #2c3e50;
            }}
            .dir-name {{
                font-weight: bold;
            }}
            .file {{
                color: #34495e;
            }}
            .max-depth {{
                color: #999;
                font-style: italic;
            }}
            .path-info {{
                margin-bottom: 20px;
                font-style: italic;
                color: #666;
            }}
            {loc_styles}
            {size_styles}
            {mtime_styles}
            {metric_styles}
            {git_styles}
        </style>
    </head>
    <body>
        <h1>{title}</h1>
        {_build_html_tree(self.structure, self.root_name if self.show_full_path else "")}
    </body>
    </html>
    """

    try:
        with open(output_path, "w", encoding="utf-8") as f:
            f.write(html_template)
    except Exception as e:
        logger.error(f"Error exporting to HTML: {e}")
        raise

to_json(output_path)

Export directory structure to a JSON file.

Creates a JSON file containing the directory structure with options for including full paths, LOC counts, file sizes, and modification times. The JSON structure includes a root name and the hierarchical structure of directories and files.

Parameters:

Name Type Description Default
output_path str

Path where the JSON file will be saved

required
Source code in recursivist/exports.py
def to_json(self, output_path: str) -> None:
    """Export directory structure to a JSON file.

    Creates a JSON file containing the directory structure with options for including full paths, LOC counts, file sizes, and modification times. The JSON structure includes a root name and the hierarchical structure of directories and files.

    Args:
        output_path: Path where the JSON file will be saved
    """

    if (
        self.show_full_path
        or self.sort_by_loc
        or self.sort_by_size
        or self.sort_by_mtime
    ):

        def convert_structure_for_json(structure: dict[str, Any]) -> dict[str, Any]:
            result: dict[str, Any] = {}
            _git_markers_here = structure.get("_git_markers", {})
            for k, v in structure.items():
                if k == "_files":
                    result[k] = []
                    for item in v:
                        if not isinstance(item, tuple):
                            if self.show_git_status:
                                _gs = _git_markers_here.get(item, "")
                                entry: Any = {"name": item}
                                if _gs:
                                    entry["git_status"] = _gs
                                result[k].append(entry)
                            else:
                                result[k].append(item)
                            continue

                        file_name = "unknown"
                        full_path = ""
                        loc = 0
                        size = 0
                        mtime = 0

                        if len(item) > 0:
                            file_name = item[0]
                        if len(item) > 1:
                            full_path = item[1]

                        _git_status = (
                            _git_markers_here.get(file_name, "")
                            if self.show_git_status
                            else ""
                        )

                        def _maybe_git(
                            d: dict[str, Any], _gs: str = _git_status
                        ) -> dict[str, Any]:
                            if _gs:
                                d["git_status"] = _gs
                            return d

                        if (
                            self.sort_by_loc
                            and self.sort_by_size
                            and self.sort_by_mtime
                            and len(item) > 4
                        ):
                            loc = item[2]
                            size = item[3]
                            mtime = item[4]
                            result[k].append(
                                _maybe_git(
                                    {
                                        "name": file_name,
                                        "path": full_path,
                                        "loc": loc,
                                        "size": size,
                                        "size_formatted": format_size(size),
                                        "mtime": mtime,
                                        "mtime_formatted": format_timestamp(mtime),
                                    }
                                )
                            )
                        elif (
                            self.sort_by_loc
                            and self.sort_by_mtime
                            and len(item) > 4
                        ):
                            loc = item[2]
                            mtime = item[4]
                            result[k].append(
                                _maybe_git(
                                    {
                                        "name": file_name,
                                        "path": full_path,
                                        "loc": loc,
                                        "mtime": mtime,
                                        "mtime_formatted": format_timestamp(mtime),
                                    }
                                )
                            )
                        elif (
                            self.sort_by_size
                            and self.sort_by_mtime
                            and len(item) > 4
                        ):
                            size = item[3]
                            mtime = item[4]
                            result[k].append(
                                _maybe_git(
                                    {
                                        "name": file_name,
                                        "path": full_path,
                                        "size": size,
                                        "size_formatted": format_size(size),
                                        "mtime": mtime,
                                        "mtime_formatted": format_timestamp(mtime),
                                    }
                                )
                            )
                        elif (
                            self.sort_by_loc and self.sort_by_size and len(item) > 3
                        ):
                            loc = item[2] if len(item) > 2 else 0
                            size = item[3] if len(item) > 3 else 0
                            result[k].append(
                                _maybe_git(
                                    {
                                        "name": file_name,
                                        "path": full_path,
                                        "loc": loc,
                                        "size": size,
                                        "size_formatted": format_size(size),
                                    }
                                )
                            )
                        elif self.sort_by_mtime and len(item) > 2:
                            mtime = item[2] if len(item) > 2 else 0
                            result[k].append(
                                _maybe_git(
                                    {
                                        "name": file_name,
                                        "path": full_path,
                                        "mtime": mtime,
                                        "mtime_formatted": format_timestamp(mtime),
                                    }
                                )
                            )
                        elif self.sort_by_size and len(item) > 2:
                            size = item[2] if len(item) > 2 else 0
                            result[k].append(
                                _maybe_git(
                                    {
                                        "name": file_name,
                                        "path": full_path,
                                        "size": size,
                                        "size_formatted": format_size(size),
                                    }
                                )
                            )
                        elif self.sort_by_loc and len(item) > 2:
                            loc = item[2] if len(item) > 2 else 0
                            result[k].append(
                                _maybe_git(
                                    {
                                        "name": file_name,
                                        "path": full_path,
                                        "loc": loc,
                                    }
                                )
                            )
                        elif self.show_full_path and len(item) > 1:
                            result[k].append(
                                _maybe_git({"name": file_name, "path": full_path})
                            )
                        else:
                            if _git_status:
                                result[k].append(
                                    {"name": file_name, "git_status": _git_status}
                                )
                            else:
                                result[k].append(file_name)
                elif k == "_loc":
                    if self.sort_by_loc:
                        result[k] = v
                elif k == "_size":
                    if self.sort_by_size:
                        result[k] = v
                        result["_size_formatted"] = format_size(v)
                elif k == "_mtime":
                    if self.sort_by_mtime:
                        result[k] = v
                        result["_mtime_formatted"] = format_timestamp(v)
                elif k == "_git_markers":
                    pass
                elif k == "_max_depth_reached":
                    result[k] = v
                elif isinstance(v, dict):
                    result[k] = convert_structure_for_json(v)
                else:
                    result[k] = v
            return result

        export_structure = convert_structure_for_json(self.structure)
    else:
        export_structure = self.structure
    try:
        with open(output_path, "w", encoding="utf-8") as f:
            json.dump(
                {
                    "root": self.root_name,
                    "structure": export_structure,
                    "show_loc": self.sort_by_loc,
                    "show_size": self.sort_by_size,
                    "show_mtime": self.sort_by_mtime,
                    "show_git_status": self.show_git_status,
                },
                f,
                indent=2,
            )
    except Exception as e:
        logger.error(f"Error exporting to JSON: {e}")
        raise

to_jsx(output_path)

Export directory structure to a React component (JSX file).

Creates a JSX file containing a React component for interactive visualization of the directory structure with collapsible folders and styling.

Parameters:

Name Type Description Default
output_path str

Path where the React component file will be saved

required
Source code in recursivist/exports.py
def to_jsx(self, output_path: str) -> None:
    """Export directory structure to a React component (JSX file).

    Creates a JSX file containing a React component for interactive visualization of the directory structure with collapsible folders and styling.

    Args:
        output_path: Path where the React component file will be saved
    """

    try:
        generate_jsx_component(
            self.structure,
            self.root_name,
            output_path,
            self.show_full_path,
            self.sort_by_loc,
            self.sort_by_size,
            self.sort_by_mtime,
            self.show_git_status,
        )
    except Exception as e:
        logger.error(f"Error exporting to React component: {e}")
        raise

to_markdown(output_path)

Export directory structure to a Markdown file.

Creates a Markdown file with a structured representation of the directory hierarchy using headings, lists, and formatting to distinguish between files and directories.

Parameters:

Name Type Description Default
output_path str

Path where the Markdown file will be saved

required
Source code in recursivist/exports.py
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
def to_markdown(self, output_path: str) -> None:
    """Export directory structure to a Markdown file.

    Creates a Markdown file with a structured representation of the directory hierarchy using headings, lists, and formatting to distinguish between files and directories.

    Args:
        output_path: Path where the Markdown file will be saved
    """

    def _build_md_tree(
        structure: dict[str, Any],
        level: int = 0,
        path_prefix: str = "",
    ) -> list[str]:
        lines = []
        indent = "    " * level
        if "_files" in structure:
            for file_item in sort_files_by_type(
                structure["_files"],
                self.sort_by_loc,
                self.sort_by_size,
                self.sort_by_mtime,
            ):
                display_path = ""
                loc = 0
                size = 0
                mtime = 0

                if isinstance(file_item, tuple):
                    if len(file_item) <= 0:
                        continue

                    if len(file_item) > 1:
                        display_path = file_item[1]
                    else:
                        display_path = file_item[0]

                    if (
                        self.sort_by_loc
                        and self.sort_by_size
                        and self.sort_by_mtime
                        and len(file_item) > 4
                    ):
                        loc = file_item[2]
                        size = file_item[3]
                        mtime = int(file_item[4])
                    elif (
                        self.sort_by_loc
                        and self.sort_by_mtime
                        and len(file_item) > 4
                    ):
                        loc = file_item[2]
                        mtime = int(file_item[4])
                    elif (
                        self.sort_by_size
                        and self.sort_by_mtime
                        and len(file_item) > 4
                    ):
                        size = file_item[3]
                        mtime = int(file_item[4])
                    elif (
                        self.sort_by_loc
                        and self.sort_by_size
                        and len(file_item) > 3
                    ):
                        loc = file_item[2]
                        size = file_item[3]
                    elif self.sort_by_loc and len(file_item) > 2:
                        loc = file_item[2]
                    elif self.sort_by_size and len(file_item) > 2:
                        size = file_item[2]
                    elif self.sort_by_mtime and len(file_item) > 2:
                        mtime = file_item[2]
                else:
                    display_path = file_item

                _fname_md = (
                    file_item[0]
                    if isinstance(file_item, tuple) and len(file_item) > 0
                    else (file_item if isinstance(file_item, str) else "unknown")
                )

                file_icon = get_icon(_fname_md, is_dir=False, style=self.icon_style)

                _git_markers_md = structure.get("_git_markers", {})
                _git_marker_md = (
                    _git_markers_md.get(_fname_md, "")
                    if self.show_git_status
                    else ""
                )
                _GIT_MD_BADGE = {
                    "U": "**[U]**",
                    "M": "**[M]**",
                    "A": "**[A]**",
                    "D": "**[D]**",
                }
                if _git_marker_md == "D":
                    _md_display = f"~~`{display_path}`~~"
                else:
                    _md_display = f"`{display_path}`"
                _md_git_suffix = (
                    f" {_GIT_MD_BADGE[_git_marker_md]}"
                    if _git_marker_md in _GIT_MD_BADGE
                    else ""
                )

                if self.sort_by_loc and self.sort_by_size and self.sort_by_mtime:
                    lines.append(
                        f"{indent}- {file_icon} {_md_display} ({loc} lines, {format_size(size)}, {format_timestamp(mtime)}){_md_git_suffix}"
                    )
                elif self.sort_by_loc and self.sort_by_mtime:
                    lines.append(
                        f"{indent}- {file_icon} {_md_display} ({loc} lines, {format_timestamp(mtime)}){_md_git_suffix}"
                    )
                elif self.sort_by_size and self.sort_by_mtime:
                    lines.append(
                        f"{indent}- {file_icon} {_md_display} ({format_size(size)}, {format_timestamp(mtime)}){_md_git_suffix}"
                    )
                elif self.sort_by_loc and self.sort_by_size:
                    lines.append(
                        f"{indent}- {file_icon} {_md_display} ({loc} lines, {format_size(size)}){_md_git_suffix}"
                    )
                elif self.sort_by_mtime:
                    lines.append(
                        f"{indent}- {file_icon} {_md_display} ({format_timestamp(mtime)}){_md_git_suffix}"
                    )
                elif self.sort_by_size:
                    lines.append(
                        f"{indent}- {file_icon} {_md_display} ({format_size(size)}){_md_git_suffix}"
                    )
                elif self.sort_by_loc:
                    lines.append(
                        f"{indent}- {file_icon} {_md_display} ({loc} lines){_md_git_suffix}"
                    )
                else:
                    lines.append(
                        f"{indent}- {file_icon} {_md_display}{_md_git_suffix}"
                    )
        for name, content in sorted(structure.items()):
            if (
                name == "_files"
                or name == "_max_depth_reached"
                or name == "_loc"
                or name == "_size"
                or name == "_mtime"
                or name == "_git_markers"
            ):
                continue

            folder_icon = get_icon(name, is_dir=True, style=self.icon_style)

            if (
                self.sort_by_loc
                and self.sort_by_size
                and self.sort_by_mtime
                and isinstance(content, dict)
                and "_loc" in content
                and "_size" in content
                and "_mtime" in content
            ):
                loc_count = content["_loc"]
                size_count = content["_size"]
                mtime_count = content["_mtime"]
                lines.append(
                    f"{indent}- {folder_icon} **{name}** ({loc_count} lines, {format_size(size_count)}, {format_timestamp(mtime_count)})"
                )
            elif (
                self.sort_by_loc
                and self.sort_by_size
                and isinstance(content, dict)
                and "_loc" in content
                and "_size" in content
            ):
                loc_count = content["_loc"]
                size_count = content["_size"]
                lines.append(
                    f"{indent}- {folder_icon} **{name}** ({loc_count} lines, {format_size(size_count)})"
                )
            elif (
                self.sort_by_loc
                and self.sort_by_mtime
                and isinstance(content, dict)
                and "_loc" in content
                and "_mtime" in content
            ):
                loc_count = content["_loc"]
                mtime_count = content["_mtime"]
                lines.append(
                    f"{indent}- {folder_icon} **{name}** ({loc_count} lines, {format_timestamp(mtime_count)})"
                )
            elif (
                self.sort_by_size
                and self.sort_by_mtime
                and isinstance(content, dict)
                and "_size" in content
                and "_mtime" in content
            ):
                size_count = content["_size"]
                mtime_count = content["_mtime"]
                lines.append(
                    f"{indent}- {folder_icon} **{name}** ({format_size(size_count)}, {format_timestamp(mtime_count)})"
                )
            elif (
                self.sort_by_loc and isinstance(content, dict) and "_loc" in content
            ):
                loc_count = content["_loc"]
                lines.append(
                    f"{indent}- {folder_icon} **{name}** ({loc_count} lines)"
                )
            elif (
                self.sort_by_size
                and isinstance(content, dict)
                and "_size" in content
            ):
                size_count = content["_size"]
                lines.append(
                    f"{indent}- {folder_icon} **{name}** ({format_size(size_count)})"
                )
            elif (
                self.sort_by_mtime
                and isinstance(content, dict)
                and "_mtime" in content
            ):
                mtime_count = content["_mtime"]
                lines.append(
                    f"{indent}- {folder_icon} **{name}** ({format_timestamp(mtime_count)})"
                )
            else:
                lines.append(f"{indent}- {folder_icon} **{name}**")
            next_path = os.path.join(path_prefix, name) if path_prefix else name
            if isinstance(content, dict):
                if content.get("_max_depth_reached"):
                    lines.append(f"{indent}    - ⋯ *(max depth reached)*")
                else:
                    lines.extend(_build_md_tree(content, level + 1, next_path))
        return lines

    root_icon = get_icon(self.root_name, is_dir=True, style=self.icon_style)

    if (
        self.sort_by_loc
        and self.sort_by_size
        and self.sort_by_mtime
        and "_loc" in self.structure
        and "_size" in self.structure
        and "_mtime" in self.structure
    ):
        md_content = [
            f"# {root_icon} {self.root_name} ({self.structure['_loc']} lines, {format_size(self.structure['_size'])}, {format_timestamp(self.structure['_mtime'])})",
            "",
        ]
    elif (
        self.sort_by_loc
        and self.sort_by_size
        and "_loc" in self.structure
        and "_size" in self.structure
    ):
        md_content = [
            f"# {root_icon} {self.root_name} ({self.structure['_loc']} lines, {format_size(self.structure['_size'])})",
            "",
        ]
    elif (
        self.sort_by_loc
        and self.sort_by_mtime
        and "_loc" in self.structure
        and "_mtime" in self.structure
    ):
        md_content = [
            f"# {root_icon} {self.root_name} ({self.structure['_loc']} lines, {format_timestamp(self.structure['_mtime'])})",
            "",
        ]
    elif (
        self.sort_by_size
        and self.sort_by_mtime
        and "_size" in self.structure
        and "_mtime" in self.structure
    ):
        md_content = [
            f"# {root_icon} {self.root_name} ({format_size(self.structure['_size'])}, {format_timestamp(self.structure['_mtime'])})",
            "",
        ]
    elif self.sort_by_loc and "_loc" in self.structure:
        md_content = [
            f"# {root_icon} {self.root_name} ({self.structure['_loc']} lines)",
            "",
        ]
    elif self.sort_by_size and "_size" in self.structure:
        md_content = [
            f"# {root_icon} {self.root_name} ({format_size(self.structure['_size'])})",
            "",
        ]
    elif self.sort_by_mtime and "_mtime" in self.structure:
        md_content = [
            f"# {root_icon} {self.root_name} ({format_timestamp(self.structure['_mtime'])})",
            "",
        ]
    else:
        md_content = [f"# {root_icon} {self.root_name}", ""]
    md_content.extend(
        _build_md_tree(
            self.structure, 0, self.root_name if self.show_full_path else ""
        )
    )
    try:
        with open(output_path, "w", encoding="utf-8") as f:
            f.write("\n".join(md_content))
    except Exception as e:
        logger.error(f"Error exporting to Markdown: {e}")
        raise

to_txt(output_path)

Export directory structure to a text file with ASCII tree representation.

Creates a text file containing an ASCII tree representation of the directory structure using standard box-drawing characters and indentation.

Parameters:

Name Type Description Default
output_path str

Path where the txt file will be saved

required
Source code in recursivist/exports.py
def to_txt(self, output_path: str) -> None:
    """Export directory structure to a text file with ASCII tree representation.

    Creates a text file containing an ASCII tree representation of the directory structure using standard box-drawing characters and indentation.

    Args:
        output_path: Path where the txt file will be saved
    """

    def _build_txt_tree(
        structure: dict[str, Any],
        prefix: str = "",
        path_prefix: str = "",
    ) -> list[str]:
        lines = []
        items = sorted(structure.items())
        for i, (name, content) in enumerate(items):
            if name == "_files":
                file_items = sort_files_by_type(
                    content, self.sort_by_loc, self.sort_by_size, self.sort_by_mtime
                )
                for j, file_item in enumerate(file_items):
                    is_last_file = j == len(file_items) - 1
                    is_last_item = is_last_file and i == len(items) - 1
                    item_prefix = prefix + ("└── " if is_last_item else "├── ")

                    _fname = (
                        file_item[0]
                        if isinstance(file_item, tuple) and len(file_item) > 0
                        else (
                            file_item if isinstance(file_item, str) else "unknown"
                        )
                    )
                    _git_markers = structure.get("_git_markers", {})
                    _git_marker = (
                        _git_markers.get(_fname, "") if self.show_git_status else ""
                    )
                    _git_suffix = (
                        f" {self._GIT_TXT_SUFFIX.get(_git_marker, _git_marker)}"
                        if _git_marker
                        else ""
                    )

                    file_icon = get_icon(
                        _fname, is_dir=False, style=self.icon_style
                    )

                    if (
                        self.sort_by_loc
                        and self.sort_by_size
                        and self.sort_by_mtime
                        and isinstance(file_item, tuple)
                        and len(file_item) > 4
                    ):
                        _, display_path, loc, size, mtime = file_item
                        lines.append(
                            f"{item_prefix}{file_icon} {display_path} ({loc} lines, {format_size(size)}, {format_timestamp(mtime)}){_git_suffix}"
                        )
                    elif (
                        self.sort_by_loc
                        and self.sort_by_mtime
                        and isinstance(file_item, tuple)
                        and len(file_item) > 4
                    ):
                        _, display_path, loc, _, mtime = file_item
                        lines.append(
                            f"{item_prefix}{file_icon} {display_path} ({loc} lines, {format_timestamp(mtime)}){_git_suffix}"
                        )
                    elif (
                        self.sort_by_size
                        and self.sort_by_mtime
                        and isinstance(file_item, tuple)
                        and len(file_item) > 4
                    ):
                        _, display_path, _, size, mtime = file_item
                        lines.append(
                            f"{item_prefix}{file_icon} {display_path} ({format_size(size)}, {format_timestamp(mtime)}){_git_suffix}"
                        )
                    elif (
                        self.sort_by_loc
                        and self.sort_by_size
                        and isinstance(file_item, tuple)
                        and len(file_item) > 3
                    ):
                        if len(file_item) > 4:
                            _, display_path, loc, size, _ = file_item
                        else:
                            _, display_path, loc, size = file_item
                        lines.append(
                            f"{item_prefix}{file_icon} {display_path} ({loc} lines, {format_size(size)}){_git_suffix}"
                        )
                    elif (
                        self.sort_by_mtime
                        and isinstance(file_item, tuple)
                        and len(file_item) > 2
                    ):
                        if len(file_item) > 4:
                            _, display_path, _, _, mtime = file_item
                        elif len(file_item) > 3:
                            _, display_path, _, mtime = file_item
                        else:
                            _, display_path, mtime = file_item
                        lines.append(
                            f"{item_prefix}{file_icon} {display_path} ({format_timestamp(mtime)}){_git_suffix}"
                        )
                    elif (
                        self.sort_by_size
                        and isinstance(file_item, tuple)
                        and len(file_item) > 2
                    ):
                        if len(file_item) > 4:
                            _, display_path, _, size, _ = file_item
                        elif len(file_item) > 3:
                            _, display_path, _, size = file_item
                        else:
                            _, display_path, size = file_item
                        lines.append(
                            f"{item_prefix}{file_icon} {display_path} ({format_size(size)}){_git_suffix}"
                        )
                    elif (
                        self.sort_by_loc
                        and isinstance(file_item, tuple)
                        and len(file_item) > 2
                    ):
                        if len(file_item) > 4:
                            _, display_path, loc, _, _ = file_item
                        elif len(file_item) > 3:
                            _, display_path, loc, _ = file_item
                        else:
                            _, display_path, loc = file_item
                        lines.append(
                            f"{item_prefix}{file_icon} {display_path} ({loc} lines){_git_suffix}"
                        )
                    elif self.show_full_path and isinstance(file_item, tuple):
                        if len(file_item) > 4:
                            _, full_path, _, _, _ = file_item
                        elif len(file_item) > 3:
                            _, full_path, _, _ = file_item
                        elif len(file_item) > 2:
                            _, full_path, _ = file_item
                        elif len(file_item) > 1:
                            _, full_path = file_item
                        else:
                            full_path = (
                                file_item[0] if len(file_item) > 0 else "unknown"
                            )
                        lines.append(
                            f"{item_prefix}{file_icon} {full_path}{_git_suffix}"
                        )
                    else:
                        if isinstance(file_item, tuple):
                            file_name = (
                                file_item[0] if len(file_item) > 0 else "unknown"
                            )
                        else:
                            file_name = file_item
                        lines.append(
                            f"{item_prefix}{file_icon} {file_name}{_git_suffix}"
                        )
                    if not is_last_item:
                        next_prefix = prefix + "│   "
                    else:
                        next_prefix = prefix + "    "
            elif (
                name == "_loc"
                or name == "_size"
                or name == "_mtime"
                or name == "_max_depth_reached"
                or name == "_git_markers"
            ):
                continue
            else:
                is_last_dir = True
                for j in range(i + 1, len(items)):
                    next_name, _ = items[j]
                    if next_name not in [
                        "_files",
                        "_max_depth_reached",
                        "_loc",
                        "_size",
                        "_mtime",
                        "_git_markers",
                    ]:
                        is_last_dir = False
                        break
                is_last_item = is_last_dir and (
                    i == len(items) - 1
                    or all(
                        key
                        in [
                            "_files",
                            "_max_depth_reached",
                            "_loc",
                            "_size",
                            "_mtime",
                            "_git_markers",
                        ]
                        for key, _ in items[i + 1 :]
                    )
                )
                item_prefix = prefix + ("└── " if is_last_item else "├── ")
                next_path = os.path.join(path_prefix, name) if path_prefix else name
                folder_icon = get_icon(name, is_dir=True, style=self.icon_style)
                if isinstance(content, dict):
                    if (
                        self.sort_by_loc
                        and self.sort_by_size
                        and self.sort_by_mtime
                        and "_loc" in content
                        and "_size" in content
                        and "_mtime" in content
                    ):
                        folder_loc = content["_loc"]
                        folder_size = content["_size"]
                        folder_mtime = content["_mtime"]
                        lines.append(
                            f"{item_prefix}{folder_icon} {name} ({folder_loc} lines, {format_size(folder_size)}, {format_timestamp(folder_mtime)})"
                        )
                    elif (
                        self.sort_by_loc
                        and self.sort_by_size
                        and "_loc" in content
                        and "_size" in content
                    ):
                        folder_loc = content["_loc"]
                        folder_size = content["_size"]
                        lines.append(
                            f"{item_prefix}{folder_icon} {name} ({folder_loc} lines, {format_size(folder_size)})"
                        )
                    elif (
                        self.sort_by_loc
                        and self.sort_by_mtime
                        and "_loc" in content
                        and "_mtime" in content
                    ):
                        folder_loc = content["_loc"]
                        folder_mtime = content["_mtime"]
                        lines.append(
                            f"{item_prefix}{folder_icon} {name} ({folder_loc} lines, {format_timestamp(folder_mtime)})"
                        )
                    elif (
                        self.sort_by_size
                        and self.sort_by_mtime
                        and "_size" in content
                        and "_mtime" in content
                    ):
                        folder_size = content["_size"]
                        folder_mtime = content["_mtime"]
                        lines.append(
                            f"{item_prefix}{folder_icon} {name} ({format_size(folder_size)}, {format_timestamp(folder_mtime)})"
                        )
                    elif self.sort_by_loc and "_loc" in content:
                        folder_loc = content["_loc"]
                        lines.append(
                            f"{item_prefix}{folder_icon} {name} ({folder_loc} lines)"
                        )
                    elif self.sort_by_size and "_size" in content:
                        folder_size = content["_size"]
                        lines.append(
                            f"{item_prefix}{folder_icon} {name} ({format_size(folder_size)})"
                        )
                    elif self.sort_by_mtime and "_mtime" in content:
                        folder_mtime = content["_mtime"]
                        lines.append(
                            f"{item_prefix}{folder_icon} {name} ({format_timestamp(folder_mtime)})"
                        )
                    else:
                        lines.append(f"{item_prefix}{folder_icon} {name}")
                    if content.get("_max_depth_reached"):
                        next_prefix = prefix + ("    " if is_last_item else "│   ")
                        lines.append(f"{next_prefix}└── ⋯ (max depth reached)")
                    else:
                        next_prefix = prefix + ("    " if is_last_item else "│   ")
                        sublines = _build_txt_tree(content, next_prefix, next_path)
                        lines.extend(sublines)
                else:
                    lines.append(f"{item_prefix}{folder_icon} {name}")
        return lines

    root_icon = get_icon(self.root_name, is_dir=True, style=self.icon_style)
    root_label = f"{root_icon} {self.root_name}"
    if (
        self.sort_by_loc
        and self.sort_by_size
        and self.sort_by_mtime
        and "_loc" in self.structure
        and "_size" in self.structure
        and "_mtime" in self.structure
    ):
        root_label = f"{root_icon} {self.root_name} ({self.structure['_loc']} lines, {format_size(self.structure['_size'])}, {format_timestamp(self.structure['_mtime'])})"
    elif (
        self.sort_by_loc
        and self.sort_by_size
        and "_loc" in self.structure
        and "_size" in self.structure
    ):
        root_label = f"{root_icon} {self.root_name} ({self.structure['_loc']} lines, {format_size(self.structure['_size'])})"
    elif (
        self.sort_by_loc
        and self.sort_by_mtime
        and "_loc" in self.structure
        and "_mtime" in self.structure
    ):
        root_label = f"{root_icon} {self.root_name} ({self.structure['_loc']} lines, {format_timestamp(self.structure['_mtime'])})"
    elif (
        self.sort_by_size
        and self.sort_by_mtime
        and "_size" in self.structure
        and "_mtime" in self.structure
    ):
        root_label = f"{root_icon} {self.root_name} ({format_size(self.structure['_size'])}, {format_timestamp(self.structure['_mtime'])})"
    elif self.sort_by_loc and "_loc" in self.structure:
        root_label = (
            f"{root_icon} {self.root_name} ({self.structure['_loc']} lines)"
        )
    elif self.sort_by_size and "_size" in self.structure:
        root_label = (
            f"{root_icon} {self.root_name} ({format_size(self.structure['_size'])})"
        )
    elif self.sort_by_mtime and "_mtime" in self.structure:
        root_label = f"{root_icon} {self.root_name} ({format_timestamp(self.structure['_mtime'])})"
    tree_lines = [root_label]
    tree_lines.extend(
        _build_txt_tree(
            self.structure, "", self.root_name if self.show_full_path else ""
        )
    )
    try:
        with open(output_path, "w", encoding="utf-8") as f:
            f.write("\n".join(tree_lines))
    except Exception as e:
        logger.error(f"Error exporting to TXT: {e}")
        raise

sort_files_by_type(files, sort_by_loc=False, sort_by_size=False, sort_by_mtime=False)

Sort a list of file entries by extension/name or by a requested statistic.

When one or more sort flags are set, files are ordered by the corresponding statistic in descending order (highest first). When multiple flags are set, they are applied as a compound sort key in the order LOC > size > mtime. When no sort flag is set, files are grouped by extension and then sorted alphabetically within each group.

files may contain plain strings (filename only) or tuples whose layout depends on which statistics were collected:

  • (name, display_path)
  • (name, display_path, loc)
  • (name, display_path, loc, size)
  • (name, display_path, loc, size, mtime)

Parameters:

Name Type Description Default
files Sequence[Union[str, tuple[str, str], tuple[str, str, int], tuple[str, str, int, int], tuple[str, str, int, int, float]]]

Sequence of file entries to sort. An empty sequence is returned unchanged.

required
sort_by_loc bool

When True, include lines-of-code in the sort key.

False
sort_by_size bool

When True, include file size in the sort key.

False
sort_by_mtime bool

When True, include modification time in the sort key (newest first).

False

Returns:

Type Description
list[Union[str, tuple[str, str], tuple[str, str, int], tuple[str, str, int, int], tuple[str, str, int, int, float]]]

A new sorted list containing the same elements as files.

Source code in recursivist/exports.py
def sort_files_by_type(
    files: Sequence[
        Union[
            str,
            tuple[str, str],
            tuple[str, str, int],
            tuple[str, str, int, int],
            tuple[str, str, int, int, float],
        ]
    ],
    sort_by_loc: bool = False,
    sort_by_size: bool = False,
    sort_by_mtime: bool = False,
) -> list[
    Union[
        str,
        tuple[str, str],
        tuple[str, str, int],
        tuple[str, str, int, int],
        tuple[str, str, int, int, float],
    ]
]:
    """Sort a list of file entries by extension/name or by a requested statistic.

    When one or more sort flags are set, files are ordered by the corresponding
    statistic in descending order (highest first). When multiple flags are set,
    they are applied as a compound sort key in the order LOC > size > mtime.
    When no sort flag is set, files are grouped by extension and then sorted
    alphabetically within each group.

    *files* may contain plain strings (filename only) or tuples whose layout
    depends on which statistics were collected:

    * ``(name, display_path)``
    * ``(name, display_path, loc)``
    * ``(name, display_path, loc, size)``
    * ``(name, display_path, loc, size, mtime)``

    Args:
        files: Sequence of file entries to sort. An empty sequence is
            returned unchanged.
        sort_by_loc: When ``True``, include lines-of-code in the sort key.
        sort_by_size: When ``True``, include file size in the sort key.
        sort_by_mtime: When ``True``, include modification time in the sort
            key (newest first).

    Returns:
        A new sorted list containing the same elements as *files*.
    """
    if not files:
        return []
    has_loc = any(isinstance(item, tuple) and len(item) > 2 for item in files)
    has_size = any(isinstance(item, tuple) and len(item) > 3 for item in files)
    has_mtime = any(isinstance(item, tuple) and len(item) > 4 for item in files)
    has_simple_size = sort_by_size and not sort_by_loc and has_loc
    has_simple_mtime = (
        sort_by_mtime and not sort_by_loc and not sort_by_size and (has_loc or has_size)
    )

    def get_size(
        item: Union[
            str,
            tuple[str, str],
            tuple[str, str, int],
            tuple[str, str, int, int],
            tuple[str, str, int, int, float],
        ],
    ) -> int:
        if not isinstance(item, tuple):
            return 0
        if len(item) > 3:
            if sort_by_loc and sort_by_size:
                return item[3]
            elif sort_by_size and not sort_by_loc:
                return item[3]
        elif len(item) == 3 and sort_by_size:
            return item[2]
        return 0

    def get_loc(
        item: Union[
            str,
            tuple[str, str],
            tuple[str, str, int],
            tuple[str, str, int, int],
            tuple[str, str, int, int, float],
        ],
    ) -> int:
        if not isinstance(item, tuple) or len(item) <= 2:
            return 0
        return item[2] if sort_by_loc else 0

    def get_mtime(
        item: Union[
            str,
            tuple[str, str],
            tuple[str, str, int],
            tuple[str, str, int, int],
            tuple[str, str, int, int, float],
        ],
    ) -> float:
        if not isinstance(item, tuple):
            return 0
        if len(item) > 4:
            return item[4]
        elif len(item) > 3 and (
            (sort_by_loc and sort_by_mtime and not sort_by_size)
            or (sort_by_size and sort_by_mtime and not sort_by_loc)
        ):
            return item[3]
        elif len(item) > 2 and sort_by_mtime and not sort_by_loc and not sort_by_size:
            return item[2]
        return 0

    if sort_by_loc and sort_by_size and sort_by_mtime and has_mtime:
        return sorted(files, key=lambda f: (-get_loc(f), -get_size(f), -get_mtime(f)))
    elif sort_by_loc and sort_by_size and (has_size or has_simple_size) and has_loc:
        return sorted(files, key=lambda f: (-get_loc(f), -get_size(f)))
    elif sort_by_loc and sort_by_mtime and has_mtime:
        return sorted(files, key=lambda f: (-get_loc(f), -get_mtime(f)))
    elif sort_by_size and sort_by_mtime and has_mtime:
        return sorted(files, key=lambda f: (-get_size(f), -get_mtime(f)))
    elif sort_by_loc and has_loc:
        return sorted(files, key=lambda f: -get_loc(f))
    elif sort_by_size and (has_size or has_simple_size):
        return sorted(files, key=lambda f: -get_size(f))
    elif sort_by_mtime and (has_mtime or has_simple_mtime):
        return sorted(files, key=lambda f: -get_mtime(f))

    def get_filename(
        item: Union[
            str,
            tuple[str, str],
            tuple[str, str, int],
            tuple[str, str, int, int],
            tuple[str, str, int, int, float],
        ],
    ) -> str:
        if isinstance(item, tuple):
            return item[0]
        return item

    return sorted(
        files,
        key=lambda f: (
            os.path.splitext(get_filename(f))[1].lower(),
            get_filename(f).lower(),
        ),
    )

Compare Module

recursivist.compare

Comparison functionality for the Recursivist directory visualization tool.

This module implements side-by-side directory structure comparison with visual highlighting of differences. It provides terminal output with colored indicators and HTML export for sharing and documentation.

Key features: - Visual highlighting of items unique to each directory - Consistent color coding for file extensions - Support for all the same filtering options as visualization - Export to HTML with interactive features - Optional display of statistics (LOC, size, modification times) - Legend explaining the highlighting scheme

build_comparison_tree(structure, other_structure, tree, color_map, parent_name='Root', show_full_path=False, sort_by_loc=False, sort_by_size=False, sort_by_mtime=False, icon_style='emoji')

Build a tree structure with highlighted differences.

Recursively builds a Rich tree with visual indicators for: - Items that exist in both structures (normal display) - Items unique to the current structure (green background) - Items unique to the comparison structure (red background)

When sort_by_loc is True, also displays lines of code counts. When sort_by_size is True, also displays file sizes. When sort_by_mtime is True, also displays file modification times.

Parameters:

Name Type Description Default
structure dict[str, Any]

Dictionary representation of the current directory structure

required
other_structure dict[str, Any]

Dictionary representation of the comparison directory structure

required
tree Tree

Rich Tree object to build upon

required
color_map dict[str, str]

Mapping of file extensions to colors

required
parent_name str

Name of the parent directory

'Root'
show_full_path bool

Whether to show full paths instead of just filenames

False
sort_by_loc bool

Whether to display lines of code counts

False
sort_by_size bool

Whether to display file sizes

False
sort_by_mtime bool

Whether to display file modification times

False
icon_style str

The style of icons to use ('emoji' or 'nerd')

'emoji'
Source code in recursivist/compare.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
def build_comparison_tree(
    structure: dict[str, Any],
    other_structure: dict[str, Any],
    tree: Tree,
    color_map: dict[str, str],
    parent_name: str = "Root",
    show_full_path: bool = False,
    sort_by_loc: bool = False,
    sort_by_size: bool = False,
    sort_by_mtime: bool = False,
    icon_style: str = "emoji",
) -> None:
    """
    Build a tree structure with highlighted differences.

    Recursively builds a Rich tree with visual indicators for:
    - Items that exist in both structures (normal display)
    - Items unique to the current structure (green background)
    - Items unique to the comparison structure (red background)

    When sort_by_loc is True, also displays lines of code counts.
    When sort_by_size is True, also displays file sizes.
    When sort_by_mtime is True, also displays file modification times.

    Args:
        structure: Dictionary representation of the current directory structure
        other_structure: Dictionary representation of the comparison directory structure
        tree: Rich Tree object to build upon
        color_map: Mapping of file extensions to colors
        parent_name: Name of the parent directory
        show_full_path: Whether to show full paths instead of just filenames
        sort_by_loc: Whether to display lines of code counts
        sort_by_size: Whether to display file sizes
        sort_by_mtime: Whether to display file modification times
        icon_style: The style of icons to use ('emoji' or 'nerd')
    """

    if "_files" in structure:
        files_in_other = other_structure.get("_files", []) if other_structure else []
        files_in_other_names = []
        for item in files_in_other:
            if isinstance(item, tuple):
                files_in_other_names.append(item[0])
            else:
                files_in_other_names.append(cast(str, item))
        for file_item in sort_files_by_type(
            structure["_files"], sort_by_loc, sort_by_size, sort_by_mtime
        ):
            if isinstance(file_item, tuple):
                file_name = file_item[0]
            else:
                file_name = file_item

            file_icon = get_icon(file_name, is_dir=False, style=icon_style)

            if (
                sort_by_loc
                and sort_by_size
                and sort_by_mtime
                and isinstance(file_item, tuple)
                and len(file_item) > 4
            ):
                file_name, full_path, loc, size, mtime = file_item
                ext = os.path.splitext(file_name)[1].lower()
                color = color_map.get(ext, "#FFFFFF")
                if file_name not in files_in_other_names:
                    colored_text = Text(
                        f"{file_icon} {full_path} ({loc} lines, {format_size(size)}, {format_timestamp(mtime)})",
                        style=f"{color} on green",
                    )
                else:
                    colored_text = Text(
                        f"{file_icon} {full_path} ({loc} lines, {format_size(size)}, {format_timestamp(mtime)})",
                        style=color,
                    )
                tree.add(colored_text)
            elif (
                sort_by_loc
                and sort_by_mtime
                and isinstance(file_item, tuple)
                and len(file_item) > 3
            ):
                if len(file_item) > 4:
                    file_name, full_path, loc, _, mtime = file_item
                else:
                    file_name, full_path, loc, mtime = file_item
                ext = os.path.splitext(file_name)[1].lower()
                color = color_map.get(ext, "#FFFFFF")
                if file_name not in files_in_other_names:
                    colored_text = Text(
                        f"{file_icon} {full_path} ({loc} lines, {format_timestamp(mtime)})",
                        style=f"{color} on green",
                    )
                else:
                    colored_text = Text(
                        f"{file_icon} {full_path} ({loc} lines, {format_timestamp(mtime)})",
                        style=color,
                    )
                tree.add(colored_text)
            elif (
                sort_by_size
                and sort_by_mtime
                and isinstance(file_item, tuple)
                and len(file_item) > 3
            ):
                if len(file_item) > 4:
                    file_name, full_path, _, size, mtime = file_item
                else:
                    file_name, full_path, size, mtime = file_item
                ext = os.path.splitext(file_name)[1].lower()
                color = color_map.get(ext, "#FFFFFF")
                if file_name not in files_in_other_names:
                    colored_text = Text(
                        f"{file_icon} {full_path} ({format_size(size)}, {format_timestamp(mtime)})",
                        style=f"{color} on green",
                    )
                else:
                    colored_text = Text(
                        f"{file_icon} {full_path} ({format_size(size)}, {format_timestamp(mtime)})",
                        style=color,
                    )
                tree.add(colored_text)
            elif (
                sort_by_loc
                and sort_by_size
                and isinstance(file_item, tuple)
                and len(file_item) > 3
            ):
                if len(file_item) > 4:
                    file_name, full_path, loc, size, _ = file_item
                else:
                    file_name, full_path, loc, size = file_item
                ext = os.path.splitext(file_name)[1].lower()
                color = color_map.get(ext, "#FFFFFF")
                if file_name not in files_in_other_names:
                    colored_text = Text(
                        f"{file_icon} {full_path} ({loc} lines, {format_size(size)})",
                        style=f"{color} on green",
                    )
                else:
                    colored_text = Text(
                        f"{file_icon} {full_path} ({loc} lines, {format_size(size)})",
                        style=color,
                    )
                tree.add(colored_text)
            elif sort_by_mtime and isinstance(file_item, tuple) and len(file_item) > 2:
                if len(file_item) > 4:
                    file_name, full_path, _, _, mtime = file_item
                elif len(file_item) > 3:
                    file_name, full_path, _, mtime = file_item
                else:
                    file_name, full_path, mtime = file_item
                ext = os.path.splitext(file_name)[1].lower()
                color = color_map.get(ext, "#FFFFFF")
                if file_name not in files_in_other_names:
                    colored_text = Text(
                        f"{file_icon} {full_path} ({format_timestamp(mtime)})",
                        style=f"{color} on green",
                    )
                else:
                    colored_text = Text(
                        f"{file_icon} {full_path} ({format_timestamp(mtime)})",
                        style=color,
                    )
                tree.add(colored_text)
            elif sort_by_size and isinstance(file_item, tuple) and len(file_item) > 2:
                if len(file_item) > 3:
                    if len(file_item) > 4:
                        file_name, full_path, _, size, _ = file_item
                    else:
                        file_name, full_path, _, size = file_item
                else:
                    file_name, full_path, size = file_item
                ext = os.path.splitext(file_name)[1].lower()
                color = color_map.get(ext, "#FFFFFF")
                if file_name not in files_in_other_names:
                    colored_text = Text(
                        f"{file_icon} {full_path} ({format_size(size)})",
                        style=f"{color} on green",
                    )
                else:
                    colored_text = Text(
                        f"{file_icon} {full_path} ({format_size(size)})",
                        style=color,
                    )
                tree.add(colored_text)
            elif sort_by_loc and isinstance(file_item, tuple) and len(file_item) > 2:
                if len(file_item) > 4:
                    file_name, full_path, loc, _, _ = file_item
                elif len(file_item) > 3:
                    file_name, full_path, loc, _ = file_item
                else:
                    file_name, full_path, loc = file_item
                ext = os.path.splitext(file_name)[1].lower()
                color = color_map.get(ext, "#FFFFFF")
                if file_name not in files_in_other_names:
                    colored_text = Text(
                        f"{file_icon} {full_path} ({loc} lines)",
                        style=f"{color} on green",
                    )
                else:
                    colored_text = Text(
                        f"{file_icon} {full_path} ({loc} lines)",
                        style=color,
                    )
                tree.add(colored_text)
            elif isinstance(file_item, tuple):
                if len(file_item) > 4:
                    file_name, full_path, _, _, _ = file_item
                elif len(file_item) > 3:
                    file_name, full_path, _, _ = file_item
                elif len(file_item) > 2:
                    file_name, full_path, _ = file_item
                else:
                    file_name, full_path = file_item
                ext = os.path.splitext(file_name)[1].lower()
                color = color_map.get(ext, "#FFFFFF")
                if file_name not in files_in_other_names:
                    colored_text = Text(
                        f"{file_icon} {full_path}", style=f"{color} on green"
                    )
                else:
                    colored_text = Text(f"{file_icon} {full_path}", style=color)
                tree.add(colored_text)
            else:
                ext = os.path.splitext(file_name)[1].lower()
                color = color_map.get(ext, "#FFFFFF")
                if file_name not in files_in_other_names:
                    colored_text = Text(
                        f"{file_icon} {file_name}", style=f"{color} on green"
                    )
                else:
                    colored_text = Text(f"{file_icon} {file_name}", style=color)
                tree.add(colored_text)
    for folder, content in sorted(structure.items()):
        if (
            folder == "_files"
            or folder == "_max_depth_reached"
            or folder == "_loc"
            or folder == "_size"
            or folder == "_mtime"
        ):
            continue

        folder_icon = get_icon(folder, is_dir=True, style=icon_style)

        other_content = other_structure.get(folder, {}) if other_structure else {}
        if folder not in (other_structure or {}):
            if (
                sort_by_loc
                and sort_by_size
                and sort_by_mtime
                and isinstance(content, dict)
                and "_loc" in content
                and "_size" in content
                and "_mtime" in content
            ):
                folder_loc = content["_loc"]
                folder_size = content["_size"]
                folder_mtime = content["_mtime"]
                subtree = tree.add(
                    Text(
                        f"{folder_icon} {folder} ({folder_loc} lines, {format_size(folder_size)}, {format_timestamp(folder_mtime)})",
                        style="green",
                    )
                )
            elif (
                sort_by_loc
                and sort_by_size
                and isinstance(content, dict)
                and "_loc" in content
                and "_size" in content
            ):
                folder_loc = content["_loc"]
                folder_size = content["_size"]
                subtree = tree.add(
                    Text(
                        f"{folder_icon} {folder} ({folder_loc} lines, {format_size(folder_size)})",
                        style="green",
                    )
                )
            elif (
                sort_by_loc
                and sort_by_mtime
                and isinstance(content, dict)
                and "_loc" in content
                and "_mtime" in content
            ):
                folder_loc = content["_loc"]
                folder_mtime = content["_mtime"]
                subtree = tree.add(
                    Text(
                        f"{folder_icon} {folder} ({folder_loc} lines, {format_timestamp(folder_mtime)})",
                        style="green",
                    )
                )
            elif (
                sort_by_size
                and sort_by_mtime
                and isinstance(content, dict)
                and "_size" in content
                and "_mtime" in content
            ):
                folder_size = content["_size"]
                folder_mtime = content["_mtime"]
                subtree = tree.add(
                    Text(
                        f"{folder_icon} {folder} ({format_size(folder_size)}, {format_timestamp(folder_mtime)})",
                        style="green",
                    )
                )
            elif sort_by_loc and isinstance(content, dict) and "_loc" in content:
                folder_loc = content["_loc"]
                subtree = tree.add(
                    Text(f"{folder_icon} {folder} ({folder_loc} lines)", style="green")
                )
            elif sort_by_size and isinstance(content, dict) and "_size" in content:
                folder_size = content["_size"]
                subtree = tree.add(
                    Text(
                        f"{folder_icon} {folder} ({format_size(folder_size)})",
                        style="green",
                    )
                )
            elif sort_by_mtime and isinstance(content, dict) and "_mtime" in content:
                folder_mtime = content["_mtime"]
                subtree = tree.add(
                    Text(
                        f"{folder_icon} {folder} ({format_timestamp(folder_mtime)})",
                        style="green",
                    )
                )
            else:
                subtree = tree.add(Text(f"{folder_icon} {folder}", style="green"))
        else:
            if (
                sort_by_loc
                and sort_by_size
                and sort_by_mtime
                and isinstance(content, dict)
                and "_loc" in content
                and "_size" in content
                and "_mtime" in content
            ):
                folder_loc = content["_loc"]
                folder_size = content["_size"]
                folder_mtime = content["_mtime"]
                subtree = tree.add(
                    f"{folder_icon} {folder} ({folder_loc} lines, {format_size(folder_size)}, {format_timestamp(folder_mtime)})"
                )
            elif (
                sort_by_loc
                and sort_by_size
                and isinstance(content, dict)
                and "_loc" in content
                and "_size" in content
            ):
                folder_loc = content["_loc"]
                folder_size = content["_size"]
                subtree = tree.add(
                    f"{folder_icon} {folder} ({folder_loc} lines, {format_size(folder_size)})"
                )
            elif (
                sort_by_loc
                and sort_by_mtime
                and isinstance(content, dict)
                and "_loc" in content
                and "_mtime" in content
            ):
                folder_loc = content["_loc"]
                folder_mtime = content["_mtime"]
                subtree = tree.add(
                    f"{folder_icon} {folder} ({folder_loc} lines, {format_timestamp(folder_mtime)})"
                )
            elif (
                sort_by_size
                and sort_by_mtime
                and isinstance(content, dict)
                and "_size" in content
                and "_mtime" in content
            ):
                folder_size = content["_size"]
                folder_mtime = content["_mtime"]
                subtree = tree.add(
                    f"{folder_icon} {folder} ({format_size(folder_size)}, {format_timestamp(folder_mtime)})"
                )
            elif sort_by_loc and isinstance(content, dict) and "_loc" in content:
                folder_loc = content["_loc"]
                subtree = tree.add(f"{folder_icon} {folder} ({folder_loc} lines)")
            elif sort_by_size and isinstance(content, dict) and "_size" in content:
                folder_size = content["_size"]
                subtree = tree.add(
                    f"{folder_icon} {folder} ({format_size(folder_size)})"
                )
            elif sort_by_mtime and isinstance(content, dict) and "_mtime" in content:
                folder_mtime = content["_mtime"]
                subtree = tree.add(
                    f"{folder_icon} {folder} ({format_timestamp(folder_mtime)})"
                )
            else:
                subtree = tree.add(f"{folder_icon} {folder}")
        if isinstance(content, dict) and content.get("_max_depth_reached"):
            subtree.add(Text("⋯ (max depth reached)", style="dim"))
        else:
            build_comparison_tree(
                content,
                other_content,
                subtree,
                color_map,
                folder,
                show_full_path,
                sort_by_loc,
                sort_by_size,
                sort_by_mtime,
                icon_style=icon_style,
            )
    if other_structure and "_files" in other_structure:
        files_in_this_names = []
        files_in_this = structure.get("_files", [])
        for item in files_in_this:
            if isinstance(item, tuple):
                files_in_this_names.append(item[0])
            else:
                files_in_this_names.append(cast(str, item))
        for file_item in sort_files_by_type(
            other_structure["_files"], sort_by_loc, sort_by_size, sort_by_mtime
        ):
            if isinstance(file_item, tuple):
                file_name = file_item[0]
            else:
                file_name = file_item

            if file_name not in files_in_this_names:
                file_icon = get_icon(file_name, is_dir=False, style=icon_style)

                if (
                    sort_by_loc
                    and sort_by_size
                    and sort_by_mtime
                    and isinstance(file_item, tuple)
                    and len(file_item) > 4
                ):
                    _, display_path, loc, size, mtime = file_item
                    ext = os.path.splitext(file_name)[1].lower()
                    color = color_map.get(ext, "#FFFFFF")
                    colored_text = Text(
                        f"{file_icon} {display_path} ({loc} lines, {format_size(size)}, {format_timestamp(mtime)})",
                        style=f"{color} on red",
                    )
                    tree.add(colored_text)
                elif (
                    sort_by_loc
                    and sort_by_mtime
                    and isinstance(file_item, tuple)
                    and len(file_item) > 3
                ):
                    if len(file_item) > 4:
                        _, display_path, loc, _, mtime = file_item
                    else:
                        _, display_path, loc, mtime = file_item
                    ext = os.path.splitext(file_name)[1].lower()
                    color = color_map.get(ext, "#FFFFFF")
                    colored_text = Text(
                        f"{file_icon} {display_path} ({loc} lines, {format_timestamp(mtime)})",
                        style=f"{color} on red",
                    )
                    tree.add(colored_text)
                elif (
                    sort_by_size
                    and sort_by_mtime
                    and isinstance(file_item, tuple)
                    and len(file_item) > 3
                ):
                    if len(file_item) > 4:
                        _, display_path, _, size, mtime = file_item
                    else:
                        _, display_path, size, mtime = file_item
                    ext = os.path.splitext(file_name)[1].lower()
                    color = color_map.get(ext, "#FFFFFF")
                    colored_text = Text(
                        f"{file_icon} {display_path} ({format_size(size)}, {format_timestamp(mtime)})",
                        style=f"{color} on red",
                    )
                    tree.add(colored_text)
                elif (
                    sort_by_loc
                    and sort_by_size
                    and isinstance(file_item, tuple)
                    and len(file_item) > 3
                ):
                    if len(file_item) > 4:
                        _, display_path, loc, size, _ = file_item
                    else:
                        _, display_path, loc, size = file_item
                    ext = os.path.splitext(file_name)[1].lower()
                    color = color_map.get(ext, "#FFFFFF")
                    colored_text = Text(
                        f"{file_icon} {display_path} ({loc} lines, {format_size(size)})",
                        style=f"{color} on red",
                    )
                    tree.add(colored_text)
                elif (
                    sort_by_mtime
                    and isinstance(file_item, tuple)
                    and len(file_item) > 2
                ):
                    if len(file_item) > 4:
                        _, full_path, _, _, mtime = file_item
                    elif len(file_item) > 3:
                        _, full_path, _, mtime = file_item
                    else:
                        _, full_path, mtime = file_item
                    ext = os.path.splitext(file_name)[1].lower()
                    color = color_map.get(ext, "#FFFFFF")
                    colored_text = Text(
                        f"{file_icon} {full_path} ({format_timestamp(mtime)})",
                        style=f"{color} on red",
                    )
                    tree.add(colored_text)
                elif (
                    sort_by_size and isinstance(file_item, tuple) and len(file_item) > 2
                ):
                    if len(file_item) > 3:
                        if len(file_item) > 4:
                            _, full_path, _, size, _ = file_item
                        else:
                            _, full_path, _, size = file_item
                    else:
                        _, full_path, size = file_item
                    ext = os.path.splitext(file_name)[1].lower()
                    color = color_map.get(ext, "#FFFFFF")
                    colored_text = Text(
                        f"{file_icon} {full_path} ({format_size(size)})",
                        style=f"{color} on red",
                    )
                    tree.add(colored_text)
                elif (
                    sort_by_loc and isinstance(file_item, tuple) and len(file_item) > 2
                ):
                    if len(file_item) > 3:
                        if len(file_item) > 4:
                            _, full_path, loc, _, _ = file_item
                        else:
                            _, full_path, loc, _ = file_item
                    else:
                        _, full_path, loc = file_item
                    ext = os.path.splitext(file_name)[1].lower()
                    color = color_map.get(ext, "#FFFFFF")
                    colored_text = Text(
                        f"{file_icon} {full_path} ({loc} lines)",
                        style=f"{color} on red",
                    )
                    tree.add(colored_text)
                elif isinstance(file_item, tuple):
                    if len(file_item) > 4:
                        _, full_path, _, _, _ = file_item
                    elif len(file_item) > 3:
                        _, full_path, _, _ = file_item
                    elif len(file_item) > 2:
                        _, full_path, _ = file_item
                    else:
                        _, full_path = file_item
                    ext = os.path.splitext(file_name)[1].lower()
                    color = color_map.get(ext, "#FFFFFF")
                    colored_text = Text(
                        f"{file_icon} {full_path}", style=f"{color} on red"
                    )
                    tree.add(colored_text)
                else:
                    ext = os.path.splitext(file_name)[1].lower()
                    color = color_map.get(ext, "#FFFFFF")
                    colored_text = Text(
                        f"{file_icon} {file_name}", style=f"{color} on red"
                    )
                    tree.add(colored_text)
    if other_structure:
        for folder in sorted(other_structure.keys()):
            if (
                folder != "_files"
                and folder != "_max_depth_reached"
                and folder != "_loc"
                and folder != "_size"
                and folder != "_mtime"
                and folder not in structure
            ):
                other_content = other_structure[folder]
                folder_icon = get_icon(folder, is_dir=True, style=icon_style)

                if (
                    sort_by_loc
                    and sort_by_size
                    and sort_by_mtime
                    and isinstance(other_content, dict)
                    and "_loc" in other_content
                    and "_size" in other_content
                    and "_mtime" in other_content
                ):
                    folder_loc = other_content["_loc"]
                    folder_size = other_content["_size"]
                    folder_mtime = other_content["_mtime"]
                    subtree = tree.add(
                        Text(
                            f"{folder_icon} {folder} ({folder_loc} lines, {format_size(folder_size)}, {format_timestamp(folder_mtime)})",
                            style="red",
                        )
                    )
                elif (
                    sort_by_loc
                    and sort_by_size
                    and isinstance(other_content, dict)
                    and "_loc" in other_content
                    and "_size" in other_content
                ):
                    folder_loc = other_content["_loc"]
                    folder_size = other_content["_size"]
                    subtree = tree.add(
                        Text(
                            f"{folder_icon} {folder} ({folder_loc} lines, {format_size(folder_size)})",
                            style="red",
                        )
                    )
                elif (
                    sort_by_loc
                    and sort_by_mtime
                    and isinstance(other_content, dict)
                    and "_loc" in other_content
                    and "_mtime" in other_content
                ):
                    folder_loc = other_content["_loc"]
                    folder_mtime = other_content["_mtime"]
                    subtree = tree.add(
                        Text(
                            f"{folder_icon} {folder} ({folder_loc} lines, {format_timestamp(folder_mtime)})",
                            style="red",
                        )
                    )
                elif (
                    sort_by_size
                    and sort_by_mtime
                    and isinstance(other_content, dict)
                    and "_size" in other_content
                    and "_mtime" in other_content
                ):
                    folder_size = other_content["_size"]
                    folder_mtime = other_content["_mtime"]
                    subtree = tree.add(
                        Text(
                            f"{folder_icon} {folder} ({format_size(folder_size)}, {format_timestamp(folder_mtime)})",
                            style="red",
                        )
                    )
                elif (
                    sort_by_loc
                    and isinstance(other_content, dict)
                    and "_loc" in other_content
                ):
                    folder_loc = other_content["_loc"]
                    subtree = tree.add(
                        Text(
                            f"{folder_icon} {folder} ({folder_loc} lines)", style="red"
                        )
                    )
                elif (
                    sort_by_size
                    and isinstance(other_content, dict)
                    and "_size" in other_content
                ):
                    folder_size = other_content["_size"]
                    subtree = tree.add(
                        Text(
                            f"{folder_icon} {folder} ({format_size(folder_size)})",
                            style="red",
                        )
                    )
                elif (
                    sort_by_mtime
                    and isinstance(other_content, dict)
                    and "_mtime" in other_content
                ):
                    folder_mtime = other_content["_mtime"]
                    subtree = tree.add(
                        Text(
                            f"{folder_icon} {folder} ({format_timestamp(folder_mtime)})",
                            style="red",
                        )
                    )
                else:
                    subtree = tree.add(Text(f"{folder_icon} {folder}", style="red"))
                if isinstance(other_content, dict) and other_content.get(
                    "_max_depth_reached"
                ):
                    subtree.add(Text("⋯ (max depth reached)", style="dim"))
                else:
                    build_comparison_tree(
                        {},
                        other_content,
                        subtree,
                        color_map,
                        folder,
                        show_full_path,
                        sort_by_loc,
                        sort_by_size,
                        sort_by_mtime,
                        icon_style=icon_style,
                    )

compare_directory_structures(dir1, dir2, exclude_dirs=None, ignore_file=None, exclude_extensions=None, exclude_patterns=None, include_patterns=None, max_depth=0, show_full_path=False, sort_by_loc=False, sort_by_size=False, sort_by_mtime=False)

Compare two directory structures and return both structures with a combined set of extensions.

Retrieves the directory structures for both directories using the same filtering options, then combines their file extensions for consistent color mapping in visualizations.

Parameters:

Name Type Description Default
dir1 str

Path to the first directory

required
dir2 str

Path to the second directory

required
exclude_dirs Optional[Sequence[str]]

List of directory names to exclude

None
ignore_file Optional[str]

Name of ignore file (like .gitignore)

None
exclude_extensions Optional[set[str]]

Set of file extensions to exclude

None
exclude_patterns Optional[Sequence[Union[str, Pattern[str]]]]

List of patterns to exclude

None
include_patterns Optional[Sequence[Union[str, Pattern[str]]]]

List of patterns to include (overrides exclusions)

None
max_depth int

Maximum depth to display (0 for unlimited)

0
show_full_path bool

Whether to show full paths instead of just filenames

False
sort_by_loc bool

Whether to calculate and display lines of code counts

False
sort_by_size bool

Whether to calculate and display file sizes

False
sort_by_mtime bool

Whether to calculate and display file modification times

False

Returns:

Type Description
tuple[dict[str, Any], dict[str, Any], set[str]]

Tuple of (structure1, structure2, combined_extensions)

Source code in recursivist/compare.py
def compare_directory_structures(
    dir1: str,
    dir2: str,
    exclude_dirs: Optional[Sequence[str]] = None,
    ignore_file: Optional[str] = None,
    exclude_extensions: Optional[set[str]] = None,
    exclude_patterns: Optional[Sequence[Union[str, Pattern[str]]]] = None,
    include_patterns: Optional[Sequence[Union[str, Pattern[str]]]] = None,
    max_depth: int = 0,
    show_full_path: bool = False,
    sort_by_loc: bool = False,
    sort_by_size: bool = False,
    sort_by_mtime: bool = False,
) -> tuple[dict[str, Any], dict[str, Any], set[str]]:
    """Compare two directory structures and return both structures with a combined set of extensions.

    Retrieves the directory structures for both directories using the same filtering options, then combines their file extensions for consistent color mapping in visualizations.

    Args:
        dir1: Path to the first directory
        dir2: Path to the second directory
        exclude_dirs: List of directory names to exclude
        ignore_file: Name of ignore file (like .gitignore)
        exclude_extensions: Set of file extensions to exclude
        exclude_patterns: List of patterns to exclude
        include_patterns: List of patterns to include (overrides exclusions)
        max_depth: Maximum depth to display (0 for unlimited)
        show_full_path: Whether to show full paths instead of just filenames
        sort_by_loc: Whether to calculate and display lines of code counts
        sort_by_size: Whether to calculate and display file sizes
        sort_by_mtime: Whether to calculate and display file modification times

    Returns:
        Tuple of (structure1, structure2, combined_extensions)
    """

    structure1, extensions1 = get_directory_structure(
        dir1,
        exclude_dirs,
        ignore_file,
        exclude_extensions,
        exclude_patterns=exclude_patterns,
        include_patterns=include_patterns,
        max_depth=max_depth,
        show_full_path=show_full_path,
        sort_by_loc=sort_by_loc,
        sort_by_size=sort_by_size,
        sort_by_mtime=sort_by_mtime,
    )
    structure2, extensions2 = get_directory_structure(
        dir2,
        exclude_dirs,
        ignore_file,
        exclude_extensions,
        exclude_patterns=exclude_patterns,
        include_patterns=include_patterns,
        max_depth=max_depth,
        show_full_path=show_full_path,
        sort_by_loc=sort_by_loc,
        sort_by_size=sort_by_size,
        sort_by_mtime=sort_by_mtime,
    )
    combined_extensions = extensions1.union(extensions2)
    return structure1, structure2, combined_extensions

display_comparison(dir1, dir2, exclude_dirs=None, ignore_file=None, exclude_extensions=None, exclude_patterns=None, include_patterns=None, use_regex=False, max_depth=0, show_full_path=False, sort_by_loc=False, sort_by_size=False, sort_by_mtime=False, icon_style='emoji')

Display two directory trees side by side with highlighted differences.

Creates a side-by-side terminal visualization with: - Two panel layout with labeled directory trees - Color-coded highlighting for unique items (green/red background) - Informative legend explaining the highlighting - Support for all standard filtering options - Optional statistics display (LOC, size, modification time)

Parameters:

Name Type Description Default
dir1 str

Path to the first directory

required
dir2 str

Path to the second directory

required
exclude_dirs Optional[list[str]]

List of directory names to exclude

None
ignore_file Optional[str]

Name of ignore file (like .gitignore)

None
exclude_extensions Optional[set[str]]

Set of file extensions to exclude

None
exclude_patterns Optional[list[str]]

List of patterns to exclude

None
include_patterns Optional[list[str]]

List of patterns to include (overrides exclusions)

None
use_regex bool

Whether to treat patterns as regex instead of glob patterns

False
max_depth int

Maximum depth to display (0 for unlimited)

0
show_full_path bool

Whether to show full paths instead of just filenames

False
sort_by_loc bool

Whether to show and sort by lines of code

False
sort_by_size bool

Whether to show and sort by file size

False
sort_by_mtime bool

Whether to show and sort by modification time

False
icon_style str

The style of icons to display ("emoji" or "nerd")

'emoji'
Source code in recursivist/compare.py
def display_comparison(
    dir1: str,
    dir2: str,
    exclude_dirs: Optional[list[str]] = None,
    ignore_file: Optional[str] = None,
    exclude_extensions: Optional[set[str]] = None,
    exclude_patterns: Optional[list[str]] = None,
    include_patterns: Optional[list[str]] = None,
    use_regex: bool = False,
    max_depth: int = 0,
    show_full_path: bool = False,
    sort_by_loc: bool = False,
    sort_by_size: bool = False,
    sort_by_mtime: bool = False,
    icon_style: str = "emoji",
) -> None:
    """Display two directory trees side by side with highlighted differences.

    Creates a side-by-side terminal visualization with:
    - Two panel layout with labeled directory trees
    - Color-coded highlighting for unique items (green/red background)
    - Informative legend explaining the highlighting
    - Support for all standard filtering options
    - Optional statistics display (LOC, size, modification time)

    Args:
        dir1: Path to the first directory
        dir2: Path to the second directory
        exclude_dirs: List of directory names to exclude
        ignore_file: Name of ignore file (like .gitignore)
        exclude_extensions: Set of file extensions to exclude
        exclude_patterns: List of patterns to exclude
        include_patterns: List of patterns to include (overrides exclusions)
        use_regex: Whether to treat patterns as regex instead of glob patterns
        max_depth: Maximum depth to display (0 for unlimited)
        show_full_path: Whether to show full paths instead of just filenames
        sort_by_loc: Whether to show and sort by lines of code
        sort_by_size: Whether to show and sort by file size
        sort_by_mtime: Whether to show and sort by modification time
        icon_style: The style of icons to display ("emoji" or "nerd")
    """

    if exclude_dirs is None:
        exclude_dirs = []
    if exclude_extensions is None:
        exclude_extensions = set()
    if exclude_patterns is None:
        exclude_patterns = []
    if include_patterns is None:
        include_patterns = []
    exclude_extensions = {
        ext.lower() if ext.startswith(".") else f".{ext.lower()}"
        for ext in exclude_extensions
    }
    compiled_exclude = compile_regex_patterns(exclude_patterns, use_regex)
    compiled_include = compile_regex_patterns(include_patterns, use_regex)
    structure1, structure2, extensions = compare_directory_structures(
        dir1,
        dir2,
        exclude_dirs,
        ignore_file,
        exclude_extensions,
        exclude_patterns=compiled_exclude,
        include_patterns=compiled_include,
        max_depth=max_depth,
        show_full_path=show_full_path,
        sort_by_loc=sort_by_loc,
        sort_by_size=sort_by_size,
        sort_by_mtime=sort_by_mtime,
    )
    color_map = {ext: generate_color_for_extension(ext) for ext in extensions}
    console = Console()

    root_base1 = os.path.basename(dir1)
    root_base2 = os.path.basename(dir2)
    root_icon1 = get_icon(root_base1, is_dir=True, style=icon_style)
    root_icon2 = get_icon(root_base2, is_dir=True, style=icon_style)

    if (
        sort_by_loc
        and sort_by_size
        and sort_by_mtime
        and "_loc" in structure1
        and "_size" in structure1
        and "_mtime" in structure1
    ):
        tree1 = Tree(
            Text(
                f"{root_icon1} {root_base1} ({structure1['_loc']} lines, {format_size(structure1['_size'])}, {format_timestamp(structure1['_mtime'])})",
                style="bold",
            )
        )
    elif (
        sort_by_loc and sort_by_size and "_loc" in structure1 and "_size" in structure1
    ):
        tree1 = Tree(
            Text(
                f"{root_icon1} {root_base1} ({structure1['_loc']} lines, {format_size(structure1['_size'])})",
                style="bold",
            )
        )
    elif (
        sort_by_loc
        and sort_by_mtime
        and "_loc" in structure1
        and "_mtime" in structure1
    ):
        tree1 = Tree(
            Text(
                f"{root_icon1} {root_base1} ({structure1['_loc']} lines, {format_timestamp(structure1['_mtime'])})",
                style="bold",
            )
        )
    elif (
        sort_by_size
        and sort_by_mtime
        and "_size" in structure1
        and "_mtime" in structure1
    ):
        tree1 = Tree(
            Text(
                f"{root_icon1} {root_base1} ({format_size(structure1['_size'])}, {format_timestamp(structure1['_mtime'])})",
                style="bold",
            )
        )
    elif sort_by_loc and "_loc" in structure1:
        tree1 = Tree(
            Text(
                f"{root_icon1} {root_base1} ({structure1['_loc']} lines)",
                style="bold",
            )
        )
    elif sort_by_size and "_size" in structure1:
        tree1 = Tree(
            Text(
                f"{root_icon1} {root_base1} ({format_size(structure1['_size'])})",
                style="bold",
            )
        )
    elif sort_by_mtime and "_mtime" in structure1:
        tree1 = Tree(
            Text(
                f"{root_icon1} {root_base1} ({format_timestamp(structure1['_mtime'])})",
                style="bold",
            )
        )
    else:
        tree1 = Tree(Text(f"{root_icon1} {root_base1}", style="bold"))

    if (
        sort_by_loc
        and sort_by_size
        and sort_by_mtime
        and "_loc" in structure2
        and "_size" in structure2
        and "_mtime" in structure2
    ):
        tree2 = Tree(
            Text(
                f"{root_icon2} {root_base2} ({structure2['_loc']} lines, {format_size(structure2['_size'])}, {format_timestamp(structure2['_mtime'])})",
                style="bold",
            )
        )
    elif (
        sort_by_loc and sort_by_size and "_loc" in structure2 and "_size" in structure2
    ):
        tree2 = Tree(
            Text(
                f"{root_icon2} {root_base2} ({structure2['_loc']} lines, {format_size(structure2['_size'])})",
                style="bold",
            )
        )
    elif (
        sort_by_loc
        and sort_by_mtime
        and "_loc" in structure2
        and "_mtime" in structure2
    ):
        tree2 = Tree(
            Text(
                f"{root_icon2} {root_base2} ({structure2['_loc']} lines, {format_timestamp(structure2['_mtime'])})",
                style="bold",
            )
        )
    elif (
        sort_by_size
        and sort_by_mtime
        and "_size" in structure2
        and "_mtime" in structure2
    ):
        tree2 = Tree(
            Text(
                f"{root_icon2} {root_base2} ({format_size(structure2['_size'])}, {format_timestamp(structure2['_mtime'])})",
                style="bold",
            )
        )
    elif sort_by_loc and "_loc" in structure2:
        tree2 = Tree(
            Text(
                f"{root_icon2} {root_base2} ({structure2['_loc']} lines)",
                style="bold",
            )
        )
    elif sort_by_size and "_size" in structure2:
        tree2 = Tree(
            Text(
                f"{root_icon2} {root_base2} ({format_size(structure2['_size'])})",
                style="bold",
            )
        )
    elif sort_by_mtime and "_mtime" in structure2:
        tree2 = Tree(
            Text(
                f"{root_icon2} {root_base2} ({format_timestamp(structure2['_mtime'])})",
                style="bold",
            )
        )
    else:
        tree2 = Tree(Text(f"{root_icon2} {root_base2}", style="bold"))

    build_comparison_tree(
        structure1,
        structure2,
        tree1,
        color_map,
        show_full_path=show_full_path,
        sort_by_loc=sort_by_loc,
        sort_by_size=sort_by_size,
        sort_by_mtime=sort_by_mtime,
        icon_style=icon_style,
    )
    build_comparison_tree(
        structure2,
        structure1,
        tree2,
        color_map,
        show_full_path=show_full_path,
        sort_by_loc=sort_by_loc,
        sort_by_size=sort_by_size,
        sort_by_mtime=sort_by_mtime,
        icon_style=icon_style,
    )
    legend_text = Text()
    legend_text.append("Legend: ", style="bold")
    legend_text.append("Green background ", style="on green")
    legend_text.append("= In this directory, ")
    legend_text.append("Red background ", style="on red")
    legend_text.append("= In the other directory")
    if sort_by_loc:
        legend_text.append("\n")
        legend_text.append(
            "LOC counts shown in parentheses, files sorted by line count"
        )
    if sort_by_size:
        legend_text.append("\n")
        legend_text.append("File sizes shown in parentheses, files sorted by size")
    if sort_by_mtime:
        legend_text.append("\n")
        legend_text.append(
            "Modification times shown in parentheses, files sorted by newest first"
        )
    if max_depth > 0:
        legend_text.append("\n")
        legend_text.append("⋯ (max depth reached) ", style="dim")
        legend_text.append(f"= Directory tree is limited to {max_depth} levels")
    if show_full_path:
        legend_text.append("\n")
        legend_text.append("Full file paths are shown instead of just filenames")
    if exclude_patterns or include_patterns:
        pattern_info = []
        if exclude_patterns:
            pattern_type = "Regex" if use_regex else "Glob"
            pattern_info.append(
                f"{pattern_type} exclusion patterns: {', '.join(str(p) for p in exclude_patterns)}"
            )
        if include_patterns:
            pattern_type = "Regex" if use_regex else "Glob"
            pattern_info.append(
                f"{pattern_type} inclusion patterns: {', '.join(str(p) for p in include_patterns)}"
            )
        if pattern_info:
            pattern_panel = Panel(
                "\n".join(pattern_info), title="Applied Patterns", border_style="blue"
            )
            console.print(pattern_panel)
    legend_panel = Panel(legend_text, border_style="dim")
    console.print(legend_panel)
    console.print(
        Columns(
            [
                Panel(
                    tree1,
                    title=f"Directory 1: {root_base1}",
                    border_style="blue",
                ),
                Panel(
                    tree2,
                    title=f"Directory 2: {root_base2}",
                    border_style="green",
                ),
            ],
            equal=True,
            expand=True,
        )
    )

export_comparison(dir1, dir2, format_type, output_path, exclude_dirs=None, ignore_file=None, exclude_extensions=None, exclude_patterns=None, include_patterns=None, use_regex=False, max_depth=0, show_full_path=False, sort_by_loc=False, sort_by_size=False, sort_by_mtime=False, icon_style='emoji')

Export directory comparison to HTML format.

Creates an HTML file containing the side-by-side comparison with: - Highlighted differences between directories - Interactive, responsive layout - Detailed metadata about the comparison settings - Visual legend explaining the highlighting - Optional statistics display

Currently only supports HTML export format.

Parameters:

Name Type Description Default
dir1 str

Path to the first directory

required
dir2 str

Path to the second directory

required
format_type str

Export format (only 'html' is supported)

required
output_path str

Path where the export file will be saved

required
exclude_dirs Optional[list[str]]

List of directory names to exclude

None
ignore_file Optional[str]

Name of ignore file (like .gitignore)

None
exclude_extensions Optional[set[str]]

Set of file extensions to exclude

None
exclude_patterns Optional[list[str]]

List of patterns to exclude

None
include_patterns Optional[list[str]]

List of patterns to include (overrides exclusions)

None
use_regex bool

Whether to treat patterns as regex instead of glob patterns

False
max_depth int

Maximum depth to display (0 for unlimited)

0
show_full_path bool

Whether to show full paths instead of just filenames

False
sort_by_loc bool

Whether to show and sort by lines of code

False
sort_by_size bool

Whether to show and sort by file size

False
sort_by_mtime bool

Whether to show and sort by modification time

False

Raises:

Type Description
ValueError

If the format_type is not supported

Source code in recursivist/compare.py
def export_comparison(
    dir1: str,
    dir2: str,
    format_type: str,
    output_path: str,
    exclude_dirs: Optional[list[str]] = None,
    ignore_file: Optional[str] = None,
    exclude_extensions: Optional[set[str]] = None,
    exclude_patterns: Optional[list[str]] = None,
    include_patterns: Optional[list[str]] = None,
    use_regex: bool = False,
    max_depth: int = 0,
    show_full_path: bool = False,
    sort_by_loc: bool = False,
    sort_by_size: bool = False,
    sort_by_mtime: bool = False,
    icon_style: str = "emoji",
) -> None:
    """Export directory comparison to HTML format.

    Creates an HTML file containing the side-by-side comparison with:
    - Highlighted differences between directories
    - Interactive, responsive layout
    - Detailed metadata about the comparison settings
    - Visual legend explaining the highlighting
    - Optional statistics display

    Currently only supports HTML export format.

    Args:
        dir1: Path to the first directory
        dir2: Path to the second directory
        format_type: Export format (only 'html' is supported)
        output_path: Path where the export file will be saved
        exclude_dirs: List of directory names to exclude
        ignore_file: Name of ignore file (like .gitignore)
        exclude_extensions: Set of file extensions to exclude
        exclude_patterns: List of patterns to exclude
        include_patterns: List of patterns to include (overrides exclusions)
        use_regex: Whether to treat patterns as regex instead of glob patterns
        max_depth: Maximum depth to display (0 for unlimited)
        show_full_path: Whether to show full paths instead of just filenames
        sort_by_loc: Whether to show and sort by lines of code
        sort_by_size: Whether to show and sort by file size
        sort_by_mtime: Whether to show and sort by modification time

    Raises:
        ValueError: If the format_type is not supported
    """

    if exclude_dirs is None:
        exclude_dirs = []
    if exclude_extensions is None:
        exclude_extensions = set()
    if exclude_patterns is None:
        exclude_patterns = []
    if include_patterns is None:
        include_patterns = []
    exclude_extensions = {
        ext.lower() if ext.startswith(".") else f".{ext.lower()}"
        for ext in exclude_extensions
    }
    compiled_exclude = compile_regex_patterns(exclude_patterns, use_regex)
    compiled_include = compile_regex_patterns(include_patterns, use_regex)
    structure1, structure2, _ = compare_directory_structures(
        dir1,
        dir2,
        exclude_dirs,
        ignore_file,
        exclude_extensions,
        exclude_patterns=compiled_exclude,
        include_patterns=compiled_include,
        max_depth=max_depth,
        show_full_path=show_full_path,
        sort_by_loc=sort_by_loc,
        sort_by_size=sort_by_size,
        sort_by_mtime=sort_by_mtime,
    )
    comparison_data = {
        "dir1": {"path": dir1, "name": os.path.basename(dir1), "structure": structure1},
        "dir2": {"path": dir2, "name": os.path.basename(dir2), "structure": structure2},
        "metadata": {
            "exclude_patterns": [str(p) for p in exclude_patterns],
            "include_patterns": [str(p) for p in include_patterns],
            "pattern_type": "regex" if use_regex else "glob",
            "max_depth": max_depth,
            "show_full_path": show_full_path,
            "sort_by_loc": sort_by_loc,
            "sort_by_size": sort_by_size,
            "sort_by_mtime": sort_by_mtime,
        },
    }
    if format_type == "html":
        _export_comparison_to_html(comparison_data, output_path, icon_style)
    else:
        raise ValueError("Only HTML format is supported for comparison export")

JSX Export Module

recursivist.jsx_export

React component export functionality for the Recursivist directory visualization tool.

This module generates a JSX file containing a sophisticated, interactive directory viewer React component with advanced features:

  • Folder expansion/collapse functionality
  • Breadcrumb navigation
  • Search with highlighted matches
  • Dark mode toggle
  • Optional file statistics display
  • Sorting by different metrics
  • Path copying
  • Mobile-responsive design

The generated component is standalone and can be integrated into React applications with minimal dependencies.

generate_jsx_component(structure, root_name, output_path, show_full_path=False, sort_by_loc=False, sort_by_size=False, sort_by_mtime=False, show_git_status=False)

Generate a React component file for directory structure visualization.

Creates a standalone JSX file containing a sophisticated directory viewer component with: - Reliable folder expand/collapse functionality - Breadcrumbs navigation - Search functionality with highlighted matches - Dark mode toggle - Path copying - Expand/collapse all buttons - Optional statistics display (LOC, size, modification times) - Optional Git status markers (untracked, modified, added, deleted) - Mobile-responsive design

Parameters:

Name Type Description Default
structure dict[str, Any]

Directory structure dictionary

required
root_name str

Root directory name

required
output_path str

Path where the React component file will be saved

required
show_full_path bool

Whether to show full paths instead of just filenames

False
sort_by_loc bool

Whether to show lines of code counts and sort by them

False
sort_by_size bool

Whether to show file sizes and sort by them

False
sort_by_mtime bool

Whether to show file modification times and sort by them

False
show_git_status bool

Whether to show Git status markers on files

False
Source code in recursivist/jsx_export.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
def generate_jsx_component(
    structure: dict[str, Any],
    root_name: str,
    output_path: str,
    show_full_path: bool = False,
    sort_by_loc: bool = False,
    sort_by_size: bool = False,
    sort_by_mtime: bool = False,
    show_git_status: bool = False,
) -> None:
    """Generate a React component file for directory structure visualization.

    Creates a standalone JSX file containing a sophisticated directory viewer component with:
    - Reliable folder expand/collapse functionality
    - Breadcrumbs navigation
    - Search functionality with highlighted matches
    - Dark mode toggle
    - Path copying
    - Expand/collapse all buttons
    - Optional statistics display (LOC, size, modification times)
    - Optional Git status markers (untracked, modified, added, deleted)
    - Mobile-responsive design

    Args:
      structure: Directory structure dictionary
      root_name: Root directory name
      output_path: Path where the React component file will be saved
      show_full_path: Whether to show full paths instead of just filenames
      sort_by_loc: Whether to show lines of code counts and sort by them
      sort_by_size: Whether to show file sizes and sort by them
      sort_by_mtime: Whether to show file modification times and sort by them
      show_git_status: Whether to show Git status markers on files
    """

    def _build_structure_jsx(
        structure: dict[str, Any], level: int = 0, path_prefix: str = ""
    ) -> str:
        jsx_content = []
        for name, content in sorted(
            [
                (k, v)
                for k, v in structure.items()
                if k != "_files"
                and k != "_max_depth_reached"
                and k != "_loc"
                and k != "_size"
                and k != "_mtime"
                and k != "_git_markers"
            ],
            key=lambda x: x[0].lower(),
        ):
            current_path = f"{path_prefix}/{name}" if path_prefix else name
            path_parts = current_path.split("/") if current_path else [name]
            if path_parts[0] == root_name and len(path_parts) > 1:
                path_parts = [root_name] + [p for p in path_parts[1:] if p]
            else:
                path_parts = [p for p in path_parts if p]
                if not path_parts or path_parts[0] != root_name:
                    path_parts = [root_name] + path_parts
            path_json = ",".join([f'"{html.escape(part)}"' for part in path_parts])
            loc_prop = ""
            size_prop = ""
            mtime_prop = ""
            if sort_by_loc and isinstance(content, dict) and "_loc" in content:
                loc_prop = f" locCount={{{content['_loc']}}}"
            if sort_by_size and isinstance(content, dict) and "_size" in content:
                size_prop = f" sizeCount={{{content['_size']}}}"
            if sort_by_mtime and isinstance(content, dict) and "_mtime" in content:
                mtime_prop = f" mtimeCount={{{content['_mtime']}}}"
            jsx_content.append(
                f"<DirectoryItem "
                f'name="{html.escape(name)}" '
                f"level={{{level}}} "
                f"path={{[{path_json}]}} "
                f'type="directory"{loc_prop}{size_prop}{mtime_prop}>'
            )
            next_path = current_path
            if isinstance(content, dict):
                if content.get("_max_depth_reached"):
                    jsx_content.append(
                        '<div className="max-depth p-3 bg-gray-50 rounded-lg border border-gray-100 ml-4 my-1">'
                    )
                    jsx_content.append(
                        '<p className="text-gray-500">⋯ (max depth reached)</p>'
                    )
                    jsx_content.append("</div>")
                else:
                    jsx_content.append(
                        _build_structure_jsx(content, level + 1, next_path)
                    )
            jsx_content.append("</DirectoryItem>")
        if "_files" in structure:
            files = structure["_files"]
            sorted_files = []

            valid_files = [
                f for f in files if not (isinstance(f, tuple) and len(f) == 0)
            ]

            def safe_get(tup: Any, idx: int, default: int = 0) -> int:
                if not isinstance(tup, tuple):
                    return default
                return int(tup[idx]) if len(tup) > idx else default

            def sort_key_all(
                f: Union[tuple[Any, ...], str],
            ) -> tuple[int, int, int, str]:
                if isinstance(f, tuple):
                    if len(f) == 0:
                        return (0, 0, 0, "")
                    file_name = f[0].lower() if len(f) > 0 else ""
                    loc = safe_get(f, 2) if len(f) > 2 else 0
                    size = safe_get(f, 3) if len(f) > 3 else 0
                    mtime = safe_get(f, 4) if len(f) > 4 else 0
                    return (-loc, -size, -mtime, file_name)
                return (0, 0, 0, f.lower() if isinstance(f, str) else "")

            def sort_key_loc_size(
                f: Union[tuple[Any, ...], str],
            ) -> tuple[int, int, str]:
                if isinstance(f, tuple):
                    if len(f) == 0:
                        return (0, 0, "")
                    file_name = f[0].lower() if len(f) > 0 else ""
                    loc = safe_get(f, 2) if len(f) > 2 else 0
                    size = safe_get(f, 3) if len(f) > 3 else 0
                    return (-loc, -size, file_name)
                return (0, 0, f.lower() if isinstance(f, str) else "")

            def sort_key_loc_mtime(
                f: Union[tuple[Any, ...], str],
            ) -> tuple[int, int, str]:
                if isinstance(f, tuple):
                    if len(f) == 0:
                        return (0, 0, "")
                    file_name = f[0].lower() if len(f) > 0 else ""
                    loc = safe_get(f, 2) if len(f) > 2 else 0
                    mtime = safe_get(f, 3) if len(f) > 3 and sort_by_loc else 0
                    if len(f) > 4 and sort_by_loc and sort_by_size:
                        mtime = safe_get(f, 4)
                    return (-loc, -mtime, file_name)
                return (0, 0, f.lower() if isinstance(f, str) else "")

            def sort_key_size_mtime(
                f: Union[tuple[Any, ...], str],
            ) -> tuple[int, int, str]:
                if isinstance(f, tuple):
                    if len(f) == 0:
                        return (0, 0, "")
                    file_name = f[0].lower() if len(f) > 0 else ""
                    size = safe_get(f, 2) if len(f) > 2 else 0
                    mtime = safe_get(f, 3) if len(f) > 3 else 0
                    return (-size, -mtime, file_name)
                return (0, 0, f.lower() if isinstance(f, str) else "")

            def sort_key_mtime(f: Union[tuple[Any, ...], str]) -> tuple[int, str]:
                if isinstance(f, tuple):
                    if len(f) == 0:
                        return (0, "")
                    file_name = f[0].lower() if len(f) > 0 else ""
                    mtime = 0
                    if len(f) > 4 and sort_by_loc and sort_by_size:
                        mtime = safe_get(f, 4)
                    elif len(f) > 3 and (sort_by_loc or sort_by_size):
                        mtime = safe_get(f, 3)
                    elif len(f) > 2:
                        mtime = safe_get(f, 2)
                    return (-mtime, file_name)
                return (0, f.lower() if isinstance(f, str) else "")

            def sort_key_size(f: Union[tuple[Any, ...], str]) -> tuple[int, str]:
                if isinstance(f, tuple):
                    if len(f) == 0:
                        return (0, "")
                    file_name = f[0].lower() if len(f) > 0 else ""
                    size = 0
                    if len(f) > 3 and sort_by_loc:
                        size = safe_get(f, 3)
                    elif len(f) > 2:
                        size = safe_get(f, 2)
                    return (-size, file_name)
                return (0, f.lower() if isinstance(f, str) else "")

            def sort_key_loc(f: Union[tuple[Any, ...], str]) -> tuple[int, str]:
                if isinstance(f, tuple):
                    if len(f) == 0:
                        return (0, "")
                    file_name = f[0].lower() if len(f) > 0 else ""
                    loc = safe_get(f, 2) if len(f) > 2 else 0
                    return (-loc, file_name)
                return (0, f.lower() if isinstance(f, str) else "")

            def sort_key_name(f: Union[tuple[Any, ...], str]) -> str:
                if isinstance(f, tuple):
                    if len(f) == 0:
                        return ""
                    return f[0].lower() if len(f) > 0 else ""
                return f.lower() if isinstance(f, str) else ""

            if sort_by_loc and sort_by_size and sort_by_mtime:
                sorted_files = sorted(valid_files, key=sort_key_all)
            elif sort_by_loc and sort_by_size:
                sorted_files = sorted(valid_files, key=sort_key_loc_size)
            elif sort_by_loc and sort_by_mtime:
                sorted_files = sorted(valid_files, key=sort_key_loc_mtime)
            elif sort_by_size and sort_by_mtime:
                sorted_files = sorted(valid_files, key=sort_key_size_mtime)
            elif sort_by_mtime:
                sorted_files = sorted(valid_files, key=sort_key_mtime)
            elif sort_by_size:
                sorted_files = sorted(valid_files, key=sort_key_size)
            elif sort_by_loc:
                sorted_files = sorted(valid_files, key=sort_key_loc)
            else:
                sorted_files = sorted(valid_files, key=sort_key_name)

            for file_item in sorted_files:
                file_name = "unknown"
                display_path = "unknown"
                loc = 0
                size = 0
                mtime = 0

                if isinstance(file_item, tuple):
                    if len(file_item) == 0:
                        continue

                    file_name = file_item[0] if len(file_item) > 0 else "unknown"
                    display_path = file_item[1] if len(file_item) > 1 else file_name

                    if (
                        sort_by_loc
                        and sort_by_size
                        and sort_by_mtime
                        and len(file_item) > 4
                    ):
                        loc = file_item[2]
                        size = file_item[3]
                        mtime = file_item[4]
                    elif sort_by_loc and sort_by_size and len(file_item) > 3:
                        loc = file_item[2]
                        size = file_item[3]
                    elif sort_by_loc and sort_by_mtime and len(file_item) > 3:
                        loc = file_item[2]
                        mtime = file_item[3]
                    elif sort_by_size and sort_by_mtime and len(file_item) > 3:
                        size = file_item[2]
                        mtime = file_item[3]
                    elif sort_by_loc and len(file_item) > 2:
                        loc = file_item[2]
                    elif sort_by_size and len(file_item) > 2:
                        size = file_item[2]
                    elif sort_by_mtime and len(file_item) > 2:
                        mtime = file_item[2]
                else:
                    file_name = file_item
                    display_path = file_name

                if path_prefix:
                    path_parts = path_prefix.split("/")
                    if path_parts and path_parts[0] == root_name:
                        path_parts = [root_name] + [p for p in path_parts[1:] if p]
                    else:
                        path_parts = [p for p in path_parts if p]
                        if not path_parts or path_parts[0] != root_name:
                            path_parts = [root_name] + path_parts
                else:
                    path_parts = [root_name]
                path_parts.append(file_name)
                path_json = ",".join(
                    [f'"{html.escape(part)}"' for part in path_parts if part]
                )

                props = [
                    f'name="{html.escape(file_name)}"',
                    f'displayPath="{html.escape(display_path)}"',
                    f"path={{[{path_json}]}}",
                    f"level={{{level}}}",
                ]

                if sort_by_loc:
                    props.append(f"locCount={{{loc}}}")

                if sort_by_size:
                    props.append(f"sizeCount={{{size}}}")
                    props.append(f'sizeFormatted="{format_size(size)}"')

                if sort_by_mtime:
                    props.append(f"mtimeCount={{{mtime}}}")
                    props.append(f'mtimeFormatted="{format_timestamp(mtime)}"')

                if show_git_status:
                    _git_markers_jsx = structure.get("_git_markers", {})
                    _gs = _git_markers_jsx.get(file_name, "")
                    if _gs:
                        props.append(f'gitStatus="{_gs}"')

                jsx_content.append(f"<FileItem {' '.join(props)} />")

        return "\n".join(jsx_content)

    combined_imports = ""
    if sort_by_loc and sort_by_size and sort_by_mtime:
        combined_imports = (
            """import { BarChart2, Database, Clock } from 'lucide-react';"""
        )
    elif sort_by_loc and sort_by_size:
        combined_imports = """import { BarChart2, Database } from 'lucide-react';"""
    elif sort_by_loc and sort_by_mtime:
        combined_imports = """import { BarChart2, Clock } from 'lucide-react';"""
    elif sort_by_size and sort_by_mtime:
        combined_imports = """import { Database, Clock } from 'lucide-react';"""
    elif sort_by_loc:
        combined_imports = """import { BarChart2 } from 'lucide-react';"""
    elif sort_by_size:
        combined_imports = """import { Database } from 'lucide-react';"""
    elif sort_by_mtime:
        combined_imports = """import { Clock } from 'lucide-react';"""
    loc_state = (
        """const showLoc = true;""" if sort_by_loc else """const showLoc = false;"""
    )
    size_state = (
        """const showSize = true;""" if sort_by_size else """const showSize = false;"""
    )
    mtime_state = (
        """const showMtime = true;"""
        if sort_by_mtime
        else """const showMtime = false;"""
    )
    git_status_state = (
        """const showGitStatus = true;"""
        if show_git_status
        else """const showGitStatus = false;"""
    )
    loc_sort_state = (
        """const sortByLoc = true;""" if sort_by_loc else """const sortByLoc = false;"""
    )
    size_sort_state = (
        """const sortBySize = true;"""
        if sort_by_size
        else """const sortBySize = false;"""
    )
    mtime_sort_state = (
        """const sortByMtime = true;"""
        if sort_by_mtime
        else """const sortByMtime = false;"""
    )
    loc_toggle_function = ""
    size_toggle_function = ""
    mtime_toggle_function = ""
    loc_toggle_button = ""
    size_toggle_button = ""
    mtime_toggle_button = ""
    root_loc_prop = ""
    root_size_prop = ""
    root_mtime_prop = ""
    if sort_by_loc and "_loc" in structure:
        root_loc_prop = f" locCount={{{structure['_loc']}}}"
    if sort_by_size and "_size" in structure:
        root_size_prop = f" sizeCount={{{structure['_size']}}}"
    if sort_by_mtime and "_mtime" in structure:
        root_mtime_prop = f" mtimeCount={{{structure['_mtime']}}}"
    format_size_function = ""
    if sort_by_size:
        format_size_function = """
  const format_size = (size_in_bytes) => {
    if (size_in_bytes < 1024) {
      return `${size_in_bytes} B`;
    } else if (size_in_bytes < 1024 * 1024) {
      return `${(size_in_bytes / 1024).toFixed(1)} KB`;
    } else if (size_in_bytes < 1024 * 1024 * 1024) {
      return `${(size_in_bytes / (1024 * 1024)).toFixed(1)} MB`;
    } else {
      return `${(size_in_bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
    }
  };"""
    format_timestamp_function = ""
    if sort_by_mtime:
        format_timestamp_function = """
  const format_timestamp = (timestamp) => {
    const dt = new Date(timestamp * 1000);
    const now = new Date();
    const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
    const yesterday = new Date(today);
    yesterday.setDate(yesterday.getDate() - 1);
    if (dt >= today) {
      return `Today ${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
    }
    else if (dt >= yesterday) {
      return `Yesterday ${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
    }
    else if ((today - dt) / (1000 * 60 * 60 * 24) < 7) {
      const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
      return `${days[dt.getDay()]} ${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
    }
    else if (dt.getFullYear() === now.getFullYear()) {
      const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
      return `${months[dt.getMonth()]} ${dt.getDate()}`;
    }
    else {
      return `${dt.getFullYear()}-${(dt.getMonth() + 1).toString().padStart(2, '0')}-${dt.getDate().toString().padStart(2, '0')}`;
    }
  };"""
    component_template = f"""import React, {{ useState, useEffect, useRef }} from 'react';
    import PropTypes from 'prop-types';
    import {{ ChevronDown, ChevronUp, Folder, FolderOpen, File, Maximize2, Minimize2, Search, X, Info, Home, ChevronRight, Copy, Check }} from 'lucide-react';
    {combined_imports}
    const AppContext = React.createContext();
    const highlightMatch = (text, searchTerm) => {{
      if (!searchTerm) return text;
      const parts = text.split(new RegExp(`(${{searchTerm}})`, 'gi'));
      return (
        <>
          {{parts.map((part, i) =>
            part.toLowerCase() === searchTerm.toLowerCase()
              ? <mark key={{i}} className="bg-yellow-200 px-1 rounded">{{part}}</mark>
              : part
          )}}
        </>
      );
    }};
    const Breadcrumbs = () => {{
      const {{
        currentPath,
        setCurrentPath,
        selectedItem,
        darkMode
      }} = React.useContext(AppContext);
      const [copied, setCopied] = useState(false);
      const breadcrumbRef = useRef(null);
      const copyPath = () => {{
        let path = currentPath.join('/');
        if (selectedItem) {{
          path = [...currentPath, selectedItem.name].join('/');
        }}
        navigator.clipboard.writeText(path).then(() => {{
          setCopied(true);
          setTimeout(() => setCopied(false), 2000);
        }});
      }};
      const navigateTo = (index) => {{
        setCurrentPath(currentPath.slice(0, index + 1));
      }};
      useEffect(() => {{
        if (breadcrumbRef.current) {{
          breadcrumbRef.current.scrollLeft = breadcrumbRef.current.scrollWidth;
        }}
      }}, [currentPath, selectedItem]);
      return (
        <div className={{`sticky top-0 left-0 right-0 ${{darkMode ? 'bg-gray-800 text-white' : 'bg-white text-gray-800'}} p-3 shadow-md z-50 overflow-visible`}}>
          <div className="container mx-auto max-w-5xl flex items-center justify-between">
            <div
              ref={{breadcrumbRef}}
              className="overflow-x-auto whitespace-nowrap flex items-center flex-grow mr-2"
              style={{{{ overflowX: 'auto' }}}}
            >
              <button
                onClick={{() => navigateTo(0)}}
                className={{`${{darkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-800'}} flex items-center mr-1 flex-shrink-0`}}
                title="Home directory"
              >
                <Home className="w-4 h-4 mr-1" />
                <span className="font-medium">{{currentPath[0]}}</span>
              </button>
              {{currentPath.length > 1 && currentPath.slice(1).map((segment, index) => (
                <React.Fragment key={{index}}>
                  <ChevronRight className={{`w-4 h-4 mx-1 ${{darkMode ? 'text-gray-500' : 'text-gray-400'}} flex-shrink-0`}} />
                  <button
                    onClick={{() => navigateTo(index + 1)}}
                    className={{`${{
                      index === currentPath.length - 2 && !selectedItem ?
                        (darkMode ? 'text-yellow-300 font-medium' : 'text-blue-700 font-medium') :
                        (darkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-800')
                    }} flex-shrink-0`}}
                  >
                    {{segment}}
                  </button>
                </React.Fragment>
              ))}}
              {{selectedItem && (
                <>
                  <ChevronRight className={{`w-4 h-4 mx-1 ${{darkMode ? 'text-gray-500' : 'text-gray-400'}} flex-shrink-0`}} />
                  <span className={{`${{darkMode ? 'text-yellow-300 font-medium' : 'text-blue-700 font-medium'}} flex-shrink-0`}}>
                    {{selectedItem.name}}
                  </span>
                </>
              )}}
            </div>
            <div className="flex items-center space-x-2 flex-shrink-0">
              {{copied ? (
                <span className={{`text-xs px-2 py-1 rounded ${{darkMode ? 'bg-green-800 text-green-200' : 'bg-green-100 text-green-800'}}`}}>
                  <Check className="w-3 h-3 inline mr-1" />
                  Copied
                </span>
              ) : (
                <button
                  onClick={{copyPath}}
                  className={{`p-1 rounded ${{darkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}}`}}
                  title="Copy path"
                >
                  <Copy className="w-4 h-4" />
                </button>
              )}}
            </div>
          </div>
        </div>
      );
    }};
    const DirectoryItem = (props) => {{
      const {{
        openFolders,
        setOpenFolders,
        searchTerm,
        darkMode,
        expandAll,
        collapseAll,
        setCurrentPath,
        currentPath,
        setSelectedItem,
        showLoc,
        showSize,
        showMtime,
        format_size,
        format_timestamp
      }} = React.useContext(AppContext);
      const {{ name, children, level = 0, path = [] }} = props;
      const folderId = path.join('/');
      const isOpen = openFolders.has(folderId);
      const isCurrentPath = currentPath.length === path.length &&
        path.every((segment, index) => segment === currentPath[index]);
      const isInCurrentPath = currentPath.length > path.length &&
        path.every((segment, index) => segment === currentPath[index]);
      const matchesSearch = searchTerm && name.toLowerCase().includes(searchTerm.toLowerCase());
      const toggleFolder = (e) => {{
        e.stopPropagation();
        const newOpenFolders = new Set(openFolders);
        if (isOpen) {{
          newOpenFolders.delete(folderId);
        }} else {{
          newOpenFolders.add(folderId);
        }}
        setOpenFolders(newOpenFolders);
      }};
      const navigateToFolder = (e) => {{
        e.stopPropagation();
        setCurrentPath(path);
        setSelectedItem(null);
      }};
      useEffect(() => {{
        if (expandAll) {{
          setOpenFolders(prev => new Set([...prev, folderId]));
        }}
      }}, [expandAll, folderId, setOpenFolders]);
      useEffect(() => {{
        if (collapseAll && folderId !== '{html.escape(root_name)}') {{
          setOpenFolders(prev => {{
            const newFolders = new Set(prev);
            newFolders.delete(folderId);
            return newFolders;
          }});
        }}
      }}, [collapseAll, folderId, setOpenFolders]);
      useEffect(() => {{
        if (searchTerm && matchesSearch) {{
          setOpenFolders(prev => new Set([...prev, folderId]));
        }}
      }}, [searchTerm, matchesSearch, folderId, setOpenFolders]);
      useEffect(() => {{
        if (isCurrentPath || isInCurrentPath) {{
          setOpenFolders(prev => new Set([...prev, folderId]));
        }}
      }}, [isCurrentPath, isInCurrentPath, folderId, setOpenFolders]);
      const indentClass = level === 0 ? '' : 'ml-4';
      const currentPathClass = isCurrentPath
        ? darkMode
          ? 'bg-blue-900 hover:bg-blue-800'
          : 'bg-blue-100 hover:bg-blue-200'
        : isInCurrentPath
          ? darkMode
            ? 'bg-blue-800/50 hover:bg-blue-800'
            : 'bg-blue-50 hover:bg-blue-100'
          : darkMode
            ? 'bg-gray-800 hover:bg-gray-700'
            : 'bg-gray-50 hover:bg-gray-100';
      const searchMatchClass = matchesSearch
        ? darkMode
          ? 'ring-1 ring-yellow-500'
          : 'ring-1 ring-yellow-300'
        : '';
      return (
        <div className={{`w-full ${{indentClass}}`}} data-folder={{folderId}}>
          <div className={{`flex items-center justify-between p-2 mb-1 rounded-lg ${{currentPathClass}} ${{searchMatchClass}}`}}>
            <div className="flex items-center flex-grow cursor-pointer" onClick={{navigateToFolder}}>
              {{isOpen
                ? <FolderOpen className={{`w-5 h-5 mr-2 ${{isCurrentPath ? (darkMode ? 'text-yellow-300' : 'text-blue-600') : darkMode ? 'text-blue-400' : 'text-blue-500'}}`}} />
                : <Folder className={{`w-5 h-5 mr-2 ${{isCurrentPath ? (darkMode ? 'text-yellow-300' : 'text-blue-600') : darkMode ? 'text-blue-400' : 'text-blue-500'}}`}} />
              }}
              <span className={{`font-medium truncate ${{isCurrentPath ? (darkMode ? 'text-yellow-300' : 'text-blue-700') : ''}}`}}>
                {{searchTerm ? highlightMatch(name, searchTerm) : name}}
              </span>
              {{props.locCount !== undefined && showLoc && (
                <span className={{`ml-2 text-xs px-1.5 py-0.5 rounded-full ${{isCurrentPath ?
                  (darkMode ? 'bg-blue-800 text-blue-200' : 'bg-blue-200 text-blue-700') :
                  (darkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-200 text-gray-700')}}`}}>
                  {{props.locCount}} lines
                </span>
              )}}
              {{props.sizeCount !== undefined && showSize && (
                <span className={{`ml-2 text-xs px-1.5 py-0.5 rounded-full ${{isCurrentPath ?
                  (darkMode ? 'bg-teal-800 text-teal-200' : 'bg-teal-200 text-teal-700') :
                  (darkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-200 text-gray-700')}}`}}>
                  {{format_size(props.sizeCount)}}
                </span>
              )}}
              {{props.mtimeCount !== undefined && showMtime && (
                <span className={{`ml-2 text-xs px-1.5 py-0.5 rounded-full ${{isCurrentPath ?
                  (darkMode ? 'bg-purple-800 text-purple-200' : 'bg-purple-200 text-purple-700') :
                  (darkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-200 text-gray-700')}}`}}>
                  {{format_timestamp(props.mtimeCount)}}
                </span>
              )}}
            </div>
            <button
              className={{`p-1 rounded-full ${{darkMode ? 'hover:bg-gray-600' : 'hover:bg-gray-200'}}`}}
              onClick={{toggleFolder}}
              data-testid="folder-toggle"
            >
              {{isOpen ? (
                <ChevronUp className="w-4 h-4" />
              ) : (
                <ChevronDown className="w-4 h-4" />
              )}}
            </button>
          </div>
          {{isOpen && (
            <div className="py-1">
              {{children}}
            </div>
          )}}
        </div>
      );
    }};
    const FileItem = (props) => {{
      const {{
        searchTerm,
        darkMode,
        currentPath,
        setCurrentPath,
        selectedItem,
        setSelectedItem,
        showLoc,
        showSize,
        showMtime,
        showGitStatus
      }} = React.useContext(AppContext);
      const {{ name, displayPath, path = [] }} = props;
      const gitStatus = props.gitStatus || '';
      const isDeleted = gitStatus === 'D';
      const GIT_BADGE_COLORS = {{
        U: {{ bg: darkMode ? 'bg-gray-700' : 'bg-gray-200', text: darkMode ? 'text-gray-400' : 'text-gray-500' }},
        M: {{ bg: darkMode ? 'bg-yellow-900' : 'bg-yellow-100', text: darkMode ? 'text-yellow-300' : 'text-yellow-700' }},
        A: {{ bg: darkMode ? 'bg-green-900' : 'bg-green-100', text: darkMode ? 'text-green-300' : 'text-green-700' }},
        D: {{ bg: darkMode ? 'bg-red-900' : 'bg-red-100', text: darkMode ? 'text-red-300' : 'text-red-600' }},
      }};
      useEffect(() => {{
      }}, [currentPath, path]);
      const matchesSearch = searchTerm && name.toLowerCase().includes(searchTerm.toLowerCase());
      const isSelected = selectedItem &&
        selectedItem.path &&
        selectedItem.path.length === path.length &&
        selectedItem.path.every((segment, index) => segment === path[index]);
      const handleFileSelect = () => {{
        setCurrentPath(path.slice(0, -1));
        setSelectedItem({{
          type: 'file',
          name,
          displayPath,
          path
        }});
      }};
      const indentClass = 'ml-4';
      const selectedClass = isSelected
        ? darkMode
          ? 'bg-blue-900 hover:bg-blue-800'
          : 'bg-blue-100 hover:bg-blue-200'
        : darkMode
          ? 'bg-gray-800 hover:bg-gray-700'
          : 'bg-white hover:bg-gray-50';
      const searchMatchClass = matchesSearch
        ? darkMode
          ? 'ring-1 ring-yellow-500'
          : 'ring-1 ring-yellow-300'
        : '';
      return (
        <div className={{`w-full ${{indentClass}}`}}>
          <div
            className={{`flex items-center p-2 rounded-lg border cursor-pointer ${{selectedClass}} ${{searchMatchClass}} ${{darkMode ? 'border-gray-700' : 'border-gray-100'}} my-1`}}
            onClick={{handleFileSelect}}
          >
            <File className={{`w-5 h-5 mr-2 ${{isSelected ? (darkMode ? 'text-yellow-300' : 'text-blue-600') : darkMode ? 'text-blue-400/70' : 'text-blue-400'}}`}} />
            <div className="min-w-0 overflow-hidden">
              <span className={{`truncate block ${{isSelected ? (darkMode ? 'text-yellow-300 font-medium' : 'text-blue-700 font-medium') : ''}} ${{isDeleted ? 'line-through opacity-60' : ''}}`}}>
                {{searchTerm ? highlightMatch(displayPath, searchTerm) : displayPath}}
              </span>
              {{props.locCount !== undefined && showLoc && (
                <span className={{`ml-2 text-xs px-1.5 py-0.5 rounded-full ${{isSelected ?
                  (darkMode ? 'bg-blue-800 text-blue-200' : 'bg-blue-200 text-blue-700') :
                  (darkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-200 text-gray-700')}}`}}>
                  {{props.locCount}} lines
                </span>
              )}}
              {{props.sizeCount !== undefined && showSize && (
                <span className={{`ml-2 text-xs px-1.5 py-0.5 rounded-full ${{isSelected ?
                  (darkMode ? 'bg-teal-800 text-teal-200' : 'bg-teal-200 text-teal-700') :
                  (darkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-200 text-gray-700')}}`}}>
                  {{props.sizeFormatted}}
                </span>
              )}}
              {{props.mtimeCount !== undefined && showMtime && (
                <span className={{`ml-2 text-xs px-1.5 py-0.5 rounded-full ${{isSelected ?
                  (darkMode ? 'bg-purple-800 text-purple-200' : 'bg-purple-200 text-purple-700') :
                  (darkMode ? 'bg-gray-700 text-gray-300' : 'bg-gray-200 text-gray-700')}}`}}>
                  {{props.mtimeFormatted}}
                </span>
              )}}
              {{showGitStatus && gitStatus && GIT_BADGE_COLORS[gitStatus] && (
                <span className={{`ml-2 text-xs px-1.5 py-0.5 rounded-full font-mono font-bold ${{GIT_BADGE_COLORS[gitStatus].bg}} ${{GIT_BADGE_COLORS[gitStatus].text}}`}}>
                  [{html.escape("{")}gitStatus{html.escape("}")}]
                </span>
              )}}
            </div>
          </div>
        </div>
      );
    }};
    const SearchBar = () => {{
      const {{ searchTerm, setSearchTerm, darkMode }} = React.useContext(AppContext);
      return (
        <div className="relative mb-4">
          <div className={{`flex items-center border rounded-lg overflow-hidden ${{
            darkMode ? 'bg-gray-700 border-gray-600' : 'bg-white border-gray-300'
          }}`}}>
            <div className="p-2">
              <Search className={{`w-5 h-5 ${{darkMode ? 'text-gray-400' : 'text-gray-500'}}`}} />
            </div>
            <input
              type="text"
              value={{searchTerm}}
              onChange={{(e) => setSearchTerm(e.target.value)}}
              placeholder="Search files and folders..."
              className={{`flex-grow p-2 outline-none ${{
                darkMode ? 'bg-gray-700 text-white placeholder-gray-400' : 'bg-white text-gray-800 placeholder-gray-400'
              }}`}}
            />
            {{searchTerm && (
              <button
                onClick={{() => setSearchTerm('')}}
                className={{`p-2 ${{darkMode ? 'hover:bg-gray-600' : 'hover:bg-gray-100'}}`}}
              >
                <X className={{`w-4 h-4 ${{darkMode ? 'text-gray-400' : 'text-gray-500'}}`}} />
              </button>
            )}}
          </div>
        </div>
      );
    }};
    const DirectoryViewer = () => {{
      const [openFolders, setOpenFolders] = useState(new Set(['{
        html.escape(root_name)
    }']));
      const [searchTerm, setSearchTerm] = useState('');
      const [darkMode, setDarkMode] = useState(false);
      const [currentPath, setCurrentPath] = useState(['{html.escape(root_name)}']);
      const [selectedItem, setSelectedItem] = useState(null);
      const [expandAll, setExpandAll] = useState(false);
      const [collapseAll, setCollapseAll] = useState(false);
      {loc_state}
      {loc_sort_state}
      {size_state}
      {size_sort_state}
      {mtime_state}
      {mtime_sort_state}
      {git_status_state}
      const handleExpandAll = () => {{
        setExpandAll(true);
        setTimeout(() => setExpandAll(false), 100);
      }};
      const handleCollapseAll = () => {{
        setCollapseAll(true);
        setTimeout(() => setCollapseAll(false), 100);
      }};
      const toggleDarkMode = () => {{
        setDarkMode(!darkMode);
      }};
      {loc_toggle_function}
      {size_toggle_function}
      {mtime_toggle_function}
      {
        format_size_function
        if format_size_function
        else '''
  const format_size = () => {
    return '0 B';
  };'''
    }

      {
        format_timestamp_function
        if format_timestamp_function
        else '''
  const format_timestamp = () => {
    return '';
  };'''
    }

      useEffect(() => {{
        if (darkMode) {{
          document.body.classList.add('dark-mode');
        }} else {{
          document.body.classList.remove('dark-mode');
        }}
      }}, [darkMode]);
      return (
        <AppContext.Provider value={{{{
          openFolders,
          setOpenFolders,
          searchTerm,
          setSearchTerm,
          darkMode,
          expandAll,
          collapseAll,
          currentPath,
          setCurrentPath,
          selectedItem,
          setSelectedItem,
          showLoc,
          sortByLoc,
          showSize,
          sortBySize,
          showMtime,
          sortByMtime,
          showGitStatus,
          format_size,
          format_timestamp
        }}}}>
          <div className={{`min-h-screen ${{darkMode ? 'bg-gray-900 text-gray-100' : 'bg-gray-50 text-gray-900'}}`}}>
            <style>{{`
              body.dark-mode {{
                background-color: rgb(17, 24, 39);
                color: rgb(243, 244, 246);
              }}
              .overflow-x-auto {{
                overflow-x: auto;
                white-space: nowrap;
              }}
            `}}</style>
            <Breadcrumbs />
            <div className="container mx-auto max-w-5xl p-4">
              <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-6">
                <div>
                  <h1 className="text-xl font-bold flex items-center">
                    <FolderOpen className={{`w-6 h-6 mr-2 ${{darkMode ? 'text-blue-400' : 'text-blue-500'}}`}} />
                    Directory Structure: {html.escape(root_name)}
                  </h1>
                  <p className={{`mt-1 text-sm ${{darkMode ? 'text-gray-400' : 'text-gray-500'}}`}}>
                    Explore and navigate through the directory structure
                  </p>
                </div>
                <div className="flex mt-4 sm:mt-0 space-x-2">
                  <button
                    onClick={{handleExpandAll}}
                    className={{`flex items-center px-3 py-1.5 text-sm rounded-md ${{
                      darkMode ? 'bg-gray-800 hover:bg-gray-700 text-blue-400' : 'bg-blue-100 hover:bg-blue-200 text-blue-700'
                    }}`}}
                  >
                    <Maximize2 className="w-4 h-4 mr-1" />
                    <span className="hidden sm:inline">Expand All</span>
                  </button>
                  <button
                    onClick={{handleCollapseAll}}
                    className={{`flex items-center px-3 py-1.5 text-sm rounded-md ${{
                      darkMode ? 'bg-gray-800 hover:bg-gray-700' : 'bg-gray-200 hover:bg-gray-300'
                    }}`}}
                  >
                    <Minimize2 className="w-4 h-4 mr-1" />
                    <span className="hidden sm:inline">Collapse All</span>
                  </button>{loc_toggle_button}{size_toggle_button}{mtime_toggle_button}
                  <button
                    onClick={{toggleDarkMode}}
                    className={{`px-3 py-1.5 text-sm rounded-md ${{
                      darkMode ? 'bg-gray-800 hover:bg-gray-700' : 'bg-gray-200 hover:bg-gray-300'
                    }}`}}
                  >
                    {{darkMode ? 'Light' : 'Dark'}}
                  </button>
                </div>
              </div>
              <SearchBar />
              <div className={{`p-4 rounded-lg shadow ${{darkMode ? 'bg-gray-800' : 'bg-white'}}`}}>
                <DirectoryItem
                  name="{html.escape(root_name)}"
                  level={{0}}
                  path={{["{html.escape(root_name)}"]}}{root_loc_prop}{root_size_prop}{
        root_mtime_prop
    }
                >
    {_build_structure_jsx(structure, 1, root_name if show_full_path else "")}
                </DirectoryItem>
                {{searchTerm && openFolders.size <= 1 && (
                  <div className="py-8 text-center">
                    <Info className={{`w-12 h-12 mx-auto mb-3 ${{darkMode ? 'text-gray-600' : 'text-gray-300'}}`}} />
                    <h3 className="text-lg font-medium">No matching files or folders</h3>
                    <p className={{`mt-1 text-sm ${{darkMode ? 'text-gray-400' : 'text-gray-500'}}`}}>
                      Try a different search term or check spelling
                    </p>
                  </div>
                )}}
              </div>
            </div>
          </div>
        </AppContext.Provider>
      );
    }};
    DirectoryItem.propTypes = {{
      name: PropTypes.string.isRequired,
      children: PropTypes.node,
      level: PropTypes.number,
      path: PropTypes.arrayOf(PropTypes.string),
      locCount: PropTypes.number,
      sizeCount: PropTypes.number,
      mtimeCount: PropTypes.number
    }};
    FileItem.propTypes = {{
      name: PropTypes.string.isRequired,
      displayPath: PropTypes.string.isRequired,
      path: PropTypes.arrayOf(PropTypes.string),
      level: PropTypes.number,
      locCount: PropTypes.number,
      sizeCount: PropTypes.number,
      sizeFormatted: PropTypes.string,
      mtimeCount: PropTypes.number,
      mtimeFormatted: PropTypes.string,
      gitStatus: PropTypes.string
    }};
    export default DirectoryViewer;
    """
    try:
        with open(output_path, "w", encoding="utf-8") as f:
            f.write(component_template)
        logger.info(f"Successfully exported to React component: {output_path}")
    except Exception as e:
        logger.error(f"Error exporting to React component: {e}")
        raise

Using the Python API in Custom Scripts

Here's an example of how to use the Python API to create a custom directory analysis script:

import sys
from recursivist.core import get_directory_structure
from recursivist.core import export_structure

def analyze_directory(directory_path):
    # Get directory structure with line counts and file sizes
    structure, extensions = get_directory_structure(
        directory_path,
        exclude_dirs=["node_modules", ".git", "venv"],
        exclude_extensions={".pyc", ".log", ".tmp"},
        sort_by_loc=True,
        sort_by_size=True
    )

    # Export to multiple formats
    export_structure(structure, directory_path, "md", "analysis.md", sort_by_loc=True, sort_by_size=True)
    export_structure(structure, directory_path, "json", "analysis.json", sort_by_loc=True, sort_by_size=True)

    # Calculate some statistics
    total_loc = structure.get("_loc", 0)
    total_size = structure.get("_size", 0)

    print(f"Directory: {directory_path}")
    print(f"Total lines of code: {total_loc}")
    print(f"Total size: {total_size} bytes")

    # Find the files with the most lines of code
    def collect_files(structure, path=""):
        files = []
        for name, content in structure.items():
            if name == "_files":
                for file_item in content:
                    if isinstance(file_item, tuple) and len(file_item) > 2:
                        file_name, full_path, loc = file_item[0], file_item[1], file_item[2]
                        files.append((file_name, full_path, loc))
            elif isinstance(content, dict) and name not in ["_max_depth_reached", "_loc", "_size", "_mtime"]:
                files.extend(collect_files(content, f"{path}/{name}"))
        return files

    files = collect_files(structure)
    files.sort(key=lambda x: x[2], reverse=True)

    print("\nTop 5 files by lines of code:")
    for i, (name, path, loc) in enumerate(files[:5], 1):
        print(f"{i}. {path} ({loc} lines)")

if __name__ == "__main__":
    if len(sys.argv) > 1:
        analyze_directory(sys.argv[1])
    else:
        analyze_directory(".")

API Extension Points

If you're looking to extend Recursivist's functionality, these are the main extension points:

  1. Custom Pattern Matching: Extend the should_exclude function in core.py
  2. New Export Format: Add a new method to the DirectoryExporter class in exports.py
  3. Custom Visualization: Modify the build_tree and display_tree functions in core.py
  4. Custom Statistics: Add new statistics collection to the get_directory_structure function

The API is designed to be modular, making it possible to reuse individual components for custom functionality while maintaining consistent behavior across the library.