diff --git a/.TOGGLES.md b/.TOGGLES.md new file mode 100644 index 0000000..1394cf1 --- /dev/null +++ b/.TOGGLES.md @@ -0,0 +1,75 @@ +# Toggle Features Documentation + +## Overview + +cmdk now supports two independent toggle features that work seamlessly together: + +### **Ctrl+T: Environment File Visibility** +- **State File**: `/tmp/cmdk_env_toggle_${USER}` +- **Default**: OFF (hides `.env`, `.gitignore`, etc.) +- **When ON**: Shows files that would normally be gitignored +- **Use Case**: Quick access to configuration files when needed + +### **Ctrl+G: Git Filter Toggle** +- **State File**: `/tmp/cmdk_git_toggle_${USER}` +- **Default**: OFF (shows all files) +- **When ON**: Shows only git-changed files (modified, staged, untracked) +- **Use Case**: Focus on work-in-progress files +- **Fallback**: Returns to normal file list if not in a git repository + +## How They Work Together + +The toggles are **independent** and can be combined: + +| Ctrl+T | Ctrl+G | Result | +|--------|--------|--------| +| OFF | OFF | All files (excluding gitignored by default) | +| ON | OFF | All files + gitignored files | +| OFF | ON | Git-changed files only | +| ON | ON | Git-changed files + any .env/.gitignored among them | + +## Implementation Details + +### State Management +- Each toggle maintains its own persistent state file in `/tmp/` +- State persists across multiple cmdk invocations during a session +- Automatically cleaned up when cmdk exits + +### Script Architecture +``` +cmdk-core.sh + └── Uses: reload-files.sh + ├── Checks: actions/toggle-state.sh (Ctrl+T) + ├── Checks: actions/git-toggle-state.sh (Ctrl+G) + ├── If Ctrl+G ON → runs: git-files.sh + └── If Ctrl+G OFF → runs: reload-with-toggle.sh + └── Respects: actions/toggle-state.sh (Ctrl+T) +``` + +### Error Handling +- If not in a git repository, Ctrl+G gracefully falls back to normal file list +- Both toggles fail silently and continue normal operation +- No breaking errors or messages to disrupt user experience + +## Testing the Toggles + +```bash +# Test Ctrl+T (env visibility) +bash actions/toggle-state.sh init off +bash reload-files.sh -o | grep -i env # Should NOT show .env + +bash actions/toggle-state.sh init on +bash reload-files.sh -o | grep -i env # Should show .env + +# Test Ctrl+G (git filter) +bash actions/git-toggle-state.sh init off +bash reload-files.sh -o | head -10 # Shows all files + +bash actions/git-toggle-state.sh init on +bash reload-files.sh -o # Shows only git-changed files +``` + +## Known Limitations +- Ctrl+G only works in git repositories (gracefully falls back) +- State files are per-user, not per-repository +- Toggles reset at end of each cmdk invocation diff --git a/.gitignore b/.gitignore index 6f72f89..5d7c448 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ go.work.sum # env file .env + +# IDE/Editor config +.claude diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 0000000..270dae3 --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,155 @@ +# Codebase Concerns + +**Analysis Date:** 2026-02-06 +**Last Updated:** 2026-02-06 + +## Tech Debt + +**~~No automated testing framework:~~** ✅ RESOLVED +- Fix: Added BATS test suite in `test/` with 21 tests covering list-files, git-files, toggle-state, and preview scripts + +**Shell compatibility concerns:** +- Issue: Scripts need to work across Bash and Fish shells +- Files: `cmdk-core.sh`, `cmdk.sh`, `cmdk.fish` +- Impact: Portability issues, different feature sets between shells +- Fix approach: Define minimum shell version requirements, add CI tests for both shells +- Status: Shebang mismatches fixed (`list-files.sh` and `preview.sh` now correctly use `#!/usr/bin/env bash`) + +**~~No error handling standardization:~~** ✅ PARTIALLY RESOLVED +- Fix: Added dependency checks for required tools (`fzf`, `fd`, `file`) in `cmdk-core.sh` +- Fix: Added flag validation with clear error messages for unknown flags +- Fix: Added `trap` cleanup in `cmdk-core.sh` for guaranteed state cleanup on exit/error +- Fix: Fixed exit code capture bug (was capturing cleanup exit code, not fzf) +- Fix: Fixed `return` in non-sourced script → `exit 1` +- Remaining: Could add a shared error handling library if more scripts are added + +**~~Missing ShellCheck integration:~~** ✅ ALREADY RESOLVED +- ShellCheck CI already exists in `.github/workflows/shellcheck.yml` +- All scripts now pass ShellCheck cleanly + +## Known Bugs + +**~~Exit code capture bug in cmdk-core.sh:~~** ✅ FIXED +- Was: `exit_code=$?` captured cleanup exit code, not fzf's exit code +- Fix: Exit code now captured immediately after fzf with `|| exit_code=$?` + +**~~`return` used in non-sourced script:~~** ✅ FIXED +- Was: `return` in `cmdk-core.sh` which is invoked via `bash`, not sourced +- Fix: Changed to `exit 1` + +**~~Home excludes not applied in list-files.sh:~~** ✅ FIXED +- Was: `home_exclude_args` was computed but never passed to the `fd` call when in HOME +- Fix: Now conditionally included in fd invocation when `PWD == HOME` + +**~~Incorrect if-then-else in reload-files.sh:~~** ✅ FIXED +- Was: `A && B || C` pattern which is not equivalent to if-then-else +- Fix: Replaced with proper `if/then/else/fi` structure + +## Security Considerations + +**Unquoted variables in scripts:** +- Risk: Word splitting and glob expansion vulnerabilities +- Files: `list-files.sh` (intentional word-splitting for fd args, documented with shellcheck directives) +- Current mitigation: All intentional word-splitting annotated with `# shellcheck disable=SC2086` +- Status: Reviewed and acceptable for controlled internal values + +**~~No input validation:~~** ✅ RESOLVED +- Fix: Added flag validation in `cmdk-core.sh` — only `-o`, `-s`, `-e` accepted +- Fix: Unknown flags now produce clear error and exit 1 +- Fix: Validated flags used instead of raw `$*` in fzf commands + +**Environment variable parsing:** +- Risk: Uncontrolled environment variables could affect behavior +- Files: `.env` parsing in core +- Current mitigation: .env file is local only +- Recommendations: Validate/whitelist environment variables + +## Performance Bottlenecks + +**File discovery inefficiency:** +- Problem: `list-files.sh` may scan entire directory trees repeatedly +- Files: `list-files.sh`, `cmdk-core.sh` +- Cause: No caching of file listings +- Improvement path: Add incremental file caching, use git for tracking changes + +**Script sourcing overhead:** +- Problem: Each invocation sources multiple files +- Files: All shell entry points +- Cause: No pre-compilation, full parsing on each run +- Improvement path: Consider compiled shell (shc) for production, profile hot paths + +## Fragile Areas + +**Shell compatibility layer:** +- Files: `cmdk.sh`, `cmdk.fish`, `cmdk-core.sh` +- Why fragile: Different shells have different semantics, behavior divergence +- Safe modification: Create comprehensive tests before changing core +- Test coverage: BATS tests now cover core scripts; cross-shell integration tests still needed + +**Action routing system:** +- Files: `cmdk-core.sh` (action dispatch logic) +- Why fragile: Central routing point, affects all commands +- Safe modification: Write integration tests first, test all actions after changes +- Test coverage: Toggle state tests added; full dispatch testing requires fzf stubbing + +**File discovery utilities:** +- Files: `list-files.sh`, `git-files.sh` +- Why fragile: Depends on specific Unix tools (find, git) +- Safe modification: Dependency checks now added in `cmdk-core.sh` +- Test coverage: BATS tests cover basic scenarios, edge cases (spaces, special chars) + +## Scaling Limits + +**Script interpretation overhead:** +- Current capacity: Suitable for small to medium CLI usage +- Limit: May become slow with very large file sets or deep nesting +- Scaling path: Profile hot paths, consider compiled versions or faster language + +**Memory usage in large operations:** +- Current capacity: Should be fine for typical usage +- Limit: Loading entire file lists into memory could be issue with huge projects +- Scaling path: Implement streaming/incremental processing + +## Dependencies at Risk + +**~~Git dependency (silent failure):~~** ✅ PARTIALLY RESOLVED +- Fix: `git-files.sh` already exits non-zero when not in git repo +- Fix: `reload-files.sh` now uses proper if/then/else for git fallback +- Remaining: Could add `git` to dependency checks if git features are required + +**Unix utilities:** +- Risk: Depends on find, grep, sed, etc. +- Impact: Breaks on systems without standard Unix tools (minimal containers, Windows WSL) +- Migration plan: `cmdk-core.sh` now checks for `fzf`, `fd`, `file` at startup +- Status: `preview.sh` now has fallbacks for optional tools (`bat`→`cat`, `tiv`, `pdftotext`, `unzip`) + +## Missing Critical Features + +None identified at this scope level. + +## Test Coverage Gaps + +**~~Core dispatcher logic:~~** ✅ PARTIALLY RESOLVED +- Added: Toggle state tests, list-files tests, git-files tests, preview tests +- Remaining: Full fzf interaction testing would require fzf stubbing + +**Shell-specific integration:** +- What's not tested: Bash-specific vs Fish-specific behavior +- Files: `cmdk.sh`, `cmdk.fish` +- Risk: Commands might work in one shell but not the other +- Priority: High + +**~~Error conditions:~~** ✅ PARTIALLY RESOLVED +- Added: Tests for invalid toggle commands, non-git directory handling +- Remaining: Missing file, permission error, missing tool scenarios +- Priority: Medium + +**~~Edge cases in file operations:~~** ✅ RESOLVED +- Added: Tests for files with spaces and special characters +- Files: `test/list-files.bats` +- Priority: Medium + +--- + +*Concerns audit: 2026-02-06* +*Last fix pass: 2026-02-06* diff --git a/README.md b/README.md index 62dd261..8904f6b 100644 --- a/README.md +++ b/README.md @@ -41,21 +41,21 @@ Installation source ~/.cmdk/cmdk.fish ``` 4. (Optional) Bind the `⌘-k` hotkey (or any other if you prefer) to send the text `cmdk\n` in your terminal: -
- 💻 iTerm - - `Settings → Profiles → Keys → Keybindings → + → Send Text`, then binding `⌘-k` to send the text `cmdk\n` - -
-
- 👻 Ghostty - - ``` - # ~/.config/ghostty/config (or $XDG_CONFIG_HOME/ghostty/config) - keybind = cmd+k=text:cmdk\r - ``` - -
+
+ 💻 iTerm + + `Settings → Profiles → Keys → Keybindings → + → Send Text`, then binding `⌘-k` to send the text `cmdk\n` + +
+
+ 👻 Ghostty + + ``` + # ~/.config/ghostty/config (or $XDG_CONFIG_HOME/ghostty/config) + keybind = cmd+k=text:cmdk\r + ``` + +
5. Open a new shell and press your hotkey (⌘-K if you bound it) or enter `cmdk` (if you don't have a hotkey) 6. (Optional) If you'd like to use `cmdk`'s functionality with `fzf`'s , add the following to your `.bashrc` or `.zshrc`: ``` @@ -74,6 +74,8 @@ Press ⌘-k (or type `cmdk`) and... - `ENTER` to select the result - `TAB` to select multiple items before `ENTER` - `Ctrl-u` to clear the selection +- `Ctrl-t` to toggle visibility of gitignored files (like `.env`) +- `Ctrl-g` to toggle between all files and git-changed files only (modified, staged, untracked) > ⚠️ Some directories like `Library`, `/`, and `.git` are full of stuff users don't need to access, so their contents are excluded. To get to their contents, first ⌘-k to them and then ⌘-k again to see their contents. @@ -83,13 +85,64 @@ Press ⌘-k (or type `cmdk`) and... - `-o` - Only list the contents of the current directory at depth 1 (original behavior) - `-s` - List all contents of the current directory recursively, including subdirectories +- `-e` - Show hidden files that are typically excluded by `.gitignore` (including `.env` files) + +### Editor Configuration + +By default, cmdk opens text files using your `$EDITOR` environment variable, or falls back to `vim -O` if unset. You can configure any editor: + +**Neovim:** +```bash +export EDITOR=nvim +``` + +**Cursor:** +```bash +export EDITOR=cursor +``` + +**VS Code:** +```bash +export EDITOR="code -w" +``` + +**Neovim with vertical splits for multiple files:** +```bash +export EDITOR="nvim -O" +``` + +**For Fish shell users**, add to `~/.config/fish/config.fish`: +```fish +set -gx EDITOR nvim +``` + +**For Bash/Zsh users**, add to `~/.bashrc` or `~/.zshrc`: +```bash +export EDITOR=nvim +``` Feedback -------- Hi HN! I'd love to hear how you're using cmdk, and making it your own. +Testing +------- +cmdk uses [BATS](https://github.com/bats-core/bats-core) (Bash Automated Testing System) for automated tests. + +```sh +brew install bats-core # if not already installed +bats test/ +``` + +Test files: +- `test/list-files.bats` — file discovery, depth modes, spaces/special chars, exclude dirs +- `test/git-files.bats` — git-changed file detection, deduplication, non-git fallback +- `test/toggle-state.bats` — toggle init, flip, get, cleanup +- `test/preview.bats` — text/directory/HOME preview + +For manual testing across shells (bash, zsh, fish), see [testing-checklist.md](testing-checklist.md). + TODO ---- -- [Allow customizing the program used to open files](https://github.com/mieubrisse/cmdk/issues/4) - [Allow for favoriting files that pop to the top of the search](https://github.com/mieubrisse/cmdk/issues/5) - [Store the results of a selection in the history](https://github.com/mieubrisse/cmdk/issues/1) diff --git a/actions/git-toggle-state.sh b/actions/git-toggle-state.sh new file mode 100755 index 0000000..f9ab4f8 --- /dev/null +++ b/actions/git-toggle-state.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +# Manage git filter toggle state (same pattern as toggle-state.sh) + +set -euo pipefail + +STATE_FILE="/tmp/cmdk_git_toggle_${USER}" + +case "${1:-}" in + "get") + if [ -f "$STATE_FILE" ]; then + cat "$STATE_FILE" + else + echo "off" + fi + ;; + "toggle") + if [ -f "$STATE_FILE" ]; then + current_state="$(cat "$STATE_FILE")" + else + current_state="off" + fi + if [ "$current_state" = "on" ]; then + echo "off" > "$STATE_FILE" + echo "off" + else + echo "on" > "$STATE_FILE" + echo "on" + fi + ;; + "init") + # Initialize with given state or default to off + state="${2:-off}" + echo "$state" > "$STATE_FILE" + echo "$state" + ;; + "cleanup") + rm -f "$STATE_FILE" + ;; + *) + echo "Usage: $0 {get|toggle|init [on|off]|cleanup}" >&2 + exit 1 + ;; +esac diff --git a/actions/toggle-state.sh b/actions/toggle-state.sh new file mode 100755 index 0000000..96ecc7b --- /dev/null +++ b/actions/toggle-state.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +set -euo pipefail + +STATE_FILE="/tmp/cmdk_env_toggle_${USER}" + +case "${1:-}" in + "get") + if [ -f "$STATE_FILE" ]; then + cat "$STATE_FILE" + else + echo "off" + fi + ;; + "toggle") + if [ -f "$STATE_FILE" ]; then + current_state="$(cat "$STATE_FILE")" + else + current_state="off" + fi + if [ "$current_state" = "on" ]; then + echo "off" > "$STATE_FILE" + echo "off" + else + echo "on" > "$STATE_FILE" + echo "on" + fi + ;; + "init") + # Initialize with given state or default to off + state="${2:-off}" + echo "$state" > "$STATE_FILE" + echo "$state" + ;; + "cleanup") + rm -f "$STATE_FILE" + ;; + *) + echo "Usage: $0 {get|toggle|init [on|off]|cleanup}" >&2 + exit 1 + ;; +esac \ No newline at end of file diff --git a/cmdk-core.sh b/cmdk-core.sh old mode 100644 new mode 100755 index 6b10095..08f432f --- a/cmdk-core.sh +++ b/cmdk-core.sh @@ -7,32 +7,80 @@ # and fish-compatible :S set -euo pipefail + +for cmd in fzf fd file; do + if ! command -v "${cmd}" >/dev/null 2>&1; then + echo "Error: '${cmd}' is required but not found. Please install it first." >&2 + exit 1 + fi +done + script_dirpath="$(cd "$(dirname "${0}")" && pwd)" +validated_flags=() +while [ $# -gt 0 ]; do + case "$1" in + -o|-s|-e) + validated_flags+=("$1") + shift + ;; + *) + echo "Error: Unknown flag '$1'. Allowed flags: -o, -s, -e" >&2 + exit 1 + ;; + esac +done + +flags_str="${validated_flags[*]:-}" + output_paths=() +# Initialize toggle states based on -e flag +if echo "${flags_str}" | grep -q '\-e'; then + bash "${script_dirpath}/actions/toggle-state.sh" init on >/dev/null +else + bash "${script_dirpath}/actions/toggle-state.sh" init off >/dev/null +fi +bash "${script_dirpath}/actions/git-toggle-state.sh" init off >/dev/null + +# Use a temporary file instead of process substitution for better shell compatibility +temp_output_file="$(mktemp)" + +cleanup() { + rm -f "${temp_output_file}" + bash "${script_dirpath}/actions/toggle-state.sh" cleanup >/dev/null 2>&1 || true + bash "${script_dirpath}/actions/git-toggle-state.sh" cleanup >/dev/null 2>&1 || true +} +trap cleanup EXIT + +# EXPLANATION: +# -m allows multiple selections +# --ansi tells fzf to parse the ANSI color codes that we're generating with fd +# --scheme=path optimizes for path-based input +# --with-nth allows us to use the custom sorting mechanism +# --bind='ctrl-i:...' adds Ctrl+I to toggle .env visibility +FZF_DEFAULT_COMMAND="bash ${script_dirpath}/reload-files.sh ${flags_str}" fzf \ + -m \ + --ansi \ + --bind='change:top' \ + --bind="ctrl-t:reload(bash ${script_dirpath}/actions/toggle-state.sh toggle >/dev/null && bash ${script_dirpath}/reload-files.sh ${flags_str})" \ + --bind="ctrl-g:reload(bash ${script_dirpath}/actions/git-toggle-state.sh toggle >/dev/null && bash ${script_dirpath}/reload-files.sh ${flags_str})" \ + --scheme=path \ + --preview="bash ${script_dirpath}/preview.sh {}" > "${temp_output_file}" || exit_code=$? +exit_code=${exit_code:-0} + +if [ "$exit_code" -ne 0 ]; then + exit 1 +fi + while IFS="" read -r line; do # IFS="" -> no splitting (we may have paths with spaces) output_paths+=("${line}") -done < <( - # EXPLANATION: - # -m allows multiple selections - # --ansi tells fzf to parse the ANSI color codes that we're generating with fd - # --scheme=path optimizes for path-based input - # --with-nth allows us to use the custom sorting mechanism - FZF_DEFAULT_COMMAND="bash ${script_dirpath}/list-files.sh ${*}" fzf \ - -m \ - --ansi \ - --bind='change:top' \ - --scheme=path \ - --preview="bash ${script_dirpath}/preview.sh {}" - if [ "${?}" -ne 0 ]; then - return - fi -) +done < "${temp_output_file}" dirs=() text_files=() open_targets=() +if [ "${#output_paths[@]}" -gt 0 ]; then for output in "${output_paths[@]}"; do case "${output}" in HOME) @@ -65,11 +113,14 @@ for output in "${output_paths[@]}"; do ;; esac done +fi # We can open open_targets here (no need to pass them to the parent) -for open_target_filepath in "${open_targets[@]}"; do - open "${open_target_filepath}" -done +if [ "${#open_targets[@]}" -gt 0 ]; then + for open_target_filepath in "${open_targets[@]}"; do + open "${open_target_filepath}" + done +fi # However, text files & dirs need to be passed to the parent, so they # get run in the user's shell process (and not this subprocess) diff --git a/cmdk.sh b/cmdk.sh old mode 100644 new mode 100755 index ac3543a..7cef74f --- a/cmdk.sh +++ b/cmdk.sh @@ -1,3 +1,4 @@ +#!/usr/bin/env bash # ARGS: # -o Only list the contents of the current directory at depth 1 (original behavior) # -s List all contents of the current directory recursively (subdirectories) @@ -17,7 +18,7 @@ function cmdk() { IFS="|" read -r text_files_filepath dir_to_cd <<< "${core_response}" if [ -n "${dir_to_cd}" ]; then - cd "${dir_to_cd}" + cd "${dir_to_cd}" || return 1 fi if [ -n "${text_files_filepath}" ]; then @@ -27,6 +28,7 @@ function cmdk() { # We have to do this because zsh doesn't do word-splitting by default, # and we can't 'setopt SH_WORD_SPLIT' else we'd set it for the user's entire shell if [ -n "$ZSH_VERSION" ]; then + # shellcheck disable=SC2296,SC2206 editor_cmd=( ${(z)${EDITOR:-vim -O}} ) else IFS=' ' read -r -a editor_cmd <<< "${EDITOR:-"vim -O"}" diff --git a/git-files.sh b/git-files.sh new file mode 100755 index 0000000..451e4f9 --- /dev/null +++ b/git-files.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +# Fetch all changed files from git (modified, staged, untracked) +# Returns deduplicated list of file paths + +set -euo pipefail + +# Check if we're in a git repo +if ! git rev-parse --git-dir >/dev/null 2>&1; then + exit 1 +fi + +# Fetch files from git, exit silently if git commands fail +modified=$(git diff --name-only 2>/dev/null) || true +staged=$(git diff --cached --name-only 2>/dev/null) || true +untracked=$(git ls-files --others --exclude-standard 2>/dev/null) || true + +# Combine and deduplicate +( + echo "$modified" + echo "$staged" + echo "$untracked" +) | sort -u | grep -v '^$' || true diff --git a/list-files.sh b/list-files.sh old mode 100644 new mode 100755 index 5aaa568..c106213 --- a/list-files.sh +++ b/list-files.sh @@ -1,7 +1,6 @@ -#!/usr/bin/env sh +#!/usr/bin/env bash set -euo pipefail -script_dirpath="$(cd "$(dirname "${0}")" && pwd)" # Common project directories to exclude @@ -65,6 +64,8 @@ SUBDIRS_MODE="subdirs" # Show all files in the current directory, and recurse i mode="${SYSTEM_MODE}" +show_ignored="false" +if [ $# -gt 0 ]; then for arg in "${@}"; do case "$arg" in -o) @@ -73,10 +74,17 @@ for arg in "${@}"; do -s) mode="${SUBDIRS_MODE}" ;; + -e) + show_ignored="true" + ;; esac done +fi fd_base_cmd="fd --follow --hidden --color=always" +if [ "$show_ignored" = "true" ]; then + fd_base_cmd="${fd_base_cmd} --no-ignore" +fi # --------------- Handle current directory ------------------ pwd_restriction="" @@ -85,13 +93,12 @@ if [ "${mode}" = "${PWD_MODE}" ]; then fi home_excludes="" -add_back_home_excludes="false" if [ "${PWD}" = "${HOME}" ]; then home_excludes="${home_exclude_args}" - add_back_home_excludes="true" fi -${fd_base_cmd} --strip-cwd-prefix ${pwd_restriction} ${common_exclude_args} . +# shellcheck disable=SC2086 +${fd_base_cmd} --strip-cwd-prefix ${pwd_restriction} ${common_exclude_args} ${home_excludes} . # Now add back the directories (but not contents) of any common excludes we removed # TODO there's a bug where they get excluded but not added back if they're in a subdirectory! @@ -113,6 +120,7 @@ done if [ "${mode}" = "${SYSTEM_MODE}" ]; then # If we're not at home, add it in (with excludes) if [ "${PWD}" != "${HOME}" ]; then + # shellcheck disable=SC2086 ${fd_base_cmd} ${home_exclude_args} ${common_exclude_args} . "${HOME}" # Add back common excluded directories in HOME @@ -139,6 +147,12 @@ if [ "${mode}" = "${SYSTEM_MODE}" ]; then fi -# --------------- Ominpresent items ------------------------ -echo "HOME" -echo ".." +# --------------- Omnipresent items ------------------------ +# Only show HOME for system mode, but show .. for all modes except system (for navigation) +if [ "${mode}" = "${SYSTEM_MODE}" ]; then + echo "HOME" + echo ".." +else + # For -o and -s modes, only show .. for navigation back + echo ".." +fi diff --git a/preview.sh b/preview.sh old mode 100644 new mode 100755 index 49a4701..b008a94 --- a/preview.sh +++ b/preview.sh @@ -1,12 +1,18 @@ -#!/usr/bin/env sh +#!/usr/bin/env bash set -euo pipefail -script_dirpath="$(cd "$(dirname "${0}")" && pwd)" -ls_base_cmd='ls --color=always' +if command -v bat >/dev/null 2>&1; then + bat_base_cmd="bat --style=plain --color=always" +else + bat_base_cmd="cat" +fi -# We use --style=plain to avoid showing line numbers and file header (which are both unneeded here) -bat_base_cmd="bat --style=plain --color=always" +if ls --color=always / >/dev/null 2>&1; then + ls_base_cmd='ls --color=always' +else + ls_base_cmd='ls -G' +fi case "${1}" in HOME) @@ -24,13 +30,25 @@ case "${1}" in ${ls_base_cmd} "${1}" ;; image/*) - tiv -w 100 -h 100 "${1}" 2>/dev/null + if command -v tiv >/dev/null 2>&1; then + tiv -w 100 -h 100 "${1}" 2>/dev/null + else + echo "[image preview requires tiv]" + fi ;; application/zip) - unzip -l "${1}" + if command -v unzip >/dev/null 2>&1; then + unzip -l "${1}" + else + echo "[zip preview requires unzip]" + fi ;; application/pdf) - pdftotext "${1}" - + if command -v pdftotext >/dev/null 2>&1; then + pdftotext "${1}" - + else + echo "[PDF preview requires pdftotext]" + fi ;; esac ;; diff --git a/reload-files.sh b/reload-files.sh new file mode 100755 index 0000000..67e6087 --- /dev/null +++ b/reload-files.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +# Unified reload script handling both .env visibility and git filter toggles +# Usage: reload-files.sh [cmdk args] +# +# Features: +# - Ctrl+T: Toggle .env/.gitignored file visibility +# - Ctrl+G: Toggle between all files and git-changed files only +# - Toggles work independently and can be combined + +set -euo pipefail +script_dirpath="$(cd "$(dirname "${0}")" && pwd)" + +# Check git filter state +git_filter_state=$(bash "${script_dirpath}/actions/git-toggle-state.sh" get) + +if [ "$git_filter_state" = "on" ]; then + if git rev-parse --git-dir >/dev/null 2>&1; then + bash "${script_dirpath}/git-files.sh" 2>/dev/null + else + bash "${script_dirpath}/reload-with-toggle.sh" "$@" + fi +else + # Show normal file list (respects .env toggle via reload-with-toggle.sh) + bash "${script_dirpath}/reload-with-toggle.sh" "$@" +fi diff --git a/reload-with-toggle.sh b/reload-with-toggle.sh new file mode 100755 index 0000000..e2a6d8b --- /dev/null +++ b/reload-with-toggle.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -euo pipefail +script_dirpath="$(cd "$(dirname "${0}")" && pwd)" + +# Get current toggle state +toggle_state="$(bash "${script_dirpath}/actions/toggle-state.sh" get)" + +# Build arguments for list-files.sh +args="" +if [ "$toggle_state" = "on" ]; then + args="-e" +fi + +# Add original arguments passed to cmdk +for arg in "$@"; do + args="$args $arg" +done + +# Execute list-files.sh with appropriate flags +# shellcheck disable=SC2086 +bash "${script_dirpath}/list-files.sh" $args \ No newline at end of file diff --git a/test/git-files.bats b/test/git-files.bats new file mode 100644 index 0000000..3d03e8e --- /dev/null +++ b/test/git-files.bats @@ -0,0 +1,62 @@ +#!/usr/bin/env bats + +SCRIPT="$BATS_TEST_DIRNAME/../git-files.sh" + +setup() { + TEST_DIR="$(mktemp -d)" + ORIG_PWD="$PWD" + cd "$TEST_DIR" + git init -q + git config user.email "test@test.com" + git config user.name "Test" + # Create an initial commit so HEAD exists + touch initial.txt + git add initial.txt + git commit -q -m "initial" +} + +teardown() { + cd "$ORIG_PWD" + rm -rf "$TEST_DIR" +} + +@test "exits non-zero when not in a git repo" { + NON_GIT="$(mktemp -d)" + cd "$NON_GIT" + run bash "$SCRIPT" + [ "$status" -ne 0 ] + rm -rf "$NON_GIT" +} + +@test "returns modified files" { + echo "change" >> initial.txt + run bash "$SCRIPT" + [ "$status" -eq 0 ] + echo "$output" | grep -q "initial.txt" +} + +@test "returns staged files" { + echo "new content" > staged.txt + git add staged.txt + run bash "$SCRIPT" + [ "$status" -eq 0 ] + echo "$output" | grep -q "staged.txt" +} + +@test "returns untracked files" { + touch untracked.txt + run bash "$SCRIPT" + [ "$status" -eq 0 ] + echo "$output" | grep -q "untracked.txt" +} + +@test "output is deduplicated" { + echo "new" > dup.txt + git add dup.txt + echo "more changes" >> dup.txt + # dup.txt is now both staged and modified + run bash "$SCRIPT" + [ "$status" -eq 0 ] + count=$(echo "$output" | grep -c "dup.txt") + [ "$count" -eq 1 ] +} diff --git a/test/list-files.bats b/test/list-files.bats new file mode 100644 index 0000000..abf3fa3 --- /dev/null +++ b/test/list-files.bats @@ -0,0 +1,73 @@ +#!/usr/bin/env bats + +SCRIPT="$BATS_TEST_DIRNAME/../list-files.sh" + +setup() { + TEST_DIR="$(mktemp -d)" + mkdir -p "$TEST_DIR/subdir/nested" + touch "$TEST_DIR/file1.txt" + touch "$TEST_DIR/subdir/file2.txt" + touch "$TEST_DIR/subdir/nested/file3.txt" + ORIG_PWD="$PWD" + cd "$TEST_DIR" +} + +teardown() { + cd "$ORIG_PWD" + rm -rf "$TEST_DIR" +} + +@test "exits 0 in a normal directory" { + run bash "$SCRIPT" -o + [ "$status" -eq 0 ] +} + +@test "-o flag limits depth to 1 level" { + run bash "$SCRIPT" -o + [ "$status" -eq 0 ] + # Should contain top-level file and subdir + echo "$output" | grep -q "file1.txt" + echo "$output" | grep -q "subdir" + # Should NOT contain nested files + ! echo "$output" | grep -q "file3.txt" +} + +@test "-s flag recurses into subdirectories" { + run bash "$SCRIPT" -s + [ "$status" -eq 0 ] + echo "$output" | grep -q "file1.txt" + echo "$output" | grep -q "file2.txt" + echo "$output" | grep -q "file3.txt" +} + +@test "handles files with spaces in names" { + touch "$TEST_DIR/file with spaces.txt" + run bash "$SCRIPT" -o + [ "$status" -eq 0 ] + echo "$output" | grep -q "file with spaces.txt" +} + +@test "handles files with special characters in names" { + touch "$TEST_DIR/file[1].txt" + touch "$TEST_DIR/file(2).txt" + run bash "$SCRIPT" -o + [ "$status" -eq 0 ] + echo "$output" | grep -q 'file\[1\].txt' + echo "$output" | grep -q 'file(2).txt' +} + +@test "common exclude dirs are excluded from output" { + mkdir -p "$TEST_DIR/node_modules/pkg" + touch "$TEST_DIR/node_modules/pkg/index.js" + mkdir -p "$TEST_DIR/.git/objects" + touch "$TEST_DIR/.git/objects/abc" + + run bash "$SCRIPT" -s + [ "$status" -eq 0 ] + # The fd output should not include files inside node_modules or .git + ! echo "$output" | grep -q "node_modules/pkg/index.js" + ! echo "$output" | grep -q ".git/objects/abc" + # But the directory names themselves get added back + echo "$output" | grep -q "node_modules" + echo "$output" | grep -q ".git" +} diff --git a/test/preview.bats b/test/preview.bats new file mode 100644 index 0000000..9c6a730 --- /dev/null +++ b/test/preview.bats @@ -0,0 +1,34 @@ +#!/usr/bin/env bats + +SCRIPT="$BATS_TEST_DIRNAME/../preview.sh" + +setup() { + TEST_DIR="$(mktemp -d)" + echo "hello world" > "$TEST_DIR/sample.txt" + mkdir -p "$TEST_DIR/mydir" + touch "$TEST_DIR/mydir/a.txt" + touch "$TEST_DIR/mydir/b.txt" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +@test "text file preview works" { + run bash "$SCRIPT" "$TEST_DIR/sample.txt" + [ "$status" -eq 0 ] + echo "$output" | grep -q "hello world" +} + +@test "directory preview works" { + run bash "$SCRIPT" "$TEST_DIR/mydir" + [ "$status" -eq 0 ] + echo "$output" | grep -q "a.txt" + echo "$output" | grep -q "b.txt" +} + +@test "HOME keyword shows home directory listing" { + run bash "$SCRIPT" HOME + [ "$status" -eq 0 ] + [ -n "$output" ] +} diff --git a/test/toggle-state.bats b/test/toggle-state.bats new file mode 100644 index 0000000..8515136 --- /dev/null +++ b/test/toggle-state.bats @@ -0,0 +1,57 @@ +#!/usr/bin/env bats + +SCRIPT="$BATS_TEST_DIRNAME/../actions/toggle-state.sh" + +setup() { + export STATE_FILE="/tmp/cmdk_env_toggle_bats_test_$$" + rm -f "$STATE_FILE" +} + +teardown() { + rm -f "$STATE_FILE" +} + +@test "init on sets state to on" { + run bash "$SCRIPT" init on + [ "$status" -eq 0 ] + [ "$output" = "on" ] +} + +@test "init off sets state to off" { + run bash "$SCRIPT" init off + [ "$status" -eq 0 ] + [ "$output" = "off" ] +} + +@test "toggle flips state from off to on" { + bash "$SCRIPT" init off + run bash "$SCRIPT" toggle + [ "$status" -eq 0 ] + [ "$output" = "on" ] +} + +@test "toggle flips state from on to off" { + bash "$SCRIPT" init on + run bash "$SCRIPT" toggle + [ "$status" -eq 0 ] + [ "$output" = "off" ] +} + +@test "get returns current state" { + bash "$SCRIPT" init on + run bash "$SCRIPT" get + [ "$status" -eq 0 ] + [ "$output" = "on" ] +} + +@test "cleanup removes state file" { + bash "$SCRIPT" init on + run bash "$SCRIPT" cleanup + [ "$status" -eq 0 ] + [ ! -f "$STATE_FILE" ] +} + +@test "invalid command exits with error" { + run bash "$SCRIPT" nonsense + [ "$status" -eq 1 ] +}