diff --git a/configs/shared/fzf.zsh b/configs/shared/fzf.zsh new file mode 100644 index 0000000..5f97a30 --- /dev/null +++ b/configs/shared/fzf.zsh @@ -0,0 +1,33 @@ +# FZF +# Note: home-directory branch returns nothing (no empty line) to avoid +# cluttering the picker when running fzf from $HOME. +export FZF_DEFAULT_COMMAND=' + if [[ "$PWD" != "$HOME" ]]; then + fd --type d --hidden --follow \ + --base-directory . \ + --exclude node_modules --exclude .git --exclude dist --exclude output --exclude tmp \ + 2>/dev/null; + fd --type f --hidden --follow \ + --base-directory . \ + --exclude node_modules --exclude .git --exclude dist --exclude output --exclude tmp \ + 2>/dev/null + fi +' + +export FZF_CTRL_T_COMMAND=$FZF_DEFAULT_COMMAND + +export FZF_CTRL_T_OPTS=" + --height 100% + --header '[C-/] toggle preview | [Alt-j/k] scroll preview' + --preview 'if [ -f {} ]; then + bat --color=always --style=plain --line-range :300 {}; + elif [ -d {} ]; then + eza -L 2 -T --git-ignore {} 2>/dev/null | head -20; + fi' + --preview-window=right:50%:wrap + --bind 'ctrl-/:toggle-preview' + --bind 'alt-j:preview-down' + --bind 'alt-k:preview-up' + --bind 'ctrl-d:preview-page-down' + --bind 'ctrl-u:preview-page-up' +" diff --git a/configs/shared/prompt.zsh b/configs/shared/prompt.zsh index 54a40a2..685ee2c 100644 --- a/configs/shared/prompt.zsh +++ b/configs/shared/prompt.zsh @@ -1,5 +1,9 @@ # ============================================================================ -# Prompt / theme loading +# Prompt / theme loading — must run last # ============================================================================ +# For zinit-based setups: load p10k as the final plugin so it wraps everything +(( ${+functions[zinit]} )) && { zinit ice depth"1"; zinit light romkatv/powerlevel10k; } + +# Apply p10k configuration [[ -f ~/.p10k.zsh ]] && source ~/.p10k.zsh diff --git a/configs/shared/tools.zsh b/configs/shared/tools.zsh index fdd59b4..900db55 100644 --- a/configs/shared/tools.zsh +++ b/configs/shared/tools.zsh @@ -2,39 +2,8 @@ # External tool configuration and initialization # ============================================================================ -# FZF -# Note: home-directory branch returns nothing (no empty line) to avoid -# cluttering the picker when running fzf from $HOME. -export FZF_DEFAULT_COMMAND=' - if [[ "$PWD" != "$HOME" ]]; then - fd --type d --hidden --follow \ - --base-directory . \ - --exclude node_modules --exclude .git --exclude dist --exclude output --exclude tmp \ - 2>/dev/null; - fd --type f --hidden --follow \ - --base-directory . \ - --exclude node_modules --exclude .git --exclude dist --exclude output --exclude tmp \ - 2>/dev/null - fi -' - -export FZF_CTRL_T_COMMAND=$FZF_DEFAULT_COMMAND - -export FZF_CTRL_T_OPTS=" - --height 100% - --header '[C-/] toggle preview | [Alt-j/k] scroll preview' - --preview 'if [ -f {} ]; then - bat --color=always --style=plain --line-range :300 {}; - elif [ -d {} ]; then - eza -L 2 -T --git-ignore {} 2>/dev/null | head -20; - fi' - --preview-window=right:50%:wrap - --bind 'ctrl-/:toggle-preview' - --bind 'alt-j:preview-down' - --bind 'alt-k:preview-up' - --bind 'ctrl-d:preview-page-down' - --bind 'ctrl-u:preview-page-up' -" +# FZF configuration (env vars for fd-based search and preview bindings) +[[ -f "${ZDOTDIR:-$HOME/.config/zsh}/shared/fzf.zsh" ]] && source "${ZDOTDIR:-$HOME/.config/zsh}/shared/fzf.zsh" _zsh_tools_cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/zsh" [[ -d "$_zsh_tools_cache_dir" ]] || mkdir -p "$_zsh_tools_cache_dir" diff --git a/configs/zinit-plugins b/configs/zinit-plugins index 9e8bac3..5953007 100644 --- a/configs/zinit-plugins +++ b/configs/zinit-plugins @@ -2,7 +2,5 @@ zinit load 'zsh-users/zsh-autosuggestions' zinit load 'zsh-users/zsh-syntax-highlighting' -zinit ice depth"1" -zinit light romkatv/powerlevel10k # <<< suitup zinit-plugins <<< diff --git a/src/append.js b/src/append.js index f916cfa..132630b 100644 --- a/src/append.js +++ b/src/append.js @@ -108,10 +108,8 @@ const BLOCKS = [ group: "Advanced", marker: "suitup/fzf-config", apply() { - const toolsContent = readFileSync(join(CONFIGS_DIR, "shared", "tools.zsh"), "utf-8"); - // Extract only FZF-related config (everything before "# Tool initialization") - const fzfPart = toolsContent.split("# Tool initialization")[0].trim(); - const block = `\n# >>> suitup/fzf-config >>>\n${fzfPart}\n# <<< suitup/fzf-config <<<\n`; + const fzfContent = readFileSync(join(CONFIGS_DIR, "shared", "fzf.zsh"), "utf-8"); + const block = `\n# >>> suitup/fzf-config >>>\n${fzfContent.trim()}\n# <<< suitup/fzf-config <<<\n`; return appendIfMissing(ZSHRC, block, "suitup/fzf-config"); }, }, diff --git a/src/steps/zsh-config.js b/src/steps/zsh-config.js index 9ef2529..3740f71 100644 --- a/src/steps/zsh-config.js +++ b/src/steps/zsh-config.js @@ -29,7 +29,7 @@ export async function setupZshConfig({ home } = {}) { } // Copy shared configs (skip if already exist) - const sharedFiles = ["tools.zsh", "prompt.zsh"]; + const sharedFiles = ["tools.zsh", "fzf.zsh", "prompt.zsh"]; for (const file of sharedFiles) { const copied = copyIfNotExists(join(CONFIGS_DIR, "shared", file), join(zshConfig, "shared", file)); if (!copied) p.log.info(`Skipped shared/${file} (already exists)`); diff --git a/tests/append.test.js b/tests/append.test.js index 61d9e62..748eda9 100644 --- a/tests/append.test.js +++ b/tests/append.test.js @@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from "no import { join } from "node:path"; import { tmpdir } from "node:os"; import { appendIfMissing, ensureDir, readFileSafe } from "../src/utils/fs.js"; +import { CONFIGS_DIR } from "../src/constants.js"; describe("Append mode utilities", () => { let sandbox; @@ -86,3 +87,72 @@ describe("Append mode utilities", () => { expect(content).toContain("# base"); }); }); + +describe("fzf-config block", () => { + let sandbox; + let zshrcPath; + + beforeEach(() => { + sandbox = mkdtempSync(join(tmpdir(), "suitup-test-")); + zshrcPath = join(sandbox, ".zshrc"); + }); + + afterEach(() => { + rmSync(sandbox, { recursive: true, force: true }); + }); + + test("configs/shared/fzf.zsh exists as a standalone file", () => { + const fzfFile = join(CONFIGS_DIR, "shared", "fzf.zsh"); + expect(existsSync(fzfFile)).toBe(true); + }); + + test("fzf.zsh contains expected FZF environment variables", () => { + const fzfContent = readFileSync(join(CONFIGS_DIR, "shared", "fzf.zsh"), "utf-8"); + expect(fzfContent).toContain("FZF_DEFAULT_COMMAND"); + expect(fzfContent).toContain("FZF_CTRL_T_COMMAND"); + expect(fzfContent).toContain("FZF_CTRL_T_OPTS"); + }); + + test("fzf.zsh does not contain tool-init cache helpers (those belong in tools.zsh)", () => { + const fzfContent = readFileSync(join(CONFIGS_DIR, "shared", "fzf.zsh"), "utf-8"); + expect(fzfContent).not.toContain("_source_cached_tool_init"); + expect(fzfContent).not.toContain("_zsh_tools_cache_dir"); + }); + + test("tools.zsh no longer embeds FZF env vars directly", () => { + const toolsContent = readFileSync(join(CONFIGS_DIR, "shared", "tools.zsh"), "utf-8"); + expect(toolsContent).not.toContain("FZF_DEFAULT_COMMAND="); + expect(toolsContent).not.toContain("FZF_CTRL_T_OPTS="); + }); + + test("fzf-config block appends fzf.zsh content directly (no brittle string splitting)", () => { + writeFileSync(zshrcPath, "# base\n", "utf-8"); + + const fzfContent = readFileSync(join(CONFIGS_DIR, "shared", "fzf.zsh"), "utf-8").trim(); + const block = `\n# >>> suitup/fzf-config >>>\n${fzfContent}\n# <<< suitup/fzf-config <<<\n`; + + const result = appendIfMissing(zshrcPath, block, "suitup/fzf-config"); + + expect(result).toBe(true); + const written = readFileSync(zshrcPath, "utf-8"); + expect(written).toContain("FZF_DEFAULT_COMMAND"); + expect(written).toContain("FZF_CTRL_T_OPTS"); + expect(written).toContain("# >>> suitup/fzf-config >>>"); + expect(written).toContain("# <<< suitup/fzf-config <<<"); + }); + + test("fzf-config block is idempotent — double append does not duplicate", () => { + writeFileSync(zshrcPath, "# base\n", "utf-8"); + + const fzfContent = readFileSync(join(CONFIGS_DIR, "shared", "fzf.zsh"), "utf-8").trim(); + const block = `\n# >>> suitup/fzf-config >>>\n${fzfContent}\n# <<< suitup/fzf-config <<<\n`; + + appendIfMissing(zshrcPath, block, "suitup/fzf-config"); + appendIfMissing(zshrcPath, block, "suitup/fzf-config"); + + const written = readFileSync(zshrcPath, "utf-8"); + const matches = written.match(/suitup\/fzf-config/g); + // Should appear exactly twice (open + close marker), not four + expect(matches.length).toBe(2); + }); +}); diff --git a/tests/configs.test.js b/tests/configs.test.js index c40111c..cd74016 100644 --- a/tests/configs.test.js +++ b/tests/configs.test.js @@ -56,7 +56,19 @@ describe("Static config templates", () => { expect(content).toContain("zinit"); expect(content).toContain("zsh-autosuggestions"); expect(content).toContain("zsh-syntax-highlighting"); + // p10k is loaded last in prompt.zsh, not here + expect(content).not.toContain("powerlevel10k"); + }); + + test("shared/prompt.zsh loads p10k theme last (after all other plugins)", () => { + const content = readFileSync(join(CONFIGS_DIR, "shared", "prompt.zsh"), "utf-8"); expect(content).toContain("powerlevel10k"); + expect(content).toContain("zinit light romkatv/powerlevel10k"); + expect(content).toContain("~/.p10k.zsh"); + // p10k theme load must appear before .p10k.zsh source + const themeIdx = content.indexOf("zinit light romkatv/powerlevel10k"); + const configIdx = content.indexOf("~/.p10k.zsh"); + expect(themeIdx).toBeLessThan(configIdx); }); test("config.vim file exists", () => { @@ -70,7 +82,7 @@ describe("Static config templates", () => { }); test("shared config files exist", () => { - for (const file of ["tools.zsh", "prompt.zsh"]) { + for (const file of ["tools.zsh", "prompt.zsh", "fzf.zsh"]) { expect(existsSync(join(CONFIGS_DIR, "shared", file))).toBe(true); } }); @@ -94,13 +106,20 @@ describe("Static config templates", () => { } }); - test("shared/tools.zsh uses fd instead of rg for FZF_DEFAULT_COMMAND", () => { + test("shared/fzf.zsh uses fd instead of rg for FZF_DEFAULT_COMMAND", () => { + const fzfContent = readFileSync(join(CONFIGS_DIR, "shared", "fzf.zsh"), "utf-8"); + expect(fzfContent).toContain("fd --type"); + expect(fzfContent).toContain("FZF_DEFAULT_COMMAND"); + expect(fzfContent).toContain("FZF_CTRL_T_COMMAND"); + expect(fzfContent).toContain("FZF_CTRL_T_OPTS"); + }); + + test("shared/tools.zsh contains tool-init helpers and sources fzf.zsh", () => { const content = readFileSync(join(CONFIGS_DIR, "shared", "tools.zsh"), "utf-8"); - expect(content).toContain("fd --type"); expect(content).toContain("fnm"); expect(content).toContain("atuin"); expect(content).toContain("zoxide"); - expect(content).toContain("command -v"); + expect(content).toContain("fzf.zsh"); }); test("all .zsh config files pass syntax check", () => { @@ -110,6 +129,7 @@ describe("Static config templates", () => { "core/paths.zsh", "core/options.zsh", "shared/tools.zsh", + "shared/fzf.zsh", "shared/prompt.zsh", "local/machine.zsh", ]; @@ -144,6 +164,7 @@ describe("Static config templates", () => { "core/paths.zsh", "core/options.zsh", "shared/tools.zsh", + "shared/fzf.zsh", "shared/prompt.zsh", "zshrc.template", "zshrc-omz.template", diff --git a/tests/setup.test.js b/tests/setup.test.js index 25b6f11..e61f94b 100644 --- a/tests/setup.test.js +++ b/tests/setup.test.js @@ -101,6 +101,13 @@ describe("Setup simulation in sandbox", () => { expect(content).toContain("suitup/aliases"); expect(content).toContain("shared/prompt.zsh"); expect(content).toContain("_zsh_report"); + + // prompt.zsh (which loads p10k last) must come after zinit-plugins + const pluginsIdx = content.indexOf("suitup/zinit-plugins"); + const promptIdx = content.indexOf("shared/prompt.zsh"); + const reportIdx = content.indexOf("_zsh_report"); + expect(pluginsIdx).toBeLessThan(promptIdx); + expect(promptIdx).toBeLessThan(reportIdx); }); test("omz template has Oh My Zsh structure", () => { diff --git a/tests/zsh-config-steps.test.js b/tests/zsh-config-steps.test.js index 5a719b7..7095e62 100644 --- a/tests/zsh-config-steps.test.js +++ b/tests/zsh-config-steps.test.js @@ -39,6 +39,7 @@ describe("zsh-config step", () => { expect(existsSync(join(sandbox.path, ".config", "zsh", "core", "paths.zsh"))).toBe(true); expect(existsSync(join(sandbox.path, ".config", "zsh", "core", "options.zsh"))).toBe(true); expect(existsSync(join(sandbox.path, ".config", "zsh", "shared", "tools.zsh"))).toBe(true); + expect(existsSync(join(sandbox.path, ".config", "zsh", "shared", "fzf.zsh"))).toBe(true); expect(existsSync(join(sandbox.path, ".config", "zsh", "shared", "prompt.zsh"))).toBe(true); expect(existsSync(join(sandbox.path, ".config", "zsh", "local", "machine.zsh"))).toBe(true); }); @@ -48,11 +49,14 @@ describe("zsh-config step", () => { const perf = readFileSync(join(sandbox.path, ".config", "zsh", "core", "perf.zsh"), "utf-8"); const tools = readFileSync(join(sandbox.path, ".config", "zsh", "shared", "tools.zsh"), "utf-8"); + const fzf = readFileSync(join(sandbox.path, ".config", "zsh", "shared", "fzf.zsh"), "utf-8"); expect(perf).toContain("EPOCHREALTIME"); expect(perf).toContain("_record_stage_duration"); expect(tools).toContain("_source_cached_tool_init"); expect(tools).toContain("$_zsh_tools_cache_dir"); + expect(fzf).toContain("FZF_DEFAULT_COMMAND"); + expect(fzf).toContain("FZF_CTRL_T_OPTS"); }); test("skips existing config files without overwriting", async () => {