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 ]
+}