mirror of
https://github.com/Xevion/dotfiles.git
synced 2026-01-31 02:24:11 -06:00
feat: add interactive fzf tools for chezmoi apply/show with shared utilities
- New chai function for interactive apply with multi-select and diff preview - Enhanced chshow for browsing managed files with edit/view modes - Shared fzf-utils.ts with standardized colors and chezmoi file parsing - Bash version of fzf abbreviation search with Alt+A binding
This commit is contained in:
@@ -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
|
||||||
@@ -1,6 +1,41 @@
|
|||||||
function chshow --description "Show rendered chezmoi template via fzf"
|
function chshow --description "Browse chezmoi managed files with fzf preview"
|
||||||
set -l target (find {{ .chezmoi.sourceDir | quote }} -name "*.tmpl" -type f | fzf)
|
# Get data from script first (before fzf takes over TTY)
|
||||||
if test -n "$target"
|
set -l data (fzf-chezmoi-show.ts)
|
||||||
cat $target | chezmoi execute-template
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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<string, { label: string; color: string; action: StatusEntry["action"] }> = {
|
||||||
|
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<StatusEntry[]> {
|
||||||
|
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<Map<string, string>> {
|
||||||
|
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<string, string>();
|
||||||
|
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();
|
||||||
@@ -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<typeof parseSourceFile>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getManagedFiles(): Promise<ManagedFile[]> {
|
||||||
|
// 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();
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -29,10 +29,6 @@ alias cha='chezmoi apply'
|
|||||||
alias chai='chezmoi apply --interactive'
|
alias chai='chezmoi apply --interactive'
|
||||||
alias ch='chezmoi'
|
alias ch='chezmoi'
|
||||||
alias cdc='chezmoi cd'
|
alias cdc='chezmoi cd'
|
||||||
chshow() {
|
|
||||||
target=$(find {{ .chezmoi.sourceDir | quote }} -name "*.tmpl" -type f | fzf)
|
|
||||||
cat $target | chezmoi execute-template
|
|
||||||
}
|
|
||||||
|
|
||||||
# Remote Management
|
# Remote Management
|
||||||
alias romanlog="ssh roman 'tail -F /var/log/syslog' --lines 100"
|
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
|
# Git log find by commit message
|
||||||
function glf() { git log --all --grep="$1"; }
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user