diff --git a/home/dot_config/fish/functions/chai.fish.tmpl b/home/dot_config/fish/functions/chai.fish.tmpl new file mode 100644 index 0000000..49d2ee5 --- /dev/null +++ b/home/dot_config/fish/functions/chai.fish.tmpl @@ -0,0 +1,55 @@ +function chai --description "Interactive chezmoi apply with fzf diff preview" + # Get status data first (before fzf takes over TTY) + # Script outputs to stderr for "no changes" message, stdout for data + set -l data (fzf-chezmoi-apply.ts 2>/dev/null) + set -l script_status $status + + # Handle error or no changes case + if test $script_status -ne 0 + return 1 + end + + if test -z "$data" + echo "No changes to apply" + return 0 + end + + # Run fzf separately so it gets proper TTY access + set -l selected (printf '%s\n' $data | fzf \ + --ansi \ + --height=50% \ + --reverse \ + --delimiter='\t' \ + --with-nth=4 \ + --nth=1 \ + --prompt='Apply Changes > ' \ + --preview='chezmoi diff {1} 2>/dev/null | head -100' \ + --preview-window=right:60%:wrap \ + --multi \ + --bind='ctrl-a:toggle-all' \ + --marker='* ' \ + --pointer='>' \ + --header='Tab: toggle | Ctrl+A: all | Enter: apply') + + if test $status -ne 0 -o -z "$selected" + echo "Cancelled" + return 1 + end + + # Apply selected files + set -l targets + for line in $selected + set -l target (string split \t $line)[1] + set -a targets $target + end + + set -l count (count $targets) + echo "Applying $count file(s)..." + + for target in $targets + echo " $target" + chezmoi apply ~/$target + end + + echo "Applied $count file(s)" +end diff --git a/home/dot_config/fish/functions/chshow.fish.tmpl b/home/dot_config/fish/functions/chshow.fish.tmpl index ca7f6c8..e2951bc 100644 --- a/home/dot_config/fish/functions/chshow.fish.tmpl +++ b/home/dot_config/fish/functions/chshow.fish.tmpl @@ -1,6 +1,41 @@ -function chshow --description "Show rendered chezmoi template via fzf" - set -l target (find {{ .chezmoi.sourceDir | quote }} -name "*.tmpl" -type f | fzf) - if test -n "$target" - cat $target | chezmoi execute-template +function chshow --description "Browse chezmoi managed files with fzf preview" + # Get data from script first (before fzf takes over TTY) + set -l data (fzf-chezmoi-show.ts) + if test $status -ne 0 -o -z "$data" + return 1 + end + + # Run fzf separately so it gets proper TTY access + set -l result (printf '%s\n' $data | fzf \ + --ansi \ + --height=50% \ + --reverse \ + --delimiter='\t' \ + --with-nth=4 \ + --nth=1,2 \ + --prompt='Chezmoi Files > ' \ + --preview='chezmoi cat {1} 2>/dev/null || echo "Preview unavailable"' \ + --preview-window=right:60%:wrap \ + --expect='ctrl-e' \ + --header='Enter: view | Ctrl+E: edit source') + + if test $status -ne 0 -o -z "$result" + return + end + + # Parse result: first line is key, second is selected item + set -l lines (string split \n $result) + set -l key $lines[1] + set -l selected $lines[2] + + if test -n "$selected" + set -l target (string split \t $selected)[1] + + if test "$key" = "ctrl-e" + chezmoi edit ~/$target + else + echo "─── Rendered: $target ───" + chezmoi cat $target 2>/dev/null || echo "Cannot render file (may be binary or encrypted)" + end end end diff --git a/home/dot_local/bin/executable_fzf-chezmoi-apply.ts b/home/dot_local/bin/executable_fzf-chezmoi-apply.ts new file mode 100644 index 0000000..1f4bddf --- /dev/null +++ b/home/dot_local/bin/executable_fzf-chezmoi-apply.ts @@ -0,0 +1,121 @@ +#!/usr/bin/env bun + +/** + * fzf-chezmoi-apply - Interactive chezmoi apply with status display + * Output format: target\tstatus\tsource\tdisplay + */ + +import { $ } from "bun"; +import { + colors, + parseSourceFile, + formatError, +} from "./fzf-utils.ts"; + +interface StatusEntry { + target: string; + statusCode: string; + action: "add" | "modify" | "delete" | "run"; + source?: string; +} + +const statusLabels: Record = { + A: { label: "ADD", color: colors.add, action: "add" }, + M: { label: "MOD", color: colors.modify, action: "modify" }, + D: { label: "DEL", color: colors.delete, action: "delete" }, + R: { label: "RUN", color: colors.script, action: "run" }, +}; + +async function getStatus(): Promise { + const result = await $`chezmoi status`.quiet().nothrow(); + + if (result.exitCode !== 0) { + console.error(formatError("chezmoi command failed")); + process.exit(1); + } + + const lines = result.text().trim().split("\n").filter(Boolean); + + if (lines.length === 0) { + // No changes - this is a success, not an error + console.error("✅ No changes to apply - target is in sync with source"); + process.exit(0); + } + + const entries: StatusEntry[] = []; + + for (const line of lines) { + // Format: "XY path" where X is last state, Y is target state (what will happen) + const statusCode = line.substring(0, 2); + const target = line.substring(3); + + // We care about the second character (what will happen on apply) + const actionChar = statusCode[1]; + const statusInfo = statusLabels[actionChar]; + + if (statusInfo) { + entries.push({ + target, + statusCode, + action: statusInfo.action, + }); + } + } + + return entries; +} + +async function getSourcePaths(targets: string[]): Promise> { + if (targets.length === 0) return new Map(); + + const homeDir = process.env.HOME || ""; + + // Get source paths for specific targets (preserves input order) + const targetPaths = targets.map(t => `${homeDir}/${t}`); + const result = await $`chezmoi source-path ${targetPaths}`.quiet().nothrow(); + + if (result.exitCode !== 0) { + return new Map(); // Fallback to no source paths + } + + const sourcePaths = result.text().trim().split("\n").filter(Boolean); + + const sourceMap = new Map(); + targets.forEach((t, i) => { + // Extract just the basename from the full source path + const source = sourcePaths[i]?.split("/").pop() || ""; + sourceMap.set(t, source); + }); + + return sourceMap; +} + +function formatDisplay(entry: StatusEntry, source?: string): string { + const statusInfo = statusLabels[entry.statusCode[1]] || { label: "CHG", color: colors.type }; + + const sourceInfo = source + ? ` ${colors.arrow}(${colors.type}${source}${colors.arrow})${colors.reset}` + : ""; + + return ( + `${statusInfo.color}[${statusInfo.label}]${colors.reset} ` + + `${colors.name}${entry.target}${colors.reset}` + + sourceInfo + ); +} + +async function main() { + const entries = await getStatus(); + + // Get source paths for all targets + const sourceMap = await getSourcePaths(entries.map(e => e.target)); + + // Output: target\tstatus\tsource\tdisplay + for (const entry of entries) { + const source = sourceMap.get(entry.target) || ""; + const display = formatDisplay(entry, source); + console.log(`${entry.target}\t${entry.action}\t${source}\t${display}`); + } +} + +main(); diff --git a/home/dot_local/bin/executable_fzf-chezmoi-show.ts b/home/dot_local/bin/executable_fzf-chezmoi-show.ts new file mode 100644 index 0000000..549fec8 --- /dev/null +++ b/home/dot_local/bin/executable_fzf-chezmoi-show.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env bun + +/** + * fzf-chezmoi-show - Browse chezmoi managed files + * Output format: target\tsource\ttype_flags\tdisplay + */ + +import { $ } from "bun"; +import { + colors, + parseSourceFile, + getTypeIndicators, + getFileColor, + padRight, + formatError, +} from "./fzf-utils.ts"; + +interface ManagedFile { + target: string; + source: string; + flags: ReturnType; +} + +async function getManagedFiles(): Promise { + // Get source-absolute paths (sorted by source path) + const sourcesResult = await $`chezmoi managed --include=files --path-style=source-absolute`.quiet().nothrow(); + + if (sourcesResult.exitCode !== 0) { + console.error(formatError("chezmoi not found or not initialized")); + process.exit(1); + } + + const sourceAbsolutes = sourcesResult.text().trim().split("\n").filter(Boolean); + + if (sourceAbsolutes.length === 0) { + return []; + } + + // Get target paths for all sources in one call (preserves input order) + const targetResult = await $`chezmoi target-path ${sourceAbsolutes}`.quiet().nothrow(); + + if (targetResult.exitCode !== 0) { + console.error(formatError("Failed to resolve target paths")); + process.exit(1); + } + + const targetAbsolutes = targetResult.text().trim().split("\n"); + const homeDir = process.env.HOME || ""; + + return sourceAbsolutes.map((sourceAbs, i) => { + const source = sourceAbs.split("/").pop() || ""; + // Remove home directory prefix to get relative target + const target = targetAbsolutes[i].replace(homeDir + "/", "").replace(/^\//, ""); + return { + target, + source, + flags: parseSourceFile(source), + }; + }); +} + +function formatDisplay(file: ManagedFile): string { + const indicators = getTypeIndicators(file.flags); + const color = getFileColor(file.flags); + + // Pad indicators to 4 chars for alignment (most are 2 emojis = 4 visual chars) + const paddedIndicators = padRight(indicators || " ", 4); + + return ( + `${paddedIndicators} ` + + `${color}${file.target}${colors.reset} ` + + `${colors.arrow}<-${colors.reset} ` + + `${colors.type}${file.source}${colors.reset}` + ); +} + +async function main() { + const files = await getManagedFiles(); + + if (files.length === 0) { + console.error(formatError("No managed files found")); + process.exit(1); + } + + // Output: target\tsource\tflags\tdisplay + for (const file of files) { + const flagStr = Object.entries(file.flags) + .filter(([_, v]) => v) + .map(([k]) => k.replace("is", "").toLowerCase()) + .join(","); + + const display = formatDisplay(file); + console.log(`${file.target}\t${file.source}\t${flagStr}\t${display}`); + } +} + +main(); diff --git a/home/dot_local/bin/fzf-utils.ts b/home/dot_local/bin/fzf-utils.ts new file mode 100644 index 0000000..8251944 --- /dev/null +++ b/home/dot_local/bin/fzf-utils.ts @@ -0,0 +1,100 @@ +/** + * Shared utilities for fzf-based tools + */ + +// Standardized color scheme for all fzf tools +export const colors = { + name: "\x1b[36m", // Cyan - primary item/name + arrow: "\x1b[90m", // Gray - separators/arrows + expansion: "\x1b[32m", // Green - positive/expansion/success + type: "\x1b[33m", // Yellow - type/category labels + add: "\x1b[32m", // Green - additions + modify: "\x1b[33m", // Yellow - modifications + delete: "\x1b[91m", // Red - deletions/removals + script: "\x1b[35m", // Purple - special actions/scripts + reset: "\x1b[0m", +}; + +// Type indicators for chezmoi files (max 3 shown) +export const typeIcons = { + encrypted: "🔒", + private: "🔐", + template: "📝", + executable: "⚡", + symlink: "🔗", + script: "▶️", +}; + +export interface ChezmoiFileFlags { + isEncrypted: boolean; + isPrivate: boolean; + isTemplate: boolean; + isExecutable: boolean; + isSymlink: boolean; + isScript: boolean; +} + +/** + * Parse a chezmoi source filename to extract type flags + */ +export function parseSourceFile(source: string): ChezmoiFileFlags { + const basename = source.split("/").pop() || source; + + return { + isEncrypted: basename.includes("encrypted_"), + isPrivate: basename.includes("private_"), + isTemplate: basename.endsWith(".tmpl"), + isExecutable: basename.includes("executable_"), + isSymlink: basename.includes("symlink_"), + isScript: basename.startsWith("run_"), + }; +} + +/** + * Get emoji indicators for file type flags (max 3) + */ +export function getTypeIndicators(flags: ChezmoiFileFlags): string { + const indicators: string[] = []; + + // Priority order for showing indicators + if (flags.isScript) indicators.push(typeIcons.script); + if (flags.isEncrypted) indicators.push(typeIcons.encrypted); + if (flags.isPrivate && !flags.isEncrypted) indicators.push(typeIcons.private); + if (flags.isExecutable) indicators.push(typeIcons.executable); + if (flags.isSymlink) indicators.push(typeIcons.symlink); + if (flags.isTemplate) indicators.push(typeIcons.template); + + // Limit to 3 indicators + return indicators.slice(0, 3).join(""); +} + +/** + * Get the primary color for a file based on its type + */ +export function getFileColor(flags: ChezmoiFileFlags): string { + if (flags.isScript) return colors.script; + if (flags.isEncrypted) return colors.delete; // Red for encrypted (sensitive) + if (flags.isPrivate) return colors.modify; // Yellow for private + if (flags.isExecutable) return colors.add; // Green for executable + if (flags.isSymlink) return colors.name; // Cyan for symlink + if (flags.isTemplate) return colors.script; // Purple for template + return colors.reset; +} + +/** + * Pad a string to a fixed width (for column alignment) + */ +export function padRight(str: string, width: number): string { + // Account for emoji width (most are 2 chars wide in terminals) + const emojiCount = (str.match(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]/gu) || []).length; + const visualWidth = str.length + emojiCount; + const padding = Math.max(0, width - visualWidth); + return str + " ".repeat(padding); +} + +/** + * Format an error message for display + */ +export function formatError(message: string): string { + return `${colors.delete}Error:${colors.reset} ${message}`; +} diff --git a/home/executable_dot_bash_aliases.tmpl b/home/executable_dot_bash_aliases.tmpl index 0fd37b3..1b544f0 100644 --- a/home/executable_dot_bash_aliases.tmpl +++ b/home/executable_dot_bash_aliases.tmpl @@ -29,10 +29,6 @@ alias cha='chezmoi apply' alias chai='chezmoi apply --interactive' alias ch='chezmoi' alias cdc='chezmoi cd' -chshow() { - target=$(find {{ .chezmoi.sourceDir | quote }} -name "*.tmpl" -type f | fzf) - cat $target | chezmoi execute-template -} # Remote Management alias romanlog="ssh roman 'tail -F /var/log/syslog' --lines 100" @@ -158,3 +154,41 @@ alias gsts='git stash save' # Git log find by commit message function glf() { git log --all --grep="$1"; } + +# fzf abbreviation/alias search +if command -v fzf-abbr-search.ts &> /dev/null && command -v fzf &> /dev/null; then + fzf_search_abbr() { + local result key selected + result=$(fzf-abbr-search.ts | fzf \ + --ansi \ + --height=50% \ + --reverse \ + --delimiter=$'\t' \ + --with-nth=4 \ + --nth=1,2 \ + --prompt='Aliases > ' \ + --preview='echo {2}' \ + --preview-window=up:3:wrap \ + --expect='tab' \ + --header='Enter: insert name | Tab: insert expansion') + + if [ -n "$result" ]; then + # First line is key, second line is selected + key=$(echo "$result" | head -n1) + selected=$(echo "$result" | tail -n1) + + if [ -n "$selected" ]; then + if [ "$key" = "tab" ]; then + # Insert expansion (field 2) + READLINE_LINE="${READLINE_LINE:0:$READLINE_POINT}$(echo "$selected" | cut -f2)${READLINE_LINE:$READLINE_POINT}" + else + # Insert name (field 1) + READLINE_LINE="${READLINE_LINE:0:$READLINE_POINT}$(echo "$selected" | cut -f1)${READLINE_LINE:$READLINE_POINT}" + fi + fi + fi + } + + # Bind to Alt+A + bind -x '"\ea": fzf_search_abbr' +fi