#!/usr/bin/env bash set -euo pipefail set -o errtrace disable_strict_mode() { set +e +u +o pipefail; } enable_strict_mode() { set -euo pipefail set -o errtrace } # shellcheck disable=SC2034 VERSION="1.3.5" # Homebrew/dev/lib path lookup if [[ -n "${FOLDER_TREE_HOME:-}" && -d "$FOLDER_TREE_HOME/lib" ]]; then BASE_DIR="$FOLDER_TREE_HOME" elif command -v brew &>/dev/null; then HOMEBREW_PREFIX="$(brew --prefix 2>/dev/null || true)" if [[ -d "$HOMEBREW_PREFIX/share/folder-tree-cli/lib" ]]; then BASE_DIR="$HOMEBREW_PREFIX/share/folder-tree-cli" elif [[ -d "$HOMEBREW_PREFIX/opt/folder-tree-cli/share/folder-tree-cli/lib" ]]; then BASE_DIR="$HOMEBREW_PREFIX/opt/folder-tree-cli/share/folder-tree-cli" fi fi if [[ -z "${BASE_DIR:-}" ]]; then BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" fi LIB_DIR="$BASE_DIR/lib" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" search_paths=( "$LIB_DIR/folder_tree_utils.sh" "$SCRIPT_DIR/../lib/folder_tree_utils.sh" "$SCRIPT_DIR/lib/folder_tree_utils.sh" "/opt/homebrew/share/folder-tree-cli/lib/folder_tree_utils.sh" ) for path in "${search_paths[@]}"; do [[ -f "$path" ]] && source "$path" && break done LIB_DIR="$BASE_DIR/lib" # Dynamically locate tpl/ folder (same strategy as LIB_DIR) possible_tpl_dirs=( "$BASE_DIR/tpl" "$SCRIPT_DIR/../tpl" "$SCRIPT_DIR/tpl" "/opt/homebrew/share/folder-tree-cli/tpl" "/opt/homebrew/opt/folder-tree-cli/share/folder-tree-cli/tpl" ) for tdir in "${possible_tpl_dirs[@]}"; do if [[ -d "$tdir" ]]; then TPL_DIR="$tdir" break fi done if [[ ! -d "$TPL_DIR" ]]; then log_err "โŒ Could not locate any tpl/ directory in: ${possible_tpl_dirs[*]}" exit 1 fi # Default presets TARGET_DIR="." EXCLUDES=() USED_PRESETS=() OUTPUT_MODE="tree" CONFIG_FILE="$HOME/.treeignore" COLOR=true HISTORY_MODE=false HIDDEN=false SHOW_VERSION=false VERBOSE=false HINT_FILE="$HOME/.broot_hint_seen" GROUP_EXT="" declare -A EXCLUDE_PRESETS # ๐Ÿงฐ Back-end / scripting EXCLUDE_PRESETS[node]="node_modules dist .next .vite" EXCLUDE_PRESETS[python]="__pycache__ .venv venv *.pyc" EXCLUDE_PRESETS[terraform]=".terraform" EXCLUDE_PRESETS[docker]="*.dockerfile Dockerfile*" # โ˜• Java / Maven / Gradle EXCLUDE_PRESETS[java]="target .gradle .idea build *.class out" # ๐Ÿ“ฆ JS tooling EXCLUDE_PRESETS[javascript]=".eslintcache .turbo .cache" # ๐ŸŽจ Vue / Nuxt EXCLUDE_PRESETS[vue]=".vite dist coverage" EXCLUDE_PRESETS[nuxt]=".nuxt .output dist" # ๐Ÿงพ Git / Infra EXCLUDE_PRESETS[github]=".git .github .gitignore" print_usage() { echo "Usage: folder_tree [options] [target_directory]" echo "" echo "Options:" # echo " --preset Comma-separated: node,nuxt,all,..." echo " --config Load exclude patterns (default: ~/.treeignore)" echo " --output markdown Output Markdown-style list" echo " --output broot Output Broot-style list" echo " --output git Show Git-tracked files as Markdown list" echo "" echo " --compute Show file/folder sizes in output" echo " --hidden Show hidden files and folders (dotfiles)" echo " --no-color Disable colored output" echo " --version Print script version" # echo " --list-presets Show all available preset types" echo " --history Append to FOLDER_TREE.md instead of overwriting" echo "" echo " --verbose Enable verbose output" echo "" echo " -h, --help Show help" echo "" echo "Examples:" echo " folder_tree --preset node,nuxt --output markdown ." echo " folder_tree --output git ./myproject" echo " folder_tree --preset all --compute --output markdown --history" echo " folder_tree --preset python --hidden --output broot" exit 0 } log_info() { echo -e "${color_info}${icon_info} $*${color_reset}"; } log_ok() { echo -e "${color_green}${icon_ok} $*${color_reset}"; } log_warn() { echo -e "${color_yellow}${icon_warn} $*${color_reset}"; } log_err() { echo -e "${color_red}${icon_err} $*${color_reset}" >&2; } log_target() { echo -e "${color_blue}${icon_target} $*${color_reset}"; } log_excl() { echo -e "${color_yellow}${icon_excl} $*${color_reset}"; } log_preset() { echo -e "${color_bold}${icon_preset} $*${color_reset}"; } log_hidden() { echo -e "${color_yellow}${icon_hidden} $*${color_reset}"; } # Fallback to interactive decision tree if no args if [[ "$#" -eq 0 ]]; then decision_paths=( "$LIB_DIR/folder_decision_tree.sh" "$SCRIPT_DIR/../lib/folder_decision_tree.sh" "$SCRIPT_DIR/lib/folder_decision_tree.sh" "/opt/homebrew/share/folder-tree-cli/lib/folder_decision_tree.sh" ) found_tree_wizard=false for dp in "${decision_paths[@]}"; do if [[ -f "$dp" ]]; then source "$dp" run_decision_tree found_tree_wizard=true break fi done if [[ "$found_tree_wizard" == false ]]; then log_err "โŒ Could not locate folder_decision_tree.sh in: ${decision_paths[*]}" exit 1 fi exit 0 fi # Ensure tree is available if ! command -v tree &>/dev/null; then log_err "'tree' not found. Install it with: brew install tree" exit 1 fi # Parse args while [[ $# -gt 0 ]]; do case "$1" in --preset) IFS=',' read -ra TYPES <<<"$2" for type in "${TYPES[@]}"; do if [[ "$type" == "all" ]]; then for val in "${EXCLUDE_PRESETS[@]}"; do EXCLUDES+=($val); done USED_PRESETS=("all") break elif [[ -n "${EXCLUDE_PRESETS[$type]+_}" ]]; then EXCLUDES+=(${EXCLUDE_PRESETS[$type]}) USED_PRESETS+=("$type") else log_warn "Unknown preset: $type" fi done shift 2 ;; --output) OUTPUT_MODE="$2" shift 2 ;; --config) CONFIG_FILE="$2" shift 2 ;; --no-color) COLOR=false shift ;; --version) SHOW_VERSION=true shift ;; --verbose) VERBOSE=true shift ;; --list-presets) list_presets ;; --history) HISTORY_MODE=true shift ;; --hidden) HIDDEN=true shift ;; --noreport) TREE_ARGS+=("--noreport") shift ;; --compute) TREE_ARGS+=("--du") # note: `tree` uses --du, not --compute shift ;; -v | -t | -c | -U | -r) TREE_ARGS+=("$1") shift ;; -h | --help) print_usage ;; *) TARGET_DIR="$1" shift ;; esac done # Resolve and validate target dir if [[ ! -d "$TARGET_DIR" ]]; then log_err "Directory not found: $TARGET_DIR" exit 1 fi TARGET_DIR="$(cd "$TARGET_DIR" && pwd)" [[ -d "$TARGET_DIR/.git" ]] && EXCLUDES+=(".git") # Load config file if [[ -f "$CONFIG_FILE" ]]; then while IFS= read -r line; do [[ -n "$line" && ! "$line" =~ ^# ]] && EXCLUDES+=("$line") done <"$CONFIG_FILE" fi # If hidden mode is enabled, allow "bin" folders (but keep other excludes) if [[ "$HIDDEN" == true ]]; then log_info "Hidden mode enabled โ€” allowing additional folders to be shown." ALLOW_FOLDERS=("bin") log_hidden "Hidden mode override โ€” allowed folders: ${ALLOW_FOLDERS[*]}" for allow in "${ALLOW_FOLDERS[@]}"; do EXCLUDES=($(printf '%s\n' "${EXCLUDES[@]}" | grep -vFx "$allow")) done fi # Git-tracked output mode if [[ "$OUTPUT_MODE" == "git" ]]; then if ! command -v git &>/dev/null; then log_err "Git not found." exit 1 fi if [[ ! -d "$TARGET_DIR/.git" ]]; then log_err "'$TARGET_DIR' is not a Git repo." exit 1 fi echo -e "${icon_git} Git-tracked files in: $(basename "$TARGET_DIR")" cd "$TARGET_DIR" git ls-tree -r --name-only HEAD | sed 's|[^/][^/]*| - &|g' exit 0 fi # broot mode if [[ "$OUTPUT_MODE" == "broot" ]]; then if ! command -v broot &>/dev/null; then log_err "'broot' is not installed. Install it with: brew install broot" exit 1 fi echo -e "${color_green}๐Ÿš€ Launching broot in: $TARGET_DIR${color_reset}" broot "$TARGET_DIR" if [[ ! -f "$HINT_FILE" && ! "$(command -v br)" ]]; then echo -e "${color_info}${icon_info} Tip: To unlock full broot functionality (like 'cd' from inside), install the shell function:${color_reset}" echo -e "๐Ÿ‘‰ Run: broot --install" echo -e "๐Ÿ” Then restart your terminal or run: exec \$SHELL" touch "$HINT_FILE" fi exit 0 fi # Build tree args TREE_ARGS+=(-F --dirsfirst --sort name) [[ "$OUTPUT_MODE" != "markdown" && "$COLOR" == true ]] && TREE_ARGS+=(-C) [[ "$HIDDEN" == true ]] && TREE_ARGS+=(-a) for pattern in "${EXCLUDES[@]}"; do TREE_ARGS+=(-I "$pattern") done [[ "$SHOW_VERSION" == true ]] && print_version disable_strict_mode log_target "Target: $(basename "$TARGET_DIR")" [[ "${#USED_PRESETS[@]}" -gt 0 ]] && log_preset "Presets: ${USED_PRESETS[*]}" [[ -f "$CONFIG_FILE" ]] && log_excl "Excludes from: $(basename "$CONFIG_FILE")" || log_excl "Using built-in presets only" [[ "$HIDDEN" == true ]] && log_hidden "Hidden files/folders will be shown." [[ "$VERBOSE" == true ]] && log_info "TREE_ARGS: ${TREE_ARGS[*]}" pushd "$TARGET_DIR" >/dev/null TREE_OUTPUT="$(tree "${TREE_ARGS[@]}" . 2>/dev/null || true)" if [[ "${TREE_ARGS[*]}" =~ --du ]]; then TREE_OUTPUT="$(echo "$TREE_OUTPUT" | awk ' function human(x) { units[0] = "B"; units[1] = "KB"; units[2] = "MB"; units[3] = "GB"; units[4] = "TB" i = 0 while (x >= 1024 && i < 4) { x /= 1024; i++ } return sprintf("%.1f %s", x, units[i]) } { while (match($0, /\[[[:space:]]*[0-9]+\]/)) { size_str = substr($0, RSTART+1, RLENGTH-2) gsub(/[[:space:]]/, "", size_str) size_n = size_str + 0 hr = "[ " human(size_n) " ]" pad = length(substr($0, RSTART, RLENGTH)) - length(hr) if (pad > 0) hr = hr sprintf("%"pad"s", "") $0 = substr($0, 1, RSTART-1) hr substr($0, RSTART+RLENGTH) } print } /^[[:space:]]*[0-9]+ bytes used/ { total = $1 + 0 sub(/^[[:space:]]*[0-9]+ bytes used/, "") printf("๐Ÿ“ฆ Total: %s%s\n", human(total), $0) next } ')" fi popd >/dev/null enable_strict_mode CLEAN_TREE="$(echo "$TREE_OUTPUT" | grep -vE '^[[:space:]]*$' | grep -vE '^[0-9]+ directories?, [0-9]+ files$')" if [[ "$OUTPUT_MODE" == "markdown" ]]; then BADGES_BLOCK="$(generate_folder_tree_badges_block "$VERSION")" DATE="$(date '+%Y-%m-%d %H:%M:%S')" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Helper to find a template file by precedence find_section_file() { local base="$1" local result="" for fn in "$SCRIPT_DIR/../tpl/$base" "$SCRIPT_DIR/tpl/$base" "./tpl/$base"; do [[ -f "$fn" ]] && result="$fn" && break done echo "$result" } # Load header/footer HEADER_FILE="$(find_section_file 'report_header.tpl')" FOOTER_FILE="$(find_section_file 'report_footer.tpl')" [[ -z "$HEADER_FILE" ]] && HEADER_FILE="$(find_section_file 'header.tpl')" [[ -z "$FOOTER_FILE" ]] && FOOTER_FILE="$(find_section_file 'footer.tpl')" HEADER_CONTENT="" FOOTER_CONTENT="" [[ -n "$HEADER_FILE" ]] && HEADER_CONTENT="$(cat "$HEADER_FILE")" [[ -n "$FOOTER_FILE" ]] && FOOTER_CONTENT="$(cat "$FOOTER_FILE")" # Prepare markdown for tree or empty state if [[ -z "$CLEAN_TREE" ]]; then TREE_MD="" IS_EMPTY="true" else TREE_MD="$(echo "$TREE_OUTPUT" | awk ' { gsub(/\u2502/, "|") gsub(/\u251c\u2500\u2500 /, "- ") gsub(/\u2514\u2500\u2500 /, "- ") gsub(/ /, " ") print } ')" IS_EMPTY="" fi # Load the main template (should NOT have badges/titles, just glue header/tree/footer) # TPL_PATH="$(find_template 'folder_tree_md.tpl' "$SCRIPT_DIR")" find_template() { local name="$1" for base in "${possible_tpl_dirs[@]}"; do local candidate="$base/$name" [[ -f "$candidate" ]] && echo "$candidate" && return done return 1 } TPL_PATH="$(find_template 'folder_tree_md.tpl')" if [[ -z "${TPL_PATH:-}" || ! -f "$TPL_PATH" ]]; then log_err "โŒ Template file not found. Tried resolving 'folder_tree_md.tpl' in: ${possible_tpl_dirs[*]}" exit 1 fi TPL="$(cat "$TPL_PATH")" # Replace inside header/footer before injecting HEADER_CONTENT="${HEADER_CONTENT//@@BADGES@@/$BADGES_BLOCK}" FOOTER_CONTENT="${FOOTER_CONTENT//@@BADGES@@/$BADGES_BLOCK}" HEADER_CONTENT="${HEADER_CONTENT//\{\{DATE\}\}/$DATE}" FOOTER_CONTENT="${FOOTER_CONTENT//\{\{DATE\}\}/$DATE}" # Inject updated header/footer into template TPL="${TPL//\{\{ header \}\}/$HEADER_CONTENT}" TPL="${TPL//\{\{ footer \}\}/$FOOTER_CONTENT}" TPL="${TPL//\{\{DATE\}\}/$DATE}" # Conditional logic for empty tree if [[ -n "$IS_EMPTY" ]]; then # Only keep the {{#if EMPTY}} block, with warning TPL="$(echo "$TPL" | awk '/\{\{#if EMPTY\}\}/{p=1;next}/\{\{else\}\}/,/\{\{\/if\}\}/{next} {if(!p)print} END{if(p)print "**โš ๏ธ Nothing to show. All contents excluded or directory is empty.**"}')" TPL="${TPL//\{\{TREE\}\}/}" else # Only keep the non-empty block, with tree content TPL="$(echo "$TPL" | awk '/\{\{#if EMPTY\}\}/,/\{\{else\}\}/{next} /\{\{\/if\}\}/{next} {print}')" TPL="${TPL//\{\{TREE\}\}/$TREE_MD}" fi # Remove unused handlebars just in case TPL="${TPL//\{\{#if EMPTY\}\}/}" TPL="${TPL//\{\{else\}\}/}" TPL="${TPL//\{\{\/if\}\}/}" OUTPUT_FILE="$(pwd)/FOLDER_TREE.md" echo "$TPL" >"$OUTPUT_FILE" if [[ -n "$IS_EMPTY" ]]; then log_warn "Nothing to show. All contents excluded or directory is empty." fi # Print relative path, but only if running in a terminal if [[ -t 1 ]]; then if command -v realpath &>/dev/null && realpath --help 2>&1 | grep -q -- '--relative-to'; then REL_PATH=$(realpath --relative-to="." "$OUTPUT_FILE") elif command -v python3 &>/dev/null; then REL_PATH=$(python3 -c "import os.path; print(os.path.relpath('$OUTPUT_FILE', '.'))") else REL_PATH="$OUTPUT_FILE" fi echo -e "${color_green}${icon_ok} Markdown output written to (overwrite): $REL_PATH${color_reset}" fi exit 0 fi # Markdown output mode (handled earlier) if [[ "$OUTPUT_MODE" == "markdown" ]]; then # (your markdown logic, already above, includes exit 0) exit 0 fi # Default: pretty banners + tree echo "$TREE_OUTPUT"