Script / Command for copying tree view of selected files & folders

Was inspired to make this script after seeing another thread.

It generates and copies to the clipboard a tree view of selected files and folders with lots of options and features, such as automatic expansion of sub folders (to customizable depth) and customizable appearance.

Example Output:

Variable Options:

  • Show Files First (filesFirst): When set to true, files will be listed before folders in the tree view.
  • Expansion Depth (expandDepth): Controls how deep the folder structure should be expanded.
    • Set to 0 for no expansion, names of files/folders in source directory only
    • Set to -1 for unlimited depth
    • Set to any positive integer for that specific depth
  • Context Line (contextLine): Determines what to show as the top line of the tree view. (Also affected by "showSourceOneUpContext" variable)
    • "none" - No context line
    • "folder" - Name of the source folder
    • "path" - Full path of the source folder
  • Up One Context (showSourceOneUpContext): When set to true, the top context line will show relative to one layer up. (It won't show any more files/folders, it basically just adds an extra indentation level for the source folder)

Optional Command Arguments (For Above Variables):

  • FILES_FIRST
  • EXPAND_DEPTH
  • CONTEXT_LINE
  • UP_ONE_CONTEXT

Appearance Customizability Variables:
You can customize the appearance of the tree view by modifying these variables:

  • middleFileBranch: Character(s) for files at a middle branch
  • endFileBranch: Character(s) for files at an end branch
  • middleFolderBranch: Character(s) for folders at a middle branch
  • endFolderBranch: Character(s) for folders at an end branch
  • verticalBranch: Character(s) for spacers and directory layers
  • folderPrefix and folderSuffix: Strings to add before and after folder names, in case you want to make folders show with brackets or something around them [Like This]
  • discontinuousBranchForLastFile: Whether or not to use the "endFileBranch" for the last file in files first mode. You can see this in action in the screenshot above between lines 6-7 and 17-18. If false, it would use the "middleFileBranch" value.

Arguments Template (Must put this in the 'Template' box in the command editor to use arguments):

  • UP_ONE_CONTEXT/O/S,FILES_FIRST/O/S,EXPAND_DEPTH/O/N,CONTEXT_LINE/O

Usage Example - Basic argument usage when calling script as a user command:

  • Copy_Tree_View UP_ONE_CONTEXT FILES_FIRST EXPAND_DEPTH=-1 CONTEXT_LINE="path"

How to Install (Easy Way):

  1. Download the User Command .dcf file here:
    Copy_Tree_View.dcf (18.7 KB)
  2. Open the Customize Menu > "User Commands" Tab
  3. Drag the file into the list of user commands. Or use the 'Import' button to import the file.

How to Install Manually:
Copy the script below to a new User Command via the Command Editor window. Be sure to set it as Script Function using JScript, and to set the Argument Template.

// Copy a tree view of selected files/folders
// By ThioJoe
// Updated: 7/3/24 (1.0.2)

//   Argument Template:
//   UP_ONE_CONTEXT/O/S,FILES_FIRST/O/S,EXPAND_DEPTH/O/N,CONTEXT_LINE/O

function OnClick(clickData)
{
    // ------- Options (These values will be used if no corresponding argument is specified when calling script --------
    // True/False - Whether to list files before folders
    // >  Optional Argument Name: FILES_FIRST (Switch, no value needed)
    var filesFirst = false;
    
    // Integer - Depth to expand folders. 0 for no expansion, -1 for unlimited depth
    // >  Optional Argument Name: EXPAND_DEPTH (Integer value)
    var expandDepth = -1;
    
    // String - What to show as the top line. Nothing, name of the source folder, or path of the source folder
    // Possible Values: "none", "folder", "path"
    // >  Optional Argument Name: CONTEXT_LINE (String value)
    var contextLine = "folder";
    
    // True/False - If true (or argument included), the context line will be moved up by one folder.
    // >  Optional Argument Name: UP_ONE_CONTEXT (Switch, no value needed)
    var showSourceOneUpContext = false;

    // -----------------------------------------------------------------------------------------------------------------
    // Example usage of arguments:
    // Copy_Tree_View UP_ONE_CONTEXT FILES_FIRST EXPAND_DEPTH=1 CONTEXT_LINE="path"
    // -----------------------------------------------------------------------------------------------------------------

    // ------------------------ APPEARANCE SETTINGS ------------------------
    // The character(s) to use for files at a middle branch of the tree
    var middleFileBranch = "├───";
    // The character(s) to use for files at an end branch of the tree
    var endFileBranch = "└───";
    // The character(s) to use for folders at a middle branch of the tree
    var middleFolderBranch = "├───";
    // The character(s) to use for folders at an end branch of the tree
    var endFolderBranch = "└───";
    // The character(s) to use as a spacer and directory layers not connected to a file/folder
    var verticalBranch = "│";

    // Folder name settings, such as to add brackets surrounding folder names like this or something: [Folder Name]
    // The string to prefix folder names with (default is empty string)
    var folderPrefix = "";
    // The string to suffix folder names with (default is empty string)
    var folderSuffix = "";
    
    // In files first mode, this sets whether to show the endFileBranch string above for the last file in the folder, even if there are folders below it
    var discontinuousBranchForLastFile = true;
    
    // ---------------------------------------------------------------------

    // Parse optional arguments if they're there
    if (clickData.func.args.got_arg.UP_ONE_CONTEXT) {
        showSourceOneUpContext = true;
        //DOpus.Output("Received UP_ONE_CONTEXT argument");
    }
    
    if (clickData.func.args.got_arg.FILES_FIRST) {
        filesFirst = true;
        //DOpus.Output("Received FILES_FIRST argument");
    }
    
    if (clickData.func.args.got_arg.EXPAND_DEPTH) {
        // Validate argument value
        var argExpandDepth = parseInt(clickData.func.args.EXPAND_DEPTH, 10);
        if (!isNaN(argExpandDepth) && (argExpandDepth >= -1)) {
            expandDepth = argExpandDepth;
        } else {
            expandDepth = -1;
            DOpus.Output("ERROR: Invalid EXPAND_DEPTH argument. Must be an integer >= -1. Got: " + clickData.func.args.EXPAND_DEPTH);
        }
        //DOpus.Output("Received EXPAND_DEPTH argument: " + expandDepth);
    }
    
    if (clickData.func.args.got_arg.CONTEXT_LINE) {
        // Validate argument value
        var argContextLine = clickData.func.args.CONTEXT_LINE.toLowerCase();
        if (argContextLine === "none" || argContextLine === "folder" || argContextLine === "path") {
            contextLine = argContextLine;
        } else {
            contextLine = "folder";
            DOpus.Output("ERROR: Invalid CONTEXT_LINE argument. Must be either 'none', 'folder', or 'path'. Got: " + clickData.func.args.CONTEXT_LINE);
        }
        //DOpus.Output("Received CONTEXT_LINE argument: " + contextLine);
    }

    // Further variable setup
    contextLine = contextLine.toLowerCase()
    if (discontinuousBranchForLastFile === false) {
        var lastFileBranch = middleFileBranch;
    } else {
        var lastFileBranch = endFileBranch;
    }

    var tab = clickData.func.sourcetab;
    var selectedItems = tab.selected;
    var sourcePathDepth = tab.path.Split.count;

    // Adjust initial depth based on the context
    var initialDepth = showSourceOneUpContext ? sourcePathDepth - 1 : sourcePathDepth;

    var expandedItems = expandSelectedItems(selectedItems, expandDepth);

    // Decide top level line to print. Whether current folder name, current full path name, or up-one context
    var topLine = "";
    if (contextLine !== "none") {

        if (showSourceOneUpContext === true) {

            var parentPath = DOpus.FSUtil.NewPath(tab.path);
            parentPath.Parent();

            if (contextLine === "folder") {
                topLine = parentPath.filepart + "\n";
            } else if (contextLine === "path") {
                topLine = parentPath + "\n";
            }

        } else if (showSourceOneUpContext === false) {

            if (contextLine === "folder") {
                topLine = tab.path.filepart + "\n";
            } else if (contextLine === "path") {
                topLine = tab.path + "\n";
            }
        }
    }

    // Create the tree output
    var treeOutput = topLine;
    treeOutput += generateTree(expandedItems, initialDepth, filesFirst, middleFileBranch, endFileBranch, middleFolderBranch, endFolderBranch, verticalBranch, folderPrefix, folderSuffix, lastFileBranch);

    DOpus.SetClip(treeOutput);
}

function expandSelectedItems(items, expandDepth) {
    var expandedItems = DOpus.Create().Vector();

    function expand(item, depth) {
        expandedItems.push_back(item);
        if (item.is_dir && (expandDepth === -1 || depth < expandDepth)) {
            var folderEnum = DOpus.FSUtil.ReadDir(item, false);
            while (!folderEnum.complete) {
                var subItem = folderEnum.Next();
                if (subItem) {
                    expand(subItem, depth + 1);
                }
            }
        }
    }

    for (var i = 0; i < items.count; i++) {
        expand(items(i), 0);
    }

    return expandedItems;
}

function generateTree(items, baseDepth, filesFirst, middleFileBranch, endFileBranch, middleFolderBranch, endFolderBranch, verticalBranch, folderPrefix, folderSuffix, lastFileBranch) {
    var treeText = "";
    var pathTree = {};

    // Build the tree structure
    for (var i = 0; i < items.count; i++) {
        var item = items(i);
        var relativePathParts = item.realpath.Split(baseDepth);

        // Navigate through the tree structure
        var currentLevel = pathTree;
        for (var j = 0; j < relativePathParts.count; j++) {
            var part = relativePathParts(j);
            if (!currentLevel[part]) {
                currentLevel[part] = { "_isDir": (j < relativePathParts.count - 1 || item.is_dir) };
            }
            currentLevel = currentLevel[part];
        }
    }

    // Convert the tree structure to text
    treeText = convertTreeToText(pathTree, "", "", filesFirst, middleFileBranch, endFileBranch, middleFolderBranch, endFolderBranch, verticalBranch, folderPrefix, folderSuffix, lastFileBranch, true);

    return treeText;
}

function convertTreeToText(tree, folderTerminator, indent, filesFirst, middleFileBranch, endFileBranch, middleFolderBranch, endFolderBranch, verticalBranch, folderPrefix, folderSuffix, lastFileBranch, isRoot) {
    var treeText = "";
    var entries = [];
    var dirs = [];
    var files = [];

    for (var key in tree) {
        if (tree.hasOwnProperty(key) && key !== "_isDir") {
            if (tree[key]["_isDir"]) {
                dirs.push(key);
            } else {
                files.push(key);
            }
        }
    }

    // Sort files and directories as needed
    if (filesFirst) {
        entries = files.concat(dirs);
    } else {
        entries = dirs.concat(files);
    }

    for (var i = 0; i < entries.length; i++) {
        var key = entries[i];
        var isDir = tree[key]["_isDir"];
        var isLastEntry = (i === entries.length - 1);
        var isLastFile = (i === files.length - 1 && filesFirst && i < files.length);

        var line = indent + (isDir ? (isLastEntry ? endFolderBranch : middleFolderBranch) : (isLastEntry ? endFileBranch : (isLastFile ? lastFileBranch : middleFileBranch))) + (isDir ? folderPrefix + key + folderSuffix : key) + "\n";
        treeText += line;

        if (isDir) {
            var subTreeText = convertTreeToText(tree[key], folderTerminator, indent + (isLastEntry ? "    " : verticalBranch + "   "), filesFirst, middleFileBranch, endFileBranch, middleFolderBranch, endFolderBranch, verticalBranch, folderPrefix, folderSuffix, lastFileBranch, false);
            treeText += subTreeText;

            // Check if current directory is empty
            var isCurrentDirEmpty = subTreeText.replace(/^\s+|\s+$/g, '').length === 0;

            // Only add spacing if it's not the last entry
            if (!isLastEntry) {
                // Get the next entry
                var nextEntry = entries[i + 1];
                
                // Check if the next entry is a directory
                if (tree[nextEntry] && tree[nextEntry]["_isDir"]) {
                    var nextSubTreeText = convertTreeToText(tree[nextEntry], folderTerminator, "", filesFirst, middleFileBranch, endFileBranch, middleFolderBranch, endFolderBranch, verticalBranch, folderPrefix, folderSuffix, lastFileBranch, false);
                    var isNextDirEmpty = nextSubTreeText.replace(/^\s+|\s+$/g, '').length === 0;

                    // Add spacer line if:
                    // 1. Current directory is not empty, OR
                    // 2. Current directory is empty but the next one is not
                    if (!isCurrentDirEmpty || (isCurrentDirEmpty && !isNextDirEmpty)) {
                        treeText += indent + verticalBranch + "\n";
                    }
                } else {
                    // If next entry is not a directory, always add a spacer
                    treeText += indent + verticalBranch + "\n";
                }
            }
        }

        // Add a vertical line after the last file and before the first folder
        if (isLastFile && filesFirst && dirs.length > 0) {
            treeText += indent + verticalBranch + "\n";
        }
    }

    return treeText;
}

Changes:

  • 1.0.2: Added discontinuousBranchForLastFile for further advanced appearance customization. Also fixed line spacing for a special case for completely empty folders.
6 Likes