Repository: yorukot/superfile Branch: main Commit: cba7254b0157 Files: 338 Total size: 1023.9 KB Directory structure: gitextract_l20rs_yr/ ├── .envrc ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── enhancement-suggestion.md │ │ └── feature_request.md │ ├── PULL_REQUEST_TEMPLATE/ │ │ └── pull_request_template.md │ ├── renovate.json │ └── workflows/ │ ├── first-interaction.yml │ ├── lint-pr-title.yml │ ├── mirror.yml │ ├── superfile-build-test.yml │ ├── testsuite-run.yml │ ├── update-gomod2nix.yml │ └── winget.yml ├── .gitignore ├── .golangci.yaml ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── asset/ │ └── spf.desktop ├── build.sh ├── cd_on_quit/ │ ├── cd_on_quit.fish │ ├── cd_on_quit.ps1 │ └── cd_on_quit.sh ├── dev.sh ├── flake.nix ├── go.mod ├── go.sum ├── gomod2nix.toml ├── main.go ├── release/ │ ├── release.sh │ ├── release_check.md │ └── remove_all_spf_config.sh ├── src/ │ ├── cmd/ │ │ ├── debug_info.go │ │ ├── help_printer.go │ │ └── main.go │ ├── config/ │ │ ├── fixed_variable.go │ │ └── icon/ │ │ ├── function.go │ │ └── icon.go │ ├── internal/ │ │ ├── backend/ │ │ │ └── README.md │ │ ├── common/ │ │ │ ├── README.md │ │ │ ├── config_type.go │ │ │ ├── default_config.go │ │ │ ├── icon_utils.go │ │ │ ├── icon_utils_test.go │ │ │ ├── load_config.go │ │ │ ├── predefined_variable.go │ │ │ ├── string_function.go │ │ │ ├── string_function_test.go │ │ │ ├── style.go │ │ │ ├── style_function.go │ │ │ ├── type.go │ │ │ └── ui_consts.go │ │ ├── config_function.go │ │ ├── default_config.go │ │ ├── file_operation_compress_test.go │ │ ├── file_operations.go │ │ ├── file_operations_compress.go │ │ ├── file_operations_extract.go │ │ ├── function.go │ │ ├── function_test.go │ │ ├── handle_file_operation_test.go │ │ ├── handle_file_operations.go │ │ ├── handle_modal.go │ │ ├── handle_panel_movement.go │ │ ├── handle_panel_navigation.go │ │ ├── key_function.go │ │ ├── model.go │ │ ├── model_file_operations_test.go │ │ ├── model_layout_test.go │ │ ├── model_msg.go │ │ ├── model_navigation_test.go │ │ ├── model_process_test.go │ │ ├── model_prompt_test.go │ │ ├── model_render.go │ │ ├── model_test.go │ │ ├── model_zoxide_test.go │ │ ├── test_utils.go │ │ ├── test_utils_teaprog.go │ │ ├── type.go │ │ ├── type_utils.go │ │ ├── ui/ │ │ │ ├── README.md │ │ │ ├── clipboard/ │ │ │ │ ├── model.go │ │ │ │ └── model_test.go │ │ │ ├── filemodel/ │ │ │ │ ├── consts.go │ │ │ │ ├── dimensions.go │ │ │ │ ├── navigation.go │ │ │ │ ├── render.go │ │ │ │ ├── type.go │ │ │ │ ├── update.go │ │ │ │ └── utils.go │ │ │ ├── filepanel/ │ │ │ │ ├── columns.go │ │ │ │ ├── consts.go │ │ │ │ ├── dimension.go │ │ │ │ ├── get_elements.go │ │ │ │ ├── get_elements_test.go │ │ │ │ ├── misc.go │ │ │ │ ├── model.go │ │ │ │ ├── navigation.go │ │ │ │ ├── navigation_test.go │ │ │ │ ├── render.go │ │ │ │ ├── selection_test.go │ │ │ │ ├── sort.go │ │ │ │ ├── types.go │ │ │ │ ├── update.go │ │ │ │ └── utils.go │ │ │ ├── helpmenu/ │ │ │ │ ├── data.go │ │ │ │ ├── model_state.go │ │ │ │ ├── navigation.go │ │ │ │ ├── render.go │ │ │ │ ├── type.go │ │ │ │ └── utils.go │ │ │ ├── metadata/ │ │ │ │ ├── README.md │ │ │ │ ├── architecture.go │ │ │ │ ├── architecture_test.go │ │ │ │ ├── const.go │ │ │ │ ├── metadata.go │ │ │ │ ├── metadata_test.go │ │ │ │ ├── metadata_unix.go │ │ │ │ ├── metadata_windows.go │ │ │ │ ├── model.go │ │ │ │ ├── model_test.go │ │ │ │ ├── testdata/ │ │ │ │ │ └── file1.txt │ │ │ │ ├── update.go │ │ │ │ └── utils.go │ │ │ ├── notify/ │ │ │ │ ├── model.go │ │ │ │ └── type.go │ │ │ ├── preview/ │ │ │ │ ├── model.go │ │ │ │ ├── model_utils.go │ │ │ │ ├── render.go │ │ │ │ ├── render_test.go │ │ │ │ ├── render_unix_test.go │ │ │ │ ├── render_utils.go │ │ │ │ └── update.go │ │ │ ├── processbar/ │ │ │ │ ├── README.md │ │ │ │ ├── const.go │ │ │ │ ├── error.go │ │ │ │ ├── model.go │ │ │ │ ├── model_navigation.go │ │ │ │ ├── model_navigation_test.go │ │ │ │ ├── model_test.go │ │ │ │ ├── model_update.go │ │ │ │ ├── model_update_test.go │ │ │ │ ├── model_utils.go │ │ │ │ ├── model_utils_test.go │ │ │ │ ├── operation.go │ │ │ │ ├── process.go │ │ │ │ ├── process_test.go │ │ │ │ └── process_update_msg.go │ │ │ ├── prompt/ │ │ │ │ ├── README.md │ │ │ │ ├── consts.go │ │ │ │ ├── error.go │ │ │ │ ├── model.go │ │ │ │ ├── model_test.go │ │ │ │ ├── tokenize.go │ │ │ │ ├── tokenize_test.go │ │ │ │ ├── type.go │ │ │ │ ├── utils.go │ │ │ │ └── utils_test.go │ │ │ ├── rendering/ │ │ │ │ ├── README.md │ │ │ │ ├── border.go │ │ │ │ ├── constants.go │ │ │ │ ├── content_renderer.go │ │ │ │ ├── content_renderer_test.go │ │ │ │ ├── renderer.go │ │ │ │ ├── renderer_core.go │ │ │ │ ├── renderer_test.go │ │ │ │ ├── truncate.go │ │ │ │ └── truncate_test.go │ │ │ ├── sidebar/ │ │ │ │ ├── README.md │ │ │ │ ├── consts.go │ │ │ │ ├── directory_utils.go │ │ │ │ ├── disk_utils.go │ │ │ │ ├── navigation.go │ │ │ │ ├── navigation_test.go │ │ │ │ ├── pinned.go │ │ │ │ ├── pinned_test.go │ │ │ │ ├── render.go │ │ │ │ ├── sidebar.go │ │ │ │ ├── type.go │ │ │ │ ├── utils.go │ │ │ │ └── utils_test.go │ │ │ ├── sortmodel/ │ │ │ │ ├── const.go │ │ │ │ ├── model.go │ │ │ │ ├── navigation.go │ │ │ │ ├── render.go │ │ │ │ ├── types.go │ │ │ │ └── utils.go │ │ │ ├── spf_renderers.go │ │ │ └── zoxide/ │ │ │ ├── README.md │ │ │ ├── consts.go │ │ │ ├── model.go │ │ │ ├── model_test.go │ │ │ ├── navigation.go │ │ │ ├── navigation_test.go │ │ │ ├── render.go │ │ │ ├── render_test.go │ │ │ ├── test_helpers.go │ │ │ ├── type.go │ │ │ ├── utils.go │ │ │ └── utils_test.go │ │ ├── validation.go │ │ └── wheel_function.go │ ├── pkg/ │ │ ├── cache/ │ │ │ └── cache.go │ │ ├── file_preview/ │ │ │ ├── ansi.go │ │ │ ├── constants.go │ │ │ ├── image_preview.go │ │ │ ├── image_resize.go │ │ │ ├── kitty.go │ │ │ ├── thumbnail_generator.go │ │ │ ├── utils.go │ │ │ ├── utils_unix.go │ │ │ └── utils_windows.go │ │ ├── string_function/ │ │ │ └── overplace.go │ │ └── utils/ │ │ ├── README.md │ │ ├── bool_file_store.go │ │ ├── bool_file_store_test.go │ │ ├── config_interface.go │ │ ├── consts.go │ │ ├── detach_unix.go │ │ ├── detach_windows.go │ │ ├── error.go │ │ ├── file_utils.go │ │ ├── file_utils_test.go │ │ ├── fzf_utils.go │ │ ├── log_utils.go │ │ ├── shell_utils.go │ │ ├── tea_utils.go │ │ ├── test_utils.go │ │ ├── testdata/ │ │ │ └── load_toml/ │ │ │ ├── default.toml │ │ │ ├── ignorer/ │ │ │ │ ├── .gitignore │ │ │ │ ├── default.toml │ │ │ │ ├── default_extra_fields.toml │ │ │ │ ├── invalid_format.toml │ │ │ │ ├── invalid_value_type.toml │ │ │ │ ├── missing_str2.toml │ │ │ │ ├── missing_str_ignore.toml │ │ │ │ ├── missing_str_ignore2.toml │ │ │ │ └── missing_str_int.toml │ │ │ └── missing_str.toml │ │ └── ui_utils.go │ └── superfile_config/ │ ├── config.toml │ ├── hotkeys.toml │ ├── theme/ │ │ ├── 0x96f.toml │ │ ├── ayu-dark.toml │ │ ├── blood.toml │ │ ├── catppuccin-frappe.toml │ │ ├── catppuccin-latte.toml │ │ ├── catppuccin-macchiato.toml │ │ ├── catppuccin-mocha.toml │ │ ├── dracula.toml │ │ ├── everforest-dark-hard.toml │ │ ├── everforest-dark-medium.toml │ │ ├── gruvbox-dark-hard.toml │ │ ├── gruvbox.toml │ │ ├── hacks.toml │ │ ├── kaolin.toml │ │ ├── monokai.toml │ │ ├── nord.toml │ │ ├── onedark.toml │ │ ├── poimandres.toml │ │ ├── rose-pine.toml │ │ ├── sugarplum.toml │ │ └── tokyonight.toml │ └── vimHotkeys.toml ├── testsuite/ │ ├── .gitignore │ ├── Notes.md │ ├── README.md │ ├── core/ │ │ ├── __init__.py │ │ ├── base_test.py │ │ ├── environment.py │ │ ├── fs_manager.py │ │ ├── keys.py │ │ ├── pyautogui_manager.py │ │ ├── runner.py │ │ ├── spf_manager.py │ │ ├── test_constants.py │ │ ├── tmux_manager.py │ │ └── utils.py │ ├── docs/ │ │ └── tmux.md │ ├── main.py │ ├── requirements.txt │ └── tests/ │ ├── __init__.py │ ├── chooser_file_test.py │ ├── command_test.py │ ├── compress_extract_test.py │ ├── copy_dir_test.py │ ├── copy_test.py │ ├── copyw_test.py │ ├── cut_test.py │ ├── delete_dir_test.py │ ├── delete_test.py │ ├── empty_panel_test.py │ ├── nav_and_copy_path_test.py │ └── rename_test.py ├── vhs/ │ ├── demo.tape │ ├── open_spf_and_quit.tape │ ├── spf_file_panel_movement.tape │ ├── spf_file_panel_navigation.tape │ ├── spf_file_panel_selection_mode.tape │ └── spf_panel_navigation.tape └── website/ ├── README.md ├── astro.config.mjs ├── ec.config.mjs ├── package.json ├── public/ │ ├── _redirects │ ├── google0fdf22175b8dde4d.html │ ├── install.ps1 │ ├── install.sh │ └── uninstall.ps1 ├── src/ │ ├── components/ │ │ ├── GithubStar.astro │ │ ├── LastUpdated.astro │ │ ├── about.astro │ │ └── code.astro │ ├── content/ │ │ ├── config.ts │ │ └── docs/ │ │ ├── changelog.md │ │ ├── configure/ │ │ │ ├── config-file-path.md │ │ │ ├── custom-hotkeys.mdx │ │ │ ├── custom-theme.mdx │ │ │ ├── enable-plugin.md │ │ │ └── superfile-config.mdx │ │ ├── contribute/ │ │ │ ├── file-struct.md │ │ │ ├── how-to-contribute.md │ │ │ └── implementation-info.md │ │ ├── getting-started/ │ │ │ ├── image-preview.md │ │ │ ├── installation.md │ │ │ └── tutorial.md │ │ ├── index.mdx │ │ ├── list/ │ │ │ ├── hotkey-list.md │ │ │ ├── plugin-list.md │ │ │ └── theme-list.md │ │ ├── overview.md │ │ ├── special-thanks.mdx │ │ └── troubleshooting.md │ ├── env.d.ts │ └── styles/ │ └── custom.css └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .envrc ================================================ use flake ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: yorukot # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: # Replace with a single Buy Me a Coffee username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **System information (please complete the following information):** - OS: [e.g. iOS] - Version [e.g. 22] - superfile Version [e.g. 1.1.1] ================================================ FILE: .github/ISSUE_TEMPLATE/enhancement-suggestion.md ================================================ --- name: Enhancement suggestion about: Enhance existing designs title: '' labels: enhancement assignees: '' --- **The part you want to Enhancement** Please briefly describe the part you want to strengthen **Why it is necessary to enhancement** Please explain why it needs to be enhancement, what are the flaws in the existing design, etc. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: idea assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE/pull_request_template.md ================================================ # Description Briefly summarize what this PR changes. Include any context or motivation behind the change. If it depends on other changes or tools, please mention that too. # Related Issues If this PR fixes or relates to existing issues, list them here. Example: `Fixes #123` # Screenshots (Optional) Add screenshots if this helps reviewers understand the change. --- # ✅ Pre-Submission Checklist Please go through the following steps **before** submitting this PR. You can delete this section after confirming everything is done. - [ ] I have run `go fmt ./...` to format the code - [ ] I have run `golangci-lint run` and fixed any reported issues - [ ] I have tested my changes and verified they work as expected - [ ] I have reviewed the diff to make sure I’m not committing any debug logs or TODOs - [ ] I have checked that the PR title follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) format ================================================ FILE: .github/renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["config:recommended", ":dependencyDashboard", "default:automergeDigest"], "packageRules": [ { "matchDatasources": ["go", "github-releases", "github-tags"], "matchUpdateTypes": ["minor", "patch", "pin", "digest"], "automerge": true, "paths": ["/"], "ignorePaths": [ "website/" ] } ] } ================================================ FILE: .github/workflows/first-interaction.yml ================================================ name: "Welcome First-Time Contributor" on: issues: types: [opened] pull_request: types: [opened] permissions: contents: read issues: write pull-requests: write jobs: greet: runs-on: ubuntu-latest steps: - uses: actions/first-interaction@v3 with: repo_token: ${{ secrets.GITHUB_TOKEN }} issue_message: | 👋 Hi there, and welcome to **superfile**! Thanks for opening your first issue. We really appreciate your interest and involvement. A maintainer might ask you for more details or clarification to better understand the issue. That’s totally normal and helps us solve the problem more effectively. If you plan to submit a PR, make sure to check our [Contribution Guide](https://github.com/yorukot/superfile/blob/main/CONTRIBUTING.md) pr_message: | 🎉 Thank you for your first contribution to **superfile**! We’re really excited to have you here 🙌 A maintainer might ask you to make a few changes before we can merge this PR. That’s totally normal and part of the process. Don’t worry, we’ll help guide you through it. 👉 Please also take a moment to review our [Contribution Guide](https://github.com/yorukot/superfile/blob/main/CONTRIBUTING.md) If you have any questions, feel free to open a [Discussion](https://github.com/yorukot/superfile/discussions) or just ask in the comments! ================================================ FILE: .github/workflows/lint-pr-title.yml ================================================ name: "Lint PR Title" on: pull_request_target: types: - opened - edited - reopened - synchronize permissions: pull-requests: write jobs: lint: name: Check PR title format runs-on: ubuntu-latest steps: - uses: amannn/action-semantic-pull-request@v6 id: lint_pr_title env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: types: | feat fix refactor chore docs test style perf ci build - uses: marocchino/sticky-pull-request-comment@v2 if: always() && (steps.lint_pr_title.outputs.error_message != null) with: header: pr-title-lint-error message: | ⚠️ PR title format invalid. superfile uses [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for squash merges. Allowed types: ``` feat, fix, refactor, chore, docs, test, style, perf, ci, build ``` 👉 While not required, it’s **recommended** to include a scope like: ``` feat(file-preview): support Kitty image protocol fix(renderer): correct ANSI fallback ``` --- ``` ${{ steps.lint_pr_title.outputs.error_message }} ``` - if: ${{ steps.lint_pr_title.outputs.error_message == null }} uses: marocchino/sticky-pull-request-comment@v2 with: header: pr-title-lint-error delete: true ================================================ FILE: .github/workflows/mirror.yml ================================================ name: Mirror to Codeberg permissions: contents: read on: [push] # Trigger on push to ANY branch jobs: mirror: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: yesolutions/mirror-action@master with: REMOTE: 'https://codeberg.org/yorukot/superfile.git' GIT_USERNAME: yorukot GIT_PASSWORD: ${{ secrets.CODEBERG_TOKEN }} PUSH_ALL_REFS: "true" ================================================ FILE: .github/workflows/superfile-build-test.yml ================================================ name: Go CI on: push: branches: [main] pull_request: branches: [main, develop] permissions: contents: read jobs: build: name: Build and Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - name: Checkout repository uses: actions/checkout@v6 - name: Setup dependencies (only on linux) if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y exiftool curl -sS https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh | bash - name: Set up Go uses: actions/setup-go@v6 with: go-version: '1.25.5' - name: Cache Go modules uses: actions/cache@v5 with: path: | ~/.cache/go-build ${{ runner.temp }}/gomodcache key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go- - name: Install dependencies run: go mod download env: GOMODCACHE: ${{ runner.temp }}/gomodcache - name: Build run: go build -v ./... env: GOMODCACHE: ${{ runner.temp }}/gomodcache - name: Test run: go test -v ./... env: GOMODCACHE: ${{ runner.temp }}/gomodcache - name: Check gofmt (skip on Windows) if: runner.os != 'Windows' run: | go fmt ./... git diff --exit-code - name: golangci-lint (skip on Windows) if: runner.os != 'Windows' uses: golangci/golangci-lint-action@v9 with: version: v2.8.0 args: --timeout 3m ================================================ FILE: .github/workflows/testsuite-run.yml ================================================ name: Python Testsuite Run on: push: branches: [ main ] pull_request: branches: [ main, develop ] permissions: contents: read jobs: test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Install tmux run: sudo apt-get update && sudo apt-get install -y tmux - name: Set up Go uses: actions/setup-go@v6 with: go-version: '1.25.5' - name: Build superfile run: ./build.sh # timeout command just launches and kills spf, to create the config directories - name: Check installation run: tmux -V; ls; pwd; ls bin/; bin/spf path-list; timeout 1s bin/spf continue-on-error: true - name: set debug run: sed -i 's/debug = false/debug = true/g' /home/runner/.config/superfile/config.toml - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.14' - name: Install Dependencies run: pip install -r testsuite/requirements.txt - name: Run Tests run: python testsuite/main.py -d - name: Print logs if: always() run: cat ~/.local/state/superfile/superfile.log ================================================ FILE: .github/workflows/update-gomod2nix.yml ================================================ name: Update gomod2nix.toml on: push: paths: - 'go.mod' - 'go.sum' permissions: contents: write jobs: dependabot: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install Nix uses: cachix/install-nix-action@v31 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} nix_path: nixpkgs=channel:nixos-unstable - name: Update checksum run: | nix develop --extra-experimental-features "nix-command flakes" '.#' -c "gomod2nix" # git push if we have a diff if [[ -n $(git diff) ]]; then git config --global user.email "107802416+yorukot@users.noreply.github.com" git config --global user.name "yorukot" git commit -am "chore: update gomod2nix" BRANCH_NAME=$(echo ${{ github.ref }} | sed -e 's/refs\/heads\///g') git push origin HEAD:$BRANCH_NAME fi ================================================ FILE: .github/workflows/winget.yml ================================================ name: Publish to WinGet on: release: types: [released] jobs: publish: name: Publish WinGet package runs-on: windows-latest steps: - name: Submit package to Windows Package Manager Community Repository uses: vedantmgoyal2009/winget-releaser@v2 with: identifier: yorukot.superfile installers-regex: '\.(zip|msi)$' version: ${{ github.event.release.tag_name }} release-tag: ${{ github.event.release.tag_name }} token: ${{ secrets.WINGET_TOKEN }} ================================================ FILE: .gitignore ================================================ *.log bin/ dist/ .idea/ # generated types .astro/ # dependencies node_modules/ # logs npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* # Go test coverage reports coverage.out coverage.html # environment variables .env .env.production .direnv # Python virtual environments venv/ .venv/ testsuite/venv/ testsuite/.venv/ # macOS-specific files .DS_Store # Agent configs CLAUDE.md ================================================ FILE: .golangci.yaml ================================================ # Based on https://gist.github.com/maratori/47a4d00457a92aa426dbd48a18776322 # Version used - # https://gist.githubusercontent.com/maratori/47a4d00457a92aa426dbd48a18776322/raw/933de4558de48c79322aee44b44a9619eeaba167/.golangci.yml # Note: Superfile doesn't ensure that this file's version is in sync with # the version we use in CI. It can be outdated. # Additional changes done for superfile # - govet shadow analyzer is set to "strict : false" settings. # strict : true causes too much noise # This file is licensed under the terms of the MIT license https://opensource.org/license/mit # Copyright (c) 2021-2025 Marat Reymers ## Golden config for golangci-lint v2.4.0 # # This is the best config for golangci-lint based on my experience and opinion. # It is very strict, but not extremely strict. # Feel free to adapt it to suit your needs. # If this config helps you, please consider keeping a link to this file (see the next comment). # Based on https://gist.github.com/maratori/47a4d00457a92aa426dbd48a18776322 version: "2" issues: # Maximum count of issues with the same text. # Set to 0 to disable. # Default: 3 max-same-issues: 50 formatters: enable: - goimports # checks if the code and import statements are formatted according to the 'goimports' command - golines # checks if code is formatted, and fixes long lines ## you may want to enable # TODO #- gci # checks if code and import statements are formatted, with additional rules #- gofmt # checks if the code is formatted according to 'gofmt' command #- gofumpt # enforces a stricter format than 'gofmt', while being backwards compatible #- swaggo # formats swaggo comments # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml settings: goimports: # A list of prefixes, which, if set, checks import paths # with the given prefixes are grouped after 3rd-party packages. # Default: [] local-prefixes: - github.com/yorukot/superfile golines: # Target maximum line length. # Default: 100 max-len: 120 linters: enable: - asasalint # checks for pass []any as any in variadic func(...any) - asciicheck # checks that your code does not contain non-ASCII identifiers - bidichk # checks for dangerous unicode character sequences - bodyclose # checks whether HTTP response body is closed successfully - canonicalheader # checks whether net/http.Header uses canonical header - copyloopvar # detects places where loop variables are copied (Go 1.22+) - cyclop # checks function and package cyclomatic complexity - depguard # checks if package imports are in a list of acceptable packages - dupl # tool for code clone detection - durationcheck # checks for two durations multiplied together - embeddedstructfieldcheck # checks embedded types in structs - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 - exhaustive # checks exhaustiveness of enum switch statements - exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions - fatcontext # detects nested contexts in loops # Not needed. this blocks only fmt.Print family and is not useful right now # - forbidigo # forbids identifiers - funcorder # checks the order of functions, methods, and constructors - funlen # tool for detection of long functions - gocheckcompilerdirectives # validates go compiler directive comments (//go:) - gochecknoglobals # checks that no global variables exist - gochecknoinits # checks that no init functions are present in Go code - gochecksumtype # checks exhaustiveness on Go "sum types" - gocognit # computes and checks the cognitive complexity of functions - goconst # finds repeated strings that could be replaced by a constant - gocritic # provides diagnostics that check for bugs, performance and style issues - gocyclo # computes and checks the cyclomatic complexity of functions # Not needed # - godot # checks if comments end in a period - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod - goprintffuncname # checks that printf-like functions are named with f at the end - gosec # inspects source code for security problems - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string - iface # checks the incorrect use of interfaces, helping developers avoid interface pollution - ineffassign # detects when assignments to existing variables are not used - intrange # finds places where for loops could make use of an integer range - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) - makezero # finds slice declarations with non-zero initial length - mirror # reports wrong mirror patterns of bytes/strings usage - mnd # detects magic numbers - musttag # enforces field tags in (un)marshaled structs - nakedret # finds naked returns in functions greater than a specified function length # TODO enable : Many reports. A bit hard to understand the nesting value. # - nestif # reports deeply nested if statements - nilerr # finds the code that returns nil even if it checks that the error is not nil - nilnesserr # reports that it checks for err != nil, but it returns a different nil value error (powered by nilness and nilerr) - nilnil # checks that there is no simultaneous return of nil error and an invalid value - noctx # finds sending http request without context.Context - nolintlint # reports ill-formed or insufficient nolint directives - nonamedreturns # reports all named returns - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative - predeclared # finds code that shadows one of Go's predeclared identifiers - promlinter # checks Prometheus metrics naming via promlint - protogetter # reports direct reads from proto message fields when getters should be used - reassign # checks that package variables are not reassigned - recvcheck # checks for receiver type consistency - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint - rowserrcheck # checks whether Err of rows is checked successfully # Need to use non-root logger. Not really needed. # - sloglint # ensure consistent code style when using log/slog - spancheck # checks for mistakes with OpenTelemetry/Census spans - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed - staticcheck # is a go vet on steroids, applying a ton of static analysis checks - testableexamples # checks if examples are testable (have an expected output) - testifylint # checks usage of github.com/stretchr/testify # [SPF Specific] Not needed - Makes it harder to test unexported package functions. # - testpackage # makes you use a separate _test package - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes - unconvert # removes unnecessary type conversions - unparam # reports unused function parameters - unused # checks for unused constants, variables, functions and types - usestdlibvars # detects the possibility to use variables/constants from the Go standard library - usetesting # reports uses of functions with replacement inside the testing package - wastedassign # finds wasted assignment statements - whitespace # detects leading and trailing whitespace ## you may want to enable #- arangolint # opinionated best practices for arangodb client - decorder # checks declaration order and count of types, constants, variables and functions # TODO Enable: Many issues right now. #- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized #- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega # TODO Enable. After fixing all TODOs #- godox # detects usage of FIXME, TODO and other keywords inside comments - goheader # checks is file header matches to pattern #- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters - interfacebloat # checks the number of methods inside an interface # TODO Enable: Function Update() is caught #- ireturn # accept interfaces, return concrete types #- noinlineerr # disallows inline error handling `if err := ...; err != nil {` #- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated #- tagalign # checks that struct tags are well aligned #- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope #- wrapcheck # checks that errors returned from external packages are wrapped #- zerologlint # detects the wrong usage of zerolog that a user forgets to dispatch zerolog.Event ## disabled #- containedctx # detects struct contained context.Context field #- contextcheck # [too many false positives] checks the function whether use a non-inherited context #- dogsled # checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()) #- dupword # [useless without config] checks for duplicate words in the source code #- err113 # [too strict] checks the errors handling expressions #- errchkjson # [don't see profit + I'm against of omitting errors like in the first example https://github.com/breml/errchkjson] checks types passed to the json encoding functions. Reports unsupported types and optionally reports occasions, where the check for the returned error can be omitted #- forcetypeassert # [replaced by errcheck] finds forced type assertions #- gomodguard # [use more powerful depguard] allow and block lists linter for direct Go module dependencies - gosmopolitan # reports certain i18n/l10n anti-patterns in your Go codebase - grouper # analyzes expression groups - importas # enforces consistent import aliases #- lll # [replaced by golines] reports long lines - maintidx # measures the maintainability index of each function - misspell # [useless] finds commonly misspelled English words in comments #- nlreturn # [too strict and mostly code is not more readable] checks for a new line before return and branch statements to increase code clarity #- paralleltest # [too many false positives] detects missing usage of t.Parallel() method in your Go test #- tagliatelle # checks the struct tags #- thelper # detects golang test helpers without t.Helper() call and checks the consistency of test helpers #- wsl # [too strict and mostly code is not more readable] whitespace linter forces you to use empty lines #- wsl_v5 # [too strict and mostly code is not more readable] add or remove empty lines # All settings can be found here https://github.com/golangci/golangci-lint/blob/HEAD/.golangci.reference.yml settings: cyclop: # The maximal code complexity to report. # Default: 10 max-complexity: 30 # The maximal average package complexity. # If it's higher than 0.0 (float) the check is enabled. # Default: 0.0 package-average: 10.0 depguard: # Rules to apply. # # Variables: # - File Variables # Use an exclamation mark `!` to negate a variable. # Example: `!$test` matches any file that is not a go test file. # # `$all` - matches all go files # `$test` - matches all go test files # # - Package Variables # # `$gostd` - matches all of go's standard library (Pulled from `GOROOT`) # # Default (applies if no custom rules are defined): Only allow $gostd in all files. rules: "deprecated": # List of file globs that will match this list of settings to compare against. # By default, if a path is relative, it is relative to the directory where the golangci-lint command is executed. # The placeholder '${base-path}' is substituted with a path relative to the mode defined with `run.relative-path-mode`. # The placeholder '${config-path}' is substituted with a path relative to the configuration file. # Default: $all files: - "$all" # List of packages that are not allowed. # Entries can be a variable (starting with $), a string prefix, or an exact match (if ending with $). # Default: [] deny: - pkg: github.com/golang/protobuf desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules - pkg: github.com/satori/go.uuid desc: Use github.com/google/uuid instead, satori's package is not maintained - pkg: github.com/gofrs/uuid$ desc: Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5 "non-test files": files: - "!$test" deny: - pkg: math/rand$ desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2 "non-main files": files: - "!**/main.go" deny: - pkg: log$ desc: Use log/slog instead, see https://go.dev/blog/slog embeddedstructfieldcheck: # Checks that sync.Mutex and sync.RWMutex are not used as embedded fields. # Default: false forbid-mutex: true errcheck: # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. # Such cases aren't reported by default. # Default: false check-type-assertions: true exhaustive: # Program elements to check for exhaustiveness. # Default: [ switch ] check: - switch - map exhaustruct: # List of regular expressions to match type names that should be excluded from processing. # Anonymous structs can be matched by '' alias. # Has precedence over `include`. # Each regular expression must match the full type name, including package path. # For example, to match type `net/http.Cookie` regular expression should be `.*/http\.Cookie`, # but not `http\.Cookie`. # Default: [] exclude: # std libs - ^net/http.Client$ - ^net/http.Cookie$ - ^net/http.Request$ - ^net/http.Response$ - ^net/http.Server$ - ^net/http.Transport$ - ^net/url.URL$ - ^os/exec.Cmd$ - ^reflect.StructField$ # public libs - ^github.com/Shopify/sarama.Config$ - ^github.com/Shopify/sarama.ProducerMessage$ - ^github.com/mitchellh/mapstructure.DecoderConfig$ - ^github.com/prometheus/client_golang/.+Opts$ - ^github.com/spf13/cobra.Command$ - ^github.com/spf13/cobra.CompletionOptions$ - ^github.com/stretchr/testify/mock.Mock$ - ^github.com/testcontainers/testcontainers-go.+Request$ - ^github.com/testcontainers/testcontainers-go.FromDockerfile$ - ^golang.org/x/tools/go/analysis.Analyzer$ - ^google.golang.org/protobuf/.+Options$ - ^gopkg.in/yaml.v3.Node$ # Allows empty structures in return statements. # Default: false allow-empty-returns: true funcorder: # Checks if the exported methods of a structure are placed before the non-exported ones. # Default: true struct-method: false funlen: # Checks the number of lines in a function. # If lower than 0, disable the check. # Default: 60 lines: 100 # Checks the number of statements in a function. # If lower than 0, disable the check. # Default: 40 statements: 50 gochecksumtype: # Presence of `default` case in switch statements satisfies exhaustiveness, if all members are not listed. # Default: true default-signifies-exhaustive: false gocognit: # Minimal code complexity to report. # Default: 30 (but we recommend 10-20) min-complexity: 20 gocritic: # Settings passed to gocritic. # The settings key is the name of a supported gocritic checker. # The list of supported checkers can be found at https://go-critic.com/overview. settings: captLocal: # Whether to restrict checker to params only. # Default: true paramsOnly: false underef: # Whether to skip (*x).method() calls where x is a pointer receiver. # Default: true skipRecvDeref: false govet: # Enable all analyzers. # Default: false enable-all: true # Disable analyzers by name. # Run `GL_DEBUG=govet golangci-lint run --enable=govet` to see default, all available analyzers, and enabled analyzers. # Default: [] disable: - fieldalignment # too strict # Settings per analyzer. settings: shadow: # Whether to be strict about shadowing; can be noisy. # Default: false strict: false inamedparam: # Skips check for interface methods with only a single parameter. # Default: false skip-single-param: true mnd: # List of function patterns to exclude from analysis. # Values always ignored: `time.Date`, # `strconv.FormatInt`, `strconv.FormatUint`, `strconv.FormatFloat`, # `strconv.ParseInt`, `strconv.ParseUint`, `strconv.ParseFloat`. # Default: [] ignored-functions: - args.Error - flag.Arg - flag.Duration.* - flag.Float.* - flag.Int.* - flag.Uint.* - os.Chmod - os.Mkdir.* - os.OpenFile - os.WriteFile - prometheus.ExponentialBuckets.* - prometheus.LinearBuckets nakedret: # Make an issue if func has more lines of code than this setting, and it has naked returns. # Default: 30 max-func-lines: 0 nolintlint: # Exclude following linters from requiring an explanation. # Default: [] allow-no-explanation: [ funlen, gocognit, golines ] # Enable to require an explanation of nonzero length after each nolint directive. # Default: false require-explanation: true # Enable to require nolint directives to mention the specific linter being suppressed. # Default: false require-specific: true perfsprint: # Optimizes into strings concatenation. # Default: true strconcat: false reassign: # Patterns for global variable names that are checked for reassignment. # See https://github.com/curioswitch/go-reassign#usage # Default: ["EOF", "Err.*"] patterns: - ".*" revive: rules: - name: exported disabled: true - name: package-comments disabled: true rowserrcheck: # database/sql is always checked. # Default: [] packages: - github.com/jmoiron/sqlx sloglint: # Enforce not using global loggers. # Values: # - "": disabled # - "all": report all global loggers # - "default": report only the default slog logger # https://github.com/go-simpler/sloglint?tab=readme-ov-file#no-global # Default: "" no-global: all # Enforce using methods that accept a context. # Values: # - "": disabled # - "all": report all contextless calls # - "scope": report only if a context exists in the scope of the outermost function # https://github.com/go-simpler/sloglint?tab=readme-ov-file#context-only # Default: "" context: scope staticcheck: # SAxxxx checks in https://staticcheck.dev/docs/configuration/options/#checks # Example (to disable some checks): [ "all", "-SA1000", "-SA1001"] # Default: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022"] checks: - all # Incorrect or missing package comment. # https://staticcheck.dev/docs/checks/#ST1000 - -ST1000 # Use consistent method receiver names. # https://staticcheck.dev/docs/checks/#ST1016 - -ST1016 # Omit embedded fields from selector expression. # https://staticcheck.dev/docs/checks/#QF1008 - -QF1008 usetesting: # Enable/disable `os.TempDir()` detections. # Default: false os-temp-dir: true exclusions: # Log a warning if an exclusion rule is unused. # Default: false warn-unused: false # Predefined exclusion rules. # Default: [] presets: - std-error-handling - common-false-positives # Excluding configuration per-path, per-linter, per-text and per-source. rules: - source: 'TODO' linters: [ godot ] - text: 'should have a package comment' linters: [ revive ] - text: 'exported \S+ \S+ should have comment( \(or a comment on this block\))? or be unexported' linters: [ revive ] - text: 'package comment should be of the form ".+"' source: '// ?(nolint|TODO)' linters: [ revive ] - text: 'comment on exported \S+ \S+ should be of the form ".+"' source: '// ?(nolint|TODO)' linters: [ revive, staticcheck ] - path: '_test\.go' linters: - bodyclose - dupl - errcheck - funlen - goconst - gosec - gosmopolitan - noctx - wrapcheck - text: 'ST102[0-2]: comment.*should be of the form' linters: [staticcheck] # TODO: Fix this - text: 'os/exec\.Command must not be called' linters: [noctx] # Stores globally accessible icon strings - path: 'src/config/icon/icon.go' linters: - gochecknoglobals # Stores prerendered styles - path: 'src/internal/common/style.go' linters: - gochecknoglobals # Some fixed variables like default config paths, version, etc. - path: 'src/config/fixed_variable.go' linters: - gochecknoglobals # Stores predefined variables, like re-used non-const strings - path: 'src/internal/common/predefined_variable.go' linters: - gochecknoglobals # Global variables storing config - path: 'src/internal/common/default_config.go' linters: - gochecknoglobals # Type defining config.toml and hotkey.toml - path: 'src/internal/common/config_type.go' linters: - golines ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to superfile Welcome to **superfile**! This guide will help you get started contributing to the project, whether you're fixing bugs, building features, or just sharing ideas. There are many ways to contribute: * Reporting bugs * Fixing issues * Adding a theme * Suggesting and implementing new features * Sharing ideas or feedback --- ## 🐞 Issues ### Found a bug? Check if there's already an open or closed issue for it. If not, open a new one and describe the problem clearly. ### Want to fix an issue? 1. Fork this repository 2. Create a new branch for the issue you're working on 3. Commit your changes with clear messages 4. Open a pull request (PR) with a description of the problem and your solution Maintainers may request changes before merging. --- ## 🎨 Adding a Theme Before starting, make sure the theme you want to add doesn’t already exist. 1. Copy an existing theme's `.toml` file as a base 2. Customize it to your needs 3. Test it by editing your `~/.config/superfile/config/config.toml` 4. When ready, submit a pull request 5. To ensure the theme looks consistent and functions properly, please include the following screenshots in your PR: - Full view of superfile (Including sidebar, file previewer, process panel, metadata panel, and clipboard panel ) - Make sure that file previewer is non empty, process panel has at least one process, and clipboard has at least one entry - Add a screenshot of these individual panel being focused (To make sure border focus color is good) - Sidebar - Processbar - Add a screenshot of help menu (Press ?) - Add a screenshot of popup that opens when you create a new file (Ctrl+n) - Add a screenshot of image being preview using your theme. - Add a screenshot of successful and unsuccessful shell command.
Example: - Full view of superfile (Including sidebar, file previewer, process panel, metadata panel, and clipboard panel) - Make sure that file previewer is non empty, process panel has at least one process, and clipboard has at least one entry ![Full view of superfile](asset/theme-example/1.png) - Add a screenshot of these individual panels being focused (To make sure border focus color is good) - Sidebar - Processbar ![Sidebar focused](asset/theme-example/2.png) ![Processbar focused](asset/theme-example/3.png) - Add a screenshot of help menu (Press `?`) ![Help menu](asset/theme-example/4.png) - Add a screenshot of popup that opens when you create a new file (Ctrl+n) ![New file popup](asset/theme-example/5.png) - Add a screenshot of image being previewed using your theme ![Image preview](asset/theme-example/6.png) - Add a screenshot of successful and unsuccessful shell command ![Successful shell command](asset/theme-example/7.png) ![Failed shell command](asset/theme-example/8.png)
--- ## 💡 Sharing Ideas Got a new idea? Awesome! 1. Check if similar ideas exist in Discussions or Issues 2. Open a discussion at: [https://github.com/yorukot/superfile/discussions](https://github.com/yorukot/superfile/discussions) 3. If you want to implement it yourself, follow the PR steps above --- ## 🧩 Don’t Know Where to Start? Check out GitHub’s official guide: [https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-a-project](https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-a-project) Still unsure? Open a discussion — we’re happy to help. --- ## ✅ Pull Request Checklist Please make sure your PR follows these steps: * [ ] I have run `go fmt ./...` to format the code * [ ] I have run `golangci-lint run` and fixed any reported issues * [ ] I have tested my changes and verified they work as expected * [ ] I have reviewed the diff to make sure I’m not committing any debug logs or TODOs * [ ] I have filled out the PR template with description, context, and screenshots if needed - [ ] I have checked that the PR title follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) format --- ## 🙏 Thank You Thank you for contributing to superfile! We appreciate every issue, pull request, and idea. Your help makes this project better for everyone. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 - Yorukot Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ .PHONY: all build test lint clean dev testsuite help # Default target all: dev # Development workflow (equivalent to ./dev.sh) dev: @FORCE_COLOR=1 ./dev.sh # Build only build: @FORCE_COLOR=1 ./dev.sh --skip-tests # Run tests test: @go test ./... # Run linter lint: @golangci-lint run # Run full testsuite testsuite: @FORCE_COLOR=1 ./dev.sh --testsuite # Clean build artifacts clean: @rm -rf ./bin/ # Show help help: @echo "Available targets:" @echo " all - Run full development workflow (default)" @echo " dev - Run development workflow (./dev.sh)" @echo " build - Build only (skip tests)" @echo " test - Run unit tests only" @echo " lint - Run linter only" @echo " testsuite - Run full testsuite" @echo " clean - Clean build artifacts" @echo " help - Show this help" @echo "" @echo "For more options, use: ./dev.sh --help" ================================================ FILE: README.md ================================================

superfile is supported by the community.

Special thanks to:

Warp sponsorship ### [Warp, the AI terminal for developers](https://www.warp.dev/?utm_source=github&utm_medium=referral&utm_campaign=superfile) [Available for macOS, Linux, & Windows](https://www.warp.dev/?utm_source=github&utm_medium=referral&utm_campaign=superfile)

superfile LOGO [![Go Report Card](https://goreportcard.com/badge/github.com/yorukot/superfile)](https://goreportcard.com/report/github.com/yorukot/superfile) [![License MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/yorukot/superfile/refs/heads/main/LICENSE) [![Discord Link](https://img.shields.io/discord/1338415256875307110?label=discord&logo=discord&logoColor=white)](https://discord.gg/YYtJ23Du7B) [![Release](https://img.shields.io/github/v/release/yorukot/superfile.svg?style=flat-square)](https://github.com/yorukot/superfile/releases/latest) [![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/yorukot/superfile?utm_source=oss&utm_medium=github&utm_campaign=yorukot%2Fsuperfile&labelColor=171717&color=FF570A&&label=CodeRabbit+Reviews)](https://www.coderabbit.ai/) ![](/asset/demo.png)
## Demo | Perform common operations | | ------------------------- | | ![](/asset/demo.gif) | ## Content - [Installation](#installation) - [Build](#build) - [Supported Systems](#supported-systems) - [Tutorial](#tutorial) - [Plugins](#plugins) - [Themes](#themes) - [Hotkeys](#hotkeys) - [Notes](#notes) - [Contributing](#contributing) - [Troubleshooting](#troubleshooting) - [Thanks](#thanks) - [Support](#Support) - [Core maintainer](#core-maintainer) - [Contributors](#contributors) - [Powered by](#powered-by) - [Star History](#star-history) ## Installation ### macOS and Linux ```bash bash -c "$(curl -sLo- https://superfile.dev/install.sh)" ``` If you want to inspect the script, see : [install.sh](./website/public/install.sh) ### Windows #### Powershell ```powershell powershell -ExecutionPolicy Bypass -Command "Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://superfile.dev/install.ps1'))" ``` If you want to inspect the script, see : [install.ps1](./website/public/install.ps1) #### [Winget](https://winget.run/) ```powershell winget install --id yorukot.superfile ``` #### [Scoop](https://scoop.sh/) ``` scoop install superfile ``` ### More installation methods [Click me to check on how to install](https://superfile.dev/getting-started/installation/) ## Build You can build the source code yourself by using these steps: **Requirements** - [golang](https://go.dev/doc/install) **Build Steps** Clone this repository using the following command: ``` git clone https://github.com/yorukot/superfile.git --depth=1 ``` Enter the downloaded directory: ```bash cd superfile ``` ### For macOS/Linux Run the `build.sh` file: ```bash ./build.sh ``` Add the binary file to your $PATH, e.g., in `/usr/local/bin`: ```bash sudo mv ./bin/spf /usr/local/bin ``` ### For Windows ```bash go build -o bin/spf.exe ``` Edit System Environment Variables and add superfile repo's `bin` directory to your PATH ## Start superfile ```bash spf ``` ## Supported Systems - \[x\] Linux - \[x\] macOS - \[x\] Windows (Not fully supported yet) ## Tutorial After you install superfile, you can go [here](https://superfile.dev/getting-started/tutorial/) to briefly understand how to use superfile! ## Plugins [Click me to the plugins wiki](https://superfile.dev/list/plugin-list/) ## Themes [Click me to the theme wiki](https://superfile.dev/configure/custom-theme/) ## Hotkeys > [!WARNING] > If you are vim/nvim user please change your default hotkeys config to vim version! [**Click me to see the hotkey wiki**](https://superfile.dev/configure/custom-hotkeys/) ## Notes We have an auto update functionality, that fetches superfile's latest released version from github (if last timestamp of last version check was less than 24 hours) and prints a prompt to user, if there is a newer version available. You can turn this off, by setting `auto_check_update` to false in superfile config. [**Click me to see the config wiki**](https://superfile.dev/configure/superfile-config/) ## Troubleshooting [**Click me to see common problem fix**](https://superfile.dev/troubleshooting/) ## Uninstalling ### macOS and Linux On macOS and Linux, you can uninstall superfile by simply removing the binary. If you installed superfile with sudo, run: ```bash sudo rm /usr/local/bin/spf ``` If you installed superfile without sudo, run: ```bash rm ~/.local/bin/spf ``` If you don't rember, just try removing both. ### Window To uninstall superfile on Windows, use this powershell script. ```powershell powershell -ExecutionPolicy Bypass -Command "Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://superfile.dev/uninstall.ps1'))" ``` ## Contributing If you want to contribute please follow the [contribution guide](./CONTRIBUTING.md) [**Click me to see changelog**](https://superfile.dev/changelog) ## Thanks ### Support - a Star on my GitHub repository would be nice 🌟 - You can buy a coffee for me 💖 [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/G2G1JEGGC) ### Core maintainer > We welcome anyone who wants to become a core maintainer. Feel free to reach out! - **[@yorukot](https://github.com/yorukot)** - Original author and maintainer - **[@lazysegtree](https://github.com/lazysegtree)** - Core maintainer ### Contributors **Thanks to all the contributors for making this project even greater!** ### Powered by JetBrains logo Thanks to JetBrains team for providing open-source licenses to support the maintenance of superfile. ### Star History **THANKS FOR All OF YOUR STARS!** Your stars are my motivation to keep updating! Star History Chart
## ༼ つ ◕_◕ ༽つ Please share.
================================================ FILE: asset/spf.desktop ================================================ [Desktop Entry] Name=spf GenericName=superfile Comment=fancy and modern terminal file manager Type=Application MimeType=inode/directory Icon=utilities-terminal Terminal=true TryExec=spf Exec=spf %u Categories=Utility;System;FileTools;FileManager;Filesystem;ConsoleOnly Keywords=File;Manager;Explorer ================================================ FILE: build.sh ================================================ #!/usr/bin/env bash # build the app CGO_ENABLED=0 go build -o ./bin/spf ================================================ FILE: cd_on_quit/cd_on_quit.fish ================================================ function spf set os $(uname -s) if test "$os" = "Linux" set spf_last_dir "$HOME/.local/state/superfile/lastdir" end if test "$os" = "Darwin" set spf_last_dir "$HOME/Library/Application Support/superfile/lastdir" end command spf $argv if test -f "$spf_last_dir" source "$spf_last_dir" rm -f -- "$spf_last_dir" >> /dev/null end end ================================================ FILE: cd_on_quit/cd_on_quit.ps1 ================================================ function spf() { param ( [string[]]$Params ) $spf_location = [Environment]::GetFolderPath("LocalApplicationData") + "\Programs\superfile\spf.exe" $SPF_LAST_DIR_PATH = [Environment]::GetFolderPath("LocalApplicationData") + "\superfile\lastdir" & $spf_location @Params if (Test-Path $SPF_LAST_DIR_PATH) { $SPF_LAST_DIR = Get-Content -Path $SPF_LAST_DIR_PATH Invoke-Expression $SPF_LAST_DIR Remove-Item -Force $SPF_LAST_DIR_PATH } } ================================================ FILE: cd_on_quit/cd_on_quit.sh ================================================ spf() { os=$(uname -s) # Linux if [[ "$os" == "Linux" ]]; then export SPF_LAST_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/superfile/lastdir" fi # macOS if [[ "$os" == "Darwin" ]]; then export SPF_LAST_DIR="$HOME/Library/Application Support/superfile/lastdir" fi command spf "$@" [ ! -f "$SPF_LAST_DIR" ] || { . "$SPF_LAST_DIR" rm -f -- "$SPF_LAST_DIR" > /dev/null } } ================================================ FILE: dev.sh ================================================ #!/usr/bin/env bash set -e # Exit on any error # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Check if colors should be disabled if [ "$FORCE_COLOR" != "1" ] && ([ -n "$MAKEFLAGS" ] || [ "$TERM" = "dumb" ] || [ ! -t 1 ]); then # Disable colors when running under Make or non-interactive RED='' GREEN='' YELLOW='' BLUE='' NC='' fi # Default values RUN_TESTSUITE=false SKIP_TESTS=false VERBOSE=false USE_GLOBAL_ENV=false # Function to print colored output print_step() { printf "${BLUE}==>${NC} %s\n" "$1" } print_success() { printf "${GREEN}✓${NC} %s\n" "$1" } print_warning() { printf "${YELLOW}⚠${NC} %s\n" "$1" } print_error() { printf "${RED}✗${NC} %s\n" "$1" } # Function to setup Python virtual environment setup_venv() { local venv_path="$1" # Remove existing incomplete virtual environment if activate script is missing or pip is broken if [ -d "$venv_path" ]; then if [ ! -f "$venv_path/bin/activate" ]; then print_warning "Removing incomplete virtual environment (missing activate)..." rm -rf "$venv_path" else # Test if pip works in the existing virtual environment if ! (source "$venv_path/bin/activate" && python -m pip --version > /dev/null 2>&1); then print_warning "Removing broken virtual environment (pip not working)..." rm -rf "$venv_path" fi fi fi if [ ! -d "$venv_path" ]; then print_step "Creating Python virtual environment..." if python3 -m venv "$venv_path" --upgrade-deps; then print_success "Virtual environment created at $venv_path" else print_error "Failed to create virtual environment" return 1 fi else print_step "Using existing virtual environment at $venv_path" fi # Check if activate script exists and has proper permissions if [ ! -f "$venv_path/bin/activate" ]; then print_error "Virtual environment activate script not found at $venv_path/bin/activate" return 1 fi # Ensure activate script has execution permissions chmod +x "$venv_path/bin/activate" # Activate virtual environment source "$venv_path/bin/activate" # Verify that we're in the virtual environment if [ -z "$VIRTUAL_ENV" ]; then print_error "Failed to activate virtual environment" return 1 fi # Upgrade pip to latest version print_step "Upgrading pip in virtual environment..." if python -m pip install --upgrade pip > /dev/null 2>&1; then print_success "Pip upgraded successfully" else print_warning "Failed to upgrade pip - continuing anyway" fi return 0 } # Function to cleanup virtual environment cleanup_venv() { if [ -n "$VIRTUAL_ENV" ]; then deactivate 2>/dev/null || true fi } # Setup trap for cleanup on exit/interruption trap cleanup_venv EXIT INT TERM # Function to show usage usage() { echo "Usage: $0 [OPTIONS]" echo "" echo "A comprehensive script for formatting, testing, and building superfile" echo "" echo "OPTIONS:" echo " -t, --testsuite Run integration testsuite after unit tests" echo " -s, --skip-tests Skip unit tests (only format, lint, and build)" echo " -v, --verbose Enable verbose output" echo " --use-global-env Use global Python environment instead of virtual environment" echo " -h, --help Show this help message" echo "" echo "STEPS PERFORMED:" echo " 1. Tidy Go modules" echo " 2. Format code with 'go fmt'" echo " 3. Run golangci-lint" echo " 4. Run unit tests (unless --skip-tests)" echo " 5. Run integration testsuite (if --testsuite)" echo " 6. Build spf binary" } # Parse command line arguments while [[ $# -gt 0 ]]; do case $1 in -t|--testsuite) RUN_TESTSUITE=true shift ;; -s|--skip-tests) SKIP_TESTS=true shift ;; -v|--verbose) VERBOSE=true shift ;; --use-global-env) USE_GLOBAL_ENV=true shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" usage exit 1 ;; esac done # Set verbose flag for commands if requested VERBOSE_FLAG="" if [ "$VERBOSE" = true ]; then VERBOSE_FLAG="-v" fi printf "${BLUE}🚀 Starting superfile development workflow${NC}\n" echo "" # Step 1: Tidy up the go mod print_step "Tidying Go modules..." if go mod tidy $VERBOSE_FLAG; then print_success "Go modules tidied" else print_error "Failed to tidy Go modules" exit 1 fi # Step 2: Format the code print_step "Formatting Go code..." if go fmt ./...; then print_success "Code formatted" else print_error "Failed to format code" exit 1 fi # Step 3: Run the linter print_step "Running golangci-lint..." if golangci-lint run; then print_success "Linting passed" else print_error "Linting failed" exit 1 fi # Step 4: Run unit tests (unless skipped) if [ "$SKIP_TESTS" = false ]; then print_step "Running unit tests..." if [ "$VERBOSE" = true ]; then if go test -v ./...; then print_success "Unit tests passed" else print_error "Unit tests failed" exit 1 fi else if go test ./...; then print_success "Unit tests passed" else print_error "Unit tests failed" exit 1 fi fi else print_warning "Skipping unit tests" fi # Step 5: Run integration testsuite (if requested) if [ "$RUN_TESTSUITE" = true ]; then print_step "Running integration testsuite..." # Check if Python is available if ! command -v python3 &> /dev/null; then print_error "Python3 is required for testsuite but not found" exit 1 fi # Check if testsuite requirements are installed if [ ! -f "testsuite/requirements.txt" ]; then print_error "testsuite/requirements.txt not found" exit 1 fi cd testsuite # Use virtual environment by default, global environment if requested if [ "$USE_GLOBAL_ENV" = true ]; then # Install requirements globally print_step "Installing testsuite requirements globally..." print_warning "Using global Python environment - consider removing --use-global-env flag to use virtual environment" if python3 -m pip install -r requirements.txt > /dev/null 2>&1; then print_success "Testsuite requirements installed globally" else print_warning "Failed to install testsuite requirements - continuing anyway" fi else # Setup virtual environment (default behavior) VENV_PATH="./venv" if ! setup_venv "$VENV_PATH"; then print_error "Failed to setup virtual environment" cd .. exit 1 fi # Install requirements in virtual environment print_step "Installing testsuite requirements in virtual environment..." if python -m pip install -r requirements.txt > /dev/null 2>&1; then print_success "Testsuite requirements installed in virtual environment" else print_error "Failed to install testsuite requirements in virtual environment" cd .. exit 1 fi fi # Run the testsuite if [ "$VERBOSE" = true ]; then if python3 main.py --debug; then print_success "Integration testsuite passed" else print_error "Integration testsuite failed" cd .. exit 1 fi else if python3 main.py; then print_success "Integration testsuite passed" else print_error "Integration testsuite failed" cd .. exit 1 fi fi cd .. fi # Step 6: Build the app print_step "Building spf binary..." if CGO_ENABLED=0 go build -o ./bin/spf; then print_success "Build completed successfully" else print_error "Build failed" exit 1 fi echo "" printf "${GREEN}🎉 All steps completed successfully!${NC}\n" printf "${BLUE}Binary location:${NC} ./bin/spf\n" # Show binary info if [ -f "./bin/spf" ]; then BINARY_SIZE=$(du -h ./bin/spf | cut -f1) printf "${BLUE}Binary size:${NC} $BINARY_SIZE\n" fi ================================================ FILE: flake.nix ================================================ { description = "A fancy, pretty terminal file manager"; inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; flake-compat.url = "github:edolstra/flake-compat"; flake-compat.flake = false; gomod2nix.url = "github:nix-community/gomod2nix"; gomod2nix.inputs.nixpkgs.follows = "nixpkgs"; gomod2nix.inputs.flake-utils.follows = "flake-utils"; }; outputs = inputs @ {...}: inputs.flake-utils.lib.eachDefaultSystem ( system: let overlays = [ inputs.gomod2nix.overlays.default ]; pkgs = import inputs.nixpkgs { inherit system overlays; }; in rec { packages = rec { superfile = pkgs.buildGoApplication { pname = "superfile"; version = "1.5.0"; src = ./.; modules = ./gomod2nix.toml; nativeCheckInputs = with pkgs; [ zoxide exiftool writableTmpDirAsHomeHook ]; }; default = superfile; }; apps = rec { superfile = { type = "app"; program = "${packages.superfile}/bin/superfile"; }; default = superfile; }; devShells = { default = pkgs.mkShell { packages = with pkgs; [ ## golang delve go-outline go golangci-lint gopkgs gopls gotools nix gomod2nix nixpkgs-fmt ]; }; }; } ); } ================================================ FILE: go.mod ================================================ module github.com/yorukot/superfile go 1.25.5 require ( github.com/adrg/xdg v0.5.3 github.com/alecthomas/chroma/v2 v2.21.1 github.com/atotto/clipboard v0.1.4 github.com/barasher/go-exiftool v1.10.0 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.10.1 github.com/fatih/color v1.18.0 github.com/fvbommel/sortorder v1.1.0 github.com/hymkor/trash-go v0.2.0 github.com/lazysegtree/go-zoxide v0.1.0 github.com/lithammer/shortuuid v3.0.0+incompatible github.com/muesli/termenv v0.16.0 github.com/reinhrst/fzf-lib v0.9.0 github.com/rkoesters/xdg v0.0.1 github.com/shirou/gopsutil/v4 v4.25.12 github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v3 v3.6.1 golang.org/x/image v0.35.0 golang.org/x/mod v0.31.0 golift.io/xtractr v0.2.2 ) require ( github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/ebitengine/purego v0.9.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/term v0.18.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( github.com/andybalholm/brotli v1.0.5 // indirect github.com/bodgit/plumbing v1.3.0 // indirect github.com/bodgit/sevenzip v1.4.0 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/connesc/cipherio v0.2.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/kdomanski/iso9660 v0.3.3 // indirect github.com/klauspost/compress v1.16.3 // indirect github.com/nwaples/rardecode v1.1.3 // indirect github.com/pierrec/lz4/v4 v4.1.17 // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/yorukot/ansichroma v0.1.0 go4.org v0.0.0-20230225012048-214862532bf5 // indirect ) require ( github.com/BourgeoisBear/rasterm v1.1.2 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/disintegration/imaging v1.6.2 github.com/go-ole/go-ole v1.2.6 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 github.com/pelletier/go-toml/v2 v2.2.4 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.33.0 ) ================================================ FILE: go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BourgeoisBear/rasterm v1.1.2 h1:hWHZBZ45N366uNSqxWFYBV0y19q8fXRXADhPkoLF4Ss= github.com/BourgeoisBear/rasterm v1.1.2/go.mod h1:Ifd+To5s/uyUiYx+B4fxhS8lUNwNLSxDBjskmC5pEyw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA= github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs= github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= github.com/bodgit/sevenzip v1.4.0 h1:OPUy/dCA9KvDTxcwQQYkv/W7kHmxhP4B3vpW8S02WlA= github.com/bodgit/sevenzip v1.4.0/go.mod h1:0WaxeLofKpADVzngQXcMoXb98627kLDiqTZyDLaVxiA= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/connesc/cipherio v0.2.1 h1:FGtpTPMbKNNWByNrr9aEBtaJtXjqOzkIXNYJp6OEycw= github.com/connesc/cipherio v0.2.1/go.mod h1:ukY0MWJDFnJEbXMQtOcn2VmTpRfzcTz4OoVrWGGJZcA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3 h1:GV+pQPG/EUUbkh47niozDcADz6go/dUwhVzdUQHIVRw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hymkor/trash-go v0.2.0 h1:t51zidKT8WuMTeeiMBsJxx+BDwnJtaaf/ckpjle2GOE= github.com/hymkor/trash-go v0.2.0/go.mod h1:pZ07qBUuGdTWPdymNtE97NAXHDY5W/b5szvoBVOhJ3U= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kdomanski/iso9660 v0.3.3 h1:cNwM9L2L1Hzc5hZWGy6fPJ92UyWDccaY69DmEPlfDNY= github.com/kdomanski/iso9660 v0.3.3/go.mod h1:K+UlIGxKgtrdAWyoigPnFbeQLVs/Xudz4iztWFThBwo= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lazysegtree/go-zoxide v0.1.0 h1:gL11AWS9fJDuB7FYxsh+ohf8ozMXeK31E/PC6f9jNBc= github.com/lazysegtree/go-zoxide v0.1.0/go.mod h1:C1K2SDM4iHkQeFVrWBiRX3tU9jpAz6BrnoHQYSiHZl0= github.com/lithammer/shortuuid v3.0.0+incompatible h1:NcD0xWW/MZYXEHa6ITy6kaXN5nwm/V115vj2YXfhS0w= github.com/lithammer/shortuuid v3.0.0+incompatible/go.mod h1:FR74pbAuElzOUuenUHTK2Tciko1/vKuIKS9dSkDrA4w= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc= github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/reinhrst/fzf-lib v0.9.0 h1:P57AkpmDOmRhuBTDclvasclcOY7kNVWJUVVEBEB9kCA= github.com/reinhrst/fzf-lib v0.9.0/go.mod h1:06+ssO8WzlXxOUT7RtLo2gXf9tSR7HU8fbBgFX1VP6k= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rkoesters/xdg v0.0.1 h1:RmfYxghVvIsb4d51u5LtNOcwqY5r3P44u6o86qqvBMA= github.com/rkoesters/xdg v0.0.1/go.mod h1:5DcbjvJkY00fIOKkaBnylbC/rmc1NNJP5dmUcnlcm7U= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY= github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo= github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yorukot/ansichroma v0.1.0 h1:S7mAB41CgSbYp2tcERMN/bT3cNfdNbexmLm5R0GftzA= github.com/yorukot/ansichroma v0.1.0/go.mod h1:Er9xbqeEBUScZnfUezMWl31vBYleQBxoZ0tBhOnqhwI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I= golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golift.io/xtractr v0.2.2 h1:MvujxeuX629d1rQs2VJbbcvYMvMmN5SzIkEflU5ryOc= golift.io/xtractr v0.2.2/go.mod h1:30CvLMUY3yOS2VoKZTTMtzeeljCzBcWkr8dU6EHqfh8= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= ================================================ FILE: gomod2nix.toml ================================================ schema = 3 [mod] [mod.'github.com/BourgeoisBear/rasterm'] version = 'v1.1.2' hash = 'sha256-fYV85hVcIAT01xriEpWn0f/YyGgsc7W+0SW6iz9K/+A=' [mod.'github.com/adrg/xdg'] version = 'v0.5.3' hash = 'sha256-bo6tBgHS+3sl6f4oWpmdFrZjfV6eA/3xAlysSW0bIEs=' [mod.'github.com/alecthomas/chroma/v2'] version = 'v2.21.1' hash = 'sha256-N6ske1LCEJy7MZS1L/0DEaWXnWlVqGaoU6oCwrM5teQ=' [mod.'github.com/andybalholm/brotli'] version = 'v1.0.5' hash = 'sha256-/qS8wU8yZQJ+uTOg66rEl9s7spxq9VIXF5L1BcaEClc=' [mod.'github.com/atotto/clipboard'] version = 'v0.1.4' hash = 'sha256-ZZ7U5X0gWOu8zcjZcWbcpzGOGdycwq0TjTFh/eZHjXk=' [mod.'github.com/aymanbagabas/go-osc52/v2'] version = 'v2.0.1' hash = 'sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=' [mod.'github.com/barasher/go-exiftool'] version = 'v1.10.0' hash = 'sha256-ed+Jhji3usyMROzjw7Xq++khLz3mDrlgfn+vrBXWZgg=' [mod.'github.com/bodgit/plumbing'] version = 'v1.3.0' hash = 'sha256-nmLdJAB2sWpwHb2lFrYTcMR4yusMM1U629Pm2K9hfm0=' [mod.'github.com/bodgit/sevenzip'] version = 'v1.4.0' hash = 'sha256-1y3jeuXQPEYWe8iuOhplbHtZjN2GgAXDnJaJohDMwLc=' [mod.'github.com/bodgit/windows'] version = 'v1.0.1' hash = 'sha256-GSpAGboli54A5wDMpDEdZDYh55o1Zs8dZzFIf7hYdMY=' [mod.'github.com/charmbracelet/bubbles'] version = 'v0.21.0' hash = 'sha256-cfjUHgy9eq5SretTHuYuRaeeT6QmJYQBB9dsI8QSnW0=' [mod.'github.com/charmbracelet/bubbletea'] version = 'v1.3.10' hash = 'sha256-7wr85TLszu1CHNEMv+o4w+r24Z0xdzCgecPv+ZtRX/A=' [mod.'github.com/charmbracelet/colorprofile'] version = 'v0.2.3-0.20250311203215-f60798e515dc' hash = 'sha256-D9E/bMOyLXAUVOHA1/6o3i+vVmLfwIMOWib6sU7A6+Q=' [mod.'github.com/charmbracelet/harmonica'] version = 'v0.2.0' hash = 'sha256-fi5N0IXhSbbYHdSZFngCfpT4kdiEaKedqj8YpnlvX0o=' [mod.'github.com/charmbracelet/lipgloss'] version = 'v1.1.0' hash = 'sha256-RHsRT2EZ1nDOElxAK+6/DC9XAaGVjDTgPvRh3pyCfY4=' [mod.'github.com/charmbracelet/x/ansi'] version = 'v0.10.1' hash = 'sha256-nY4zkUGnuD+Lczwt+NMXdQ38cAsy5mtxzXrFSJmR0E4=' [mod.'github.com/charmbracelet/x/cellbuf'] version = 'v0.0.13-0.20250311204145-2c3ea96c31dd' hash = 'sha256-XAhCOt8qJ2vR77lH1ez0IVU1/2CaLTq9jSmrHVg5HHU=' [mod.'github.com/charmbracelet/x/term'] version = 'v0.2.1' hash = 'sha256-VBkCZLI90PhMasftGw3403IqoV7d3E5WEGAIVrN5xQM=' [mod.'github.com/clipperhouse/stringish'] version = 'v0.1.1' hash = 'sha256-Mp8M1CRbwr6dcJ4BD9tXD5I78ZgCFEm0GDxJv0GYReg=' [mod.'github.com/clipperhouse/uax29/v2'] version = 'v2.3.0' hash = 'sha256-dLL70mEOxrmYVoQjy2K7QvZiahAlJKOcFDz2mW/R/Do=' [mod.'github.com/connesc/cipherio'] version = 'v0.2.1' hash = 'sha256-PeuFnTak2WADesP8YTBqHP/XyvBue9bdKHYser2v6LU=' [mod.'github.com/davecgh/go-spew'] version = 'v1.1.2-0.20180830191138-d8f796af33cc' hash = 'sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc=' [mod.'github.com/disintegration/imaging'] version = 'v1.6.2' hash = 'sha256-pSeMTPvSkxlthh65LjNYYhPLvCZDkBgVgAGYWW0Aguo=' [mod.'github.com/dlclark/regexp2'] version = 'v1.11.5' hash = 'sha256-jN5+2ED+YbIoPIuyJ4Ou5pqJb2w1uNKzp5yTjKY6rEQ=' [mod.'github.com/ebitengine/purego'] version = 'v0.9.1' hash = 'sha256-iVfU8vaJ7IPa92dUeHeuW+yKvUbe59F/eV7GlDRIAcE=' [mod.'github.com/erikgeiser/coninput'] version = 'v0.0.0-20211004153227-1c3628e74d0f' hash = 'sha256-OWSqN1+IoL73rWXWdbbcahZu8n2al90Y3eT5Z0vgHvU=' [mod.'github.com/fatih/color'] version = 'v1.18.0' hash = 'sha256-pP5y72FSbi4j/BjyVq/XbAOFjzNjMxZt2R/lFFxGWvY=' [mod.'github.com/fvbommel/sortorder'] version = 'v1.1.0' hash = 'sha256-553Rg/amloO6nv67p0ARsKas9QNnTQucpi08vNLtjQU=' [mod.'github.com/go-ole/go-ole'] version = 'v1.2.6' hash = 'sha256-+oxitLeJxYF19Z6g+6CgmCHJ1Y5D8raMi2Cb3M6nXCs=' [mod.'github.com/google/uuid'] version = 'v1.6.0' hash = 'sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=' [mod.'github.com/hashicorp/errwrap'] version = 'v1.1.0' hash = 'sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw=' [mod.'github.com/hashicorp/go-multierror'] version = 'v1.1.1' hash = 'sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA=' [mod.'github.com/hymkor/trash-go'] version = 'v0.2.0' hash = 'sha256-p7zJYJpPjNAYKRNW7No+BXRemNabGp/No1mp9ItHXII=' [mod.'github.com/kdomanski/iso9660'] version = 'v0.3.3' hash = 'sha256-iMyzZnZCKXUfBKJDdqwEYyzcFKo/VEkoo4Lm/Ct8tj4=' [mod.'github.com/klauspost/compress'] version = 'v1.16.3' hash = 'sha256-dU0OgO5afQ1z5s83Y3w8Bg0ftvg+ikWbktUACEgY3OQ=' [mod.'github.com/lazysegtree/go-zoxide'] version = 'v0.1.0' hash = 'sha256-PKEV+zCKf/7MsFAMMctL/pFSnEsri1yS8Bzxcj1dLWE=' [mod.'github.com/lithammer/shortuuid'] version = 'v3.0.0+incompatible' hash = 'sha256-dD6ArCHGnpo84RBy6SI2kUjMqHS4IZU+K+DgAgmRakY=' [mod.'github.com/lucasb-eyer/go-colorful'] version = 'v1.3.0' hash = 'sha256-6BKrJsfmxie+YFAWzTYVPQfrwjQEXRo+J8LY+50C1BU=' [mod.'github.com/mattn/go-colorable'] version = 'v0.1.13' hash = 'sha256-qb3Qbo0CELGRIzvw7NVM1g/aayaz4Tguppk9MD2/OI8=' [mod.'github.com/mattn/go-isatty'] version = 'v0.0.20' hash = 'sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=' [mod.'github.com/mattn/go-localereader'] version = 'v0.0.1' hash = 'sha256-JlWckeGaWG+bXK8l8WEdZqmSiTwCA8b1qbmBKa/Fj3E=' [mod.'github.com/mattn/go-runewidth'] version = 'v0.0.19' hash = 'sha256-GpnbKplhX410Q/eIdknvWbYZgdav1keN+7wNUeOSMHE=' [mod.'github.com/muesli/ansi'] version = 'v0.0.0-20230316100256-276c6243b2f6' hash = 'sha256-qRKn0Bh2yvP0QxeEMeZe11Vz0BPFIkVcleKsPeybKMs=' [mod.'github.com/muesli/cancelreader'] version = 'v0.2.2' hash = 'sha256-uEPpzwRJBJsQWBw6M71FDfgJuR7n55d/7IV8MO+rpwQ=' [mod.'github.com/muesli/reflow'] version = 'v0.3.0' hash = 'sha256-Pou2ybE9SFSZG6YfZLVV1Eyfm+X4FuVpDPLxhpn47Cc=' [mod.'github.com/muesli/termenv'] version = 'v0.16.0' hash = 'sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI=' [mod.'github.com/nwaples/rardecode'] version = 'v1.1.3' hash = 'sha256-X7Cg0kEygyy6Xw6sxRF9HirgefkH9tn9UPPelxRaAGg=' [mod.'github.com/pelletier/go-toml/v2'] version = 'v2.2.4' hash = 'sha256-8qQIPldbsS5RO8v/FW/se3ZsAyvLzexiivzJCbGRg2Q=' [mod.'github.com/pierrec/lz4/v4'] version = 'v4.1.17' hash = 'sha256-36L+GNhRrHBCyhbHCqweCk5rfmggdRtHqH6g5m1ViQI=' [mod.'github.com/pmezard/go-difflib'] version = 'v1.0.1-0.20181226105442-5d4384ee4fb2' hash = 'sha256-XA4Oj1gdmdV/F/+8kMI+DBxKPthZ768hbKsO3d9Gx90=' [mod.'github.com/power-devops/perfstat'] version = 'v0.0.0-20240221224432-82ca36839d55' hash = 'sha256-ujzuJ1ttQgjHQJEij4O/2+I8DZaUVZQCQgA4ysfqulI=' [mod.'github.com/reinhrst/fzf-lib'] version = 'v0.9.0' hash = 'sha256-UUe5g+oXopDj+JjvzAguqJ70/a4pqZhn7pdJiFE97yI=' [mod.'github.com/rivo/uniseg'] version = 'v0.4.7' hash = 'sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=' [mod.'github.com/rkoesters/xdg'] version = 'v0.0.1' hash = 'sha256-+ckoKNaiEqQIpRFPr3CNRE3w8+OHmRKZ2cn6MquNIOE=' [mod.'github.com/rwcarlsen/goexif'] version = 'v0.0.0-20190401172101-9e8deecbddbd' hash = 'sha256-AiY2T9hXj6jnfldYDoe4WNr3FldpVTxc3lScR++HOLc=' [mod.'github.com/shirou/gopsutil/v4'] version = 'v4.25.12' hash = 'sha256-gzk9GW4+tXUWmxAVD3by/k4D/+l++TvajRVTkQJvwmM=' [mod.'github.com/stretchr/testify'] version = 'v1.11.1' hash = 'sha256-sWfjkuKJyDllDEtnM8sb/pdLzPQmUYWYtmeWz/5suUc=' [mod.'github.com/ulikunitz/xz'] version = 'v0.5.15' hash = 'sha256-L5KYLue5U14bxUuNyhZ6lIjbda6eCQsx1V6gToqfRdk=' [mod.'github.com/urfave/cli/v3'] version = 'v3.6.1' hash = 'sha256-q1WeKEvWoSyA0Wcan+Edjm71e0/vnPmqVvlfjUix7+8=' [mod.'github.com/xo/terminfo'] version = 'v0.0.0-20220910002029-abceb7e1c41e' hash = 'sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=' [mod.'github.com/yorukot/ansichroma'] version = 'v0.1.0' hash = 'sha256-aIQZ16auuGfGLltKt2lm2T83f4sALrmRpOujxQepkS4=' [mod.'github.com/yusufpapurcu/wmi'] version = 'v1.2.4' hash = 'sha256-N+YDBjOW59YOsZ2lRBVtFsEEi48KhNQRb63/0ZSU3bA=' [mod.'go4.org'] version = 'v0.0.0-20230225012048-214862532bf5' hash = 'sha256-8y7krSESdxZfxTzo16EDqmF8USRzGmLPYr3iKuVYzzE=' [mod.'golang.org/x/exp'] version = 'v0.0.0-20231006140011-7918f672742d' hash = 'sha256-2SO1etTQ6UCUhADR5sgvDEDLHcj77pJKCIa/8mGDbAo=' [mod.'golang.org/x/image'] version = 'v0.35.0' hash = 'sha256-l9GP7N78XsAxe+LFUrBERp16SnerF7I5/WSTvHLGe2M=' [mod.'golang.org/x/mod'] version = 'v0.31.0' hash = 'sha256-ZVNmaZADgM3+30q9rW8q4gP6ySkT7r1eb4vrHIlpCjM=' [mod.'golang.org/x/sys'] version = 'v0.38.0' hash = 'sha256-1+i5EaG3JwH3KMtefzJLG5R6jbOeJM4GK3/LHBVnSy0=' [mod.'golang.org/x/term'] version = 'v0.18.0' hash = 'sha256-lpze9arFZIhBV8Ht3VZyoiUwqPkeH2IwfXt8M3xljiM=' [mod.'golang.org/x/text'] version = 'v0.33.0' hash = 'sha256-XdA6D39ESuJkaaM/SRBnqZzjKUwi6Gbt1Si1nvauTr4=' [mod.'golift.io/xtractr'] version = 'v0.2.2' hash = 'sha256-ihKdIrWG1DKADjQ4X1AW62EGZWGgYU+9dY3g260bNOY=' [mod.'gopkg.in/yaml.v3'] version = 'v3.0.1' hash = 'sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU=' ================================================ FILE: main.go ================================================ package main import ( "embed" "github.com/yorukot/superfile/src/cmd" ) var ( //go:embed src/superfile_config/* content embed.FS ) func main() { cmd.Run(content) } ================================================ FILE: release/release.sh ================================================ #!/usr/bin/env -S bash -euo pipefail projectName="superfile" version="v1.5.0" osList=("darwin" "linux" "windows") archList=("amd64" "arm64") mkdir dist # Prevent macOS from adding ._* files to archives export COPYFILE_DISABLE=1 for os in "${osList[@]}"; do if [ "$os" = "windows" ]; then for arch in "${archList[@]}"; do echo "$projectName-$os-$version-$arch" mkdir "./dist/$projectName-$os-$version-$arch" cd ../ || exit env GOOS="$os" GOARCH="$arch" CGO_ENABLED=0 go build -o "./release/dist/$projectName-$os-$version-$arch/spf.exe" main.go cd ./release || exit zip -r "./dist/$projectName-$os-$version-$arch.zip" "./dist/$projectName-$os-$version-$arch" done else for arch in "${archList[@]}"; do echo "$projectName-$os-$version-$arch" mkdir "./dist/$projectName-$os-$version-$arch" cd ../ || exit env GOOS="$os" GOARCH="$arch" CGO_ENABLED=0 go build -o "./release/dist/$projectName-$os-$version-$arch/spf" main.go cd ./release || exit tar czf "./dist/$projectName-$os-$version-$arch.tar.gz" "./dist/$projectName-$os-$version-$arch" done fi done ================================================ FILE: release/release_check.md ================================================ - [ ] check all plugins is disable - [ ] check update version and zip file ================================================ FILE: release/remove_all_spf_config.sh ================================================ #!/usr/bin/env bash rm -r "$HOME/.config/superfile" rm -r "$HOME/.local/state/superfile" rm -r "$HOME/.local/share/superfile" ================================================ FILE: src/cmd/debug_info.go ================================================ package cmd import ( "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "github.com/fatih/color" "github.com/yorukot/superfile/src/pkg/utils" variable "github.com/yorukot/superfile/src/config" ) const ( keyWidth = 20 maxVersionLength = 50 ) type debugPrinter struct { titleColor *color.Color flagColor *color.Color warningColor *color.Color successColor *color.Color } func newDebugPrinter() *debugPrinter { return &debugPrinter{ titleColor: color.New(color.FgGreen, color.Bold), flagColor: color.New(color.FgCyan, color.Bold), warningColor: color.New(color.FgRed, color.Bold), successColor: color.New(color.FgGreen), } } func printDebugInfo() { dp := newDebugPrinter() fmt.Println() dp.printHeader("Superfile") dp.printKeyValue("Version", variable.CurrentVersion+variable.PreReleaseSuffix) fmt.Println() dp.printHeader("System") dp.printKeyValue("OS", runtime.GOOS) dp.printKeyValue("Arch", runtime.GOARCH) if kernel, err := getKernelVersion(); err == nil { dp.printKeyValue("Kernel", kernel) } fmt.Println() dp.printHeader("Configuration") dp.printKeyValue("Config File", variable.ConfigFile) dp.printKeyValue("Hotkeys File", variable.HotkeysFile) dp.printKeyValue("Theme Folder", variable.ThemeFolder) dp.printKeyValue("Log File", variable.LogFile) dp.printKeyValue("Data Dir", variable.SuperFileDataDir) fmt.Println() dp.printHeader("Environment") if runtime.GOOS == utils.OsWindows { dp.printEnv("COMSPEC") dp.printEnv("APPDATA") dp.printEnv("LOCALAPPDATA") } else { dp.printEnv("TERM") dp.printEnv("TERM_PROGRAM") dp.printEnv("TERM_PROGRAM_VERSION") dp.printEnv("SHELL") dp.printEnv("EDITOR") dp.printEnv("VISUAL") dp.printEnv("XDG_SESSION_TYPE") dp.printEnv("WAYLAND_DISPLAY") dp.printEnv("DISPLAY") } fmt.Println() dp.printHeader("Dependencies") dp.checkDependency("ffmpeg", "-version") dp.checkDependency("pdftoppm", "-v") dp.checkDependency("exiftool", "-ver") dp.checkDependency("bat", "--version") dp.checkDependency("zoxide", "--version") switch runtime.GOOS { case utils.OsDarwin: dp.checkDependency("open", "") dp.checkDependency("pbcopy", "") case utils.OsWindows: dp.checkDependency("clip", "") case utils.OsLinux: dp.checkDependency("xdg-open", "--version") dp.checkDependency("wl-copy", "--version") dp.checkDependency("xclip", "-version") dp.checkDependency("xsel", "--version") } } func (dp *debugPrinter) printHeader(text string) { _, _ = dp.titleColor.Add(color.Underline).Println(text) } func (dp *debugPrinter) printKeyValue(key, value string) { if filepath.IsAbs(value) { if _, err := os.Stat(value); os.IsNotExist(err) { value = dp.warningColor.Sprint(value + " (Not Found)") } } // Use fixed width formatting for key keyStr := fmt.Sprintf("%-*s", keyWidth, key) _, _ = dp.flagColor.Print(keyStr) fmt.Printf(": %s\n", value) } func (dp *debugPrinter) printEnv(key string) { val := os.Getenv(key) if val == "" { val = "Not Set" } dp.printKeyValue(key, val) } func (dp *debugPrinter) checkDependency(name string, flag string) { path, err := exec.LookPath(name) var status string if err != nil { status = dp.warningColor.Sprint("Not Found") } else { // Try to get version version := "Found at " + path if flag != "" { //nolint:gosec // flags are hardcoded strings cmd := exec.Command(name, strings.Split(flag, " ")...) out, err := cmd.CombinedOutput() if err == nil { lines := strings.Split(string(out), "\n") if len(lines) > 0 { v := strings.TrimSpace(lines[0]) if len(v) > maxVersionLength { v = v[:maxVersionLength] + "..." } if v != "" { version = v } } } } status = dp.successColor.Sprint(version) } keyStr := fmt.Sprintf("%-*s", keyWidth, name) _, _ = dp.flagColor.Print(keyStr) fmt.Printf(": %s\n", status) } func getKernelVersion() (string, error) { if runtime.GOOS == utils.OsWindows { cmd := exec.Command("cmd", "/c", "ver") out, err := cmd.Output() if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } out, err := exec.Command("uname", "-r").Output() if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } ================================================ FILE: src/cmd/help_printer.go ================================================ package cmd import ( "fmt" "io" "os" "path/filepath" "strings" "github.com/fatih/color" "github.com/urfave/cli/v3" ) // CustomHelpPrinter provides cargo-style colored help output for superfile CLI func CustomHelpPrinter(w io.Writer, templ string, data interface{}) { // Define color styles matching superfile's aesthetic titleColor := color.New(color.FgGreen, color.Bold) flagColor := color.New(color.FgCyan, color.Bold) commandColor := color.New(color.FgBlue, color.Bold) accentColor := color.New(color.FgMagenta, color.Bold) switch v := data.(type) { case *cli.Command: // Get the actual binary name from os.Args[0] binaryName := filepath.Base(os.Args[0]) printUsage(w, titleColor, accentColor, binaryName, v) printCommands(w, titleColor, commandColor, v) printFlags(w, titleColor, flagColor, v) // Print version info if available if v.Version != "" { fmt.Printf("Version: ") _, _ = accentColor.Fprintf(w, "%s\n\n", v.Version) } // Print help footer using the actual binary name fmt.Fprint(w, "Use \"") _, _ = accentColor.Fprintf(w, "%s", binaryName) fmt.Fprint(w, " [COMMAND] --help\" for more information about a command.\n") default: // Fallback to default template rendering for other cases cli.HelpPrinterCustom(w, templ, data, nil) } } func printUsage(w io.Writer, titleColor *color.Color, accentColor *color.Color, binaryName string, v *cli.Command) { _, _ = titleColor.Fprintf(w, "Usage:") fmt.Fprint(w, " ") _, _ = accentColor.Fprintf(w, "%s", binaryName) if len(v.Commands) > 0 { fmt.Fprint(w, " [COMMAND]") } if len(v.Flags) > 0 { fmt.Fprint(w, " [OPTIONS]") } if v.ArgsUsage != "" { fmt.Fprintf(w, " %s", v.ArgsUsage) } fmt.Fprintln(w) fmt.Fprintln(w) if v.Description != "" { fmt.Fprintf(w, "%s\n\n", strings.TrimSpace(v.Description)) } } func printCommands(w io.Writer, titleColor *color.Color, commandColor *color.Color, v *cli.Command) { if len(v.Commands) == 0 { return } _, _ = titleColor.Fprintf(w, "Commands:\n") for _, cmd := range v.Commands { // Format command name with aliases cmdDisplay := cmd.Name if len(cmd.Aliases) > 0 { cmdDisplay = fmt.Sprintf("%s, %s", cmd.Name, strings.Join(cmd.Aliases, ", ")) } _, _ = commandColor.Fprintf(w, " %-20s", cmdDisplay) fmt.Fprintf(w, " %s\n", cmd.Usage) } fmt.Fprintln(w) } func printFlags(w io.Writer, titleColor *color.Color, flagColor *color.Color, v *cli.Command) { if len(v.Flags) == 0 { return } _, _ = titleColor.Fprintf(w, "Options:\n") for _, flag := range v.Flags { names := flag.Names() // Format flag names with proper prefixes and aliases var flagParts []string var valuePlaceholder string var usage string // Determine flag type, value placeholder, and usage in one switch switch f := flag.(type) { case *cli.BoolFlag: // Boolean flags don't need values valuePlaceholder = "" usage = f.Usage case *cli.StringFlag: valuePlaceholder = " " usage = f.Usage if f.Value != "" { usage += fmt.Sprintf(" (default: %q)", f.Value) } case *cli.StringSliceFlag: valuePlaceholder = " ..." usage = f.Usage case *cli.IntFlag: valuePlaceholder = " " usage = f.Usage if f.Value != 0 { usage += fmt.Sprintf(" (default: %d)", f.Value) } default: valuePlaceholder = " " usage = "No description available" } for _, name := range names { if len(name) == 1 { flagParts = append(flagParts, "-"+name) } else { flagParts = append(flagParts, "--"+name) } } flagStr := strings.Join(flagParts, ", ") + valuePlaceholder _, _ = flagColor.Fprintf(w, " %-30s", flagStr) fmt.Fprintf(w, " %s\n", usage) } fmt.Fprintln(w) } ================================================ FILE: src/cmd/main.go ================================================ package cmd import ( "context" "embed" "encoding/json" "fmt" "io" "log/slog" "net/http" "os" "time" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/pkg/utils" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/urfave/cli/v3" "golang.org/x/mod/semver" variable "github.com/yorukot/superfile/src/config" internal "github.com/yorukot/superfile/src/internal" ) // Run superfile app func Run(content embed.FS) { // Enable custom colored help output cli.HelpPrinter = CustomHelpPrinter //nolint:reassign // Intentionally reassigning to customize help output // Before we open log file, set all "non debug" logs to stdout utils.SetRootLoggerToStdout(false) common.LoadInitialPrerenderedVariables() common.LoadAllDefaultConfig(content) app := &cli.Command{ Name: "superfile", Version: variable.CurrentVersion + variable.PreReleaseSuffix, Description: "Pretty fancy and modern terminal file manager ", ArgsUsage: "[PATH]...", Commands: []*cli.Command{ { Name: "path-list", Aliases: []string{"pl"}, Usage: "Print the path to the configuration and directory", Action: func(_ context.Context, c *cli.Command) error { if c.Bool("lastdir-file") { fmt.Println(variable.LastDirFile) return nil } fmt.Printf("%-*s %s\n", common.HelpKeyColumnWidth, lipgloss.NewStyle().Foreground(lipgloss.Color("#66b2ff")).Render("[Configuration file path]"), variable.ConfigFile, ) fmt.Printf("%-*s %s\n", common.HelpKeyColumnWidth, lipgloss.NewStyle().Foreground(lipgloss.Color("#ffcc66")).Render("[Hotkeys file path]"), variable.HotkeysFile, ) logStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#66ff66")) configStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff9999")) dataStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#ff66ff")) fmt.Printf("%-*s %s\n", common.HelpKeyColumnWidth, logStyle.Render("[Log file path]"), variable.LogFile) fmt.Printf("%-*s %s\n", common.HelpKeyColumnWidth, configStyle.Render("[Configuration directory path]"), variable.SuperFileMainDir) fmt.Printf("%-*s %s\n", common.HelpKeyColumnWidth, dataStyle.Render("[Data directory path]"), variable.SuperFileDataDir) return nil }, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "lastdir-file", Aliases: []string{"ld"}, Usage: "Print path to lastdir file (Where last dir is written when cd_on_quit config is true)", Value: false, }, }, }, }, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "debug-info", Aliases: []string{"di"}, Usage: "Print debug information", Value: false, }, &cli.BoolFlag{ Name: "fix-hotkeys", Aliases: []string{"fh"}, Usage: "Adds any missing hotkeys to the hotkey config file", Value: false, }, &cli.BoolFlag{ Name: "fix-config-file", Aliases: []string{"fch"}, Usage: "Adds any missing hotkeys to the hotkey config file", Value: false, }, &cli.BoolFlag{ Name: "print-last-dir", Aliases: []string{"pld"}, Usage: "Print the last dir to stdout on exit (to use for cd)", Value: false, }, &cli.StringFlag{ Name: "config-file", Aliases: []string{"c"}, Usage: "Specify the path to a different config file", Value: "", // Default to the blank string indicating non-usage of flag }, &cli.StringFlag{ Name: "hotkey-file", Aliases: []string{"hf"}, Usage: "Specify the path to a different hotkey file", Value: "", // Default to the blank string indicating non-usage of flag }, &cli.StringFlag{ Name: "chooser-file", Aliases: []string{"cf"}, Usage: "On trying to open any file, superfile will write to its path to this file, and exit", Value: "", // Default to the blank string indicating non-usage of flag }, }, Action: spfAppAction, } err := app.Run(context.Background(), os.Args) if err != nil { utils.PrintlnAndExit(err) } } func spfAppAction(_ context.Context, c *cli.Command) error { variable.UpdateVarFromCliArgs(c) if c.Bool("debug-info") { printDebugInfo() return nil } // If no args are called along with "spf" use current dir firstPanelPaths := []string{""} if c.Args().Present() { firstPanelPaths = c.Args().Slice() } InitConfigFile() firstUse := checkFirstUse() p := tea.NewProgram(internal.InitialModel(firstPanelPaths, firstUse), tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { utils.PrintfAndExitf("Alas, there's been an error: %v", err) } // This must be after calling internal.InitialModel() // so that we know `common.Config` is loaded // Should not be a goroutine, Otherwise the main // goroutine will exit first, and this will not be able to finish CheckForUpdates() if variable.PrintLastDir { fmt.Println(variable.LastDir) } return nil } // Create proper directories for storing configuration and write default // configurations to Config and Hotkeys toml func InitConfigFile() { // Create directories if err := utils.CreateDirectories( variable.SuperFileMainDir, variable.SuperFileDataDir, variable.SuperFileStateDir, variable.ThemeFolder, ); err != nil { utils.PrintlnAndExit("Error creating directories:", err) } // Create files if err := utils.CreateFiles( variable.ToggleDotFile, variable.LogFile, variable.ThemeFileVersion, variable.ToggleFooter, ); err != nil { utils.PrintlnAndExit("Error creating files:", err) } // Write config file if err := writeConfigFile(variable.ConfigFile, common.ConfigTomlString); err != nil { utils.PrintlnAndExit("Error writing config file:", err) } if err := writeConfigFile(variable.HotkeysFile, common.HotkeysTomlString); err != nil { utils.PrintlnAndExit("Error writing config file:", err) } } // Check if is the first time initializing the app, if it is create // use check file func checkFirstUse() bool { file := variable.FirstUseCheck firstUse := false if _, err := os.Stat(file); os.IsNotExist(err) { firstUse = true if err = os.WriteFile(file, nil, utils.ConfigFilePerm); err != nil { utils.PrintfAndExitf("Failed to create file: %v", err) } } return firstUse } // Write data to the path file if it does not exists func writeConfigFile(path, data string) error { if _, err := os.Stat(path); os.IsNotExist(err) { if err = os.WriteFile(path, []byte(data), utils.ConfigFilePerm); err != nil { return fmt.Errorf("failed to write config file %s: %w", path, err) } } return nil } func writeLastCheckTime(t time.Time) { err := os.WriteFile(variable.LastCheckVersion, []byte(t.Format(time.RFC3339)), utils.ConfigFilePerm) if err != nil { slog.Error("Error writing LastCheckVersion file", "error", err) } } // Check for the need of updates if AutoCheckUpdate is on, if its the first time // that version is checked or if has more than 24h since the last version check, // look into the repo if there's any more recent version func CheckForUpdates() { if !common.Config.AutoCheckUpdate { return } currentTime := time.Now().UTC() lastCheckTime := readLastCheckTime() if !shouldCheckForUpdate(currentTime, lastCheckTime) { return } defer writeLastCheckTime(currentTime) checkAndNotifyUpdate() } // Default to zero time if file doesn't exist, is empty, or has errors func readLastCheckTime() time.Time { content, err := os.ReadFile(variable.LastCheckVersion) if err != nil || len(content) == 0 { return time.Time{} } parsedTime, parseErr := time.Parse(time.RFC3339, string(content)) if parseErr != nil { slog.Error("Failed to parse LastCheckVersion timestamp", "error", parseErr) return time.Time{} } return parsedTime.UTC() } func shouldCheckForUpdate(now, last time.Time) bool { return last.IsZero() || now.Sub(last) >= 24*time.Hour } func checkAndNotifyUpdate() { ctx, cancel := context.WithTimeout(context.Background(), common.DefaultCLIContextTimeout) defer cancel() resp, err := fetchLatestRelease(ctx) if err != nil { slog.Error("Failed to fetch update", "error", err) return } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { slog.Error("Failed to read update response", "error", err) return } type GitHubRelease struct { TagName string `json:"tag_name"` } var release GitHubRelease if err := json.Unmarshal(body, &release); err != nil { slog.Error("Failed to parse GitHub JSON", "error", err) return } if semver.Compare(release.TagName, variable.CurrentVersion) > 0 { notifyUpdateAvailable(release.TagName) } } func fetchLatestRelease(ctx context.Context) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, variable.LatestVersionURL, nil) if err != nil { return nil, err } return (&http.Client{}).Do(req) } func notifyUpdateAvailable(latest string) { fmt.Println( lipgloss.NewStyle().Foreground(lipgloss.Color("#FF69E1")).Render("┃ ") + lipgloss.NewStyle().Foreground(lipgloss.Color("#FFBA52")).Bold(true).Render("A new version ") + lipgloss.NewStyle().Foreground(lipgloss.Color("#00FFF2")).Bold(true).Italic(true).Render(latest) + lipgloss.NewStyle().Foreground(lipgloss.Color("#FFBA52")).Bold(true).Render(" is available."), ) fmt.Printf( lipgloss.NewStyle().Foreground(lipgloss.Color("#FF69E1")).Render("┃ ")+"Please update.\n┏\n\n => %s\n\n", variable.LatestVersionGithub, ) fmt.Println(" ┛") } ================================================ FILE: src/config/fixed_variable.go ================================================ package variable import ( "os" "path/filepath" "github.com/urfave/cli/v3" "github.com/yorukot/superfile/src/pkg/utils" "github.com/adrg/xdg" ) const ( CurrentVersion = "v1.5.0" // Allowing pre-releases with non production version // Set this to "" for production releases PreReleaseSuffix = "" // This gives most recent non-prerelease, non-draft release LatestVersionURL = "https://api.github.com/repos/yorukot/superfile/releases/latest" LatestVersionGithub = "github.com/yorukot/superfile/releases/latest" // This will not break in windows. This is a relative path for Embed FS. It uses "/" only EmbedConfigDir = "src/superfile_config" EmbedConfigFile = EmbedConfigDir + "/config.toml" EmbedHotkeysFile = EmbedConfigDir + "/hotkeys.toml" EmbedThemeDir = EmbedConfigDir + "/theme" EmbedThemeCatppuccinFile = EmbedThemeDir + "/catppuccin-mocha.toml" ) var ( HomeDir = xdg.Home SuperFileMainDir = filepath.Join(xdg.ConfigHome, "superfile") SuperFileCacheDir = filepath.Join(xdg.CacheHome, "superfile") SuperFileDataDir = filepath.Join(xdg.DataHome, "superfile") SuperFileStateDir = filepath.Join(xdg.StateHome, "superfile") // MainDir files ThemeFolder = filepath.Join(SuperFileMainDir, "theme") // DataDir files LastCheckVersion = filepath.Join(SuperFileDataDir, "lastCheckVersion") ThemeFileVersion = filepath.Join(SuperFileDataDir, "themeFileVersion") FirstUseCheck = filepath.Join(SuperFileDataDir, "firstUseCheck") PinnedFile = filepath.Join(SuperFileDataDir, "pinned.json") ToggleDotFile = filepath.Join(SuperFileDataDir, "toggleDotFile") ToggleFooter = filepath.Join(SuperFileDataDir, "toggleFooter") // StateDir files LogFile = filepath.Join(SuperFileStateDir, "superfile.log") LastDirFile = filepath.Join(SuperFileStateDir, "lastdir") // Trash Directories DarwinTrashDirectory = filepath.Join(HomeDir, ".Trash") // These are used by github.com/rkoesters/xdg/trash package // We need to make sure that these directories exist LinuxTrashDirectory = filepath.Join(xdg.DataHome, "Trash") LinuxTrashDirectoryFiles = filepath.Join(xdg.DataHome, "Trash", "files") LinuxTrashDirectoryInfo = filepath.Join(xdg.DataHome, "Trash", "info") ) // These variables are actually not fixed, they are sometimes updated dynamically var ( ConfigFile = filepath.Join(SuperFileMainDir, "config.toml") HotkeysFile = filepath.Join(SuperFileMainDir, "hotkeys.toml") // ChooserFile is the path where superfile will write the file's path, which is to be // opened, before exiting ChooserFile = "" // Other state variables FixHotkeys = false FixConfigFile = false LastDir = "" PrintLastDir = false ) // Still we are preventing other packages to directly modify them via reassign linter func SetLastDir(path string) { LastDir = path } func SetChooserFile(path string) { ChooserFile = path } func UpdateVarFromCliArgs(c *cli.Command) { // Setting the config file path configFileArg := c.String("config-file") // Validate the config file exists if configFileArg != "" { if _, err := os.Stat(configFileArg); err != nil { utils.PrintfAndExitf("Error: While reading config file '%s' from argument : %v", configFileArg, err) } ConfigFile = configFileArg } hotkeyFileArg := c.String("hotkey-file") if hotkeyFileArg != "" { if _, err := os.Stat(hotkeyFileArg); err != nil { utils.PrintfAndExitf("Error: While reading hotkey file '%s' from argument : %v", hotkeyFileArg, err) } HotkeysFile = hotkeyFileArg } // It could be non existent. We are writing to the file. If file doesn't exists, we would attempt to create it. SetChooserFile(c.String("chooser-file")) FixHotkeys = c.Bool("fix-hotkeys") FixConfigFile = c.Bool("fix-config-file") PrintLastDir = c.Bool("print-last-dir") } ================================================ FILE: src/config/icon/function.go ================================================ package icon // InitIcon initializes the icon configuration for the application. // It sets up different icons based on whether nerd fonts are enabled and configures directory icon colors. // // Parameters: // - nerdfont: boolean flag to determine if nerd fonts should be used // When false, uses simple ASCII characters for icons // When true, uses nerd font icons (default behavior) // - directoryIconColor: string representing the color for directory icons // If empty, defaults to "NONE" (dark yellowish) // // The function configures various icons for: // - System directories (Home, Download, Documents, etc.) // - File operations (Compress, Extract, Copy, Cut, Delete) // - UI elements (Cursor, Browser, Select, etc.) // - Status indicators (Error, Warn, Done, InOperation) // - Navigation and sorting (Directory, Search, SortAsc, SortDesc) func InitIcon(nerdfont bool, directoryIconColor string) { // Make sure that these alternatives are ASCII characters only. // Dont place any special unicode characters here. if !nerdfont { // When nerdfont is disabled, we use simple ASCII characters // Space is set to empty string because we don't need special spacing // for ASCII characters, unlike nerd fonts which often need proper spacing // to display correctly Space = "" SuperfileIcon = "" Home = "" Download = "" Documents = "" Pictures = "" Videos = "" Music = "" Templates = "" PublicShare = "" // file operations CompressFile = "" ExtractFile = "" Copy = "" Cut = "" Delete = "" // other Cursor = ">" Browser = "B" Select = "S" Error = "" Warn = "" Done = "" InOperation = "" Directory = "" Search = "" SortAsc = "^" SortDesc = "v" Terminal = "" Pinned = "" Disk = "" } if directoryIconColor == "" { directoryIconColor = "NONE" // Dark yellowish } Folders["folder"] = Style{ Icon: "\uf07b", // Printable Rune : "" Color: directoryIconColor, } } func GetCopyOrCutIcon(cut bool) string { if cut { return Cut } return Copy } ================================================ FILE: src/config/icon/icon.go ================================================ package icon // Style for icons type Style struct { Icon string Color string } var ( Space = " " SuperfileIcon = "\ue6ad" // Printable Rune : "" // Well Known Directories Home = "\U000f02dc" // Printable Rune : "󰋜" Download = "\U000f03d4" // Printable Rune : "󰏔" Documents = "\U000f0219" // Printable Rune : "󰈙" Pictures = "\U000f02e9" // Printable Rune : "󰋩" Videos = "\U000f0381" // Printable Rune : "󰎁" Music = "♬" // Printable Rune : "♬" Templates = "\U000f03e2" // Printable Rune : "󰏢" PublicShare = "\uf0ac" // Printable Rune : "" Trash = "\uf1f8" // Printable Rune : "" // file operations CompressFile = "\U000f05c4" // Printable Rune : "󰗄" ExtractFile = "\U000f06eb" // Printable Rune : "󰛫" Copy = "\U000f018f" // Printable Rune : "󰆏" Cut = "\U000f0190" // Printable Rune : "󰆐" Delete = "\U000f01b4" // Printable Rune : "󰆴" // other Cursor = "\uf054" // Printable Rune : "" Browser = "\U000f0208" // Printable Rune : "󰈈" Select = "\U000f01bd" // Printable Rune : "󰆽" CheckboxEmpty = "\U000f0131" // Printable Rune : "󰄱" CheckboxChecked = "\U000f0856" // Printable Rune : "󰡖" Error = "\uf530" // Printable Rune : "" Warn = "\uf071" // Printable Rune : "" Done = "\uf4a4" // Printable Rune : "" InOperation = "\U000f0954" // Printable Rune : "󰥔" Directory = "\uf07b" // Printable Rune : "" Search = "\ue68f" // Printable Rune : "" SortAsc = "\uf0de" // Printable Rune : "" SortDesc = "\uf0dd" // Printable Rune : "" Terminal = "\ue795" // Printable Rune : "" Pinned = "\U000f0403" // Printable Rune : "󰐃" Disk = "\U000f11f0" // Printable Rune : "󱇰" ) /* THESE CODE BASE ON https://github.com/acarl005/ls-go thanks for the great work!! */ var Icons = map[string]Style{ "ai": {Icon: "\ue669", Color: "#ce6f14"}, // Printable Rune : "" "android": {Icon: "\uf17b", Color: "#a7c83f"}, // Printable Rune : "" "apple": {Icon: "\ue711", Color: "#78909c"}, // Printable Rune : "" "asm": {Icon: "\U000f061a", Color: "#ff7844"}, // Printable Rune : "󰘚" "audio": {Icon: "\uf001", Color: "#ee524f"}, // Printable Rune : "" "binary": {Icon: "\uf471", Color: "#ff7844"}, // Printable Rune : "" "c": {Icon: "\ue649", Color: "#0188d2"}, // Printable Rune : "" "cfg": {Icon: "\ue615", Color: "#8B8B8B"}, // Printable Rune : "" "clj": {Icon: "\ue76a", Color: "#68b338"}, // Printable Rune : "" "conf": {Icon: "\ue615", Color: "#8B8B8B"}, // Printable Rune : "" "cpp": {Icon: "\ue646", Color: "#0188d2"}, // Printable Rune : "" "css": {Icon: "\uf13c", Color: "#2d53e5"}, // Printable Rune : "" "dart": {Icon: "\ue64c", Color: "#03589b"}, // Printable Rune : "" "db": {Icon: "\uf1c0", Color: "#FF8400"}, // Printable Rune : "" "deb": {Icon: "\ue77d", Color: "#ab0836"}, // Printable Rune : "" "doc": {Icon: "\ue6a5", Color: "#295394"}, // Printable Rune : "" "dockerfile": {Icon: "\U000f0868", Color: "#099cec"}, // Printable Rune : "󰡨" "ebook": {Icon: "\uf02d", Color: "#67b500"}, // Printable Rune : "" "env": {Icon: "\uf462", Color: "#eed645"}, // Printable Rune : "" "f": {Icon: "\U000f121a", Color: "#8e44ad"}, // Printable Rune : "󱈚" "file": {Icon: "\uf15b", Color: "NONE"}, // Printable Rune : "" "font": {Icon: "\uf031", Color: "#3498db"}, // Printable Rune : "" "fs": {Icon: "\ue7a7", Color: "#2ecc71"}, // Printable Rune : "" "gb": {Icon: "\ue272", Color: "#f1c40f"}, // Printable Rune : "" "gform": {Icon: "\uf298", Color: "#9b59b6"}, // Printable Rune : "" "git": {Icon: "\ue702", Color: "#e67e22"}, // Printable Rune : "" "go": {Icon: "\ue627", Color: "#6ed8e5"}, // Printable Rune : "" "graphql": {Icon: "\ue662", Color: "#e74c3c"}, // Printable Rune : "" "glp": {Icon: "\U000f01a7", Color: "#3498db"}, // Printable Rune : "󰆧" "groovy": {Icon: "\ue775", Color: "#2ecc71"}, // Printable Rune : "" "gruntfile.js": {Icon: "\ue74c", Color: "#3498db"}, // Printable Rune : "" "gulpfile.js": {Icon: "\ue610", Color: "#e67e22"}, // Printable Rune : "" "gv": {Icon: "\ue225", Color: "#9b59b6"}, // Printable Rune : "" "h": {Icon: "\uf0fd", Color: "#3498db"}, // Printable Rune : "" "haml": {Icon: "\ue664", Color: "#9b59b6"}, // Printable Rune : "" "hs": {Icon: "\ue777", Color: "#2980b9"}, // Printable Rune : "" "html": {Icon: "\uf13b", Color: "#e67e22"}, // Printable Rune : "" "hx": {Icon: "\ue666", Color: "#e74c3c"}, // Printable Rune : "" "ics": {Icon: "\uf073", Color: "#f1c40f"}, // Printable Rune : "" "image": {Icon: "\uf1c5", Color: "#e74c3c"}, // Printable Rune : "" "iml": {Icon: "\ue7b5", Color: "#3498db"}, // Printable Rune : "" "ini": {Icon: "\U000f016a", Color: "#f1c40f"}, // Printable Rune : "󰅪" "ino": {Icon: "\ue255", Color: "#2ecc71"}, // Printable Rune : "" "iso": {Icon: "\U000f02ca", Color: "#f1c40f"}, // Printable Rune : "󰋊" "jade": {Icon: "\ue66c", Color: "#9b59b6"}, // Printable Rune : "" "java": {Icon: "\ue738", Color: "#e67e22"}, // Printable Rune : "" "jenkinsfile": {Icon: "\ue767", Color: "#e74c3c"}, // Printable Rune : "" "jl": {Icon: "\ue624", Color: "#2ecc71"}, // Printable Rune : "" "js": {Icon: "\ue781", Color: "#f39c12"}, // Printable Rune : "" "json": {Icon: "\ue60b", Color: "#f1c40f"}, // Printable Rune : "" "jsx": {Icon: "\ue7ba", Color: "#e67e22"}, // Printable Rune : "" "key": {Icon: "\uf43d", Color: "#f1c40f"}, // Printable Rune : "" "ko": {Icon: "\uebc6", Color: "#9b59b6"}, // Printable Rune : "" "kt": {Icon: "\ue634", Color: "#2980b9"}, // Printable Rune : "" "less": {Icon: "\ue758", Color: "#3498db"}, // Printable Rune : "" "link_file": {Icon: "\uf481", Color: "NONE"}, // Printable Rune : "" "lock": {Icon: "\uf023", Color: "#f1c40f"}, // Printable Rune : "" "log": {Icon: "\uf18d", Color: "#7f8c8d"}, // Printable Rune : "" "lua": {Icon: "\ue620", Color: "#e74c3c"}, // Printable Rune : "" "maintainers": {Icon: "\uf0c0", Color: "#7f8c8d"}, // Printable Rune : "" "makefile": {Icon: "\ue20f", Color: "#3498db"}, // Printable Rune : "" "md": {Icon: "\uf48a", Color: "#7f8c8d"}, // Printable Rune : "" "mjs": {Icon: "\ue718", Color: "#f39c12"}, // Printable Rune : "" "ml": {Icon: "\U000f0627", Color: "#2ecc71"}, // Printable Rune : "󰘧" "mustache": {Icon: "\ue60f", Color: "#e67e22"}, // Printable Rune : "" "nc": {Icon: "\U000f02c1", Color: "#f1c40"}, // Printable Rune : "󰋁" "nim": {Icon: "\ue677", Color: "#3498db"}, // Printable Rune : "" "nix": {Icon: "\uf313", Color: "#f39c12"}, // Printable Rune : "" "npmignore": {Icon: "\ue71e", Color: "#e74c3c"}, // Printable Rune : "" "package": {Icon: "\U000f03d7", Color: "#9b59b6"}, // Printable Rune : "󰏗" "passwd": {Icon: "\uf023", Color: "#f1c40f"}, // Printable Rune : "" "patch": {Icon: "\uf440", Color: "#e67e22"}, // Printable Rune : "" "pdf": {Icon: "\uf1c1", Color: "#d35400"}, // Printable Rune : "" "php": {Icon: "\ue608", Color: "#9b59b6"}, // Printable Rune : "" "pl": {Icon: "\ue7a1", Color: "#3498db"}, // Printable Rune : "" "prisma": {Icon: "\ue684", Color: "#9b59b6"}, // Printable Rune : "" "ppt": {Icon: "\uf1c4", Color: "#c0392b"}, // Printable Rune : "" "psd": {Icon: "\ue7b8", Color: "#3498db"}, // Printable Rune : "" "py": {Icon: "\ue606", Color: "#3498db"}, // Printable Rune : "" "r": {Icon: "\ue68a", Color: "#9b59b6"}, // Printable Rune : "" "rb": {Icon: "\ue21e", Color: "#9b59b6"}, // Printable Rune : "" "rdb": {Icon: "\ue76d", Color: "#9b59b6"}, // Printable Rune : "" "rpm": {Icon: "\uf17c", Color: "#d35400"}, // Printable Rune : "" "rs": {Icon: "\ue7a8", Color: "#f39c12"}, // Printable Rune : "" "rss": {Icon: "\uf09e", Color: "#c0392b"}, // Printable Rune : "" "rst": {Icon: "\U000f016b", Color: "#2ecc71"}, // Printable Rune : "󰅫" "rubydoc": {Icon: "\ue73b", Color: "#e67e22"}, // Printable Rune : "" "sass": {Icon: "\ue603", Color: "#e74c3c"}, // Printable Rune : "" "scala": {Icon: "\ue737", Color: "#e67e22"}, // Printable Rune : "" "shell": {Icon: "\uf489", Color: "#2ecc71"}, // Printable Rune : "" "shp": {Icon: "\U000f065e", Color: "#f1c40f"}, // Printable Rune : "󰙞" "sol": {Icon: "\U000f086a", Color: "#3498db"}, // Printable Rune : "󰡪" "sqlite": {Icon: "\ue7c4", Color: "#27ae60"}, // Printable Rune : "" "styl": {Icon: "\ue600", Color: "#e74c3c"}, // Printable Rune : "" "svelte": {Icon: "\ue697", Color: "#ff3e00"}, // Printable Rune : "" "swift": {Icon: "\ue755", Color: "#ff6f61"}, // Printable Rune : "" "tex": {Icon: "\u222b", Color: "#9b59b6"}, // Printable Rune : "∫" "tf": {Icon: "\ue69a", Color: "#2ecc71"}, // Printable Rune : "" "toml": {Icon: "\U000f016a", Color: "#f39c12"}, // Printable Rune : "󰅪" "ts": {Icon: "\U000f06e6", Color: "#2980b9"}, // Printable Rune : "󰛦" "twig": {Icon: "\ue61c", Color: "#9b59b6"}, // Printable Rune : "" "txt": {Icon: "\uf15c", Color: "#7f8c8d"}, // Printable Rune : "" "vagrantfile": {Icon: "\ue21e", Color: "#3498db"}, // Printable Rune : "" "video": {Icon: "\uf03d", Color: "#c0392b"}, // Printable Rune : "" "vim": {Icon: "\ue62b", Color: "#019833"}, // Printable Rune : "" "vue": {Icon: "\ue6a0", Color: "#41b883"}, // Printable Rune : "" "windows": {Icon: "\uf17a", Color: "#4a90e2"}, // Printable Rune : "" "xls": {Icon: "\uf1c3", Color: "#27ae60"}, // Printable Rune : "" "xml": {Icon: "\ue796", Color: "#3498db"}, // Printable Rune : "" "yml": {Icon: "\ue601", Color: "#f39c12"}, // Printable Rune : "" "zig": {Icon: "\ue6a9", Color: "#9b59b6"}, // Printable Rune : "" "zip": {Icon: "\uf410", Color: "#e74c3c"}, // Printable Rune : "" } var Aliases = map[string]string{ "dart": "dart", "apk": "android", "gradle": "android", "ds_store": "apple", "localized": "apple", "m": "apple", "mm": "apple", "s": "asm", "aac": "audio", "alac": "audio", "flac": "audio", "m4a": "audio", "mka": "audio", "mp3": "audio", "ogg": "audio", "opus": "audio", "wav": "audio", "wma": "audio", "bson": "binary", "feather": "binary", "mat": "binary", "o": "binary", "pb": "binary", "pickle": "binary", "pkl": "binary", "tfrecord": "binary", "conf": "cfg", "config": "cfg", "cljc": "clj", "cljs": "clj", "editorconfig": "conf", "rc": "conf", "c++": "cpp", "cc": "cpp", "cxx": "cpp", "scss": "css", "sql": "db", "docx": "doc", "gdoc": "doc", "dockerignore": "dockerfile", "epub": "ebook", "ipynb": "ebook", "mobi": "ebook", "env": "env", ".env.local": "env", "local": "env", "f03": "f", "f77": "f", "f90": "f", "f95": "f", "for": "f", "fpp": "f", "ftn": "f", "eot": "font", "otf": "font", "ttf": "font", "woff": "font", "woff2": "font", "fsi": "fs", "fsscript": "fs", "fsx": "fs", "dna": "gb", "gitattributes": "git", "gitconfig": "git", "gitignore": "git", "gitignore_global": "git", "gitmirrorall": "git", "gitmodules": "git", "gltf": "glp", "gsh": "groovy", "gvy": "groovy", "gy": "groovy", "h++": "h", "hh": "h", "hpp": "h", "hxx": "h", "lhs": "hs", "htm": "html", "xhtml": "html", "bmp": "image", "cbr": "image", "cbz": "image", "dvi": "image", "eps": "image", "gif": "image", "ico": "image", "jpeg": "image", "jpg": "image", "nef": "image", "orf": "image", "pbm": "image", "pgm": "image", "png": "image", "pnm": "image", "ppm": "image", "pxm": "image", "sixel": "image", "stl": "image", "svg": "image", "tif": "image", "tiff": "image", "webp": "image", "xpm": "image", "disk": "iso", "dmg": "iso", "img": "iso", "ipsw": "iso", "smi": "iso", "vhd": "iso", "vhdx": "iso", "vmdk": "iso", "jar": "java", "kts": "kt", "cjs": "js", "properties": "json", "webmanifest": "json", "tsx": "jsx", "cjsx": "jsx", "cer": "key", "crt": "key", "der": "key", "gpg": "key", "p7b": "key", "pem": "key", "pfx": "key", "pgp": "key", "license": "key", "codeowners": "maintainers", "credits": "maintainers", "cmake": "makefile", "justfile": "makefile", "markdown": "md", "mkd": "md", "rdoc": "md", "readme": "md", "mli": "ml", "sml": "ml", "netcdf": "nc", "brewfile": "package", "cargo.toml": "package", "cargo.lock": "package", "go.mod": "package", "go.sum": "package", "pyproject.toml": "package", "poetry.lock": "package", "package.json": "package", "pipfile": "package", "pipfile.lock": "package", "php3": "php", "php4": "php", "php5": "php", "phpt": "php", "phtml": "php", "gslides": "ppt", "pptx": "ppt", "pxd": "py", "pyc": "py", "pyx": "py", "whl": "py", "rdata": "r", "rds": "r", "rmd": "r", "gemfile": "rb", "gemspec": "rb", "guardfile": "rb", "procfile": "rb", "rakefile": "rb", "rspec": "rb", "rspec_parallel": "rb", "rspec_status": "rb", "ru": "rb", "erb": "rubydoc", "slim": "rubydoc", "awk": "shell", "bash": "shell", "bash_history": "shell", "bash_profile": "shell", "bashrc": "shell", "csh": "shell", "fish": "shell", "ksh": "shell", "sh": "shell", "zsh": "shell", "zsh-theme": "shell", "zshrc": "shell", "plpgsql": "sql", "plsql": "sql", "psql": "sql", "tsql": "sql", "sl3": "sqlite", "sqlite3": "sqlite", "stylus": "styl", "cls": "tex", "avi": "video", "flv": "video", "m2v": "video", "mkv": "video", "mov": "video", "mp4": "video", "mpeg": "video", "mpg": "video", "ogm": "video", "ogv": "video", "vob": "video", "webm": "video", "vimrc": "vim", "bat": "windows", "cmd": "windows", "exe": "windows", "csv": "xls", "gsheet": "xls", "xlsx": "xls", "plist": "xml", "xul": "xml", "yaml": "yml", "7z": "zip", "Z": "zip", "bz2": "zip", "gz": "zip", "lzma": "zip", "par": "zip", "rar": "zip", "tar": "zip", "tc": "zip", "tgz": "zip", "txz": "zip", "xz": "zip", "z": "zip", } var Folders = map[string]Style{ ".atom": {Icon: "\ue764", Color: "#66595c"}, // Atom folder - Dark gray // Printable Rune : "" ".aws": {Icon: "\ue7ad", Color: "#ff9900"}, // AWS folder - Orange // Printable Rune : "" ".docker": {Icon: "\ue7b0", Color: "#0db7ed"}, // Docker folder - Blue // Printable Rune : "" ".gem": {Icon: "\ue21e", Color: "#e9573f"}, // Gem folder - Red // Printable Rune : "" ".git": {Icon: "\ue5fb", Color: "#f14e32"}, // Git folder - Red // Printable Rune : "" ".git-credential-cache": { Icon: "\ue5fb", Color: "#f14e32", }, // Git credential cache folder - Red // Printable Rune : "" ".github": {Icon: "\ue5fd", Color: "#000000"}, // GitHub folder - Black // Printable Rune : "" ".npm": {Icon: "\ue5fa", Color: "#cb3837"}, // npm folder - Red // Printable Rune : "" ".nvm": {Icon: "\ue718", Color: "#cb3837"}, // nvm folder - Red // Printable Rune : "" ".rvm": {Icon: "\ue21e", Color: "#e9573f"}, // rvm folder - Red // Printable Rune : "" ".Trash": {Icon: "\uf1f8", Color: "#7f8c8d"}, // Trash folder - Light gray // Printable Rune : "" ".vscode": {Icon: "\ue70c", Color: "#007acc"}, // VSCode folder - Blue // Printable Rune : "" ".vim": {Icon: "\ue62b", Color: "#019833"}, // Vim folder - Green // Printable Rune : "" "config": {Icon: "\ue5fc", Color: "#ffb86c"}, // Config folder - Light orange // Printable Rune : "" // Item for Generic folder, with key "folder" is initialized in InitIcon() "hidden": {Icon: "\uf023", Color: "#75715e"}, // Hidden folder - Dark yellowish // Printable Rune : "" "node_modules": {Icon: "\ue5fa", Color: "#cb3837"}, // Node modules folder - Red // Printable Rune : "" "link_folder": {Icon: "\uf482", Color: "NONE"}, // link folder - None // Printable Rune : "" "superfile": {Icon: "\U000f069d", Color: "#FF6F00"}, // Printable Rune : "󰚝" } ================================================ FILE: src/internal/backend/README.md ================================================ # Backend Package Handles operations on the User's OS. For example, executing shell commands, performing file operations on user's files... Reading OS-specific configurations like disk partitions. The name 'backend' isn't the most appropriate, open to suggestions. This would modularize the code, and would enable us to write unit tests where we would 'mock' the backend functionality with dummy interface implementations # Dependencies Should not import any "ui" package Can import common and its subpackages # Implementation specifications Try to implement everything via interfaces, so that we can easily write unit tests ================================================ FILE: src/internal/common/README.md ================================================ # common package Defines common utilities for ui and file operations package everyone can use common package, but common package should not have any dependency on any other package. Currently, common package is a big monolith, but we plan to separate it into config, # Dependencies - src/config package ================================================ FILE: src/internal/common/config_type.go ================================================ package common // Theme configuration type ThemeType struct { // Code syntax highlight theme CodeSyntaxHighlightTheme string `toml:"code_syntax_highlight"` // Border FilePanelBorder string `toml:"file_panel_border"` SidebarBorder string `toml:"sidebar_border"` FooterBorder string `toml:"footer_border"` // Border Active FilePanelBorderActive string `toml:"file_panel_border_active"` SidebarBorderActive string `toml:"sidebar_border_active"` FooterBorderActive string `toml:"footer_border_active"` ModalBorderActive string `toml:"modal_border_active"` // Background (bg) FullScreenBG string `toml:"full_screen_bg"` FilePanelBG string `toml:"file_panel_bg"` SidebarBG string `toml:"sidebar_bg"` FooterBG string `toml:"footer_bg"` ModalBG string `toml:"modal_bg"` // Foreground (fg) FullScreenFG string `toml:"full_screen_fg"` FilePanelFG string `toml:"file_panel_fg"` SidebarFG string `toml:"sidebar_fg"` FooterFG string `toml:"footer_fg"` ModalFG string `toml:"modal_fg"` // Special Color Cursor string `toml:"cursor"` Correct string `toml:"correct"` Error string `toml:"error"` Hint string `toml:"hint"` Cancel string `toml:"cancel"` // Note: this is linked with `RequiredGradientColorCount` constant GradientColor []string `toml:"gradient_color"` DirectoryIconColor string `toml:"directory_icon_color"` // File Panel Special Items FilePanelTopDirectoryIcon string `toml:"file_panel_top_directory_icon"` FilePanelTopPath string `toml:"file_panel_top_path"` FilePanelItemSelectedFG string `toml:"file_panel_item_selected_fg"` FilePanelItemSelectedBG string `toml:"file_panel_item_selected_bg"` // Sidebar Special Items SidebarTitle string `toml:"sidebar_title"` SidebarItemSelectedFG string `toml:"sidebar_item_selected_fg"` SidebarItemSelectedBG string `toml:"sidebar_item_selected_bg"` SidebarDivider string `toml:"sidebar_divider"` // Modal Special Items ModalCancelFG string `toml:"modal_cancel_fg"` ModalCancelBG string `toml:"modal_cancel_bg"` ModalConfirmFG string `toml:"modal_confirm_fg"` ModalConfirmBG string `toml:"modal_confirm_bg"` HelpMenuHotkey string `toml:"help_menu_hotkey"` HelpMenuTitle string `toml:"help_menu_title"` } // Configuration settings type ConfigType struct { Theme string `toml:"theme" comment:"More details are at https://superfile.dev/configure/superfile-config/\nchange your theme"` Editor string `toml:"editor" comment:"\nThe editor files will be opened with. (Leave blank to use the EDITOR environment variable)."` DirEditor string `toml:"dir_editor" comment:"\nThe editor directories will be opened with. (Leave blank to use the default editors)."` // The table (map) for editor by file extension OpenWith map[string]string `toml:"open_with" comment:"\nCustom open commands by file extension."` AutoCheckUpdate bool `toml:"auto_check_update" comment:"\nAuto check for update"` CdOnQuit bool `toml:"cd_on_quit" comment:"\nCd on quit (For more details, please check out https://superfile.dev/configure/superfile-config/#cd_on_quit)"` DefaultOpenFilePreview bool `toml:"default_open_file_preview" comment:"\nWhether to open file preview automatically every time superfile is opened."` ShowImagePreview bool `toml:"show_image_preview" comment:"\nWhether to show image preview."` ShowPanelFooterInfo bool `toml:"show_panel_footer_info" comment:"\nWhether to show additional footer info for file panel."` DefaultDirectory string `toml:"default_directory" comment:"\nThe path of the first file panel when superfile is opened."` FileSizeUseSI bool `toml:"file_size_use_si" comment:"\nDisplay file sizes using powers of 1000 (kB, MB, GB) instead of powers of 1024 (KiB, MiB, GiB)."` DefaultSortType int `toml:"default_sort_type" comment:"\nDefault sort type (0: Name, 1: Size, 2: Date Modified, 3: Type, 4: Natural)."` SortOrderReversed bool `toml:"sort_order_reversed" comment:"\nDefault sort order (false: Ascending, true: Descending)."` CaseSensitiveSort bool `toml:"case_sensitive_sort" comment:"\nCase sensitive sort by name (capital \"B\" comes before \"a\" if true)."` ShellCloseOnSuccess bool `toml:"shell_close_on_success" comment:"\nWhether to close the shell on successful command execution."` Debug bool `toml:"debug" comment:"\nWhether to enable debug mode."` // IgnoreMissingFields controls whether warnings about missing TOML fields are suppressed. IgnoreMissingFields bool `toml:"ignore_missing_fields" comment:"\nWhether to ignore warnings about missing fields in the config file."` PageScrollSize int `toml:"page_scroll_size" comment:"\nNumber of lines to scroll for PgUp/PgDown keys (0: full page, default behavior)."` FilePanelExtraColumns int `toml:"file_panel_extra_columns" comment:"\nCount of extra columns in file panel in addition to file name. When option equal 0 then feature is disabled."` FilePanelNamePercent int `toml:"file_panel_name_percent" comment:"\nPercentage of file panel width allocated to file names (25-100). Higher values give more space to names, less to extra columns."` Nerdfont bool `toml:"nerdfont" comment:"\n================ Style =================\n\n If you don't have or don't want Nerdfont installed you can turn this off"` ShowSelectIcons bool `toml:"show_select_icons" comment:"\nShow checkbox icons in select mode (requires nerdfont)"` TransparentBackground bool `toml:"transparent_background" comment:"\nSet transparent background or not (this only work when your terminal background is transparent)"` FilePreviewWidth int `toml:"file_preview_width" comment:"\nFile preview width allow '0' (this mean same as file panel),'x' x must be less than 10 and greater than 1 (This means that the width of the file preview will be one xth of the total width.)"` EnableFilePreviewBorder bool `toml:"enable_file_preview_border" comment:"\nEnable border around the file preview panel (default: false)"` CodePreviewer string `toml:"code_previewer" comment:"\nWhether to use the builtin syntax highlighting with chroma or use bat. Values: \"\" for builtin chroma, \"bat\" for bat"` SidebarWidth int `toml:"sidebar_width" comment:"\nThe length of the sidebar(excluding borders). If you don't find to display the sidebar, you can input 0 directly. If you want to display the value, please place it in the range of 5-20."` SidebarSections []string `toml:"sidebar_sections" comment:"\nOrder of sidebar sections (valid values: \"home\", \"pinned\", \"disks\").\nOnly sections included in this list will be displayed."` BorderTop string `toml:"border_top" comment:"\nBorder style"` BorderBottom string `toml:"border_bottom"` BorderLeft string `toml:"border_left"` BorderRight string `toml:"border_right"` BorderTopLeft string `toml:"border_top_left"` BorderTopRight string `toml:"border_top_right"` BorderBottomLeft string `toml:"border_bottom_left"` BorderBottomRight string `toml:"border_bottom_right"` BorderMiddleLeft string `toml:"border_middle_left"` BorderMiddleRight string `toml:"border_middle_right"` Metadata bool `toml:"metadata" comment:"\n==========PLUGINS========== #\nPlugins means that you need to install some external dependencies to use them.\n\nShow more detailed metadata, please install exiftool before enabling this plugin!"` EnableMD5Checksum bool `toml:"enable_md5_checksum" comment:"Enable MD5 checksum generation for files"` ZoxideSupport bool `toml:"zoxide_support" comment:"Zoxide support for the fast navigation"` } // GetIgnoreMissingFields reports whether warnings about missing TOML fields should be ignored. func (c *ConfigType) GetIgnoreMissingFields() bool { return c.IgnoreMissingFields } type HotkeysType struct { Confirm []string `toml:"confirm" comment:"=================================================================================================\nGlobal hotkeys (cannot conflict with other hotkeys)"` Quit []string `toml:"quit"` CdQuit []string `toml:"cd_quit"` // movement ListUp []string `toml:"list_up" comment:"movement"` ListDown []string `toml:"list_down"` PageUp []string `toml:"page_up"` PageDown []string `toml:"page_down"` CloseFilePanel []string `toml:"close_file_panel" comment:"file panel control"` CreateNewFilePanel []string `toml:"create_new_file_panel"` SplitFilePanel []string `toml:"split_file_panel"` NextFilePanel []string `toml:"next_file_panel"` PreviousFilePanel []string `toml:"previous_file_panel"` ToggleFilePreviewPanel []string `toml:"toggle_file_preview_panel"` OpenSortOptionsMenu []string `toml:"open_sort_options_menu"` ToggleReverseSort []string `toml:"toggle_reverse_sort"` FocusOnProcessBar []string `toml:"focus_on_process_bar" comment:"change focus"` FocusOnSidebar []string `toml:"focus_on_sidebar"` FocusOnMetaData []string `toml:"focus_on_metadata"` FilePanelItemCreate []string `toml:"file_panel_item_create" comment:"create file/directory and rename "` FilePanelItemRename []string `toml:"file_panel_item_rename"` CopyItems []string `toml:"copy_items" comment:"file operate"` PasteItems []string `toml:"paste_items"` CutItems []string `toml:"cut_items"` DeleteItems []string `toml:"delete_items"` PermanentlyDeleteItems []string `toml:"permanently_delete_items"` ExtractFile []string `toml:"extract_file" comment:"compress and extract"` CompressFile []string `toml:"compress_file"` OpenFileWithEditor []string `toml:"open_file_with_editor" comment:"editor"` OpenCurrentDirectoryWithEditor []string `toml:"open_current_directory_with_editor"` PinnedDirectory []string `toml:"pinned_directory" comment:"other"` ToggleDotFile []string `toml:"toggle_dot_file"` ChangePanelMode []string `toml:"change_panel_mode"` OpenHelpMenu []string `toml:"open_help_menu"` OpenCommandLine []string `toml:"open_command_line"` OpenSPFPrompt []string `toml:"open_spf_prompt"` OpenZoxide []string `toml:"open_zoxide"` CopyPath []string `toml:"copy_path"` CopyPWD []string `toml:"copy_present_working_directory"` ToggleFooter []string `toml:"toggle_footer"` ConfirmTyping []string `toml:"confirm_typing" comment:"=================================================================================================\nTyping hotkeys (can conflict with all hotkeys)"` CancelTyping []string `toml:"cancel_typing"` ParentDirectory []string `toml:"parent_directory" comment:"=================================================================================================\nNormal mode hotkeys (can conflict with other modes, cannot conflict with global hotkeys)"` SearchBar []string `toml:"search_bar"` FilePanelSelectModeItemsSelectDown []string `toml:"file_panel_select_mode_items_select_down" comment:"=================================================================================================\nSelect mode hotkeys (can conflict with other modes, cannot conflict with global hotkeys)"` FilePanelSelectModeItemsSelectUp []string `toml:"file_panel_select_mode_items_select_up"` FilePanelSelectAllItem []string `toml:"file_panel_select_all_items"` } ================================================ FILE: src/internal/common/default_config.go ================================================ package common // Variables for holding default configurations of each settings var ( HotkeysTomlString string ConfigTomlString string DefaultThemeString string ) var Theme ThemeType var Config ConfigType var Hotkeys HotkeysType ================================================ FILE: src/internal/common/icon_utils.go ================================================ package common import ( "path/filepath" "strings" "github.com/yorukot/superfile/src/config/icon" ) func getFileIcon(file string, isLink bool) icon.Style { if isLink { return icon.Icons["link_file"] } ext := strings.TrimPrefix(filepath.Ext(file), ".") // default icon for all files. try to find a better one though... resultIcon := icon.Icons["file"] // resolve aliased extensions extKey := strings.ToLower(ext) alias, hasAlias := icon.Aliases[extKey] if hasAlias { extKey = alias } // see if we can find a better icon based on extension alone betterIcon, hasBetterIcon := icon.Icons[extKey] if hasBetterIcon { resultIcon = betterIcon } // now look for icons based on full names fullName := file fullName = strings.ToLower(fullName) fullAlias, hasFullAlias := icon.Aliases[fullName] if hasFullAlias { fullName = fullAlias } bestIcon, hasBestIcon := icon.Icons[fullName] if hasBestIcon { resultIcon = bestIcon } if resultIcon.Color == "NONE" { return icon.Style{ Icon: resultIcon.Icon, Color: Theme.FilePanelFG, } } return resultIcon } func GetElementIcon(file string, isDir bool, isLink bool, nerdFont bool) icon.Style { if !nerdFont { return icon.Style{ Icon: "", Color: Theme.FilePanelFG, } } if isDir { if isLink { return icon.Folders["link_folder"] } resultIcon := icon.Folders["folder"] betterIcon, hasBetterIcon := icon.Folders[file] if hasBetterIcon { resultIcon = betterIcon } return resultIcon } return getFileIcon(file, isLink) } ================================================ FILE: src/internal/common/icon_utils_test.go ================================================ package common import ( "testing" "github.com/yorukot/superfile/src/config/icon" ) func TestGetElementIcon(t *testing.T) { tests := []struct { name string file string isDir bool isLink bool nerdFont bool expected icon.Style }{ { name: "Non-nerdfont returns empty icon", file: "test.txt", isDir: false, isLink: false, nerdFont: false, expected: icon.Style{ Icon: "", Color: Theme.FilePanelFG, }, }, { name: "Directory with nerd font", file: "folder", isDir: true, isLink: false, nerdFont: true, expected: icon.Folders["folder"], }, { name: "File with known extension", file: "test.js", isDir: false, isLink: false, nerdFont: true, expected: icon.Icons["js"], }, { name: "Full name takes priority over extension", file: "gulpfile.js", isDir: false, isLink: false, nerdFont: true, expected: icon.Icons["gulpfile.js"], }, { name: ".git directory", file: ".git", isDir: true, isLink: false, nerdFont: true, expected: icon.Folders[".git"], }, { name: "superfile directory", file: "superfile", isDir: true, isLink: false, nerdFont: true, expected: icon.Folders["superfile"], }, { name: "package.json file", file: "package.json", isDir: false, isLink: false, nerdFont: true, expected: icon.Icons["package"], }, { name: "File with unknown extension", file: "test.xyz", isDir: false, isLink: false, nerdFont: true, expected: icon.Style{ Icon: icon.Icons["file"].Icon, // Theme is not defined here, so this will be blank Color: Theme.FilePanelFG, }, }, { name: "File with aliased name", file: "dockerfile", isDir: false, isLink: false, nerdFont: true, expected: icon.Icons["dockerfile"], }, { name: "Link to Directory with nerd font", file: "folder", isDir: true, isLink: true, nerdFont: true, expected: icon.Folders["link_folder"], }, { name: "Link to File", file: "test.js", isDir: false, isLink: true, nerdFont: true, expected: icon.Icons["link_file"], }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := GetElementIcon(tt.file, tt.isDir, tt.isLink, tt.nerdFont) if result.Icon != tt.expected.Icon || result.Color != tt.expected.Color { t.Errorf("GetElementIcon() = %v, want %v", result, tt.expected) } }) } } ================================================ FILE: src/internal/common/load_config.go ================================================ package common import ( "embed" "errors" "fmt" "log/slog" "os" "path/filepath" "reflect" "runtime" "github.com/charmbracelet/x/ansi" "github.com/pelletier/go-toml/v2" "github.com/yorukot/superfile/src/pkg/utils" variable "github.com/yorukot/superfile/src/config" "github.com/yorukot/superfile/src/config/icon" ) // Load configurations from the configuration file. Compares the content // with the default values and modify the config file to include default configs // if the FixConfigFile flag is on // TODO : Fix the code duplication with LoadHotkeysFile(). func LoadConfigFile() { err := utils.LoadTomlFile(variable.ConfigFile, ConfigTomlString, &Config, variable.FixConfigFile, false) if err != nil { userMsg := fmt.Sprintf("%s%s", LipglossError, err.Error()) toExit := true var loadError *utils.TomlLoadError if errors.As(err, &loadError) && loadError != nil { if loadError.MissingFields() && !variable.FixConfigFile { // Had missing fields and we did not fix userMsg += "\nTo add missing fields to configuration file automatically run superfile " + "with the --fix-config-file flag `spf --fix-config-file`" } toExit = loadError.IsFatal() } if toExit { utils.PrintfAndExitf("%s\n", userMsg) } else { fmt.Println(userMsg) } } // Even if there is a missing field, we want to validate fields that are present if err := ValidateConfig(&Config); err != nil { // If config is incorrect we cannot continue. We need to exit utils.PrintlnAndExit(err.Error()) } } func ValidateConfig(c *ConfigType) error { if (c.FilePreviewWidth > 10 || c.FilePreviewWidth < 2) && c.FilePreviewWidth != 0 { return errors.New( LoadConfigError("file_preview_width", "File preview width must be 2–10, or 0 to disable preview."), ) } if c.SidebarWidth != 0 && (c.SidebarWidth < 5 || c.SidebarWidth > 20) { return errors.New(LoadConfigError("sidebar_width", "Sidebar width must be 5–20, or 0 to hide the sidebar.")) } for _, order := range c.SidebarSections { if order != utils.SidebarSectionHome && order != utils.SidebarSectionPinned && order != utils.SidebarSectionDisks { return errors.New( LoadConfigError( "sidebar_sections", "Sidebar sections contain an unsupported value. Allowed values are: home, pinned, disks.", ), ) } } if c.DefaultSortType < 0 || c.DefaultSortType > 4 { return errors.New(LoadConfigError("default_sort_type", "Default sort type must be between 0 and 4.")) } if c.FilePanelNamePercent < FileNameRatioMin || c.FilePanelNamePercent > FileNameRatioMax { return errors.New( LoadConfigError("file_panel_name_percent", "File panel name percent is outside the supported range."), ) } if ansi.StringWidth(c.BorderTop) != 1 { return errors.New(LoadConfigError("border_top", "Border character must be exactly one cell wide.")) } return validateBorders(c) } func validateBorders(c *ConfigType) error { if ansi.StringWidth(c.BorderBottom) != 1 { return errors.New(LoadConfigError("border_bottom", "Border character must be exactly one cell wide.")) } if ansi.StringWidth(c.BorderLeft) != 1 { return errors.New(LoadConfigError("border_left", "Border character must be exactly one cell wide.")) } if ansi.StringWidth(c.BorderRight) != 1 { return errors.New(LoadConfigError("border_right", "Border character must be exactly one cell wide.")) } if ansi.StringWidth(c.BorderBottomLeft) != 1 { return errors.New(LoadConfigError("border_bottom_left", "Border character must be exactly one cell wide.")) } if ansi.StringWidth(c.BorderBottomRight) != 1 { return errors.New(LoadConfigError("border_bottom_right", "Border character must be exactly one cell wide.")) } if ansi.StringWidth(c.BorderTopLeft) != 1 { return errors.New(LoadConfigError("border_top_left", "Border character must be exactly one cell wide.")) } if ansi.StringWidth(c.BorderTopRight) != 1 { return errors.New(LoadConfigError("border_top_right", "Border character must be exactly one cell wide.")) } if ansi.StringWidth(c.BorderMiddleLeft) != 1 { return errors.New(LoadConfigError("border_middle_left", "Border character must be exactly one cell wide.")) } if ansi.StringWidth(c.BorderMiddleRight) != 1 { return errors.New(LoadConfigError("border_middle_right", "Border character must be exactly one cell wide.")) } return nil } // Load keybinds from the hotkeys file. Compares the content // with the default values and modify the hotkeys if the FixHotkeys flag is on. func LoadHotkeysFile(ignoreMissingFields bool) { err := utils.LoadTomlFile( variable.HotkeysFile, HotkeysTomlString, &Hotkeys, variable.FixHotkeys, ignoreMissingFields, ) if err != nil { userMsg := fmt.Sprintf("%s%s", LipglossError, err.Error()) toExit := true var loadError *utils.TomlLoadError if errors.As(err, &loadError) { if loadError.MissingFields() && !variable.FixHotkeys { // Had missing fields and we did not fix userMsg += "\nTo add missing fields to hotkeys file automatically run superfile " + "with the --fix-hotkeys flag `spf --fix-hotkeys`" } toExit = loadError.IsFatal() } if toExit { utils.PrintfAndExitf("%s\n", userMsg) } else { fmt.Println(userMsg) } } // Validate hotkey values val := reflect.ValueOf(Hotkeys) for i := range val.NumField() { field := val.Type().Field(i) value := val.Field(i) // Although this is redundant as Hotkey is always a slice // This adds a layer against accidental struct modifications // Makes sure its always be a string slice. It's somewhat like a unit test if value.Kind() != reflect.Slice || value.Type().Elem().Kind() != reflect.String { utils.PrintlnAndExit( LoadHotkeysError( field.Name, "Hotkey value must be a list of strings.", ), ) } hotkeysList, ok := value.Interface().([]string) if !ok || len(hotkeysList) == 0 || hotkeysList[0] == "" { utils.PrintlnAndExit( LoadHotkeysError( field.Name, "Hotkey list is empty; at least one key binding is required.", ), ) } } } // LoadThemeFile : Load configurations from theme file into &theme // set default values if we cant read user's theme file func LoadThemeFile() { themeFile := filepath.Join(variable.ThemeFolder, Config.Theme+".toml") if err := LoadUserTheme(themeFile, &Theme); err != nil { slog.Error("Could not read user's theme file. Falling back to default theme", "error", err) err = toml.Unmarshal([]byte(DefaultThemeString), &Theme) if err != nil { utils.PrintfAndExitf("Unexpected error while reading default theme file : %v. Exiting...", err) } } // Validations if len(Theme.GradientColor) != RequiredGradientColorCount { utils.PrintlnAndExit( LoadThemeError( "gradient_color", "Gradient color must contain exactly two values.", ), ) } } func LoadUserTheme(themeFile string, obj *ThemeType) error { data, err := os.ReadFile(themeFile) if err != nil { return fmt.Errorf("could not read user's theme file(%s), err : %w", themeFile, err) } if err = toml.Unmarshal(data, obj); err != nil { return fmt.Errorf("could not unmarshal user's theme file(%s) : %w", themeFile, err) } return nil } // LoadAllDefaultConfig : Load all default configurations from embedded superfile_config folder into global // configurations variables and write theme files if its needed. func LoadAllDefaultConfig(content embed.FS) { err := LoadConfigStringGlobals(content) if err != nil { slog.Error("Could not load default config from embed FS", "error", err) return } currentThemeVersion, err := os.ReadFile(variable.ThemeFileVersion) if err != nil && !os.IsNotExist(err) { slog.Error("Unexpected error reading from file:", "error", err) return } if string(currentThemeVersion) == variable.CurrentVersion { // We don't need to update themes as its already up to date return } // Write theme files to theme directory err = WriteThemeFiles(content) if err != nil { slog.Error("Error while writing default theme directories", "error", err) return } // Prevent failure for first time app run by making sure parent directories exists if err = os.MkdirAll(filepath.Dir(variable.ThemeFileVersion), utils.ConfigDirPerm); err != nil { slog.Error("Error creating theme file parent directory", "error", err) return } err = os.WriteFile(variable.ThemeFileVersion, []byte(variable.CurrentVersion), utils.ConfigFilePerm) if err != nil { slog.Error("Error writing theme file version", "error", err) } } func LoadConfigStringGlobals(content embed.FS) error { hotkeyData, err := content.ReadFile(variable.EmbedHotkeysFile) if err != nil { return err } HotkeysTomlString = string(hotkeyData) configData, err := content.ReadFile(variable.EmbedConfigFile) if err != nil { return err } ConfigTomlString = string(configData) themeData, err := content.ReadFile(variable.EmbedThemeCatppuccinFile) if err != nil { return err } DefaultThemeString = string(themeData) return nil } func WriteThemeFiles(content embed.FS) error { _, err := os.Stat(variable.ThemeFolder) if os.IsNotExist(err) { if err = os.MkdirAll(variable.ThemeFolder, utils.ConfigDirPerm); err != nil { slog.Error("Error creating theme directory", "error", err) return err } } files, err := content.ReadDir(variable.EmbedThemeDir) if err != nil { slog.Error("Error reading theme directory from embed", "error", err) return err } for _, file := range files { if file.IsDir() { continue } // This will not break in windows. This is a relative path for Embed FS. It uses "/" only src, err := content.ReadFile(variable.EmbedThemeDir + "/" + file.Name()) if err != nil { slog.Error("Error reading theme file from embed", "error", err) return err } curThemeFile, err := os.Create(filepath.Join(variable.ThemeFolder, file.Name())) if err != nil { slog.Error("Error creating theme file from embed", "error", err) return err } defer curThemeFile.Close() _, err = curThemeFile.Write(src) if err != nil { slog.Error("Error writing theme file from embed", "error", err) return err } } return nil } // Used only in unit tests // Populate config variables based on given file func PopulateGlobalConfigs() error { _, filename, _, ok := runtime.Caller(0) if !ok { return errors.New("failed to determine source file location") } // This is src/internal/common/load_config.go // we want src/superfile_config spfConfigDir := filepath.Join(filepath.Dir(filepath.Dir(filepath.Dir(filename))), "superfile_config") configFilePath := filepath.Join(spfConfigDir, "config.toml") hotkeyFilePath := filepath.Join(spfConfigDir, "hotkeys.toml") themeFilePath := filepath.Join(spfConfigDir, "theme", "monokai.toml") err := PopulateConfigFromFile(configFilePath) if err != nil { return err } err = PopulateHotkeyFromFile(hotkeyFilePath) if err != nil { return err } err = PopulateThemeFromFile(themeFilePath) if err != nil { return err } // Populate fixed variables LoadInitialPrerenderedVariables() icon.InitIcon(Config.Nerdfont, Theme.DirectoryIconColor) LoadPrerenderedVariables() return nil } // No validation required func populateFromFile(filePath string, target interface{}) error { data, err := os.ReadFile(filePath) if err != nil { return err } err = toml.Unmarshal(data, target) if err != nil { return err } return nil } func PopulateConfigFromFile(configFilePath string) error { return populateFromFile(configFilePath, &Config) } func PopulateHotkeyFromFile(hotkeyFilePath string) error { return populateFromFile(hotkeyFilePath, &Hotkeys) } func PopulateThemeFromFile(themeFilePath string) error { return populateFromFile(themeFilePath, &Theme) } func InitTrash() bool { // Create trash directories if runtime.GOOS != utils.OsLinux { return true } err := utils.CreateDirectories( variable.LinuxTrashDirectory, variable.LinuxTrashDirectoryFiles, variable.LinuxTrashDirectoryInfo, ) if err != nil { slog.Warn("Failed to initialize XDG trash; falling back to permanent delete", "error", err, "trashDir", variable.LinuxTrashDirectory) return false } return true } ================================================ FILE: src/internal/common/predefined_variable.go ================================================ package common import ( "time" "github.com/charmbracelet/lipgloss" "github.com/yorukot/superfile/src/config/icon" ) const ( WheelRunTime = 5 DefaultCommandTimeout = 5000 * time.Millisecond DateModifiedOption = "Date Modified" InvalidTypeString = "InvalidType" ) const ( SameRenameWarnTitle = "There is already a file or directory with that name" SameRenameWarnContent = "This operation will override the existing file" ) const ( TrashWarnTitle = "Are you sure you want to move this to trash can" TrashWarnContent = "This operation will move file or directory to trash can." PermanentDeleteWarnTitle = "Are you sure you want to completely delete" PermanentDeleteWarnContent = "This operation cannot be undone and your data will be completely lost." ) const ( MinimumHeight = 24 MinimumWidth = 60 // TODO : These are model object properties, not global properties // We are modifying them in the code many time. They need to be part of model struct. MinFooterHeight = 6 ModalWidth = 60 ModalHeight = 7 ) var ( SideBarSuperfileTitle string SideBarHomeDivider string SideBarPinnedDivider string SideBarDisksDivider string SideBarNoneText string ProcessBarNoneText string ClipboardNoneText string FilePanelTopDirectoryIcon string FilePanelNoneText string FilePreviewNoFileInfoText string FilePreviewNoContentText string FilePreviewUnsupportedFormatText string FilePreviewUnsupportedFileMode string FilePreviewDirectoryUnreadableText string FilePreviewEmptyText string FilePreviewError string FilePreviewPanelClosedText string FilePreviewImagePreviewDisabledText string FilePreviewUnsupportedImageFormatsText string FilePreviewImageConversionErrorText string FilePreviewBatNotInstalledText string FilePreviewThumbnailGenerationErrorText string CheckboxChecked string CheckboxCheckedFocused string CheckboxEmpty string CheckboxEmptyFocused string ModalConfirmInputText string ModalCancelInputText string ModalOkayInputText string ModalInputSpacingText string LipglossError string ) var ( UnsupportedPreviewFormats = []string{".torrent"} ImageExtensions = map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".bmp": true, ".tiff": true, ".svg": true, ".webp": true, ".ico": true, } VideoExtensions = map[string]bool{ ".mkv": true, ".mp4": true, ".mov": true, ".avi": true, ".flv": true, ".webm": true, ".wmv": true, ".m4v": true, ".mpeg": true, ".3gp": true, ".ogv": true, } ) // No dependencies func LoadInitialPrerenderedVariables() { LipglossError = lipgloss.NewStyle().Foreground(lipgloss.Color("#F93939")).Render("Error") + lipgloss.NewStyle().Foreground(lipgloss.Color("#00FFEE")).Render(" ┃ ") } // This should be used only after InitIcon() has been called. func wrapFilePreviewErrorMsg(msg string) string { return "\n--- " + icon.Error + icon.Space + msg + " ---" } // Dependecies - TODO We should programmatically guarantee these dependencies. And log error // if its not satisfied. // LoadThemeConfig() in style.go should be finished // loadConfigFile() in config_types.go should be finished // InitIcon() in config package in function.go should be finished func LoadPrerenderedVariables() { SideBarSuperfileTitle = SidebarTitleStyle.Render(" " + icon.SuperfileIcon + icon.Space + "superfile") SideBarHomeDivider = SidebarTitleStyle.Render(icon.Home+icon.Space+"Home") + SidebarDividerStyle.Render(" ─────────────") SideBarPinnedDivider = SidebarTitleStyle.Render(icon.Pinned+icon.Space+"Pinned") + SidebarDividerStyle.Render(" ───────────") SideBarDisksDivider = SidebarTitleStyle.Render(icon.Disk+icon.Space+"Disks") + SidebarDividerStyle.Render(" ────────────") SideBarNoneText = SidebarStyle.Render(" " + icon.Error + icon.Space + "None") ProcessBarNoneText = icon.Error + icon.Space + "No processes running" ClipboardNoneText = " " + icon.Error + icon.Space + " No content in clipboard" FilePanelTopDirectoryIcon = FilePanelTopDirectoryIconStyle.Render(" " + icon.Directory + icon.Space) FilePanelNoneText = FilePanelStyle.Render(" " + icon.Error + icon.Space + "No such file or directory") FilePreviewNoContentText = wrapFilePreviewErrorMsg( "No content to preview") FilePreviewNoFileInfoText = wrapFilePreviewErrorMsg( "Could not get file info") FilePreviewUnsupportedFormatText = wrapFilePreviewErrorMsg( "Unsupported formats") FilePreviewUnsupportedFileMode = wrapFilePreviewErrorMsg( "Unsupported File Mode") FilePreviewDirectoryUnreadableText = wrapFilePreviewErrorMsg( "Cannot read directory") FilePreviewError = wrapFilePreviewErrorMsg( "Error") FilePreviewEmptyText = wrapFilePreviewErrorMsg( "Empty") FilePreviewPanelClosedText = wrapFilePreviewErrorMsg( "Preview panel is closed") FilePreviewImagePreviewDisabledText = wrapFilePreviewErrorMsg( "Image preview is disabled") FilePreviewUnsupportedImageFormatsText = wrapFilePreviewErrorMsg( "Unsupported image formats") FilePreviewImageConversionErrorText = wrapFilePreviewErrorMsg( "Error convert image to ansi") FilePreviewBatNotInstalledText = wrapFilePreviewErrorMsg( "'bat' is not installed or not found") FilePreviewThumbnailGenerationErrorText = wrapFilePreviewErrorMsg( "Thumbnail generation failed") CheckboxChecked = FilePanelSelectBoxStyle. Foreground(FilePanelBorderColor). Render(icon.CheckboxChecked + icon.Space) CheckboxCheckedFocused = FilePanelSelectBoxStyle. Foreground(FilePanelBorderActiveColor). Render(icon.CheckboxChecked + icon.Space) CheckboxEmpty = FilePanelSelectBoxStyle. Foreground(FilePanelBorderColor). Render(icon.CheckboxEmpty + icon.Space) CheckboxEmptyFocused = FilePanelSelectBoxStyle. Foreground(FilePanelBorderActiveColor). Render(icon.CheckboxEmpty + icon.Space) ModalOkayInputText = MainStyle.AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).Render( ModalConfirm.Render(" (" + Hotkeys.ConfirmTyping[0] + ") Okay ")) ModalConfirmInputText = ModalConfirm.Render(" (" + Hotkeys.ConfirmTyping[0] + ") Confirm ") ModalCancelInputText = ModalCancel.Render(" (" + Hotkeys.Quit[0] + ") Cancel ") ModalInputSpacingText = lipgloss.NewStyle().Background(ModalBGColor).Render(" ") } ================================================ FILE: src/internal/common/string_function.go ================================================ package common import ( "bufio" "errors" "fmt" "io" "log/slog" "math" "os" "path/filepath" "strings" "unicode" "unicode/utf8" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) // Size calculation constants const ( KilobyteSize = 1000 // SI decimal unit KibibyteSize = 1024 // Binary unit TabWidth = 4 // Standard tab expansion width DefaultBufferSize = 1024 // Default buffer size for string operations NonBreakingSpace = 0xa0 // Unicode non-breaking space EscapeChar = 0x1b // ANSI escape character ASCIIMax = 0x7f // Maximum ASCII character value ) // TODO: This has a bug. Remove its usage. Remove all custom truncation // And audit and evaluate any problem // The logic truncates to maxChars - len(tails) first, then checks if truncation occurred. This means: // - "Hello" with maxChars=5 gets truncated to 2 chars (5-3=2), producing "He..." // - "Hello" with maxChars=6 gets truncated to 3 chars (6-3=3), producing "Hel..." // Both cases are wrong - "Hello" fits within 5 and 6 characters, so it shouldn't be truncated at all. func TruncateText(text string, maxChars int, tails string) string { truncatedText := ansi.Truncate(text, maxChars-len(tails), "") if text != truncatedText { return truncatedText + tails } return text } func TruncateTextBeginning(text string, maxChars int, tails string) string { if ansi.StringWidth(text) <= maxChars { return text } truncatedRunes := []rune(text) truncatedWidth := ansi.StringWidth(string(truncatedRunes)) for truncatedWidth > maxChars { truncatedRunes = truncatedRunes[1:] truncatedWidth = ansi.StringWidth(string(truncatedRunes)) } if len(truncatedRunes) > len(tails) { truncatedRunes = append([]rune(tails), truncatedRunes[len(tails):]...) } return string(truncatedRunes) } func TruncateMiddleText(text string, maxChars int, tails string) string { if utf8.RuneCountInString(text) <= maxChars { return text } //nolint:mnd // standard halving for center truncation halfEllipsisLength := (maxChars - 3) / 2 // TODO : Use ansi.Substring to correctly handle ANSI escape codes truncatedText := text[:halfEllipsisLength] + tails + text[utf8.RuneCountInString(text)-halfEllipsisLength:] return truncatedText } func FilePanelItemRenderWithIcon( name string, width int, isDir bool, isLink bool, isSelected bool, bgColor lipgloss.Color, ) string { style := GetElementIcon(name, isDir, isLink, Config.Nerdfont) iconData := style.Icon + " " filenameWidth := width - ansi.StringWidth(iconData) if filenameWidth <= 0 { // This should never happen, unless there is extremely low size or programming bug slog.Debug("Too low width for rendering file name", "width", width, "filenameWidth", filenameWidth) return "" } return StringColorRender(lipgloss.Color(style.Color), bgColor). Background(bgColor).Render(iconData) + FilePanelItemRender(name, filenameWidth, isSelected, bgColor, lipgloss.Left) } func FilePanelItemRender(data string, width int, isSelected bool, bgColor lipgloss.Color, alignment lipgloss.Position, ) string { outputData := ansi.Truncate(data, width, "...") style := FilePanelStyle if isSelected { style = FilePanelItemSelectedStyle } return style.Background(bgColor).Width(width).Align(alignment).Render(outputData) } func ClipboardPrettierName(name string, width int, isDir bool, isLink bool, isSelected bool) string { style := GetElementIcon(filepath.Base(name), isDir, isLink, Config.Nerdfont) if isSelected { return StringColorRender(lipgloss.Color(style.Color), FooterBGColor). Background(FooterBGColor). Render(style.Icon+" ") + FilePanelItemSelectedStyle.Render(TruncateTextBeginning(name, width, "...")) } return StringColorRender(lipgloss.Color(style.Color), FooterBGColor). Background(FooterBGColor). Render(style.Icon+" ") + FilePanelStyle.Render(TruncateTextBeginning(name, width, "...")) } func FileNameWithoutExtension(fileName string) string { for { pos := strings.LastIndexByte(fileName, '.') if pos <= 0 { break } fileName = fileName[:pos] } return fileName } func unitsDec() [7]string { return [...]string{"B", "kB", "MB", "GB", "TB", "PB", "EB"} } func unitsBin() [7]string { return [...]string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} } func formatSizeInternal(size int64, power int, unitlist [7]string) string { if size == 0 { return "0 B" } unitIndex := int(math.Floor(math.Log(float64(size)) / math.Log(float64(power)))) if unitIndex == 0 { return fmt.Sprintf("%d %s", size, unitlist[unitIndex]) } adjustedSize := float64(size) / math.Pow(float64(power), float64(unitIndex)) return fmt.Sprintf("%.2f %s", adjustedSize, unitlist[unitIndex]) } func FormatFileSize(size int64) string { units := unitsBin() if Config.FileSizeUseSI { units = unitsDec() } return formatSizeInternal(size, KibibyteSize, units) } func GetHelpMenuHotkeyString(hotkeys []string) string { var hotkey strings.Builder for i, key := range hotkeys { if key == "" { continue } if i != 0 { hotkey.WriteString(" | ") } if key == " " { key = "space" } hotkey.WriteString(key) } return hotkey.String() } // Separated this out out for easy testing func IsBufferPrintable(buffer []byte) bool { for _, b := range buffer { // This will also handle b==0 if !unicode.IsPrint(rune(b)) && !unicode.IsSpace(rune(b)) { return false } } return true } // IsExtensionExtractable checks if a string is a valid compressed archive file extension. func IsExtensionExtractable(ext string) bool { // Extensions based on the types that package: `xtractr` `ExtractFile` function handles. validExtensions := map[string]struct{}{ ".zip": {}, ".bz": {}, ".gz": {}, ".iso": {}, ".rar": {}, ".7z": {}, ".tar": {}, ".tar.gz": {}, ".tar.bz2": {}, } _, exists := validExtensions[strings.ToLower(ext)] return exists } // Check file is text file or not func IsTextFile(filename string) (bool, error) { file, err := os.Open(filename) if err != nil { return false, err } defer file.Close() reader := bufio.NewReader(file) buffer := make([]byte, DefaultBufferSize) cnt, err := reader.Read(buffer) if err != nil && !errors.Is(err, io.EOF) { return false, err } return IsBufferPrintable(buffer[:cnt]), nil } // Although some characters like `\x0b`(vertical tab) are printable, // previewing them breaks the layout. // So, among the "non-graphic" printable characters, we only need \n and \t // Space and NBSP are already considered graphic by unicode. // Allow Any rune that is above ASCII control characters range 0x7f // for valid unicodes like nerdfont \uf410 \U000f0868 // Also allow \x0b that is for escape sequences // This function should better not be broken into multiple functions func MakePrintableWithEscCheck(line string, allowEsc bool) string { //nolint: gocognit // See above var sb strings.Builder for _, r := range line { if r == utf8.RuneError { continue } // It needs to be handled separately since considered a space, // It is multi-byte in UTF-8, But it has zero display width if r == NonBreakingSpace { sb.WriteRune(r) continue } // It needs to be handled separately since considered a space, // Since we are using ansi.StringWidth() for truncation, and \t is // considered zero width if r == '\t' { sb.WriteString(" ") continue } if r == EscapeChar { if allowEsc { sb.WriteRune(r) } continue } if r > ASCIIMax { if unicode.IsSpace(r) && utf8.RuneLen(r) > 1 { // See https://github.com/charmbracelet/x/issues/466 // Space chacters spanning more than one bytes are not handled well by // ansi.Wrap(), and so lipgloss.Render() has issues r = ' ' } sb.WriteRune(r) continue } if unicode.IsGraphic(r) || r == rune('\n') { sb.WriteRune(r) } } return sb.String() } func MakePrintable(line string) string { // We assume default behaviour of allowing ESC is not a problem // If you disallow ESC, then you would see ansi codes afer \x1b and it will look ugly // But thats only for files with that kind of data, and its rare. // And yazi does it too. // We will keep it false only if it can cause a rendering problem return MakePrintableWithEscCheck(line, true) } ================================================ FILE: src/internal/common/string_function_test.go ================================================ package common import ( "fmt" "math" "testing" "github.com/stretchr/testify/assert" ) func TestStringTruncate(t *testing.T) { var inputs = []struct { function func(string, int, string) string funcName string input string maxSize int talis string expected string }{ {TruncateText, "TruncateText", "Hello world", 4, "...", "H..."}, {TruncateText, "TruncateText", "Hello world", 6, "...", "Hel..."}, {TruncateText, "TruncateText", "Hello", 100, "...", "Hello"}, {TruncateTextBeginning, "TruncateTextBeginning", "Hello world", 4, "...", "...d"}, {TruncateTextBeginning, "TruncateTextBeginning", "Hello world", 6, "...", "...rld"}, {TruncateTextBeginning, "TruncateTextBeginning", "Hello", 100, "...", "Hello"}, {TruncateMiddleText, "TruncateMiddleText", "Hello world", 5, "...", "H...d"}, {TruncateMiddleText, "TruncateMiddleText", "Hello world", 7, "...", "He...ld"}, {TruncateMiddleText, "TruncateMiddleText", "Hello", 100, "...", "Hello"}, } for _, tt := range inputs { t.Run(fmt.Sprintf("Run %s on string %s to %d chars", tt.funcName, tt.input, tt.maxSize), func(t *testing.T) { result := tt.function(tt.input, tt.maxSize, tt.talis) expected := tt.expected if result != expected { t.Errorf("got \"%s\", expected \"%s\"", result, expected) } }) } } func TestFilenameWithouText(t *testing.T) { var inputs = []struct { input string expected string }{ {"hello", "hello"}, {"hello.zip", "hello"}, {"hello.tar.gz", "hello"}, {".gitignore", ".gitignore"}, {"", ""}, } for _, tt := range inputs { t.Run(fmt.Sprintf("Remove extension from %s", tt.input), func(t *testing.T) { result := FileNameWithoutExtension(tt.input) if result != tt.expected { t.Errorf("Expected %s, got %s", tt.expected, result) } }) } } func TestHelpHotkeyString(t *testing.T) { tests := []struct { name string input []string expected string }{ { name: "Single key", input: []string{"a"}, expected: "a", }, { name: "Multiple keys", input: []string{"a", "b", "c"}, expected: "a | b | c", }, { name: "Empty key", input: []string{"a", "", "b"}, expected: "a | b", }, { name: "Trailing empty", input: []string{"a", ""}, expected: "a", }, { name: "Trailing empty with multiple keys", input: []string{"a", "b", ""}, expected: "a | b", }, { name: "Space key", input: []string{" "}, expected: "space", }, // Starting with an empty key ("", "a") is not allowed by the file parser, // so a test is not needed } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := GetHelpMenuHotkeyString(tt.input) assert.Equal(t, tt.expected, result) }) } } func TestIsBufferPrintable(t *testing.T) { var inputs = []struct { input string expected bool }{ {"", true}, {"hello", true}, {"abcdABCD0123~!@#$%^&*()_+-={}|:\"<>?,./;'[]", true}, {"Horizontal Tab and NewLine\t\t\n\n", true}, {"\xa0(NBSP)", true}, {"\x0b(Vertical Tab)", true}, {"\x0d(CR)", true}, {"ASCII control characters : \x00(NULL)", false}, {"\x05(ENQ)", false}, {"\x0f(SI)", false}, {"\x1b(ESC)", false}, {"\x7f(DEL)", false}, } for _, tt := range inputs { t.Run(fmt.Sprintf("Testing if buffer %q is printable", tt.input), func(t *testing.T) { result := IsBufferPrintable([]byte(tt.input)) if result != tt.expected { t.Errorf("Expected %v, got %v", tt.expected, result) } }) } } func TestIsExtensionExtractable(t *testing.T) { inputs := []struct { ext string expected bool }{ {".zip", true}, {".rar", true}, {".7z", true}, {".tar.gz", true}, {".tar.bz2", true}, {".exe", false}, {".txt", false}, {".tar", true}, {"", false}, // Empty string case {".ZIP", true}, // Case sensitivity check {".Zip", true}, // Case sensitivity check {".bz", true}, {".gz", true}, {".iso", true}, } for _, tt := range inputs { t.Run(tt.ext, func(t *testing.T) { result := IsExtensionExtractable(tt.ext) if result != tt.expected { t.Errorf("IsExensionExtractable (%q) = %v; want %v", tt.ext, result, tt.expected) } }) } } func TestMakePrintable(t *testing.T) { var inputs = []struct { input string expected string }{ {"", ""}, {"hello", "hello"}, {"abcdABCD0123~!@#$%^&*()_+-={}|:\"<>?,./;'[]", "abcdABCD0123~!@#$%^&*()_+-={}|:\"<>?,./;'[]"}, {"Horizontal Tab and NewLine\t\t\n\n", "Horizontal Tab and NewLine \n\n"}, {"(NBSP)\u00a0\u00a0\u00a0\u00a0;", "(NBSP)\u00a0\u00a0\u00a0\u00a0;"}, {"\x0b(Vertical Tab)", "(Vertical Tab)"}, {"\x0d(CR)", "(CR)"}, {"ASCII control characters : \x00(NULL)", "ASCII control characters : (NULL)"}, {"\x05(ENQ)", "(ENQ)"}, {"\x0f(SI)", "(SI)"}, {"\x1b(ESC)", "\x1b(ESC)"}, {"\x7f(DEL)", "(DEL)"}, {"\x7f(DEL)", "(DEL)"}, {"Valid unicodes like nerdfont \uf410 \U000f0868", "Valid unicodes like nerdfont \uf410 \U000f0868"}, {"Invalid Unicodes\ufffd", "Invalid Unicodes"}, {"Invalid Unicodes\xa0", "Invalid Unicodes"}, {"Ascii color sequence\x1b[38;2;230;219;116;48;2;39;40;34m\ue68f \x1b[0m", "Ascii color sequence\x1b[38;2;230;219;116;48;2;39;40;34m\ue68f \x1b[0m"}, {"Unicodes spaces\u202f\u205f\u2029", "Unicodes spaces "}, {"IDEOGRAPHIC SPACE\u3000", "IDEOGRAPHIC SPACE "}, } for _, tt := range inputs { t.Run(fmt.Sprintf("Make %q printable", tt.input), func(t *testing.T) { result := MakePrintable(tt.input) if result != tt.expected { t.Errorf("Expected '%v', got '%v' (input : '%v')", tt.expected, result, tt.input) } }) } t.Run("ESC is skipped", func(t *testing.T) { assert.Equal(t, "(ESC)", MakePrintableWithEscCheck("\x1b(ESC)", false)) }) t.Run("ESC is not skipped", func(t *testing.T) { assert.Equal(t, "\x1b(ESC)", MakePrintableWithEscCheck("\x1b(ESC)", true)) }) } func TestFormatSizeInternal(t *testing.T) { t.Run("max int size", func(t *testing.T) { actual := formatSizeInternal(math.MaxInt64, KilobyteSize, unitsDec()) assert.Equal(t, "9.22 EB", actual) }) t.Run("zero size", func(t *testing.T) { actual := formatSizeInternal(0, KilobyteSize, unitsDec()) assert.Equal(t, "0 B", actual) }) t.Run("100 bytes size", func(t *testing.T) { actual := formatSizeInternal(100, KilobyteSize, unitsDec()) assert.Equal(t, "100 B", actual) }) t.Run("1005 bytes size", func(t *testing.T) { actual := formatSizeInternal(1005, KilobyteSize, unitsDec()) assert.Equal(t, "1.00 kB", actual) }) t.Run("1005 bytes size kibi", func(t *testing.T) { actual := formatSizeInternal(1005, KibibyteSize, unitsBin()) assert.Equal(t, "1005 B", actual) }) t.Run("1025 bytes size kibi", func(t *testing.T) { actual := formatSizeInternal(1025, KibibyteSize, unitsBin()) assert.Equal(t, "1.00 KiB", actual) }) t.Run("1035 bytes size kibi", func(t *testing.T) { actual := formatSizeInternal(1035, KibibyteSize, unitsBin()) assert.Equal(t, "1.01 KiB", actual) }) } ================================================ FILE: src/internal/common/style.go ================================================ package common import ( "github.com/charmbracelet/lipgloss" ) var ( BottomMiddleBorderSplit string ) var ( TerminalTooSmall lipgloss.Style TerminalCorrectSize lipgloss.Style ) var ( MainStyle lipgloss.Style FilePanelStyle lipgloss.Style SidebarStyle lipgloss.Style FooterStyle lipgloss.Style ModalStyle lipgloss.Style ) var ( SidebarDividerStyle lipgloss.Style SidebarTitleStyle lipgloss.Style SidebarSelectedStyle lipgloss.Style ) var ( FilePanelCursorStyle lipgloss.Style FooterCursorStyle lipgloss.Style ModalCursorStyle lipgloss.Style ) var ( FilePanelTopDirectoryIconStyle lipgloss.Style FilePanelTopPathStyle lipgloss.Style FilePanelItemSelectedStyle lipgloss.Style FilePanelSelectBoxStyle lipgloss.Style ) var ( ProcessErrorStyle lipgloss.Style ProcessInOperationStyle lipgloss.Style ProcessCancelStyle lipgloss.Style ProcessSuccessfulStyle lipgloss.Style ) var ( ModalCancel lipgloss.Style ModalConfirm lipgloss.Style ModalTitleStyle lipgloss.Style ModalErrorStyle lipgloss.Style ) var ( HelpMenuHotkeyStyle lipgloss.Style HelpMenuTitleStyle lipgloss.Style ) var ( PromptSuccessStyle lipgloss.Style PromptFailureStyle lipgloss.Style ) var TransparentBackgroundColor string var ( FilePanelBorderColor lipgloss.Color SidebarBorderColor lipgloss.Color FooterBorderColor lipgloss.Color FilePanelBorderActiveColor lipgloss.Color SidebarBorderActiveColor lipgloss.Color FooterBorderActiveColor lipgloss.Color ModalBorderActiveColor lipgloss.Color FullScreenBGColor lipgloss.Color FilePanelBGColor lipgloss.Color SidebarBGColor lipgloss.Color FooterBGColor lipgloss.Color ModalBGColor lipgloss.Color FullScreenFGColor lipgloss.Color FilePanelFGColor lipgloss.Color SidebarFGColor lipgloss.Color FooterFGColor lipgloss.Color ModalFGColor lipgloss.Color cursorColor lipgloss.Color correctColor lipgloss.Color errorColor lipgloss.Color hintColor lipgloss.Color cancelColor lipgloss.Color filePanelTopDirectoryIconColor lipgloss.Color filePanelTopPathColor lipgloss.Color filePanelItemSelectedFGColor lipgloss.Color filePanelItemSelectedBGColor lipgloss.Color sidebarTitleColor lipgloss.Color sidebarItemSelectedFGColor lipgloss.Color sidebarItemSelectedBGColor lipgloss.Color sidebarDividerColor lipgloss.Color modalCancelFGColor lipgloss.Color modalCancelBGColor lipgloss.Color modalConfirmFGColor lipgloss.Color modalConfirmBGColor lipgloss.Color helpMenuHotkeyColor lipgloss.Color helpMenuTitleColor lipgloss.Color promptSuccessColor lipgloss.Color promptFailureColor lipgloss.Color ) func LoadThemeConfig() { //nolint: funlen // Variable initialization BottomMiddleBorderSplit = Config.BorderMiddleLeft + Config.BorderBottom + Config.BorderMiddleRight FilePanelBorderColor = lipgloss.Color(Theme.FilePanelBorder) SidebarBorderColor = lipgloss.Color(Theme.SidebarBorder) FooterBorderColor = lipgloss.Color(Theme.FooterBorder) FilePanelBorderActiveColor = lipgloss.Color(Theme.FilePanelBorderActive) SidebarBorderActiveColor = lipgloss.Color(Theme.SidebarBorderActive) FooterBorderActiveColor = lipgloss.Color(Theme.FooterBorderActive) ModalBorderActiveColor = lipgloss.Color(Theme.ModalBorderActive) FullScreenBGColor = lipgloss.Color(Theme.FullScreenBG) FilePanelBGColor = lipgloss.Color(Theme.FilePanelBG) SidebarBGColor = lipgloss.Color(Theme.SidebarBG) FooterBGColor = lipgloss.Color(Theme.FooterBG) ModalBGColor = lipgloss.Color(Theme.ModalBG) FullScreenFGColor = lipgloss.Color(Theme.FullScreenFG) FilePanelFGColor = lipgloss.Color(Theme.FilePanelFG) SidebarFGColor = lipgloss.Color(Theme.SidebarFG) FooterFGColor = lipgloss.Color(Theme.FooterFG) ModalFGColor = lipgloss.Color(Theme.ModalFG) cursorColor = lipgloss.Color(Theme.Cursor) correctColor = lipgloss.Color(Theme.Correct) errorColor = lipgloss.Color(Theme.Error) hintColor = lipgloss.Color(Theme.Hint) cancelColor = lipgloss.Color(Theme.Cancel) filePanelTopDirectoryIconColor = lipgloss.Color(Theme.FilePanelTopDirectoryIcon) filePanelTopPathColor = lipgloss.Color(Theme.FilePanelTopPath) filePanelItemSelectedFGColor = lipgloss.Color(Theme.FilePanelItemSelectedFG) filePanelItemSelectedBGColor = lipgloss.Color(Theme.FilePanelItemSelectedBG) sidebarTitleColor = lipgloss.Color(Theme.SidebarTitle) sidebarItemSelectedFGColor = lipgloss.Color(Theme.SidebarItemSelectedFG) sidebarItemSelectedBGColor = lipgloss.Color(Theme.SidebarItemSelectedBG) sidebarDividerColor = lipgloss.Color(Theme.SidebarDivider) modalCancelFGColor = lipgloss.Color(Theme.ModalCancelFG) modalCancelBGColor = lipgloss.Color(Theme.ModalCancelBG) modalConfirmFGColor = lipgloss.Color(Theme.ModalConfirmFG) modalConfirmBGColor = lipgloss.Color(Theme.ModalConfirmBG) helpMenuHotkeyColor = lipgloss.Color(Theme.HelpMenuHotkey) helpMenuTitleColor = lipgloss.Color(Theme.HelpMenuTitle) promptSuccessColor = lipgloss.Color(Theme.Correct) promptFailureColor = lipgloss.Color(Theme.Error) if Config.TransparentBackground { TransparentAllBackgroundColor() } // All Panel Main Color // (full screen and default color) MainStyle = lipgloss.NewStyle().Foreground(FullScreenFGColor).Background(FullScreenBGColor) FilePanelStyle = lipgloss.NewStyle().Foreground(FilePanelFGColor).Background(FilePanelBGColor) SidebarStyle = lipgloss.NewStyle().Foreground(SidebarFGColor).Background(SidebarBGColor) FooterStyle = lipgloss.NewStyle().Foreground(FooterFGColor).Background(FooterBGColor) ModalStyle = lipgloss.NewStyle().Foreground(ModalFGColor).Background(ModalBGColor) // Terminal Size Error TerminalTooSmall = lipgloss.NewStyle().Foreground(errorColor).Background(FullScreenBGColor) TerminalCorrectSize = lipgloss.NewStyle().Foreground(cursorColor).Background(FullScreenBGColor) // Cursor FilePanelCursorStyle = lipgloss.NewStyle().Foreground(cursorColor).Background(FilePanelBGColor) FooterCursorStyle = lipgloss.NewStyle().Foreground(cursorColor).Background(FooterBGColor) ModalCursorStyle = lipgloss.NewStyle().Foreground(cursorColor).Background(ModalBGColor) // File Panel Special Style FilePanelTopDirectoryIconStyle = lipgloss.NewStyle().Foreground(filePanelTopDirectoryIconColor). Background(FilePanelBGColor) FilePanelTopPathStyle = lipgloss.NewStyle().Foreground(filePanelTopPathColor).Background(FilePanelBGColor) FilePanelItemSelectedStyle = lipgloss.NewStyle().Foreground(filePanelItemSelectedFGColor). Background(filePanelItemSelectedBGColor) FilePanelSelectBoxStyle = lipgloss.NewStyle().Background(FilePanelBGColor) // Sidebar Special Style SidebarDividerStyle = lipgloss.NewStyle().Foreground(sidebarDividerColor).Background(SidebarBGColor) SidebarTitleStyle = lipgloss.NewStyle().Foreground(sidebarTitleColor).Background(SidebarBGColor) SidebarSelectedStyle = lipgloss.NewStyle().Foreground(sidebarItemSelectedFGColor). Background(sidebarItemSelectedBGColor) // Footer Special Style ProcessErrorStyle = lipgloss.NewStyle().Foreground(errorColor).Background(FooterBGColor) ProcessInOperationStyle = lipgloss.NewStyle().Foreground(hintColor).Background(FooterBGColor) ProcessCancelStyle = lipgloss.NewStyle().Foreground(cancelColor).Background(FooterBGColor) ProcessSuccessfulStyle = lipgloss.NewStyle().Foreground(correctColor).Background(FooterBGColor) // Modal Special Style ModalCancel = lipgloss.NewStyle().Foreground(modalCancelFGColor).Background(modalCancelBGColor) ModalConfirm = lipgloss.NewStyle().Foreground(modalConfirmFGColor).Background(modalConfirmBGColor) ModalTitleStyle = lipgloss.NewStyle().Foreground(hintColor).Background(ModalBGColor) ModalErrorStyle = lipgloss.NewStyle().Foreground(errorColor).Background(ModalBGColor) // Help Menu Style HelpMenuHotkeyStyle = lipgloss.NewStyle().Foreground(helpMenuHotkeyColor).Background(ModalBGColor) HelpMenuTitleStyle = lipgloss.NewStyle().Foreground(helpMenuTitleColor).Background(ModalBGColor) // Prompt Style PromptSuccessStyle = lipgloss.NewStyle().Foreground(promptSuccessColor).Background(ModalBGColor) PromptFailureStyle = lipgloss.NewStyle().Foreground(promptFailureColor).Background(ModalBGColor) } func TransparentAllBackgroundColor() { if SidebarBGColor == sidebarItemSelectedBGColor { sidebarItemSelectedBGColor = lipgloss.Color(TransparentBackgroundColor) } if FilePanelBGColor == filePanelItemSelectedBGColor { filePanelItemSelectedBGColor = lipgloss.Color(TransparentBackgroundColor) } FullScreenBGColor = lipgloss.Color(TransparentBackgroundColor) FilePanelBGColor = lipgloss.Color(TransparentBackgroundColor) SidebarBGColor = lipgloss.Color(TransparentBackgroundColor) FooterBGColor = lipgloss.Color(TransparentBackgroundColor) ModalBGColor = lipgloss.Color(TransparentBackgroundColor) } ================================================ FILE: src/internal/common/style_function.go ================================================ package common import ( "path/filepath" "strings" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/lipgloss" "github.com/yorukot/superfile/src/config/icon" ) func ModalBorderStyle(height int, width int) lipgloss.Style { return modalBorderStyleWithAlign(height, width, lipgloss.Center) } // Generate modal (pop up widnwos) border style func modalBorderStyleWithAlign(height int, width int, horizontalAlignment lipgloss.Position) lipgloss.Style { border := GenerateBorder() return lipgloss.NewStyle().Height(height). Width(width). Align(horizontalAlignment, lipgloss.Center). Border(border). BorderForeground(ModalBorderActiveColor). BorderBackground(ModalBGColor). Background(ModalBGColor). Foreground(ModalFGColor) } // Generate first use modal style (This modal pop up when user first use superfile) func FirstUseModal(height int, width int) lipgloss.Style { border := GenerateBorder() return lipgloss.NewStyle().Height(height). Width(width). Align(lipgloss.Left, lipgloss.Center). Border(border). BorderForeground(ModalBorderActiveColor). BorderBackground(ModalBGColor). Background(ModalBGColor). Foreground(ModalFGColor) } // Generate sort options modal border style func SortOptionsModalBorderStyle(height int, width int, borderBottom string) lipgloss.Style { border := GenerateBorder() border.Bottom = borderBottom return lipgloss.NewStyle(). Border(border). BorderForeground(ModalBorderActiveColor). BorderBackground(ModalBGColor). Width(width). Height(height). Background(ModalBGColor). Foreground(ModalFGColor) } // Generate full screen style for terminal size too small etc func FullScreenStyle(height int, width int) lipgloss.Style { return lipgloss.NewStyle(). Height(height). Width(width). Align(lipgloss.Center, lipgloss.Center). Background(FullScreenBGColor). Foreground(FullScreenFGColor) } // Return only fg and bg color style func StringColorRender(fgColor lipgloss.Color, bgColor lipgloss.Color) lipgloss.Style { return lipgloss.NewStyle(). Foreground(fgColor). Background(bgColor) } // Generate border style func GenerateBorder() lipgloss.Border { return lipgloss.Border{ Top: Config.BorderTop, Bottom: Config.BorderBottom, Left: Config.BorderLeft, Right: Config.BorderRight, TopLeft: Config.BorderTopLeft, TopRight: Config.BorderTopRight, BottomLeft: Config.BorderBottomLeft, BottomRight: Config.BorderBottomRight, } } func LoadConfigError(value string, msg string) string { return UserConfigInvalidationErrorString(value, "Config", msg) } func LoadHotkeysError(value string, msg string) string { return UserConfigInvalidationErrorString(value, "Hotkey", msg) } func LoadThemeError(value string, msg string) string { return UserConfigInvalidationErrorString(value, "Theme", msg) } func UserConfigInvalidationErrorString(value string, configType string, msg string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555")).Render("■ ERROR: ") + configType + " value for \"" + lipgloss.NewStyle().Foreground(lipgloss.Color("#00D9FF")).Render(value) + "\" is invalid : " + msg } // TODO : Fix Code duplication in textInput.Model creation // This eventually caused a bug, where we created new model for sidebar search, and // Didn't set `Width` in that. Take Width and other parameters as input in one function // Generate search bar for file panel func GenerateSearchBar() textinput.Model { ti := textinput.New() ti.Cursor.Style = FooterCursorStyle ti.Cursor.TextStyle = FooterStyle ti.TextStyle = FilePanelStyle ti.Prompt = FilePanelTopDirectoryIconStyle.Render(icon.Search + icon.Space) ti.Cursor.Blink = true ti.PlaceholderStyle = FilePanelStyle ti.Placeholder = "(" + Hotkeys.SearchBar[0] + ") Type something" ti.Blur() ti.CharLimit = 156 return ti } func GeneratePromptTextInput() textinput.Model { t := textinput.New() t.Prompt = "" t.CharLimit = 156 t.SetValue("") t.Cursor.Style = ModalCursorStyle t.Cursor.TextStyle = ModalStyle t.TextStyle = ModalStyle t.PlaceholderStyle = ModalStyle return t } func GenerateNewFileTextInput() textinput.Model { t := textinput.New() t.Cursor.Style = ModalCursorStyle t.Cursor.TextStyle = ModalStyle t.TextStyle = ModalStyle t.Cursor.Blink = true t.Placeholder = "Add \"" + string(filepath.Separator) + "\" transcend folders" t.PlaceholderStyle = ModalStyle t.Focus() t.CharLimit = 156 //nolint:mnd // modal width minus padding t.Width = ModalWidth - 10 return t } func GenerateRenameTextInput(width int, cursorPos int, defaultValue string) textinput.Model { ti := textinput.New() ti.Cursor.Style = FilePanelCursorStyle ti.Cursor.TextStyle = FilePanelStyle ti.Prompt = FilePanelCursorStyle.Render(icon.Cursor + " ") ti.TextStyle = ModalStyle ti.Cursor.Blink = true ti.Placeholder = "New name" ti.PlaceholderStyle = ModalStyle ti.SetValue(defaultValue) ti.SetCursor(cursorPos) ti.Focus() ti.CharLimit = 156 ti.Width = width return ti } func GeneratePinnedRenameTextInput(cursorPos int, defaultValue string) textinput.Model { ti := textinput.New() ti.Cursor.Style = FilePanelCursorStyle ti.Cursor.TextStyle = FilePanelStyle ti.Prompt = FilePanelCursorStyle.Render(icon.Cursor + " ") ti.TextStyle = ModalStyle ti.Cursor.Blink = true ti.Placeholder = "New name" ti.PlaceholderStyle = ModalStyle ti.SetValue(defaultValue) ti.SetCursor(cursorPos) ti.Focus() ti.CharLimit = 156 ti.Width = Config.SidebarWidth - PanelPadding return ti } func GenerateGradientColor() progress.Option { return progress.WithScaledGradient(Theme.GradientColor[0], Theme.GradientColor[1]) } func GenerateFooterBorder(countString string, width int) string { repeatCount := width - len(countString) if repeatCount < 0 { repeatCount = 0 } return strings.Repeat(Config.BorderBottom, repeatCount) + Config.BorderMiddleRight + countString + Config.BorderMiddleLeft } ================================================ FILE: src/internal/common/type.go ================================================ package common // Placeholder inteface for now, might later move 'model' type to commons and have // and add an execute(model) function to this type ModelAction interface { String() string } type NoAction struct { } func (n NoAction) String() string { return "NoAction" } type ShellCommandAction struct { Command string } func (s ShellCommandAction) String() string { return "ShellCommandAction for command " + s.Command } // We could later move 'model' type to commons and have // these actions implement an execute(model) interface type SplitPanelAction struct{} func (s SplitPanelAction) String() string { return "SplitPanelAction" } type CDCurrentPanelAction struct { Location string } func (c CDCurrentPanelAction) String() string { return "CDCurrentPanelAction to " + c.Location } type OpenPanelAction struct { Location string } func (o OpenPanelAction) String() string { return "OpenPanelAction at " + o.Location } ================================================ FILE: src/internal/common/ui_consts.go ================================================ package common import "time" // Shared UI/layout constants to replace magic numbers flagged by mnd. const ( HelpKeyColumnWidth = 55 // width of help key column in CLI help DefaultCLIContextTimeout = 5 * time.Second // default CLI context timeout for CLI ops PanelPadding = 3 // rows reserved around file list (borders/header/footer) BorderPadding = 2 // rows/cols for outer border frame InnerPadding = 4 // cols for inner content padding (truncate widths) FooterGroupCols = 3 // columns per group in footer layout math DefaultFilePanelWidth = 10 // default width for file panels FilePanelMax = 10 // max number of file panels supported ResponsiveWidthThreshold = 95 // width breakpoint for layout behavior HeightBreakA = 30 // responsive height tiers HeightBreakB = 35 HeightBreakC = 40 HeightBreakD = 45 FilePanelWidthUnit = 20 // width unit used to calculate max file panels DefaultPreviewTimeout = 500 * time.Millisecond // preview operation timeout FileNameRatioMin = 25 FileNameRatioMax = 100 RequiredGradientColorCount = 2 // UI positioning CenterDivisor = 2 // divisor for centering UI elements ) ================================================ FILE: src/internal/config_function.go ================================================ package internal import ( "errors" "log/slog" "os" "reflect" "runtime" zoxidelib "github.com/lazysegtree/go-zoxide" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/ui/filepanel" "github.com/barasher/go-exiftool" "github.com/yorukot/superfile/src/internal/ui/processbar" "github.com/yorukot/superfile/src/internal/ui/rendering" "github.com/yorukot/superfile/src/internal/ui/sidebar" variable "github.com/yorukot/superfile/src/config" "github.com/yorukot/superfile/src/config/icon" "github.com/yorukot/superfile/src/internal/common" ) // initialConfig load and handle all configuration files (spf config,Hotkeys // themes) setted up. Processes input directories and returns toggle states. // This is the only usecase of named returns, distinguish between multiple return values func initialConfig(firstPanelPaths []string) (toggleDotFile bool, //nolint: nonamedreturns // See above toggleFooter bool, zClient *zoxidelib.Client) { // Open log stream file, err := os.OpenFile(variable.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, utils.LogFilePerm) // TODO : This could be improved if we want to make superfile more resilient to errors // For example if the log file directories have access issues. // we could pass a dummy object to log.SetOutput() and the app would still function. if err != nil { utils.PrintfAndExitf("Error while opening superfile.log file : %v", err) } common.LoadConfigFile() logLevel := slog.LevelInfo if common.Config.Debug { logLevel = slog.LevelDebug } slog.SetDefault(slog.New(slog.NewTextHandler( file, &slog.HandlerOptions{Level: logLevel}))) printRuntimeInfo() common.LoadHotkeysFile(common.Config.IgnoreMissingFields) common.LoadThemeFile() icon.InitIcon(common.Config.Nerdfont, common.Theme.DirectoryIconColor) common.LoadThemeConfig() common.LoadPrerenderedVariables() // TODO: Make sure to clean it up. Via et.Close() // Note: All the tool we use to interact with OS, should be abstracted behind a struc // Have exiftool manager, Zoxide Manager, OS Manager, Xtractor, Zipper, Command Executor if common.Config.Metadata { et, err = exiftool.NewExiftool() if err != nil { slog.Error("Error while initial model function init exiftool error", "error", err) } } cwd, err := os.Getwd() if err != nil { slog.Error("cannot get current working directory", "error", err) cwd = variable.HomeDir } if common.Config.ZoxideSupport { zClient, err = zoxidelib.New() if err != nil { slog.Error("Error initializing zoxide client", "error", err) } } updateFirstFilePanelPaths(firstPanelPaths, cwd, zClient) slog.Debug("Directory configuration", "cwd", cwd, "start_paths", firstPanelPaths) printRuntimeInfo() toggleDotFile = utils.ReadBoolFile(variable.ToggleDotFile, false) toggleFooter = utils.ReadBoolFile(variable.ToggleFooter, true) return toggleDotFile, toggleFooter, zClient } func updateFirstFilePanelPaths(firstPanelPaths []string, cwd string, zClient *zoxidelib.Client) { for i := range firstPanelPaths { if firstPanelPaths[i] == "" { firstPanelPaths[i] = common.Config.DefaultDirectory } originalPath := firstPanelPaths[i] firstPanelPaths[i] = utils.ResolveAbsPath(cwd, firstPanelPaths[i]) if _, err := os.Stat(firstPanelPaths[i]); err != nil { slog.Error("cannot get stats", "path", firstPanelPaths[i], "error", err) // In case the path provided did not exist, use zoxide query // else, fallback to home dir if common.Config.ZoxideSupport && zClient != nil { path, err := attemptZoxideForInitPath(originalPath, zClient) if err != nil { slog.Error("Zoxide query error", "originalPath", originalPath, "error", err) firstPanelPaths[i] = variable.HomeDir } else { firstPanelPaths[i] = path } } else { firstPanelPaths[i] = variable.HomeDir } } } } func attemptZoxideForInitPath(originalPath string, zClient *zoxidelib.Client) (string, error) { path, err := zClient.Query(originalPath) if err != nil { return "", err } if path == "" { return "", errors.New("zoxide returned empty path") } if stat, statErr := os.Stat(path); statErr != nil || !stat.IsDir() { return "", errors.New("zoxide returned invalid path") } return path, nil } func printRuntimeInfo() { slog.Debug("Runtime information", "runtime.GOOS", runtime.GOOS) var memStats runtime.MemStats runtime.ReadMemStats(&memStats) slog.Debug("Memory usage", "alloc_bytes", memStats.Alloc, "total_alloc_bytes", memStats.TotalAlloc, "heap_objects", memStats.HeapObjects, "sys_bytes", memStats.Sys) slog.Debug("Object sizes", "model_size_bytes", reflect.TypeOf(model{}).Size(), "filePanel_size_bytes", reflect.TypeOf(filepanel.Model{}).Size(), "sidebarModel_size_bytes", reflect.TypeOf(sidebar.Model{}).Size(), "renderer_size_bytes", reflect.TypeOf(rendering.Renderer{}).Size(), "borderConfig_size_bytes", reflect.TypeOf(rendering.BorderConfig{}).Size(), "process_size_bytes", reflect.TypeOf(processbar.Process{}).Size()) } ================================================ FILE: src/internal/default_config.go ================================================ package internal import ( zoxidelib "github.com/lazysegtree/go-zoxide" "github.com/yorukot/superfile/src/internal/ui/helpmenu" "github.com/yorukot/superfile/src/internal/ui/filemodel" "github.com/yorukot/superfile/src/internal/ui/sortmodel" "github.com/yorukot/superfile/src/internal/ui/metadata" "github.com/yorukot/superfile/src/internal/ui/processbar" "github.com/yorukot/superfile/src/internal/ui/sidebar" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui/prompt" zoxideui "github.com/yorukot/superfile/src/internal/ui/zoxide" ) // Generate and return model containing default configurations for interface // Maybe we can replace slice of strings with var args - Should we ? // TODO: Move the configuration parameters to a ModelConfig struct. // Something like `RendererConfig` struct for `Renderer` struct in ui/renderer package // Or even better API like varargs lambda function opts // which can be WithFooter(), WithXYZ() // Lots of improvements are waiting on it // - Allow Sending thumbnailGeneratorNeeded as false to preview.New() // to prevent noise in test logs. Same with imagePreviewer func defaultModelConfig(toggleDotFile, toggleFooter, firstUse bool, firstPanelPaths []string, zClient *zoxidelib.Client) *model { return &model{ focusPanel: nonePanelFocus, processBarModel: processbar.New(), sidebarModel: sidebar.New(), fileMetaData: metadata.New(), fileModel: filemodel.New(firstPanelPaths, toggleDotFile), helpMenu: helpmenu.New(), promptModal: prompt.DefaultModel(prompt.PromptMinHeight, prompt.PromptMinWidth), zoxideModal: zoxideui.DefaultModel(zoxideui.ZoxideMinHeight, zoxideui.ZoxideMinWidth, zClient), sortModal: sortmodel.New(), zClient: zClient, modelQuitState: notQuitting, toggleFooter: toggleFooter, firstUse: firstUse, hasTrash: common.InitTrash(), } } ================================================ FILE: src/internal/file_operation_compress_test.go ================================================ package internal import ( "archive/zip" "io" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/ui/processbar" ) func TestZipSources(t *testing.T) { processBar := processbar.New() processBar.ListenForChannelUpdates() t.Cleanup(processBar.SendStopListeningMsgBlocking) tests := []struct { name string setupFunc func(t *testing.T, tempDir string) ([]string, error) expectedFiles map[string]string expectError bool }{ { name: "multiple directories with subdirectories", setupFunc: func(t *testing.T, tempDir string) ([]string, error) { testDir1 := filepath.Join(tempDir, "testdir1") testDir2 := filepath.Join(tempDir, "testdir2") subDir := filepath.Join(testDir1, "subdir") utils.SetupDirectories(t, testDir1, testDir2, subDir) utils.SetupFilesWithData(t, []byte("Content of file1"), filepath.Join(testDir1, "file1.txt")) utils.SetupFilesWithData(t, []byte("Content of file2"), filepath.Join(subDir, "file2.txt")) utils.SetupFilesWithData(t, []byte("Content of file3"), filepath.Join(testDir2, "file3.txt")) return []string{testDir1, testDir2}, nil }, // End for directory is always "/" regardless of windows and linux for zipReader library expectedFiles: map[string]string{ "testdir1/": "", filepath.Join("testdir1", "file1.txt"): "Content of file1", filepath.Join("testdir1", "subdir") + "/": "", filepath.Join("testdir1", "subdir", "file2.txt"): "Content of file2", "testdir2/": "", filepath.Join("testdir2", "file3.txt"): "Content of file3", }, expectError: false, }, { name: "single file", setupFunc: func(t *testing.T, tempDir string) ([]string, error) { testFile := filepath.Join(tempDir, "single.txt") utils.SetupFilesWithData(t, []byte("Single file content"), testFile) return []string{testFile}, nil }, expectedFiles: map[string]string{ "single.txt": "Single file content", }, expectError: false, }, { name: "empty list", setupFunc: func(_ *testing.T, _ string) ([]string, error) { return []string{}, nil }, expectedFiles: map[string]string{}, expectError: false, }, { name: "non-existent source", setupFunc: func(_ *testing.T, _ string) ([]string, error) { return []string{"/non/existent/path"}, nil }, expectedFiles: nil, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tempDir := t.TempDir() sources, err := tt.setupFunc(t, tempDir) if err != nil { t.Fatalf("Setup failed: %v", err) } targetZip := filepath.Join(tempDir, "test.zip") err = zipSources(sources, targetZip, &processBar) if tt.expectError { require.Error(t, err, "zipSources should return error") return } require.NoError(t, err, "zipSources should not return error") zipReader, err := zip.OpenReader(targetZip) require.NoError(t, err, "should be able to open ZIP file") defer zipReader.Close() validateZipExtraction(t, zipReader, tt.expectedFiles) }) } } func validateZipExtraction(t *testing.T, zipReader *zip.ReadCloser, expectedFiles map[string]string) { require.Len(t, zipReader.File, len(expectedFiles), "ZIP should contain expected number of files") foundFiles := make(map[string]string) for _, file := range zipReader.File { foundFiles[file.Name] = "" if !strings.HasSuffix(file.Name, "/") { rc, err := file.Open() require.NoError(t, err, "should be able to open file %s in ZIP", file.Name) content, err := io.ReadAll(rc) rc.Close() require.NoError(t, err, "should be able to read file %s", file.Name) foundFiles[file.Name] = string(content) } } for expectedFile, expectedContent := range expectedFiles { foundContent, exists := foundFiles[expectedFile] require.True(t, exists, "expected file %s should be found in ZIP", expectedFile) if expectedContent != "" { require.Equal(t, expectedContent, foundContent, "content should match for file %s", expectedFile) } } for foundFile := range foundFiles { _, expected := expectedFiles[foundFile] require.True(t, expected, "unexpected file %s found in ZIP", foundFile) } } func TestZipSourcesInvalidTarget(t *testing.T) { processBar := processbar.New() processBar.ListenForChannelUpdates() t.Cleanup(processBar.SendStopListeningMsgBlocking) tempDir := t.TempDir() testFile := filepath.Join(tempDir, "test.txt") err := os.WriteFile(testFile, []byte("test"), 0644) require.NoError(t, err, "should be able to create test file") invalidTarget := "/invalid/path/test.zip" err = zipSources([]string{testFile}, invalidTarget, &processBar) require.Error(t, err, "zipSources should return error for invalid target") } ================================================ FILE: src/internal/file_operations.go ================================================ package internal import ( "fmt" "io" "log/slog" "os" "path/filepath" "runtime" "strings" "github.com/yorukot/superfile/src/internal/ui/processbar" "github.com/yorukot/superfile/src/pkg/utils" trash_win "github.com/hymkor/trash-go" "github.com/rkoesters/xdg/trash" variable "github.com/yorukot/superfile/src/config" ) // isSamePartition checks if two paths are on the same filesystem partition func isSamePartition(path1, path2 string) (bool, error) { // Get the absolute path to handle relative paths absPath1, err := filepath.Abs(path1) if err != nil { return false, fmt.Errorf("failed to get absolute path of the first path: %w", err) } absPath2, err := filepath.Abs(path2) if err != nil { return false, fmt.Errorf("failed to get absolute path of the second path: %w", err) } if runtime.GOOS == utils.OsWindows { // On Windows, we can check if both paths are on the same drive (same letter) drive1 := getDriveLetter(absPath1) drive2 := getDriveLetter(absPath2) return drive1 == drive2, nil } // For Unix-like systems, we use the same path to check the root partition return filepath.VolumeName(absPath1) == filepath.VolumeName(absPath2), nil } // getDriveLetter extracts the drive letter from a Windows path func getDriveLetter(path string) string { // Windows paths are usually like "C:\path\to\file" // So we need to extract the drive letter (e.g., "C") return strings.ToUpper(string(path[0])) } // moveElement moves a file or directory efficiently func moveElement(src, dst string) error { // Check if source and destination are on the same partition sameDev, err := isSamePartition(src, dst) if err != nil { return fmt.Errorf("failed to check partitions: %w", err) } // If on the same partition, attempt to rename (which will use the same inode) if sameDev { if err = os.Rename(src, dst); err == nil { return nil } // If rename fails, fall back to copy+delete } // If on different partitions or rename failed, fall back to copy+delete err = copyElement(src, dst) if err != nil { return fmt.Errorf("failed to copy: %w", err) } err = os.RemoveAll(src) if err != nil { return fmt.Errorf("failed to remove source after copy: %w", err) } return nil } // copyElement handles copying of both files and directories func copyElement(src, dst string) error { srcInfo, err := os.Stat(src) if err != nil { return fmt.Errorf("failed to stat source: %w", err) } if srcInfo.IsDir() { return copyDir(src, dst, srcInfo) } return copyFile(src, dst, srcInfo) } // copyDir recursively copies a directory func copyDir(src, dst string, srcInfo os.FileInfo) error { err := os.MkdirAll(dst, srcInfo.Mode()) if err != nil { return fmt.Errorf("failed to create destination directory: %w", err) } entries, err := os.ReadDir(src) if err != nil { return fmt.Errorf("failed to read source directory: %w", err) } for _, entry := range entries { srcPath := filepath.Join(src, entry.Name()) dstPath := filepath.Join(dst, entry.Name()) entryInfo, err := entry.Info() if err != nil { return fmt.Errorf("failed to get entry info: %w", err) } if entryInfo.IsDir() { err = copyDir(srcPath, dstPath, entryInfo) } else { err = copyFile(srcPath, dstPath, entryInfo) } if err != nil { return err } } return nil } // copyFile copies a single file func copyFile(src, dst string, srcInfo os.FileInfo) error { srcFile, err := os.Open(src) if err != nil { return fmt.Errorf("failed to open source file: %w", err) } defer srcFile.Close() dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode()) if err != nil { return fmt.Errorf("failed to create destination file: %w", err) } defer dstFile.Close() if _, err := io.Copy(dstFile, srcFile); err != nil { return fmt.Errorf("failed to copy file contents: %w", err) } return nil } func moveToTrash(src string) error { var err error switch runtime.GOOS { case utils.OsDarwin: err = moveElement(src, filepath.Join(variable.DarwinTrashDirectory, filepath.Base(src))) case utils.OsWindows: err = trash_win.Throw(src) default: // TODO: We should consider moving away from this package. Its not well written. // It uses package globals, It doesn't initializes trash directory, and we have to do it // separately outside of the this package. There is not documentation about this // It also uses deprecated libraries, and isn't well maintained. err = trash.Trash(src) } if err != nil { slog.Error("Error while deleting single item, in function to move file to trash can", "error", err) } return err } // pasteDir handles directory copying with progress tracking func pasteDir(src, dst string, p *processbar.Process, cut bool, processBarModel *processbar.Model) error { dst, err := renameIfDuplicate(dst) if err != nil { return err } // Check if we can do a fast move within the same partition sameDev, err := isSamePartition(src, dst) if err == nil && sameDev && cut { // For cut operations on same partition, try fast rename first err = os.Rename(src, dst) if err == nil { return nil } // If rename fails, fall back to manual copy } err = filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } relPath, err := filepath.Rel(src, path) if err != nil { return err } newPath := filepath.Join(dst, relPath) return actualPasteOperation(info, path, newPath, cut, sameDev, p, processBarModel) }) if err != nil { return err } // If this was a cut operation and we had to do a manual copy, remove the source if cut && !sameDev { err = os.RemoveAll(src) if err != nil { return fmt.Errorf("failed to remove source after move: %w", err) } } return nil } func actualPasteOperation(info os.FileInfo, path string, newPath string, cut bool, sameDev bool, p *processbar.Process, processBarModel *processbar.Model) error { var err error if info.IsDir() { // TODO - this is likely not needed because we did // dst, err := renameIfDuplicate(dst) above newPath, err = renameIfDuplicate(newPath) if err != nil { return err } err = os.MkdirAll(newPath, info.Mode()) return err } // File p.CurrentFile = filepath.Base(path) if cut && sameDev { err = os.Rename(path, newPath) } else { err = copyFile(path, newPath, info) } if err != nil { p.State = processbar.Failed pSendErr := processBarModel.SendUpdateProcessMsg(*p, true) if pSendErr != nil { slog.Error("Error sending process update", "error", pSendErr) } return err } p.Done++ processBarModel.TrySendingUpdateProcessMsg(*p) return nil } // isAncestor checks if dst is the same as src or a subdirectory of src. // It handles symlinks by resolving them and applies case-insensitive comparison on Windows. func isAncestor(src, dst string) bool { // Resolve symlinks for both paths srcResolved, err := filepath.EvalSymlinks(src) if err != nil { // If we can't resolve symlinks, fall back to original path srcResolved = src } dstResolved, err := filepath.EvalSymlinks(dst) if err != nil { // If we can't resolve symlinks, fall back to original path dstResolved = dst } // Get absolute paths. Abs() also Cleans paths to normalize separators and resolve . and .. srcAbs, err := filepath.Abs(srcResolved) if err != nil { return false } dstAbs, err := filepath.Abs(dstResolved) if err != nil { return false } // On Windows, perform case-insensitive comparison if runtime.GOOS == "windows" { srcAbs = strings.ToLower(srcAbs) dstAbs = strings.ToLower(dstAbs) } // Check if dst is the same as src if srcAbs == dstAbs { return true } // Check if dst is a subdirectory of src // Use filepath.Rel to check the relationship rel, err := filepath.Rel(srcAbs, dstAbs) if err != nil { return false } // If rel is "." or doesn't start with "..", then dst is inside src return rel == "." || !strings.HasPrefix(rel, "..") } ================================================ FILE: src/internal/file_operations_compress.go ================================================ package internal import ( "archive/zip" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "strings" "time" "github.com/yorukot/superfile/src/internal/ui/processbar" ) func zipSources(sources []string, target string, processBar *processbar.Model) error { var err error totalFiles := 0 for _, src := range sources { if _, err = os.Stat(src); os.IsNotExist(err) { return fmt.Errorf("source path does not exist: %s", src) } count, e := countFiles(src) if e != nil { slog.Error("Error while zip file count files ", "error", e) } totalFiles += count } p, err := processBar.SendAddProcessMsg(filepath.Base(target), processbar.OpCompress, totalFiles, true) if err != nil { return fmt.Errorf("cannot spawn process : %w", err) } _, err = os.Stat(target) if err == nil { p.ErrorMsg = "File already exists" p.State = processbar.Cancelled p.DoneTime = time.Now() pSendErr := processBar.SendUpdateProcessMsg(p, true) if pSendErr != nil { slog.Error("Error sending process update", "error", pSendErr) } return errors.New("file already exists") } f, err := os.Create(target) if err != nil { return err } defer f.Close() writer := zip.NewWriter(f) defer writer.Close() zipSourcesCore(sources, processBar, &p, writer) if p.State != processbar.Failed { // TODO: User p.SetSuccessful(), p.SetFailed() p.State = processbar.Successful p.Done = totalFiles } p.DoneTime = time.Now() pSendErr := processBar.SendUpdateProcessMsg(p, true) if pSendErr != nil { slog.Error("Error sending process update", "error", pSendErr) } return nil } func zipSourcesCore(sources []string, processBar *processbar.Model, p *processbar.Process, writer *zip.Writer) { for _, src := range sources { srcParentDir := filepath.Dir(src) err := filepath.Walk(src, func(path string, info os.FileInfo, err error) error { p.CurrentFile = filepath.Base(path) if err != nil { return err } relPath, err := filepath.Rel(srcParentDir, path) if err != nil { return err } err = writeZipFile(path, relPath, info, writer) if err != nil { return err } p.Done++ processBar.TrySendingUpdateProcessMsg(*p) return nil }) if err != nil { slog.Error("Error while zip file", "error", err) p.State = processbar.Failed break } } } func writeZipFile(path string, relPath string, info os.FileInfo, writer *zip.Writer) error { header, err := zip.FileInfoHeader(info) if err != nil { return err } header.Method = zip.Deflate header.Name = relPath if info.IsDir() { header.Name += "/" } headerWriter, err := writer.CreateHeader(header) if err != nil { return err } if info.IsDir() { return nil } file, err := os.Open(path) if err != nil { return err } defer file.Close() _, err = io.Copy(headerWriter, file) if err != nil { return err } return nil } func getZipArchiveName(base string) (string, error) { zipName := strings.TrimSuffix(base, filepath.Ext(base)) + ".zip" zipName, err := renameIfDuplicate(zipName) return zipName, err } ================================================ FILE: src/internal/file_operations_extract.go ================================================ package internal import ( "fmt" "log/slog" "path/filepath" "time" "golift.io/xtractr" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/ui/processbar" ) func extractCompressFile(src, dest string, processBar *processbar.Model) error { p, err := processBar.SendAddProcessMsg(filepath.Base(src), processbar.OpExtract, 1, true) if err != nil { return fmt.Errorf("cannot spawn process : %w", err) } x := &xtractr.XFile{ FilePath: src, OutputDir: dest, FileMode: utils.ExtractedFileMode, DirMode: utils.ExtractedDirMode, } _, _, _, err = xtractr.ExtractFile(x) if err != nil { p.State = processbar.Failed slog.Error("Error extracting", "path", src, "error", err) } else { p.State = processbar.Successful p.Done = 1 } p.DoneTime = time.Now() pSendErr := processBar.SendUpdateProcessMsg(p, true) if pSendErr != nil { slog.Error("Error sending process update", "error", pSendErr) } return err } ================================================ FILE: src/internal/function.go ================================================ package internal import ( "errors" "fmt" "os" "path/filepath" "regexp" "runtime" "strconv" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/ui/processbar" ) var suffixRegexp = regexp.MustCompile(`^(.*)\((\d+)\)$`) // Check if the directory is external disk path // TODO : This function should be give two directories, and it should return // if the two share a different disk partition. // Ideally we shouldn't even try to figure that out in our file operations, and let OS handles it. // But at least right now its not okay. This returns if `path` is an External disk // from perspective of `/`, but it should tell from perspective of currently open directory // The usage of this function in cut/paste is not as expected. func isExternalDiskPath(path string) bool { // This is very vague. You cannot tell if a path is belonging to an external partition // if you dont define the source path to compare with // But making this true will cause slow file operations based on current implementation if runtime.GOOS == utils.OsWindows { return false } // exclude timemachine on macOS if strings.HasPrefix(path, "/Volumes/.timemachine") { return false } // to filter out mounted partitions like /, /boot etc return strings.HasPrefix(path, "/mnt") || strings.HasPrefix(path, "/media") || strings.HasPrefix(path, "/run/media") || strings.HasPrefix(path, "/Volumes") } func checkFileNameValidity(name string) error { switch { case name == ".", name == "..": return errors.New("file name cannot be '.' or '..'") case strings.HasSuffix(name, fmt.Sprintf("%c.", filepath.Separator)), strings.HasSuffix(name, fmt.Sprintf("%c..", filepath.Separator)): return fmt.Errorf("file name cannot end with '%c.' or '%c..'", filepath.Separator, filepath.Separator) default: return nil } } func renameIfDuplicate(destination string) (string, error) { if _, err := os.Stat(destination); os.IsNotExist(err) { return destination, nil } else if err != nil { return "", err } dir := filepath.Dir(destination) base := filepath.Base(destination) ext := filepath.Ext(base) name := base[:len(base)-len(ext)] // Extract base name without existing suffix counter := 1 //nolint:mnd // 3 = full match + 2 capture groups if match := suffixRegexp.FindStringSubmatch(name); len(match) == 3 { name = match[1] // base name without (N) if num, err := strconv.Atoi(match[2]); err == nil { counter = num + 1 // start from next number } } // Find first available name for i := counter; i < 10_000; i++ { newName := fmt.Sprintf("%s(%d)%s", name, i, ext) newPath := filepath.Join(dir, newName) if _, err := os.Stat(newPath); os.IsNotExist(err) { return newPath, nil } } return "", fmt.Errorf("could not find free name for %s after many attempts", destination) } // Count how many file in the directory func countFiles(dirPath string) (int, error) { count := 0 err := filepath.Walk(dirPath, func(_ string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() { count++ } return nil }) return count, err } func processCmdToTeaCmd(cmd processbar.Cmd) tea.Cmd { if cmd == nil { // To prevent us from running cmd() on nil cmd return nil } return func() tea.Msg { updateMsg := cmd() return ProcessBarUpdateMsg{ pMsg: updateMsg, BaseMessage: BaseMessage{ reqID: updateMsg.GetReqID(), }, } } } func getCopyOrCutOperationName(cut bool) string { if cut { return "cut" } return "copy" } ================================================ FILE: src/internal/function_test.go ================================================ package internal import ( "fmt" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" ) func TestCheckFileNameValidity(t *testing.T) { tests := []struct { name string input string wantErr bool errMsg string }{ {name: "Invalid - single dot", input: ".", wantErr: true, errMsg: "file name cannot be '.' or '..'", }, { name: "invalid - double dot", input: "..", wantErr: true, errMsg: "file name cannot be '.' or '..'", }, { name: "invalid - ends with /.. (platform separator)", input: fmt.Sprintf("testDir%c..", filepath.Separator), wantErr: true, errMsg: fmt.Sprintf("file name cannot end with '%c.' or '%c..'", filepath.Separator, filepath.Separator), }, { name: "invalid - ends with /. (platform separator)", input: fmt.Sprintf("testDir%c.", filepath.Separator), wantErr: true, errMsg: fmt.Sprintf("file name cannot end with '%c.' or '%c..'", filepath.Separator, filepath.Separator), }, { name: "valid - normal file name", input: "valid_file.txt", wantErr: false, }, { name: "valid - contains dot inside", input: "some.folder.name/file.txt", wantErr: false, }, { name: "valid - ends with dot not after separator", input: "somefile.", wantErr: false, }, { name: "valid - ends with .. not after separator", input: "somefile..", wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := checkFileNameValidity(tt.input) if !tt.wantErr { require.NoError(t, err) } else { require.Error(t, err) assert.Contains(t, err.Error(), tt.errMsg) } }) } } func Test_renameIfDuplicate(t *testing.T) { curTestDir := t.TempDir() f1NonExistent := filepath.Join(curTestDir, "file.txt") f2 := filepath.Join(curTestDir, "file2.txt") f3 := filepath.Join(curTestDir, "file3(3).txt") d1 := filepath.Join(curTestDir, "dir1") utils.SetupFiles(t, f2, f3) utils.SetupDirectories(t, d1) tests := []struct { name string fileName string want string }{ { name: "file does not exist", fileName: f1NonExistent, want: filepath.Base(f1NonExistent), }, { name: "file exists without suffix", fileName: f2, want: "file2(1).txt", }, { name: "file exists with suffix", fileName: f3, want: "file3(4).txt", }, { name: "directory exists", fileName: d1, want: "dir1(1)", // without extension }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { results, err := renameIfDuplicate(tt.fileName) require.NoError(t, err) assert.Equal(t, filepath.Base(tt.want), filepath.Base(results)) }) } } func Benchmark_renameIfDuplicate(b *testing.B) { dir := b.TempDir() existingFile := filepath.Join(dir, "file.txt") err := os.WriteFile(existingFile, utils.SampleDataBytes, 0644) require.NoError(b, err) existingDir := filepath.Join(dir, "docs") err = os.Mkdir(existingDir, 0o755) require.NoError(b, err) b.Run("file_exists", func(b *testing.B) { for range b.N { _, err := renameIfDuplicate(existingFile) if err != nil { b.Fatal(err) } } }) b.Run("dir_exists", func(b *testing.B) { for range b.N { _, err := renameIfDuplicate(existingDir) if err != nil { b.Fatal(err) } } }) b.Run("file_not_exists", func(b *testing.B) { nonExistent := filepath.Join(dir, "nofile.txt") for range b.N { _, err := renameIfDuplicate(nonExistent) if err != nil { b.Fatal(err) } } }) } ================================================ FILE: src/internal/handle_file_operation_test.go ================================================ package internal import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/ui/filepanel" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui/processbar" ) func TestCompressSelectedFiles(t *testing.T) { curTestDir := t.TempDir() dir1 := filepath.Join(curTestDir, "dir1") dir2 := filepath.Join(curTestDir, "dir2") file1 := filepath.Join(curTestDir, "file1.txt") file2 := filepath.Join(dir1, "file2.txt") utils.SetupDirectories(t, curTestDir, dir1, dir2) utils.SetupFiles(t, file1, file2) // Note this is to validate the end to end user interface, not to extensively validate // that compress works as expected. For that, we have TestZipSources // Need to test that // 1 - Compress single file (Browser Mode) // 2 - Compress Single directory with files (Browser Mode) // 3 - Compress single file where cursor is pointed when nothing is selected (Select Mode) // 4 - Compress single selected file in Select Mode where cursor points to different file // 5 - Compress multiple selected files and directories // 6 - Pressing compress hotkey on empty panel doesn't do anything or crashes on both browser/select mode // Copied from CopyTest. TODO - work on it. testdata := []struct { name string startDir string cursor int selectMode bool selectedElem []string expectedZipName string extractedDirName string // Relative to extractedDir expectedFilesAfterExtract []string }{ { name: "Single File Compress", startDir: curTestDir, cursor: 2, selectMode: false, selectedElem: nil, expectedZipName: "file1.zip", extractedDirName: "file1", expectedFilesAfterExtract: []string{"file1.txt"}, }, { name: "Single Directory Compress", startDir: curTestDir, cursor: 0, selectMode: false, selectedElem: nil, expectedZipName: "dir1.zip", extractedDirName: "dir1(1)", expectedFilesAfterExtract: []string{filepath.Join("dir1", "file2.txt")}, }, { name: "Single File Compress with select mode without selection", startDir: curTestDir, cursor: 2, selectMode: true, selectedElem: []string{}, expectedZipName: "file1.zip", extractedDirName: "file1", expectedFilesAfterExtract: []string{"file1.txt"}, }, { name: "Single File Compress with select mode with different cursor and selection", startDir: curTestDir, cursor: 0, // points to dir1 selectMode: true, selectedElem: []string{file1}, expectedZipName: "file1.zip", extractedDirName: "file1", expectedFilesAfterExtract: []string{"file1.txt"}, }, { name: "Multi file compression", startDir: curTestDir, cursor: 0, // points to dir1 selectMode: true, selectedElem: []string{dir2, dir1, file1}, expectedZipName: "dir2.zip", extractedDirName: "dir2(1)", expectedFilesAfterExtract: []string{"dir2", filepath.Join("dir1", "file2.txt"), "file1.txt"}, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { m := defaultTestModel(tt.startDir) p := NewTestTeaProgWithEventLoop(t, m) require.Greater(t, m.getFocusedFilePanel().ElemCount(), tt.cursor) // Update cursor for range tt.cursor { m.getFocusedFilePanel().ListDown() } require.Equal(t, filepanel.BrowserMode, m.getFocusedFilePanel().PanelMode) if tt.selectMode { m.getFocusedFilePanel().ChangeFilePanelMode() m.getFocusedFilePanel().SetSelectedAll(tt.selectedElem) } p.SendKey(common.Hotkeys.CompressFile[0]) // This is a bit of an indirect validation, but there aren't many ways. // We many add a process type later, and ensure that a process of // type compress was done ensureOneProcessDone(t, m) zipFile := filepath.Join(tt.startDir, tt.expectedZipName) require.FileExists(t, zipFile, "Expected zip file does not exist after compression") setFilePanelSelectedItemByLocation(t, m.getFocusedFilePanel(), zipFile) selectedItemLocation := m.getFocusedFilePanel().GetFocusedItem().Location assert.Equal(t, zipFile, selectedItemLocation) // Ensure we are extracting the zip file, not a directory fileInfo, err := os.Stat(selectedItemLocation) require.NoError(t, err, "Failed to stat panel location before extraction") require.False(t, fileInfo.IsDir(), "Panel location for extraction is a directory, expected a zip file: %s", selectedItemLocation) p.SendKey(common.Hotkeys.ExtractFile[0]) // File extraction is supposedly async. So function's return doesn't means its done. extractedDir := filepath.Join(tt.startDir, tt.extractedDirName) // Setup cleanup to run even if test fails t.Cleanup(func() { cleanupWithRetry(t, extractedDir, "extracted directory") cleanupWithRetry(t, zipFile, "zip file") }) assert.Eventually(t, func() bool { for _, f := range tt.expectedFilesAfterExtract { _, err := os.Stat(filepath.Join(extractedDir, f)) if err != nil { return false } } return true }, DefaultTestTimeout, DefaultTestTick, "Extraction of files failed Required - [%s]+%v", extractedDir, tt.expectedFilesAfterExtract) }) } t.Run("Compress on Empty panel", func(t *testing.T) { NewTestTeaProgWithEventLoop(t, defaultTestModel(dir2)). SendKey(common.Hotkeys.CompressFile[0]) // Should not crash. Nothing should happen. If there is a crash, it will be caught entries, err := os.ReadDir(dir2) require.NoError(t, err) assert.Empty(t, entries) }) } func TestPasteItem(t *testing.T) { curTestDir := t.TempDir() sourceDir := filepath.Join(curTestDir, "source") destDir := filepath.Join(curTestDir, "dest") subDir := filepath.Join(sourceDir, "subdir") file1 := filepath.Join(sourceDir, "file1.txt") file2 := filepath.Join(sourceDir, "file2.txt") dirFile1 := filepath.Join(subDir, "dirfile1.txt") utils.SetupDirectories(t, curTestDir, sourceDir, destDir, subDir) utils.SetupFiles(t, file1, file2, dirFile1) testdata := []struct { name string startDir string targetDir string itemName string isCut bool selectMode bool selectedItems []string shouldClipboardClear bool shouldOriginalExist bool expectedDestFiles []string shouldPreventPaste bool description string }{ { name: "Copy Single File", startDir: sourceDir, targetDir: destDir, itemName: "file1.txt", isCut: false, selectMode: false, selectedItems: nil, shouldClipboardClear: false, shouldOriginalExist: true, expectedDestFiles: []string{"file1.txt"}, shouldPreventPaste: false, description: "Copy a single file from source to destination", }, { name: "Cut Single File", startDir: sourceDir, targetDir: destDir, itemName: "file2.txt", isCut: true, selectMode: false, selectedItems: nil, shouldClipboardClear: true, shouldOriginalExist: false, expectedDestFiles: []string{"file2.txt"}, shouldPreventPaste: false, description: "Cut a single file from source to destination", }, { name: "Cut Directory into Same Location", startDir: sourceDir, targetDir: sourceDir, // Same directory itemName: "subdir", isCut: true, selectMode: false, selectedItems: nil, shouldClipboardClear: false, // Should not clear because paste should be prevented shouldOriginalExist: true, // Should still exist because paste prevented expectedDestFiles: []string{}, // No files should be created in dest shouldPreventPaste: true, description: "Cutting directory into same location should be prevented", }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { m := setupModelAndPerformOperation(t, tt.startDir, tt.selectMode, tt.itemName, tt.selectedItems, tt.isCut) p := NewTestTeaProgWithEventLoop(t, m) // Navigate to target directory navigateToTargetDir(t, m, tt.startDir, tt.targetDir) // Get original file path for existence check originalPath := getOriginalPath(tt.selectMode, tt.itemName, tt.startDir) // Perform paste operation p.SendKey(common.Hotkeys.PasteItems[0]) // Verify results based on whether paste should be prevented if tt.shouldPreventPaste { verifyPreventedPasteResults(t, m, originalPath) } else { verifySuccessfulPasteResults( t, tt.targetDir, tt.expectedDestFiles, originalPath, tt.shouldOriginalExist, ) } // Checking separately, as this is something independent of tt.shouldPreventPaste if tt.shouldClipboardClear { // Wait for the Paste Model message to reach to Model via bubble tea event loop, that will clear clipboard // TODO: There are two eventually here in this test. A bit inefficient. // We have different async activities, create of files, completion of process message reaching processbar, model msg reaching model. Ideally we would like to have one final check, that is process completion message reaching model, and then check all conditions serially one be one. // For that there, might be need to implement ioReqDone count in model message, or a count per process type, or even an full fledged async manager. assert.Eventually(t, func() bool { return len(p.m.clipboard.GetItems()) == 0 }, DefaultTestTimeout, DefaultTestTick, "Clipboard should be cleared after successful cut-paste") } else { assert.NotEmpty(t, p.m.clipboard.GetItems(), "Clipboard should remain after copy-paste") } }) } // Special test cases that don't fit the table-driven pattern t.Run("Paste with Empty Clipboard", func(t *testing.T) { emptyTestDir := filepath.Join(curTestDir, "empty_test") utils.SetupDirectories(t, emptyTestDir) m := defaultTestModel(emptyTestDir) p := NewTestTeaProgWithEventLoop(t, m) // Ensure clipboard is empty m.clipboard.Reset(false) // Get initial count entriesBefore, err := os.ReadDir(emptyTestDir) require.NoError(t, err) // Attempt to paste (should do nothing) p.SendKey(common.Hotkeys.PasteItems[0]) // Should not crash and no new files should be created entriesAfter, err := os.ReadDir(emptyTestDir) require.NoError(t, err) assert.Len(t, entriesAfter, len(entriesBefore), "No new files should be created when pasting with empty clipboard") }) t.Run("Multiple Items Copy and Paste", func(t *testing.T) { // Create fresh files for this test multiFile1 := filepath.Join(sourceDir, "multi1.txt") multiFile2 := filepath.Join(sourceDir, "multi2.txt") utils.SetupFiles(t, multiFile1, multiFile2) selectedItems := []string{multiFile1, multiFile2} m := setupModelAndPerformOperation(t, sourceDir, true, "", selectedItems, false) p := NewTestTeaProgWithEventLoop(t, m) // Navigate to destination navigateToTargetDir(t, m, sourceDir, destDir) // Paste items p.SendKey(common.Hotkeys.PasteItems[0]) // Verify both files were copied expectedDestFiles := []string{"multi1.txt", "multi2.txt"} verifyDestinationFiles(t, destDir, expectedDestFiles) }) t.Run("Cut into Subdirectory Prevention", func(t *testing.T) { // Create a separate subdirectory for this test to avoid conflicts with table-driven tests testSubDir := filepath.Join(sourceDir, "testsubdir") testDirFile := filepath.Join(testSubDir, "testdirfile.txt") utils.SetupDirectories(t, testSubDir) utils.SetupFiles(t, testDirFile) // Test the logic that prevents cutting a directory into its subdirectory m := setupModelAndPerformOperation(t, sourceDir, false, "testsubdir", nil, true) p := NewTestTeaProgWithEventLoop(t, m) // Navigate into the subdirectory and try to paste there (should be prevented) navigateToTargetDir(t, m, sourceDir, testSubDir) p.SendKey(common.Hotkeys.PasteItems[0]) // Directory should still exist in original location after prevention assert.DirExists(t, testSubDir, "Directory should still exist after failed paste into subdirectory") }) t.Run("Duplicate File Handling", func(t *testing.T) { // Create a file to copy dupFile := filepath.Join(sourceDir, "duplicate.txt") utils.SetupFiles(t, dupFile) m := setupModelAndPerformOperation(t, sourceDir, false, "duplicate.txt", nil, false) p := NewTestTeaProgWithEventLoop(t, m) // Navigate to destination and paste navigateToTargetDir(t, m, sourceDir, destDir) p.SendKey(common.Hotkeys.PasteItems[0]) // Verify first copy verifyDestinationFiles(t, destDir, []string{"duplicate.txt"}) // Paste again to test duplicate handling p.SendKey(common.Hotkeys.PasteItems[0]) // Verify duplicate file with different name verifyDestinationFiles(t, destDir, []string{"duplicate(1).txt"}) }) } // ------ Very specific utilities that are required for this test case file only // Helper function to setup model and perform copy/cut operation func setupModelAndPerformOperation(t *testing.T, startDir string, useSelectMode bool, itemName string, selectedItems []string, isCut bool) *model { t.Helper() m := defaultTestModel(startDir) TeaUpdate(m, nil) setupPanelModeAndSelection(t, m, useSelectMode, itemName, selectedItems) performCopyOrCutOperation(t, m, isCut) selectedItemsCount := len(selectedItems) if !useSelectMode { selectedItemsCount = 1 } verifyClipboardState(t, m, isCut, useSelectMode, selectedItemsCount) return m } func ensureOneProcessDone(t *testing.T, m *model) { // Don't attempt to print // m.processBarModel.GetProcessesSlice() in the failure message // This will print values calculated at the beginning of the call require.Eventually(t, func() bool { processes := m.processBarModel.GetProcessesSlice() return len(processes) == 1 && processes[0].State == processbar.Successful }, DefaultTestTimeout, DefaultTestTick, "Compress process not done") } // Duct tape to have less flaky tests in windows func cleanupWithRetry(t *testing.T, path, label string) { var lastErr error ok := assert.Eventually(t, func() bool { lastErr = os.RemoveAll(path) return lastErr == nil }, DefaultTestTimeout, DefaultTestTick) if !ok { t.Fatalf("Failed to remove %s %q: %v", label, path, lastErr) } } ================================================ FILE: src/internal/handle_file_operations.go ================================================ package internal import ( "errors" "fmt" "log/slog" "os" "os/exec" "path/filepath" "runtime" "strings" "time" variable "github.com/yorukot/superfile/src/config" "github.com/yorukot/superfile/src/internal/ui/filepanel" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui/notify" "github.com/yorukot/superfile/src/internal/ui/processbar" "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" ) // Create a file in the currently focus file panel // TODO: Fix it. It doesn't creates a new file. It just opens a file model, // that allows you to create a file. Actual creation happens here - createItem() in handle_modal.go func (m *model) panelCreateNewFile() { panel := m.getFocusedFilePanel() m.typingModal.location = panel.Location m.typingModal.open = true m.typingModal.textInput = common.GenerateNewFileTextInput() m.firstTextInput = true } // TODO : This function does not needs the entire model. Only pass the panel object func (m *model) IsRenamingConflicting() bool { // TODO : Replace this with m.getCurrentFilePanel() everywhere panel := m.getFocusedFilePanel() if panel.ElemCount() == 0 { slog.Error("IsRenamingConflicting() being called on empty panel") return false } oldPath := panel.GetFocusedItem().Location newPath := filepath.Join(panel.Location, panel.Rename.Value()) if oldPath == newPath { return false } _, err := os.Stat(newPath) return err == nil } // TODO: Remove channel messaging and use tea.Cmd func (m *model) warnModalForRenaming() tea.Cmd { reqID := m.ioReqCnt m.ioReqCnt++ slog.Debug("Submitting rename notify model request", "reqID", reqID) res := func() tea.Msg { notifyModel := notify.New(true, common.SameRenameWarnTitle, common.SameRenameWarnContent, notify.RenameAction) return NewNotifyModalMsg(notifyModel, reqID) } return res } // Rename file where the cusror is located // TODO: Fix this. It doesn't do any rename, just opens the rename text input // Actual rename happens at confirmRename() in handle_modal.go func (m *model) panelItemRename() { panel := m.getFocusedFilePanel() if panel.Empty() { return } cursorPos := -1 nameRunes := []rune(panel.GetFocusedItem().Name) nameLen := len(nameRunes) for i := nameLen - 1; i >= 0; i-- { if nameRunes[i] == '.' { cursorPos = i break } } if cursorPos == -1 || cursorPos == 0 && nameLen > 0 || panel.GetFocusedItem().Directory { cursorPos = nameLen } m.fileModel.Renaming = true panel.Renaming = true m.firstTextInput = true // TODO: Don't re-create a new model on each rename. Don't create // unnecessary gargage for collection. Reuse the existing model. // Maintain its state, dimensions. Update its cursor and text when needed panel.Rename = common.GenerateRenameTextInput( m.fileModel.SinglePanelWidth-common.InnerPadding, cursorPos, panel.GetFocusedItem().Name) } func (m *model) getDeleteCmd(permDelete bool) tea.Cmd { panel := m.getFocusedFilePanel() if panel.Empty() { return nil } var items []string if panel.PanelMode == filepanel.SelectMode { items = panel.GetSelectedLocations() } else { items = []string{panel.GetFocusedItem().Location} } useTrash := m.hasTrash && !isExternalDiskPath(panel.Location) && !permDelete reqID := m.ioReqCnt m.ioReqCnt++ slog.Debug("Submitting delete request", "id", reqID, "items cnt", len(items)) return func() tea.Msg { state := deleteOperation(&m.processBarModel, items, useTrash) return NewDeleteOperationMsg(state, reqID) } } func deleteOperation(processBarModel *processbar.Model, items []string, useTrash bool) processbar.ProcessState { if len(items) == 0 { return processbar.Cancelled } p, err := processBarModel.SendAddProcessMsg(filepath.Base(items[0]), processbar.OpDelete, len(items), true) if err != nil { slog.Error("Cannot spawn a new process", "error", err) return processbar.Failed } deleteFunc := os.RemoveAll if useTrash { deleteFunc = moveToTrash } for _, item := range items { err = deleteFunc(item) if err != nil { p.State = processbar.Failed slog.Error("Error in delete operation", "item", item, "useTrash", useTrash, "error", err) break } p.CurrentFile = filepath.Base(item) p.Done++ processBarModel.TrySendingUpdateProcessMsg(p) } if p.State != processbar.Failed { p.State = processbar.Successful } p.DoneTime = time.Now() err = processBarModel.SendUpdateProcessMsg(p, true) if err != nil { slog.Error("Failed to send final delete operation update", "error", err) } return p.State } func (m *model) getDeleteTriggerCmd(deletePermanent bool) tea.Cmd { panel := m.getFocusedFilePanel() if (panel.PanelMode == filepanel.SelectMode && panel.SelectedCount() == 0) || (panel.PanelMode == filepanel.BrowserMode && panel.Empty()) { return nil } reqID := m.ioReqCnt m.ioReqCnt++ return func() tea.Msg { title := common.TrashWarnTitle content := common.TrashWarnContent action := notify.DeleteAction if !m.hasTrash || isExternalDiskPath(panel.Location) || deletePermanent { title = common.PermanentDeleteWarnTitle content = common.PermanentDeleteWarnContent action = notify.PermanentDeleteAction } return NewNotifyModalMsg(notify.New(true, title, content, action), reqID) } } // Copy directory or file's path to superfile's clipboard // set cut to true/false accordingly func (m *model) copySingleItem(cut bool) { panel := m.getFocusedFilePanel() m.clipboard.Reset(cut) if panel.Empty() { return } slog.Debug("handle_file_operations.copySingleItem", "cut", cut, "panel location", panel.GetFocusedItem().Location) m.clipboard.Add(panel.GetFocusedItem().Location) } // Copy all selected file or directory's paths to the clipboard func (m *model) copyMultipleItem(cut bool) { panel := m.getFocusedFilePanel() m.clipboard.Reset(cut) if panel.SelectedCount() == 0 { return } slog.Debug("handle_file_operations.copyMultipleItem", "cut", cut, "panel selected files", panel.GetSelectedLocations()) m.clipboard.SetItems(panel.GetSelectedLocations()) } func (m *model) getPasteItemCmd() tea.Cmd { copyItems := m.clipboard.PruneInaccessibleItemsAndGet() cut := m.clipboard.IsCut() if len(copyItems) == 0 { return nil } // TODO: Do it via m.getNewReqID() // TODO: Have an IO Req Management, collecting info about pending IO Req too reqID := m.ioReqCnt m.ioReqCnt++ panelLocation := m.getFocusedFilePanel().Location slog.Debug("Submitting pasteItems request", "id", reqID, "items cnt", len(copyItems), "dest", panelLocation) return func() tea.Msg { err := validatePasteOperation(panelLocation, copyItems, cut) if err != nil { return NewNotifyModalMsg(notify.New(true, "Invalid paste location", err.Error(), notify.NoAction), reqID) } state := executePasteOperation(&m.processBarModel, panelLocation, copyItems, cut) return NewPasteOperationMsg(state, reqID) } } func validatePasteOperation(panelLocation string, copyItems []string, cut bool) error { // Check if trying to paste into source or subdirectory for both cut and copy operations for _, srcPath := range copyItems { // Check if trying to cut and paste into the same directory - this would be a no-op // and could potentially cause issues, so we prevent it if filepath.Dir(srcPath) == panelLocation && cut { return fmt.Errorf("cannot paste into parent directory of source, srcPath : %v, panelLocation : %v", srcPath, panelLocation) } if cut && srcPath == panelLocation { return errors.New("cannot paste a directory into itself") } if isAncestor(srcPath, panelLocation) { return fmt.Errorf("cannot %s and paste a directory into itself or its subdirectory", getCopyOrCutOperationName(cut)) } } return nil } // new func to check and return an error that will go in m.content // create a new error type // Paste all clipboard items func executePasteOperation(processBarModel *processbar.Model, panelLocation string, copyItems []string, cut bool, ) processbar.ProcessState { slog.Debug("executePasteOperation", "items", copyItems, "cut", cut, "panel location", panelLocation) var operation processbar.OperationType if cut { operation = processbar.OpCut } else { operation = processbar.OpCopy } p, err := processBarModel.SendAddProcessMsg( filepath.Base(copyItems[0]), operation, getTotalFilesCnt(copyItems), true) if err != nil { slog.Error("Cannot spawn a new process", "error", err) return processbar.Failed } for _, filePath := range copyItems { errMessage := "cut item error" if cut && !isExternalDiskPath(filePath) { err = moveElement(filePath, filepath.Join(panelLocation, filepath.Base(filePath))) } else { // TODO : These error cases are hard to test. We have to somehow make the paste operations fail, // which is time consuming and manual. We should test these with automated testcases err = pasteDir(filePath, filepath.Join(panelLocation, filepath.Base(filePath)), &p, cut, processBarModel) if err != nil { errMessage = "paste item error" } } p.CurrentFile = filepath.Base(filePath) if err != nil { slog.Debug("model.pasteItem - paste failure", "error", err, "current item", filePath, "errMessage", errMessage) p.State = processbar.Failed slog.Error(errMessage, "error", err) break } processBarModel.TrySendingUpdateProcessMsg(p) } if p.State != processbar.Failed { p.State = processbar.Successful p.Done = p.Total } p.DoneTime = time.Now() err = processBarModel.SendUpdateProcessMsg(p, true) if err != nil { slog.Error("Could not send final update for process Bar", "error", err) } return p.State } func getTotalFilesCnt(copyItems []string) int { totalFiles := 0 for _, folderPath := range copyItems { // TODO : Fix this. This is inefficient // In case of a cut operations for a directory with a lot of files // we are unnecessarily walking the whole directory recursively // while os will just perform a rename // So instead of few operations this will cause the cut paste // to read the whole directory recursively // we should avoid doing this. // Although this allows us a more detailed progress tracking // this make the copy/cut more inefficient // instead, we could just track progress based on total items in // copyItems // efficiency should be prioritized over more detailed feedback. count, err := countFiles(folderPath) if err != nil { slog.Error("Error in countFiles", "error", err) continue } totalFiles += count } return totalFiles } // Extract compressed file // TODO : err should be returned and properly handled by the caller func (m *model) getExtractFileCmd() tea.Cmd { panel := m.getFocusedFilePanel() if panel.Empty() { return nil } item := panel.GetFocusedItem().Location ext := strings.ToLower(filepath.Ext(item)) if !common.IsExtensionExtractable(ext) { slog.Error("Error unexpected file", "extension type", ext, "item", item, "error", errors.ErrUnsupported) return nil } reqID := m.ioReqCnt m.ioReqCnt++ slog.Debug("Submitting Extract file request", "reqID", reqID, "item", item) return func() tea.Msg { outputDir := common.FileNameWithoutExtension(item) outputDir, err := renameIfDuplicate(outputDir) if err != nil { slog.Error("Error while renaming for duplicates", "error", err) return NewExtractOperationMsg(processbar.Failed, reqID) } err = os.MkdirAll( outputDir, utils.ExtractedDirMode, ) if err != nil { slog.Error("Error while making directory for extracting files", "error", err) return NewExtractOperationMsg(processbar.Failed, reqID) } err = extractCompressFile(item, outputDir, &m.processBarModel) if err != nil { slog.Error("Error extract file", "error", err) return NewExtractOperationMsg(processbar.Failed, reqID) } return NewExtractOperationMsg(processbar.Successful, reqID) } } func (m *model) getCompressSelectedFilesCmd() tea.Cmd { panel := m.getFocusedFilePanel() if panel.Empty() { return nil } var filesToCompress []string var firstFile string if panel.SelectedCount() == 0 { firstFile = panel.GetFocusedItem().Location filesToCompress = append(filesToCompress, firstFile) } else { firstFile = panel.GetFirstSelectedLocation() filesToCompress = panel.GetSelectedLocations() } reqID := m.ioReqCnt m.ioReqCnt++ return func() tea.Msg { zipName, err := getZipArchiveName(filepath.Base(firstFile)) if err != nil { slog.Error("Error in getZipArchiveName", "error", err) return NewCompressOperationMsg(processbar.Failed, reqID) } zipPath := filepath.Join(panel.Location, zipName) if err := zipSources(filesToCompress, zipPath, &m.processBarModel); err != nil { slog.Error("Error in zipping files", "error", err) return NewCompressOperationMsg(processbar.Failed, reqID) } return NewCompressOperationMsg(processbar.Successful, reqID) } } func (m *model) chooserFileWriteAndQuit(path string) error { // Attempt to write to the file err := os.WriteFile(variable.ChooserFile, []byte(path), utils.ConfigFilePerm) if err != nil { return err } m.modelQuitState = quitInitiated return nil } // Open file with default editor func (m *model) openFileWithEditor() tea.Cmd { panel := m.getFocusedFilePanel() // Check if panel is empty if panel.Empty() { return nil } if variable.ChooserFile != "" { err := m.chooserFileWriteAndQuit(panel.GetFocusedItem().Location) if err == nil { return nil } // Continue with preview if file is not writable slog.Error("Error while writing to chooser file, continuing with open via file editor", "error", err) } editor := common.Config.Editor if editor == "" { editor = os.Getenv("EDITOR") } // Make sure there is an editor if editor == "" { if runtime.GOOS == utils.OsWindows { editor = "notepad" } else { editor = "nano" } } // Split the editor command into command and arguments parts := strings.Fields(editor) cmd := parts[0] //nolint:gocritic // appendAssign: intentionally creating a new slice args := append(parts[1:], panel.GetFocusedItem().Location) c := exec.Command(cmd, args...) return tea.ExecProcess(c, func(err error) tea.Msg { return editorFinishedMsg{err} }) } // Open directory with default editor func (m *model) openDirectoryWithEditor() tea.Cmd { if variable.ChooserFile != "" { err := m.chooserFileWriteAndQuit(m.getFocusedFilePanel().Location) if err == nil { return nil } // Continue with preview if file is not writable slog.Error("Error while writing to chooser file, continuing with open via directory editor", "error", err) } editor := common.Config.DirEditor if editor == "" { switch runtime.GOOS { case utils.OsWindows: editor = "explorer" case utils.OsDarwin: editor = "open" default: editor = "vi" } } // Split the editor command into command and arguments parts := strings.Fields(editor) cmd := parts[0] //nolint:gocritic // appendAssign: intentionally creating a new slice args := append(parts[1:], m.getFocusedFilePanel().Location) c := exec.Command(cmd, args...) return tea.ExecProcess(c, func(err error) tea.Msg { return editorFinishedMsg{err} }) } // Copy file path // TODO: This is also an IO operations, do it via tea.Cmd func (m *model) copyPath() { panel := m.getFocusedFilePanel() if panel.Empty() { return } if err := clipboard.WriteAll(panel.GetFocusedItem().Location); err != nil { slog.Error("Error while copy path", "error", err) } } // TODO: This is also an IO operations, do it via tea.Cmd func (m *model) copyPWD() { panel := m.getFocusedFilePanel() if err := clipboard.WriteAll(panel.Location); err != nil { slog.Error("Error while copy present working directory", "error", err) } } ================================================ FILE: src/internal/handle_modal.go ================================================ package internal import ( "log/slog" "os" "path/filepath" "strings" "github.com/yorukot/superfile/src/internal/ui/filepanel" "github.com/yorukot/superfile/src/pkg/utils" ) // Cancel typing modal e.g. create file or directory func (m *model) cancelTypingModal() { m.typingModal.textInput.Blur() m.typingModal.open = false } // Confirm to create file or directory func (m *model) createItem() { if err := checkFileNameValidity(m.typingModal.textInput.Value()); err != nil { m.typingModal.errorMesssage = err.Error() slog.Error("Errow while createItem during item creation", "error", err) return } defer func() { m.typingModal.errorMesssage = "" m.typingModal.open = false m.typingModal.textInput.Blur() }() path := filepath.Join(m.typingModal.location, m.typingModal.textInput.Value()) if !strings.HasSuffix(m.typingModal.textInput.Value(), string(filepath.Separator)) { path, _ = renameIfDuplicate(path) if err := os.MkdirAll(filepath.Dir(path), utils.UserDirPerm); err != nil { slog.Error("Error while createItem during directory creation", "error", err) return } f, err := os.Create(path) if err != nil { slog.Error("Error while createItem during file creation", "error", err) return } defer f.Close() } else { err := os.MkdirAll(path, utils.UserDirPerm) if err != nil { slog.Error("Error while createItem during directory creation", "error", err) return } } } // Cancel rename file or directory func (m *model) cancelRename() { panel := m.getFocusedFilePanel() panel.Rename.Blur() panel.Renaming = false m.fileModel.Renaming = false } // Connfirm rename file or directory func (m *model) confirmRename() { panel := m.getFocusedFilePanel() // Although we dont expect this to happen based on our current flow // Just adding it here to be safe if panel.Empty() { slog.Error("confirmRename called on empty panel") return } oldPath := panel.GetFocusedItem().Location newPath := filepath.Join(panel.Location, panel.Rename.Value()) // Rename the file err := os.Rename(oldPath, newPath) if err != nil { slog.Error("Error while confirmRename during rename", "error", err) // Dont return. We have to also reset the panel and model information } m.fileModel.Renaming = false panel.Rename.Blur() panel.Renaming = false } func (m *model) confirmSortOptions() { panel := m.getFocusedFilePanel() panel.SortKind = m.sortModal.GetSelectedKind() m.sortModal.Close() } // Cancel search, this will clear all searchbar input func (m *model) cancelSearch() { panel := m.getFocusedFilePanel() panel.SearchBar.Blur() panel.SearchBar.SetValue("") } // Confirm search. This will exit the search bar and filter the files func (m *model) confirmSearch() { panel := m.getFocusedFilePanel() panel.SearchBar.Blur() } func (m *model) getFocusedFilePanel() *filepanel.Model { return m.fileModel.GetFocusedFilePanel() } ================================================ FILE: src/internal/handle_panel_movement.go ================================================ package internal import ( "log/slog" "os" "os/exec" "path/filepath" "runtime" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/yorukot/superfile/src/pkg/utils" variable "github.com/yorukot/superfile/src/config" "github.com/yorukot/superfile/src/internal/common" ) // Back to parent directory func (m *model) parentDirectory() { err := m.getFocusedFilePanel().ParentDirectory() if err != nil { slog.Error("Error while changing to parent directory", "error", err) } } // Enter directory or open file with default application // TODO: Unit test this func (m *model) enterPanel() { panel := m.getFocusedFilePanel() if panel.Empty() { return } selectedItem := panel.GetFocusedItem() if selectedItem.Directory { targetPath := selectedItem.Location if selectedItem.Info.Mode()&os.ModeSymlink != 0 { var symlinkErr error targetPath, symlinkErr = filepath.EvalSymlinks(targetPath) if symlinkErr != nil { return } // targetPath shouldn't be a link now, so Stat and Lstat should be same if targetInfo, lstatErr := os.Lstat(targetPath); lstatErr != nil || !targetInfo.IsDir() { return } } // TODO : Propagate error out from this this function. Return here, instead of logging err := m.updateCurrentFilePanelDir(targetPath) if err != nil { slog.Error("Error while changing to directory", "error", err, "target", targetPath) } return } if variable.ChooserFile != "" { chooserErr := m.chooserFileWriteAndQuit(panel.GetFocusedItem().Location) if chooserErr == nil { return } // Continue with preview if file is not writable slog.Error("Error while writing to chooser file, continuing with file open", "error", chooserErr) } m.executeOpenCommand() } func (m *model) executeOpenCommand() { panel := m.getFocusedFilePanel() filePath := panel.GetFocusedItem().Location openCommand := "xdg-open" switch runtime.GOOS { case utils.OsDarwin: openCommand = "open" case utils.OsWindows: dllpath := filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "rundll32.exe") dllfile := "url.dll,FileProtocolHandler" cmd := exec.Command(dllpath, dllfile, filePath) err := cmd.Start() if err != nil { slog.Error("Error while open file with", "error", err) } return } // For now open_with works only for mac and linux // TODO: Make it in parity with windows. ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filePath), ".")) if extEditor, ok := common.Config.OpenWith[ext]; ok { openCommand = extEditor } cmd := exec.Command(openCommand, filePath) utils.DetachFromTerminal(cmd) err := cmd.Start() if err != nil { // TODO: This kind of errors should go to user facing pop ups slog.Error("Error while open file with", "error", err) } } // Switch to the directory where the sidebar cursor is located func (m *model) sidebarSelectDirectory() { // We can't do this when we have only divider directories // m.sidebarModel.directories[m.sidebarModel.cursor].location would point to a divider dir. if m.sidebarModel.NoActualDir() { return } // TODO(Refactor): Move this to a function m.ResetFocus() m.focusPanel = nonePanelFocus panel := m.getFocusedFilePanel() err := m.updateCurrentFilePanelDir(m.sidebarModel.GetCurrentDirectoryLocation()) if err != nil { slog.Error("Error switching to sidebar directory", "error", err) } panel.IsFocused = true } // Toggle dotfile display or not func (m *model) toggleDotFileController() { m.fileModel.ToggleDotFile() err := utils.WriteBoolFile(variable.ToggleDotFile, m.fileModel.DisplayDotFiles) if err != nil { slog.Error("Error while updating toggleDotFile data", "error", err) } } // Toggle dotfile display or not func (m *model) toggleFooterController() tea.Cmd { m.toggleFooter = !m.toggleFooter err := utils.WriteBoolFile(variable.ToggleFooter, m.toggleFooter) if err != nil { slog.Error("Error while updating toggleFooter data", "error", err) } m.setHeightValues() return m.updateComponentDimensions() } // Focus on search bar func (m *model) searchBarFocus() { panel := m.getFocusedFilePanel() if panel.SearchBar.Focused() { panel.SearchBar.Blur() } else { panel.SearchBar.Focus() m.firstTextInput = true } // config search bar width panel.SearchBar.Width = m.fileModel.SinglePanelWidth - common.InnerPadding } func (m *model) sidebarSearchBarFocus() { if m.sidebarModel.SearchBarFocused() { // Ideally Code should never reach here. Once sidebar is focused, we should // not cause sidebarSearchBarFocus() event by pressing search key slog.Error("sidebarSearchBarFocus() called on Focused sidebar") m.sidebarModel.SearchBarBlur() return } m.sidebarModel.SearchBarFocus() m.firstTextInput = true } ================================================ FILE: src/internal/handle_panel_navigation.go ================================================ package internal import ( "log/slog" "github.com/yorukot/superfile/src/internal/common" ) // Pinned directory func (m *model) pinnedDirectory() { panel := m.getFocusedFilePanel() err := m.sidebarModel.TogglePinnedDirectory(panel.Location) if err != nil { slog.Error("Error while toggling pinned directory", "error", err) } } // Focus on sidebar func (m *model) focusOnSideBar() { if common.Config.SidebarWidth == 0 { return } if m.focusPanel == sidebarFocus { m.focusPanel = nonePanelFocus m.getFocusedFilePanel().IsFocused = true } else { m.focusPanel = sidebarFocus m.getFocusedFilePanel().IsFocused = false } } // Focus on processbar func (m *model) focusOnProcessBar() { if !m.toggleFooter { return } if m.focusPanel == processBarFocus { m.focusPanel = nonePanelFocus m.getFocusedFilePanel().IsFocused = true } else { m.focusPanel = processBarFocus m.getFocusedFilePanel().IsFocused = false } } // focus on metadata func (m *model) focusOnMetadata() { if !m.toggleFooter { return } if m.focusPanel == metadataFocus { m.focusPanel = nonePanelFocus m.getFocusedFilePanel().IsFocused = true } else { m.focusPanel = metadataFocus m.getFocusedFilePanel().IsFocused = false } } ================================================ FILE: src/internal/key_function.go ================================================ package internal import ( "errors" "log/slog" "slices" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui/filemodel" "github.com/yorukot/superfile/src/internal/ui/filepanel" "github.com/yorukot/superfile/src/internal/ui/notify" tea "github.com/charmbracelet/bubbletea" variable "github.com/yorukot/superfile/src/config" ) // mainKey handles most of key commands in the regular state of the application. For // keys that performs actions in multiple panels, like going up or down, // check the state of model m and handle properly. // TODO: This function has grown too big. It needs to be fixed, via major // updates and fixes in key handling code func (m *model) mainKey(msg string) tea.Cmd { //nolint: gocyclo,cyclop,funlen // See above switch { // If move up Key is pressed, check the current state and executes case slices.Contains(common.Hotkeys.ListUp, msg): switch m.focusPanel { case sidebarFocus: m.sidebarModel.ListUp() case processBarFocus: m.processBarModel.ListUp() case metadataFocus: m.fileMetaData.ListUp() case nonePanelFocus: m.getFocusedFilePanel().ListUp() } // If move down Key is pressed, check the current state and executes case slices.Contains(common.Hotkeys.ListDown, msg): switch m.focusPanel { case sidebarFocus: m.sidebarModel.ListDown() case processBarFocus: m.processBarModel.ListDown() case metadataFocus: m.fileMetaData.ListDown() case nonePanelFocus: m.getFocusedFilePanel().ListDown() } case slices.Contains(common.Hotkeys.PageUp, msg): m.getFocusedFilePanel().PgUp() case slices.Contains(common.Hotkeys.PageDown, msg): m.getFocusedFilePanel().PgDown() case slices.Contains(common.Hotkeys.ChangePanelMode, msg): m.getFocusedFilePanel().ChangeFilePanelMode() case slices.Contains(common.Hotkeys.NextFilePanel, msg): if m.focusPanel == nonePanelFocus { m.fileModel.NextFilePanel() } case slices.Contains(common.Hotkeys.PreviousFilePanel, msg): if m.focusPanel == nonePanelFocus { m.fileModel.PreviousFilePanel() } case slices.Contains(common.Hotkeys.CloseFilePanel, msg): cmd, err := m.fileModel.CloseFilePanel() if err != nil && !errors.Is(err, filemodel.ErrMinimumPanelCount) { slog.Error("unexpected error while closing new panel", "error", err) } return cmd case slices.Contains(common.Hotkeys.CreateNewFilePanel, msg): cmd, err := m.fileModel.CreateNewFilePanel(variable.HomeDir) if err != nil && !errors.Is(err, filemodel.ErrMaximumPanelCount) { slog.Error("unexpected error while creating new panel", "error", err) } return cmd case slices.Contains(common.Hotkeys.SplitFilePanel, msg): cmd, err := m.splitPanel() if err != nil && !errors.Is(err, filemodel.ErrMaximumPanelCount) { slog.Error("unexpected error while splitting panel", "error", err) } return cmd case slices.Contains(common.Hotkeys.ToggleFilePreviewPanel, msg): return m.fileModel.ToggleFilePreviewPanel() case slices.Contains(common.Hotkeys.FocusOnSidebar, msg): m.focusOnSideBar() case slices.Contains(common.Hotkeys.FocusOnProcessBar, msg): m.focusOnProcessBar() case slices.Contains(common.Hotkeys.FocusOnMetaData, msg): m.focusOnMetadata() case slices.Contains(common.Hotkeys.PasteItems, msg): return m.getPasteItemCmd() case slices.Contains(common.Hotkeys.FilePanelItemCreate, msg): m.panelCreateNewFile() case slices.Contains(common.Hotkeys.PinnedDirectory, msg): m.pinnedDirectory() case slices.Contains(common.Hotkeys.ToggleDotFile, msg): m.toggleDotFileController() case slices.Contains(common.Hotkeys.ToggleFooter, msg): return m.toggleFooterController() case slices.Contains(common.Hotkeys.ExtractFile, msg): return m.getExtractFileCmd() case slices.Contains(common.Hotkeys.CompressFile, msg): return m.getCompressSelectedFilesCmd() case slices.Contains(common.Hotkeys.OpenCommandLine, msg): m.promptModal.Open(true) case slices.Contains(common.Hotkeys.OpenSPFPrompt, msg): m.promptModal.Open(false) case slices.Contains(common.Hotkeys.OpenZoxide, msg): return m.zoxideModal.Open() case slices.Contains(common.Hotkeys.OpenHelpMenu, msg): m.helpMenu.Open() case slices.Contains(common.Hotkeys.OpenSortOptionsMenu, msg): m.sortModal.Open(m.getFocusedFilePanel().SortKind) case slices.Contains(common.Hotkeys.ToggleReverseSort, msg): m.getFocusedFilePanel().ToggleReverseSort() case slices.Contains(common.Hotkeys.OpenFileWithEditor, msg): return m.openFileWithEditor() case slices.Contains(common.Hotkeys.OpenCurrentDirectoryWithEditor, msg): return m.openDirectoryWithEditor() default: return m.normalAndBrowserModeKey(msg) } return nil } func (m *model) normalAndBrowserModeKey(msg string) tea.Cmd { // if not focus on the filepanel return if !m.getFocusedFilePanel().IsFocused { if m.focusPanel == sidebarFocus && slices.Contains(common.Hotkeys.Confirm, msg) { m.sidebarSelectDirectory() } if m.focusPanel == sidebarFocus && slices.Contains(common.Hotkeys.FilePanelItemRename, msg) { m.sidebarModel.PinnedItemRename() } if m.focusPanel == sidebarFocus && slices.Contains(common.Hotkeys.SearchBar, msg) { m.sidebarSearchBarFocus() } return nil } // Check if in the select mode and focusOn filepanel if m.getFocusedFilePanel().PanelMode == filepanel.SelectMode { switch { case slices.Contains(common.Hotkeys.Confirm, msg): m.getFocusedFilePanel().SingleItemSelect() case slices.Contains(common.Hotkeys.FilePanelSelectModeItemsSelectUp, msg): m.getFocusedFilePanel().ItemSelectUp() case slices.Contains(common.Hotkeys.FilePanelSelectModeItemsSelectDown, msg): m.getFocusedFilePanel().ItemSelectDown() case slices.Contains(common.Hotkeys.DeleteItems, msg): return m.getDeleteTriggerCmd(false) case slices.Contains(common.Hotkeys.PermanentlyDeleteItems, msg): return m.getDeleteTriggerCmd(true) case slices.Contains(common.Hotkeys.CopyItems, msg): m.copyMultipleItem(false) case slices.Contains(common.Hotkeys.CutItems, msg): m.copyMultipleItem(true) case slices.Contains(common.Hotkeys.FilePanelSelectAllItem, msg): m.getFocusedFilePanel().SelectAllItem() } return nil } switch { case slices.Contains(common.Hotkeys.Confirm, msg): m.enterPanel() case slices.Contains(common.Hotkeys.ParentDirectory, msg): m.parentDirectory() case slices.Contains(common.Hotkeys.DeleteItems, msg): return m.getDeleteTriggerCmd(false) case slices.Contains(common.Hotkeys.PermanentlyDeleteItems, msg): return m.getDeleteTriggerCmd(true) case slices.Contains(common.Hotkeys.CopyItems, msg): m.copySingleItem(false) case slices.Contains(common.Hotkeys.CutItems, msg): m.copySingleItem(true) case slices.Contains(common.Hotkeys.FilePanelItemRename, msg): m.panelItemRename() case slices.Contains(common.Hotkeys.SearchBar, msg): m.searchBarFocus() case slices.Contains(common.Hotkeys.CopyPath, msg): m.copyPath() case slices.Contains(common.Hotkeys.CopyPWD, msg): m.copyPWD() } return nil } // Check the hotkey to cancel operation or create file func (m *model) typingModalOpenKey(msg string) { switch { case slices.Contains(common.Hotkeys.CancelTyping, msg): m.typingModal.errorMesssage = "" m.cancelTypingModal() case slices.Contains(common.Hotkeys.ConfirmTyping, msg): m.createItem() } } func (m *model) notifyModelOpenKey(msg string) tea.Cmd { isCancel := slices.Contains(common.Hotkeys.CancelTyping, msg) || slices.Contains(common.Hotkeys.Quit, msg) isConfirm := slices.Contains(common.Hotkeys.ConfirmTyping, msg) if !isCancel && !isConfirm { slog.Warn("Invalid keypress in notifyModel", "msg", msg) return nil } m.notifyModel.Close() action := m.notifyModel.GetConfirmAction() if isCancel { return m.handleNotifyModelCancel(action) } return m.handleNotifyModelConfirm(action) } func (m *model) handleNotifyModelCancel(action notify.ConfirmActionType) tea.Cmd { switch action { case notify.RenameAction: m.cancelRename() case notify.QuitAction: m.modelQuitState = notQuitting case notify.DeleteAction, notify.NoAction, notify.PermanentDeleteAction: // Do nothing default: slog.Error("Unknown type of action", "action", action) } return nil } func (m *model) handleNotifyModelConfirm(action notify.ConfirmActionType) tea.Cmd { switch action { case notify.DeleteAction: return m.getDeleteCmd(false) case notify.PermanentDeleteAction: return m.getDeleteCmd(true) case notify.RenameAction: m.confirmRename() case notify.QuitAction: m.modelQuitState = quitConfirmationReceived case notify.NoAction: // Ignore default: slog.Error("Unknown type of action", "action", action) } return nil } // Handles key inputs inside sort options menu func (m *model) sortOptionsKey(msg string) { switch { case slices.Contains(common.Hotkeys.OpenSortOptionsMenu, msg): m.sortModal.Close() case slices.Contains(common.Hotkeys.Quit, msg): m.sortModal.Close() case slices.Contains(common.Hotkeys.Confirm, msg): m.confirmSortOptions() case slices.Contains(common.Hotkeys.ListUp, msg): m.sortModal.ListUp() case slices.Contains(common.Hotkeys.ListDown, msg): m.sortModal.ListDown() } } func (m *model) renamingKey(msg string) tea.Cmd { switch { case slices.Contains(common.Hotkeys.CancelTyping, msg): m.cancelRename() case slices.Contains(common.Hotkeys.ConfirmTyping, msg): if m.IsRenamingConflicting() { return m.warnModalForRenaming() } m.confirmRename() } return nil } func (m *model) sidebarRenamingKey(msg string) { switch { case slices.Contains(common.Hotkeys.CancelTyping, msg): m.sidebarModel.CancelSidebarRename() case slices.Contains(common.Hotkeys.ConfirmTyping, msg): m.sidebarModel.ConfirmSidebarRename() } } // Check the key input and cancel or confirms the search func (m *model) focusOnSearchbarKey(msg string) { switch { case slices.Contains(common.Hotkeys.CancelTyping, msg): m.cancelSearch() case slices.Contains(common.Hotkeys.ConfirmTyping, msg): m.confirmSearch() } } ================================================ FILE: src/internal/model.go ================================================ package internal import ( "errors" "log/slog" "os" "reflect" "slices" "strings" "time" "github.com/yorukot/superfile/src/config/icon" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/pkg/utils" "github.com/barasher/go-exiftool" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/yorukot/superfile/src/internal/ui/filepanel" "github.com/yorukot/superfile/src/internal/ui/metadata" "github.com/yorukot/superfile/src/internal/ui/notify" "github.com/yorukot/superfile/src/internal/ui/preview" variable "github.com/yorukot/superfile/src/config" zoxideui "github.com/yorukot/superfile/src/internal/ui/zoxide" stringfunction "github.com/yorukot/superfile/src/pkg/string_function" ) // These represent model's state information, its not a global preperty var ( LastTimeCursorMove = [2]int{int(time.Now().UnixMicro()), 0} //nolint: gochecknoglobals // TODO: Move to model struct et *exiftool.Exiftool //nolint: gochecknoglobals // TODO: Move to model struct ) // Initialize and return model with default configs // It returns only tea.Model because when it used in main, the return value // is passed to tea.NewProgram() which accepts tea.Model // Either way type 'model' is not exported, so there is not way main package can // be aware of it, and use it directly func InitialModel(firstPanelPaths []string, firstUseCheck bool) tea.Model { toggleDotFile, toggleFooter, zClient := initialConfig(firstPanelPaths) return defaultModelConfig(toggleDotFile, toggleFooter, firstUseCheck, firstPanelPaths, zClient) } // Init function to be called by Bubble tea framework, sets windows title, // cursos blinking and starts message streamming channel // Note : What init should do, for example read file panel data, read sidebar directories, and // disk, is being done in at the creation of model of object. Right now creation of model object // and its initialization isn't well separated. func (m *model) Init() tea.Cmd { return tea.Batch( tea.SetWindowTitle("superfile"), textinput.Blink, // Assuming textinput.Blink is a valid command processCmdToTeaCmd(m.processBarModel.GetListenCmd()), ) } // Update function for bubble tea to provide internal communication to the // application func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { slog.Debug("model.Update() called", "msgType", reflect.TypeOf(msg)) var sidebarCmd, inputCmd, updateCmd, panelCmd, metadataCmd, filePreviewCmd, helpMenuCmd, resizeCmd tea.Cmd // These are above the key message handing to prevent issues with firstKeyInput // if someone presses `/` to focus to searchBar, searchBar will otherwise // get `/` input too. sidebarCmd = m.sidebarModel.UpdateState(msg) // Necessary for blinking. Can't do this in HandleKey, as we only pass KeyMsg there helpMenuCmd = m.helpMenu.HandleTeaMsg(msg) switch msg := msg.(type) { case tea.WindowSizeMsg: resizeCmd = m.handleWindowResize(msg) case tea.MouseMsg: m.handleMouseMsg(msg) case tea.KeyMsg: inputCmd = m.handleKeyInput(msg) // Has to handle zoxide messages separately as they could be generated via // zoxide update commands, or batched commands from textinput // Cannot do it like processbar messages case zoxideui.UpdateMsg: slog.Debug("Got ModelUpdate message", "id", msg.GetReqID()) updateCmd = msg.Apply(&m.zoxideModal) // Its a pain to interconvert commands like processBar case preview.UpdateMsg: slog.Debug("Got ModelUpdate message", "id", msg.GetReqID()) m.fileModel.UpdatePreviewPanel(msg) case ModelUpdateMessage: slog.Debug("Got ModelUpdate message", "id", msg.GetReqID()) updateCmd = msg.ApplyToModel(m) default: slog.Debug("Message of type that is not explicitly handled") } // This is needed for blink, etc to work panelCmd = m.updateComponentState(msg) m.updateModelStateAfterMsg() filePreviewCmd = m.fileModel.GetFilePreviewCmd(false) metadataCmd = m.getMetadataCmd() return m, tea.Batch(sidebarCmd, helpMenuCmd, inputCmd, updateCmd, panelCmd, metadataCmd, filePreviewCmd, resizeCmd) } func (m *model) handleMouseMsg(msg tea.MouseMsg) { msgStr := msg.String() if msgStr == "wheel up" || msgStr == "wheel down" { wheelMainAction(msgStr, m) } else { slog.Debug("Mouse event of type that is not handled", "msg", msgStr) } } func (m *model) updateModelStateAfterMsg() { m.sidebarModel.UpdateDirectories() m.fileModel.UpdateFilePanelsIfNeeded(false) // TODO: Move to utility if m.focusPanel != metadataFocus { m.fileMetaData.ResetRender() } // TODO: Entirely remove the need of this variable, and handle first loading via Init() // Init() should return a basic model object with all IO waiting via a tea.Cmd if !m.firstLoadingComplete { m.firstLoadingComplete = true } } // Note : Maybe we should not trigger metadata fetch for updates // that dont change the currently selected file panel element // TODO : At least dont trigger metadata fetch when user is scrolling // through the metadata panel func (m *model) getMetadataCmd() tea.Cmd { if m.disableMetadata { return nil } if m.getFocusedFilePanel().EmptyOrInvalid() { m.fileMetaData.SetBlank() return nil } selectedItem := m.getFocusedFilePanel().GetFocusedItem() metadataFocused := m.focusPanel == metadataFocus // Note : This will cause metadata not being refreshed there is any file update events. // We can have a cache with TTL or watch filesystem changes to fix this if selectedItem.Location == m.fileMetaData.GetMetadataLocation() && metadataFocused == m.fileMetaData.GetMetadataExpectedFocused() { return nil } if m.fileMetaData.UpdateMetadataIfExistsInCache(selectedItem.Location, metadataFocused) { return nil } m.fileMetaData.SetMetadataLocationAndFocused(selectedItem.Location, metadataFocused) if m.fileMetaData.IsBlank() { m.fileMetaData.SetInfoMsg(icon.InOperation + icon.Space + "Loading metadata...") } reqCnt := m.ioReqCnt m.ioReqCnt++ // If there are too many metadata fetches, we need to have a cache with path as a key // and timeout based eviction slog.Debug("Submitting metadata fetch request", "id", reqCnt, "path", selectedItem.Location) return func() tea.Msg { return NewMetadataMsg( metadata.GetMetadata(selectedItem.Location, metadataFocused, et), metadataFocused, reqCnt) } } // Adjust window size based on msg information func (m *model) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd { m.fullHeight = msg.Height m.fullWidth = msg.Width m.setHeightValues() return m.updateComponentDimensions() } func (m *model) setHeightValues() { //nolint: gocritic // This is to be separated out to a function, and made better later. No need to refactor here if !m.toggleFooter { m.footerHeight = 0 } else if m.fullHeight < common.HeightBreakA { m.footerHeight = 6 } else if m.fullHeight < common.HeightBreakB { m.footerHeight = 7 } else if m.fullHeight < common.HeightBreakC { m.footerHeight = 8 } else if m.fullHeight < common.HeightBreakD { m.footerHeight = 9 } else { m.footerHeight = 10 } // TODO : Make it grow even more for bigger screen sizes. // TODO : Calculate the value , instead of manually hard coding it. // Main panel height = Total terminal height- 2(file panel border) - footer height m.mainPanelHeight = m.fullHeight - common.BorderPadding - utils.FullFooterHeight(m.footerHeight, m.toggleFooter) } func (m *model) updateComponentDimensions() tea.Cmd { m.setHelpMenuSize() m.setPromptModelSize() m.setZoxideModelSize() m.setFooterComponentSize() // File preview panel requires explicit height update, unlike sidebar/file panels // which receive height as render parameters and update automatically on each frame // Force re-render of preview content with new dimensions return m.setMainModelDimensions() } func (m *model) setMainModelDimensions() tea.Cmd { fileModelWidth := m.fullWidth if common.Config.SidebarWidth != 0 { fileModelWidth -= common.Config.SidebarWidth + common.BorderPadding } m.sidebarModel.SetHeight(m.mainPanelHeight + common.BorderPadding) return m.fileModel.SetDimensions(fileModelWidth, m.mainPanelHeight+common.BorderPadding) } // Set help menu size func (m *model) setHelpMenuSize() { height := m.fullHeight - common.BorderPadding width := m.fullWidth - common.BorderPadding if m.fullHeight > common.HeightBreakB { height = 30 } if m.fullWidth > common.ResponsiveWidthThreshold { width = 90 } m.helpMenu.SetDimensions(width, height) } func (m *model) setPromptModelSize() { // Scale prompt model's maxHeight - 33% of total height m.promptModal.SetMaxHeight(m.fullHeight / 3) //nolint:mnd // modal uses third height for layout // Scale prompt model's maxHeight - 50% of total height m.promptModal.SetWidth(m.fullWidth / 2) //nolint:mnd // modal uses half width for layout } func (m *model) setZoxideModelSize() { // Scale zoxide model's maxHeight - 50% of total height to accommodate scroll indicators m.zoxideModal.SetMaxHeight(m.fullHeight / 2) //nolint:mnd // modal uses half height for layout // Scale zoxide model's width - 50% of total width m.zoxideModal.SetWidth(m.fullWidth / 2) //nolint:mnd // modal uses half width for layout } func (m *model) setFooterComponentSize() { var width, clipBoardwidth, height int height = m.footerHeight + common.BorderPadding width = m.fullWidth / utils.CntFooterPanels clipBoardwidth = width + m.fullWidth%utils.CntFooterPanels m.fileMetaData.SetDimensions(width, height) m.processBarModel.SetDimensions(width, height) m.clipboard.SetDimensions(clipBoardwidth, height) } // Identify the current state of the application m and properly handle the // msg keybind pressed func (m *model) handleKeyInput(msg tea.KeyMsg) tea.Cmd { slog.Debug("model.handleKeyInput", "msg", msg, "typestr", msg.Type.String(), "runes", msg.Runes, "type", int(msg.Type), "paste", msg.Paste, "alt", msg.Alt) slog.Debug("model.handleKeyInput. model info. ", "fileModel.FocusedPanelIndex", m.fileModel.FocusedPanelIndex, "filePanel.isFocused", m.getFocusedFilePanel().IsFocused, "filePanel.panelMode", m.getFocusedFilePanel().PanelMode, "typingModal.open", m.typingModal.open, "notifyModel.open", m.notifyModel.IsOpen(), "promptModal.open", m.promptModal.IsOpen(), "fileModel.renaming", m.fileModel.Renaming, "searchBar.focused", m.getFocusedFilePanel().SearchBar.Focused(), "helpMenu.open", m.helpMenu.IsOpen(), "firstTextInput", m.firstTextInput, "focusPanel", m.focusPanel, ) if m.firstUse { m.firstUse = false return nil } var cmd tea.Cmd cdOnQuit := common.Config.CdOnQuit switch { case m.typingModal.open: m.typingModalOpenKey(msg.String()) case m.promptModal.IsOpen(): // Ignore keypress. It will be handled in Update call via // updateFilePanelState // TODO: Convert that to async via tea.Cmd case m.zoxideModal.IsOpen(): // Ignore keypress. It will be handled in Update call via // updateFilePanelState // Handles all warn models except the warn model for confirming to quit case m.notifyModel.IsOpen(): cmd = m.notifyModelOpenKey(msg.String()) // If renaming a object case m.fileModel.Renaming: cmd = m.renamingKey(msg.String()) case m.sidebarModel.IsRenaming(): m.sidebarRenamingKey(msg.String()) // If search bar is open case m.getFocusedFilePanel().SearchBar.Focused(): m.focusOnSearchbarKey(msg.String()) // If sort options menu is open case m.sidebarModel.SearchBarFocused(): m.sidebarModel.HandleSearchBarKey(msg.String()) case m.sortModal.IsOpen(): m.sortOptionsKey(msg.String()) // If help menu is open case m.helpMenu.IsOpen(): m.helpMenu.HandleKey(msg.String()) case slices.Contains(common.Hotkeys.Quit, msg.String()): m.modelQuitState = quitInitiated case slices.Contains(common.Hotkeys.CdQuit, msg.String()): m.modelQuitState = quitInitiated cdOnQuit = true default: // Handles general kinds of inputs in the regular state of the application cmd = m.mainKey(msg.String()) } // If quiting input pressed, check if has any running process and displays a // warn. Otherwise just quits application if m.modelQuitState == quitInitiated { if m.processBarModel.HasRunningProcesses() { // Dont quit now, get a confirmation first. m.modelQuitState = quitConfirmationInitiated m.warnModalForQuit() return cmd } m.modelQuitState = quitConfirmationReceived } if m.modelQuitState == quitConfirmationReceived { m.quitSuperfile(cdOnQuit) return tea.Quit } return cmd } // Update the file panel state. Change name of renamed files, filter out files // in search, update typingb bar, etc func (m *model) updateComponentState(msg tea.Msg) tea.Cmd { focusPanel := m.getFocusedFilePanel() var cmd tea.Cmd var action common.ModelAction switch { case m.firstTextInput: m.firstTextInput = false case m.fileModel.Renaming: focusPanel.Rename, cmd = focusPanel.Rename.Update(msg) case focusPanel.SearchBar.Focused(): focusPanel.SearchBar, cmd = focusPanel.SearchBar.Update(msg) case m.typingModal.open: m.typingModal.textInput, cmd = m.typingModal.textInput.Update(msg) case m.promptModal.IsOpen(): // TODO : Separate this to a utility cwdLocation := m.getFocusedFilePanel().Location action, cmd = m.promptModal.HandleUpdate(msg, cwdLocation) cmd = tea.Batch(cmd, m.applyPromptModalAction(action)) case m.zoxideModal.IsOpen(): action, cmd = m.zoxideModal.HandleUpdate(msg) cmd = tea.Batch(cmd, m.applyZoxideModalAction(action)) } return cmd } // Apply the Action and notify the promptModal func (m *model) applyPromptModalAction(action common.ModelAction) tea.Cmd { successMsg, cmd, actionErr := m.logAndExecuteAction(action) if actionErr != nil { m.promptModal.HandleSPFActionResults(false, actionErr.Error()) } else if successMsg != "" { m.promptModal.HandleSPFActionResults(true, successMsg) } return cmd } // Utility function to log and execute actions, reducing duplication func (m *model) logAndExecuteAction(action common.ModelAction) (string, tea.Cmd, error) { // Only log actions that aren't NoAction to reduce debug noise if _, ok := action.(common.NoAction); !ok { slog.Debug("Applying model action", "action", action) } switch action := action.(type) { case common.NoAction: return "", nil, nil case common.ShellCommandAction: // Shell commands are handled separately and don't return here m.applyShellCommandAction(action.Command) return "", nil, nil case common.SplitPanelAction: cmd, err := m.splitPanel() return "Panel successfully split", cmd, err case common.CDCurrentPanelAction: return "Panel directory changed", nil, m.updateCurrentFilePanelDir(action.Location) case common.OpenPanelAction: cmd, err := m.createNewFilePanelRelativeToCurrent(action.Location) return "New panel opened", cmd, err default: return "", nil, errors.New("unhandled action type") } } // Apply the Action for zoxide modal (no result notifications needed) func (m *model) applyZoxideModalAction(action common.ModelAction) tea.Cmd { _, cmd, _ := m.logAndExecuteAction(action) return cmd } // TODO : Move them around to appropriate places func (m *model) applyShellCommandAction(shellCommand string) { focusPanelDir := m.getFocusedFilePanel().Location retCode, output, err := utils.ExecuteCommandInShell(common.DefaultCommandTimeout, focusPanelDir, shellCommand) m.promptModal.HandleShellCommandResults(retCode, output) if err != nil { slog.Error("Command execution failed", "retCode", retCode, "error", err, "output", output) return } } func (m *model) splitPanel() (tea.Cmd, error) { return m.fileModel.CreateNewFilePanel(m.getFocusedFilePanel().Location) } func (m *model) createNewFilePanelRelativeToCurrent(path string) (tea.Cmd, error) { currentDir := m.getFocusedFilePanel().Location return m.fileModel.CreateNewFilePanel(utils.ResolveAbsPath(currentDir, path)) } // simulates a 'cd' action func (m *model) updateCurrentFilePanelDir(path string) error { panel := m.getFocusedFilePanel() err := panel.UpdateCurrentFilePanelDir(path) if err == nil { // Track the directory change with zoxide m.trackDirectoryWithZoxide(panel.Location) } return err } // trackDirectoryWithZoxide adds the directory to zoxide database if zoxide is available and enabled func (m *model) trackDirectoryWithZoxide(path string) { if !common.Config.ZoxideSupport || m.zClient == nil { return } err := m.zClient.Add(path) if err != nil { slog.Debug("Failed to add directory to zoxide", "path", path, "error", err) } } // Check if there's any processes running in background // Triggers a warn for confirm quiting func (m *model) warnModalForQuit() { m.notifyModel = notify.New(true, "Confirm to quit superfile", "You still have files being processed. Are you sure you want to exit?", notify.QuitAction) } // Implement View function for bubble tea model to handle visualization. func (m *model) View() string { slog.Debug("model.View() called", "mainPanelHeight", m.mainPanelHeight, "footerHeight", m.footerHeight, "fullHeight", m.fullHeight, "fullWidth", m.fullWidth, "panelCount", m.fileModel.PanelCount(), "singlePanelWidth", m.fileModel.SinglePanelWidth, "maxPanels", m.fileModel.MaxFilePanel, "sideBarWidth", common.Config.SidebarWidth, "firstFilePanelWidth", m.fileModel.FilePanels[0].GetWidth()) if !m.firstLoadingComplete { return "Loading..." } // check is the terminal size enough if m.fullHeight < common.MinimumHeight || m.fullWidth < common.MinimumWidth { return m.terminalSizeWarnRender() } if m.fileModel.SinglePanelWidth < filepanel.MinWidth { return m.terminalSizeWarnAfterFirstRender() } // Do validations after min size check above. Validations will fail if user give // too less size to the terminal program if err := m.validateLayout(); err != nil { slog.Error("Invalid layout", "error", err) } return m.updateRenderForOverlay(m.mainComponentsRender()) } func (m *model) updateRenderForOverlay(finalRender string) string { // check if need pop up modal if m.helpMenu.IsOpen() { helpMenu := m.helpMenu.Render() overlayX := m.fullWidth/common.CenterDivisor - m.helpMenu.GetWidth()/common.CenterDivisor overlayY := m.fullHeight/common.CenterDivisor - m.helpMenu.GetHeight()/common.CenterDivisor return stringfunction.PlaceOverlay(overlayX, overlayY, helpMenu, finalRender) } if m.promptModal.IsOpen() { promptModal := m.promptModalRender() overlayX := m.fullWidth/common.CenterDivisor - m.promptModal.GetWidth()/common.CenterDivisor overlayY := m.fullHeight/common.CenterDivisor - m.promptModal.GetMaxHeight()/common.CenterDivisor return stringfunction.PlaceOverlay(overlayX, overlayY, promptModal, finalRender) } if m.zoxideModal.IsOpen() { zoxideModal := m.zoxideModalRender() overlayX := m.fullWidth/common.CenterDivisor - m.zoxideModal.GetWidth()/common.CenterDivisor overlayY := m.fullHeight/common.CenterDivisor - m.zoxideModal.GetMaxHeight()/common.CenterDivisor return stringfunction.PlaceOverlay(overlayX, overlayY, zoxideModal, finalRender) } if m.sortModal.IsOpen() { sortOptions := m.sortModal.Render() overlayX := m.fullWidth/common.CenterDivisor - m.sortModal.Width/common.CenterDivisor overlayY := m.fullHeight/common.CenterDivisor - m.sortModal.Height/common.CenterDivisor return stringfunction.PlaceOverlay(overlayX, overlayY, sortOptions, finalRender) } if m.firstUse { introduceModal := m.introduceModalRender() overlayX := m.fullWidth/common.CenterDivisor - m.helpMenu.GetWidth()/common.CenterDivisor overlayY := m.fullHeight/common.CenterDivisor - m.helpMenu.GetHeight()/common.CenterDivisor return stringfunction.PlaceOverlay(overlayX, overlayY, introduceModal, finalRender) } if m.typingModal.open { typingModal := m.typineModalRender() overlayX := m.fullWidth/common.CenterDivisor - common.ModalWidth/common.CenterDivisor overlayY := m.fullHeight/common.CenterDivisor - common.ModalHeight/common.CenterDivisor return stringfunction.PlaceOverlay(overlayX, overlayY, typingModal, finalRender) } if m.notifyModel.IsOpen() { notifyModal := m.notifyModel.Render() overlayX := m.fullWidth/common.CenterDivisor - common.ModalWidth/common.CenterDivisor overlayY := m.fullHeight/common.CenterDivisor - common.ModalHeight/common.CenterDivisor return stringfunction.PlaceOverlay(overlayX, overlayY, notifyModal, finalRender) } return finalRender } func (m *model) mainComponentsRender() string { sidebar := m.sidebarRender() fileModel := m.fileModel.Render() mainPanel := lipgloss.JoinHorizontal(0, sidebar, fileModel) if !m.toggleFooter { return mainPanel } processBar := m.processBarRender() metaData := m.fileMetaData.Render(m.focusPanel == metadataFocus) clipboardBar := m.clipboard.Render() footer := lipgloss.JoinHorizontal(0, processBar, metaData, clipboardBar) return lipgloss.JoinVertical(0, mainPanel, footer) } // Close superfile application. Cd into the current dir if CdOnQuit on and save // the path in state direcotory func (m *model) quitSuperfile(cdOnQuit bool) { // Resource cleanup if common.Config.Metadata && et != nil { _ = et.Close() } m.fileModel.FilePreview.CleanUp() // cd on quit currentDir := m.getFocusedFilePanel().Location variable.SetLastDir(currentDir) if cdOnQuit { // escape single quote currentDir = strings.ReplaceAll(currentDir, "'", "'\\''") err := os.WriteFile(variable.LastDirFile, []byte("cd '"+currentDir+"'"), utils.ConfigFilePerm) if err != nil { slog.Error("Error during writing lastdir file", "error", err) } } m.modelQuitState = quitDone slog.Debug("Quitting superfile", "current dir", currentDir) } ================================================ FILE: src/internal/model_file_operations_test.go ================================================ package internal import ( "fmt" "os" "path/filepath" "runtime" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/rkoesters/xdg/trash" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" variable "github.com/yorukot/superfile/src/config" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui/notify" ) // TODO : Add test for model initialized with multiple directories // TODO : Add test for clipboard different variations, cut paste // TODO : Add test for tea resizing // TODO : Add test for quitting func TestCopy(t *testing.T) { curTestDir := filepath.Join(testDir, "TestCopy") dir1 := filepath.Join(curTestDir, "dir1") dir2 := filepath.Join(curTestDir, "dir2") file1 := filepath.Join(dir1, "file1.txt") t.Run("Basic Copy", func(t *testing.T) { utils.SetupDirectories(t, curTestDir, dir1, dir2) utils.SetupFiles(t, file1) t.Cleanup(func() { os.RemoveAll(curTestDir) }) p := NewTestTeaProgWithEventLoop(t, defaultTestModel(dir1)) require.Equal(t, "file1.txt", p.getModel().getFocusedFilePanel().GetFocusedItem().Name) p.SendKeyDirectly(common.Hotkeys.CopyItems[0]) assert.False(t, p.getModel().clipboard.IsCut()) assert.Equal(t, file1, p.getModel().clipboard.GetFirstItem()) p.getModel().updateCurrentFilePanelDir("../dir2") p.SendKey(common.Hotkeys.PasteItems[0]) assert.Eventually(t, func() bool { _, err := os.Lstat(filepath.Join(dir2, "file1.txt")) return err == nil }, DefaultTestTimeout, DefaultTestTick) assert.False(t, p.getModel().clipboard.IsCut()) assert.Equal(t, file1, p.getModel().clipboard.GetFirstItem()) p.SendKey(common.Hotkeys.PasteItems[0]) assert.Eventually(t, func() bool { _, err := os.Lstat(filepath.Join(dir2, "file1(1).txt")) return err == nil }, DefaultTestTimeout, DefaultTestTick) assert.FileExists(t, filepath.Join(dir2, "file1(1).txt")) //TODO: Also verify if there are only 2 items in process bar }) } func TestFileCreation(t *testing.T) { // TODO Also add directory creation test to this curTestDir := filepath.Join(testDir, "TestNaming") testParentDir := filepath.Join(curTestDir, "parentDir") testChildDir := filepath.Join(testParentDir, "childDir") utils.SetupDirectories(t, curTestDir, testParentDir, testChildDir) t.Cleanup(func() { os.RemoveAll(curTestDir) }) testdata := []struct { name string fileName string expectedError bool }{ {"valid name", "file.txt", false}, {"invalid single dot", ".", true}, {"invalid double dot", "..", true}, {"invalid trailing slash-dot", fmt.Sprintf("test%c.", filepath.Separator), true}, {"invalid trailing slash-dot-dot", fmt.Sprintf("test%c..", filepath.Separator), true}, {"valid name with trailing .", "abc.", false}, } for _, tt := range testdata { m := defaultTestModel(testChildDir) TeaUpdate(m, nil) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.FilePanelItemCreate[0])) assert.Empty(t, m.typingModal.errorMesssage) m.typingModal.textInput.SetValue(tt.fileName) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.ConfirmTyping[0])) if tt.expectedError { assert.NotEmpty(t, m.typingModal.errorMesssage, "expected an error for input: %q", tt.fileName) } else { assert.Empty(t, m.typingModal.errorMesssage, "expected an error for input: %q", tt.fileName) assert.FileExists( t, filepath.Join(testChildDir, tt.fileName), "expected file to be created: %q", tt.fileName, ) } } } func TestFileRename(t *testing.T) { curTestDir := t.TempDir() file1 := filepath.Join(curTestDir, "file1.txt") file2 := filepath.Join(curTestDir, "file2.txt") file3 := filepath.Join(curTestDir, "file3.txt") utils.SetupFilesWithData(t, []byte("f1"), file1) utils.SetupFilesWithData(t, []byte("f2"), file2) utils.SetupFilesWithData(t, []byte("f3"), file3) file1New := filepath.Join(curTestDir, "file1_new.txt") t.Run("Basic rename", func(t *testing.T) { m := defaultTestModel(curTestDir) p := NewTestTeaProgWithEventLoop(t, m) setFilePanelSelectedItemByLocation(t, m.getFocusedFilePanel(), file1) p.SendKey(common.Hotkeys.FilePanelItemRename[0]) p.SendKey("_new") p.Send(tea.KeyMsg{Type: tea.KeyEnter}) assert.Eventually(t, func() bool { _, err1 := os.Stat(file1) _, err1New := os.Stat(file1New) return err1New == nil && os.IsNotExist(err1) }, DefaultTestTimeout, DefaultTestTick, "File never got renamed") }) t.Run("Rename confirmation for same name", func(t *testing.T) { actualTest := func(doRename bool) { m := defaultTestModel(curTestDir) p := NewTestTeaProgWithEventLoop(t, m) setFilePanelSelectedItemByLocation(t, m.getFocusedFilePanel(), file3) p.SendKey(common.Hotkeys.FilePanelItemRename[0]) p.Send(tea.KeyMsg{Type: tea.KeyBackspace}) p.SendKey("2") p.Send(tea.KeyMsg{Type: tea.KeyEnter}) require.Eventually(t, func() bool { return m.notifyModel.IsOpen() }, DefaultTestTimeout, DefaultTestTick, "Notify modal never opened, renaming text : %v", m.getFocusedFilePanel().Rename.Value()) assert.Equal(t, notify.New(true, common.SameRenameWarnTitle, common.SameRenameWarnContent, notify.RenameAction), m.notifyModel, "Notify model should be as expected") if doRename { p.Send(tea.KeyMsg{Type: tea.KeyEnter}) } else { p.SendKey(common.Hotkeys.CancelTyping[0]) } assert.Eventually(t, func() bool { _, err2 := os.Stat(file2) _, err3 := os.Stat(file3) f2Data, err := os.ReadFile(file2) require.NoError(t, err) if doRename { // f3 should be gone. f2 should have content of f3 return os.IsNotExist(err3) && err2 == nil && string(f2Data) == "f3" } return err2 == nil && err3 == nil }, DefaultTestTimeout, DefaultTestTick, "Rename could not be done/not done appropriately") } actualTest(false) actualTest(true) }) } func isTrashed(fileAbsPath string) bool { fileName := filepath.Base(fileAbsPath) switch runtime.GOOS { case utils.OsDarwin: _, err := os.Stat(filepath.Join(variable.DarwinTrashDirectory, fileName)) return err == nil case utils.OsLinux: _, err := trash.Stat(fileAbsPath) return err == nil default: return false } } func TestFileDelete(t *testing.T) { if runtime.GOOS == utils.OsWindows { t.Skip("Skipping for windows") } curTestDir := t.TempDir() file1 := filepath.Join(curTestDir, "file1.txt") file2 := filepath.Join(curTestDir, "file2.txt") utils.SetupFilesWithData(t, []byte("f1"), file1) utils.SetupFilesWithData(t, []byte("f2"), file2) testdata := []struct { name string filePath string permanentDelete bool }{ { name: "Move to trash", filePath: file1, permanentDelete: false, }, { name: "Permanently delete", filePath: file2, permanentDelete: true, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { m := defaultTestModel(curTestDir) m.hasTrash = common.InitTrash() p := NewTestTeaProgWithEventLoop(t, m) setFilePanelSelectedItemByLocation(t, m.getFocusedFilePanel(), tt.filePath) if tt.permanentDelete { p.SendKey(common.Hotkeys.PermanentlyDeleteItems[0]) } else { p.SendKey(common.Hotkeys.DeleteItems[0]) } assert.Eventually(t, m.notifyModel.IsOpen, DefaultTestTimeout, DefaultTestTick, "Notify model never opened") expectedTitle := common.TrashWarnTitle expectedAction := notify.DeleteAction if tt.permanentDelete { expectedTitle = common.PermanentDeleteWarnTitle expectedAction = notify.PermanentDeleteAction } assert.Equal(t, expectedTitle, m.notifyModel.GetTitle()) assert.Equal(t, expectedAction, m.notifyModel.GetConfirmAction()) p.Send(tea.KeyMsg{Type: tea.KeyEnter}) assert.Eventually(t, func() bool { _, err := os.Stat(tt.filePath) return err != nil && os.IsNotExist(err) }, DefaultTestTimeout, DefaultTestTick, "File never removed from original location") // Window's trash is not flexible enough for the check. // Sorry windows if runtime.GOOS == utils.OsDarwin || runtime.GOOS == utils.OsLinux { assert.Equal(t, tt.permanentDelete, !isTrashed(filepath.Base(tt.filePath)), "Existence in trash status should be expected only of not permanently deleted") } }) } } ================================================ FILE: src/internal/model_layout_test.go ================================================ package internal // TODO add two new tests for sidebar, a - with only one section, and b - without any sections. // note - we should update `testWithConfig` to take a new object of `common.ConfigType`, so that any custom config can be provided. import ( "fmt" "path/filepath" "strconv" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui/filepanel" "github.com/yorukot/superfile/src/internal/ui/metadata" "github.com/yorukot/superfile/src/internal/ui/processbar" ) const ScrollDownCount = 10 const ScrollUpCount = 5 func TestLayout(t *testing.T) { // This runs 800+ tests can be skipped via go test ./... -short if testing.Short() { t.Skip("skipping test in short mode.") } // Uncomment this to debug locally. // This is to prevent too many logs in CICD utils.SetRootLoggerToDiscarded() t.Cleanup(func() { if testing.Verbose() { utils.SetRootLoggerToStdout(true) } }) baseTestDir := t.TempDir() subDir := filepath.Join(baseTestDir, "subdir") subDir2 := filepath.Join(baseTestDir, "subdir2") utils.SetupDirectories(t, baseTestDir, subDir, subDir2) utils.SetupFiles(t, filepath.Join(baseTestDir, "file1.txt"), filepath.Join(baseTestDir, "file2.txt"), filepath.Join(baseTestDir, "file3.txt"), filepath.Join(subDir, "nested1.txt"), filepath.Join(subDir, "nested2.txt"), ) sidebarWidths := []int{0, 5, 12, 20} previewWidths := []int{0, 2, 3, 10} pWDef := common.Config.FilePreviewWidth sWDef := common.Config.SidebarWidth // Note: These cannot run in parallel for now as they share the same // config global variable. Later we might fix that and use parallelization for _, w := range sidebarWidths { t.Run(fmt.Sprintf("sW=%d;pW=%d", w, pWDef), func(t *testing.T) { testWithConfig(t, w, pWDef, false, baseTestDir) }) } for _, w := range previewWidths { t.Run(fmt.Sprintf("sW=%d;pW=%d", sWDef, w), func(t *testing.T) { testWithConfig(t, sWDef, w, false, baseTestDir) }) } // One test for preview border enabled t.Run("sW=10;pW=5;previewWithBorder", func(t *testing.T) { testWithConfig(t, 10, 5, true, baseTestDir) }) } func testWithConfig(t *testing.T, sidebarWidth int, previewWidth int, previewBorderEnabled bool, testPath string) { // Save original config values and restore them after test origSidebarWidth := common.Config.SidebarWidth origPreviewWidth := common.Config.FilePreviewWidth origPreviewBorderEnabled := common.Config.EnableFilePreviewBorder defer func() { common.Config.SidebarWidth = origSidebarWidth common.Config.FilePreviewWidth = origPreviewWidth common.Config.EnableFilePreviewBorder = origPreviewBorderEnabled }() // Set test config common.Config.SidebarWidth = sidebarWidth common.Config.FilePreviewWidth = previewWidth common.Config.EnableFilePreviewBorder = previewBorderEnabled m := defaultTestModelWithFooterAndFilePreview(testPath) p := NewTestTeaProgWithEventLoop(t, m) resizeSizes := []struct { width, height int }{ {60, 24}, // Minimum {80, 30}, // Small HeightBreakC (<35) {100, 39}, // HeightBreakC {130, 44}, // HeightBreakD {200, 60}, // Large {400, 120}, // Extra large {91, 41}, // Back to medium {60, 24}, // Back to minimum } // Run resize tests for _, size := range resizeSizes { t.Run(fmt.Sprintf("w=%d;h=%d", size.width, size.height), func(t *testing.T) { updateModelDimensionsAndValidate(t, p, size.width, size.height) }) } t.Run("Edge cases", func(t *testing.T) { edgeCases := []struct { name string width, height int }{ {"Ultra-narrow", 70, 100}, {"Ultra-wide", 500, 30}, {"Boundary-79", 79, 30}, {"Boundary-80", 80, 30}, {"Boundary-81", 81, 30}, {"Below-minimum", 59, 23}, } for _, tc := range edgeCases { t.Run(tc.name, func(t *testing.T) { p.SendDirectly(tea.WindowSizeMsg{Width: tc.width, Height: tc.height}) assertLayoutValidity(t, p.m) }) } }) } // Note: this will create as many panels possible and leave the model in that state // This is to ensure that at time of resize operations, there are more panels func updateModelDimensionsAndValidate(t *testing.T, p *TeaProg, width int, height int) { // Set Footer OFF, Preview OFF via model state changes // if p.m.toggleFooter { // p.SendDirectly(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(common.Hotkeys.ToggleFooter[0])[0:1]}) //} // File preview toggle - just send the key, no need to check state // Sending toggle key will turn it off if it's on require.True(t, p.m.toggleFooter) require.True(t, p.m.fileModel.FilePreview.IsOpen()) testdata := []struct { name string msg []tea.Msg }{ { name: "Resize", msg: []tea.Msg{tea.WindowSizeMsg{Width: width, Height: height}}, }, { name: "FooterOffPreviewOff", msg: []tea.Msg{ utils.TeaRuneKeyMsg(common.Hotkeys.ToggleFooter[0]), utils.TeaRuneKeyMsg(common.Hotkeys.ToggleFilePreviewPanel[0]), }, }, { name: "ToggleFooterOn", msg: []tea.Msg{utils.TeaRuneKeyMsg(common.Hotkeys.ToggleFooter[0])}, }, { name: "FooterOffPreviewOn", msg: []tea.Msg{ utils.TeaRuneKeyMsg(common.Hotkeys.ToggleFooter[0]), utils.TeaRuneKeyMsg(common.Hotkeys.ToggleFilePreviewPanel[0]), }, }, { name: "FooterOnAgain", msg: []tea.Msg{utils.TeaRuneKeyMsg(common.Hotkeys.ToggleFooter[0])}, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { for _, msg := range tt.msg { p.SendDirectly(msg) } assertLayoutValidity(t, p.m) }) } t.Run("FilePanelRemoval", func(t *testing.T) { for { initialCount := p.m.fileModel.PanelCount() p.SendDirectly(utils.TeaRuneKeyMsg(common.Hotkeys.CloseFilePanel[0])) assertLayoutValidity(t, p.m) if p.m.fileModel.PanelCount() == initialCount { break // No panel was removed } require.Positive(t, p.m.fileModel.PanelCount()) } }) testModelScrolling(t, p) t.Run("FilePanelCreation", func(t *testing.T) { for { initialCount := p.m.fileModel.PanelCount() p.SendDirectly(utils.TeaRuneKeyMsg(common.Hotkeys.CreateNewFilePanel[0])) assertLayoutValidity(t, p.m) if p.m.fileModel.PanelCount() == initialCount { break // No new panel created } require.LessOrEqual(t, p.m.fileModel.PanelCount(), common.FilePanelMax, "Panel count should not exceed maximum") } }) assert.Equal(t, p.m.fileModel.MaxFilePanel, p.m.fileModel.PanelCount()) } func testModelScrolling(t *testing.T, p *TeaProg) { // We are at Filepanel now testModelScrollingCore(t, p) // Add dummy data to ProcessBar and Metadata for i := range 10 { p.m.processBarModel.AddProcess( processbar.NewProcess(strconv.Itoa(i), "test", processbar.OpCopy, 1), ) } dummyData := [][2]string{ {"a", "b"}, {"a", "b"}, {"a", "b"}, {"a", "b"}, {"a", "b"}, } p.m.fileMetaData.SetMetadata(metadata.NewMetadata(dummyData, "", ""), true) panels := []struct { name string focusKey string }{ {"Sidebar", common.Hotkeys.FocusOnSidebar[0]}, {"ProcessBar", common.Hotkeys.FocusOnProcessBar[0]}, {"Metadata", common.Hotkeys.FocusOnMetaData[0]}, } for _, panel := range panels { t.Run(panel.name+"Scrolling", func(t *testing.T) { p.SendKeyDirectly(panel.focusKey) // TODO: Add validation that we are actually at sidebar testModelScrollingCore(t, p) }) } } func testModelScrollingCore(t *testing.T, p *TeaProg) { for range ScrollDownCount { p.SendDirectly(tea.KeyMsg{Type: tea.KeyDown}) } assertLayoutValidity(t, p.m) // Scroll up for range ScrollUpCount { p.SendDirectly(tea.KeyMsg{Type: tea.KeyUp}) } assertLayoutValidity(t, p.m) } func assertLayoutValidity(t *testing.T, m *model) { // Skip for edge conditions where terminal is too small if m.fullHeight < common.MinimumHeight || m.fullWidth < common.MinimumWidth { return // Terminal too small for valid layout } if m.fileModel.SinglePanelWidth < filepanel.MinWidth { return // Panels too narrow for valid layout } returnFirstError := func() error { if err := m.validateLayout(); err != nil { return err } if err := m.validateComponentRender(); err != nil { return err } if err := m.validateFinalRender(); err != nil { return err } return nil } err := returnFirstError() // Not using assert to prevent `getLayoutInfoForDebug` getting called // in happy case. This is hot-path for 906 tests if err != nil { t.Errorf("validations failed, error : %v, layout info : %v", err, getLayoutInfoForDebug(m)) } } func getLayoutInfoForDebug(m *model) string { firstPanel := m.fileModel.FilePanels[0] lastPanel := m.fileModel.FilePanels[m.fileModel.PanelCount()-1] location := m.getFocusedFilePanel().Location width := fmt.Sprintf("width=%d[sidebar=%d,filemodel=%d"+ "[firstpanel=%d,lastpanel=%d,previewExp=%d,previewActual=%d]]"+ "[panelCount=%d,maxPanel=%d]"+ "[processbarWidth=%d,clipboardWidth=%d]", m.fullWidth, common.Config.SidebarWidth, m.fileModel.Width, firstPanel.GetWidth(), lastPanel.GetWidth(), m.fileModel.ExpectedPreviewWidth, m.fileModel.FilePreview.GetContentWidth(), m.fileModel.PanelCount(), m.fileModel.MaxFilePanel, m.processBarModel.GetWidth(), m.clipboard.GetWidth()) height := fmt.Sprintf("height=%d[fileModel=%d[firstPanel=%d,previewActual=%d],footer=%d]", m.fullHeight, m.fileModel.Height, firstPanel.GetHeight(), m.fileModel.FilePreview.GetContentHeight(), m.footerHeight) return fmt.Sprintf("%s %s location=%s", width, height, location) } ================================================ FILE: src/internal/model_msg.go ================================================ package internal import ( "log/slog" tea "github.com/charmbracelet/bubbletea" "github.com/yorukot/superfile/src/internal/ui/metadata" "github.com/yorukot/superfile/src/internal/ui/notify" "github.com/yorukot/superfile/src/internal/ui/processbar" ) type ModelUpdateMessage interface { ApplyToModel(m *model) tea.Cmd GetReqID() int } type BaseMessage struct { reqID int } func (msg BaseMessage) GetReqID() int { return msg.reqID } type PasteOperationMsg struct { BaseMessage state processbar.ProcessState } func NewPasteOperationMsg(state processbar.ProcessState, reqID int) PasteOperationMsg { return PasteOperationMsg{ state: state, BaseMessage: BaseMessage{ reqID: reqID, }, } } func (msg PasteOperationMsg) ApplyToModel(m *model) tea.Cmd { if (msg.state == processbar.Failed || msg.state == processbar.Successful) && m.clipboard.IsCut() { m.clipboard.Reset(false) } return nil } type DeleteOperationMsg struct { BaseMessage state processbar.ProcessState } func NewDeleteOperationMsg(state processbar.ProcessState, reqID int) DeleteOperationMsg { return DeleteOperationMsg{ state: state, BaseMessage: BaseMessage{ reqID: reqID, }, } } func (msg DeleteOperationMsg) ApplyToModel(m *model) tea.Cmd { // Remove selection m.getFocusedFilePanel().ResetSelected() return nil } type ProcessBarUpdateMsg struct { BaseMessage pMsg processbar.UpdateMsg } func (msg ProcessBarUpdateMsg) ApplyToModel(m *model) tea.Cmd { cmd, err := msg.pMsg.Apply(&m.processBarModel) if err != nil { slog.Error("Error applying processbar update", "error", err) } return processCmdToTeaCmd(cmd) } type CompressOperationMsg struct { BaseMessage state processbar.ProcessState } func NewCompressOperationMsg(state processbar.ProcessState, reqID int) CompressOperationMsg { return CompressOperationMsg{ state: state, BaseMessage: BaseMessage{ reqID: reqID, }, } } func (msg CompressOperationMsg) ApplyToModel(_ *model) tea.Cmd { return nil } type ExtractOperationMsg struct { BaseMessage state processbar.ProcessState } func NewExtractOperationMsg(state processbar.ProcessState, reqID int) ExtractOperationMsg { return ExtractOperationMsg{ state: state, BaseMessage: BaseMessage{ reqID: reqID, }, } } func (msg ExtractOperationMsg) ApplyToModel(_ *model) tea.Cmd { return nil } type MetadataMsg struct { BaseMessage meta metadata.Metadata metadataFocused bool } func NewMetadataMsg(meta metadata.Metadata, metadataFocused bool, reqID int) MetadataMsg { return MetadataMsg{ meta: meta, metadataFocused: metadataFocused, BaseMessage: BaseMessage{ reqID: reqID, }, } } func (msg MetadataMsg) ApplyToModel(m *model) tea.Cmd { m.fileMetaData.SetMetadataCache(msg.meta, msg.metadataFocused) selectedItem := m.getFocusedFilePanel().GetFocusedItemPtr() if selectedItem == nil { slog.Debug("Panel empty or cursor invalid. Ignoring MetadataMsg") return nil } if selectedItem.Location != msg.meta.GetPath() { slog.Debug("MetadataMsg for older files. Ignoring", "currentItem", selectedItem.Location, "msgItem", msg.meta.GetPath()) return nil } if (m.focusPanel == metadataFocus) != msg.metadataFocused { slog.Debug("MetadataMsg for older state. Ignoring", "actualFocus", m.focusPanel, "msgFocus", msg.metadataFocused) return nil } m.fileMetaData.SetMetadata(msg.meta, msg.metadataFocused) return nil } type NotifyModalUpdateMsg struct { BaseMessage m notify.Model } func NewNotifyModalMsg(m notify.Model, reqID int) NotifyModalUpdateMsg { return NotifyModalUpdateMsg{ m: m, BaseMessage: BaseMessage{ reqID: reqID, }, } } func (msg NotifyModalUpdateMsg) ApplyToModel(m *model) tea.Cmd { m.notifyModel = msg.m return nil } ================================================ FILE: src/internal/model_navigation_test.go ================================================ package internal import ( "os" "path/filepath" "runtime" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/common" ) func TestFilePanelNavigation(t *testing.T) { /* We want to test (1) Switching to parent directory (2) Switching to parent on being at root "/" (3) Entering current directory (4) Entering via cd / command (5) Cd to itself via cd . command Make sure to validate - Search bar is cleared - The cursor and render values are restored correctly */ curTestDir := t.TempDir() dir1 := filepath.Join(curTestDir, "dir1") dir2 := filepath.Join(curTestDir, "dir2") file1 := filepath.Join(curTestDir, "file1.txt") // We >=3 files in dir1 and >=2 files in dir2 // so that cursor=2, and cursor=1 are valid values. file2 := filepath.Join(dir1, "file2.txt") file3 := filepath.Join(dir1, "file3.txt") file4 := filepath.Join(dir1, "file4.txt") file5 := filepath.Join(dir2, "file5.txt") file6 := filepath.Join(dir2, "file6.txt") rootDir := "/" if runtime.GOOS == utils.OsWindows { rootDir = "\\" } utils.SetupDirectories(t, dir1, dir2) utils.SetupFiles(t, file1, file2, file3, file4, file5, file6) testdata := []struct { name string startDir string resultDir string startCursor int keyInput []string searchBarClear bool }{ { name: "Switch to parent", startDir: dir1, resultDir: curTestDir, startCursor: 1, keyInput: []string{ common.Hotkeys.ParentDirectory[0], }, searchBarClear: true, }, { name: "Switch to parent when at root", startDir: rootDir, resultDir: rootDir, startCursor: 0, keyInput: []string{ common.Hotkeys.ParentDirectory[0], }, searchBarClear: false, }, { name: "Enter current directory", startDir: curTestDir, resultDir: dir2, startCursor: 1, keyInput: []string{ common.Hotkeys.Confirm[0], }, searchBarClear: true, }, { name: "Enter via cd command first dir", startDir: curTestDir, resultDir: dir1, startCursor: 0, keyInput: []string{ common.Hotkeys.OpenSPFPrompt[0], // TODO : Have it quoted, once cd command supports quoted paths "cd " + dir1, common.Hotkeys.ConfirmTyping[0], }, searchBarClear: true, }, { name: "cd . should be ignored", startDir: curTestDir, resultDir: curTestDir, startCursor: 2, keyInput: []string{ common.Hotkeys.OpenSPFPrompt[0], // TODO : Have it quoted, once cd command supports quoted paths "cd .", common.Hotkeys.ConfirmTyping[0], }, searchBarClear: false, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { m := defaultTestModel(tt.startDir) for range tt.startCursor { m.getFocusedFilePanel().ListDown() } require.Equal(t, tt.startCursor, m.getFocusedFilePanel().GetCursor()) originalRenderIndex := m.getFocusedFilePanel().GetRenderIndex() for _, s := range tt.keyInput { TeaUpdate(m, utils.TeaRuneKeyMsg(s)) } assert.Equal(t, tt.resultDir, m.getFocusedFilePanel().Location) if tt.searchBarClear { assert.Empty(t, m.getFocusedFilePanel().SearchBar.Value()) } // Go back to original directory TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenSPFPrompt[0])) TeaUpdate(m, utils.TeaRuneKeyMsg("cd "+tt.startDir)) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) // Make sure we have original cursor and render assert.Equal(t, tt.startCursor, m.getFocusedFilePanel().GetCursor()) assert.Equal(t, originalRenderIndex, m.getFocusedFilePanel().GetRenderIndex()) }) } t.Run("Focus on current directory on navigation to parent directory", func(t *testing.T) { m := defaultTestModel(dir2) p := NewTestTeaProgWithEventLoop(t, m) p.SendKey(common.Hotkeys.ParentDirectory[0]) assert.Eventually(t, func() bool { return m.getFocusedFilePanel().GetFocusedItem().Location == dir2 && m.getFocusedFilePanel().GetCursor() == 1 }, DefaultTestTimeout, DefaultTestTick) }) } func TestCursorOutOfBoundsAfterDirectorySwitch(t *testing.T) { // Create two directories with different file counts tempDir := t.TempDir() dir1 := filepath.Join(tempDir, "dir1") dir2 := filepath.Join(tempDir, "dir2") utils.SetupDirectories(t, dir1, dir2) var files1, files2 []string for i := range 10 { files1 = append(files1, filepath.Join(dir1, string('a'+rune(i))+".txt")) } for i := range 5 { files2 = append(files2, filepath.Join(dir2, string('a'+rune(i))+".txt")) } utils.SetupFiles(t, files1...) utils.SetupFiles(t, files2...) // Start with dir1 m := defaultTestModel(dir1) p := NewTestTeaProgWithEventLoop(t, m) // It will immediately load as defaultTestModel does one sync TeaUpdate assert.Equal(t, 10, m.getFocusedFilePanel().ElemCount(), "Should load 10 files in dir1") // Move cursor to position 8 (near end of list) panel := m.getFocusedFilePanel() for range 8 { p.Send(tea.KeyMsg{Type: tea.KeyDown}) } // Verify cursor is at position 8 assert.Eventually(t, func() bool { return m.getFocusedFilePanel().GetCursor() == 8 }, DefaultTestTimeout, DefaultTestTick, "Cursor should be at position 8") t.Logf("Cursor at position %d with %d elements", panel.GetCursor(), panel.ElemCount()) // Navigate to dir2 (this saves cursor=8 in directoryRecords) navigateToTargetDir(t, m, dir1, dir2) assert.Equal(t, dir2, m.getFocusedFilePanel().Location, "Should be in dir2") assert.Equal(t, 5, m.getFocusedFilePanel().ElemCount()) for i := 4; i < 10; i++ { err := os.Remove(files1[i]) require.NoError(t, err) } t.Log("Deleted 6 files from dir1 externally") // Navigate back to dir1 (this restores cursor=8 from cache) navigateToTargetDir(t, m, dir2, dir1) assert.Equal(t, 0, panel.GetCursor(), "Cursor not restored as is from directoryRecords cache") assert.NoError(t, panel.ValidateCursorAndRenderIndex(), "panel not valid") } func TestCursorRemembersParentPosition(t *testing.T) { /* We want to test that the cursor remembers its position in the parent directory in 3 different cases (1) jump back from child with more elements than parent and near top of list of parent (2) jump back from child with less elements than parent and near end of list of parent (3) jump back from child with no elements */ curTestDir := t.TempDir() dir1 := filepath.Join(curTestDir, "dir1") dir2 := filepath.Join(curTestDir, "dir2") dir3 := filepath.Join(curTestDir, "dir3") dir4 := filepath.Join(curTestDir, "dir4") dir5 := filepath.Join(curTestDir, "dir5") file1 := filepath.Join(dir2, "file1.txt") file2 := filepath.Join(dir2, "file2.txt") file3 := filepath.Join(dir2, "file3.txt") file4 := filepath.Join(dir2, "file4.txt") file5 := filepath.Join(dir2, "file5.txt") file6 := filepath.Join(dir2, "file6.txt") file7 := filepath.Join(dir2, "file7.txt") file8 := filepath.Join(dir4, "file8.txt") file9 := filepath.Join(dir4, "file9.txt") utils.SetupDirectories(t, dir1, dir2, dir3, dir4, dir5) utils.SetupFiles(t, file1, file2, file3, file4, file5, file6, file7, file8, file9) cases := []struct { name string moveDowns int childDir string expectedCursor int }{ {"case1", 1, dir2, 1}, {"case2", 3, dir4, 3}, {"case3", 4, dir5, 4}, } // if a case fails the next case(s) fail also for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { m := defaultTestModel(curTestDir) for range tc.moveDowns { m.getFocusedFilePanel().ListDown() } originalRenderIndex := m.getFocusedFilePanel().GetRenderIndex() assert.Eventually(t, func() bool { return m.getFocusedFilePanel().GetCursor() == tc.expectedCursor }, DefaultTestTimeout, DefaultTestTick, "Cursor should be at correct position") // Move into child directory TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.Confirm[0])) assert.Eventually(t, func() bool { return m.getFocusedFilePanel().Location == tc.childDir }, DefaultTestTimeout, DefaultTestTick, "Should have stepped into child directory") // Go back to original directory TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.ParentDirectory[0])) assert.Eventually(t, func() bool { return m.getFocusedFilePanel().Location == curTestDir }, DefaultTestTimeout, DefaultTestTick, "Should have stepped into parent directory curTestDir") // Make sure we have original cursor and render assert.Equal( t, tc.expectedCursor, m.getFocusedFilePanel().GetCursor(), "Should have remembered cursor position in parent", ) assert.Equal(t, originalRenderIndex, m.getFocusedFilePanel().GetRenderIndex()) }) } } ================================================ FILE: src/internal/model_process_test.go ================================================ package internal import "testing" func TestProcess(_ *testing.T) { // TODO : // We need to test - We could implement these checks in other tests like Test Copy // 1 - Successful process for Copy, Delete, Compress, Extract // Validate donetime, done value, state, etc. // 2 - Failed processes // TODO : Figure out a way test the progress updating. The fact that progress gradually updates. // 3 - Process progress tracking } ================================================ FILE: src/internal/model_prompt_test.go ================================================ package internal import ( "fmt" "os" "path/filepath" "runtime" "strings" "testing" "github.com/adrg/xdg" tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui/prompt" ) func TestModel_Update_Prompt(t *testing.T) { curTestDir := filepath.Join(testDir, "TestPrompt") dir1 := filepath.Join(curTestDir, "dir1") dir2 := filepath.Join(curTestDir, "dir2") file1 := filepath.Join(dir1, "file1.txt") utils.SetupDirectories(t, curTestDir, dir1, dir2) utils.SetupFiles(t, file1) t.Cleanup(func() { os.RemoveAll(curTestDir) }) // We want to test these. TODO : complete important tests // 1. Being able to open prompt // 1a. Open in shell mode, 1b. Open in prompt mode 1c. Switching between then // 2. Being able to execute shell commands // 3. Shell command failure is handled and prompt stays open // 4. Successful Model actions - Split, Cd, Open new panel // 4a. Working split // 4b. Working cd : cd to abs path, cd to relative path, cd to home // 4c. Working open : open to abs path, open to relative path, open to home // 5. Split - Failure due to reaching max no. of panels // 6. cd - failure due to invalid path // 7. open - failure due to reaching max no. of panels // 8. open - failure due to invalid path // 9. cd and open - handling absolute and relative paths correctly // 10. Model closing // 10a. Pressing escape or ctrl+c and model closes // 10b. Autoclose based on config // Dont test shell command substitution here. // We might want to wrap os command execution in an interface and // ? Use a mock os command executor to have timeouts, and // custom command behaviour // Other tests cases // -- UI // 1. Entire model's rendering with promptModel open/closed // 2. Rendering not breaking when user pastes/enter special character or too much text // 3. Prompt gets resized based on total screen size. And always fits in // -- Functionality // 1. Shell command Timeout. Testing timeout is a pain. We should use async, and configure low timeout // like 1 sec for testing // 2. In case we plan to show output, we need to test case of // too big Shell command output testBasicPromptFunctionality(t, dir1) testPanelOperations(t, dir1, dir2, curTestDir) testDirectoryHandlingWithQuotes(t, curTestDir, dir1) testShellCommandsWithQuotes(t, curTestDir, dir1) } // testBasicPromptFunctionality tests opening, closing and basic command execution func testBasicPromptFunctionality(t *testing.T, dir1 string) { t.Run("Basic Prompt Opening", func(t *testing.T) { m := defaultTestModel(dir1) assert.False(t, m.promptModal.IsOpen()) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenCommandLine[0])) assert.True(t, m.promptModal.IsOpen()) assert.True(t, m.promptModal.IsShellMode()) // Switching between modes TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenSPFPrompt[0])) assert.False(t, m.promptModal.IsShellMode(), "Pressing prompt key should switch to prompt mode") TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenCommandLine[0])) assert.True(t, m.promptModal.IsShellMode(), "Pressing shell key should switch to shell mode") // Closing and opening in prompt mode TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.CancelTyping[0])) assert.False(t, m.promptModal.IsOpen()) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenSPFPrompt[0])) assert.True(t, m.promptModal.IsOpen()) assert.False(t, m.promptModal.IsShellMode()) }) t.Run("Shell command execution", func(t *testing.T) { m := defaultTestModel(dir1) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenCommandLine[0])) // Prefer cross platform command TeaUpdate(m, utils.TeaRuneKeyMsg("mkdir test_dir")) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded()) assert.DirExists(t, filepath.Join(dir1, "test_dir")) // Invalid command shouldn't cause issues. TeaUpdate(m, utils.TeaRuneKeyMsg("xyz_non_exisiting_command")) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.False(t, m.promptModal.LastActionSucceeded()) assert.True(t, m.promptModal.IsOpen()) }) t.Run("Model closing", func(t *testing.T) { m := defaultTestModel(dir1) for _, key := range common.Hotkeys.CancelTyping { TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenSPFPrompt[0])) assert.True(t, m.promptModal.IsOpen()) TeaUpdate(m, utils.TeaRuneKeyMsg(key)) assert.False(t, m.promptModal.IsOpen(), "Prompt should get closed") } }) } // testPanelOperations tests split, cd, and open panel operations func testPanelOperations(t *testing.T, dir1, dir2, curTestDir string) { t.Run("Split Panel", func(t *testing.T) { m := defaultTestModel(dir1) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenSPFPrompt[0])) require.True(t, m.promptModal.IsOpen()) for len(m.fileModel.FilePanels) < m.fileModel.MaxFilePanel { prevCnt := len(m.fileModel.FilePanels) TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.SplitCommand)) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) require.Len(t, m.fileModel.FilePanels, prevCnt+1) assert.Equal(t, dir1, m.fileModel.FilePanels[prevCnt].Location) assert.True(t, m.promptModal.LastActionSucceeded()) } // Now doing a split should fail prevCnt := len(m.fileModel.FilePanels) TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.SplitCommand)) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.False(t, m.promptModal.LastActionSucceeded()) assert.Len(t, m.fileModel.FilePanels, prevCnt) }) t.Run("cd Panel", func(t *testing.T) { m := defaultTestModel(dir1) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenSPFPrompt[0])) TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.CdCommand+" "+dir2)) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "cd using absolute path should work") assert.Equal(t, dir2, m.getFocusedFilePanel().Location) TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.CdCommand+" ..")) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "cd using relative path should work") assert.Equal(t, curTestDir, m.getFocusedFilePanel().Location) TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.CdCommand+" "+filepath.Base(dir2))) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "cd using relative path should work") assert.Equal(t, dir2, m.getFocusedFilePanel().Location) TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.CdCommand+" "+filepath.Join(dir2, "non_existing_dir"))) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.False(t, m.promptModal.LastActionSucceeded(), "cd invalid abs path should not work") assert.Equal(t, dir2, m.getFocusedFilePanel().Location) TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.CdCommand+" non_existing_dir")) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.False(t, m.promptModal.LastActionSucceeded(), "cd invalid relative path should not work") assert.Equal(t, dir2, m.getFocusedFilePanel().Location) TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.CdCommand+" ~")) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "cd using tilde should work") assert.Equal(t, xdg.Home, m.getFocusedFilePanel().Location) }) t.Run("open Panel", func(t *testing.T) { m := defaultTestModel(dir1) orgCnt := len(m.fileModel.FilePanels) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenSPFPrompt[0])) TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.OpenCommand+" "+dir2)) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "open using absolute path should work") assert.Equal(t, dir2, m.getFocusedFilePanel().Location) m.fileModel.CloseFilePanel() assert.Len(t, m.fileModel.FilePanels, orgCnt) assert.Equal(t, dir1, m.getFocusedFilePanel().Location) TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.OpenCommand+" ../dir2")) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "open using relative path should work") assert.Equal(t, dir2, m.getFocusedFilePanel().Location) m.fileModel.CloseFilePanel() TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.OpenCommand+" ~")) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "open using tilde should work") assert.Equal(t, xdg.Home, m.getFocusedFilePanel().Location) m.fileModel.CloseFilePanel() userHomeEnv := "HOME" if runtime.GOOS == utils.OsWindows { userHomeEnv = "USERPROFILE" } TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.OpenCommand+fmt.Sprintf(" ${%s}", userHomeEnv))) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "open using variable substitution should work") assert.Equal(t, xdg.Home, m.getFocusedFilePanel().Location) m.fileModel.CloseFilePanel() // Note : resolving shell subsitution is flaky in windows. TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.OpenCommand+" $(echo \"~\")")) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "open using command substitution should work") assert.Equal(t, xdg.Home, m.getFocusedFilePanel().Location) m.fileModel.CloseFilePanel() TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.OpenCommand+" non_existing_dir")) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.False(t, m.promptModal.LastActionSucceeded(), "open using invalid relative path should not work") TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.OpenCommand+" "+filepath.Join(dir2, "non_existing_dir"))) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.False(t, m.promptModal.LastActionSucceeded(), "open using invalid abs path should not work") for len(m.fileModel.FilePanels) < m.fileModel.MaxFilePanel { TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.OpenCommand+" .")) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded()) } // Now doing a open should fail TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.OpenCommand+" .")) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.False(t, m.promptModal.LastActionSucceeded()) }) } // testDirectoryHandlingWithQuotes tests handling directories with spaces and quotes func testDirectoryHandlingWithQuotes(t *testing.T, curTestDir, dir1 string) { t.Run("Directory names with spaces and quotes", func(t *testing.T) { // Create test directories with spaces and special characters dirWithSpaces := filepath.Join(curTestDir, "dir with spaces") dirWithQuotes := filepath.Join(curTestDir, "dir'with'quotes") // Windows doesn't allow double quotes in directory names var dirWithSpecialChars, dirWithMixed string var directoriesToCreate []string if runtime.GOOS == "windows" { // On Windows, use alternative characters that don't conflict with filesystem restrictions dirWithSpecialChars = filepath.Join(curTestDir, `dir[with]quotes`) dirWithMixed = filepath.Join(curTestDir, `dir with 'mixed' [quotes]`) directoriesToCreate = []string{dirWithSpaces, dirWithQuotes, dirWithSpecialChars, dirWithMixed} } else { // On Unix-like systems, double quotes are allowed in directory names dirWithSpecialChars = filepath.Join(curTestDir, `dir"with"quotes`) dirWithMixed = filepath.Join(curTestDir, `dir with 'mixed' "quotes"`) directoriesToCreate = []string{dirWithSpaces, dirWithQuotes, dirWithSpecialChars, dirWithMixed} } utils.SetupDirectories(t, directoriesToCreate...) t.Run("cd with double quotes", func(t *testing.T) { m := defaultTestModel(dir1) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenSPFPrompt[0])) TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.CdCommand+` "`+dirWithSpaces+`"`)) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "cd with double quotes should work") assert.Equal(t, dirWithSpaces, m.getFocusedFilePanel().Location) }) t.Run("cd with single quotes", func(t *testing.T) { m := defaultTestModel(dir1) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenSPFPrompt[0])) TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.CdCommand+` '`+dirWithSpaces+`'`)) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "cd with single quotes should work") assert.Equal(t, dirWithSpaces, m.getFocusedFilePanel().Location) }) t.Run("cd with single quotes in path using double quotes", func(t *testing.T) { m := defaultTestModel(dir1) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenSPFPrompt[0])) TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.CdCommand+` "`+dirWithQuotes+`"`)) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "cd with single quotes in path should work") assert.Equal(t, dirWithQuotes, m.getFocusedFilePanel().Location) }) t.Run("cd with double quotes in path using single quotes", func(t *testing.T) { m := defaultTestModel(dir1) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenSPFPrompt[0])) TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.CdCommand+` '`+dirWithSpecialChars+`'`)) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "cd with double quotes in path should work") assert.Equal(t, dirWithSpecialChars, m.getFocusedFilePanel().Location) }) t.Run("cd with escaped spaces", func(t *testing.T) { m := defaultTestModel(dir1) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenSPFPrompt[0])) TeaUpdate( m, utils.TeaRuneKeyMsg(prompt.CdCommand+` `+strings.ReplaceAll(dirWithSpaces, " ", `\ `)), ) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "cd with escaped spaces should work") assert.Equal(t, dirWithSpaces, m.getFocusedFilePanel().Location) }) t.Run("open with double quotes", func(t *testing.T) { m := defaultTestModel(dir1) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenSPFPrompt[0])) TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.OpenCommand+` "`+dirWithSpaces+`"`)) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "open with double quotes should work") assert.Equal(t, dirWithSpaces, m.getFocusedFilePanel().Location) m.fileModel.CloseFilePanel() }) t.Run("cd with quoted environment variable", func(t *testing.T) { m := defaultTestModel(dir1) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenSPFPrompt[0])) userHomeEnv := "HOME" if runtime.GOOS == utils.OsWindows { userHomeEnv = "USERPROFILE" } TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.CdCommand+` "${`+userHomeEnv+`}"`)) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "cd with quoted env var should work") assert.Equal(t, xdg.Home, m.getFocusedFilePanel().Location) }) t.Run("cd with single quoted environment variable", func(t *testing.T) { m := defaultTestModel(dir1) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenSPFPrompt[0])) userHomeEnv := "HOME" if runtime.GOOS == utils.OsWindows { userHomeEnv = "USERPROFILE" } TeaUpdate(m, utils.TeaRuneKeyMsg(prompt.CdCommand+` '${`+userHomeEnv+`}'`)) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True( t, m.promptModal.LastActionSucceeded(), "cd with single quoted env var works in superfile (unlike bash)", ) assert.Equal(t, xdg.Home, m.getFocusedFilePanel().Location) }) }) } // testShellCommandsWithQuotes tests shell command execution with quoted arguments func testShellCommandsWithQuotes(t *testing.T, curTestDir, dir1 string) { t.Run("Shell command with quotes", func(t *testing.T) { dirWithSpaces := filepath.Join(curTestDir, "test dir with spaces") utils.SetupDirectories(t, dirWithSpaces) t.Run("shell command with double quotes", func(t *testing.T) { m := defaultTestModel(dir1) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenCommandLine[0])) TeaUpdate(m, utils.TeaRuneKeyMsg(`mkdir "`+filepath.Join(dir1, "new dir with spaces")+`"`)) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "shell command with quotes should work") assert.DirExists(t, filepath.Join(dir1, "new dir with spaces")) }) t.Run("shell command with single quotes", func(t *testing.T) { m := defaultTestModel(dir1) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenCommandLine[0])) TeaUpdate(m, utils.TeaRuneKeyMsg(`mkdir '`+filepath.Join(dir1, "another dir with spaces")+`'`)) TeaUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) assert.True(t, m.promptModal.LastActionSucceeded(), "shell command with single quotes should work") assert.DirExists(t, filepath.Join(dir1, "another dir with spaces")) }) }) } ================================================ FILE: src/internal/model_render.go ================================================ package internal import ( "path/filepath" "strconv" filepreview "github.com/yorukot/superfile/src/pkg/file_preview" "github.com/yorukot/superfile/src/internal/common" "github.com/charmbracelet/lipgloss" "github.com/yorukot/superfile/src/config/icon" ) func (m *model) sidebarRender() string { return m.sidebarModel.Render(m.focusPanel == sidebarFocus, m.getFocusedFilePanel().Location) } func (m *model) processBarRender() string { return m.processBarModel.Render(m.focusPanel == processBarFocus) } func (m *model) terminalSizeWarnRender() string { fullWidthString := strconv.Itoa(m.fullWidth) fullHeightString := strconv.Itoa(m.fullHeight) minimumWidthString := strconv.Itoa(common.MinimumWidth) minimumHeightString := strconv.Itoa(common.MinimumHeight) if m.fullHeight < common.MinimumHeight { fullHeightString = common.TerminalTooSmall.Render(fullHeightString) } if m.fullWidth < common.MinimumWidth { fullWidthString = common.TerminalTooSmall.Render(fullWidthString) } fullHeightString = common.TerminalCorrectSize.Render(fullHeightString) fullWidthString = common.TerminalCorrectSize.Render(fullWidthString) heightString := common.MainStyle.Render(" Height = ") return common.FullScreenStyle(m.fullHeight, m.fullWidth).Render(`Terminal size too small:`+"\n"+ "Width = "+fullWidthString+ heightString+fullHeightString+"\n\n"+ "Needed for current config:"+"\n"+ "Width = "+common.TerminalCorrectSize.Render(minimumWidthString)+ heightString+common.TerminalCorrectSize.Render(minimumHeightString)) + filepreview.ClearKittyImages() } func (m *model) terminalSizeWarnAfterFirstRender() string { minimumWidthInt := common.Config.SidebarWidth + common.FilePanelWidthUnit*len( m.fileModel.FilePanels, ) + common.FilePanelWidthUnit - 1 minimumWidthString := strconv.Itoa(minimumWidthInt) fullWidthString := strconv.Itoa(m.fullWidth) fullHeightString := strconv.Itoa(m.fullHeight) minimumHeightString := strconv.Itoa(common.MinimumHeight) if m.fullHeight < common.MinimumHeight { fullHeightString = common.TerminalTooSmall.Render(fullHeightString) } if m.fullWidth < minimumWidthInt { fullWidthString = common.TerminalTooSmall.Render(fullWidthString) } fullHeightString = common.TerminalCorrectSize.Render(fullHeightString) fullWidthString = common.TerminalCorrectSize.Render(fullWidthString) heightString := common.MainStyle.Render(" Height = ") return common.FullScreenStyle(m.fullHeight, m.fullWidth).Render(`You change your terminal size too small:`+"\n"+ "Width = "+fullWidthString+ heightString+fullHeightString+"\n\n"+ "Needed for current config:"+"\n"+ "Width = "+common.TerminalCorrectSize.Render(minimumWidthString)+ heightString+common.TerminalCorrectSize.Render(minimumHeightString)) + filepreview.ClearKittyImages() } func (m *model) typineModalRender() string { previewPath := filepath.Join(m.typingModal.location, m.typingModal.textInput.Value()) fileLocation := common.FilePanelTopDirectoryIconStyle.Render(" "+icon.Directory+icon.Space) + common.FilePanelTopPathStyle.Render( common.TruncateTextBeginning(previewPath, common.ModalWidth-common.InnerPadding, "..."), ) + "\n" confirm := common.ModalConfirm.Render(" (" + common.Hotkeys.ConfirmTyping[0] + ") Create ") cancel := common.ModalCancel.Render(" (" + common.Hotkeys.CancelTyping[0] + ") Cancel ") tip := confirm + lipgloss.NewStyle().Background(common.ModalBGColor).Render(" ") + cancel var err string if m.typingModal.errorMesssage != "" { err = "\n\n" + common.ModalErrorStyle.Render(m.typingModal.errorMesssage) } // TODO : Move this all to rendering package to avoid specifying newlines manually return common.ModalBorderStyle(common.ModalHeight, common.ModalWidth). Render(fileLocation + "\n" + m.typingModal.textInput.View() + "\n\n" + tip + err) } func (m *model) introduceModalRender() string { title := common.SidebarTitleStyle.Render(" Thanks for using superfile!!") + common.ModalStyle.Render("\n You can read the following information before starting to use it!") vimUserWarn := common.ProcessErrorStyle.Render(" ** Very importantly ** If you are a Vim/Nvim user, go to:\n" + " https://superfile.dev/configure/custom-hotkeys/ to change your hotkey settings!") subOne := common.SidebarTitleStyle.Render(" (1)") + common.ModalStyle.Render(" If this is your first time, make sure you read:\n"+ " https://superfile.dev/getting-started/tutorial/") subTwo := common.SidebarTitleStyle.Render(" (2)") + common.ModalStyle.Render(" If you forget the relevant keys during use,\n"+ " you can press \"?\" (shift+/) at any time to query the keys!") subThree := common.SidebarTitleStyle.Render(" (3)") + common.ModalStyle.Render(" For more customization you can refer to:\n"+ " https://superfile.dev/") subFour := common.SidebarTitleStyle.Render(" (4)") + common.ModalStyle.Render(" Thank you again for using superfile.\n"+ " If you have any questions, please feel free to ask at:\n"+ " https://github.com/yorukot/superfile\n"+ " Of course, you can always open a new issue to share your idea \n"+ " or report a bug!") return common.FirstUseModal(m.helpMenu.GetHeight(), m.helpMenu.GetWidth()). Render(title + "\n\n" + vimUserWarn + "\n\n" + subOne + "\n\n" + subTwo + "\n\n" + subThree + "\n\n" + subFour + "\n\n") } func (m *model) promptModalRender() string { return m.promptModal.Render() } func (m *model) zoxideModalRender() string { return m.zoxideModal.Render() } ================================================ FILE: src/internal/model_test.go ================================================ package internal import ( "flag" "fmt" "os" "path/filepath" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/yorukot/superfile/src/pkg/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" variable "github.com/yorukot/superfile/src/config" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui/processbar" ) /* The purpose of this test file is to have the (1) common global data for tests (2) common setup for tests, and cleanup (3) Basic model fuctionality tests - Initialization - Resize - Update - Quitting */ // Helps to have centralized cleanup var testDir string //nolint: gochecknoglobals // One-time initialized, and then read-only global test variable func cleanupTestDir() { err := os.RemoveAll(testDir) if err != nil { fmt.Printf("error while cleaning up test directory, err : %v", err) os.Exit(1) } } func TestMain(m *testing.M) { err := common.PopulateGlobalConfigs() if err != nil { fmt.Printf("error while populating config, err : %v", err) os.Exit(1) } // A cleanup before is required in case the previous test run had a panic, and then // deferred cleanup never executed // Create testDir testDir = filepath.Join(os.TempDir(), "spf_testdir") cleanupTestDir() if err := os.Mkdir(testDir, 0o755); err != nil { fmt.Printf("error while creating test directory, err : %v", err) os.Exit(1) } defer cleanupTestDir() flag.Parse() if testing.Verbose() { utils.SetRootLoggerToStdout(true) } else { utils.SetRootLoggerToDiscarded() } m.Run() // Maybe catch panic } func TestBasic(t *testing.T) { curTestDir := filepath.Join(testDir, "TestBasic") dir1 := filepath.Join(curTestDir, "dir1") dir2 := filepath.Join(curTestDir, "dir2") file1 := filepath.Join(dir1, "file1.txt") t.Run("Basic Checks", func(t *testing.T) { utils.SetupDirectories(t, curTestDir, dir1, dir2) utils.SetupFiles(t, file1) t.Cleanup(func() { os.RemoveAll(curTestDir) }) m := defaultTestModel(dir1) // Validate the most of the data stored in model object // Inspect model struct to see what more can be validated. // 1 - File panel location, cursor, render index, etc. // 2 - Directory Items are listed // 3 - sidebar items pinned items are listed // 4 - process panel is empty // 5 - clipboard is empty // 6 - model's dimenstion assert.Equal(t, dir1, m.getFocusedFilePanel().Location) }) } func TestInitialFilePathPositionsCursorWindow(t *testing.T) { curTestDir := t.TempDir() dir1 := filepath.Join(curTestDir, "dir1") utils.SetupDirectories(t, curTestDir, dir1) var file7 string var file2 string for i := range 10 { f := filepath.Join(dir1, fmt.Sprintf("file%d.txt", i)) utils.SetupFiles(t, f) if i == 7 { file7 = f } if i == 2 { file2 = f } } m := defaultTestModel(dir1, file2, file7) // View port of 5 TeaUpdate(m, tea.WindowSizeMsg{Width: common.MinimumWidth, Height: 10}) // Uncomment below to understand the distribution // t.Logf("Heights : %d [%d - [%d] %d]\n", m.fullHeight, m.footerHeight, m.mainPanelHeight, // panelElementHeight(m.mainPanelHeight)) require.Len(t, m.fileModel.FilePanels, 3) assert.Equal(t, dir1, m.fileModel.FilePanels[0].Location) assert.Equal(t, file2, m.fileModel.FilePanels[1].GetFocusedItem().Location) assert.Equal(t, 2, m.fileModel.FilePanels[1].GetCursor()) assert.Equal(t, 0, m.fileModel.FilePanels[1].GetRenderIndex()) assert.Equal(t, file7, m.fileModel.FilePanels[2].GetFocusedItem().Location) assert.Equal(t, 7, m.fileModel.FilePanels[2].GetCursor()) assert.Equal(t, 3, m.fileModel.FilePanels[2].GetRenderIndex()) } func TestQuit(t *testing.T) { // Test // 1 - Normal quit // 2 - Normal quit with running process causing a warn modal // 2a - Cancelling quit // 2b - Proceeding with the quit // 3 - Cd on quit test that LastDir is written on t.Run("Normal Quit", func(t *testing.T) { m := defaultTestModel(testDir) assert.Equal(t, notQuitting, m.modelQuitState) cmd := TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.Quit[0])) assert.Equal(t, quitDone, m.modelQuitState) assert.True(t, IsTeaQuit(cmd)) }) t.Run("Quit with running process", func(t *testing.T) { m := defaultTestModel(testDir) m.processBarModel.AddOrUpdateProcess(processbar.Process{ State: processbar.InOperation, Done: 0, Total: 100, ID: "1", }) assert.Equal(t, notQuitting, m.modelQuitState) cmd := TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.Quit[0])) assert.Equal(t, quitConfirmationInitiated, m.modelQuitState) assert.False(t, IsTeaQuit(cmd)) // Now we would be asked for confirmation. // Cancel the quit cmd = TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.CancelTyping[0])) assert.Equal(t, notQuitting, m.modelQuitState) assert.False(t, IsTeaQuit(cmd)) // Again trigger quit cmd = TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.Quit[0])) assert.Equal(t, quitConfirmationInitiated, m.modelQuitState) assert.False(t, IsTeaQuit(cmd)) // Confirm this time cmd = TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.Confirm[0])) assert.Equal(t, quitDone, m.modelQuitState) assert.True(t, IsTeaQuit(cmd)) }) t.Run("Cd on quit test that LastDir is written on", func(t *testing.T) { lastDirFile := filepath.Join(variable.SuperFileStateDir, "lastdir") require.NoError(t, os.MkdirAll(filepath.Dir(lastDirFile), 0o755)) m := defaultTestModel(testDir) assert.Equal(t, notQuitting, m.modelQuitState) cmd := TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.CdQuit[0])) assert.Equal(t, quitDone, m.modelQuitState) assert.True(t, IsTeaQuit(cmd)) data, err := os.ReadFile(lastDirFile) require.NoError(t, err) assert.Equal(t, "cd '"+testDir+"'", string(data), "LastDir file should contain the tempDir path") err = os.Remove(lastDirFile) require.NoError(t, err) }) } func TestChooserFile(t *testing.T) { // 1 - No quit - blank chooser file // 2 - Quit with valid chooser file // 2a - file preview // 2b - directory preview // 3 - No quit - invalid chooser file curTestDir := filepath.Join(testDir, "TestChooserFile") dir1 := filepath.Join(curTestDir, "dir1") dir2 := filepath.Join(curTestDir, "dir2") file1 := filepath.Join(dir1, "file1.txt") testChooserFile := filepath.Join(dir2, "chooser_file.txt") utils.SetupDirectories(t, curTestDir, dir1, dir2) utils.SetupFiles(t, file1) testdata := []struct { name string chooserFile string hotkey string expectedQuit bool expectedContent string }{ { name: "Open with default app with valid chooser file", chooserFile: testChooserFile, hotkey: common.Hotkeys.Confirm[0], expectedQuit: true, expectedContent: file1, }, { name: "Open with file editor with valid chooser file", chooserFile: testChooserFile, hotkey: common.Hotkeys.OpenFileWithEditor[0], expectedQuit: true, expectedContent: file1, }, { name: "Open with directory editor valid chooser file", hotkey: common.Hotkeys.OpenCurrentDirectoryWithEditor[0], chooserFile: testChooserFile, expectedQuit: true, expectedContent: dir1, }, { name: "Open with file editor with Blank chooser file", chooserFile: "", hotkey: common.Hotkeys.OpenFileWithEditor[0], expectedQuit: false, expectedContent: "", }, { name: "Open with file editor with Invalid chooser file", chooserFile: filepath.Join(curTestDir, "non_existent_dir", "file.txt"), hotkey: common.Hotkeys.OpenFileWithEditor[0], expectedQuit: false, expectedContent: "", }, } // Must be sequential as we are using global variable chooserfile for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { m := defaultTestModel(dir1) if tt.expectedQuit { err := os.WriteFile(tt.chooserFile, []byte{}, 0o644) require.NoError(t, err) } variable.SetChooserFile(tt.chooserFile) cmd := TeaUpdate(m, utils.TeaRuneKeyMsg(tt.hotkey)) if tt.expectedQuit { assert.Equal(t, quitDone, m.modelQuitState) assert.True(t, IsTeaQuit(cmd)) assert.FileExists(t, tt.chooserFile) data, err := os.ReadFile(tt.chooserFile) require.NoError(t, err) assert.Equal(t, tt.expectedContent, string(data)) } else { assert.Equal(t, notQuitting, m.modelQuitState) assert.False(t, IsTeaQuit(cmd)) } }) } } func eventuallyEnsurePreviewContent(t *testing.T, m *model, content string, msgAndArgs ...any) { contains := false assert.Eventually(t, func() bool { contains = strings.Contains(m.fileModel.FilePreview.GetContent(), content) return contains }, DefaultTestTimeout, DefaultTestTick, msgAndArgs...) if !contains { pContent := ansi.Strip(m.fileModel.FilePreview.GetContent()) pContent = pContent[:min(len(pContent), 20)] t.Logf("%s was not found in '%s'", content, pContent) } } func TestAsyncPreviewPanelSync(t *testing.T) { curTestDir := t.TempDir() originalPreviewWidth := common.Config.FilePreviewWidth common.Config.FilePreviewWidth = 0 t.Cleanup(func() { common.Config.FilePreviewWidth = originalPreviewWidth }) file1, content1 := filepath.Join(curTestDir, "file1.txt"), "File 1 content" file2, content2 := filepath.Join(curTestDir, "file2.txt"), "File 2 content" utils.SetupFilesWithData(t, []byte(content1), file1) utils.SetupFilesWithData(t, []byte(content2), file2) m := defaultTestModelWithFilePreview(curTestDir) p := NewTestTeaProgWithEventLoop(t, m) // We need to send message via event loop to ensure that preview load command // is actually processed, also we want a size bigger than default // to allow more number of panels p.Send(tea.WindowSizeMsg{Width: 4 * DefaultTestModelWidth, Height: 4 * DefaultTestModelHeight}) eventuallyEnsurePreviewContent(t, m, content1, "file1 content should load initially") pW := m.fileModel.FilePreview.GetContentWidth() // Create two panels splitPanelAsync(p) splitPanelAsync(p) eventuallyEnsurePreviewContent(t, m, content1, "file1 content should reload after new panel") assert.NotEqual(t, pW, m.fileModel.FilePreview.GetContentWidth(), "width should change on new panel creation") p.Send(tea.KeyMsg{Type: tea.KeyDown}) t.Logf("Current element : %s", m.getFocusedFilePanel().GetFocusedItem().Location) eventuallyEnsurePreviewContent(t, m, content2, "content should update to file2") p.SendKey(common.Hotkeys.CloseFilePanel[0]) eventuallyEnsurePreviewContent(t, m, content1, "content should update to file1 after closing panel") // Upscale p.Send(tea.WindowSizeMsg{Width: 8 * DefaultTestModelWidth, Height: 8 * DefaultTestModelHeight}) eventuallyEnsurePreviewContent(t, m, content1, "content should update to file1 after resize") // Downscale p.Send(tea.WindowSizeMsg{Width: 6 * DefaultTestModelWidth, Height: 6 * DefaultTestModelHeight}) eventuallyEnsurePreviewContent(t, m, content1, "content should update to file1 after resize") } ================================================ FILE: src/internal/model_zoxide_test.go ================================================ package internal import ( "path/filepath" "runtime" "testing" tea "github.com/charmbracelet/bubbletea" zoxidelib "github.com/lazysegtree/go-zoxide" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/common" ) func setupProgAndOpenZoxide(t *testing.T, zClient *zoxidelib.Client, dir string) *TeaProg { t.Helper() common.Config.ZoxideSupport = true m := defaultTestModelWithZClient(zClient, dir) p := NewTestTeaProgWithEventLoop(t, m) p.SendKey(common.Hotkeys.OpenZoxide[0]) assert.Eventually(t, func() bool { return p.getModel().zoxideModal.IsOpen() }, DefaultTestTimeout, DefaultTestTick, "Zoxide modal should open") return p } func updateCurrentFilePanelDirOfTestModel(t *testing.T, p *TeaProg, dir string) { err := p.getModel().updateCurrentFilePanelDir(dir) require.NoError(t, err, "Failed to navigate to %s", dir) assert.Equal(t, dir, p.getModel().getFocusedFilePanel().Location, "Should be in %s after navigation", dir) } func TestZoxide(t *testing.T) { zoxideDataDir := t.TempDir() zClient, err := zoxidelib.New(zoxidelib.WithDataDir(zoxideDataDir)) if err != nil { if runtime.GOOS != utils.OsLinux { t.Skipf("Skipping zoxide tests in non-Linux because zoxide client cannot be initialized") } else { t.Fatalf("zoxide initialization failed") } } originalZoxideSupport := common.Config.ZoxideSupport defer func() { common.Config.ZoxideSupport = originalZoxideSupport }() curTestDir := filepath.Join(testDir, "TestZoxide") dir1 := filepath.Join(curTestDir, "dir1") dir2 := filepath.Join(curTestDir, "dir2") dir3 := filepath.Join(curTestDir, "dir3") multiSpaceDir := filepath.Join(curTestDir, "test dir") utils.SetupDirectories(t, curTestDir, dir1, dir2, dir3, multiSpaceDir) t.Run("Zoxide tracking and navigation", func(t *testing.T) { p := setupProgAndOpenZoxide(t, zClient, dir1) updateCurrentFilePanelDirOfTestModel(t, p, dir2) updateCurrentFilePanelDirOfTestModel(t, p, dir3) p.SendKey("dir2") assert.Eventually(t, func() bool { results := p.getModel().zoxideModal.GetResults() return len(results) == 1 && results[0].Path == dir2 }, DefaultTestTimeout, DefaultTestTick, "dir2 should be found by zoxide UI search") // Press enter to navigate to dir2 p.SendKey(common.Hotkeys.ConfirmTyping[0]) // Wait for both modal to close AND location to change to avoid race condition assert.Eventually(t, func() bool { return !p.getModel().zoxideModal.IsOpen() && p.getModel().getFocusedFilePanel().Location == dir2 }, DefaultTestTimeout, DefaultTestTick, "Zoxide modal should close and navigate to %s (current location: %s)", dir2, p.getModel().getFocusedFilePanel().Location) }) t.Run("Zoxide disabled shows no results", func(t *testing.T) { common.Config.ZoxideSupport = false m := defaultTestModelWithZClient(zClient, dir1) TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.OpenZoxide[0])) assert.True(t, m.zoxideModal.IsOpen(), "Zoxide modal should open even when ZoxideSupport is disabled") results := m.zoxideModal.GetResults() assert.Empty(t, results, "Zoxide modal should show no results when ZoxideSupport is disabled") }) t.Run("Zoxide modal size on window resize", func(t *testing.T) { p := setupProgAndOpenZoxide(t, zClient, dir1) initialWidth := p.getModel().zoxideModal.GetWidth() initialMaxHeight := p.getModel().zoxideModal.GetMaxHeight() p.SendDirectly(tea.WindowSizeMsg{Width: 2 * DefaultTestModelWidth, Height: 2 * DefaultTestModelHeight}) updatedWidth := p.getModel().zoxideModal.GetWidth() updatedMaxHeight := p.getModel().zoxideModal.GetMaxHeight() assert.Greater(t, updatedWidth, initialWidth, "Width should increase with larger window") assert.Greater(t, updatedMaxHeight, initialMaxHeight, "MaxHeight should increase with larger window") }) t.Run("Zoxide 'z' key suppression on open", func(t *testing.T) { p := setupProgAndOpenZoxide(t, zClient, dir1) assert.Empty(t, p.getModel().zoxideModal.GetTextInputValue(), "The 'z' key should not be added to textInput") p.SendKeyDirectly("abc") assert.Equal(t, "abc", p.getModel().zoxideModal.GetTextInputValue()) }) t.Run("Multi-space directory name navigation", func(t *testing.T) { p := setupProgAndOpenZoxide(t, zClient, dir1) updateCurrentFilePanelDirOfTestModel(t, p, multiSpaceDir) updateCurrentFilePanelDirOfTestModel(t, p, dir1) p.SendKey(filepath.Base(multiSpaceDir)) assert.Eventually(t, func() bool { results := p.getModel().zoxideModal.GetResults() for _, result := range results { if result.Path == multiSpaceDir { return true } } return false }, DefaultTestTimeout, DefaultTestTick, "Multi-space directory should be found by zoxide") // Reset textinput via Close-Open p.SendKey(common.Hotkeys.Quit[0]) p.SendKey(common.Hotkeys.OpenZoxide[0]) p.SendKey("di r 1") assert.Eventually(t, func() bool { results := p.getModel().zoxideModal.GetResults() for _, result := range results { if result.Path == dir1 { return true } } return false }, DefaultTestTimeout, DefaultTestTick, "dir1 should be found by zoxide") }) t.Run("Zoxide escape key closes modal", func(t *testing.T) { p := setupProgAndOpenZoxide(t, zClient, dir1) p.SendKeyDirectly(common.Hotkeys.CancelTyping[0]) assert.False(t, p.getModel().zoxideModal.IsOpen(), "Zoxide modal should close on escape key") }) } ================================================ FILE: src/internal/test_utils.go ================================================ package internal import ( "os" "path/filepath" "testing" "time" tea "github.com/charmbracelet/bubbletea" zoxidelib "github.com/lazysegtree/go-zoxide" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/ui/filepanel" "github.com/yorukot/superfile/src/internal/common" ) const DefaultTestTick = 10 * time.Millisecond const DefaultTestTimeout = time.Second const DefaultTestModelWidth = 2 * common.MinimumWidth const DefaultTestModelHeight = 2 * common.MinimumHeight // -------------------- Model setup utils func defaultTestModel(dirs ...string) *model { m := defaultModelConfig(false, false, false, dirs, nil) return setModelParamsForTest(m, true) } // TODO: Change this to a better API. passing opts // WithZClient(), WithFooter() func defaultTestModelWithZClient(zClient *zoxidelib.Client, dirs ...string) *model { m := defaultModelConfig(false, false, false, dirs, zClient) return setModelParamsForTest(m, true) } func defaultTestModelWithFooterAndFilePreview(dirs ...string) *model { m := defaultModelConfig(false, true, false, dirs, nil) return setModelParamsForTest(m, false) } func defaultTestModelWithFilePreview(dirs ...string) *model { m := defaultModelConfig(false, false, false, dirs, nil) return setModelParamsForTest(m, false) } func setModelParamsForTest(m *model, disablePreview bool) *model { m.disableMetadata = true if disablePreview { m.fileModel.FilePreview.Close() } // async size updates like preview panel content update // will not be done TeaUpdate(m, tea.WindowSizeMsg{Width: DefaultTestModelWidth, Height: DefaultTestModelHeight}) return m } // Helper function to setup panel mode and selection func setupPanelModeAndSelection(t *testing.T, m *model, useSelectMode bool, itemName string, selectedItems []string) { t.Helper() panel := m.getFocusedFilePanel() if useSelectMode { // Switch to select mode and set selected items m.getFocusedFilePanel().ChangeFilePanelMode() require.Equal(t, filepanel.SelectMode, panel.PanelMode) for _, item := range selectedItems { panel.SetSelected(item) } } else { // Find the item in browser mode setFilePanelSelectedItemByName(t, panel, itemName) } } // -------------------- Bubletea utilities // TODO : Should we validate that returned value is of type *model ? // and equal to m ? We are assuming that to be true as of now func TeaUpdate(m *model, msg tea.Msg) tea.Cmd { _, cmd := m.Update(msg) return cmd } // Is the command tea.quit, or a batch that contains tea.quit func IsTeaQuit(cmd tea.Cmd) bool { if cmd == nil { return false } // Ignore commands with longer IO Operations, which waits on a channel msg := ExecuteTeaCmdWithTimeout(cmd, time.Millisecond) switch msg := msg.(type) { case tea.QuitMsg: return true case tea.BatchMsg: for _, curCmd := range msg { if IsTeaQuit(curCmd) { return true } } return false default: return false } } func ExecuteTeaCmdWithTimeout(cmd tea.Cmd, timeout time.Duration) tea.Msg { result := make(chan tea.Msg, 1) go func() { result <- cmd() }() select { case msg := <-result: return msg case <-time.After(timeout): return nil } } // Helper function to perform copy or cut operation func performCopyOrCutOperation(t *testing.T, m *model, isCut bool) { t.Helper() if isCut { TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.CutItems[0])) } else { TeaUpdate(m, utils.TeaRuneKeyMsg(common.Hotkeys.CopyItems[0])) } } // -------------- Validation Utilities // Helper function to verify clipboard state after copy/cut func verifyClipboardState(t *testing.T, m *model, isCut bool, useSelectMode bool, selectedItemsCount int) { t.Helper() assert.Equal(t, isCut, m.clipboard.IsCut(), "Clipboard cut state should match operation") if useSelectMode { assert.Len(t, m.clipboard.GetItems(), selectedItemsCount, "Clipboard should contain all selected items") } else { assert.Len(t, m.clipboard.GetItems(), 1, "Clipboard should contain one item") } } // Helper function to verify file or directory exists func verifyPathExists(t *testing.T, path, message string) { t.Helper() info, err := os.Stat(path) require.NoError(t, err, message) if info.IsDir() { assert.DirExists(t, path, message) } else { assert.FileExists(t, path, message) } } // Helper function to verify file or directory doesn't exist after cut func verifyPathNotExistsEventually(t *testing.T, path, message string) { t.Helper() assert.Eventually(t, func() bool { _, err := os.Stat(path) return os.IsNotExist(err) }, DefaultTestTimeout, DefaultTestTick, message) } // Helper function to verify expected destination files exist func verifyDestinationFiles(t *testing.T, targetDir string, expectedDestFiles []string) { t.Helper() for _, expectedFile := range expectedDestFiles { destPath := filepath.Join(targetDir, expectedFile) assert.Eventually(t, func() bool { _, err := os.Stat(destPath) return err == nil }, DefaultTestTimeout, DefaultTestTick, "%s should exist in destination", expectedFile) } } // Helper function to verify prevented paste results func verifyPreventedPasteResults(t *testing.T, m *model, originalPath string) { t.Helper() if originalPath != "" { verifyPathExists(t, originalPath, "Original file should still exist when paste is prevented") } // Clipboard should not be cleared when paste is prevented assert.NotEmpty(t, m.clipboard.GetItems(), "Clipboard should not be cleared when paste is prevented") } // Helper function to verify successful paste results func verifySuccessfulPasteResults(t *testing.T, targetDir string, expectedDestFiles []string, originalPath string, shouldOriginalExist bool) { t.Helper() // Verify expected files were created in destination verifyDestinationFiles(t, targetDir, expectedDestFiles) // Verify original file existence based on operation type if originalPath != "" { if shouldOriginalExist { verifyPathExists(t, originalPath, "Original file should exist after copy operation") } else { verifyPathNotExistsEventually(t, originalPath, "Original file should not exist after cut operation") } } } // -------------- Other utilities // Helper function to navigate to target directory if different from start func navigateToTargetDir(t *testing.T, m *model, startDir, targetDir string) { t.Helper() if targetDir != startDir { err := m.updateCurrentFilePanelDir(targetDir) require.NoError(t, err) TeaUpdate(m, nil) } } // Helper function to get original path for existence check func getOriginalPath(useSelectMode bool, itemName, startDir string) string { if !useSelectMode && itemName != "" { return filepath.Join(startDir, itemName) } return "" } func setFilePanelSelectedItemByLocation(t *testing.T, panel *filepanel.Model, filePath string) { t.Helper() idx := panel.FindElementIndexByLocation(filePath) require.NotEqual(t, -1, idx, "%s should be found in panel", filePath) panel.SetCursorPosition(idx) } func setFilePanelSelectedItemByName(t *testing.T, panel *filepanel.Model, fileName string) { t.Helper() idx := panel.FindElementIndexByName(fileName) require.NotEqual(t, -1, idx, "%s should be found in panel", fileName) panel.SetCursorPosition(idx) } func splitPanelAsync(p *TeaProg) { p.SendKey(common.Hotkeys.OpenSPFPrompt[0]) p.SendKey("split") p.Send(tea.KeyMsg{Type: tea.KeyEnter}) p.Send(tea.KeyMsg{Type: tea.KeyEsc}) } ================================================ FILE: src/internal/test_utils_teaprog.go ================================================ package internal import ( "errors" "log/slog" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/yorukot/superfile/src/pkg/utils" ) type IgnorerWriter struct{} func (w IgnorerWriter) Write(p []byte) (int, error) { return len(p), nil } type TeaProg struct { m *model prog *tea.Program } // If you use this, make sure to handle cleanup func NewTeaProg(m *model, eventLoop bool) *TeaProg { p := &TeaProg{m: m, prog: tea.NewProgram(m, tea.WithInput(nil), tea.WithOutput(IgnorerWriter{}))} if eventLoop { p.StartEventLoop() } return p } func NewTestTeaProgWithEventLoop(t *testing.T, m *model) *TeaProg { p := NewTeaProg(m, true) t.Cleanup(func() { p.Close() }) return p } func (p *TeaProg) getModel() *model { return p.m } func (p *TeaProg) StartEventLoop() { go func() { _, err := p.prog.Run() // This will return only after Run() is completed // This will not be error if its due to p.Close() being called if err != nil && !errors.Is(err, tea.ErrProgramKilled) { slog.Error("TeaProg finished with error", "error", err) } }() // Send nil to block for start of event loop p.prog.Send(nil) } func (p *TeaProg) Send(msgs ...tea.Msg) { for _, msg := range msgs { p.prog.Send(msg) } } func (p *TeaProg) SendKey(key string) { p.Send(utils.TeaRuneKeyMsg(key)) } // Dont use eventloop and dont care about the tea.Cmd returned by Update() func (p *TeaProg) SendDirectly(msgs ...tea.Msg) tea.Cmd { cmds := make([]tea.Cmd, len(msgs)) for i, msg := range msgs { var retModel tea.Model retModel, cmds[i] = p.m.Update(msg) if m, ok := retModel.(*model); ok { p.m = m } else { // This should never happen as we return *model on Update() panic("model is not of type *model") } } return tea.Batch(cmds...) } func (p *TeaProg) SendKeyDirectly(key string) tea.Cmd { return p.SendDirectly(utils.TeaRuneKeyMsg(key)) } func (p *TeaProg) Close() { p.prog.Kill() } ================================================ FILE: src/internal/type.go ================================================ package internal import ( zoxidelib "github.com/lazysegtree/go-zoxide" "github.com/yorukot/superfile/src/internal/ui/helpmenu" "github.com/yorukot/superfile/src/internal/ui/clipboard" "github.com/yorukot/superfile/src/internal/ui/sortmodel" "github.com/yorukot/superfile/src/internal/ui/filemodel" "github.com/yorukot/superfile/src/internal/ui/metadata" "github.com/yorukot/superfile/src/internal/ui/notify" "github.com/yorukot/superfile/src/internal/ui/processbar" "github.com/yorukot/superfile/src/internal/ui/sidebar" "github.com/charmbracelet/bubbles/textinput" "github.com/yorukot/superfile/src/internal/ui/prompt" zoxideui "github.com/yorukot/superfile/src/internal/ui/zoxide" ) // Type representing the type of focused panel type focusPanelType int type modelQuitStateType int // Constants for panel with no focus const ( nonePanelFocus focusPanelType = iota processBarFocus sidebarFocus metadataFocus ) const ( notQuitting modelQuitStateType = iota quitInitiated quitConfirmationInitiated quitConfirmationReceived quitDone ) // Main model // TODO : We could consider using *model as tea.Model, instead of model. // for reducing re-allocations. The struct is 20K bytes. But this could lead to // issues like race conditions and whatnot, which are hidden since we are creating // new model in each tea update. type model struct { // Main Panels fileModel filemodel.Model sidebarModel sidebar.Model processBarModel processbar.Model clipboard clipboard.Model focusPanel focusPanelType // Modals notifyModel notify.Model typingModal typingModal helpMenu helpmenu.Model promptModal prompt.Model zoxideModal zoxideui.Model sortModal sortmodel.Model // Zoxide client for directory tracking zClient *zoxidelib.Client fileMetaData metadata.Model ioReqCnt int modelQuitState modelQuitStateType firstTextInput bool toggleFooter bool firstLoadingComplete bool firstUse bool // This entirely disables metadata fetching. Used in test model disableMetadata bool // Height in number of lines of actual viewport of // main panel and sidebar excluding border mainPanelHeight int // Height in number of lines of actual viewport of // footer panels - process/metadata/clipboard - excluding border footerHeight int fullWidth int fullHeight int // whether usable trash directory exists or not hasTrash bool } type typingModal struct { location string open bool textInput textinput.Model errorMesssage string } type editorFinishedMsg struct{ err error } ================================================ FILE: src/internal/type_utils.go ================================================ package internal import ( "github.com/yorukot/superfile/src/internal/common" ) // ================ String method for easy logging ===================== func (f focusPanelType) String() string { switch f { case nonePanelFocus: return "nonePanelFocus" case processBarFocus: return "processBarFocus" case sidebarFocus: return "sidebarFocus" case metadataFocus: return "metadataFocus" default: return common.InvalidTypeString } } ================================================ FILE: src/internal/ui/README.md ================================================ # ui package # To-dos - Put model, filePanel, sidebarModel, etc. in separate packages like this ================================================ FILE: src/internal/ui/clipboard/model.go ================================================ package clipboard import ( "log/slog" "os" "slices" "strconv" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui" ) // The fact that its visible in UI or not, is controlled by the main model type Model struct { width int height int items copyItems } // Copied items type copyItems struct { items []string cut bool } func (m *Model) SetDimensions(width int, height int) { m.width = width m.height = height } func (m *Model) Render() string { r := ui.ClipboardRenderer(m.height, m.width) viewHeight := m.height - common.BorderPadding viewWidth := m.width - common.InnerPadding if len(m.items.items) == 0 { // TODO move this to a string r.AddLines("", common.ClipboardNoneText) } else { for i := 0; i < len(m.items.items) && i < viewHeight; i++ { if i == viewHeight-1 && i != len(m.items.items)-1 { // Last Entry we can render, but there are more that one left r.AddLines(strconv.Itoa(len(m.items.items)-i) + " items left....") } else { // TODO: Avoid Lstat during render for performance // Add IsDir/IsLink information in the item type or // better use filepanel's Element strcut as-is fileInfo, err := os.Lstat(m.items.items[i]) if err != nil { slog.Error("Clipboard render function get item state ", "error", err) continue } isLink := fileInfo.Mode()&os.ModeSymlink != 0 r.AddLines(common.ClipboardPrettierName(m.items.items[i], viewWidth, fileInfo.IsDir(), isLink, false)) } } } return r.Render() } func (m *Model) IsCut() bool { return m.items.cut } func (m *Model) Reset(cut bool) { m.items.cut = cut m.items.items = m.items.items[:0] } func (m *Model) Add(location string) { m.items.items = append(m.items.items, location) } func (m *Model) SetItems(items []string) { m.items.items = make([]string, len(items)) copy(m.items.items, items) } func (m *Model) pruneInaccessibleItems() { m.items.items = slices.DeleteFunc(m.items.items, func(item string) bool { _, err := os.Lstat(item) return err != nil }) } func (m *Model) GetItems() []string { // return a copy to prevent external mutation items := make([]string, len(m.items.items)) copy(items, m.items.items) return items } // Use this to use a copy that is in sync with current state of filesystem func (m *Model) PruneInaccessibleItemsAndGet() []string { // Clipboard items might becomes outdated with // externally/interally triggered changes m.pruneInaccessibleItems() return m.GetItems() } func (m *Model) Len() int { return len(m.items.items) } func (m *Model) GetWidth() int { return m.width } func (m *Model) GetHeight() int { return m.height } func (m *Model) GetFirstItem() string { if len(m.items.items) == 0 { return "" } return m.items.items[0] } ================================================ FILE: src/internal/ui/clipboard/model_test.go ================================================ package clipboard import ( "flag" "os" "path/filepath" "strconv" "testing" "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/config/icon" "github.com/yorukot/superfile/src/internal/common" ) func TestMain(m *testing.M) { //nolint:reassign // Needed to tests common.ClipboardNoneText = " " + icon.Error + icon.Space + " No content in clipboard" flag.Parse() if testing.Verbose() { utils.SetRootLoggerToStdout(true) } else { utils.SetRootLoggerToDiscarded() } m.Run() } func TestClipboardRender_Empty(t *testing.T) { dir := t.TempDir() var items []string for i := range 5 { fp := filepath.Join(dir, "f"+strconv.Itoa(i)+".txt") items = append(items, fp) } m := &Model{} m.SetDimensions(15+len(items[0]), 6) t.Run("Empty", func(t *testing.T) { out := ansi.Strip(m.Render()) assert.Contains(t, out, common.ClipboardNoneText) }) utils.CreateFiles(items[0]) t.Run("Single Item", func(t *testing.T) { m.SetItems([]string{items[0]}) out := ansi.Strip(m.Render()) assert.NotContains(t, out, common.ClipboardNoneText) assert.Contains(t, out, items[0]) assert.NotContains(t, out, items[1]) }) utils.CreateFiles(items[1]) t.Run("Only two items exist, rest don't", func(t *testing.T) { m.SetItems(items) out := ansi.Strip(m.Render()) assert.NotContains(t, out, common.ClipboardNoneText) assert.Contains(t, out, items[0]) assert.Contains(t, out, items[1]) for i := 2; i < 5; i++ { assert.NotContains(t, out, items[i]) } }) utils.CreateFiles(items[2:]...) t.Run("Overflow", func(t *testing.T) { m.SetItems(items) out := ansi.Strip(m.Render()) assert.NotContains(t, out, common.ClipboardNoneText) for i := range 3 { assert.Contains(t, out, items[i]) } assert.Contains(t, out, "2 items left....", "expected overflow indicator in render") }) } func TestPruneInaccessibleItemsAndGet(t *testing.T) { dir := t.TempDir() files := []string{filepath.Join(dir, "f1"), filepath.Join(dir, "f2")} utils.SetupFiles(t, files...) m := &Model{} m.SetItems(files) assert.Equal(t, files, m.PruneInaccessibleItemsAndGet()) require.NoError(t, os.Remove(files[1])) assert.Equal(t, []string{files[0]}, m.PruneInaccessibleItemsAndGet()) } ================================================ FILE: src/internal/ui/filemodel/consts.go ================================================ package filemodel import ( "errors" "github.com/yorukot/superfile/src/internal/ui/filepanel" ) // Now they are same doesn't means that they will be forever. // Explicitly stating here tells that they are derived from same // source, but have inherently different meaning const ( FileModelMinHeight = filepanel.MinHeight FileModelMinWidth = filepanel.MinWidth FilePreviewResizingText = "Resizing..." FilePreviewLoadingText = "Loading..." ) var ErrMaximumPanelCount = errors.New("maximum panel count reached") var ErrMinimumPanelCount = errors.New("minimum panel count reached") ================================================ FILE: src/internal/ui/filemodel/dimensions.go ================================================ package filemodel import ( "log/slog" tea "github.com/charmbracelet/bubbletea" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui/filepanel" ) // Use SetDimensions if you want to update both // it will prevent duplicate file preview commands and hence, is efficient func (m *Model) SetDimensions(width int, height int) tea.Cmd { m.Height = max(height, FileModelMinHeight) m.Width = max(width, FileModelMinWidth) m.updateChildComponentWidth() m.updateChildComponentHeight() return m.ensurePreviewDimensionsSync() } func (m *Model) SetHeight(height int) tea.Cmd { m.Height = max(height, FileModelMinHeight) m.updateChildComponentHeight() return m.ensurePreviewDimensionsSync() } func (m *Model) SetWidth(width int) tea.Cmd { m.Width = max(width, FileModelMinWidth) m.updateChildComponentWidth() return m.ensurePreviewDimensionsSync() } func (m *Model) PanelCount() int { return len(m.FilePanels) } func (m *Model) updateChildComponentHeight() { for i := range m.FilePanels { m.FilePanels[i].SetHeight(m.Height) } } func (m *Model) updateChildComponentWidth() { // TODO: programatically ensure that this becomes impossible if m.PanelCount() == 0 { slog.Error("Unexpected error: fileModel with 0 panels") return } panelCount := len(m.FilePanels) widthForPanels := m.Width if m.FilePreview.IsOpen() { // Need to give some width to preview if common.Config.FilePreviewWidth == 0 { // FileModel will be split among `panelCount+1` m.ExpectedPreviewWidth = m.Width / (panelCount + 1) } else { m.ExpectedPreviewWidth = m.Width / common.Config.FilePreviewWidth } widthForPanels -= m.ExpectedPreviewWidth } panelWidth := widthForPanels / panelCount lastPanelWidth := widthForPanels - (panelCount-1)*panelWidth for i := range panelCount { if i == panelCount-1 { m.FilePanels[i].SetWidth(lastPanelWidth) } else { m.FilePanels[i].SetWidth(panelWidth) } } m.SinglePanelWidth = panelWidth m.MaxFilePanel = widthForPanels / filepanel.MinWidth // Cap at the system maximum if m.MaxFilePanel > common.FilePanelMax { m.MaxFilePanel = common.FilePanelMax } } func (m *Model) ensurePreviewDimensionsSync() tea.Cmd { if m.FilePreview.GetContentWidth() != m.ExpectedPreviewWidth || m.FilePreview.GetContentHeight() != m.Height { return m.GetFilePreviewCmd(true) } return nil } ================================================ FILE: src/internal/ui/filemodel/navigation.go ================================================ package filemodel import "log/slog" func (m *Model) NextFilePanel() { m.MoveFocusedPanelBy(1) } func (m *Model) PreviousFilePanel() { m.MoveFocusedPanelBy(-1) } func (m *Model) MoveFocusedPanelBy(delta int) { if m.PanelCount() == 0 { slog.Error("Unexpected error: fileModel with 0 panels") return } m.GetFocusedFilePanel().IsFocused = false m.FocusedPanelIndex = (m.FocusedPanelIndex + delta + m.PanelCount()) % m.PanelCount() m.FilePanels[m.FocusedPanelIndex].IsFocused = true } ================================================ FILE: src/internal/ui/filemodel/render.go ================================================ package filemodel import "github.com/charmbracelet/lipgloss" func (m *Model) Render() string { f := make([]string, m.PanelCount()+1) for i, filePanel := range m.FilePanels { f[i] = filePanel.Render(filePanel.IsFocused) } f[m.PanelCount()] = m.GetFilePreviewRender() return lipgloss.JoinHorizontal(lipgloss.Top, f...) } func (m *Model) GetFilePreviewRender() string { if !m.FilePreview.IsOpen() { return "" } // Check if width and height have been synced yet if m.FilePreview.GetContentHeight() == m.Height && m.FilePreview.GetContentWidth() == m.ExpectedPreviewWidth { if m.FilePreview.IsLoading() { return m.FilePreview.RenderText(FilePreviewLoadingText) } return m.FilePreview.GetContent() } // Placeholder resizing text till they get synced return m.FilePreview.RenderTextWithDimension( FilePreviewResizingText, m.Height, m.ExpectedPreviewWidth) } ================================================ FILE: src/internal/ui/filemodel/type.go ================================================ package filemodel import ( "github.com/yorukot/superfile/src/internal/ui/filepanel" "github.com/yorukot/superfile/src/internal/ui/preview" ) // TODO: Make the fields unexported, as much as possible // some fields like `Width` should not be updated directly, only via // Set functions. Having them exported is dangerous type Model struct { FilePanels []filepanel.Model SinglePanelWidth int Width int ExpectedPreviewWidth int Height int Renaming bool MaxFilePanel int FilePreview preview.Model FocusedPanelIndex int ioReqCnt int DisplayDotFiles bool } ================================================ FILE: src/internal/ui/filemodel/update.go ================================================ package filemodel import ( "fmt" "log/slog" "os" tea "github.com/charmbracelet/bubbletea" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui/filepanel" "github.com/yorukot/superfile/src/internal/ui/preview" ) func (m *Model) CreateNewFilePanel(location string) (tea.Cmd, error) { if m.PanelCount() >= m.MaxFilePanel { return nil, ErrMaximumPanelCount } if _, err := os.Stat(location); err != nil { return nil, fmt.Errorf("cannot access location : %s", location) } m.FilePanels = append(m.FilePanels, filepanel.New( location, false, "", m.GetFocusedFilePanel().SortKind, m.GetFocusedFilePanel().SortReversed)) newPanelIndex := m.PanelCount() - 1 m.FilePanels[m.FocusedPanelIndex].IsFocused = false m.FilePanels[newPanelIndex].IsFocused = true m.FilePanels[newPanelIndex].SetHeight(m.Height) m.FocusedPanelIndex = newPanelIndex m.updateChildComponentWidth() return m.ensurePreviewDimensionsSync(), nil } func (m *Model) CloseFilePanel() (tea.Cmd, error) { if m.PanelCount() <= 1 { return nil, ErrMinimumPanelCount } m.FilePanels = append(m.FilePanels[:m.FocusedPanelIndex], m.FilePanels[m.FocusedPanelIndex+1:]...) if m.FocusedPanelIndex != 0 { m.FocusedPanelIndex-- } m.FilePanels[m.FocusedPanelIndex].IsFocused = true m.updateChildComponentWidth() return m.ensurePreviewDimensionsSync(), nil } func (m *Model) ToggleFilePreviewPanel() tea.Cmd { m.FilePreview.ToggleOpen() m.updateChildComponentWidth() return m.ensurePreviewDimensionsSync() } func (m *Model) UpdatePreviewPanel(msg preview.UpdateMsg) { selectedItem := m.GetFocusedFilePanel().GetFocusedItemPtr() if selectedItem == nil { slog.Debug("Panel empty or cursor invalid. Ignoring FilePreviewUpdateMsg") return } if selectedItem.Location != msg.GetLocation() { slog.Debug("FilePreviewUpdateMsg for older files. Ignoring", "curLocation", selectedItem.Location, "msgLocation", msg.GetLocation()) return } if m.ExpectedPreviewWidth != msg.GetContentWidth() || m.Height != msg.GetContentHeight() { slog.Debug("FilePreviewUpdateMsg for older dimensions. Ignoring", "curW", m.ExpectedPreviewWidth, "curH", m.Height, "msgW", msg.GetContentWidth(), "msgH", msg.GetContentHeight()) return } m.FilePreview.Apply(msg) } func (m *Model) GetFilePreviewCmd(forcePreviewRender bool) tea.Cmd { if !m.FilePreview.IsOpen() { return nil } panel := m.GetFocusedFilePanel() if panel.EmptyOrInvalid() { // Sync call because this will be fast m.FilePreview.SetEmptyWithDimensions(m.ExpectedPreviewWidth, m.Height) return nil } selectedItem := panel.GetFocusedItem() if m.FilePreview.GetLocation() == selectedItem.Location && !forcePreviewRender { return nil } m.FilePreview.SetLocation(selectedItem.Location) m.FilePreview.SetLoading() // HACK!!!. fileModel must not be aware of other dimensions. but... // Unfortunately, previewPanel isn't completely 'under' fileModel // Note: Must save the dimensions for the closure of the Cmd to avoid // problems fullModalWidth := m.Width + common.Config.SidebarWidth if common.Config.SidebarWidth != 0 { fullModalWidth += common.BorderPadding } width := m.ExpectedPreviewWidth height := m.Height reqCnt := m.ioReqCnt m.ioReqCnt++ slog.Debug("Submitting file preview render request", "id", reqCnt, "path", selectedItem.Location, "w", width, "h", height) return func() tea.Msg { content := m.FilePreview.RenderWithPath(selectedItem.Location, width, height, fullModalWidth) return preview.NewUpdateMsg(selectedItem.Location, content, width, height, reqCnt) } } func (m *Model) ToggleDotFile() { m.DisplayDotFiles = !m.DisplayDotFiles m.UpdateFilePanelsIfNeeded(true) } func (m *Model) UpdateFilePanelsIfNeeded(force bool) { for i := range m.FilePanels { m.FilePanels[i].UpdateElementsIfNeeded(force, m.DisplayDotFiles) } } ================================================ FILE: src/internal/ui/filemodel/utils.go ================================================ package filemodel import ( "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui/filepanel" "github.com/yorukot/superfile/src/internal/ui/preview" ) func (m *Model) GetFocusedFilePanel() *filepanel.Model { return &m.FilePanels[m.FocusedPanelIndex] } func New(firstPanelPaths []string, toggleDotFile bool) Model { return Model{ FilePanels: filepanel.FilePanelSlice(firstPanelPaths), FilePreview: preview.New(), SinglePanelWidth: common.DefaultFilePanelWidth, DisplayDotFiles: toggleDotFile, } } ================================================ FILE: src/internal/ui/filepanel/columns.go ================================================ package filepanel import ( "os" "strings" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/yorukot/superfile/src/config/icon" "github.com/yorukot/superfile/src/internal/common" ) // The renderer for the mandatory first column in the file panel, with a name, a cursor, and a select option. func (m *Model) renderFileName(indexElement int, columnWidth int) string { elem := m.GetElementAtIdx(indexElement) isSelected := m.CheckSelected(elem.Location) cursor := emptyCursor if indexElement == m.GetCursor() && !m.SearchBar.Focused() { cursor = icon.Cursor } selectBox := m.renderSelectBox(isSelected) // Calculate the actual prefix width for proper alignment prefixWidth := ansi.StringWidth(cursor+" ") + ansi.StringWidth(selectBox) isLink := elem.Info.Mode()&os.ModeSymlink != 0 renderedName := common.FilePanelItemRenderWithIcon( elem.Name, columnWidth-prefixWidth, elem.Directory, isLink, isSelected, common.FilePanelBGColor, ) return common.FilePanelCursorStyle.Render(cursor+" ") + selectBox + renderedName } // The renderer of delimiter spaces. It has a strict fixed size that depends only on the delimiter string. func (m *Model) renderDelimiter(indexElement int, columnWidth int) string { isSelected := m.CheckSelected(m.GetElementAtIdx(indexElement).Location) return common.FilePanelItemRender( ColumnDelimiter, columnWidth, isSelected, common.FilePanelBGColor, lipgloss.Left, ) } func (m *Model) renderFileSize(indexElement int, columnWidth int) string { elem := m.GetElementAtIdx(indexElement) isSelected := m.CheckSelected(elem.Location) sizeValue := common.FormatFileSize(elem.Info.Size()) if elem.Info.IsDir() { sizeValue = "" } return common.FilePanelItemRender( sizeValue, columnWidth, isSelected, common.FilePanelBGColor, lipgloss.Right, ) } // TODO: make time template configurable func (m *Model) renderModifyTime(indexElement int, columnWidth int) string { elem := m.GetElementAtIdx(indexElement) isSelected := m.CheckSelected(elem.Location) modifyTime := elem.Info.ModTime().Format("2006-01-02 15:04") return common.FilePanelItemRender( modifyTime, columnWidth, isSelected, common.FilePanelBGColor, lipgloss.Right, ) } func (m *Model) renderPermissions(indexElement int, columnWidth int) string { elem := m.GetElementAtIdx(indexElement) isSelected := m.CheckSelected(elem.Location) return common.FilePanelItemRender( elem.Info.Mode().Perm().String(), columnWidth, isSelected, common.FilePanelBGColor, lipgloss.Right, ) } func (cd *columnDefinition) Render(index int) string { return cd.columnRender(index, cd.Size) } func (cd *columnDefinition) RenderHeader() string { return common.FilePanelItemRender( cd.Name, cd.Size, false, common.FilePanelBGColor, cd.HeaderAlign, ) } func (m *Model) makeColumns(columnThreshold int, fileNameRatio int) []columnDefinition { // TODO: make column set configurable // Note: May use a predefined slice for efficiency. This content is static extraColumns := []columnDefinition{ { Name: "Size", columnRender: m.renderFileSize, Size: FileSizeColumnWidth, HeaderAlign: lipgloss.Center, }, { Name: "Modify time", columnRender: m.renderModifyTime, Size: ModifyTimeSizeColumnWidth, HeaderAlign: lipgloss.Center, }, { Name: "Permission", columnRender: m.renderPermissions, Size: PermissionsColumnWidth, HeaderAlign: lipgloss.Center, }, } maxColumns := min(columnThreshold, len(extraColumns)) columns := []columnDefinition{ { Name: strings.Repeat(" ", ansi.StringWidth(emptyCursor+" ")) + "Name", columnRender: m.renderFileName, Size: m.GetContentWidth(), HeaderAlign: lipgloss.Left, }, } minWidthForNameColumn := int(float64(m.GetContentWidth() * fileNameRatio / common.FileNameRatioMax)) // Worst case (5 * 100 / 100) could evaluate to 5.0001 // Hence, we need this check. Our constraints on Width and ratio guarantee it to be > 0 though minWidthForNameColumn = min(minWidthForNameColumn, m.GetContentWidth()) for _, col := range extraColumns[0:maxColumns] { widthExtraColumn := ansi.StringWidth(ColumnDelimiter) + col.Size // This condition checks that can we borrow some width from first column for additional columns? if columns[0].Size-widthExtraColumn > minWidthForNameColumn { delimiterCol := columnDefinition{ Name: "", columnRender: m.renderDelimiter, Size: ansi.StringWidth(ColumnDelimiter), } columns = append(columns, delimiterCol, col) columns[0].Size -= widthExtraColumn } else { break } } return columns } ================================================ FILE: src/internal/ui/filepanel/consts.go ================================================ package filepanel import ( "time" "github.com/yorukot/superfile/src/internal/common" ) const ( contentPadding = 3 // Title + Searchbar + middle border line MinHeight = contentPadding + common.BorderPadding + 1 MinWidth = 18 // minimal width for rename input to render FileSizeColumnWidth = 15 ModifyTimeSizeColumnWidth = 18 PermissionsColumnWidth = 12 ColumnHeaderHeight = 1 // Delimiter between columns in the file panel. ColumnDelimiter = " " ReRenderChunkDivisor = 100 ReRenderMaxDelay = 3 nonFocussedPanelReRenderTime = 3 * time.Second emptyCursor = " " ) ================================================ FILE: src/internal/ui/filepanel/dimension.go ================================================ package filepanel import ( "github.com/yorukot/superfile/src/internal/common" ) func (m *Model) UpdateDimensions(width, height int) { m.SetWidth(width) m.SetHeight(height) } func (m *Model) SetWidth(width int) { if width < MinWidth { width = MinWidth } m.width = width m.SearchBar.Width = m.width - common.InnerPadding m.columns = m.makeColumns(common.Config.FilePanelExtraColumns, common.Config.FilePanelNamePercent) } func (m *Model) SetHeight(height int) { if height < MinHeight { height = MinHeight } m.height = height // Adjust scroll if needed m.scrollToCursor(m.GetCursor()) } func (m *Model) GetWidth() int { return m.width } func (m *Model) GetHeight() int { return m.height } func (m *Model) GetMainPanelHeight() int { return m.height - common.BorderPadding } func (m *Model) GetContentWidth() int { return m.width - common.BorderPadding } func (m *Model) NeedRenderHeaders() bool { return common.Config.FilePanelExtraColumns > 0 && len(m.columns) > 1 } // PanelElementHeight calculates the number of visible elements in content area func (m *Model) PanelElementHeight() int { headerHeight := 0 if m.NeedRenderHeaders() { headerHeight = ColumnHeaderHeight } return m.GetMainPanelHeight() - contentPadding - headerHeight } ================================================ FILE: src/internal/ui/filepanel/get_elements.go ================================================ package filepanel import ( "log/slog" "os" "slices" "strings" "time" "github.com/yorukot/superfile/src/pkg/utils" ) // TODO : Take common.Config.CaseSensitiveSort as a function parameter // and also consider testing this caseSensitive with both true and false in // our unit_test TestReturnDirElement // getDirectoryElements returns the directory elements for the panel's current location func (m *Model) getDirectoryElements(displayDotFile bool) []Element { dirEntries, err := os.ReadDir(m.Location) if err != nil { slog.Error("Error while returning folder elements", "error", err) return nil } dirEntries = slices.DeleteFunc(dirEntries, func(e os.DirEntry) bool { // Entries not needed to be considered _, err := e.Info() return err != nil || (strings.HasPrefix(e.Name(), ".") && !displayDotFile) }) // No files/directories to process if len(dirEntries) == 0 { return nil } return sortFileElement(m.SortKind, m.SortReversed, dirEntries, m.Location) } // getDirectoryElementsBySearch returns filtered directory elements based on search string func (m *Model) getDirectoryElementsBySearch(displayDotFile bool) []Element { searchString := m.SearchBar.Value() items, err := os.ReadDir(m.Location) if err != nil { slog.Error("Error while return folder element function", "error", err) return nil } if len(items) == 0 { return nil } folderElementMap := map[string]os.DirEntry{} fileAndDirectories := []string{} for _, item := range items { fileInfo, err := item.Info() if err != nil { continue } if !displayDotFile && strings.HasPrefix(fileInfo.Name(), ".") { continue } fileAndDirectories = append(fileAndDirectories, item.Name()) folderElementMap[item.Name()] = item } // https://github.com/reinhrst/fzf-lib/blob/main/core.go#L43 // fzf returns matches ordered by score; we subsequently sort by the chosen sort option. fzfResults := utils.FzfSearch(searchString, fileAndDirectories) dirElements := make([]os.DirEntry, 0, len(fzfResults)) for _, item := range fzfResults { resultItem := folderElementMap[item.Key] dirElements = append(dirElements, resultItem) } return sortFileElement(m.SortKind, m.SortReversed, dirElements, m.Location) } // Helper to decide whether to skip updating a panel this tick. func (m *Model) shouldSkipPanelUpdate(nowTime time.Time) bool { if !m.IsFocused { return nowTime.Sub(m.LastTimeGetElement) < nonFocussedPanelReRenderTime } reRenderTime := int(float64(m.ElemCount()) / ReRenderChunkDivisor) reRenderTime = min(reRenderTime, ReRenderMaxDelay) return !m.NeedsReRender() && nowTime.Sub(m.LastTimeGetElement) < time.Duration(reRenderTime)*time.Second } func (m *Model) UpdateElementsIfNeeded(force bool, displayDotFile bool) { nowTime := time.Now() if force || !m.shouldSkipPanelUpdate(nowTime) { // Load elements for this panel (with/without search filter) m.element = m.getElements(displayDotFile) // Update file panel list m.LastTimeGetElement = nowTime // For hover to file on first time loading if m.TargetFile != "" { m.applyTargetFileCursor() } // If cursor becomes invalid due to element update, reset if m.ValidateCursorAndRenderIndex() != nil { m.scrollToCursor(0) } } } // Retrieves elements for a panel based on search bar value and sort options. func (m *Model) getElements(displayDotFile bool) []Element { if m.SearchBar.Value() != "" { return m.getDirectoryElementsBySearch(displayDotFile) } return m.getDirectoryElements(displayDotFile) } ================================================ FILE: src/internal/ui/filepanel/get_elements_test.go ================================================ package filepanel import ( "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/ui/sortmodel" ) func TestReturnDirElement(t *testing.T) { curTestDir := t.TempDir() dir1 := filepath.Join(curTestDir, "dir1") dir2 := filepath.Join(curTestDir, "dir2") dirNatural := filepath.Join(curTestDir, "dirNatural") utils.SetupDirectories(t, curTestDir, dir1, dir2, dirNatural) creationDelay := time.Millisecond * 5 // Cleanup is handled by TestMain // Setup files // All files with 10 bytes of text // dir1 // - file1.txt // dir2 (Empty) // .xyz // 1.json // abc - Add 15 bytes of text // aBcD // file1.txt // file2.txt - Add 20 bytes of text // xyz.json fileSetup := []struct { path string data []byte }{ {filepath.Join(curTestDir, ".xyz"), []byte("0123456789")}, {filepath.Join(dir1, "file1.txt"), []byte("0123456789")}, {filepath.Join(curTestDir, "aBcD"), []byte("0123456789")}, {filepath.Join(curTestDir, "file1.txt"), []byte("0123456789")}, {filepath.Join(curTestDir, "xyz.json"), []byte("0123456789")}, {filepath.Join(curTestDir, "abc"), []byte("012345678901234")}, {filepath.Join(curTestDir, "file2.txt"), []byte("01234567890123456789")}, {filepath.Join(curTestDir, "1.json"), []byte("0123456789")}, {filepath.Join(dirNatural, "file1.txt"), []byte("a")}, {filepath.Join(dirNatural, "file2.txt"), []byte("b")}, {filepath.Join(dirNatural, "file10.txt"), []byte("c")}, {filepath.Join(dirNatural, "file20.txt"), []byte("d")}, } for _, f := range fileSetup { utils.SetupFilesWithData(t, f.data, f.path) time.Sleep(creationDelay) } testdata := []struct { name string location string dotFiles bool sortKind sortmodel.SortKind reversed bool searchString string expectedElemNames []string }{ { name: "Empty Directory", location: dir2, dotFiles: false, sortKind: sortmodel.SortByName, reversed: false, expectedElemNames: []string{}, }, { name: "Sort by Name", location: curTestDir, dotFiles: false, sortKind: sortmodel.SortByName, reversed: false, expectedElemNames: []string{"dir1", "dir2", "dirNatural", "1.json", "abc", "aBcD", "file1.txt", "file2.txt", "xyz.json"}, }, { name: "Sort by Name, with dotfiles", location: curTestDir, dotFiles: true, sortKind: sortmodel.SortByName, reversed: false, expectedElemNames: []string{"dir1", "dir2", "dirNatural", ".xyz", "1.json", "abc", "aBcD", "file1.txt", "file2.txt", "xyz.json"}, }, { name: "Sort by Name Reversed", location: curTestDir, dotFiles: false, sortKind: sortmodel.SortByName, reversed: true, expectedElemNames: []string{"dirNatural", "dir2", "dir1", "xyz.json", "file2.txt", "file1.txt", "aBcD", "abc", "1.json"}, }, { name: "Sort by Size", location: curTestDir, dotFiles: false, sortKind: sortmodel.SortBySize, reversed: false, expectedElemNames: []string{"dir2", "dir1", "dirNatural", "1.json", "aBcD", "file1.txt", "xyz.json", "abc", "file2.txt"}, }, { name: "Sort by Size Reversed", location: curTestDir, dotFiles: false, sortKind: sortmodel.SortBySize, reversed: true, expectedElemNames: []string{"dirNatural", "dir1", "dir2", "file2.txt", "abc", "xyz.json", "file1.txt", "aBcD", "1.json"}, }, // This one could be flakey if files are created to quickly, or maybe created in // parallel { name: "Sort by Date", location: curTestDir, dotFiles: false, sortKind: sortmodel.SortByDate, reversed: false, expectedElemNames: []string{"dirNatural", "1.json", "file2.txt", "abc", "xyz.json", "file1.txt", "aBcD", "dir1", "dir2"}, }, { name: "Sort by Type", location: curTestDir, dotFiles: false, sortKind: sortmodel.SortByType, reversed: false, expectedElemNames: []string{"dir1", "dir2", "dirNatural", "abc", "aBcD", "1.json", "xyz.json", "file1.txt", "file2.txt"}, }, { name: "Sort by Type Reversed and dotfiles", location: curTestDir, dotFiles: true, sortKind: sortmodel.SortByType, reversed: true, expectedElemNames: []string{"dirNatural", "dir2", "dir1", ".xyz", "file2.txt", "file1.txt", "xyz.json", "1.json", "aBcD", "abc"}, }, { name: "Sort by Type Reversed and dotfiles with search", location: curTestDir, dotFiles: true, sortKind: sortmodel.SortByType, reversed: true, searchString: "x", expectedElemNames: []string{".xyz", "file2.txt", "file1.txt", "xyz.json"}, }, { name: "Sort by Size Reversed with search ftt", location: curTestDir, dotFiles: false, sortKind: sortmodel.SortBySize, reversed: true, searchString: "ftt", expectedElemNames: []string{"file2.txt", "file1.txt"}, }, { name: "Sort by Size Reversed with search d", location: curTestDir, dotFiles: false, sortKind: sortmodel.SortBySize, reversed: true, searchString: "d", expectedElemNames: []string{"dirNatural", "dir1", "dir2", "aBcD"}, }, { name: "Sort by Natural", location: dirNatural, dotFiles: false, sortKind: sortmodel.SortByNatural, reversed: false, expectedElemNames: []string{"file1.txt", "file2.txt", "file10.txt", "file20.txt"}, }, { name: "Sort by Natural Reversed", location: dirNatural, dotFiles: false, sortKind: sortmodel.SortByNatural, reversed: true, expectedElemNames: []string{"file20.txt", "file10.txt", "file2.txt", "file1.txt"}, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { panel := testModel(0, 0, 0, BrowserMode, nil) panel.Location = tt.location panel.SortKind = tt.sortKind panel.SortReversed = tt.reversed panel.SearchBar.SetValue(tt.searchString) var res []Element if tt.searchString == "" { res = panel.getDirectoryElements(tt.dotFiles) } else { res = panel.getDirectoryElementsBySearch(tt.dotFiles) } assert.Len(t, res, len(tt.expectedElemNames)) actualNames := []string{} for i := range res { actualNames = append(actualNames, res[i].Name) } assert.Equal(t, tt.expectedElemNames, actualNames) }) } } func TestSingleItemSelect(t *testing.T) { testdata := []struct { name string panel Model panelToSelect []string expectedSelected map[string]int }{ { name: "Select unselected item", panel: testModel(0, 0, 12, SelectMode, []Element{ {Name: "file1.txt", Location: "/tmp/file1.txt"}, {Name: "file2.txt", Location: "/tmp/file2.txt"}, }), panelToSelect: []string{}, expectedSelected: map[string]int{"/tmp/file1.txt": 1}, }, { name: "Deselect selected item", panel: testModel(0, 0, 12, SelectMode, []Element{ {Name: "file1.txt", Location: "/tmp/file1.txt"}, {Name: "file2.txt", Location: "/tmp/file2.txt"}, }), panelToSelect: []string{"/tmp/file1.txt"}, expectedSelected: map[string]int{}, }, { name: "Out of bounds cursor negative", panel: testModel(-1, 0, 12, SelectMode, []Element{ {Name: "file1.txt", Location: "/tmp/file1.txt"}, }), panelToSelect: []string{}, expectedSelected: map[string]int{}, }, { name: "Out of bounds cursor beyond count", panel: testModel(5, 0, 12, SelectMode, []Element{ {Name: "file1.txt", Location: "/tmp/file1.txt"}, }), panelToSelect: []string{}, expectedSelected: map[string]int{}, }, { name: "Empty element list", panel: testModel(0, 0, 12, SelectMode, []Element{}), panelToSelect: []string{}, expectedSelected: map[string]int{}, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { tt.panel.SetSelectedAll(tt.panelToSelect) tt.panel.SingleItemSelect() assert.Equal(t, tt.expectedSelected, tt.panel.selected) }) } } ================================================ FILE: src/internal/ui/filepanel/misc.go ================================================ package filepanel import "github.com/yorukot/superfile/src/internal/common" func (p PanelMode) String() string { switch p { case SelectMode: return "selectMode" case BrowserMode: return "browserMode" default: return common.InvalidTypeString } } ================================================ FILE: src/internal/ui/filepanel/model.go ================================================ package filepanel import ( "os" "path/filepath" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui/sortmodel" ) // FilePanelSlice creates a slice of FilePanels from the given paths func FilePanelSlice(paths []string) []Model { res := make([]Model, len(paths)) for i := range paths { // Making the first panel as the focused isFocus := i == 0 res[i] = defaultFilePanel(paths[i], isFocus) } return res } // defaultFilePanel creates a new FilePanel with default settings func defaultFilePanel(path string, focused bool) Model { targetFile := "" panelPath := path // If path refers to a file, switch to its parent and remember the filename if stat, err := os.Stat(panelPath); err == nil && !stat.IsDir() { targetFile = filepath.Base(panelPath) panelPath = filepath.Dir(panelPath) } return New(panelPath, focused, targetFile, sortmodel.SortKind(common.Config.DefaultSortType), common.Config.SortOrderReversed) } func New(location string, focused bool, targetFile string, sortKind sortmodel.SortKind, sortReversed bool) Model { return Model{ cursor: 0, renderIndex: 0, Location: location, SortKind: sortKind, SortReversed: sortReversed, PanelMode: BrowserMode, IsFocused: focused, DirectoryRecords: make(map[string]directoryRecord), SearchBar: common.GenerateSearchBar(), TargetFile: targetFile, width: MinWidth, height: MinHeight, selected: make(map[string]int), } } ================================================ FILE: src/internal/ui/filepanel/navigation.go ================================================ package filepanel import ( "fmt" ) func (m *Model) scrollToCursor(cursor int) { if cursor < 0 || cursor >= m.ElemCount() { return } m.cursor = cursor // Modify renderIndex if needed renderCount := m.PanelElementHeight() if m.cursor < m.renderIndex { // Due to size change, when last element is selected, we might have // empty space (renderIndex ... ElemCount()-1 spans less then renderCount) // Even with >0 renderIndex m.renderIndex = m.cursor } else if m.cursor > m.renderIndex+renderCount-1 { m.renderIndex = m.cursor - renderCount + 1 } } func (m *Model) moveCursorBy(delta int) { if m.Empty() { return } // Wrap cursor cursor := (m.cursor + delta + m.ElemCount()) % m.ElemCount() m.scrollToCursor(cursor) } // Control file panel list up func (m *Model) ListUp() { m.moveCursorBy(-1) } // Control file panel list down func (m *Model) ListDown() { m.moveCursorBy(1) } func (m *Model) PgUp() { m.moveCursorBy(-m.getPageScrollSize()) } func (m *Model) PgDown() { m.moveCursorBy(m.getPageScrollSize()) } // Handles the action of selecting an item in the file panel upwards. (only work on select mode) // This basically just toggles the "selected" status of element that is pointed by the cursor // and then moves the cursor up // TODO : Add unit tests for ItemSelectUp and singleItemSelect func (m *Model) ItemSelectUp() { m.SingleItemSelect() m.ListUp() } // Handles the action of selecting an item in the file panel downwards. (only work on select mode) func (m *Model) ItemSelectDown() { m.SingleItemSelect() m.ListDown() } // Applies targetFile cursor positioning, if configured for the panel. func (m *Model) applyTargetFileCursor() { idx := m.FindElementIndexByName(m.TargetFile) if idx != -1 { m.scrollToCursor(idx) } m.TargetFile = "" } func (m *Model) ValidateCursorAndRenderIndex() error { if m.cursor < 0 || m.ElemCount() <= m.cursor { return fmt.Errorf("invalid cursor : %d, element count : %d", m.cursor, m.ElemCount()) } renderCount := m.PanelElementHeight() if (m.cursor < m.renderIndex) || (m.cursor > m.renderIndex+renderCount-1) { return fmt.Errorf("invalid renderIndex : %d, cursor : %d, renderCount : %d", m.renderIndex, m.cursor, renderCount) } return nil } ================================================ FILE: src/internal/ui/filepanel/navigation_test.go ================================================ package filepanel import ( "testing" "github.com/stretchr/testify/assert" "github.com/yorukot/superfile/src/internal/common" ) func testModelWithElemCount(cursor int, renderIndex int, height int, elemCount int) Model { return testModel(cursor, renderIndex, height, BrowserMode, make([]Element, elemCount)) } func testModel(cursor int, renderIndex int, height int, mode PanelMode, elements []Element) Model { return Model{ element: elements, cursor: cursor, renderIndex: renderIndex, height: height, selected: make(map[string]int), PanelMode: mode, } } func Test_filePanelUpDown(t *testing.T) { testdata := []struct { name string panel Model listDown bool expectedCursor int expectedRender int }{ { name: "Down movement within renderable range", panel: testModelWithElemCount(0, 0, 12, 10), listDown: true, expectedCursor: 1, expectedRender: 0, }, { name: "Down movement when cursor is at bottom", panel: testModelWithElemCount(6, 0, 12, 10), listDown: true, expectedCursor: 7, expectedRender: 1, }, { name: "Down movement causing wrap to top", panel: testModelWithElemCount(9, 3, 12, 10), listDown: true, expectedCursor: 0, expectedRender: 0, }, { name: "Up movement within renderable range", panel: testModelWithElemCount(2, 0, 12, 10), listDown: false, expectedCursor: 1, expectedRender: 0, }, { name: "Up movement when cursor is at top", panel: testModelWithElemCount(3, 3, 12, 10), listDown: false, expectedCursor: 2, expectedRender: 2, }, { name: "Up movement causing wrap to bottom", panel: testModelWithElemCount(0, 0, 12, 10), listDown: false, expectedCursor: 9, expectedRender: 3, }, { name: "Down movement on empty panel", panel: testModelWithElemCount(0, 0, 12, 0), listDown: true, expectedCursor: 0, expectedRender: 0, }, { name: "Up movement on empty panel", panel: testModelWithElemCount(0, 0, 12, 0), listDown: false, expectedCursor: 0, expectedRender: 0, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { if tt.listDown { tt.panel.ListDown() } else { tt.panel.ListUp() } assert.Equal(t, tt.expectedCursor, tt.panel.GetCursor()) assert.Equal(t, tt.expectedRender, tt.panel.GetRenderIndex()) }) } } func TestPgUpDown(t *testing.T) { testdata := []struct { name string panel Model pageDown bool expectedCursor int expectedRender int }{ { name: "Page down with full page of items", panel: testModelWithElemCount(0, 0, 12, 20), pageDown: true, expectedCursor: 7, expectedRender: 1, }, { name: "Page down near end wraps to start", panel: testModelWithElemCount(18, 12, 12, 20), pageDown: true, expectedCursor: 5, // (18 + 7) % 20 = 5 expectedRender: 5, }, { name: "Page up from middle", panel: testModelWithElemCount(10, 4, 12, 20), pageDown: false, expectedCursor: 3, // 10 - 7 = 3 expectedRender: 3, }, { name: "Page up near beginning wraps to end", panel: testModelWithElemCount(2, 0, 12, 20), pageDown: false, expectedCursor: 15, // (2 - 7 + 20) % 20 = 15 expectedRender: 9, }, { name: "Page navigation with small element count", panel: testModelWithElemCount(0, 0, 12, 5), pageDown: true, expectedCursor: 2, // (0 + 7) % 5 = 2 expectedRender: 0, }, { name: "Page down on empty panel", panel: testModelWithElemCount(0, 0, 12, 0), pageDown: true, expectedCursor: 0, expectedRender: 0, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { if tt.pageDown { tt.panel.PgDown() } else { tt.panel.PgUp() } assert.Equal(t, tt.expectedCursor, tt.panel.GetCursor()) assert.Equal(t, tt.expectedRender, tt.panel.GetRenderIndex()) }) } } func TestItemSelectUpDown(t *testing.T) { testdata := []struct { name string panel Model panelToSelect []string selectDown bool expectedCursor int expectedRender int expectedSelected map[string]int }{ { name: "Select and move down", panel: testModel(0, 0, 12, SelectMode, []Element{ {Name: "file1.txt", Location: "/tmp/file1.txt"}, {Name: "file2.txt", Location: "/tmp/file2.txt"}, {Name: "file3.txt", Location: "/tmp/file3.txt"}, }), panelToSelect: []string{}, selectDown: true, expectedCursor: 1, expectedRender: 0, expectedSelected: map[string]int{"/tmp/file1.txt": 1}, }, { name: "Select and move up", panel: testModel(2, 0, 12, SelectMode, []Element{ {Name: "file1.txt", Location: "/tmp/file1.txt"}, {Name: "file2.txt", Location: "/tmp/file2.txt"}, {Name: "file3.txt", Location: "/tmp/file3.txt"}, }), panelToSelect: []string{}, selectDown: false, expectedCursor: 1, expectedRender: 0, expectedSelected: map[string]int{"/tmp/file3.txt": 1}, }, { name: "Deselect already selected item", panel: testModel(0, 0, 12, SelectMode, []Element{ {Name: "file1.txt", Location: "/tmp/file1.txt"}, {Name: "file2.txt", Location: "/tmp/file2.txt"}, }), panelToSelect: []string{"/tmp/file1.txt"}, selectDown: true, expectedCursor: 1, expectedRender: 0, expectedSelected: map[string]int{}, }, { name: "Selection at boundary with wrap", panel: testModel(1, 0, 12, SelectMode, []Element{ {Name: "file1.txt", Location: "/tmp/file1.txt"}, {Name: "file2.txt", Location: "/tmp/file2.txt"}, }), panelToSelect: []string{}, selectDown: true, expectedCursor: 0, // wraps to beginning expectedRender: 0, expectedSelected: map[string]int{"/tmp/file2.txt": 1}, }, { name: "Selection persistence across moves", panel: testModel(1, 0, 12, SelectMode, []Element{ {Name: "file1.txt", Location: "/tmp/file1.txt"}, {Name: "file2.txt", Location: "/tmp/file2.txt"}, {Name: "file3.txt", Location: "/tmp/file3.txt"}, }), panelToSelect: []string{"/tmp/file1.txt"}, selectDown: true, expectedCursor: 2, expectedRender: 0, expectedSelected: map[string]int{"/tmp/file1.txt": 1, "/tmp/file2.txt": 2}, }, { name: "Empty panel selection", panel: testModel(0, 0, 12, SelectMode, []Element{}), panelToSelect: []string{}, selectDown: true, expectedCursor: 0, expectedRender: 0, expectedSelected: map[string]int{}, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { tt.panel.SetSelectedAll(tt.panelToSelect) if tt.selectDown { tt.panel.ItemSelectDown() } else { tt.panel.ItemSelectUp() } assert.Equal(t, tt.expectedCursor, tt.panel.GetCursor()) assert.Equal(t, tt.expectedRender, tt.panel.GetRenderIndex()) assert.Equal(t, tt.expectedSelected, tt.panel.selected) }) } } func TestScrollToCursor(t *testing.T) { testdata := []struct { name string panel Model cursorPos int expectedCursor int expectedRender int }{ { name: "Jump to visible cursor no change", panel: testModelWithElemCount(5, 3, 12, 20), cursorPos: 4, expectedCursor: 4, expectedRender: 3, }, { name: "Jump above view", panel: testModelWithElemCount(10, 5, 12, 20), cursorPos: 2, expectedCursor: 2, expectedRender: 2, }, { name: "Jump below view", panel: testModelWithElemCount(5, 0, 12, 20), cursorPos: 15, expectedCursor: 15, expectedRender: 9, // 15 - 7 + 1 }, { name: "Jump above view with empty space", panel: testModelWithElemCount(19, 18, 12, 20), cursorPos: 17, expectedCursor: 17, expectedRender: 17, }, { name: "Invalid cursor negative", panel: testModelWithElemCount(5, 2, 12, 10), cursorPos: -1, expectedCursor: 5, // unchanged expectedRender: 2, // unchanged }, { name: "Invalid cursor beyond count", panel: testModelWithElemCount(5, 2, 12, 10), cursorPos: 15, expectedCursor: 5, // unchanged expectedRender: 2, // unchanged }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { tt.panel.scrollToCursor(tt.cursorPos) assert.Equal(t, tt.expectedCursor, tt.panel.GetCursor()) assert.Equal(t, tt.expectedRender, tt.panel.GetRenderIndex()) }) } } func TestApplyTargetFileCursor(t *testing.T) { panel := testModel(0, 0, 8, BrowserMode, []Element{ {Name: "file1.txt", Location: "/tmp/file1.txt"}, {Name: "file2.txt", Location: "/tmp/file2.txt"}, {Name: "file3.txt", Location: "/tmp/file3.txt"}, {Name: "file4.txt", Location: "/tmp/file4.txt"}, {Name: "target.txt", Location: "/tmp/target.txt"}, {Name: "file6.txt", Location: "/tmp/file6.txt"}, }) panel.TargetFile = "target.txt" expCursor := 4 expRender := 2 panel.applyTargetFileCursor() assert.Equal(t, expCursor, panel.GetCursor()) assert.Equal(t, expRender, panel.GetRenderIndex()) assert.Empty(t, panel.TargetFile) // Shouldn't do anything panel.applyTargetFileCursor() assert.Equal(t, expCursor, panel.GetCursor()) assert.Equal(t, expRender, panel.GetRenderIndex()) } func TestPageScrollSizeConfig(t *testing.T) { originalPageScrollSize := common.Config.PageScrollSize defer func() { common.Config.PageScrollSize = originalPageScrollSize }() tests := []struct { name string pageScrollSize int totalElements int initialCursor int panelHeight int expectedCursor int pgUp bool }{ { name: "Default full page scroll (PageScrollSize = 0)", pageScrollSize: 0, totalElements: 30, initialCursor: 0, panelHeight: 10, // panelElementHeight = 10 - 3 = 7 expectedCursor: 7, // Should move by 7 (full page) }, { name: "Custom scroll size 5", pageScrollSize: 5, totalElements: 30, initialCursor: 0, panelHeight: 10, expectedCursor: 5, // Should move by 5 }, { name: "Custom scroll size 10", pageScrollSize: 10, totalElements: 30, initialCursor: 0, panelHeight: 10, expectedCursor: 10, // Should move by 10 }, { name: "PgUp with custom scroll size", pageScrollSize: 3, totalElements: 30, initialCursor: 10, panelHeight: 10, expectedCursor: 7, // 10 - 3 = 7 pgUp: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { common.Config.PageScrollSize = tt.pageScrollSize // Create model with elements m := testModelWithElemCount(tt.initialCursor, 0, tt.panelHeight+2, tt.totalElements) if tt.pgUp { m.PgUp() } else { m.PgDown() } assert.Equal(t, tt.expectedCursor, m.GetCursor(), "Cursor position should match expected after PgUp/PgDown") }) } } ================================================ FILE: src/internal/ui/filepanel/render.go ================================================ package filepanel import ( "fmt" "path/filepath" "strings" "github.com/yorukot/superfile/src/config/icon" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui" "github.com/yorukot/superfile/src/internal/ui/rendering" "github.com/yorukot/superfile/src/internal/ui/sortmodel" ) /* - TODO: Write File Panel Specific unit test - Individual panel resizes - Footer content of filepanel changes due to resizing - i Only mode icons remains on smaller - ii Other things that change too - Other panels like clipboard and metadata's content changes too on resize */ func (m *Model) Render(focused bool) string { r := ui.FilePanelRenderer(m.height, m.width, focused) m.renderTopBar(r) m.renderSearchBar(r) m.renderFooter(r, m.SelectedCount()) if m.NeedRenderHeaders() { m.renderColumnHeaders(r) } m.renderFileEntries(r) return r.Render() } func (m *Model) renderTopBar(r *rendering.Renderer) { // TODO - Add ansitruncate left in renderer and remove truncation here truncatedPath := common.TruncateTextBeginning(m.Location, m.GetContentWidth()-common.InnerPadding, "...") r.AddLines(common.FilePanelTopDirectoryIcon + common.FilePanelTopPathStyle.Render(truncatedPath)) r.AddSection() } func (m *Model) renderSearchBar(r *rendering.Renderer) { r.AddLines(" " + m.SearchBar.View()) } // TODO : Unit test this func (m *Model) renderFooter(r *rendering.Renderer, selectedCount uint) { sortLabel, sortIcon := m.getSortInfo() modeLabel, modeIcon := m.getPanelModeInfo(selectedCount) cursorStr := m.getCursorString() if common.Config.Nerdfont { sortLabel = sortIcon + icon.Space + sortLabel modeLabel = modeIcon + icon.Space + modeLabel } else { // TODO : Figure out if we can set icon.Space to " " if nerdfont is false // That would simplify code sortLabel = sortIcon + " " + sortLabel } if common.Config.ShowPanelFooterInfo { r.SetBorderInfoItems(sortLabel, modeLabel, cursorStr) if r.AreInfoItemsTruncated() { r.SetBorderInfoItems(sortIcon, modeIcon, cursorStr) } } else { r.SetBorderInfoItems(cursorStr) } } func (m *Model) renderColumnHeaders(r *rendering.Renderer) { var builder strings.Builder for _, column := range m.columns { builder.WriteString(column.RenderHeader()) } r.AddLines(builder.String()) } func (m *Model) renderFileEntries(r *rendering.Renderer) { if m.Empty() { r.AddLines(common.FilePanelNoneText) return } end := min(m.renderIndex+m.PanelElementHeight(), m.ElemCount()) for itemIndex := m.renderIndex; itemIndex < end; itemIndex++ { if m.Renaming && itemIndex == m.GetCursor() { r.AddLines(m.Rename.View()) continue } var builder strings.Builder for _, column := range m.columns { colData := column.Render(itemIndex) builder.WriteString(colData) } r.AddLines(builder.String()) } } func (m *Model) getSortInfo() (string, string) { iconStr := icon.SortAsc if m.SortReversed { iconStr = icon.SortDesc } return sortmodel.SortOptionsShortStr[m.SortKind], iconStr } func (m *Model) getPanelModeInfo(selectedCount uint) (string, string) { switch m.PanelMode { case BrowserMode: return "Browser", icon.Browser case SelectMode: return "Select" + icon.Space + fmt.Sprintf("(%d)", selectedCount), icon.Select default: return "", "" } } func (m *Model) getCursorString() string { cursor := m.GetCursor() if !m.Empty() { cursor++ // Convert to 1-based } return fmt.Sprintf("%d/%d", cursor, m.ElemCount()) } func (m *Model) renderSelectBox(isSelected bool) string { if !common.Config.ShowSelectIcons || !common.Config.Nerdfont || m.PanelMode != SelectMode { return "" } if m.IsFocused { if isSelected { return common.CheckboxCheckedFocused } return common.CheckboxEmptyFocused } if isSelected { return common.CheckboxChecked } return common.CheckboxEmpty } // Checks whether a panel needs re-render due to being invalid or due to directory change func (m *Model) NeedsReRender() bool { if !m.EmptyOrInvalid() { return filepath.Dir(m.GetFirstElement().Location) != m.Location } return true } ================================================ FILE: src/internal/ui/filepanel/selection_test.go ================================================ package filepanel import ( "testing" "github.com/stretchr/testify/assert" ) func TestPanelSelectionLifeCycle(t *testing.T) { panel := testModel(0, 0, 0, BrowserMode, []Element{ {Name: "file1.txt", Location: "/tmp/file1.txt"}, {Name: "file2.txt", Location: "/tmp/file2.txt"}, {Name: "file3.txt", Location: "/tmp/file3.txt"}, {Name: "file4.txt", Location: "/tmp/file4.txt"}, {Name: "file5.txt", Location: "/tmp/file5.txt"}}) assert.Equal(t, uint(0), panel.SelectedCount()) // first added panel.SetSelected("/tmp/file1.txt") assert.Equal(t, uint(1), panel.SelectedCount()) assert.Equal(t, map[string]int{"/tmp/file1.txt": 1}, panel.selected) // second added panel.SetSelected("/tmp/file2.txt") assert.Equal(t, map[string]int{"/tmp/file1.txt": 1, "/tmp/file2.txt": 2}, panel.selected) assert.Equal(t, uint(2), panel.SelectedCount()) currentFirst := panel.GetFirstSelectedLocation() assert.Equal(t, "/tmp/file1.txt", currentFirst) // first removed panel.SetUnSelected("/tmp/file1.txt") assert.Equal(t, uint(1), panel.SelectedCount()) assert.Equal(t, map[string]int{"/tmp/file2.txt": 2}, panel.selected) currentFirst = panel.GetFirstSelectedLocation() assert.Equal(t, "/tmp/file2.txt", currentFirst) // multi select panel.SetSelectedAll([]string{"/tmp/file3.txt", "/tmp/file4.txt"}) assert.Equal(t, map[string]int{"/tmp/file2.txt": 2, "/tmp/file3.txt": 3, "/tmp/file4.txt": 4}, panel.selected) assert.Equal(t, uint(3), panel.SelectedCount()) // multi unselect panel.SetUnSelected("/tmp/file2.txt") panel.SetUnSelected("/tmp/file4.txt") assert.Equal(t, map[string]int{"/tmp/file3.txt": 3}, panel.selected) assert.Equal(t, uint(1), panel.SelectedCount()) currentFirst = panel.GetFirstSelectedLocation() assert.Equal(t, "/tmp/file3.txt", currentFirst) // reset selection panel.ResetSelected() assert.Equal(t, uint(0), panel.SelectedCount()) assert.Equal(t, map[string]int{}, panel.selected) assert.Equal(t, 0, panel.selectOrderCounter) } ================================================ FILE: src/internal/ui/filepanel/sort.go ================================================ package filepanel import ( "log/slog" "os" "path/filepath" "sort" "strings" "github.com/fvbommel/sortorder" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui/sortmodel" ) func getOrderingFunc(elements []Element, reversed bool, sortKind sortmodel.SortKind) sliceOrderFunc { var order func(i, j int) bool switch sortKind { case sortmodel.SortByName: order = func(i, j int) bool { // One of them is a directory, and other is not if elements[i].Directory != elements[j].Directory { return elements[i].Directory } if common.Config.CaseSensitiveSort { return elements[i].Name < elements[j].Name != reversed } return strings.ToLower(elements[i].Name) < strings.ToLower(elements[j].Name) != reversed } case sortmodel.SortBySize: order = getSizeOrderingFunc(elements, reversed) case sortmodel.SortByDate: order = func(i, j int) bool { return elements[i].Info.ModTime().After(elements[j].Info.ModTime()) != reversed } case sortmodel.SortByType: order = getTypeOrderingFunc(elements, reversed) case sortmodel.SortByNatural: order = func(i, j int) bool { // One of them is a directory, and other is not if elements[i].Directory != elements[j].Directory { return elements[i].Directory } if common.Config.CaseSensitiveSort { return sortorder.NaturalLess(elements[i].Name, elements[j].Name) != reversed } return sortorder.NaturalLess( strings.ToLower(elements[i].Name), strings.ToLower(elements[j].Name), ) != reversed } } return order } func getSizeOrderingFunc(elements []Element, reversed bool) sliceOrderFunc { return func(i, j int) bool { // Directories at the top sorted by direct child count (not recursive) // Files sorted by size // One of them is a directory, and other is not if elements[i].Directory != elements[j].Directory { return elements[i].Directory } // This needs to be improved, and we should sort by actual size only // Repeated recursive read would be slow, so we could cache if elements[i].Directory && elements[j].Directory { filesI, err := os.ReadDir(elements[i].Location) // No need of early return, we only call len() on filesI, so nil would // just result in 0 if err != nil { slog.Error("Error when reading directory during sort", "error", err) } filesJ, err := os.ReadDir(elements[j].Location) if err != nil { slog.Error("Error when reading directory during sort", "error", err) } return len(filesI) < len(filesJ) != reversed } return elements[i].Info.Size() < elements[j].Info.Size() != reversed } } func getTypeOrderingFunc(elements []Element, reversed bool) sliceOrderFunc { return func(i, j int) bool { // One of them is a directory, and the other is not if elements[i].Directory != elements[j].Directory { return elements[i].Directory } var extI, extJ string if !elements[i].Directory { extI = strings.ToLower(filepath.Ext(elements[i].Name)) } if !elements[j].Directory { extJ = strings.ToLower(filepath.Ext(elements[j].Name)) } // Compare by extension/type if extI != extJ { return (extI < extJ) != reversed } // If same type, fall back to name if common.Config.CaseSensitiveSort { return (elements[i].Name < elements[j].Name) != reversed } return (strings.ToLower(elements[i].Name) < strings.ToLower(elements[j].Name)) != reversed } } func sortFileElement(sortKind sortmodel.SortKind, reversed bool, dirEntries []os.DirEntry, location string) []Element { elements := make([]Element, 0, len(dirEntries)) for _, item := range dirEntries { info, err := item.Info() if err != nil { slog.Error("Error while retrieving file info during sort", "error", err, "path", filepath.Join(location, item.Name())) continue } elements = append(elements, Element{ Name: item.Name(), Directory: item.IsDir() || isSymlinkToDir(location, info, item.Name()), Location: filepath.Join(location, item.Name()), Info: info, }) } sort.Slice(elements, getOrderingFunc(elements, reversed, sortKind)) return elements } // Symlinks to directories are to be identified as directories func isSymlinkToDir(location string, info os.FileInfo, name string) bool { if info.Mode()&os.ModeSymlink != 0 { targetInfo, errStat := os.Stat(filepath.Join(location, name)) return errStat == nil && targetInfo.IsDir() } return false } func (m *Model) getPageScrollSize() int { scrollSize := common.Config.PageScrollSize if scrollSize <= 0 { // Use default full page behavior scrollSize = m.PanelElementHeight() } return scrollSize } ================================================ FILE: src/internal/ui/filepanel/types.go ================================================ package filepanel import ( "os" "time" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/lipgloss" "github.com/yorukot/superfile/src/internal/ui/sortmodel" ) // Make sure to use New() to ensure that maps are initialized // zero value `Model{}`, or direct initialization should be avoided // or used very carefully if needed type Model struct { // Note: We have tried to minimize direct access to cursor, // and read it via GetCursor() at most places, to make it easier // to find and harder to cause bugs of invalid value getting set to cursor cursor int renderIndex int IsFocused bool Location string // Dimension fields width int // Total width including borders height int // Total height including borders SortKind sortmodel.SortKind SortReversed bool PanelMode PanelMode // key is file location, value order of selection selected map[string]int selectOrderCounter int element []Element DirectoryRecords map[string]directoryRecord Rename textinput.Model Renaming bool SearchBar textinput.Model LastTimeGetElement time.Time TargetFile string // filename to position cursor on after load columns []columnDefinition // columns for rendering } // Record for directory navigation type directoryRecord struct { directoryCursor int directoryRender int } // Element within a file panel type Element struct { Name string Location string Directory bool Info os.FileInfo } // Type representing the mode of the panel type PanelMode uint // Constants for select mode or browser mode const ( SelectMode PanelMode = iota BrowserMode ) type sliceOrderFunc func(i, j int) bool type columnRenderer func(indexElement int, columnWidth int) string type columnDefinition struct { Name string Size int HeaderAlign lipgloss.Position columnRender columnRenderer } ================================================ FILE: src/internal/ui/filepanel/update.go ================================================ package filepanel import ( "fmt" "log/slog" "os" "path/filepath" "github.com/yorukot/superfile/src/pkg/utils" ) func (m *Model) ChangeFilePanelMode() { switch m.PanelMode { case SelectMode: m.ResetSelected() m.PanelMode = BrowserMode case BrowserMode: m.PanelMode = SelectMode default: slog.Error("Unexpected panelMode", "panelMode", m.PanelMode) } } // This should be the function that is always called whenever we are updating a directory. func (m *Model) UpdateCurrentFilePanelDir(path string) error { slog.Debug("updateCurrentFilePanelDir", "panel.location", m.Location, "path", path) // In case non Absolute path is passed, make sure to resolve it. path = utils.ResolveAbsPath(m.Location, path) // Ignore if its the same directory. It prevents resetting of searchBar if path == m.Location { return nil } // NOTE: This could be a configurable feature // Update the cursor and render status in case we switch back to this. m.DirectoryRecords[m.Location] = directoryRecord{ directoryCursor: m.cursor, directoryRender: m.renderIndex, } if info, err := os.Stat(path); err != nil { return fmt.Errorf("%s : no such file or directory, stats err : %w", path, err) } else if !info.IsDir() { return fmt.Errorf("%s is not a directory", path) } // In case of switching to parent, explicitly set focus. // This is to handle when there isn't a DirectoryRecord, yet. if filepath.Dir(m.Location) == path { m.TargetFile = filepath.Base(m.Location) } // Switch to "path" m.Location = path // NOTE: We are fetching the cursor and render from cache, but this could become invalid // in case user deletes some items in the directory via another file manager and then switch back // Basically this directoryRecords cache can be invalid. On each Update(), on dire change // we do a element fetch and validate the cursor and render values. But the filepane could // stay in invalid state till that and operations done before the update may fail curDirectoryRecord, hasRecord := m.DirectoryRecords[m.Location] if hasRecord { m.cursor = curDirectoryRecord.directoryCursor m.renderIndex = curDirectoryRecord.directoryRender } else { m.cursor = 0 m.renderIndex = 0 } slog.Debug("updateCurrentFilePanelDir : After update", "cursor", m.cursor, "render", m.renderIndex) // Reset the searchbar Value // TODO(Refactoring) : Have a common searchBar type for sidebar and this search bar. m.SearchBar.SetValue("") return nil } func (m *Model) ParentDirectory() error { return m.UpdateCurrentFilePanelDir("..") } // Select all item in the file panel (only work on select mode) func (m *Model) SelectAllItem() { for _, item := range m.element { m.SetSelected(item.Location) } } ================================================ FILE: src/internal/ui/filepanel/utils.go ================================================ package filepanel import "math" func (m *Model) GetCursor() int { return m.cursor } func (m *Model) GetRenderIndex() int { return m.renderIndex } func (m *Model) GetFocusedItem() Element { return m.GetElementAtIdx(m.GetCursor()) } func (m *Model) GetElementAtIdx(idx int) Element { if idx < 0 || m.ElemCount() <= idx { return Element{} } return m.element[idx] } func (m *Model) GetFirstElement() Element { return m.GetElementAtIdx(0) } func (m *Model) ResetSelected() { m.selectOrderCounter = 0 m.selected = make(map[string]int) } // For modification. Make sure to do a nil check func (m *Model) GetFocusedItemPtr() *Element { if m.GetCursor() < 0 || m.ElemCount() <= m.GetCursor() { return nil } return &m.element[m.GetCursor()] } // Note : If this is called on an already selected element // it will make its order last. This is expected behaviour func (m *Model) SetSelected(location string) { m.selectOrderCounter++ m.selected[location] = m.selectOrderCounter } func (m *Model) SetUnSelected(location string) { if m.CheckSelected(location) { delete(m.selected, location) } } func (m *Model) ToggleSelected(location string) { if m.CheckSelected(location) { delete(m.selected, location) return } m.SetSelected(location) } // Only used in tests, including tests outside this package func (m *Model) SetSelectedAll(locations []string) { for _, location := range locations { m.SetSelected(location) } } func (m *Model) CheckSelected(location string) bool { _, isSelected := m.selected[location] return isSelected } // Returns an unordered list of selected locations func (m *Model) GetSelectedLocations() []string { result := make([]string, 0, len(m.selected)) for k := range m.selected { result = append(result, k) } return result } func (m *Model) GetFirstSelectedLocation() string { if len(m.selected) == 0 { return "" } result := "" minOrder := math.MaxInt for location, order := range m.selected { if minOrder > order { result = location minOrder = order } } return result } // Select the item where cursor located (only work on select mode) func (m *Model) SingleItemSelect() { if !m.EmptyOrInvalid() { m.ToggleSelected(m.GetFocusedItem().Location) } } func (m *Model) ElemCount() int { return len(m.element) } func (m *Model) SelectedCount() uint { return uint(len(m.selected)) } func (m *Model) Empty() bool { return m.ElemCount() == 0 } func (m *Model) EmptyOrInvalid() bool { return m.Empty() || m.ValidateCursorAndRenderIndex() != nil } func (m *Model) ToggleReverseSort() { m.SortReversed = !m.SortReversed } // SetCursorPosition sets cursor and updates renderIndex accordingly. // Note: Intended for test utilities only!!!!! func (m *Model) SetCursorPosition(cursor int) { m.scrollToCursor(cursor) } func (m *Model) FindElementIndexByName(name string) int { for i, elem := range m.element { if elem.Name == name { return i } } return -1 } func (m *Model) FindElementIndexByLocation(location string) int { for i, elem := range m.element { if elem.Location == location { return i } } return -1 } ================================================ FILE: src/internal/ui/helpmenu/data.go ================================================ package helpmenu import ( "path/filepath" "strings" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/pkg/utils" ) // Return help menu for Hotkeys func getData() []hotkeydata { //nolint: funlen // This should be self contained data := []hotkeydata{ { subTitle: "General", }, { hotkey: []string{"spf", ""}, description: "Open superfile", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.Confirm, description: "Confirm your select or typing", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.Quit, description: "Quit typing, modal or superfile", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.CdQuit, description: "Quit superfile and change directory to current folder", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.ConfirmTyping, description: "Confirm typing", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.CancelTyping, description: "Cancel typing", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.OpenHelpMenu, description: "Open help menu (hotkeylist)", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.OpenCommandLine, description: "Open command line", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.OpenSPFPrompt, description: "Open SPF prompt", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.OpenZoxide, description: "Open zoxide navigation", hotkeyWorkType: globalType, }, { subTitle: "Panel navigation", }, { hotkey: common.Hotkeys.CreateNewFilePanel, description: "Create new file panel", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.SplitFilePanel, description: "Split file panel (open new panel in same directory)", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.CloseFilePanel, description: "Close the focused file panel", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.ToggleFilePreviewPanel, description: "Toggle file preview panel", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.OpenSortOptionsMenu, description: "Open sort options menu", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.ToggleReverseSort, description: "Toggle reverse sort", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.ToggleFooter, description: "Toggle footer", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.NextFilePanel, description: "Focus on the next file panel", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.PreviousFilePanel, description: "Focus on the previous file panel", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.FocusOnProcessBar, description: "Focus on the processbar panel", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.FocusOnSidebar, description: "Focus on the sidebar", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.FocusOnMetaData, description: "Focus on the metadata panel", hotkeyWorkType: globalType, }, { subTitle: "Panel movement", }, { hotkey: common.Hotkeys.ListUp, description: "Up", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.ListDown, description: "Down", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.PageUp, description: "Page up", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.PageDown, description: "Page down", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.ParentDirectory, description: "Return to parent folder", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.FilePanelSelectAllItem, description: "Select all items in focused file panel", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.FilePanelSelectModeItemsSelectUp, description: "Select up with your course", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.FilePanelSelectModeItemsSelectDown, description: "Select down with your course", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.ToggleDotFile, description: "Toggle dot file display", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.SearchBar, description: "Toggle active search bar", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.ChangePanelMode, description: "Change between selection mode or normal mode", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.PinnedDirectory, description: "Pin or Unpin folder to sidebar (can be auto saved)", hotkeyWorkType: globalType, }, { subTitle: "File operations", }, { hotkey: common.Hotkeys.FilePanelItemCreate, description: "Create file or folder(end with " + string(filepath.Separator) + " to create a folder)", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.FilePanelItemRename, description: "Rename file or folder", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.CopyItems, description: "Copy selected items to the clipboard", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.CutItems, description: "Cut selected items to the clipboard", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.PasteItems, description: "Paste clipboard items into the current file panel", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.DeleteItems, description: "Delete selected items", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.PermanentlyDeleteItems, description: "Permanently delete selected items", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.CopyPath, description: "Copy current file or directory path", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.CopyPWD, description: "Copy current working directory", hotkeyWorkType: globalType, }, { hotkey: common.Hotkeys.ExtractFile, description: "Extract compressed file", hotkeyWorkType: normalType, }, { hotkey: common.Hotkeys.CompressFile, description: "Zip file or folder to .zip file", hotkeyWorkType: normalType, }, { hotkey: common.Hotkeys.OpenFileWithEditor, description: "Open file with your default editor", hotkeyWorkType: normalType, }, { hotkey: common.Hotkeys.OpenCurrentDirectoryWithEditor, description: "Open current directory with default editor", hotkeyWorkType: normalType, }, } return data } func removeOrphanSections(items []hotkeydata) []hotkeydata { var result []hotkeydata // Since we can't know beforehand which section are we actually filtering // we may end up in a scenario where there are two sections (General, Panel navigation) // with no hotkeys between them, so we need to remove the section which its hotkeys was // completely filtered out (Orphan sections) for i := range items { if items[i].subTitle != "" { // Look ahead: is the next item a real hotkey? if i+1 < len(items) && items[i+1].subTitle == "" { result = append(result, items[i]) } // Else: skip this subtitle because no children } else { result = append(result, items[i]) } } return result } func (m *Model) filter(query string) { filtered := fuzzySearch(query, m.data) filtered = removeOrphanSections(filtered) m.filteredData = filtered if len(filtered) == 0 { m.cursor = 0 } else { m.cursor = 1 } m.renderIndex = 0 } // Fuzzy search function for a list of helpMenuModalData. // inspired from: sidebar/directory_utils.go func fuzzySearch(query string, data []hotkeydata) []hotkeydata { if len(data) == 0 { return []hotkeydata{} } // Optimization - This haystack can be kept precomputed based on description // instead of re computing it in each call haystack := []string{} idxMap := []int{} for i, item := range data { if item.subTitle != "" { continue } searchText := strings.Join(item.hotkey, " ") + " " + item.description haystack = append(haystack, searchText) idxMap = append(idxMap, i) } matchedIdx := map[int]struct{}{} for _, match := range utils.FzfSearch(query, haystack) { matchedIdx[idxMap[match.HayIndex]] = struct{}{} } results := []hotkeydata{} for i, d := range data { _, isMatch := matchedIdx[i] if d.subTitle != "" || isMatch { results = append(results, d) } } return results } ================================================ FILE: src/internal/ui/helpmenu/model_state.go ================================================ package helpmenu import ( "slices" tea "github.com/charmbracelet/bubbletea" "github.com/yorukot/superfile/src/internal/common" ) func New() Model { data := getData() return Model{ renderIndex: 0, cursor: 1, data: data, filteredData: data, opened: false, searchBar: common.GenerateSearchBar(), } } // Toggle help menu func (m *Model) Open() { if m.opened { m.searchBar.Reset() m.opened = false return } // Reset filteredData to the full data whenever the helpMenu is opened m.filteredData = m.data m.opened = true } // Quit help menu func (m *Model) Close() { m.searchBar.Reset() m.opened = false } // Check hotkey input in help menu. Possible actions are moving up, down // and quiting the menu func (m *Model) HandleKey(msg string) { if m.searchBar.Focused() { switch { case slices.Contains(common.Hotkeys.ConfirmTyping, msg), slices.Contains(common.Hotkeys.CancelTyping, msg): m.searchBar.Blur() default: m.filter(m.searchBar.Value()) } } else { m.handleNavKeys(msg) } } func (m *Model) handleNavKeys(msg string) { switch { case slices.Contains(common.Hotkeys.ListUp, msg): m.ListUp() case slices.Contains(common.Hotkeys.ListDown, msg): m.ListDown() case slices.Contains(common.Hotkeys.Quit, msg): m.Close() case slices.Contains(common.Hotkeys.SearchBar, msg): m.searchBar.Focus() } } func (m *Model) HandleTeaMsg(msg tea.Msg) tea.Cmd { var cmd tea.Cmd if m.searchBar.Focused() { m.searchBar, cmd = m.searchBar.Update(msg) } return cmd } func (m *Model) SetDimensions(width int, height int) { m.width = width m.height = height // 2 for border, 1 for left padding, 2 for placeholder icon of searchbar // 1 for additional character that View() of search bar function mysteriously adds. m.searchBar.Width = m.width - (common.InnerPadding + common.BorderPadding) } ================================================ FILE: src/internal/ui/helpmenu/navigation.go ================================================ package helpmenu import "github.com/yorukot/superfile/src/internal/common" // Help menu panel list up func (m *Model) ListUp() { if m.cursor > 1 { m.cursor-- if m.cursor < m.renderIndex { m.renderIndex = m.cursor } if m.filteredData[m.cursor].subTitle != "" { m.cursor-- } } else { // Set the cursor to the last item in the list. // We use max(..., 0) as a safeguard to prevent a negative cursor index // in case the filtered list is empty. m.cursor = max(len(m.filteredData)-1, 0) // Adjust the render index to show the bottom of the list. // Similarly, we use max(..., 0) to ensure the renderIndex doesn't become negative, // which can happen if the number of items is less than the view height. // This prevents a potential out-of-bounds panic during rendering. m.renderIndex = max(len(m.filteredData)-(m.height-common.InnerPadding), 0) } } // Help menu panel list down func (m *Model) ListDown() { if len(m.filteredData) == 0 { return } if m.cursor < len(m.filteredData)-1 { // Compute the next selectable row (skip subtitles). next := m.cursor + 1 for next < len(m.filteredData) && m.filteredData[next].subTitle != "" { next++ } if next >= len(m.filteredData) { // Wrap if no more selectable rows. m.cursor = 1 m.renderIndex = 0 return } m.cursor = next // Scroll down if cursor moved past the viewport. if m.cursor > m.renderIndex+m.height-5 { m.renderIndex++ } // Clamp renderIndex to bottom. bottom := len(m.filteredData) - (m.height - common.InnerPadding) if bottom < 0 { bottom = 0 } if m.renderIndex > bottom { m.renderIndex = bottom } } else { m.cursor = 1 m.renderIndex = 0 } } ================================================ FILE: src/internal/ui/helpmenu/render.go ================================================ package helpmenu import ( "fmt" "strconv" "github.com/yorukot/superfile/src/config/icon" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui" "github.com/yorukot/superfile/src/internal/ui/rendering" ) func (m *Model) Render() string { r := ui.HelpMenuRenderer(m.height, m.width) r.AddLines(" " + m.searchBar.View()) r.AddLines("") // one-line separation between searchbar and content // TODO : This computation should not happen at render time. Move this to update // TODO : Move these computations to a utility function maxKeyLength := 0 for _, data := range m.filteredData { totalKeyLen := 0 for _, key := range data.hotkey { totalKeyLen += len(key) } separatorLen := max(0, (len(data.hotkey)-1)) * common.FooterGroupCols if data.subTitle == "" && totalKeyLen+separatorLen > maxKeyLength { maxKeyLength = totalKeyLen + separatorLen } } valueLength := m.width - maxKeyLength - common.BorderPadding if valueLength < m.width/common.CenterDivisor { valueLength = m.width/common.CenterDivisor - common.BorderPadding } totalTitleCount := 0 cursorBeenTitleCount := 0 for i, data := range m.filteredData { if data.subTitle != "" { if i < m.cursor { cursorBeenTitleCount++ } totalTitleCount++ } } renderHotkeyLength := m.getRenderHotkeyLength() m.getContent(r, renderHotkeyLength, valueLength) current := m.cursor + 1 - cursorBeenTitleCount if len(m.filteredData) == 0 { current = 0 } r.SetBorderInfoItems(fmt.Sprintf("%s/%s", strconv.Itoa(current), strconv.Itoa(len(m.filteredData)-totalTitleCount))) return r.Render() } func (m *Model) getRenderHotkeyLength() int { renderHotkeyLength := 0 for i := m.renderIndex; i < m.renderIndex+(m.height-common.InnerPadding) && i < len(m.filteredData); i++ { if m.filteredData[i].subTitle != "" { continue } hotkey := common.GetHelpMenuHotkeyString(m.filteredData[i].hotkey) renderHotkeyLength = max(renderHotkeyLength, len(common.HelpMenuHotkeyStyle.Render(hotkey))) } return renderHotkeyLength + 1 } func (m *Model) getContent(r *rendering.Renderer, renderHotkeyLength int, valueLength int) { for i := m.renderIndex; i < m.renderIndex+(m.height-common.InnerPadding) && i < len(m.filteredData); i++ { if m.filteredData[i].subTitle != "" { r.AddLines(common.HelpMenuTitleStyle.Render(" " + m.filteredData[i].subTitle)) continue } hotkey := common.GetHelpMenuHotkeyString(m.filteredData[i].hotkey) description := common.TruncateText(m.filteredData[i].description, valueLength, "...") cursor := " " if m.cursor == i { cursor = common.FilePanelCursorStyle.Render(icon.Cursor + " ") } r.AddLines(cursor + common.ModalStyle.Render(fmt.Sprintf("%*s%s", renderHotkeyLength, common.HelpMenuHotkeyStyle.Render(hotkey+" "), common.ModalStyle.Render(description)))) } } ================================================ FILE: src/internal/ui/helpmenu/type.go ================================================ package helpmenu import "github.com/charmbracelet/bubbles/textinput" type hotkeyType int const ( globalType hotkeyType = iota normalType selectType ) // Modal type Model struct { height int width int opened bool renderIndex int cursor int data []hotkeydata filteredData []hotkeydata searchBar textinput.Model } type hotkeydata struct { hotkey []string description string hotkeyWorkType hotkeyType subTitle string } ================================================ FILE: src/internal/ui/helpmenu/utils.go ================================================ package helpmenu func (m *Model) IsOpen() bool { return m.opened } func (m *Model) GetHeight() int { return m.height } func (m *Model) GetWidth() int { return m.width } ================================================ FILE: src/internal/ui/metadata/README.md ================================================ # metadata package This is for the metadata panel, fetching and rendering metadata. Since metadata fetching is not fully contained, some part of functionality is offloaded to main model # To-dos - Add unit tests - Finish required TODOs - Update coverage stats # Coverage ```bash cd /path/to/ui/metadata go test -cover ``` Current coverage is 0% ================================================ FILE: src/internal/ui/metadata/architecture.go ================================================ package metadata import ( "debug/elf" "debug/macho" "debug/pe" "errors" "fmt" "strings" ) const ( archI386 = "i386" archX8664 = "x86-64" archARM = "ARM" archARM64 = "ARM64" archPPC = "PowerPC" archPPC64 = "PowerPC64" archRISCV = "RISC-V" archS390x = "s390x" archSPARC64 = "SPARC64" archMIPS = "MIPS" ) var errNotBinary = errors.New("not a recognized binary format") func GetBinaryArchitecture(filePath string) (string, error) { if arch, err := getELFArchitecture(filePath); err == nil { return arch, nil } if arch, err := getPEArchitecture(filePath); err == nil { return arch, nil } if arch, err := getMachOArchitecture(filePath); err == nil { return arch, nil } return "", errNotBinary } func getELFArchitecture(filePath string) (string, error) { f, err := elf.Open(filePath) if err != nil { return "", err } defer f.Close() arch := elfMachineToString(f.Machine) return fmt.Sprintf("ELF %s", arch), nil } func getPEArchitecture(filePath string) (string, error) { f, err := pe.Open(filePath) if err != nil { return "", err } defer f.Close() arch := peArchitectureToString(f.Machine) return fmt.Sprintf("PE %s", arch), nil } func getMachOArchitecture(filePath string) (string, error) { f, err := macho.Open(filePath) if err == nil { defer f.Close() arch := machoCPUToString(f.Cpu) return fmt.Sprintf("Mach-O %s", arch), nil } fat, err := macho.OpenFat(filePath) if err != nil { return "", err } defer fat.Close() archs := make([]string, 0, len(fat.Arches)) for _, arch := range fat.Arches { archs = append(archs, machoCPUToString(arch.Cpu)) } if len(archs) == 1 { return fmt.Sprintf("Mach-O %s", archs[0]), nil } return fmt.Sprintf("Mach-O Universal (%s)", strings.Join(archs, ", ")), nil } //nolint:exhaustive // common architectures only func elfMachineToString(machine elf.Machine) string { switch machine { case elf.EM_386: return archI386 case elf.EM_X86_64: return archX8664 case elf.EM_ARM: return archARM case elf.EM_AARCH64: return archARM64 case elf.EM_MIPS: return archMIPS case elf.EM_PPC: return archPPC case elf.EM_PPC64: return archPPC64 case elf.EM_RISCV: return archRISCV case elf.EM_S390: return archS390x case elf.EM_SPARCV9: return archSPARC64 default: return machine.String() } } func peArchitectureToString(machine uint16) string { switch machine { case pe.IMAGE_FILE_MACHINE_I386: return archI386 case pe.IMAGE_FILE_MACHINE_AMD64: return archX8664 case pe.IMAGE_FILE_MACHINE_ARM: return archARM case pe.IMAGE_FILE_MACHINE_ARM64: return archARM64 default: return fmt.Sprintf("Unknown (0x%x)", machine) } } func machoCPUToString(cpu macho.Cpu) string { switch cpu { case macho.Cpu386: return archI386 case macho.CpuAmd64: return archX8664 case macho.CpuArm: return archARM case macho.CpuArm64: return archARM64 case macho.CpuPpc: return archPPC case macho.CpuPpc64: return archPPC64 default: return cpu.String() } } ================================================ FILE: src/internal/ui/metadata/architecture_test.go ================================================ package metadata import ( "debug/elf" "os" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetBinaryArchitecture_NonBinaryFile(t *testing.T) { tmpFile := t.TempDir() + "/test.txt" err := os.WriteFile(tmpFile, []byte("This is not a binary file"), 0o644) require.NoError(t, err) arch, err := GetBinaryArchitecture(tmpFile) require.Error(t, err) assert.Empty(t, arch) } func TestGetBinaryArchitecture_NonExistentFile(t *testing.T) { arch, err := GetBinaryArchitecture("/nonexistent/file/path") require.Error(t, err) assert.Empty(t, arch) } func TestGetBinaryArchitecture_CurrentBinary(t *testing.T) { executable, err := os.Executable() if err != nil { t.Skip("Could not get current executable path") } arch, err := GetBinaryArchitecture(executable) require.NoError(t, err) assert.NotEmpty(t, arch) hasValidPrefix := strings.HasPrefix(arch, "ELF") || strings.HasPrefix(arch, "PE") || strings.HasPrefix(arch, "Mach-O") assert.True(t, hasValidPrefix, "Architecture should start with a known format prefix, got: %s", arch) } func TestElfMachineToString(t *testing.T) { tests := []struct { name string input uint16 expected string }{ {"x86-64", 0x3E, archX8664}, {"i386", 0x03, archI386}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expected, elfMachineToString(elf.Machine(tt.input))) }) } } func TestPeArchitectureToString(t *testing.T) { assert.Equal(t, archI386, peArchitectureToString(0x14c)) assert.Equal(t, archX8664, peArchitectureToString(0x8664)) assert.Equal(t, archARM, peArchitectureToString(0x1c0)) assert.Equal(t, archARM64, peArchitectureToString(0xaa64)) assert.Contains(t, peArchitectureToString(0x9999), "Unknown") } ================================================ FILE: src/internal/ui/metadata/const.go ================================================ package metadata import "time" // Spacing between Key and Value while rendering const keyValueSpacing = " " const keyValueSpacingLen = 1 const fileStatErrorMsg = "Cannot load file stats" const linkFileBrokenMsg = "Link file is broken!" const etFetchErrorMsg = "Errors while fetching metadata via exiftool" const keyName = "Name" const keySize = "Size" const keyDataModified = "Date Modified" const keyDataAccessed = "Date Accessed" const keyPermissions = "Permissions" const keyMd5Checksum = "MD5Checksum" const keyOwner = "Owner" const keyGroup = "Group" const keyPath = "Path" const keyArchitecture = "Architecture" const borderSize = 2 // Cache configuration const defaultCacheSize = 300 const defaultCacheExpiration = 5 * time.Minute var sortPriority = map[string]int{ //nolint: gochecknoglobals // This is effectively const. // Metadata field priority indices for display ordering keyName: 0, keySize: 1, keyDataModified: 2, //nolint:mnd // display order index keyDataAccessed: 3, //nolint:mnd // display order index keyPermissions: 4, //nolint:mnd // display order index keyOwner: 5, //nolint:mnd // display order index keyGroup: 6, //nolint:mnd // display order index keyPath: 7, //nolint:mnd // display order index keyArchitecture: 8, //nolint:mnd // display order index } ================================================ FILE: src/internal/ui/metadata/metadata.go ================================================ package metadata import ( "fmt" "log/slog" "os" "path/filepath" "sort" "github.com/barasher/go-exiftool" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/common" ) type Metadata struct { // Stores key value pairs data [][2]string infoMsg string filepath string } func NewMetadata(data [][2]string, filepath string, infoMsg string) Metadata { return Metadata{ data: data, filepath: filepath, infoMsg: infoMsg, } } func (m Metadata) GetPath() string { return m.filepath } func (m Metadata) GetData() [][2]string { return m.data } func (m Metadata) GetValue(key string) (string, error) { for _, pair := range m.data { if pair[0] == key { return pair[1], nil } } return "", fmt.Errorf("key %s not found", key) } // Note : We dont use map[string]string, as metadata // 1 -> We dont need to support get(key) yet. Only usage is via iterating the whole list // 2 -> We need custom ordering func sortMetadata(meta [][2]string) { sort.SliceStable(meta, func(i, j int) bool { pi, iOkay := sortPriority[meta[i][0]] pj, jOkay := sortPriority[meta[j][0]] // Both are priority fields if iOkay && jOkay { return pi < pj } // i is a priority field, and j is not if iOkay { return true } // j is a priority field, and i is not if jOkay { return false } // None of them are priority fields, sort with name return meta[i][0] < meta[j][0] }) } func GetMetadata(filePath string, metadataFocused bool, et *exiftool.Exiftool) Metadata { meta := getMetaDataUnsorted(filePath, metadataFocused, et) sortMetadata(meta.data) return meta } func getSymLinkMetaData(filePath string) Metadata { res := Metadata{ filepath: filePath, } linkPath, symlinkErr := filepath.EvalSymlinks(filePath) if symlinkErr != nil { res.infoMsg = linkFileBrokenMsg } else { path := [2]string{keyPath, linkPath} res.data = append(res.data, path) } return res } func getMetaDataUnsorted(filePath string, metadataFocused bool, et *exiftool.Exiftool) Metadata { res := Metadata{ filepath: filePath, } fileInfo, err := os.Lstat(filePath) if err != nil { res.infoMsg = fileStatErrorMsg return res } if fileInfo.Mode()&os.ModeSymlink != 0 { return getSymLinkMetaData(filePath) } // Add basic metadata information irrespective of what is fetched from exiftool // Note : we prioritize these while sorting Metadata name := [2]string{keyName, fileInfo.Name()} size := [2]string{keySize, common.FormatFileSize(fileInfo.Size())} modifyDate := [2]string{keyDataModified, fileInfo.ModTime().String()} permissions := [2]string{keyPermissions, fileInfo.Mode().String()} ownerVal, groupVal := getOwnerAndGroup(fileInfo) owner := [2]string{keyOwner, ownerVal} group := [2]string{keyGroup, groupVal} if fileInfo.IsDir() && metadataFocused { // TODO : Calling dirSize() could be expensive for large directories, as it recursively // walks the entire tree. For now we have async approach of loading metadata, // and its only loaded when metadata panel is focused. size = [2]string{keySize, common.FormatFileSize(utils.DirSize(filePath))} } res.data = append(res.data, name, size, modifyDate, permissions, owner, group) if fileInfo.Mode().IsRegular() { if arch, err := GetBinaryArchitecture(filePath); err == nil { archData := [2]string{keyArchitecture, arch} res.data = append(res.data, archData) } } updateExiftoolMetadata(filePath, et, &res) if fileInfo.Mode().IsRegular() && common.Config.EnableMD5Checksum { // Calculate MD5 checksum checksum, err := calculateMD5Checksum(filePath) if err != nil { slog.Error("Error calculating MD5 checksum", "error", err) } else { md5Data := [2]string{keyMd5Checksum, checksum} res.data = append(res.data, md5Data) } } return res } func updateExiftoolMetadata(filePath string, et *exiftool.Exiftool, res *Metadata) { if !common.Config.Metadata || et == nil { return } fileInfos := et.ExtractMetadata(filePath) for _, fileInfo := range fileInfos { if fileInfo.Err != nil { res.infoMsg = etFetchErrorMsg slog.Error("Error while return metadata function", "fileInfo", fileInfo, "error", fileInfo.Err) continue } for k, v := range fileInfo.Fields { res.data = append(res.data, [2]string{k, fmt.Sprintf("%v", v)}) } } } ================================================ FILE: src/internal/ui/metadata/metadata_test.go ================================================ package metadata import ( "path/filepath" "runtime" "testing" "github.com/barasher/go-exiftool" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" ) func TestGetMetadata(t *testing.T) { if runtime.GOOS != utils.OsLinux { t.Skip("Skipping metatada fetch test in windows and macOS") } et, err := exiftool.NewExiftool() require.NoError(t, err) _, curFilename, _, ok := runtime.Caller(0) testdataDir := filepath.Join(filepath.Dir(curFilename), "testdata") defaultKeys := []string{keyName, keySize, keyDataModified, keyPermissions} require.True(t, ok) testdata := []struct { name string filepath string metadataFocused bool }{ { name: "Basic Metadata fetching", filepath: filepath.Join(testdataDir, "file1.txt"), metadataFocused: true, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { meta := GetMetadata(tt.filepath, tt.metadataFocused, et) assert.Empty(t, meta.infoMsg) assert.Equal(t, tt.filepath, meta.filepath) for _, key := range defaultKeys { _, err := meta.GetValue(key) require.NoError(t, err) } }) } } ================================================ FILE: src/internal/ui/metadata/metadata_unix.go ================================================ //go:build !windows package metadata import ( "os" "os/user" "strconv" "syscall" ) func getOwnerAndGroup(fileInfo os.FileInfo) (string, string) { usr := "" grp := "" if stat, ok := fileInfo.Sys().(*syscall.Stat_t); ok { uid := strconv.FormatUint(uint64(stat.Uid), 10) gid := strconv.FormatUint(uint64(stat.Gid), 10) if userData, err := user.LookupId(uid); err == nil { usr = userData.Username } if groupData, err := user.LookupGroupId(gid); err == nil { grp = groupData.Name } } return usr, grp } ================================================ FILE: src/internal/ui/metadata/metadata_windows.go ================================================ //go:build windows package metadata import ( "os" ) func getOwnerAndGroup(_ os.FileInfo) (string, string) { return "", "" } ================================================ FILE: src/internal/ui/metadata/model.go ================================================ package metadata import ( "fmt" "github.com/yorukot/superfile/src/internal/ui" "github.com/yorukot/superfile/src/pkg/cache" ) type Model struct { metadata Metadata // current metadata cache *cache.Cache[Metadata] // It tells what the metadata should have. Its used to prevent additional requests // if one is already underway expectedLocation string expectedFocused bool // Render state renderIndex int // Model Dimensions, including borders width int height int } func New() Model { return Model{ cache: cache.New[Metadata](defaultCacheSize, defaultCacheExpiration), } } // Should be at least 2x2 // TODO : Validate this func (m *Model) SetDimensions(width int, height int) { m.width = width m.height = height } func (m *Model) GetHeight() int { return m.height } func (m *Model) GetWidth() int { return m.width } func (m *Model) ResetRenderIfInvalid() { if m.renderIndex >= m.MetadataLen() { m.ResetRender() } } func (m *Model) ResetRender() { m.renderIndex = 0 } func (m *Model) MetadataLen() int { return len(m.metadata.data) } // Control metadata panel up func (m *Model) ListUp() { if m.MetadataLen() == 0 { return } if m.renderIndex > 0 { m.renderIndex-- } else { m.renderIndex = m.MetadataLen() - 1 } } // Control metadata panel down func (m *Model) ListDown() { if m.renderIndex < m.MetadataLen()-1 { m.renderIndex++ } else { m.renderIndex = 0 } } func (m *Model) SetBlank() { m.metadata.filepath = "" m.metadata.data = m.metadata.data[:0] m.metadata.infoMsg = "No metadata present" } func (m *Model) IsBlank() bool { return m.MetadataLen() == 0 && m.metadata.infoMsg == "" } func (m *Model) SetInfoMsg(msg string) { m.metadata.infoMsg = msg } func (m *Model) Render(metadataFocused bool) string { r := ui.MetadataRenderer(m.height, m.width, metadataFocused) if m.MetadataLen() == 0 { r.AddLines("", " "+m.metadata.infoMsg) return r.Render() } keyLen, valueLen := computeRenderDimensions(m.metadata.data, m.width-2-keyValueSpacingLen) r.SetBorderInfoItems(fmt.Sprintf("%d/%d", m.renderIndex+1, len(m.metadata.data))) lines := formatMetadataLines(m.metadata.data, m.renderIndex, m.height-borderSize, keyLen, valueLen) r.AddLines(lines...) return r.Render() } ================================================ FILE: src/internal/ui/metadata/model_test.go ================================================ package metadata import ( "testing" "github.com/stretchr/testify/assert" ) func TestUpDown(t *testing.T) { defaultMetadata := Metadata{ data: make([][2]string, 5), } testdata := []struct { name string m Model listDown bool // Whether to do listDown or listUp expectedRenderIndex int }{ { name: "Basic down movement 1", m: Model{ metadata: defaultMetadata, renderIndex: 0, }, listDown: true, expectedRenderIndex: 1, }, { name: "Down wraps to top", m: Model{ metadata: defaultMetadata, renderIndex: 4, }, listDown: true, expectedRenderIndex: 0, }, { name: "Basic up movement 1", m: Model{ metadata: defaultMetadata, renderIndex: 4, }, listDown: false, expectedRenderIndex: 3, }, { name: "Up wraps to top", m: Model{ metadata: defaultMetadata, renderIndex: 0, }, listDown: false, expectedRenderIndex: 4, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { if tt.listDown { tt.m.ListDown() } else { tt.m.ListUp() } assert.Equal(t, tt.expectedRenderIndex, tt.m.renderIndex) }) } } ================================================ FILE: src/internal/ui/metadata/testdata/file1.txt ================================================ 12345678 ================================================ FILE: src/internal/ui/metadata/update.go ================================================ package metadata import "strconv" func (m *Model) SetMetadataCache(metadata Metadata, metadataFocused bool) { m.cache.Set(cacheKey(metadata.filepath, metadataFocused), metadata) } func (m *Model) SetMetadata(metadata Metadata, metadataFocused bool) { m.metadata = metadata m.SetMetadataLocationAndFocused(metadata.filepath, metadataFocused) // Note : Dont always reset render to 0 // We would have update requests coming in during user scrolling through metadata m.ResetRenderIfInvalid() } func (m *Model) GetMetadataLocation() string { return m.expectedLocation } func (m *Model) GetMetadataExpectedFocused() bool { return m.expectedFocused } func (m *Model) SetMetadataLocationAndFocused(filepath string, metadataFocused bool) { m.expectedLocation = filepath m.expectedFocused = metadataFocused } func cacheKey(filePath string, metadataFocused bool) string { return filePath + ":" + strconv.FormatBool(metadataFocused) } func (m *Model) UpdateMetadataIfExistsInCache(filepath string, metadataFocused bool) bool { if meta, ok := m.cache.Get(cacheKey(filepath, metadataFocused)); ok { m.SetMetadata(meta, metadataFocused) return true } return false } ================================================ FILE: src/internal/ui/metadata/utils.go ================================================ package metadata import ( "crypto/md5" //nolint:gosec // MD5 used for file checksum display only, not for security "encoding/hex" "fmt" "io" "os" "github.com/yorukot/superfile/src/internal/common" ) func getMaxKeyLength(meta [][2]string) int { maxLen := 0 for _, pair := range meta { if len(pair[0]) > maxLen { maxLen = len(pair[0]) } } return maxLen } func computeMetadataWidths(viewWidth, maxKeyLen int) (int, int) { keyLen := maxKeyLen valueLen := viewWidth - keyLen if valueLen < viewWidth/2 { //nolint:mnd // standard halving for center split valueLen = viewWidth / 2 keyLen = viewWidth - valueLen } return keyLen, valueLen } // TODO : Simplify these mystic calculations, or add explanation comments. // TODO : unit test and fix this mess func formatMetadataLines(meta [][2]string, startIdx, height, keyLen, valueLen int) []string { lines := []string{} endIdx := min(startIdx+height, len(meta)) for i := startIdx; i < endIdx; i++ { value := common.TruncateMiddleText(meta[i][1], valueLen, "...") key := common.TruncateMiddleText(meta[i][0], keyLen, "...") line := fmt.Sprintf("%-*s%s%s", keyLen, key, keyValueSpacing, value) lines = append(lines, line) } return lines } func computeRenderDimensions(metadata [][2]string, viewWidth int) (int, int) { // Compute dimension based values maxKeyLen := getMaxKeyLength(metadata) return computeMetadataWidths(viewWidth, maxKeyLen) } // TODO : Unit test this func calculateMD5Checksum(filePath string) (string, error) { file, err := os.Open(filePath) if err != nil { return "", fmt.Errorf("failed to open file: %w", err) } defer file.Close() hash := md5.New() //nolint:gosec // MD5 is sufficient for file integrity display, not used for security if _, err := io.Copy(hash, file); err != nil { return "", fmt.Errorf("failed to calculate MD5 checksum: %w", err) } checksum := hex.EncodeToString(hash.Sum(nil)) return checksum, nil } ================================================ FILE: src/internal/ui/notify/model.go ================================================ package notify import ( "github.com/yorukot/superfile/src/internal/common" ) type Model struct { open bool title string content string confirmAction ConfirmActionType } func New(open bool, title string, content string, confirmAction ConfirmActionType) Model { return Model{ open: open, title: title, content: content, confirmAction: confirmAction, } } func (m *Model) GetTitle() string { return m.title } func (m *Model) GetContent() string { return m.content } func (m *Model) IsOpen() bool { return m.open } func (m *Model) Open() { m.open = true } func (m *Model) Close() { m.open = false } func (m *Model) GetConfirmAction() ConfirmActionType { return m.confirmAction } // TODO: Remove code duplication with typineModalRender func (m *Model) Render() string { var inputKeysText string if m.confirmAction == NoAction { inputKeysText = common.ModalOkayInputText } else { inputKeysText = common.ModalConfirmInputText + common.ModalInputSpacingText + common.ModalCancelInputText } return common.ModalBorderStyle(common.ModalHeight, common.ModalWidth). Render(m.title + "\n\n" + m.content + "\n\n" + inputKeysText) } ================================================ FILE: src/internal/ui/notify/type.go ================================================ package notify type ConfirmActionType int const ( RenameAction ConfirmActionType = iota DeleteAction QuitAction NoAction PermanentDeleteAction ) ================================================ FILE: src/internal/ui/preview/model.go ================================================ package preview import ( "log/slog" "github.com/yorukot/superfile/src/internal/common" filepreview "github.com/yorukot/superfile/src/pkg/file_preview" ) type Model struct { open bool // Location denotes what is supposed to be in model. // Might not be always in sync with content location string content string contentWidth int contentHeight int loading bool imagePreviewer *filepreview.ImagePreviewer batCmd string thumbnailGenerator *filepreview.ThumbnailGenerator } func New() Model { generator, err := filepreview.NewThumbnailGenerator() if err != nil { slog.Error("Could not NewThumbnailGenerator object", "error", err) } return Model{ open: common.Config.DefaultOpenFilePreview, // TODO: This causes unnecessary terminal cell size detection // logs in tests, we should not be initializing it in tests // when `DefaultOpenFilePreview` is false // And only initialize these objects when Open() is called // Still them being nil should be handled well, right we don't // have code with good defensive programming // Some of these processes are IO operations so maybe it should // be done via an Init() function imagePreviewer: filepreview.NewImagePreviewer(), thumbnailGenerator: generator, // TODO: This is an IO operation, move to async ? batCmd: checkBatCmd(), } } ================================================ FILE: src/internal/ui/preview/model_utils.go ================================================ package preview import "log/slog" func (m *Model) GetContent() string { return m.content } func (m *Model) GetContentWidth() int { return m.contentWidth } func (m *Model) GetContentHeight() int { return m.contentHeight } func (m *Model) GetLocation() string { return m.location } func (m *Model) SetOpen(open bool) { m.open = open } func (m *Model) SetLocation(location string) { m.location = location } func (m *Model) SetLoading() { m.loading = true } // All content change happen via this only, to ensure the sync between // content and width x height, and the loading variable reset func (m *Model) setContent(content string, width int, height int, location string) { m.content = content m.contentWidth = width m.contentHeight = height m.location = location m.loading = false } func (m *Model) SetEmptyWithDimensions(width int, height int) { m.setContent(m.RenderTextWithDimension("", height, width), width, height, "") } func (m *Model) IsLoading() bool { return m.loading } func (m *Model) ToggleOpen() { m.open = !m.open } func (m *Model) CleanUp() { if m.thumbnailGenerator != nil { err := m.thumbnailGenerator.CleanUp() if err != nil { slog.Error("Error While cleaning up TempDirectory", "error", err) } } } func (m *Model) IsOpen() bool { return m.open } func (m *Model) Open() { m.open = true } func (m *Model) Close() { m.open = false } ================================================ FILE: src/internal/ui/preview/render.go ================================================ package preview import ( "errors" "image" "io/fs" "log/slog" "os" "path/filepath" "slices" "sort" "strings" "github.com/alecthomas/chroma/v2/lexers" "github.com/charmbracelet/lipgloss" "github.com/yorukot/ansichroma" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui" "github.com/yorukot/superfile/src/internal/ui/rendering" ) func renderDirectoryPreview(r *rendering.Renderer, itemPath string, previewHeight int) string { files, err := os.ReadDir(itemPath) if err != nil { slog.Error("Error render directory preview", "error", err) r.AddLines(common.FilePreviewDirectoryUnreadableText) return r.Render() } if len(files) == 0 { r.AddLines(common.FilePreviewEmptyText) return r.Render() } sort.Slice(files, func(i, j int) bool { if files[i].IsDir() && !files[j].IsDir() { return true } if !files[i].IsDir() && files[j].IsDir() { return false } return files[i].Name() < files[j].Name() }) for i := 0; i < previewHeight && i < len(files); i++ { file := files[i] isLink := false if info, err := file.Info(); err == nil { isLink = info.Mode()&os.ModeSymlink != 0 } style := common.GetElementIcon(file.Name(), file.IsDir(), isLink, common.Config.Nerdfont) res := lipgloss.NewStyle().Foreground(lipgloss.Color(style.Color)).Background(common.FilePanelBGColor). Render(style.Icon+" ") + common.FilePanelStyle.Render(file.Name()) r.AddLines(res) } return r.Render() } func (m *Model) renderImagePreview(r *rendering.Renderer, itemPath string, previewWidth, previewHeight int, sideAreaWidth int, clearCmd string, ) string { if !m.open { return r.AddLines(common.FilePreviewPanelClosedText).Render() + clearCmd } if !common.Config.ShowImagePreview { return r.AddLines(common.FilePreviewImagePreviewDisabledText).Render() + clearCmd } // Use the new auto-detection function to choose the best renderer imageRender, err := m.imagePreviewer.ImagePreview(itemPath, previewWidth, previewHeight, common.Theme.FilePanelBG, sideAreaWidth) if errors.Is(err, image.ErrFormat) { return r.AddLines(common.FilePreviewUnsupportedImageFormatsText).Render() + clearCmd } if err != nil { slog.Error("Error convert image to ansi", "error", err) return r.AddLines(common.FilePreviewImageConversionErrorText).Render() + clearCmd } // Check if this looks like Kitty protocol output (starts with escape sequences) // For Kitty protocol, avoid using lipgloss alignment to prevent layout drift if strings.HasPrefix(imageRender, "\x1b_G") { r.AddLines(imageRender) return r.Render() } // For ANSI output, we can safely use vertical alignment return r.AddStyleModifier(func(s lipgloss.Style) lipgloss.Style { return s.AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center) }).AddLines(imageRender).Render() + clearCmd } func (m *Model) renderTextPreview(r *rendering.Renderer, itemPath string, previewWidth, previewHeight int, ) string { format := lexers.Match(filepath.Base(itemPath)) if format == nil { isText, err := common.IsTextFile(itemPath) if err != nil { slog.Error("Error while checking text file", "error", err) return r.AddLines(common.FilePreviewError).Render() } else if !isText { return r.AddLines(common.FilePreviewUnsupportedFormatText).Render() } } fileContent, err := utils.ReadFileContent(itemPath, previewWidth, previewHeight) if err != nil { slog.Error("Error open file", "error", err) return r.AddLines(common.FilePreviewError).Render() } if fileContent == "" { return r.AddLines(common.FilePreviewEmptyText).Render() } if format != nil { background := "" if !common.Config.TransparentBackground { background = common.Theme.FilePanelBG } if common.Config.CodePreviewer == "bat" { if m.batCmd == "" { return r.AddLines(common.FilePreviewBatNotInstalledText).Render() } fileContent, err = getBatSyntaxHighlightedContent(itemPath, previewHeight, background, m.batCmd) } else { fileContent, err = ansichroma.HightlightString(fileContent, format.Config().Name, common.Theme.CodeSyntaxHighlightTheme, background) } if err != nil { slog.Error("Error render code highlight", "error", err) return r.AddLines(common.FilePreviewError).Render() } } r.AddLines(fileContent) return r.Render() } // Only use this when height and width are synced with filemodel's expectations func (m *Model) RenderText(text string) string { return m.RenderTextWithDimension(text, m.contentHeight, m.contentWidth) } func (m *Model) RenderTextWithDimension(text string, height int, width int) string { // For zero size, don't need to render anything. Its kinda hack, but // its to prevent error logs clearCmd := m.imagePreviewer.ClearKittyImages() if width == 0 && height == 0 { return clearCmd } return ui.FilePreviewPanelRenderer(height, width). AddLines(text). Render() + clearCmd } func (m *Model) RenderWithPath(itemPath string, previewWidth int, previewHeight int, fullModelWidth int) string { r := ui.FilePreviewPanelRenderer(previewHeight, previewWidth) clearCmd := m.imagePreviewer.ClearKittyImages() // Adjust dimensions if border is enabled contentWidth := previewWidth contentHeight := previewHeight if common.Config.EnableFilePreviewBorder { contentWidth = previewWidth - common.BorderPadding contentHeight = previewHeight - common.BorderPadding } fileInfo, infoErr := os.Stat(itemPath) if infoErr != nil { slog.Error("Error get file info", "error", infoErr) return r.AddLines(common.FilePreviewNoFileInfoText).Render() + clearCmd } slog.Debug("Attempting to render preview", "itemPath", itemPath, "mode", fileInfo.Mode().String(), "isRegular", fileInfo.Mode().IsRegular()) // For non regular files which are not directories Dont try to read them // See Issue #876 if !fileInfo.Mode().IsRegular() && (fileInfo.Mode()&fs.ModeDir) == 0 { return r.AddLines(common.FilePreviewUnsupportedFileMode).Render() + clearCmd } ext := filepath.Ext(itemPath) if slices.Contains(common.UnsupportedPreviewFormats, ext) { return r.AddLines(common.FilePreviewUnsupportedFormatText).Render() + clearCmd } if fileInfo.IsDir() { return renderDirectoryPreview(r, itemPath, contentHeight) + clearCmd } if m.thumbnailGenerator != nil && m.thumbnailGenerator.SupportsExt(ext) { thumbnailPath, err := m.thumbnailGenerator.GetThumbnailOrGenerate(itemPath) if err != nil { slog.Error("Error generating thumbnail", "error", err) return r.AddLines(common.FilePreviewThumbnailGenerationErrorText).Render() + clearCmd } // Notes : If renderImagePreview fails, and return some error message // render, then we dont apply clearCmd. This might cause issues. // same for below usage of renderImagePreview return m.renderImagePreview( r, thumbnailPath, contentWidth, contentHeight, fullModelWidth-previewWidth, clearCmd) } if isImageFile(itemPath) { return m.renderImagePreview( r, itemPath, contentWidth, contentHeight, fullModelWidth-previewWidth, clearCmd) } return m.renderTextPreview(r, itemPath, contentWidth, contentHeight) + clearCmd } ================================================ FILE: src/internal/ui/preview/render_test.go ================================================ package preview import ( "os" "path/filepath" "strconv" "testing" "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" ) /* - TODO Tests - testdata with 10-15 small files(< 100 bytes each) with all kind of contents - ascii control chars - bin content - video, pdf, image, corrupted files, files with bad perms?, - symlinks, directories, */ func TestFilePreviewRenderWithDimensions(t *testing.T) { testDir := t.TempDir() // Test that // 1 - we can truncate width and height // 2 - We add extra whitespace to make up for width and height // 3 - Emojis and special unicodes characters can be rendered and Special characters - ~!@#$%^&*()_+-={}\"" // 4 - File with spaces, tabs, unicode spaces, etc, is rendered correctly // 5 - File with problematic characters like ascii control char, invalid unicodes etc, // is cleaned up // Additional tests // 1 - File with ascii color sequences can be rendered correctly // 2 - Test all cases - unsupported file, non text file curTestDir := filepath.Join(testDir, "TestFilePreviewRender") // Cleanup is taken care by TestMain() utils.SetupDirectories(t, curTestDir) testdata := []struct { name string fileContent string fileName string height int width int expectedPreview string }{ { name: "Basic test", fileContent: "" + "abcd\n" + "1234", fileName: "basic.txt", height: 2, width: 4, expectedPreview: "" + "abcd\n" + "1234", }, { name: "Width and height truncation", fileContent: "" + "abcd\n" + "1234\n" + "WXYZ", fileName: "truncate.txt", height: 2, width: 3, expectedPreview: "" + "abc\n" + "123", }, { name: "Whitespace filling", fileContent: "" + "abc\n" + "123", fileName: "fill.txt", height: 3, width: 4, expectedPreview: "" + "abc \n" + "123 \n" + " ", }, { name: "Special char, Emojies and special unicodes", fileContent: "" + "✅\uf410\U000f0868abcdABCD0123~\n" + "!@#$%^&*()_+-={}|:\"<>?,./;'[]", fileName: "special.txt", height: 2, width: 30, expectedPreview: "" + "✅\uf410\U000f0868abcdABCD0123~ \n" + "!@#$%^&*()_+-={}|:\"<>?,./;'[] ", }, { // Contains various Unicode whitespace characters: // U+00A0 (NO-BREAK SPACE) // U+202F (NARROW NO-BREAK SPACE) // U+205F (MEDIUM MATHEMATICAL SPACE) // U+2029 (PARAGRAPH SEPARATOR) name: "Whitespace handling", fileContent: "" + "\n" + "\t1\t\t2\t\n" + "0\u00a01\u00a02\u202f3\u205f4\u20295\u202f6\u205f7\u2029\n" + "0\u30001\u30002", fileName: "whitespace.txt", height: 5, width: 12, expectedPreview: "" + " \n" + " 1 \n" + "0\u00a01\u00a02 3 4 5 \n" + "0 1 2 \n" + " ", }, { // Contains control characters: // \x0b (Vertical Tab) // \x0d (Carriage Return) // \x00 (Null) // \x05 (Enquiry) // \x0f (Shift In) // \x7f (Delete) // \xa0 (Non-breaking space) // \ufffd (Replacement character) name: "Invalid character cleanup", fileContent: "" + "\x0b\x0d\x00\x05\x0f\x7f\xa0\ufffd", fileName: "invalid.txt", height: 2, width: 10, expectedPreview: "" + " \n" + " ", }, } for i, tt := range testdata { t.Run(tt.name, func(t *testing.T) { curDir := filepath.Join(curTestDir, "dir"+strconv.Itoa(i)) utils.SetupDirectories(t, curDir) filePath := filepath.Join(curDir, tt.fileName) err := os.WriteFile(filePath, []byte(tt.fileContent), 0o644) require.NoError(t, err) m := New() res := ansi.Strip(m.RenderWithPath(filePath, tt.width, tt.height, tt.width)) assert.Equal(t, tt.expectedPreview, res, "filePath = %s", filePath) }) } } ================================================ FILE: src/internal/ui/preview/render_unix_test.go ================================================ //go:build !windows package preview import ( "path/filepath" "syscall" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/internal/common" ) func TestFilePreviewWithInvalidMode(t *testing.T) { curTestDir := t.TempDir() file := filepath.Join(curTestDir, "testf") err := syscall.Mkfifo(file, 0644) require.NoError(t, err) m := New() res := m.RenderWithPath(file, 20, 10, 20) assert.Contains(t, res, common.FilePreviewUnsupportedFileMode) } ================================================ FILE: src/internal/ui/preview/render_utils.go ================================================ package preview import ( "context" "fmt" "log/slog" "os/exec" "path/filepath" "strings" "github.com/charmbracelet/lipgloss" "github.com/yorukot/superfile/src/internal/common" ) func getBatSyntaxHighlightedContent( itemPath string, previewLine int, background string, batCmd string, ) (string, error) { // --plain: use the plain style without line numbers and decorations // --force-colorization: force colorization for non-interactive shell output // --line-range <:m>: only read from line 1 to line "m" batArgs := []string{itemPath, "--plain", "--force-colorization", "--line-range", fmt.Sprintf(":%d", previewLine)} // set timeout for the external command execution to 500ms max ctx, cancel := context.WithTimeout(context.Background(), common.DefaultPreviewTimeout) defer cancel() cmd := exec.CommandContext(ctx, batCmd, batArgs...) fileContentBytes, err := cmd.Output() if err != nil { slog.Error("Error render code highlight", "error", err) return "", err } fileContent := string(fileContentBytes) if !common.Config.TransparentBackground { fileContent = setBatBackground(fileContent, background) } return fileContent, nil } func setBatBackground(input string, background string) string { tokens := strings.Split(input, "\x1b[0m") backgroundStyle := lipgloss.NewStyle().Background(lipgloss.Color(background)) for idx, token := range tokens { tokens[idx] = backgroundStyle.Render(token) } return strings.Join(tokens, "\x1b[0m") } // Check if bat is an executable in PATH and whether to use bat or batcat as command func checkBatCmd() string { if _, err := exec.LookPath("bat"); err == nil { return "bat" } // on ubuntu bat executable is called batcat if _, err := exec.LookPath("batcat"); err == nil { return "batcat" } return "" } func isImageFile(filename string) bool { return common.ImageExtensions[strings.ToLower(filepath.Ext(filename))] } ================================================ FILE: src/internal/ui/preview/update.go ================================================ package preview // UpdateMsg represents an async query result type UpdateMsg struct { // location can contain either the path of current content's file // or path of file whose preview request is in flight. // It should not have past data location string // preview panel's content needs to be in sync with its width/height // you cannot update width/height without updating the content content string contentWidth int contentHeight int reqID int } func NewUpdateMsg(location string, content string, width int, height int, reqID int) UpdateMsg { return UpdateMsg{ location: location, content: content, contentWidth: width, contentHeight: height, reqID: reqID, } } func (msg UpdateMsg) GetReqID() int { return msg.reqID } func (m *Model) Apply(msg UpdateMsg) { m.setContent(msg.content, msg.contentWidth, msg.contentHeight, msg.location) } func (msg UpdateMsg) GetLocation() string { return msg.location } func (msg UpdateMsg) GetContentWidth() int { return msg.contentWidth } func (msg UpdateMsg) GetContentHeight() int { return msg.contentHeight } ================================================ FILE: src/internal/ui/processbar/README.md ================================================ # processbar package This package is for processbar. This should not import internal package, and should not be aware of main 'model' # To-do - Finish code TODOs - Add end to end test with model - Add unit tests for Render(), and getSortedProcesses() ================================================ FILE: src/internal/ui/processbar/const.go ================================================ package processbar const ( // Min width and height for borders minHeight = 2 minWidth = 2 // This should allow smooth tracking of 5-10 active processes // In case we have issues in future, we could attempt to change this msgChannelSize = 50 // UI dimension constants for process bar rendering // borderSize is the border width for the process bar panel borderSize = 2 // progressBarRightPadding is padding after progress bar progressBarRightPadding = 3 // processNameTruncatePadding is the space reserved for ellipsis and icon in process name processNameTruncatePadding = 7 // linesPerProcess is the number of lines needed to render one process linesPerProcess = 3 ) ================================================ FILE: src/internal/ui/processbar/error.go ================================================ package processbar type ProcessChannelFullError struct { } func (p *ProcessChannelFullError) Error() string { return "process channel is full" } type NoProcessFoundError struct { id string } func (p *NoProcessFoundError) Error() string { return "no process with id : " + p.id } type ProcessAlreadyExistsError struct { id string } func (p *ProcessAlreadyExistsError) Error() string { return "process already exists with id : " + p.id } ================================================ FILE: src/internal/ui/processbar/model.go ================================================ package processbar import ( "fmt" "log/slog" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui" ) // Model for process bar internal type Model struct { renderIndex int cursor int // Including borders height int width int // TODO: Fix this. No mechanism to remove completed processes from memory // processes map grows indefinitely // Maybe, TTL or cleanup mechanism for successful/failed processes processes map[string]Process msgChan chan UpdateMsg reqCnt int } func New() Model { return NewModelWithOptions(minWidth, minHeight) } // Note: We should considering our internal models, they // should be returning pointer object, and implement tea.Model func NewModelWithOptions(width int, height int) Model { m := Model{ renderIndex: 0, cursor: 0, processes: make(map[string]Process), msgChan: make(chan UpdateMsg, msgChannelSize), reqCnt: 0, } m.SetDimensions(width, height) return m } func (m *Model) SetDimensions(width int, height int) { if width < minWidth { slog.Warn("Invalid width, using minimum", "provided", width, "minimum", minWidth) width = minWidth } if height < minHeight { slog.Warn("Invalid height, using minimum", "provided", height, "minimum", minHeight) height = minHeight } m.width = width m.height = height } func (m *Model) AddProcess(p Process) error { if _, ok := m.processes[p.ID]; ok { return &ProcessAlreadyExistsError{id: p.ID} } m.processes[p.ID] = p return nil } func (m *Model) AddOrUpdateProcess(p Process) { m.processes[p.ID] = p } func (m *Model) UpdateExistingProcess(p Process) error { if _, ok := m.processes[p.ID]; !ok { return &NoProcessFoundError{id: p.ID} } m.processes[p.ID] = p return nil } func (m *Model) GetByID(id string) (Process, bool) { p, ok := m.processes[id] return p, ok } func (m *Model) HasRunningProcesses() bool { for _, data := range m.processes { if data.State == InOperation && data.Done != data.Total { return true } } return false } func (m *Model) Render(processBarFocused bool) string { r := ui.ProcessBarRenderer(m.height, m.width, processBarFocused) if !m.isValid() { slog.Error("processBar in invalid state", "render", m.renderIndex, "cursor", m.cursor, "Height", m.height) r.AddLines("Invalid state") return r.Render() } if m.cntProcesses() == 0 { r.AddLines("", " "+common.ProcessBarNoneText) return r.Render() } r.SetBorderInfoItems(fmt.Sprintf("%d/%d", m.cursor+1, m.cntProcesses())) renderedHeight := 0 processes := m.getSortedProcesses() for i := m.renderIndex; i < len(processes); i++ { // We allow rendering of a process if we have at least 2 lines left if m.viewHeight() < renderedHeight+2 { break } renderedHeight += 3 // Note : We will be updating this on each Render, although harmless from performance // perspective. We are rendering modified version of the data. // TODO: We could, save pointer of process in map and update progressbar of each // map on each SetWidth. This would be cleaner and more efficient. curProcess := processes[i] curProcess.Progress.Width = m.viewWidth() - progressBarRightPadding // TODO : get them via a separate function. var cursor string if i == m.cursor { // TODO : Prerender it. cursor = common.FooterCursorStyle.Render("┃ ") } else { cursor = common.FooterCursorStyle.Render(" ") } r.AddLines(cursor + common.FooterStyle.Render( common.TruncateText(curProcess.GetDisplayName(), m.viewWidth()-processNameTruncatePadding, "...")+" ") + curProcess.State.Icon()) // We add two lines here, and let the renderer take care of // dropping the second line if it exceeds height if curProcess.Total != 0 { progressPercentage := float64(curProcess.Done) / float64(curProcess.Total) r.AddLines(cursor+curProcess.Progress.ViewAs(progressPercentage), "") } else { // if the total is 0, that means the process only have directory // so we can set the progress to 100% r.AddLines(cursor+curProcess.Progress.ViewAs(1), "") } } return r.Render() } ================================================ FILE: src/internal/ui/processbar/model_navigation.go ================================================ package processbar import ( "github.com/yorukot/superfile/src/internal/common" ) // Control processbar panel list up // There is a shadowing happening here, but it will be removed // Once we make footerHeight part of model struct func (m *Model) ListUp() { cntP := m.cntProcesses() if cntP == 0 { return } if m.cursor > 0 { m.cursor-- if m.cursor < m.renderIndex { m.renderIndex-- } } else { m.cursor = cntP - 1 // Either start from beginning or // from a process so that we could render last one m.renderIndex = max(0, cntP-m.cntRenderableProcess()) } } // Control processbar panel list down func (m *Model) ListDown() { cntP := m.cntProcesses() if cntP == 0 { return } if m.cursor < cntP-1 { m.cursor++ if m.cursor > m.renderIndex+m.cntRenderableProcess()-1 { m.renderIndex++ } } else { m.renderIndex = 0 m.cursor = 0 } } func (m *Model) cntRenderableProcess() int { footerHeight := m.height - common.BorderPadding return cntRenderableProcess(footerHeight) } func cntRenderableProcess(footerHeight int) int { // We can render one process in three lines // And last process in two or three lines ( with/without a line separtor) return (footerHeight + 1) / linesPerProcess } ================================================ FILE: src/internal/ui/processbar/model_navigation_test.go ================================================ package processbar import ( "strconv" "testing" "github.com/stretchr/testify/assert" ) func Test_cntRenderableProcess(t *testing.T) { assert.Equal(t, 1, cntRenderableProcess(4)) assert.Equal(t, 2, cntRenderableProcess(5)) assert.Equal(t, 2, cntRenderableProcess(6)) assert.Equal(t, 2, cntRenderableProcess(7)) assert.Equal(t, 3, cntRenderableProcess(8)) assert.Equal(t, 3, cntRenderableProcess(9)) assert.Equal(t, 3, cntRenderableProcess(10)) assert.Equal(t, 4, cntRenderableProcess(11)) } func genProcessBarModel(count int, cursor int, render int, viewHeight int) Model { pMap := map[string]Process{} for i := range count { pID := strconv.Itoa(i) pMap[pID] = Process{ ID: pID, CurrentFile: pID, } } return Model{ processes: pMap, cursor: cursor, renderIndex: render, width: minWidth, height: viewHeight + 2, } } func Test_processBarModelUpDown(t *testing.T) { testdata := []struct { name string processCnt int cursor int render int listDown bool // Whether to do ListDown or ListUp expectedCursor int expectedRender int footerHeight int }{ { name: "Basic down movement 1", processCnt: 10, cursor: 0, render: 0, listDown: true, expectedCursor: 1, expectedRender: 0, footerHeight: 10, }, { name: "Down at the last process - Footer height is plenty", processCnt: 3, cursor: 2, render: 0, listDown: true, expectedCursor: 0, expectedRender: 0, footerHeight: 10, }, { name: "Down at the last process - Footer height just enough", processCnt: 3, cursor: 2, render: 0, listDown: true, expectedCursor: 0, expectedRender: 0, footerHeight: 8, }, { name: "Down at the last process - Footer height is small", processCnt: 10, cursor: 9, render: 7, listDown: true, expectedCursor: 0, expectedRender: 0, footerHeight: 8, }, { name: "Down at the process causing render index to move", processCnt: 10, cursor: 3, render: 0, listDown: true, expectedCursor: 4, expectedRender: 1, footerHeight: 11, // Can hold 4 processes }, { name: "Basic up movement 1", processCnt: 10, cursor: 1, render: 0, listDown: false, expectedCursor: 0, expectedRender: 0, footerHeight: 10, }, { name: "Up at top wraps to last and adjusts render", processCnt: 10, cursor: 0, render: 0, listDown: false, expectedCursor: 9, expectedRender: 6, // 10 processes , 4 renderable footerHeight: 11, }, { name: "Up causes render index decrement", processCnt: 10, cursor: 3, render: 3, listDown: false, expectedCursor: 2, expectedRender: 2, // Cursor moved above render start footerHeight: 8, // Renders 3 processes }, { name: "Up on short list wraps correctly", processCnt: 3, cursor: 0, render: 0, listDown: false, expectedCursor: 2, expectedRender: 0, // 3 processes, 3 renderable footerHeight: 11, }, { name: "Up within render window maintains position", processCnt: 8, cursor: 5, render: 3, listDown: false, expectedCursor: 4, expectedRender: 3, // Remain in render window footerHeight: 11, }, { name: "Up with minimal footer height", processCnt: 5, cursor: 0, render: 0, listDown: false, expectedCursor: 4, expectedRender: 3, footerHeight: 5, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { pModel := genProcessBarModel(tt.processCnt, tt.cursor, tt.render, tt.footerHeight) assert.True(t, pModel.isValid()) if tt.listDown { pModel.ListDown() } else { pModel.ListUp() } assert.Equal(t, tt.expectedCursor, pModel.cursor) assert.Equal(t, tt.expectedRender, pModel.renderIndex) }) } } ================================================ FILE: src/internal/ui/processbar/model_test.go ================================================ package processbar import ( "flag" "fmt" "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/common" ) // TODO: This is duplicated in tests of prompt package, internal package too. // Fix this code duplication // Initialize the globals we need for testing func initGlobals() { // Updating globals for test is not a good idea and can lead to all sorts of issue // When multiple tests depend on same global variable and want different values // Since this is config that would likely stay same, maybe this is okay. // Also, this is done in main model's test too. // We need to find a better way to do this err := common.PopulateGlobalConfigs() if err != nil { fmt.Printf("error while populating config, err : %v", err) os.Exit(1) } } func TestMain(m *testing.M) { flag.Parse() if testing.Verbose() { utils.SetRootLoggerToStdout(true) } else { utils.SetRootLoggerToDiscarded() } initGlobals() m.Run() } func TestModelProcessUtils(t *testing.T) { m := New() p1 := NewProcess("1", "test", OpCopy, 10) p2 := NewProcess("2", "test2", OpDelete, 11) // ------- Testing AddProcess err := m.AddProcess(p1) require.NoError(t, err, "Add should succeed without errors") err = m.AddProcess(p2) require.NoError(t, err, "Add should succeed without errors for second process") pRes, ok := m.GetByID(p1.ID) require.True(t, ok, "Should be able to get the process we just added") assert.Equal(t, p1, pRes, "Should get the correct process value") p2Dup := NewProcess("2", "test2_dup", OpCopy, 1) err = m.AddProcess(p2Dup) var errExp *ProcessAlreadyExistsError require.ErrorAs(t, err, &errExp, "Should get ProcessAlreadyExistsError") assert.Equal(t, errExp.id, p2Dup.ID, "ID in the error should match with what we sent") // ------ Testing AddOrUpdate process m.AddOrUpdateProcess(p2Dup) pRes, ok = m.GetByID(p2Dup.ID) require.True(t, ok) assert.Equal(t, p2Dup, pRes, "Should get the correct process value after update") p3 := NewProcess("3", "test3", OpExtract, 1) // ------ Testing UpdateExisting err = m.UpdateExistingProcess(p3) var errExpUpdate *NoProcessFoundError require.ErrorAs(t, err, &errExpUpdate, "Should get NoProcessFoundError") assert.Equal(t, p3.ID, errExpUpdate.id, "ID in the error should match with what we sent") assert.True(t, m.HasRunningProcesses()) // Update all to done p1.State = Successful p2Dup.Done = p2Dup.Total p3.State = Failed _ = m.UpdateExistingProcess(p1) _ = m.UpdateExistingProcess(p2Dup) _ = m.UpdateExistingProcess(p3) assert.False(t, m.HasRunningProcesses()) } func TestModelSetDimenstions(t *testing.T) { m := New() m.SetDimensions(5, 6) assert.Equal(t, 5, m.width, "Correct value should be set") assert.Equal(t, 6, m.height, "Correct value should be set") m.SetDimensions(minWidth+1, minHeight-1) assert.Equal(t, minHeight, m.height, "Min value should be set") assert.Equal(t, minWidth+1, m.width, "Given value should be set") } ================================================ FILE: src/internal/ui/processbar/model_update.go ================================================ package processbar import ( "log/slog" ) // Only used in tests, to have processbar used in a standalone way without model func (m *Model) ListenForChannelUpdates() { // A goroutine running forever go func() { for { msg, ok := <-m.msgChan if !ok { slog.Debug("Channel closed, stopping listener") return } if _, ok := msg.(stopListeningMsg); ok { return } _, err := msg.Apply(m) if err != nil { slog.Error("Could not apply update to processbar", "error", err) } } }() } // An IO Operation, that will wait forever on msgChannel func (m *Model) GetListenCmd() Cmd { return func() UpdateMsg { return <-m.msgChan } } // Might add options to drain the channel in case msg is high priority. func (m *Model) trySendMsgToChannel(msg UpdateMsg) error { select { case m.msgChan <- msg: return nil default: // Process queue full with messages. Cannot add new process return &ProcessChannelFullError{} } } // Block till message is sent func (m *Model) sendMsgToChannelBlocking(msg UpdateMsg) { m.msgChan <- msg } func (m *Model) sendMsgToChannel(msg UpdateMsg, blocking bool) error { if blocking { m.sendMsgToChannelBlocking(msg) return nil } return m.trySendMsgToChannel(msg) } func (m *Model) SendAddProcessMsg( currentFile string, operation OperationType, total int, blockingSend bool, ) (Process, error) { id := m.newUUIDForProcess() p := NewProcess(id, currentFile, operation, total) msg := newProcessMsg{ NewProcess: p, BaseMsg: BaseMsg{reqID: m.newReqCnt()}, } err := m.sendMsgToChannel(msg, blockingSend) if err != nil { // Return zero-value process to indicate failure return Process{}, err } return p, nil } func (m *Model) SendUpdateProcessMsg(p Process, blockingSend bool) error { msg := updateProcessMsg{NewProcess: p, BaseMsg: BaseMsg{reqID: m.newReqCnt()}} return m.sendMsgToChannel(msg, blockingSend) } // Non Blocking and can fail func (m *Model) TrySendingUpdateProcessMsg(p Process) { msg := updateProcessMsg{NewProcess: p, BaseMsg: BaseMsg{reqID: m.newReqCnt()}} err := m.sendMsgToChannel(msg, false) if err != nil { slog.Error("Failed to send message to channel", "reqID", msg.GetReqID(), "error", err) } } func (m *Model) SendStopListeningMsgBlocking() { m.sendMsgToChannelBlocking(stopListeningMsg{BaseMsg: BaseMsg{reqID: m.newReqCnt()}}) } ================================================ FILE: src/internal/ui/processbar/model_update_test.go ================================================ package processbar import "testing" func TestUpdateMsg(_ *testing.T) { // TODO // Test these // 1 - Sending messages without starting to listen // - messages fail after limit (tests trySendMsgToChannel()) // - blocking messages stuck forever - timeout after 0.5sec (Just do 1) // - Tests sendMsgToChannelBlocking() // 2 - Use SendAddProcessMsg() and verify that new process is added soon // 3 - Use SendUpdateProcessNameMsg() and verify update // 4 - Verify that stopListeningMsg works. Use SendStopListeningMsgBlocking() // - Test m.IsListeningForUpdates() } ================================================ FILE: src/internal/ui/processbar/model_utils.go ================================================ package processbar import ( "sort" "github.com/lithammer/shortuuid" ) func (m *Model) cntProcesses() int { return len(m.processes) } func (m *Model) isValid() bool { return m.renderIndex <= m.cursor && m.cursor <= m.renderIndex+cntRenderableProcess(m.height-borderSize)-1 } func (m *Model) viewHeight() int { return m.height - borderSize } func (m *Model) viewWidth() int { return m.width - borderSize } func (m *Model) GetHeight() int { return m.height } func (m *Model) GetWidth() int { return m.width } func (m *Model) getSortedProcesses() []Process { // save process in the array and sort the process by finished or not, // completion percetage, or finish time // TODO : This is very inefficient and can be improved. // The whole design needs to be changed so that we dont need to recreate the slice // and sort on each render. Idea : Maintain two slices - completed, ongoing // Processes should be added / removed to the slice on correct time, and we dont // need to redo slice formation and sorting on each render. // TODO : One idea is that we can use google/btree to store processes // have process implement a Less() method, and we can do O(logn) inserts/deletes // To make sure its always stored in an order we want. And then iterate in O(n) // in render() processes := m.GetProcessesSlice() // sort by the process sort.Slice(processes, func(i, j int) bool { doneI := (processes[i].State == Successful || processes[i].State == Failed) doneJ := (processes[j].State == Successful || processes[j].State == Failed) // sort by done or not if doneI != doneJ { return !doneI } // if both not done if !doneI { completionI := float64(processes[i].Done) / float64(processes[i].Total) completionJ := float64(processes[j].Done) / float64(processes[j].Total) return completionI < completionJ // Those who finish first will be ranked later. } // if both done sort by the doneTime return processes[j].DoneTime.Before(processes[i].DoneTime) }) return processes } func (m *Model) newReqCnt() int { m.reqCnt++ return m.reqCnt } // TODO: Maybe make sure that there isn't any existing process with this UUID func (m *Model) newUUIDForProcess() string { return shortuuid.New() } // Copy of the current processes for read only purpose func (m *Model) GetProcessesSlice() []Process { var processes []Process for _, p := range m.processes { processes = append(processes, p) } return processes } ================================================ FILE: src/internal/ui/processbar/model_utils_test.go ================================================ package processbar import ( "testing" "github.com/stretchr/testify/assert" ) func TestModelUtils(t *testing.T) { m := NewModelWithOptions(14, 10) assert.Equal(t, 8, m.viewHeight()) assert.Equal(t, 12, m.viewWidth()) assert.Equal(t, 0, m.cntProcesses()) assert.Equal(t, 1, m.newReqCnt()) assert.Equal(t, 2, m.newReqCnt()) assert.True(t, m.isValid()) p1 := NewProcess("1", "test", OpCopy, 10) p2 := NewProcess("2", "test2", OpCompress, 11) _ = m.AddProcess(p1) _ = m.AddProcess(p2) assert.Equal(t, 2, m.cntProcesses()) assert.True(t, m.isValid()) m.cursor = -1 assert.False(t, m.isValid()) m.cursor = 0 m.renderIndex = 1 assert.False(t, m.isValid()) } ================================================ FILE: src/internal/ui/processbar/operation.go ================================================ package processbar import "github.com/yorukot/superfile/src/config/icon" type OperationType int const ( OpCopy OperationType = iota OpCut OpDelete OpCompress OpExtract ) // GetIcon returns the appropriate icon for the operation type func (op OperationType) GetIcon() string { switch op { case OpCopy: return icon.Copy case OpCut: return icon.Cut case OpDelete: return icon.Delete case OpCompress: return icon.CompressFile case OpExtract: return icon.ExtractFile default: return icon.InOperation } } // GetVerb returns the present tense verb for the operation func (op OperationType) GetVerb() string { switch op { case OpCopy: return "Copying" case OpCut: return "Moving" case OpDelete: return "Deleting" case OpCompress: return "Compressing" case OpExtract: return "Extracting" default: return "Processing" } } // GetPastVerb returns the past tense verb for the operation func (op OperationType) GetPastVerb() string { switch op { case OpCopy: return "Copied" case OpCut: return "Moved" case OpDelete: return "Deleted" case OpCompress: return "Compressed" case OpExtract: return "Extracted" default: return "Processed" } } ================================================ FILE: src/internal/ui/processbar/process.go ================================================ package processbar import ( "fmt" "time" "github.com/charmbracelet/bubbles/progress" "github.com/yorukot/superfile/src/config/icon" "github.com/yorukot/superfile/src/internal/common" ) // Model for an individual process // Note : Its size is ~ 800 bytes type Process struct { ID string CurrentFile string // TODO : We always want ErrorMsg to be set when State is // moved to Cancelled or Failed. To ensure it, we need to only allow state // change via helper functions and ask for the errorMsg ErrorMsg string Operation OperationType Progress progress.Model State ProcessState Total int Done int DoneTime time.Time } func NewProcess(id string, currentFile string, operation OperationType, total int) Process { prog := progress.New(common.GenerateGradientColor()) prog.PercentageStyle = common.FooterStyle return Process{ ID: id, CurrentFile: currentFile, Operation: operation, Progress: prog, State: InOperation, Total: total, Done: 0, } } type ProcessState int const ( InOperation ProcessState = iota Successful Cancelled Failed ) // TODO : Should we store in a global map for efficiency ? At least need to prerender // Yes, this is a Render() call, which is expensive func (p ProcessState) Icon() string { switch p { case Failed: return common.ProcessErrorStyle.Render(icon.Warn) case Successful: return common.ProcessSuccessfulStyle.Render(icon.Done) case InOperation: return common.ProcessInOperationStyle.Render(icon.InOperation) case Cancelled: fallthrough default: return common.ProcessCancelStyle.Render(icon.Error) } } // GetDisplayName returns the appropriate display name for the process func (p *Process) GetDisplayName() string { return p.Operation.GetIcon() + icon.Space + p.displayNameWithoutIcon() } func (p *Process) displayNameWithoutIcon() string { if p.State == Cancelled { return p.Operation.GetVerb() + " cancelled : " + p.ErrorMsg } if p.State == Failed { return p.Operation.GetVerb() + " failed : " + p.ErrorMsg } if p.State == InOperation { return p.Operation.GetVerb() + " " + p.CurrentFile } if p.Total > 1 { return fmt.Sprintf("%s %d files", p.Operation.GetPastVerb(), p.Total) } return p.Operation.GetPastVerb() + " " + p.CurrentFile } ================================================ FILE: src/internal/ui/processbar/process_test.go ================================================ package processbar import ( "testing" "github.com/stretchr/testify/assert" "github.com/yorukot/superfile/src/config/icon" ) func TestGetDisplayName(t *testing.T) { tests := []struct { name string process Process expected string }{ { name: "Cancelled", process: Process{ CurrentFile: "file.txt", ErrorMsg: "File already exists", Operation: OpCompress, Total: 1, State: Cancelled, }, expected: icon.CompressFile + icon.Space + "Compressing cancelled : File already exists", }, { name: "Failed without error Msg", process: Process{ CurrentFile: "file.txt", Operation: OpCompress, Total: 1, State: Failed, }, expected: icon.CompressFile + icon.Space + "Compressing failed : ", }, { name: "Error message during operations", process: Process{ CurrentFile: "file.txt", ErrorMsg: "File already exists", Operation: OpCompress, Total: 1, State: InOperation, }, expected: icon.CompressFile + icon.Space + "Compressing file.txt", }, { name: "Single file during operation", process: Process{ CurrentFile: "file.txt", Operation: OpCopy, Total: 1, State: InOperation, }, expected: icon.Copy + icon.Space + "Copying file.txt", }, { name: "Multiple files during operation", process: Process{ CurrentFile: "file2.txt", Operation: OpDelete, Total: 10, State: InOperation, }, expected: icon.Delete + icon.Space + "Deleting file2.txt", }, { name: "Multiple files after completion", process: Process{ CurrentFile: "file.txt", Operation: OpCopy, Total: 5, State: Successful, }, expected: icon.Copy + icon.Space + "Copied 5 files", }, { name: "Single file after completion", process: Process{ CurrentFile: "file.txt", Operation: OpDelete, Total: 1, State: Successful, }, expected: icon.Delete + icon.Space + "Deleted file.txt", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expected, tt.process.GetDisplayName()) }) } } ================================================ FILE: src/internal/ui/processbar/process_update_msg.go ================================================ package processbar type Cmd func() UpdateMsg type UpdateMsg interface { Apply(m *Model) (Cmd, error) GetReqID() int } // TODO: Can we remove this duplication with model_msg ? type BaseMsg struct { reqID int } func (msg BaseMsg) GetReqID() int { return msg.reqID } type newProcessMsg struct { BaseMsg NewProcess Process } func (msg newProcessMsg) Apply(m *Model) (Cmd, error) { return m.GetListenCmd(), m.AddProcess(msg.NewProcess) } type updateProcessMsg struct { BaseMsg NewProcess Process } func (msg updateProcessMsg) Apply(m *Model) (Cmd, error) { return m.GetListenCmd(), m.UpdateExistingProcess(msg.NewProcess) } // Construction will be options UpdateName(), UpdateDone(), etc.. type stopListeningMsg struct { BaseMsg } func (msg stopListeningMsg) Apply(_ *Model) (Cmd, error) { //nolint:nilnil // This is a no-op apply. return nil, nil } ================================================ FILE: src/internal/ui/prompt/README.md ================================================ # prompt package This is for the Prompt modal of superfile Handles user input updates, spf model updates, and returns a PromptAction to model. # Coverage ```bash cd /path/to/ui/prompt # Basic coverage go test -cover # HTML report go test -coverprofile=coverage.out && go tool cover -html=coverage.out -o coverage.html ``` Current coverage is 91.3%. ================================================ FILE: src/internal/ui/prompt/consts.go ================================================ package prompt import "time" // These could as well be property of prompt Model vs being global consts // But its fine const ( promptHeadlineText = "superfile Prompt" OpenCommand = "open" SplitCommand = "split" CdCommand = "cd" // We could later make this configurable. But, not needed now. spfPromptChar = ">" shellPromptChar = ":" successMessagePrefix = "Success" failureMessagePrefix = "Error" shellModeString = "(Shell Mode)" spfModeString = "(SPF Mode)" // Error message string tokenizationError = "Failed during tokenization" splitCommandArgError = "split command should not be given arguments" // Timeout for command executed for shell substitution shellSubTimeout = 1000 * time.Millisecond shellSubTimeoutInTests = 100 * time.Millisecond defaultTestCwd = "/" PromptMinWidth = 10 PromptMinHeight = 3 defaultTestWidth = 100 defaultTestMaxHeight = 100 // UI dimension constants for prompt modal // promptInputPadding is total padding for prompt input fields promptInputPadding = 6 // 2 + 1 + 2 + 1 (borders and spacing) // expectedArgCount is the expected number of prompt arguments expectedArgCount = 2 ) func modeString(shellMode bool) string { if shellMode { return shellModeString } return spfModeString } func shellPrompt(shellMode bool) string { if shellMode { return shellPromptChar } return spfPromptChar } func defaultCommandSlice() []promptCommand { return []promptCommand{ { command: OpenCommand, usage: OpenCommand + " ", description: "Open a new panel at a specified path", }, { command: SplitCommand, usage: SplitCommand, description: "Open a new panel at a current file panel's path", }, { command: CdCommand, usage: CdCommand + " ", description: "Change directory of current panel", }, } } ================================================ FILE: src/internal/ui/prompt/error.go ================================================ package prompt import "fmt" // This is to generate error objects that can be nicely printed to UI type invalidCmdError struct { uiMsg string wrappedError error } func (e invalidCmdError) Error() string { if e.wrappedError == nil { return e.uiMsg } return e.wrappedError.Error() } func (e invalidCmdError) Unwrap() error { return e.wrappedError } func (e invalidCmdError) uiMessage() string { return e.uiMsg } type envVarNotFoundError struct { varName string } func (e envVarNotFoundError) Error() string { return fmt.Sprintf("env var %s not found", e.varName) } type bracketMatchError struct { openChar rune closeChar rune } func (p bracketMatchError) Error() string { return fmt.Sprintf("could not find matching %c for %c", p.closeChar, p.openChar) } func roundBracketMatchError() bracketMatchError { return bracketMatchError{openChar: '(', closeChar: ')'} } func curlyBracketMatchError() bracketMatchError { return bracketMatchError{openChar: '{', closeChar: '}'} } ================================================ FILE: src/internal/ui/prompt/model.go ================================================ package prompt import ( "fmt" "log/slog" "slices" "strings" "github.com/yorukot/superfile/src/internal/ui" tea "github.com/charmbracelet/bubbletea" "github.com/yorukot/superfile/src/config/icon" "github.com/yorukot/superfile/src/internal/common" ) func DefaultModel(maxHeight int, width int) Model { return GenerateModel(common.Hotkeys.OpenSPFPrompt[0], common.Hotkeys.OpenCommandLine[0], common.Config.ShellCloseOnSuccess, maxHeight, width) } func GenerateModel(spfPromptHotkey string, shellPromptHotkey string, closeOnSuccess bool, maxHeight int, width int) Model { m := Model{ headline: icon.Terminal + icon.Space + promptHeadlineText, open: false, shellMode: true, textInput: common.GeneratePromptTextInput(), commands: defaultCommandSlice(), spfPromptHotkey: spfPromptHotkey, shellPromptHotkey: shellPromptHotkey, actionSuccess: true, closeOnSuccess: closeOnSuccess, } m.SetMaxHeight(maxHeight) m.SetWidth(width) return m } func (m *Model) HandleUpdate(msg tea.Msg, cwdLocation string) (common.ModelAction, tea.Cmd) { var action common.ModelAction action = common.NoAction{} var cmd tea.Cmd if !m.IsOpen() { slog.Error("HandleUpdate called on closed prompt") return action, cmd } switch msg := msg.(type) { case tea.KeyMsg: switch { case slices.Contains(common.Hotkeys.ConfirmTyping, msg.String()): action = m.handleConfirm(cwdLocation) case slices.Contains(common.Hotkeys.CancelTyping, msg.String()): m.Close() default: cmd = m.handleNormalKeyInput(msg) } default: // Non keypress updates like Cursor Blink m.textInput, cmd = m.textInput.Update(msg) } return action, cmd } func (m *Model) handleConfirm(cwdLocation string) common.ModelAction { // Pressing confirm on empty prompt will trigger close if m.textInput.Value() == "" { m.CloseOnSuccessIfNeeded() } // Create Action based on input var err error action, err := getPromptAction(m.shellMode, m.textInput.Value(), cwdLocation) if err == nil { m.resultMsg = "" m.actionSuccess = true } else if cmdErr, ok := err.(invalidCmdError); ok { //nolint: errorlint // We don't expect a wrapped error here slog.Error("Error from getPromptAction", "error", cmdErr, "uiMsg", cmdErr.uiMsg) m.resultMsg = cmdErr.uiMessage() m.actionSuccess = false } else { slog.Error("Unexpected error from getPromptAction", "error", err) m.resultMsg = err.Error() m.actionSuccess = false } m.textInput.SetValue("") return action } func (m *Model) handleNormalKeyInput(msg tea.KeyMsg) tea.Cmd { var cmd tea.Cmd switch { case m.textInput.Value() == "" && msg.String() == m.spfPromptHotkey: m.setShellMode(false) case m.textInput.Value() == "" && msg.String() == m.shellPromptHotkey: m.setShellMode(true) default: m.textInput, cmd = m.textInput.Update(msg) } m.resultMsg = "" m.actionSuccess = true return cmd } // After action is performed, model will update the Model with results func (m *Model) HandleShellCommandResults(retCode int, output string) { m.actionSuccess = retCode == 0 m.resultMsg = fmt.Sprintf("Command exited with status %d", retCode) output = strings.TrimSpace(common.MakePrintableWithEscCheck(output, false)) if output != "" { m.resultMsg += ", Output:\n" + output } else { m.resultMsg += " (No output)" } m.CloseOnSuccessIfNeeded() } // After action is performed, model will update the prompt.Model with results // In case of NoAction, this method should not be called. func (m *Model) HandleSPFActionResults(success bool, msg string) { m.actionSuccess = success m.resultMsg = msg m.CloseOnSuccessIfNeeded() } func (m *Model) Render() string { r := ui.PromptRenderer(m.maxHeight, m.width) r.SetBorderTitle(m.headline + " " + modeString(m.shellMode)) r.AddLines(" " + m.textInput.View()) if !m.shellMode { // To make sure its added one time only per render call hintSectionAdded := false if m.textInput.Value() == "" { if !hintSectionAdded { r.AddSection() hintSectionAdded = true } r.AddLines(" '" + m.shellPromptHotkey + "' - Get into Shell mode") } command := getFirstToken(m.textInput.Value()) for _, cmd := range m.commands { if strings.HasPrefix(cmd.command, command) { if !hintSectionAdded { r.AddSection() hintSectionAdded = true } r.AddLines(" '" + cmd.usage + "' - " + cmd.description) } } } else if m.textInput.Value() == "" { r.AddSection() r.AddLines(" '" + m.spfPromptHotkey + "' - Get into SPF mode") } if m.resultMsg != "" { msgPrefix := successMessagePrefix resultStyle := common.PromptSuccessStyle if !m.actionSuccess { resultStyle = common.PromptFailureStyle msgPrefix = failureMessagePrefix } r.AddSection() r.AddLines(resultStyle.Render(" " + msgPrefix + " : " + m.resultMsg)) } return r.Render() } func (m *Model) Open(shellMode bool) { m.open = true m.setShellMode(shellMode) _ = m.textInput.Focus() } func (m *Model) setShellMode(shellMode bool) { m.shellMode = shellMode m.textInput.Prompt = shellPrompt(m.shellMode) + " " } func (m *Model) Close() { m.open = false m.setShellMode(true) m.textInput.SetValue("") } func (m *Model) IsOpen() bool { return m.open } func (m *Model) IsShellMode() bool { return m.shellMode } func (m *Model) LastActionSucceeded() bool { return m.actionSuccess } func (m *Model) GetWidth() int { return m.width } func (m *Model) GetMaxHeight() int { return m.maxHeight } func (m *Model) SetWidth(width int) { if width < PromptMinWidth { slog.Warn("Prompt initialized with too less width", "width", width) width = PromptMinWidth } m.width = width // Excluding borders(2), SpacePadding(1), Prompt(2), and one extra character that is appended // by textInput.View() m.textInput.Width = width - promptInputPadding } func (m *Model) SetMaxHeight(maxHeight int) { if maxHeight < PromptMinHeight { slog.Warn("Prompt initialized with too less maxHeight", "maxHeight", maxHeight) maxHeight = PromptMinHeight } m.maxHeight = maxHeight } func (m *Model) validate() bool { // Prompt was closed, but textInput was not cleared if !m.open && m.textInput.Value() != "" { return false } return true } func (m *Model) CloseOnSuccessIfNeeded() { if m.closeOnSuccess && m.actionSuccess { m.Close() } } ================================================ FILE: src/internal/ui/prompt/model_test.go ================================================ package prompt import ( "flag" "fmt" "os" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/config/icon" "github.com/yorukot/superfile/src/internal/common" ) // Initialize the globals we need for testing func initGlobals() { // Updating globals for test is not a good idea and can lead to all sorts of issue // When multiple tests depend on same global variable and want different values // Since this is config that would likely stay same, maybe this is okay. // Also, this is done in main model's test too. // We need to find a better way to do this err := common.PopulateGlobalConfigs() if err != nil { fmt.Printf("error while populating config, err : %v", err) os.Exit(1) } } func TestMain(m *testing.M) { for env, val := range testEnvValues { err := os.Setenv(env, val) if err != nil { fmt.Printf("Could not set env variables, error : %v", err) os.Exit(1) } } flag.Parse() if testing.Verbose() { utils.SetRootLoggerToStdout(true) } else { utils.SetRootLoggerToDiscarded() } initGlobals() m.Run() } func defaultTestModel() Model { return GenerateModel(spfPromptChar, shellPromptChar, true, defaultTestMaxHeight, defaultTestWidth) } func TestModel_HandleUpdate(t *testing.T) { // We don't test getPromptAction here. It is a separate test t.Run("Handle update called on closed Model", func(t *testing.T) { m := defaultTestModel() action, _ := m.HandleUpdate(utils.TeaRuneKeyMsg("x"), defaultTestCwd) assert.Empty(t, m.textInput.Value()) assert.True(t, m.validate()) assert.False(t, m.IsOpen()) assert.Equal(t, common.NoAction{}, action) }) t.Run("Pressing confirm on empty input", func(t *testing.T) { actualTest := func(closeOnSuccess bool, openAfterEnter bool) { m := GenerateModel(spfPromptChar, shellPromptChar, closeOnSuccess, defaultTestMaxHeight, defaultTestWidth) m.Open(true) assert.True(t, m.IsOpen()) action, _ := m.HandleUpdate(tea.KeyMsg{Type: tea.KeyEnter}, defaultTestCwd) assert.Equal(t, openAfterEnter, m.IsOpen()) assert.Equal(t, common.NoAction{}, action) assert.Empty(t, m.resultMsg) assert.True(t, m.LastActionSucceeded()) assert.True(t, m.validate()) } actualTest(true, false) actualTest(false, true) }) t.Run("Validate Prompt Actions", func(t *testing.T) { m := defaultTestModel() m.Open(false) action, _ := m.HandleUpdate(utils.TeaRuneKeyMsg(SplitCommand), defaultTestCwd) assert.Equal(t, common.NoAction{}, action) action, _ = m.HandleUpdate(tea.KeyMsg{Type: tea.KeyEnter}, defaultTestCwd) assert.Equal(t, common.SplitPanelAction{}, action) _, _ = m.HandleUpdate(utils.TeaRuneKeyMsg("bad_command"), defaultTestCwd) action, _ = m.HandleUpdate(tea.KeyMsg{Type: tea.KeyEnter}, defaultTestCwd) assert.Equal(t, common.NoAction{}, action) assert.False(t, m.LastActionSucceeded()) assert.NotEmpty(t, m.resultMsg) m.setShellMode(true) command := "abc def /xyz" _, _ = m.HandleUpdate(utils.TeaRuneKeyMsg(command), defaultTestCwd) action, _ = m.HandleUpdate(tea.KeyMsg{Type: tea.KeyEnter}, defaultTestCwd) assert.Equal(t, common.ShellCommandAction{Command: command}, action) }) t.Run("Validate Cancel typing", func(t *testing.T) { m := defaultTestModel() actualTest := func(closeKey tea.KeyMsg, shouldBeOpen bool) { m.Open(true) _, _ = m.HandleUpdate(utils.TeaRuneKeyMsg("xyz"), defaultTestCwd) action, _ := m.HandleUpdate(closeKey, defaultTestCwd) assert.Equal(t, common.NoAction{}, action) assert.Equal(t, shouldBeOpen, m.IsOpen()) } actualTest(tea.KeyMsg{Type: tea.KeyCtrlC}, false) actualTest(tea.KeyMsg{Type: tea.KeyEscape}, false) actualTest(tea.KeyMsg{Type: tea.KeyCtrlD}, true) }) t.Run("Switching between shell and SPF mode", func(t *testing.T) { actualTest := func(promptChar string, shellChar string) { m := GenerateModel(promptChar, shellChar, true, defaultTestMaxHeight, defaultTestWidth) m.Open(true) assert.True(t, m.IsShellMode()) // Shell to prompt action, _ := m.HandleUpdate(utils.TeaRuneKeyMsg(promptChar), defaultTestCwd) assert.False(t, m.IsShellMode()) assert.True(t, m.LastActionSucceeded()) assert.Equal(t, common.NoAction{}, action) // Prompt to shell action, _ = m.HandleUpdate(utils.TeaRuneKeyMsg(shellChar), defaultTestCwd) assert.True(t, m.IsShellMode()) assert.True(t, m.LastActionSucceeded()) assert.Equal(t, common.NoAction{}, action) // Pressing shellChar when you are already on shell shouldn't to anything action, _ = m.HandleUpdate(utils.TeaRuneKeyMsg(shellChar), defaultTestCwd) assert.True(t, m.IsShellMode()) assert.True(t, m.LastActionSucceeded()) assert.Equal(t, common.NoAction{}, action) } actualTest(">", ":") actualTest("$", "#") }) t.Run("Validate Cursor Blink update", func(t *testing.T) { m := defaultTestModel() m.Open(true) assert.False(t, m.textInput.Cursor.Blink) blinkMsg := m.textInput.Cursor.BlinkCmd()() action, _ := m.HandleUpdate(blinkMsg, defaultTestCwd) assert.Equal(t, common.NoAction{}, action) assert.True(t, m.textInput.Cursor.Blink) blinkMsg = m.textInput.Cursor.BlinkCmd()() action, _ = m.HandleUpdate(blinkMsg, defaultTestCwd) assert.Equal(t, common.NoAction{}, action) assert.False(t, m.textInput.Cursor.Blink) blinkMsg = m.textInput.Cursor.BlinkCmd()() action, _ = m.HandleUpdate(blinkMsg, defaultTestCwd) assert.Equal(t, common.NoAction{}, action) assert.True(t, m.textInput.Cursor.Blink) // We could test BlinkCancelled and initialBlink as well, but that's too much for now }) } func TestModel_HandleResults(t *testing.T) { t.Run("Verify Shell results update", func(t *testing.T) { m := defaultTestModel() m.Open(true) m.HandleShellCommandResults(0, "") // Validate close happens when closeOnSuccess is true assert.True(t, m.LastActionSucceeded()) assert.Equal(t, "Command exited with status 0 (No output)", m.resultMsg) assert.False(t, m.IsOpen()) m.Open(true) m.HandleShellCommandResults(1, "") assert.False(t, m.LastActionSucceeded()) assert.Equal(t, "Command exited with status 1 (No output)", m.resultMsg) assert.True(t, m.IsOpen()) m.closeOnSuccess = false m.HandleShellCommandResults(0, "") // Validate that close does not happen when closeOnSuccess is true assert.True(t, m.LastActionSucceeded()) assert.Equal(t, "Command exited with status 0 (No output)", m.resultMsg) assert.True(t, m.IsOpen()) }) t.Run("Verify Shell command output is displayed", func(t *testing.T) { m := defaultTestModel() m.closeOnSuccess = false m.Open(true) // Test with single line output m.HandleShellCommandResults(0, "hello world") assert.Equal(t, "Command exited with status 0, Output:\nhello world", m.resultMsg) // Test with multi-line output m.HandleShellCommandResults(0, "line1\nline2\nline3") assert.Equal(t, "Command exited with status 0, Output:\nline1\nline2\nline3", m.resultMsg) // Test output is trimmed m.HandleShellCommandResults(0, " trimmed output \n") assert.Equal(t, "Command exited with status 0, Output:\ntrimmed output", m.resultMsg) m.HandleShellCommandResults(0, "ESC SEQ\x1b[2;6H") assert.Equal(t, "Command exited with status 0, Output:\nESC SEQ[2;6H", m.resultMsg) // Test with failed command and output m.HandleShellCommandResults(1, "error message") assert.False(t, m.LastActionSucceeded()) assert.Equal(t, "Command exited with status 1, Output:\nerror message", m.resultMsg) }) t.Run("Verify SPF results update", func(t *testing.T) { m := GenerateModel(spfPromptChar, shellPromptChar, false, defaultTestMaxHeight, defaultTestWidth) m.Open(true) msg := "Test message" m.HandleSPFActionResults(true, msg) assert.True(t, m.LastActionSucceeded()) assert.Equal(t, msg, m.resultMsg) assert.True(t, m.IsOpen()) m.closeOnSuccess = true // Validate close happens when closeOnSuccess is true m.HandleSPFActionResults(true, "") assert.False(t, m.IsOpen()) }) } func TestModel_Render(t *testing.T) { // Test // 1 - Default view with shell mode and spf prompt mode // 2 - User input // 3 - User input, that is truncated due to being too large // 4 - User input with special characters, emojies, etc. // 5 - Prompt mode suggestion with these prefixes // - "cd" // - "c" // - "open " // - "open " // - "non_existent_command" // 6 - Model with result message (Without is tested above) // 7 - Color of result message green on success, red on failure // This one is hard, and we will likely not do it soon. // Needs global style variables // Challenges - needs border config strings for render test t.Run("Basic Render Checks", func(t *testing.T) { m := GenerateModel(spfPromptChar, shellPromptChar, true, 10, 40) m.setShellMode(true) res := ansi.Strip(m.Render()) exp := "" + "╭─┤ " + icon.Terminal + " superfile Prompt (Shell Mode) ├──╮\n" + // 23--------4------------56789012345678901234567890123456789 "│ : │\n" + // 23456789012345678901234567890123456789 "├──────────────────────────────────────┤\n" + "│ '>' - Get into SPF mode │\n" + "╰──────────────────────────────────────╯" assert.Equal(t, exp, res) m.setShellMode(false) res = ansi.Strip(m.Render()) exp = "" + "╭─┤ " + icon.Terminal + " superfile Prompt (SPF Mode) ├────╮\n" + // 23--------4------------56789012345678901234567890123456789 "│ > │\n" + "├──────────────────────────────────────┤\n" + "│ ':' - Get into Shell mode │\n" + "│ 'open ' - Open a new panel at a│\n" + "│ 'split' - Open a new panel at a curre│\n" + "│ 'cd ' - Change directory of cur│\n" + "╰──────────────────────────────────────╯" assert.Equal(t, exp, res) }) t.Run("Test User Input", func(t *testing.T) { execute := func(input string, expected string) { // Changing this will need test adjustments width := 10 m := GenerateModel(spfPromptChar, shellPromptChar, true, 10, width) m.Open(true) m.textInput.SetValue(input) m.textInput.Cursor.Blink = false res := ansi.Strip(m.Render()) inputLine := strings.Split(res, "\n")[1] require.Equal(t, width, ansi.StringWidth(inputLine)) // | : xxxx | // 0123456789 content := strings.TrimPrefix(inputLine, "│ : ") content = strings.TrimSuffix(content, " │") assert.Equal(t, expected, content) } execute("abc", "abc ") execute("0123456789", "6789") execute("✅1✅2", "1✅2") execute("✅1✅2✅", "2✅ ") }) t.Run("Result Message", func(t *testing.T) { m := GenerateModel(spfPromptChar, shellPromptChar, true, 10, 50) m.setShellMode(true) m.HandleShellCommandResults(0, "") res := ansi.Strip(m.Render()) exp := "" + "╭─┤ " + icon.Terminal + " superfile Prompt (Shell Mode) ├────────────╮\n" + // 23--------4------------567890123456789012345678901234567890123456789 "│ : │\n" + // 234567890123456789012345678901234567890123456789 "├────────────────────────────────────────────────┤\n" + "│ '>' - Get into SPF mode │\n" + "├────────────────────────────────────────────────┤\n" + "│ Success : Command exited with status 0 (No outp│\n" + "╰────────────────────────────────────────────────╯" assert.Equal(t, exp, res) m.HandleShellCommandResults(1, "") res = ansi.Strip(m.Render()) exp = "" + "╭─┤ " + icon.Terminal + " superfile Prompt (Shell Mode) ├────────────╮\n" + // 23--------4------------567890123456789012345678901234567890123456789 "│ : │\n" + // 234567890123456789012345678901234567890123456789 "├────────────────────────────────────────────────┤\n" + "│ '>' - Get into SPF mode │\n" + "├────────────────────────────────────────────────┤\n" + "│ Error : Command exited with status 1 (No output│\n" + "╰────────────────────────────────────────────────╯" assert.Equal(t, exp, res) }) shellModeSuggestion := "':' - Get into Shell mode" var openCmdSuggestion string var splitCmdSuggestion string var cdCmdSuggestion string for _, cmd := range defaultCommandSlice() { curSuggestion := "'" + cmd.usage + "' - " + cmd.description switch cmd.command { case OpenCommand: openCmdSuggestion = curSuggestion case SplitCommand: splitCmdSuggestion = curSuggestion case CdCommand: cdCmdSuggestion = curSuggestion default: assert.Fail(t, "Unknow command") } } testdataSuggestions := []struct { name string textInput string expectedSuggestions []string }{ { name: "No Input", textInput: "", expectedSuggestions: []string{ shellModeSuggestion, openCmdSuggestion, splitCmdSuggestion, cdCmdSuggestion, }, }, { name: "Command without args", textInput: "cd", expectedSuggestions: []string{ cdCmdSuggestion, }, }, { name: "Incomplete Command", textInput: "c", expectedSuggestions: []string{ cdCmdSuggestion, }, }, { name: "Command with args", textInput: "open /abc", expectedSuggestions: []string{ openCmdSuggestion, }, }, { name: "Command with extra args", textInput: "open /abc /abc", expectedSuggestions: []string{ openCmdSuggestion, }, }, { name: "Invalid command", textInput: "non_existent_command", expectedSuggestions: []string{}, }, } for _, tt := range testdataSuggestions { t.Run(tt.name, func(t *testing.T) { m := DefaultModel(defaultTestMaxHeight, defaultTestWidth) m.Open(false) m.textInput.SetValue(tt.textInput) res := ansi.Strip(m.Render()) resLines := strings.Split(res, "\n") if len(tt.expectedSuggestions) == 0 { require.Len(t, resLines, 3) return } require.Len(t, resLines, 4+len(tt.expectedSuggestions)) suggestionLines := resLines[3 : len(resLines)-1] require.Len(t, suggestionLines, len(tt.expectedSuggestions)) for i := range tt.expectedSuggestions { exp := tt.expectedSuggestions[i] actualLine := suggestionLines[i] actualLine = strings.TrimPrefix(actualLine, "│ ") actualLine = strings.TrimSuffix(actualLine, "│") actualLine = strings.TrimSpace(actualLine) assert.Equal(t, exp, actualLine) } }) } } ================================================ FILE: src/internal/ui/prompt/tokenize.go ================================================ package prompt import ( "errors" "fmt" "log/slog" "os" "strings" "time" "unicode" "github.com/yorukot/superfile/src/pkg/utils" ) // split into tokens func tokenizePromptCommand(command string, cwdLocation string) ([]string, error) { command, err := resolveShellSubstitution(shellSubTimeout, command, cwdLocation) if err != nil { return nil, err } return tokenizeWithQuotes(command) } // Replace ${} and $() with values func resolveShellSubstitution(subCmdTimeout time.Duration, command string, cwdLocation string) (string, error) { resCommand := strings.Builder{} cmdRunes := []rune(command) i := 0 for i < len(cmdRunes) { if i+1 < len(cmdRunes) && cmdRunes[i] == '$' { if !isOpenBracket(cmdRunes[i+1]) { resCommand.WriteRune(cmdRunes[i]) i++ continue } openChar := cmdRunes[i+1] closeChar := getClosingBracket(openChar) end := findEndingBracket(cmdRunes, i+1, openChar, closeChar) if end == -1 { return "", errors.New("unexpected error in tokenization") } if end == len(cmdRunes) { return "", bracketMatchError{openChar: openChar, closeChar: closeChar} } err := updateResCommand(&resCommand, openChar, string(cmdRunes[i+2:end]), subCmdTimeout, cwdLocation) if err != nil { return "", err } i = end + 1 } else { resCommand.WriteRune(cmdRunes[i]) i++ } } return resCommand.String(), nil } func updateResCommand(resCommand *strings.Builder, openChar rune, token string, subCmdTimeout time.Duration, cwdLocation string) error { switch openChar { case '{': value, ok := os.LookupEnv(token) if !ok { return envVarNotFoundError{varName: token} } // Might Handle values being too big, or having multiple lines // But this is based on user input, so it is probably okay for now // Same comment for command substitution resCommand.WriteString(value) case '(': retCode, output, err := utils.ExecuteCommandInShell(subCmdTimeout, cwdLocation, token) if retCode == -1 { return fmt.Errorf("could not execute shell substitution command : %s : %w", token, err) } // We are allowing commands that exit with non zero status code // We still use its output if retCode != 0 { slog.Debug("substitution command exited with non zero status", "retCode", retCode, "command", token) } resCommand.WriteString(output) default: return fmt.Errorf("unexpected openChar %v in tokenization", openChar) } return nil } func findEndingBracket(r []rune, openIdx int, openParan rune, closeParan rune) int { if openIdx < 0 || openIdx >= len(r) || r[openIdx] != openParan { return -1 } openCount := 1 i := openIdx + 1 for i < len(r) && openCount != 0 { switch r[i] { case openParan: openCount++ case closeParan: openCount-- } if openCount != 0 { i++ } } return i } func isOpenBracket(r rune) bool { switch r { case '(', '{': return true default: return false } } func getClosingBracket(r rune) rune { switch r { case '(': return ')' case '{': return '}' default: return ' ' } } // splits command into tokens while respecting quotes and escapes func tokenizeWithQuotes(command string) ([]string, error) { var ( tokens []string buffer strings.Builder quoteOpen rune // 0:none, '\'' or '"' escaped bool ) // Initialize tokens as empty slice instead of nil tokens = []string{} // Helper function to flush the current buffer into tokens flush := func() { tokens = append(tokens, buffer.String()) buffer.Reset() } for _, r := range command { switch { case escaped: // Only allow escaping of specific characters that have special meaning switch r { case '"', '\'', '\\', ' ': // These are valid escape sequences buffer.WriteRune(r) default: // Invalid escape sequence - treat backslash as literal buffer.WriteRune('\\') buffer.WriteRune(r) } escaped = false case r == '\\': escaped = true case quoteOpen == 0 && (r == '"' || r == '\''): quoteOpen = r case quoteOpen == r: // End of quoted section - always flush (even if empty) flush() quoteOpen = 0 case unicode.IsSpace(r) && quoteOpen == 0: // Only flush if we have content if buffer.Len() > 0 { flush() } default: buffer.WriteRune(r) } } if escaped || quoteOpen != 0 { return nil, errors.New("unmatched quotes or escape characters in command") } // Flush any remaining content if buffer.Len() > 0 { flush() } return tokens, nil } ================================================ FILE: src/internal/ui/prompt/tokenize_test.go ================================================ package prompt import ( "context" "fmt" "runtime" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( spfTestEnvVar1 = "SPF_TEST_ENV_VAR1" spfTestEnvVar2 = "SPF_TEST_ENV_VAR2" spfTestEnvVar3 = "SPF_TEST_ENV_VAR3" spfTestEnvVar4 = "SPF_TEST_ENV_VAR4" ) var testEnvValues = map[string]string{ //nolint:gochecknoglobals // This is more like a const. spfTestEnvVar1: "1", spfTestEnvVar2: "hello", spfTestEnvVar3: "", } func Test_tokenizePromptCommand(t *testing.T) { // Just test that we can split as expected // Don't try to test shell substitution in this. This is just // to test that tokenize function can handle the results of shell // substitution as expected testdata := []struct { name string command string expectedRes []string isErrorExpected bool }{ { name: "Empty String", command: "", expectedRes: []string{}, isErrorExpected: false, }, { name: "Parenthesis issue", command: "abcd $(xyz", expectedRes: nil, isErrorExpected: true, }, { name: "Parenthesis issue - But no dollar", command: "abcd (xyz", expectedRes: []string{"abcd", "(xyz"}, isErrorExpected: false, }, { name: "Whitespace", command: " a b c ", expectedRes: []string{"a", "b", "c"}, isErrorExpected: false, }, { name: "Single token", command: "()", expectedRes: []string{"()"}, isErrorExpected: false, }, { name: "Special characters", command: "() \t\n\t a $5^&*\v\a\n\uF0AC", expectedRes: []string{"()", "a", "$5^&*", "\a", "\uF0AC"}, isErrorExpected: false, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { res, err := tokenizePromptCommand(tt.command, defaultTestCwd) assert.Equal(t, tt.expectedRes, res) assert.Equal(t, tt.isErrorExpected, err != nil) }) } } // Note : resolving shell subsitution is flaky in windows. // It usually times out, and environment variables sometimes dont work. func Test_resolveShellSubstitution(t *testing.T) { timeout := shellSubTimeoutInTests newLineSuffix := "\n" noopCommand := "true" if runtime.GOOS == "windows" { // Substitution is slow in windows timeout = 2 * time.Second // Windows uses \r\n as new line for echo newLineSuffix = "\r\n" noopCommand = "cd ." } testdata := []struct { name string command string expectedResult string isErrorExpected bool errorToMatch error }{ // Test with no substitution being performed { name: "Empty String", command: "", expectedResult: "", isErrorExpected: false, errorToMatch: nil, }, { name: "String without substitution requirement", command: " a b c $%^ () {} \a\v\t \u0087", expectedResult: " a b c $%^ () {} \a\v\t \u0087", isErrorExpected: false, errorToMatch: nil, }, { name: "Ill formed command 1", command: "abc $(abc", expectedResult: "", isErrorExpected: true, errorToMatch: roundBracketMatchError(), }, { name: "Ill formed command 2", command: "abc $(echo abc) syt ${ sdfc ( {)}", expectedResult: "", isErrorExpected: true, errorToMatch: curlyBracketMatchError(), }, // Test with substitution being performed { name: "Basic substitution", command: "$(echo abc)", expectedResult: "abc" + newLineSuffix, isErrorExpected: false, errorToMatch: nil, }, // Might not work on windows ? { name: "Command with internal substitution", command: "$(echo $(echo abc))", expectedResult: "abc" + newLineSuffix, isErrorExpected: false, errorToMatch: nil, }, { name: "Multiple substitution", command: fmt.Sprintf("$(echo $(echo hi)) ${%s}", spfTestEnvVar2), expectedResult: fmt.Sprintf("hi%s %s", newLineSuffix, testEnvValues[spfTestEnvVar2]), isErrorExpected: false, errorToMatch: nil, }, { name: "Non Existing env var", command: fmt.Sprintf("${%s}", spfTestEnvVar4), expectedResult: "", isErrorExpected: true, errorToMatch: envVarNotFoundError{varName: spfTestEnvVar4}, }, { name: "Shell substitution inside env var substitution", command: "${$(pwd)}", expectedResult: "", isErrorExpected: true, errorToMatch: envVarNotFoundError{varName: "$(pwd)"}, }, { name: "Empty output", command: "cd abc $(" + noopCommand + ")", expectedResult: "cd abc ", isErrorExpected: false, errorToMatch: nil, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { result, err := resolveShellSubstitution(timeout, tt.command, defaultTestCwd) assert.Equal(t, tt.expectedResult, result) if err != nil { assert.True(t, tt.isErrorExpected) if tt.errorToMatch != nil { assert.ErrorIs(t, err, tt.errorToMatch) } } }) } t.Run("Testing shell substitution timeout", func(t *testing.T) { result, err := resolveShellSubstitution(timeout, "$(sleep 2)", defaultTestCwd) assert.Empty(t, result) require.Error(t, err) require.ErrorIs(t, err, context.DeadlineExceeded) }) } func Test_findEndingParenthesis(t *testing.T) { testdata := []struct { name string value string openIdx int openPar rune closePar rune expectedRes int }{ { name: "Empty String", value: "", openIdx: 0, openPar: '(', closePar: ')', expectedRes: -1, }, { name: "Invalid input", value: "abc", openIdx: 0, openPar: '(', closePar: ')', expectedRes: -1, }, { name: "Simple", value: "abc(def)", openIdx: 3, openPar: '(', closePar: ')', expectedRes: 7, }, { name: "Nesting Example 1", value: "abc(d(e{f})gh)", //------01234567890123 openIdx: 3, openPar: '(', closePar: ')', expectedRes: 13, }, { name: "Nesting Example 2", value: "abc(d(e{f})gh)", //------01234567890123 openIdx: 5, openPar: '(', closePar: ')', expectedRes: 10, }, { name: "Nesting Example 2", value: "abc(d(e{f(x}))gh)", //------01234567890123456 openIdx: 7, openPar: '{', closePar: '}', expectedRes: 11, }, { name: "No Closing Parenthesis 1", value: "abc(def}", //------012345678901234 openIdx: 3, openPar: '(', closePar: ')', expectedRes: 8, }, { name: "No Closing Parenthesis 2", value: "abc((d(e{f})gh)", //------012345678901234 openIdx: 3, openPar: '(', closePar: ')', expectedRes: 15, }, { name: "Asymmetric Parenthesis", value: "abc((d(e{f}>gh)", //------012345678901234 openIdx: 8, openPar: '{', closePar: '>', expectedRes: 11, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { res := findEndingBracket([]rune(tt.value), tt.openIdx, tt.openPar, tt.closePar) assert.Equal(t, tt.expectedRes, res) }) } } func Test_tokenizeWithQuotes(t *testing.T) { testdata := []struct { name string command string expectedRes []string isErrorExpected bool }{ // Basic cases { name: "Empty String", command: "", expectedRes: []string{}, isErrorExpected: false, }, { name: "Simple tokens", command: "a b c", expectedRes: []string{"a", "b", "c"}, isErrorExpected: false, }, { name: "Whitespace handling", command: " a b c ", expectedRes: []string{"a", "b", "c"}, isErrorExpected: false, }, { name: "Tab and newline handling", command: "a\tb\nc", expectedRes: []string{"a", "b", "c"}, isErrorExpected: false, }, { name: "Multiple spaces", command: "command arg", expectedRes: []string{"command", "arg"}, isErrorExpected: false, }, // Basic quoting { name: "Double quotes", command: `"hello world"`, expectedRes: []string{"hello world"}, isErrorExpected: false, }, { name: "Single quotes", command: `'hello world'`, expectedRes: []string{"hello world"}, isErrorExpected: false, }, { name: "Mixed quotes and unquoted", command: `command "arg with spaces" normal`, expectedRes: []string{"command", "arg with spaces", "normal"}, isErrorExpected: false, }, { name: "Leading and trailing quotes", command: `"command" arg "trailing"`, expectedRes: []string{"command", "arg", "trailing"}, isErrorExpected: false, }, // Empty quotes { name: "Empty double quotes", command: `command ""`, expectedRes: []string{"command", ""}, isErrorExpected: false, }, { name: "Empty single quotes", command: `command ''`, expectedRes: []string{"command", ""}, isErrorExpected: false, }, { name: "Only empty quotes", command: `""`, expectedRes: []string{""}, isErrorExpected: false, }, // Nested different quotes { name: "Single quotes inside double quotes", command: `"it's working"`, expectedRes: []string{"it's working"}, isErrorExpected: false, }, { name: "Double quotes inside single quotes", command: `'he said "hello"'`, expectedRes: []string{`he said "hello"`}, isErrorExpected: false, }, // Escaping { name: "Escaped double quote", command: `"escaped \" quote"`, expectedRes: []string{`escaped " quote`}, isErrorExpected: false, }, { name: "Escaped single quote", command: `'can\'t'`, expectedRes: []string{`can't`}, isErrorExpected: false, }, { name: "Escaped backslash", command: `"path\\to\\file"`, expectedRes: []string{`path\to\file`}, isErrorExpected: false, }, { name: "Multiple escaped backslashes", command: `"\\\\"`, expectedRes: []string{`\\`}, isErrorExpected: false, }, { name: "Escaped characters outside quotes", command: `a\ b c`, expectedRes: []string{`a b`, `c`}, isErrorExpected: false, }, // Special characters { name: "Special characters in quotes", command: `"$HOME" '${USER}' "$(pwd)"`, expectedRes: []string{"$HOME", "${USER}", "$(pwd)"}, isErrorExpected: false, }, { name: "Unicode in quotes", command: `"こんにちは" '世界'`, expectedRes: []string{"こんにちは", "世界"}, isErrorExpected: false, }, // Error cases { name: "Unmatched double quote", command: `abcd "sdf`, expectedRes: nil, isErrorExpected: true, }, { name: "Unmatched single quote", command: `"abcd'`, expectedRes: nil, isErrorExpected: true, }, { name: "Unmatched quotes mixed", command: `abc "def' ghi`, expectedRes: nil, isErrorExpected: true, }, { name: "Trailing escape", command: `abc\`, expectedRes: nil, isErrorExpected: true, }, { name: "Escape at end of quoted string", command: `"abc\`, expectedRes: nil, isErrorExpected: true, }, // Complex cases { name: "Multiple quoted sections", command: `"first part" "second part" third`, expectedRes: []string{"first part", "second part", "third"}, isErrorExpected: false, }, { name: "Quotes with no spaces", command: `"hello""world"`, expectedRes: []string{"hello", "world"}, isErrorExpected: false, }, { name: "Mixed quotes no spaces", command: `"hello"'world'`, expectedRes: []string{"hello", "world"}, isErrorExpected: false, }, // Invalid escape sequences (should preserve backslash) { name: "Invalid escape sequence \\n", command: `"hello\nworld"`, expectedRes: []string{`hello\nworld`}, isErrorExpected: false, }, { name: "Invalid escape sequence \\t", command: `"hello\tworld"`, expectedRes: []string{`hello\tworld`}, isErrorExpected: false, }, { name: "Invalid escape sequence \\x", command: `"hello\xworld"`, expectedRes: []string{`hello\xworld`}, isErrorExpected: false, }, { name: "Invalid escape sequence \\$", command: `"hello\$world"`, expectedRes: []string{`hello\$world`}, isErrorExpected: false, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { res, err := tokenizeWithQuotes(tt.command) assert.Equal(t, tt.expectedRes, res) assert.Equal(t, tt.isErrorExpected, err != nil) }) } } ================================================ FILE: src/internal/ui/prompt/type.go ================================================ package prompt import "github.com/charmbracelet/bubbles/textinput" // No need to name it as PromptModel. It will me imported as prompt.Model type Model struct { // Configuration headline string commands []promptCommand spfPromptHotkey string shellPromptHotkey string closeOnSuccess bool // State open bool // whether its shellMode or spfMode // Always use setShellMode to adjust shellMode bool textInput textinput.Model resultMsg string // Whether the user intended action was successful actionSuccess bool // Dimensions - Exported, since model will be dynamically adjusting them width int // Height is dynamically adjusted based on content maxHeight int } // This is only used to render suggestions // Should not be exported type promptCommand struct { command string usage string description string } ================================================ FILE: src/internal/ui/prompt/utils.go ================================================ package prompt import ( "fmt" "strings" "github.com/yorukot/superfile/src/internal/common" ) func getPromptAction(shellMode bool, value string, cwdLocation string) (common.ModelAction, error) { noAction := common.NoAction{} if value == "" { return noAction, nil } if shellMode { return common.ShellCommandAction{ Command: value, }, nil } promptArgs, err := tokenizePromptCommand(value, cwdLocation) if err != nil { return noAction, invalidCmdError{ uiMsg: tokenizationError + " : " + err.Error(), wrappedError: fmt.Errorf("error during tokenization : %w", err), } } switch promptArgs[0] { case "split": if len(promptArgs) != 1 { return noAction, invalidCmdError{ uiMsg: splitCommandArgError, } } return common.SplitPanelAction{}, nil case "cd": if len(promptArgs) != expectedArgCount { return noAction, invalidCmdError{ uiMsg: fmt.Sprintf("cd command needs exactly one argument, received %d", len(promptArgs)-1), } } return common.CDCurrentPanelAction{ Location: promptArgs[1], }, nil case "open": if len(promptArgs) != expectedArgCount { return noAction, invalidCmdError{ uiMsg: fmt.Sprintf("open command needs exactly one argument, received %d", len(promptArgs)-1), } } return common.OpenPanelAction{ Location: promptArgs[1], }, nil default: return noAction, invalidCmdError{ uiMsg: "Invalid spf command : " + promptArgs[0], } } } // Only allocates memory proportional to first token's size // Only works for space right now. Does not splits command based on // \n or \t , etc func getFirstToken(command string) string { command = strings.TrimSpace(command) spaceIndex := strings.IndexByte(command, ' ') if spaceIndex == -1 { return command } return command[:spaceIndex] } ================================================ FILE: src/internal/ui/prompt/utils_test.go ================================================ package prompt import ( "testing" "github.com/stretchr/testify/assert" "github.com/yorukot/superfile/src/internal/common" ) func TestModel_getPromptAction(t *testing.T) { // Notes of Things we tested // About Tokenization failure. Don't test all failures, // it will be in tokenize_test.go testdata := []struct { name string text string shellMode bool expectecAction common.ModelAction expectedErr bool expectedErrMsg string }{ { name: "No Action", text: "", shellMode: true, expectecAction: common.NoAction{}, expectedErr: false, expectedErrMsg: "", }, { name: "Shell command", text: "abc xyz /def", shellMode: true, expectecAction: common.ShellCommandAction{ Command: "abc xyz /def", }, expectedErr: false, expectedErrMsg: "", }, { name: "Tokenization failure", text: "cd ${sdfdsf", // Missing "}" shellMode: false, expectecAction: common.NoAction{}, expectedErr: true, expectedErrMsg: tokenizationError + " : " + curlyBracketMatchError().Error(), }, { name: "Split with extra arguments", text: SplitCommand + " xyz", shellMode: false, expectecAction: common.NoAction{}, expectedErr: true, expectedErrMsg: splitCommandArgError, }, { name: "cd with 0 arguments", text: CdCommand, shellMode: false, expectecAction: common.NoAction{}, expectedErr: true, expectedErrMsg: "cd command needs exactly one argument, received 0", }, { name: "Invalid command", text: "abcd", shellMode: false, expectecAction: common.NoAction{}, expectedErr: true, expectedErrMsg: "Invalid spf command : abcd", }, { name: "Correct split command", text: SplitCommand, shellMode: false, expectecAction: common.SplitPanelAction{}, expectedErr: false, expectedErrMsg: "", }, { name: "Correct cd command", text: CdCommand + " /abc", shellMode: false, expectecAction: common.CDCurrentPanelAction{Location: "/abc"}, expectedErr: false, expectedErrMsg: "", }, { name: "Correct open command", text: OpenCommand + " /abc", shellMode: false, expectecAction: common.OpenPanelAction{Location: "/abc"}, expectedErr: false, expectedErrMsg: "", }, { name: "open with three arguments", text: OpenCommand + " /abc /xyz", shellMode: false, expectecAction: common.NoAction{}, expectedErr: true, expectedErrMsg: "open command needs exactly one argument, received 2", }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { action, err := getPromptAction(tt.shellMode, tt.text, "/") if err != nil { assert.True(t, tt.expectedErr) //nolint: errorlint // We don't expect a wrapped error here, so using type assertion cmdErr, ok := err.(invalidCmdError) assert.True(t, ok) if tt.expectedErrMsg != "" { assert.Equal(t, tt.expectedErrMsg, cmdErr.uiMessage()) } } assert.Equal(t, tt.expectecAction, action) }) } } func Test_getFirstToken(t *testing.T) { t.Run("Basic test", func(t *testing.T) { assert.Equal(t, "abc", getFirstToken("abc")) assert.Equal(t, "abc", getFirstToken("abc a b c")) assert.Equal(t, "abc", getFirstToken("abc ")) assert.Equal(t, "abc", getFirstToken(" abc ")) assert.Equal(t, "abc\n\ta", getFirstToken("abc\n\ta")) }) } ================================================ FILE: src/internal/ui/rendering/README.md ================================================ # renderer package Responsible for rendering # Dependencies This package should not not import any other UI package, and should have minimal, ideally zero, dependency on common, utils or any other spf package. Its meant as a utilites to be used by ui components and main model. It also should not be even in-directly coupled with any UI components. Assume anything like color, style, border config of any other UI component change. This package should not have any changes. # To-dos - [ ] Use rendering package for other models like sort Menu, Help menu, etc. # Notes - Can we move this whole thing into a good useful TUI library outside of this repo ?. At least code it in a way that it can be moved ================================================ FILE: src/internal/ui/rendering/border.go ================================================ package rendering import ( "strings" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/lipgloss" ) type BorderConfig struct { // ANSI encoded strings are not allowed in border title and info items, for now. // The style is overridden with border's style. title string // Optional info items at the bottom of the border infoItems []string // Section dividers - A slice of values within [0,height-2] // Signifying usage of MiddleLeft and MiddleRight borders in Left and Right borders for // Section divider line. dividerIdx []int // Including corners. Both should be >= 2 width int height int titleLeftMargin int } func NewBorderConfig(height int, width int) BorderConfig { return BorderConfig{ height: height, width: width, titleLeftMargin: 1, } } func (b *BorderConfig) SetTitle(title string) { b.title = ansi.Strip(title) } func (b *BorderConfig) SetInfoItems(infoItems ...string) { for i := range infoItems { infoItems[i] = ansi.Strip(infoItems[i]) } b.infoItems = infoItems } func (b *BorderConfig) AreInfoItemsTruncated() bool { cnt := len(b.infoItems) if cnt == 0 { return false } actualWidth := b.width - borderCornerWidth // border.MiddleLeft border.MiddleRight border.Bottom availWidth := actualWidth/cnt - borderDividerWidth for i := range b.infoItems { if ansi.StringWidth(b.infoItems[i]) > availWidth { return true } } return false } func (b *BorderConfig) AddDivider(idx int) { b.dividerIdx = append(b.dividerIdx, idx) } // border.Top with something that takes up more than 1 runewidth will not work, so // we only allow 1 runewidth for now, in the config. multiple things like // border corner characters must be single rune, or else it would break rendering. // This is all filled in one function to prevent passing around too many values // in helper functions func (b *BorderConfig) GetBorder(borderStrings lipgloss.Border) lipgloss.Border { res := borderStrings // excluding corners. Maybe we can move this to a utility function actualWidth := b.width - borderCornerWidth actualHeight := b.height - borderCornerWidth // Min 5 width is needed for title so that at least one character can be // rendered if b.title != "" && actualWidth >= minTitleWidth { // We need to plain truncate the title if needed. // topWidth - 1( for BorderMiddleLeft) - 1 (for BorderMiddleRight) - 2 (padding) titleAvailWidth := actualWidth - borderCornerWidth - borderPaddingWidth // Basic Right truncation truncatedTitle := ansi.Truncate(b.title, titleAvailWidth, "") remainingWidth := actualWidth - borderCornerWidth - borderPaddingWidth - ansi.StringWidth(truncatedTitle) margin := "" if remainingWidth > b.titleLeftMargin { margin = strings.Repeat(borderStrings.Top, b.titleLeftMargin) remainingWidth -= b.titleLeftMargin } // Title alignment is by default Left for now res.Top = margin + borderStrings.MiddleRight + " " + truncatedTitle + " " + borderStrings.MiddleLeft + strings.Repeat(borderStrings.Top, remainingWidth) } cnt := len(b.infoItems) // Minimum 4 character for each info item so that at least first character is rendered if cnt > 0 && actualWidth >= cnt*minInfoItemWidth { // Max available width for each item's actual content // border.MiddleLeft border.MiddleRight border.Bottom availWidth := actualWidth/cnt - borderDividerWidth var infoText strings.Builder for _, item := range b.infoItems { item = ansi.Truncate(item, availWidth, "") infoText.WriteString( borderStrings.MiddleRight + item + borderStrings.MiddleLeft + borderStrings.Bottom, ) } // Fill the rest with border char. remainingWidth := actualWidth - ansi.StringWidth(infoText.String()) res.Bottom = strings.Repeat(borderStrings.Bottom, remainingWidth) + infoText.String() } if len(b.dividerIdx) > 0 { // Update res.Left and res.Right leftBorder := strings.Builder{} rightBorder := strings.Builder{} di := 0 for i := range actualHeight { if di < len(b.dividerIdx) && b.dividerIdx[di] == i { di++ leftBorder.WriteString(borderStrings.MiddleLeft) rightBorder.WriteString(borderStrings.MiddleRight) } else { leftBorder.WriteString(borderStrings.Left) rightBorder.WriteString(borderStrings.Right) } } res.Left = leftBorder.String() res.Right = rightBorder.String() } return res } ================================================ FILE: src/internal/ui/rendering/constants.go ================================================ package rendering // Border rendering constants const ( // borderCornerWidth is the width occupied by border corners borderCornerWidth = 2 // borderPaddingWidth is padding around border title/content borderPaddingWidth = 2 // borderDividerWidth is width for middle dividers (MiddleLeft + MiddleRight + Bottom) borderDividerWidth = 3 // minTitleWidth is minimum width needed to render at least 1 char of title minTitleWidth = 5 // minInfoItemWidth is minimum width for each info item to render at least 1 char minInfoItemWidth = 4 rendererNameMax = 1000 MinWidthForBorder = 2 MinHeightForBorder = 2 ) ================================================ FILE: src/internal/ui/rendering/content_renderer.go ================================================ package rendering import ( "log/slog" "strings" "github.com/yorukot/superfile/src/internal/common" ) type ContentRenderer struct { lines []string // Allow at max this many lines. If there are lesser lines maxLines int // Every line should have at most this many characters maxLineWidth int sanitizeContent bool // We can add alignStyle if needed truncateStyle TruncateStyle name string } func NewContentRenderer(maxLines int, maxLineWidth int, truncateStyle TruncateStyle, name string) ContentRenderer { return ContentRenderer{ lines: make([]string, 0), maxLines: maxLines, maxLineWidth: maxLineWidth, truncateStyle: truncateStyle, sanitizeContent: true, name: name, } } func (r *ContentRenderer) CntLines() int { return len(r.lines) } func (r *ContentRenderer) AddLines(lines ...string) { for _, line := range lines { r.AddLineWithCustomTruncate(line, r.truncateStyle) } } func (r *ContentRenderer) ClearLines() { r.lines = r.lines[:0] } // Maybe better return an error ? // AddLineWithCustomTruncate adds lines to the renderer, truncating each line according to the specified style. // It does not trims whitespace, and its possible to add multiple empty lines using this. func (r *ContentRenderer) AddLineWithCustomTruncate(lineStr string, truncateStyle TruncateStyle) { // If string is multiline, add individual lines separately // We dont use strings.Lines() we need to allow adding empty strings "" as line. for line := range strings.SplitSeq(lineStr, "\n") { if len(r.lines) >= r.maxLines { slog.Debug("Max lines reached", "name", r.name, "maxLines", r.maxLines) return } // Sanitazation should be done before truncate. Sanitization can increase width // For ex: Converting problematic unicode nbsp to spaces. if r.sanitizeContent { line = common.MakePrintableWithEscCheck(line, true) } // Some characters like "\t" are considered 1 width line = TruncateBasedOnStyle(line, r.maxLineWidth, truncateStyle) r.lines = append(r.lines, line) } } func (r *ContentRenderer) Render() string { return strings.Join(r.lines, "\n") } ================================================ FILE: src/internal/ui/rendering/content_renderer_test.go ================================================ package rendering import ( "testing" "github.com/stretchr/testify/assert" ) func TestContentRendererBasic(t *testing.T) { t.Run("Basic test", func(t *testing.T) { r := NewContentRenderer(6, 5, PlainTruncateRight, "") r.AddLines("123456") r.AddLines("12345\n12345", "123") assert.Equal(t, 4, r.CntLines()) r.AddLineWithCustomTruncate("123456", TailsTruncateRight) r.AddLines("\t1234") // Should be ignored r.AddLines("1234") res := r.Render() expected := "12345\n" + "12345\n" + "12345\n" + "123\n" + "12...\n" + " 1" assert.Equal(t, expected, res, "Basic truncation, and adding lines") r.ClearLines() assert.Zero(t, r.CntLines(), "ClearLines should remove all content") r.AddLines("\x00\x11\x1babc") assert.Equal(t, "\x1babc", r.Render()) r.sanitizeContent = false r.ClearLines() r.AddLines("\x00\x11\x1babc") assert.Equal(t, "\x00\x11\x1babc", r.Render()) r = NewContentRenderer(0, 0, PlainTruncateRight, "") r.AddLines("L1") r.AddLines("L2") assert.Empty(t, r.Render()) }) } ================================================ FILE: src/internal/ui/rendering/renderer.go ================================================ package rendering import ( "errors" "fmt" "log/slog" "math/rand/v2" "strconv" "github.com/charmbracelet/lipgloss" ) type StyleModifier func(lipgloss.Style) lipgloss.Style // For now we are not allowing to add/update/remove lines to previous sections // We may allow that later. // Also we could have functions about getting sections count, line count, adding updating a // specific line in a specific section, and adjusting section sizes. But not needed now. // NOTE: Renderer's zero value isn't safe to use, always use NewRenderer() type Renderer struct { // Current sectionization will not allow to predefine section // but only allow adding them via AddSection(). Hence trucateWill be applicable to // last section only. contentSections []ContentRenderer // Empty for last section . len(sectionDividers) should be equal to len(contentSections) - 1 sectionDividers []string curSectionIdx int // Including Dividers - Count of actual lines that were added. It maybe <= totalHeight - 2 actualContentHeight int defTruncateStyle TruncateStyle // Whether to reduce rendered height to fit number of lines truncateHeight bool border BorderConfig // Should this go in contentRenderer - No . ContentRenderer is not for storing style configs contentFGColor lipgloss.TerminalColor contentBGColor lipgloss.TerminalColor // Should this go in borderConfig ? borderFGColor lipgloss.TerminalColor borderBGColor lipgloss.TerminalColor // Use this to add additional style modifications // This is applied before any style update that are defined by other configurations, // like border, height, width. Hence if conflicting styles are used, they can get // overridden styleModifiers []StyleModifier // Maybe better rename these to maxHeight // Final rendered string should have exactly this many lines, including borders // But if truncateHeight is true, it maybe be <= totalHeight totalHeight int // Every line should have at most this many characters, including borders totalWidth int contentHeight int contentWidth int // Note: Must pass non empty borderStrings if borderRequired is set as true // TODO: Have ansi.StringWidth checks in `ValidateConfig` // If you silently pass empty border, rendering will be unexpectd and, // it might take some time to RCA. borderRequired bool borderStrings lipgloss.Border // for logging name string } type RendererConfig struct { TotalHeight int TotalWidth int DefTruncateStyle TruncateStyle TruncateHeight bool BorderRequired bool ContentFGColor lipgloss.TerminalColor ContentBGColor lipgloss.TerminalColor BorderFGColor lipgloss.TerminalColor BorderBGColor lipgloss.TerminalColor Border lipgloss.Border RendererName string } func DefaultRendererConfig(totalHeight int, totalWidth int) RendererConfig { return RendererConfig{ TotalHeight: totalHeight, TotalWidth: totalWidth, TruncateHeight: false, BorderRequired: false, DefTruncateStyle: PlainTruncateRight, ContentFGColor: lipgloss.NoColor{}, ContentBGColor: lipgloss.NoColor{}, BorderFGColor: lipgloss.NoColor{}, BorderBGColor: lipgloss.NoColor{}, //nolint: gosec // Not for security purpose, only for logging RendererName: "R-" + strconv.Itoa(rand.IntN(rendererNameMax)), } } func NewRenderer(cfg RendererConfig) (*Renderer, error) { if err := validate(cfg); err != nil { return nil, err } return createRendererWithValidatedConfig(cfg), nil } func NewRendererWithAutoFixConfig(cfg RendererConfig) *Renderer { validateAndAutoFix(&cfg) return createRendererWithValidatedConfig(cfg) } func createRendererWithValidatedConfig(cfg RendererConfig) *Renderer { contentHeight := cfg.TotalHeight if cfg.BorderRequired { contentHeight -= 2 } contentWidth := cfg.TotalWidth if cfg.BorderRequired { contentWidth -= 2 } return &Renderer{ contentSections: []ContentRenderer{ NewContentRenderer(contentHeight, contentWidth, cfg.DefTruncateStyle, cfg.RendererName), }, sectionDividers: nil, curSectionIdx: 0, actualContentHeight: 0, defTruncateStyle: cfg.DefTruncateStyle, truncateHeight: cfg.TruncateHeight, border: NewBorderConfig(cfg.TotalHeight, cfg.TotalWidth), contentFGColor: cfg.ContentFGColor, contentBGColor: cfg.ContentBGColor, borderFGColor: cfg.BorderFGColor, borderBGColor: cfg.BorderBGColor, totalHeight: cfg.TotalHeight, totalWidth: cfg.TotalWidth, contentHeight: contentHeight, contentWidth: contentWidth, borderRequired: cfg.BorderRequired, borderStrings: cfg.Border, name: cfg.RendererName, } } // There is code duplication with `validate` but, I can't think of any clean design pattern to fix that. // Note: Having a function validate(cfg,autoFix) error and ensure err is not nil via panic is not clean. func validateAndAutoFix(cfg *RendererConfig) { if cfg.TotalHeight < 0 || cfg.TotalWidth < 0 { slog.Debug("AutoFixConfig: clamping negative dimensions", "h", cfg.TotalHeight, "w", cfg.TotalWidth) cfg.TotalHeight = max(0, cfg.TotalHeight) cfg.TotalWidth = max(0, cfg.TotalWidth) } if cfg.BorderRequired { if cfg.TotalWidth < MinWidthForBorder || cfg.TotalHeight < MinHeightForBorder { slog.Debug("AutoFixConfig: disabling border due to insufficient dimensions", "h", cfg.TotalHeight, "w", cfg.TotalWidth) cfg.BorderRequired = false } } } func validate(cfg RendererConfig) error { if cfg.TotalHeight < 0 || cfg.TotalWidth < 0 { return fmt.Errorf("dimensions must be non-negative (h=%d, w=%d)", cfg.TotalHeight, cfg.TotalWidth) } if cfg.BorderRequired { if cfg.TotalWidth < MinWidthForBorder || cfg.TotalHeight < MinHeightForBorder { return errors.New("need at least 2 width and height for borders") } } return nil } ================================================ FILE: src/internal/ui/rendering/renderer_core.go ================================================ package rendering import ( "log/slog" "strings" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) // Add lines as much as the remaining capacity allows func (r *Renderer) AddLines(lines ...string) *Renderer { r.contentSections[r.curSectionIdx].AddLines(lines...) return r } // Lines until now will belong to current section, and // Any new lines will belong to a new section func (r *Renderer) AddSection() { // r.actualContentHeight before this point only includes sections // before r.curSectionIdx r.actualContentHeight += r.contentSections[r.curSectionIdx].CntLines() // Silently Fail if cannot add if r.contentHeight <= r.actualContentHeight { slog.Error("Cannot add any more sections", "name", r.name, "actualHeight", r.actualContentHeight, "contentHeight", r.contentHeight) return } // Add divider r.border.AddDivider(r.actualContentHeight) // sectionDivider should be of borderstyle r.sectionDividers = append(r.sectionDividers, lipgloss.NewStyle(). Foreground(r.borderFGColor). Background(r.borderBGColor). Render(strings.Repeat(r.borderStrings.Top, r.contentWidth))) r.actualContentHeight++ remainingHeight := r.contentHeight - r.actualContentHeight r.contentSections = append(r.contentSections, NewContentRenderer(remainingHeight, r.contentWidth, r.defTruncateStyle, r.name)) // Adjust index r.curSectionIdx++ } // Truncate would always preserve ansi codes. func (r *Renderer) AddLineWithCustomTruncate(line string, truncateStyle TruncateStyle) { r.contentSections[r.curSectionIdx].AddLineWithCustomTruncate(line, truncateStyle) } func (r *Renderer) AddStyleModifier(modifier StyleModifier) *Renderer { r.styleModifiers = append(r.styleModifiers, modifier) return r } func (r *Renderer) SetBorderTitle(title string) { r.border.SetTitle(title) } func (r *Renderer) SetBorderInfoItems(infoItems ...string) { r.border.SetInfoItems(infoItems...) } func (r *Renderer) AreInfoItemsTruncated() bool { return r.border.AreInfoItemsTruncated() } // Should not do any updates on 'r' func (r *Renderer) Render() string { content := strings.Builder{} for i := range r.contentSections { // After every iteration, current cursor will be on next newline curContent := r.contentSections[i].Render() content.WriteString(curContent) // == "" check cant differentiate between no data, vs empty line if r.contentSections[i].CntLines() > 0 { content.WriteString("\n") } if i < len(r.contentSections)-1 { // True for all except last section content.WriteString(r.sectionDividers[i]) content.WriteString("\n") } } contentStr := strings.TrimSuffix(content.String(), "\n") res := r.Style().Render(contentStr) // Post rendering validations - Maybe we can return an error instead of logging // TODO(perf): This can be disabled to improve performance maxW := 0 for line := range strings.Lines(res) { maxW = max(maxW, ansi.StringWidth(line)) } lineCnt := strings.Count(res, "\n") + 1 if maxW > r.totalWidth || lineCnt > r.totalHeight { slog.Error( "Rendered output data inconsistency", "name", r.name, "lineCnt", lineCnt, "totalHeight", r.totalHeight, "totalWidth", r.totalWidth, "maxW", maxW, ) // lipgloss Render() doesn't always respects the "height" value, // so res can have more height than intended. In that case, we must truncate lines here. newRes := strings.Builder{} curCnt := 0 // Dont use strings.Lines(), that wont allow us to have empty lines for line := range strings.SplitSeq(res, "\n") { if curCnt == r.totalHeight { break } newRes.WriteString(ansi.Truncate(line, r.totalWidth, "")) curCnt++ if curCnt < r.totalHeight { newRes.WriteByte('\n') } } return newRes.String() } return res } func (r *Renderer) Style() lipgloss.Style { contentHeight := r.contentHeight if r.truncateHeight { contentHeight = r.actualContentHeight } s := lipgloss.NewStyle() for _, modifier := range r.styleModifiers { s = modifier(s) } s = s.Width(r.contentWidth). Height(contentHeight). Background(r.contentBGColor). Foreground(r.contentFGColor) if r.borderRequired { s = s.Border(r.border.GetBorder(r.borderStrings)) s = s.BorderForeground(r.borderFGColor). BorderBackground(r.borderBGColor) } return s } ================================================ FILE: src/internal/ui/rendering/renderer_test.go ================================================ package rendering import ( "flag" "testing" "github.com/charmbracelet/lipgloss" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" ) const ( sectionStr = "
" ) func TestMain(m *testing.M) { flag.Parse() if testing.Verbose() { utils.SetRootLoggerToStdout(true) } else { utils.SetRootLoggerToDiscarded() } m.Run() } func getDefaultTestRendererConfig(totalHeight int, totalWidth int, borderRequired bool, truncateHeight bool) RendererConfig { cfg := DefaultRendererConfig(totalHeight, totalWidth) if borderRequired { cfg.BorderRequired = true cfg.Border = lipgloss.Border{ Top: "─", Bottom: "─", Left: "│", Right: "│", TopLeft: "╭", TopRight: "╮", BottomLeft: "╰", BottomRight: "╯", MiddleLeft: "├", MiddleRight: "┤", } } cfg.TruncateHeight = truncateHeight return cfg } func getDefaultTestRenderer(totalHeight int, totalWidth int, borderRequired bool) *Renderer { r, _ := NewRenderer(getDefaultTestRendererConfig(totalHeight, totalWidth, borderRequired, false)) return r } func TestRendererBasic(t *testing.T) { t.Run("Basic test", func(t *testing.T) { r := getDefaultTestRenderer(4, 4, true) r.AddLines("L1") r.AddLines("L2--Extra line should truncated") r.AddLines("L3--Extra line should not be added") res := r.Render() expected := "" + "╭──╮\n" + "│L1│\n" + "│L2│\n" + "╰──╯" assert.Equal(t, expected, res) }) t.Run("Empty Renderer", func(t *testing.T) { r := getDefaultTestRenderer(0, 0, false) r.AddLines("L1") r.AddLines("L2--Extra line should truncated") r.AddLines("L3--Extra line should not be added") res := r.Render() expected := "" assert.Equal(t, expected, res) }) t.Run("Invalid config Renderer", func(t *testing.T) { cfg := getDefaultTestRendererConfig(0, 0, true, false) r, err := NewRenderer(cfg) assert.Nil(t, r) require.Error(t, err) }) } func TestSections(t *testing.T) { sectionTests := []struct { name string totalHeight int totalWidth int borderRequired bool // Test expects only single line strings. lines []string trucateheight bool expected string }{ { name: "Basic Sections", totalHeight: 7, totalWidth: 4, borderRequired: true, lines: []string{"L1", sectionStr, "L2", sectionStr, sectionStr, "L3", sectionStr}, trucateheight: false, expected: "" + "╭──╮\n" + "│L1│\n" + "├──┤\n" + "│L2│\n" + "├──┤\n" + "├──┤\n" + "╰──╯", }, { name: "Only Sections, with empty lines", totalHeight: 7, totalWidth: 4, borderRequired: true, lines: []string{sectionStr, sectionStr, "", sectionStr, sectionStr}, trucateheight: false, expected: "" + "╭──╮\n" + "├──┤\n" + "├──┤\n" + "│ │\n" + "├──┤\n" + "├──┤\n" + "╰──╯", }, { name: "Single line at the end", totalHeight: 7, totalWidth: 4, borderRequired: true, lines: []string{sectionStr, sectionStr, sectionStr, sectionStr, "L1"}, trucateheight: false, expected: "" + "╭──╮\n" + "├──┤\n" + "├──┤\n" + "├──┤\n" + "├──┤\n" + "│L1│\n" + "╰──╯", }, { name: "Only sections", totalHeight: 3, totalWidth: 4, borderRequired: true, lines: []string{sectionStr}, trucateheight: false, expected: "" + "╭──╮\n" + "├──┤\n" + "╰──╯", }, { name: "Minimal width", totalHeight: 4, totalWidth: 2, borderRequired: true, lines: []string{sectionStr, "L1", sectionStr, sectionStr}, trucateheight: false, expected: "" + "╭╮\n" + "├┤\n" + "││\n" + "╰╯", }, { name: "Minimal height", totalHeight: 2, totalWidth: 8, borderRequired: true, lines: []string{sectionStr, "L1", sectionStr, sectionStr}, trucateheight: false, expected: "" + "╭──────╮\n" + "│ │", // Border breaks here, because lipgloss creates a 3 line string, and // our renderer, than manually adjusts it. }, { name: "Minimal heightBorderless", totalHeight: 0, totalWidth: 8, borderRequired: false, lines: []string{sectionStr, "L1", sectionStr, sectionStr}, trucateheight: false, expected: "", }, { name: "No Border", totalHeight: 4, totalWidth: 4, borderRequired: false, lines: []string{sectionStr, "L1", sectionStr}, trucateheight: false, expected: "" + " \n" + "L1 \n" + " \n" + " ", }, } for _, tt := range sectionTests { t.Run(tt.name, func(t *testing.T) { r, _ := NewRenderer(getDefaultTestRendererConfig( tt.totalHeight, tt.totalWidth, tt.borderRequired, tt.trucateheight)) // maxL := r.contentWidth // if i >= maxL, check for errors here for _, l := range tt.lines { if l == sectionStr { r.AddSection() } else { r.AddLines(l) } } assert.Equal(t, tt.expected, r.Render()) }) } } func TestDynamicHeight(t *testing.T) { dynamicHeightTests := []struct { name string totalHeight int lines []string trucateheight bool expected string }{ { name: "No truncate", totalHeight: 5, lines: []string{"L1"}, trucateheight: false, expected: "" + "╭──╮\n" + "│L1│\n" + "│ │\n" + "│ │\n" + "╰──╯", }, { name: "Basic truncate", totalHeight: 7, lines: []string{"L1", ""}, trucateheight: true, expected: "" + "╭──╮\n" + "│L1│\n" + "│ │\n" + "╰──╯", }, { name: "Basic truncate with Sections", totalHeight: 100, lines: []string{"L1", "", sectionStr, "L2", "", "L3"}, trucateheight: true, expected: "" + "╭──╮\n" + "│L1│\n" + "│ │\n" + "├──┤\n" + "│L2│\n" + "│ │\n" + "│L3│\n" + "╰──╯", }, } for _, tt := range dynamicHeightTests { t.Run(tt.name, func(t *testing.T) { r, _ := NewRenderer(getDefaultTestRendererConfig( tt.totalHeight, 4, true, tt.trucateheight)) for _, l := range tt.lines { if l == sectionStr { r.AddSection() } else { r.AddLines(l) } } assert.Equal(t, tt.expected, r.Render()) }) } } func TestBorders(t *testing.T) { t.Run("Basic test", func(t *testing.T) { r := getDefaultTestRenderer(4, 10, true) r.AddLines("L1") r.AddLines("L2") r.SetBorderTitle("Title") res := r.Render() expected := "" + "╭┤ Titl ├╮\n" + "│L1 │\n" + "│L2 │\n" + "╰────────╯" assert.False(t, r.AreInfoItemsTruncated()) assert.Equal(t, expected, res, "No margin if title is too big") r.SetBorderTitle("T") res = r.Render() expected = "" + "╭─┤ T ├──╮\n" + "│L1 │\n" + "│L2 │\n" + "╰────────╯" assert.Equal(t, expected, res, "Margin should be there if title fits well") r.border.SetInfoItems("A", "B") assert.False(t, r.AreInfoItemsTruncated()) res = r.Render() expected = "" + "╭─┤ T ├──╮\n" + "│L1 │\n" + "│L2 │\n" + "╰┤A├─┤B├─╯" assert.Equal(t, expected, res) r.border.SetInfoItems("A1", "B2") assert.True(t, r.AreInfoItemsTruncated()) res = r.Render() expected = "" + "╭─┤ T ├──╮\n" + "│L1 │\n" + "│L2 │\n" + "╰┤A├─┤B├─╯" assert.Equal(t, expected, res) r.border.SetInfoItems("A12345") assert.True(t, r.AreInfoItemsTruncated()) res = r.Render() expected = "" + "╭─┤ T ├──╮\n" + "│L1 │\n" + "│L2 │\n" + "╰┤A1234├─╯" assert.Equal(t, expected, res, "Info Items Truncation") r.SetBorderTitle("✅1✅2✅3") r.SetBorderInfoItems() res = r.Render() expected = "" + "╭┤ ✅1 ├─╮\n" + "│L1 │\n" + "│L2 │\n" + "╰────────╯" assert.Equal(t, expected, res, "Double terminal width characters in Title") testStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#0000ff")) title := testStyle.Render("Title") r.SetBorderTitle(title) res = r.Render() expected = "" + "╭┤ Titl ├╮\n" + "│L1 │\n" + "│L2 │\n" + "╰────────╯" assert.Equal(t, expected, res, "Ansi escapes are not preserved") r.SetBorderTitle("") r.SetBorderInfoItems("A", "") res = r.Render() expected = "" + "╭────────╮\n" + "│L1 │\n" + "│L2 │\n" + "╰─┤A├─┤├─╯" assert.Equal(t, expected, res, "Empty title is ignored, but not empty infoitems") r.SetBorderInfoItems("AA", "") res = r.Render() expected = "" + "╭────────╮\n" + "│L1 │\n" + "│L2 │\n" + "╰─┤A├─┤├─╯" assert.True(t, r.AreInfoItemsTruncated()) assert.Equal(t, expected, res, "Truncated even if there was enough space because one item was too big") }) t.Run("Different Border", func(t *testing.T) { cfg := getDefaultTestRendererConfig(6, 10, true, false) cfg.Border = lipgloss.Border{ Top: "─", Bottom: "*", Left: "+", Right: "│", TopLeft: "╭", TopRight: "╮", BottomLeft: "╰", BottomRight: "╯", MiddleLeft: "├", MiddleRight: "┤", } r, _ := NewRenderer(cfg) r.SetBorderTitle("Title") r.SetBorderInfoItems("A") r.AddLines("L1") r.AddSection() r.AddLines("") r.AddLines("L2") res := r.Render() expected := "" + "╭┤ Titl ├╮\n" + "+L1 │\n" + "├────────┤\n" + "+ │\n" + "+L2 │\n" + "╰****┤A├*╯" assert.Equal(t, expected, res, "Ansi escape is preserved") }) } ================================================ FILE: src/internal/ui/rendering/truncate.go ================================================ package rendering import ( "log/slog" "github.com/charmbracelet/x/ansi" ) type TruncateStyle int // These truncate styles must preserve ansi escape codes. If something doesn't preserves // it shouldn't be here const ( PlainTruncateRight = iota TailsTruncateRight ) func TruncateBasedOnStyle(line string, maxWidth int, truncateStyle TruncateStyle) string { switch truncateStyle { case PlainTruncateRight: return ansi.Truncate(line, maxWidth, "") case TailsTruncateRight: return ansi.Truncate(line, maxWidth, "...") default: slog.Error("Invalid truncate style", "style", truncateStyle) return "" } } ================================================ FILE: src/internal/ui/rendering/truncate_test.go ================================================ package rendering import ( "testing" "github.com/charmbracelet/lipgloss" "github.com/stretchr/testify/assert" ) func TestTruncate(t *testing.T) { testStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#0000ff")) testdata := []struct { name string line string maxWidth int style TruncateStyle expected string }{ { name: "No truncate", line: "abc", maxWidth: 10, style: PlainTruncateRight, expected: "abc", }, { name: "Plain truncate", line: "abcdefgh", maxWidth: 5, style: PlainTruncateRight, expected: "abcde", }, { name: "Tails truncate", line: "abcdefgh", maxWidth: 5, style: TailsTruncateRight, expected: "ab...", }, { name: "Invalid style", line: "abcdefgh", maxWidth: 5, style: 10, expected: "", }, { name: "Tails truncate with too less width", line: "abcdefgh", maxWidth: 2, style: TailsTruncateRight, expected: "", }, { name: "Wide characters", line: "✅1✅2✅3", maxWidth: 3, style: PlainTruncateRight, expected: "✅1", }, { name: "Wide characters 2", line: "✅1✅2✅3", maxWidth: 4, style: PlainTruncateRight, expected: "✅1", }, { name: "Wide characters 3", line: "✅1✅2✅3", maxWidth: 4, style: TailsTruncateRight, expected: "...", }, { name: "Ansi color sequence", line: testStyle.Render("12345"), maxWidth: 4, style: TailsTruncateRight, expected: testStyle.Render("1..."), }, { name: "Ansi color sequence with just enough widht", line: testStyle.Render("1234"), maxWidth: 4, style: TailsTruncateRight, expected: testStyle.Render("1234"), }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expected, TruncateBasedOnStyle(tt.line, tt.maxWidth, tt.style)) }) } } ================================================ FILE: src/internal/ui/sidebar/README.md ================================================ # sidebar package This is for the sidebar UI, and for fetching and updating sidebar directories # To-dos - Add missing unit tests - Separate out implementation of file I/O operations. (Disk listing, Reading and Updating pinned.json) This package should only be concerned with UI/UX. - Implementing a proper state transitioning for the sidebar's different modes (normal, search, rename) - Some methods could be made more pure by reducing side effects # Coverage ```bash cd /path/to/ui/sidebar go test -cover ``` Current coverage is 29.3%. ================================================ FILE: src/internal/ui/sidebar/consts.go ================================================ package sidebar import ( "github.com/yorukot/superfile/src/pkg/utils" ) // These are effectively consts // Had to use `var` as go doesn't allows const structs var homeDividerDir = directory{ //nolint: gochecknoglobals // This is more like a const. Name: "", Location: "Home+-*/=?", } var pinnedDividerDir = directory{ //nolint: gochecknoglobals // This is more like a const. Name: "", Location: "Pinned+-*/=?", } var diskDividerDir = directory{ //nolint: gochecknoglobals // This is more like a const. Name: "", Location: "Disks+-*/=?", } var defaultSectionSlice = []string{ //nolint: gochecknoglobals // This is more like a const. utils.SidebarSectionHome, utils.SidebarSectionPinned, utils.SidebarSectionDisks, } // superfile logo + blank line + search bar const sideBarInitialHeight = 3 // UI dimension constants for sidebar const ( // searchBarPadding is the total padding for search bar (borders + prompt + extra char) searchBarPadding = 5 // 2 (borders) + 2 (prompt) + 1 (extra char) directoryCapacityForDividers = 2 // dividerDirHeight is the default height when no height is available dividerDirHeight = 3 minHeight = 5 minWidth = 7 ) ================================================ FILE: src/internal/ui/sidebar/directory_utils.go ================================================ package sidebar import ( "os" "runtime" "slices" "github.com/adrg/xdg" "github.com/yorukot/superfile/src/pkg/utils" variable "github.com/yorukot/superfile/src/config" "github.com/yorukot/superfile/src/config/icon" "github.com/yorukot/superfile/src/internal/common" ) // Fuzzy search function for a list of directories. func fuzzySearch(query string, dirs []directory) []directory { if len(dirs) == 0 { return []directory{} } var filteredDirs []directory // Optimization - This haystack can be kept precomputed based on directories // instead of re computing it in each call haystack := make([]string, len(dirs)) dirMap := make(map[string]directory, len(dirs)) for i, dir := range dirs { haystack[i] = dir.Name dirMap[dir.Name] = dir } for _, match := range utils.FzfSearch(query, haystack) { if d, ok := dirMap[match.Key]; ok { filteredDirs = append(filteredDirs, d) } } return filteredDirs } // getDirectories returns the list of directories to display in the sidebar. func getDirectories(pinnedMgr *PinnedManager, sections []string) []directory { return formDirctorySlice( getWellKnownDirectories(), getPinnedDirectoriesWithIcon(pinnedMgr), getExternalMediaFolders(), sections, ) } // Return system default directory e.g. Home, Downloads, etc func getWellKnownDirectories() []directory { wellKnownDirectories := []directory{ {Location: xdg.Home, Name: icon.Home + icon.Space + "Home"}, {Location: xdg.UserDirs.Download, Name: icon.Download + icon.Space + "Downloads"}, {Location: xdg.UserDirs.Documents, Name: icon.Documents + icon.Space + "Documents"}, {Location: xdg.UserDirs.Pictures, Name: icon.Pictures + icon.Space + "Pictures"}, {Location: xdg.UserDirs.Videos, Name: icon.Videos + icon.Space + "Videos"}, {Location: xdg.UserDirs.Music, Name: icon.Music + icon.Space + "Music"}, {Location: xdg.UserDirs.Templates, Name: icon.Templates + icon.Space + "Templates"}, {Location: xdg.UserDirs.PublicShare, Name: icon.PublicShare + icon.Space + "PublicShare"}, } // Add Trash directory for Linux only if runtime.GOOS == utils.OsLinux { wellKnownDirectories = append(wellKnownDirectories, directory{ Location: variable.LinuxTrashDirectory, Name: icon.Trash + icon.Space + "Trash", }) } return slices.DeleteFunc(wellKnownDirectories, func(d directory) bool { _, err := os.Stat(d.Location) return err != nil }) } func getPinnedDirectoriesWithIcon(pinnedMgr *PinnedManager) []directory { dirs := pinnedMgr.Load() for i := range dirs { iconInfo := common.GetElementIcon(dirs[i].Name, true, false, common.Config.Nerdfont) dirs[i].Name = iconInfo.Icon + icon.Space + dirs[i].Name } return dirs } // Get filtered directories using fuzzy search logic with three haystacks. func getFilteredDirectories(query string, pinnedMgr *PinnedManager, sections []string) []directory { return formDirctorySlice( fuzzySearch(query, getWellKnownDirectories()), fuzzySearch(query, getPinnedDirectoriesWithIcon(pinnedMgr)), fuzzySearch(query, getExternalMediaFolders()), sections, ) } func formDirctorySlice(homeDirectories []directory, pinnedDirectories []directory, diskDirectories []directory, sections []string) []directory { // Preallocation for efficiency totalCapacity := len(homeDirectories) + len(pinnedDirectories) + len(diskDirectories) + directoryCapacityForDividers directories := make([]directory, 0, totalCapacity) for _, section := range sections { switch section { case utils.SidebarSectionHome: if len(directories) > 0 { directories = append(directories, homeDividerDir) } directories = append(directories, homeDirectories...) case utils.SidebarSectionPinned: directories = append(directories, pinnedDividerDir) directories = append(directories, pinnedDirectories...) case utils.SidebarSectionDisks: directories = append(directories, diskDividerDir) directories = append(directories, diskDirectories...) } } return directories } ================================================ FILE: src/internal/ui/sidebar/disk_utils.go ================================================ package sidebar import ( "log/slog" "path/filepath" "runtime" "strings" "github.com/shirou/gopsutil/v4/disk" "github.com/yorukot/superfile/src/pkg/utils" ) // Get external media directories func getExternalMediaFolders() []directory { // only get physical drives parts, err := disk.Partitions(false) if err != nil { slog.Error("Error while getting external media: ", "error", err) return nil } var disks []directory for _, disk := range parts { // ShouldListDisk, DiskName, and DiskLocation, each has runtime.GOOS checks // We can ideally reduce it to one check only. if shouldListDisk(disk.Mountpoint) { disks = append(disks, directory{ Name: diskName(disk.Mountpoint), Location: diskLocation(disk.Mountpoint), }) } } return disks } func shouldListDisk(mountPoint string) bool { if runtime.GOOS == utils.OsWindows { // We need to get C:, D: drive etc in the list return true } // Should always list the main disk if mountPoint == "/" { return true } // TODO : make a configurable field in config.yaml // excluded_disk_mounts = ["/Volumes/.timemachine"] // Mountpoints that are in subdirectory of disk_mounts // but still are to be excluded in disk section of sidebar if strings.HasPrefix(mountPoint, "/Volumes/.timemachine") { return false } // We avoid listing all mounted partitions (Otherwise listed disk could get huge) // but only a few partitions that usually corresponds to external physical devices // For example : mounts like /boot, /var/ will get skipped // This can be inaccurate based on your system setup if you mount any external devices // on other directories, or if you have some extra mounts on these directories // TODO : make a configurable field in config.yaml // disk_mounts = ["/mnt", "/media", "/run/media", "/Volumes"] // Only block devicies that are mounted on these or any subdirectory of these Mountpoints // Will be shown in disk sidebar return strings.HasPrefix(mountPoint, "/mnt") || strings.HasPrefix(mountPoint, "/media") || strings.HasPrefix(mountPoint, "/run/media") || strings.HasPrefix(mountPoint, "/Volumes") } func diskName(mountPoint string) string { // In windows we dont want to use filepath.Base as it returns "\" for when // mountPoint is any drive root "C:", "D:", etc. Hence causing same name // for each drive if runtime.GOOS == utils.OsWindows { return mountPoint } // This might cause duplicate names in case you mount two devices in // /mnt/usb and /mnt/dir2/usb . Full mountpoint is a more accurate way // but that results in messy UI, hence we do this. return filepath.Base(mountPoint) } func diskLocation(mountPoint string) string { // In windows if you are in "C:\some\path", "cd C:" will not cd to root of C: drive // but "cd C:\" will if runtime.GOOS == utils.OsWindows { return filepath.Join(mountPoint, "\\") } return mountPoint } ================================================ FILE: src/internal/ui/sidebar/navigation.go ================================================ package sidebar import ( "log/slog" "github.com/yorukot/superfile/src/internal/common" ) func (s *Model) ListUp() { slog.Debug("controlListUp called", "cursor", s.cursor, "renderIndex", s.renderIndex, "directory count", len(s.directories)) if s.NoActualDir() { return } if s.cursor > 0 { // Not at the top, can safely decrease s.cursor-- } else { // We are at the top. Move to the bottom s.cursor = len(s.directories) - 1 } // We should update even if cursor is at divider for now // Otherwise dividers are sometimes skipped in render in case of // large pinned directories s.updateRenderIndex() if s.directories[s.cursor].isDivider() { // cause another listUp trigger to move up. s.ListUp() } } func (s *Model) ListDown() { slog.Debug("controlListDown called", "cursor", s.cursor, "renderIndex", s.renderIndex, "directory count", len(s.directories)) if s.NoActualDir() { return } if s.cursor < len(s.directories)-1 { // Not at the bottom, can safely increase s.cursor++ } else { // We are at the bottom. Move to the top s.cursor = 0 } // We should update even if cursor is at divider for now // Otherwise dividers are sometimes skipped in render in case of // large pinned directories s.updateRenderIndex() // Move below special divider directories if s.directories[s.cursor].isDivider() { // cause another listDown trigger to move down. s.ListDown() } } // Return till what indexes we will render, if we start from startIndex // if returned value is `startIndex - 1`, that means nothing can be rendered // This could be made constant time by keeping Indexes ot special directories saved, // but that too much. func (s *Model) lastRenderedIndex(startIndex int) int { mainPanelHeight := s.height - common.BorderPadding curHeight := sideBarInitialHeight endIndex := startIndex - 1 for i := startIndex; i < len(s.directories); i++ { curHeight += s.directories[i].requiredHeight() if curHeight > mainPanelHeight { break } endIndex = i } return endIndex } // Return what will be the startIndex, if we end at endIndex // if returned value is `endIndex + 1`, that means nothing can be rendered func (s *Model) firstRenderedIndex(endIndex int) int { mainPanelHeight := s.height - common.BorderPadding // This should ideally never happen. Maybe we should panic ? if endIndex >= len(s.directories) { return endIndex + 1 } curHeight := sideBarInitialHeight startIndex := endIndex + 1 for i := endIndex; i >= 0; i-- { curHeight += s.directories[i].requiredHeight() if curHeight > mainPanelHeight { break } startIndex = i } return startIndex } func (s *Model) updateRenderIndex() { // Case I : New cursor moved above current renderable range if s.cursor < s.renderIndex { // We will start rendering from there s.renderIndex = s.cursor return } curEndIndex := s.lastRenderedIndex(s.renderIndex) // Case II : new cursor also comes in range of rendered directories // Taking this case later avoid extra lastRenderedIndex() call if s.renderIndex <= s.cursor && s.cursor <= curEndIndex { // no need to update s.renderIndex return } // Case III : New cursor is too below if curEndIndex < s.cursor { s.renderIndex = s.firstRenderedIndex(s.cursor) return } // Code should never reach here slog.Error("Unexpected situation in updateRenderIndex", "cursor", s.cursor, "renderIndex", s.renderIndex, "directory count", len(s.directories)) } ================================================ FILE: src/internal/ui/sidebar/navigation_test.go ================================================ package sidebar import ( "testing" "github.com/stretchr/testify/assert" "github.com/yorukot/superfile/src/internal/common" ) func Test_lastRenderIndex(t *testing.T) { // Setup test data sidebarA := defaultTestModel(0, 0, 0, 10, 10, 10) sidebarB := defaultTestModel(0, 0, 0, 1, 0, 5) testCases := []struct { name string sidebar Model mainPanelHeight int startIndex int expectedLastIndex int explanation string }{ { name: "Small viewport with home directories", sidebar: sidebarA, mainPanelHeight: 10, startIndex: 0, expectedLastIndex: 6, explanation: "3(initialHeight) + 7 (0-6 home dirs)", }, { name: "Medium viewport showing home and some pinned", sidebar: sidebarA, mainPanelHeight: 20, startIndex: 0, expectedLastIndex: 14, explanation: "3(initialHeight) + 10 (0-9 home dirs) + 3 (10-pinned divider) + 4 (11-14 pinned dirs)", }, { name: "Medium viewport starting from pinned dirs", sidebar: sidebarA, mainPanelHeight: 20, startIndex: 11, expectedLastIndex: 25, explanation: "3(initialHeight) + 10 (11-20 pinned dirs) + 3 (21-disk divider) + 4 (22-25 disk dirs)", }, { name: "Large viewport showing all directories", sidebar: sidebarA, mainPanelHeight: 100, startIndex: 11, expectedLastIndex: 31, explanation: "Last dir index is 31", }, { name: "Start index beyond directory count", sidebar: sidebarA, mainPanelHeight: 100, startIndex: 32, expectedLastIndex: 31, explanation: "When startIndex > len(directories), return last valid index", }, { name: "Asymmetric directory distribution", sidebar: sidebarB, mainPanelHeight: 12, startIndex: 0, expectedLastIndex: 4, explanation: "3(initialHeight) + 1 (0-homedir) + 3(1-pinned divider) + 3 (2-diskdivider) + 2 (3-4 diskdirs)", }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { tt.sidebar.SetHeight(tt.mainPanelHeight + common.BorderPadding) result := tt.sidebar.lastRenderedIndex(tt.startIndex) assert.Equal(t, tt.expectedLastIndex, result, "lastRenderedIndex failed: %s", tt.explanation) }) } } func Test_firstRenderIndex(t *testing.T) { sidebarA := defaultTestModel(0, 0, 0, 10, 10, 10) sidebarB := defaultTestModel(0, 0, 0, 1, 0, 5) sidebarC := defaultTestModel(0, 0, 0, 0, 5, 5) sidebarD := defaultTestModel(0, 0, 0, 0, 0, 3) // Empty sidebar with only dividers sidebarE := defaultTestModel(0, 0, 0, 0, 0, 0) testCases := []struct { name string sidebar Model mainPanelHeight int endIndex int expectedFirstIndex int explanation string }{ { name: "Basic calculation from end index", sidebar: sidebarA, mainPanelHeight: 10, endIndex: 10, expectedFirstIndex: 6, explanation: "3(InitialHeight) + 4 (6-9 homedirs) + 3 (10-pinned divider)", }, { name: "Small panel height", sidebar: sidebarA, mainPanelHeight: 5, endIndex: 15, expectedFirstIndex: 14, explanation: "3(InitialHeight) + 2(14-15 pinned dirs)", }, { name: "End index near beginning", sidebar: sidebarA, mainPanelHeight: 20, endIndex: 3, expectedFirstIndex: 0, explanation: "When end index is near beginning, first index should be 0", }, { name: "End index at disk divider", sidebar: sidebarA, mainPanelHeight: 15, endIndex: 21, // Disk divider in sidebar_a expectedFirstIndex: 12, explanation: "3(InitialHeight) + 9(12-20 pinned dirs) + 3(21-disk divider)", }, { name: "Very large panel height showing all items", sidebar: sidebarA, mainPanelHeight: 100, endIndex: 31, // Last disk dir in sidebar_a expectedFirstIndex: 0, explanation: "Large panel should show all directories from start", }, { name: "Asymetric sidebar with few directories", sidebar: sidebarB, mainPanelHeight: 12, endIndex: 4, // Last disk dir in sidebar_b expectedFirstIndex: 0, explanation: "Small sidebar should fit in panel height", }, { name: "No home directories case", sidebar: sidebarC, mainPanelHeight: 10, endIndex: 6, // Disk dir in sidebar_c expectedFirstIndex: 2, // Pinned divider explanation: "3(InitialHeight) + 4(2-5 pinned dirs) + 3(6-disk divider)", }, { name: "Only disk directories case", sidebar: sidebarD, mainPanelHeight: 8, endIndex: 4, // Last disk dir expectedFirstIndex: 2, // Disk divider explanation: "3(InitialHeight) + 3(2-4 disk dirs)", }, { name: "Empty sidebar case", sidebar: sidebarE, mainPanelHeight: 10, endIndex: 1, // Disk divider expectedFirstIndex: 0, // Pinned divider explanation: "Empty sidebar should show all dividers", }, { name: "End index at the start", sidebar: sidebarA, mainPanelHeight: 5, endIndex: 0, expectedFirstIndex: 0, explanation: "When end index is at start, first index should be the same", }, { name: "End index out of bounds", sidebar: sidebarA, mainPanelHeight: 20, endIndex: 32, // Out of bounds for sidebar_a expectedFirstIndex: 33, // endIndex + 1 explanation: "When end index is out of bounds, should return endIndex+1", }, { name: "Very small panel height", sidebar: sidebarA, mainPanelHeight: 2, // Too small to fit anything endIndex: 10, expectedFirstIndex: 11, explanation: "With panel height less than initialHeight, first index is invalid", }, { name: "Panel height exactly matches divider", sidebar: sidebarA, mainPanelHeight: 6, // Just enough for initialHeight + divider endIndex: 10, // Pinned divider expectedFirstIndex: 10, explanation: "When panel height only fits the divider, start index should be the same", }, { name: "Boundary case between directory types", sidebar: sidebarA, mainPanelHeight: 7, endIndex: 11, // First pinned dir expectedFirstIndex: 10, // Pinned divider explanation: "3(InitialHeight) + 3(10-pinned divider) + 1(11-pinned dir)", }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { tt.sidebar.SetHeight(tt.mainPanelHeight + common.BorderPadding) result := tt.sidebar.firstRenderedIndex(tt.endIndex) assert.Equal(t, tt.expectedFirstIndex, result, "firstRenderedIndex failed: %s", tt.explanation) }) } } func Test_updateRenderIndex(t *testing.T) { testCases := []struct { name string sidebar Model mainPanelHeight int initialRenderIndex int initialCursor int expectedRenderIndex int explanation string }{ { name: "Case I: Cursor moved above render range", sidebar: defaultTestModel(5, 10, 0, 10, 10, 10), mainPanelHeight: 15, expectedRenderIndex: 5, explanation: "When cursor moves above render range, renderIndex should be set to cursor", }, { name: "Case II: Cursor within render range", sidebar: defaultTestModel(8, 5, 0, 10, 10, 10), mainPanelHeight: 15, expectedRenderIndex: 5, // No change expected explanation: "When cursor is within render range, renderIndex should not change", }, { name: "Case III: Cursor moved below render range", sidebar: defaultTestModel(20, 0, 0, 10, 10, 10), mainPanelHeight: 10, expectedRenderIndex: 14, // Should adjust to make cursor visible // 3(Initial height) + 7(14-20 pinned dirs) explanation: "When cursor moves below render range, renderIndex should adjust to make cursor visible", }, { name: "Edge case: Small panel with cursor at end", sidebar: defaultTestModel(31, 0, 0, 10, 10, 10), mainPanelHeight: 5, expectedRenderIndex: 30, // Should show only the last couple items explanation: "With small panel and cursor at end, should adjust renderIndex to show cursor", }, { name: "Edge case: Large panel showing everything", sidebar: defaultTestModel(4, 2, 0, 1, 0, 5), mainPanelHeight: 50, // Large enough to show all directories expectedRenderIndex: 2, // No change needed as everything is visible explanation: "With large panel showing all items, renderIndex should remain unchanged", }, { name: "Edge case: Empty sidebar", sidebar: defaultTestModel(1, 0, 0, 0, 0, 0), mainPanelHeight: 10, expectedRenderIndex: 0, // No change needed for empty sidebar explanation: "With empty sidebar, renderIndex should remain at 0", }, { name: "Case I and III overlap: Cursor exactly at current renderIndex", sidebar: defaultTestModel(15, 15, 0, 10, 10, 10), mainPanelHeight: 10, expectedRenderIndex: 15, // No change needed, Case I takes precedence explanation: "When cursor is exactly at renderIndex, " + "Case I takes precedence and renderIndex remains unchanged", }, { name: "Boundary case: Cursor at edge of visible range", sidebar: defaultTestModel(9, 5, 0, 10, 10, 10), mainPanelHeight: 8, expectedRenderIndex: 5, // Still visible, no change needed explanation: "When cursor is at the edge of visible range, renderIndex should not change", }, { name: "Boundary case: Cursor just beyond visible range", sidebar: defaultTestModel(11, 5, 0, 10, 10, 10), mainPanelHeight: 10, expectedRenderIndex: 7, // Adjust to make cursor visible explanation: "When cursor is just beyond visible range, renderIndex should adjust", }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { // Create a copy of the sidebar to avoid modifying the original sidebar := tt.sidebar sidebar.SetHeight(tt.mainPanelHeight + common.BorderPadding) // Update render index sidebar.updateRenderIndex() // Check the result assert.Equal(t, tt.expectedRenderIndex, sidebar.renderIndex, "updateRenderIndex failed: %s", tt.explanation) }) } } func Test_listUp(t *testing.T) { testCases := []struct { name string sidebar Model mainPanelHeight int expectedCursor int expectedRenderIndex int explanation string }{ { name: "Basic cursor movement from middle position", sidebar: defaultTestModel(5, 5, 0, 10, 10, 10), mainPanelHeight: 15, expectedCursor: 4, // Should move up one position expectedRenderIndex: 4, // Render index should follow cursor explanation: "When cursor is in the middle, it should move up one position", }, { name: "Skip divider when moving up", sidebar: defaultTestModel(11, 8, 0, 10, 10, 10), mainPanelHeight: 10, expectedCursor: 9, // Should skip divider (10) and move to home dir (9) expectedRenderIndex: 8, explanation: "When moving up to a divider, cursor should skip it and move to previous item", }, { name: "Wrap around from top to bottom", sidebar: defaultTestModel(0, 0, 0, 10, 10, 10), mainPanelHeight: 10, expectedCursor: 31, // Should wrap to last directory (index 31) expectedRenderIndex: 25, // Should adjust render to show cursor // 3(Initial Height) + 7(25-31 disk dirs) explanation: "When at the top, cursor should wrap to the bottom", }, { name: "Skip multiple consecutive dividers", sidebar: defaultTestModel(7, 5, 0, 5, 0, 5), mainPanelHeight: 10, expectedCursor: 4, // Should skip all dividers and move to item before dividers expectedRenderIndex: 4, // Should adjust render index accordingly explanation: "When encountering multiple consecutive dividers, cursor should skip all of them", }, { name: "No actual directories case", sidebar: defaultTestModel(0, 0, 0, 0, 0, 0), mainPanelHeight: 10, expectedCursor: 0, // Should remain unchanged expectedRenderIndex: 0, // Should remain unchanged explanation: "When there are no actual directories, cursor should not move", }, { name: "Large panel showing all directories", sidebar: defaultTestModel(3, 0, 0, 2, 2, 2), mainPanelHeight: 50, // Large enough to show all directories expectedCursor: 1, // Should move up one position expectedRenderIndex: 0, // No change needed as everything is visible explanation: "With large panel showing all items, cursor should move up and renderIndex remain unchanged", }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { // Create a copy of the sidebar to avoid modifying the original sidebar := tt.sidebar sidebar.SetHeight(tt.mainPanelHeight + common.BorderPadding) // Call the function to test sidebar.ListUp() // Check the results assert.Equal(t, tt.expectedCursor, sidebar.cursor, "listUp cursor position: %s", tt.explanation) assert.Equal(t, tt.expectedRenderIndex, sidebar.renderIndex, "listUp render index: %s", tt.explanation) }) } } func Test_listDown(t *testing.T) { testCases := []struct { name string sidebar Model mainPanelHeight int expectedCursor int expectedRenderIndex int explanation string }{ { name: "Basic cursor movement from middle position", sidebar: defaultTestModel(5, 5, 0, 10, 10, 10), mainPanelHeight: 15, expectedCursor: 6, // Should move down one position expectedRenderIndex: 5, // Render index should remain the same as cursor is still visible explanation: "When cursor is in the middle, it should move down one position", }, { name: "Skip divider when moving down", sidebar: defaultTestModel(9, 8, 0, 10, 10, 10), mainPanelHeight: 10, expectedCursor: 11, // Should skip divider (10) and move to pinned dir (11) expectedRenderIndex: 8, // Should adjust render index to keep cursor visible explanation: "When moving down to a divider, cursor should skip it and move to next item", }, { name: "Wrap around from bottom to top", sidebar: defaultTestModel(31, 26, 0, 10, 10, 10), mainPanelHeight: 10, expectedCursor: 0, // Should wrap to first directory (index 0) expectedRenderIndex: 0, // Should adjust render to show cursor explanation: "When at the bottom, cursor should wrap to the top", }, { name: "Skip multiple consecutive dividers", sidebar: defaultTestModel(4, 0, 0, 5, 0, 5), mainPanelHeight: 10, expectedCursor: 7, // Should skip all dividers and move to item after dividers expectedRenderIndex: 5, // Should adjust render index accordingly // 3 (Initial Height) 6(5,6 - pinned and disk divider), 1 (7-Disk dir) explanation: "When encountering multiple consecutive dividers, cursor should skip all of them", }, { name: "No actual directories case", sidebar: defaultTestModel(0, 0, 0, 0, 0, 0), mainPanelHeight: 10, expectedCursor: 0, // Should remain unchanged expectedRenderIndex: 0, // Should remain unchanged explanation: "When there are no actual directories, cursor should not move", }, { name: "Move down from home to pinned section", sidebar: defaultTestModel(9, 6, 0, 10, 10, 10), mainPanelHeight: 10, expectedCursor: 11, // Should move to first pinned directory expectedRenderIndex: 7, // Should adjust render index to show cursor explanation: "When moving down from last home directory," + " cursor should skip divider and go to first pinned directory", }, { name: "Large panel showing all directories", sidebar: defaultTestModel(3, 0, 0, 2, 2, 2), mainPanelHeight: 50, // Large enough to show all directories expectedCursor: 4, // Should move down one position expectedRenderIndex: 0, // No change needed as everything is visible explanation: "With large panel showing all items, cursor should move down and renderIndex remain unchanged", }, { name: "Cursor at the end of visible range", sidebar: defaultTestModel(14, 5, 0, 10, 10, 10), mainPanelHeight: 15, expectedCursor: 15, // Should move down one position expectedRenderIndex: 6, // Should increase render index to keep cursor visible explanation: "When cursor is at the end of visible range, moving down should adjust renderIndex", }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { // Create a copy of the sidebar to avoid modifying the original sidebar := tt.sidebar sidebar.SetHeight(tt.mainPanelHeight + common.BorderPadding) // Call the function to test sidebar.ListDown() // Check the results assert.Equal(t, tt.expectedCursor, sidebar.cursor, "listDown cursor position: %s", tt.explanation) assert.Equal(t, tt.expectedRenderIndex, sidebar.renderIndex, "listDown render index: %s", tt.explanation) }) } } ================================================ FILE: src/internal/ui/sidebar/pinned.go ================================================ package sidebar import ( "encoding/json" "fmt" "log/slog" "os" "path/filepath" "github.com/yorukot/superfile/src/pkg/utils" ) type PinnedManager struct { filePath string } func NewPinnedFileManager(filePath string) PinnedManager { if err := utils.InitJSONFile(filePath); err != nil { slog.Error("Error initializing pinned JSON file", "error", err) } return PinnedManager{ filePath: filePath, } } // Load reads the pinned directories from file and cleans non-existing ones func (mgr *PinnedManager) Load() []directory { directories := []directory{} jsonData, err := os.ReadFile(mgr.filePath) if err != nil { slog.Error("Error reading pinned directories file", "error", err) return directories } // Check for the old format has been dropped in this manager if err := json.Unmarshal(jsonData, &directories); err != nil { slog.Error("Error parsing pinned directories data", "error", err) return directories } // Clean non-existing directories cleanedDirs := mgr.Clean(directories) return cleanedDirs } // Save marshals and writes the pinned directories to file. func (mgr *PinnedManager) Save(dirs []directory) error { data, err := json.Marshal(dirs) if err != nil { return fmt.Errorf("error marshaling pinned directories: %w", err) } if err := os.WriteFile(mgr.filePath, data, utils.ConfigFilePerm); err != nil { return fmt.Errorf("error writing pinned directories file: %w", err) } return nil } // Toggle adds or removes a directory from the pinned directories list func (mgr *PinnedManager) Toggle(dir string) error { dirs := mgr.Load() unPinned := false for i, other := range dirs { if other.Location == dir { dirs = append(dirs[:i], dirs[i+1:]...) unPinned = true break } } if !unPinned { dirs = append(dirs, directory{ Location: dir, Name: filepath.Base(dir), }) } if err := mgr.Save(dirs); err != nil { return fmt.Errorf("error saving pinned directories: %w", err) } return nil } // Clean removes non-existing directories and optionally saves the updated list func (mgr *PinnedManager) Clean(dirs []directory) []directory { cleanedDirs := make([]directory, 0, len(dirs)) for _, dir := range dirs { if _, err := os.Stat(dir.Location); err == nil { cleanedDirs = append(cleanedDirs, dir) } else if !os.IsNotExist(err) { slog.Warn("error while checking pinned directory", "directory", dir.Location, "error", err) } } if len(cleanedDirs) == len(dirs) { return cleanedDirs } if err := mgr.Save(cleanedDirs); err != nil { slog.Error("error saving pinned directories", "error", err) } return cleanedDirs } ================================================ FILE: src/internal/ui/sidebar/pinned_test.go ================================================ package sidebar import ( "encoding/json" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" ) func Test_Load(t *testing.T) { tempDir := t.TempDir() pDirName := "pinnedDir" pinnedDir := filepath.Join(tempDir, pDirName) utils.SetupDirectories(t, pinnedDir) emptyBytes, err := json.Marshal([]directory{}) require.NoError(t, err) emptyPath := filepath.Join(pinnedDir, "empty.json") utils.SetupFilesWithData(t, emptyBytes, emptyPath) invalidPath := filepath.Join(pinnedDir, "invalid.json") utils.SetupFilesWithData(t, []byte("{ invalid json }"), invalidPath) validData := []directory{ { Location: pinnedDir, Name: pDirName, }, } validBytes, err := json.Marshal(validData) require.NoError(t, err) validPath := filepath.Join(pinnedDir, "valid.json") utils.SetupFilesWithData(t, validBytes, validPath) nonexistData := []directory{ { Location: pinnedDir, Name: pDirName, }, { Location: filepath.Join(pinnedDir, "nonexistent9"), Name: "nonexistent9", }, } nonexistBytes, err := json.Marshal(nonexistData) require.NoError(t, err) nonexistentPath := filepath.Join(pinnedDir, "nonexistent.json") utils.SetupFilesWithData(t, nonexistBytes, nonexistentPath) cleanDirs := []directory{ { Location: pinnedDir, Name: pDirName, }, } testCases := []struct { name string pinnedMgr PinnedManager expected []directory }{ { name: "Empty No Pinned Directories", pinnedMgr: PinnedManager{filePath: emptyPath}, expected: []directory{}, }, { name: "Invalid Format File", pinnedMgr: PinnedManager{filePath: invalidPath}, expected: []directory{}, }, { name: "Valid With No Non-Existent Directories", pinnedMgr: PinnedManager{filePath: validPath}, expected: cleanDirs, }, { name: "Valid With One Non-Existent Directory", pinnedMgr: PinnedManager{filePath: nonexistentPath}, expected: cleanDirs, }, { name: "Invalid filePath", pinnedMgr: PinnedManager{filePath: filepath.Join(pinnedDir, "pinned_not_exists.json")}, expected: []directory{}, }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expected, tt.pinnedMgr.Load()) }) } } func Test_Save(t *testing.T) { tempDir := t.TempDir() pDirName := "pinnedDir" pinnedDir := filepath.Join(tempDir, pDirName) utils.SetupDirectories(t, pinnedDir) savePath := filepath.Join(pinnedDir, "pinned.json") dirs := []directory{ { Location: pinnedDir, Name: pDirName, }, } testCases := []struct { name string pinnedMgr PinnedManager noError bool expected []directory argDirs []directory }{ { name: "Valid Normal Case", pinnedMgr: PinnedManager{filePath: savePath}, noError: true, expected: dirs, argDirs: dirs, }, { name: "Empty Slice", pinnedMgr: PinnedManager{filePath: savePath}, noError: true, expected: []directory{}, argDirs: []directory{}, }, { name: "Write Failure", pinnedMgr: PinnedManager{filePath: pinnedDir}, noError: false, expected: nil, argDirs: dirs, }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { err := tt.pinnedMgr.Save(tt.argDirs) if tt.noError { require.NoError(t, err) assert.Equal(t, tt.expected, tt.pinnedMgr.Load()) } else { require.Error(t, err) } }) } } func Test_Toggle(t *testing.T) { tempDir := t.TempDir() pDirName := "pinnedDir" pinnedDir := filepath.Join(tempDir, pDirName) utils.SetupDirectories(t, pinnedDir) pinnedFile := filepath.Join(pinnedDir, "pinned.json") testCases := []struct { name string pinnedMgr PinnedManager expected []directory noError bool argDir string }{ { name: "Add Non-Existing Directory to Pinned", pinnedMgr: PinnedManager{filePath: pinnedFile}, expected: []directory{}, noError: true, argDir: filepath.Join(tempDir, "nonExistentDir"), }, { name: "Add a Directory to Pinned", pinnedMgr: PinnedManager{filePath: pinnedFile}, expected: []directory{ { Location: pinnedDir, Name: pDirName, }, }, noError: true, argDir: pinnedDir, }, { name: "Remove a Directory from Pinned", pinnedMgr: PinnedManager{filePath: pinnedFile}, expected: []directory{}, noError: true, argDir: pinnedDir, }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { err := tt.pinnedMgr.Toggle(tt.argDir) if tt.noError { require.NoError(t, err) assert.Equal(t, tt.expected, tt.pinnedMgr.Load()) } else { require.Error(t, err) } }) } } func Test_Clean(t *testing.T) { tempDir := t.TempDir() pDirName := "pinnedDir" pinnedDir := filepath.Join(tempDir, pDirName) utils.SetupDirectories(t, pinnedDir) pinnedFile := filepath.Join(pinnedDir, "pinned.json") cleanDirs := []directory{ { Location: pinnedDir, Name: pDirName, }, } badDirs := append([]directory{}, cleanDirs...) badDirs = append(badDirs, directory{ Location: filepath.Join(tempDir, "nonexistentDir"), Name: "nonexistentDir", }) testCases := []struct { name string pinnedMgr PinnedManager modified bool expected []directory argDirs []directory }{ { name: "All Directories Exist", pinnedMgr: PinnedManager{filePath: pinnedFile}, modified: false, expected: cleanDirs, argDirs: cleanDirs, }, { name: "Some Directories Exist", pinnedMgr: PinnedManager{filePath: pinnedFile}, modified: true, expected: cleanDirs, argDirs: badDirs, }, { name: "Save Fails", pinnedMgr: PinnedManager{filePath: pinnedDir}, modified: false, expected: cleanDirs, argDirs: badDirs, }, { name: "Empty Input Slice", pinnedMgr: PinnedManager{filePath: pinnedFile}, modified: false, expected: []directory{}, argDirs: []directory{}, }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { subjectPath := tt.pinnedMgr.filePath if subjectPath == pinnedFile { _ = tt.pinnedMgr.Save(tt.argDirs) } beforeInfo, beforeErr := os.Stat(subjectPath) cleaned := tt.pinnedMgr.Clean(tt.argDirs) afterInfo, afterErr := os.Stat(subjectPath) if beforeErr == nil && afterErr == nil && !tt.modified { require.Equal(t, beforeInfo.ModTime(), afterInfo.ModTime()) } assert.Equal(t, tt.expected, cleaned) }) } } ================================================ FILE: src/internal/ui/sidebar/render.go ================================================ package sidebar import ( "log/slog" "github.com/yorukot/superfile/src/internal/ui" "github.com/yorukot/superfile/src/config/icon" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui/rendering" ) // Render returns the rendered sidebar string. func (s *Model) Render(sidebarFocused bool, currentFilePanelLocation string) string { if s.Disabled() { return "" } r := ui.SidebarRenderer(s.height, s.width, sidebarFocused) r.AddLines(common.SideBarSuperfileTitle, "") if s.searchBar.Focused() || s.searchBar.Value() != "" || sidebarFocused { r.AddLines(s.searchBar.View()) } if s.NoActualDir() { r.AddLines(common.SideBarNoneText) } else { s.directoriesRender(currentFilePanelLocation, sidebarFocused, r) } return r.Render() } // directoriesRender handles the iterative rendering of directories within the sidebar model. func (s *Model) directoriesRender(curFilePanelFileLocation string, sideBarFocused bool, r *rendering.Renderer) { // Cursor should always point to a valid directory at this point if s.isCursorInvalid() { slog.Error("Unexpected situation in sideBar Model. "+ "Cursor is at invalid position, while there are valid directories", "cursor", s.cursor, "directory count", len(s.directories)) } // TODO : This is not true when searchbar is not rendered(totalHeight is 2, not 3), // so we end up underutilizing one line for our render. But it wont break anything. totalHeight := sideBarInitialHeight mainPanelHeight := s.height - common.BorderPadding for i := s.renderIndex; i < len(s.directories); i++ { if totalHeight+s.directories[i].requiredHeight() > mainPanelHeight { break } totalHeight += s.directories[i].requiredHeight() switch s.directories[i] { case homeDividerDir: r.AddLines("", common.SideBarHomeDivider, "") case pinnedDividerDir: r.AddLines("", common.SideBarPinnedDivider, "") case diskDividerDir: r.AddLines("", common.SideBarDisksDivider, "") default: cursor := " " if s.cursor == i && sideBarFocused && !s.searchBar.Focused() { cursor = icon.Cursor } if s.renaming && s.cursor == i { r.AddLines(s.rename.View()) } else { renderStyle := common.SidebarStyle if s.directories[i].Location == curFilePanelFileLocation { renderStyle = common.SidebarSelectedStyle } line := common.FilePanelCursorStyle.Render(cursor+" ") + renderStyle.Render(s.directories[i].Name) r.AddLineWithCustomTruncate(line, rendering.TailsTruncateRight) } } } } ================================================ FILE: src/internal/ui/sidebar/sidebar.go ================================================ package sidebar import ( "log/slog" "slices" tea "github.com/charmbracelet/bubbletea" variable "github.com/yorukot/superfile/src/config" "github.com/yorukot/superfile/src/internal/common" ) // PinnedItemRename initiates the rename process for the currently selected pinned directory. func (s *Model) PinnedItemRename() { pinnedBegin, pinnedEnd := s.pinnedIndexRange() // We have not selected a pinned directory, rename is not allowed if s.cursor < pinnedBegin || s.cursor > pinnedEnd { return } nameLen := len(s.directories[s.cursor].Name) cursorPos := nameLen s.renaming = true s.rename = common.GeneratePinnedRenameTextInput(cursorPos, s.directories[s.cursor].Name) } // CancelSidebarRename aborts the rename process for a pinned directory. func (s *Model) CancelSidebarRename() { s.rename.Blur() s.renaming = false } // ConfirmSidebarRename finalizes the rename process and saves changes to the pinned directories file. func (s *Model) ConfirmSidebarRename() { itemLocation := s.directories[s.cursor].Location newItemName := s.rename.Value() // This is needed to update the current pinned directory data loaded into memory s.directories[s.cursor].Name = newItemName // recover the state of rename s.CancelSidebarRename() pinnedDirs := s.pinnedMgr.Load() // Call getPinnedDirectories, instead of using what is stored in sidebar.directories // sidebar.directories could have less directories in case a search filter is used for i := range pinnedDirs { // Considering the situation when many if pinnedDirs[i].Location == itemLocation { pinnedDirs[i].Name = newItemName } } if err := s.pinnedMgr.Save(pinnedDirs); err != nil { slog.Error("error saving pinned directories", "error", err) } } // UpdateState handles the sidebar's state updates in response to Bubble Tea messages. func (s *Model) UpdateState(msg tea.Msg) tea.Cmd { var cmd tea.Cmd if s.renaming { s.rename, cmd = s.rename.Update(msg) } else if s.searchBar.Focused() { s.searchBar, cmd = s.searchBar.Update(msg) } if s.cursor < 0 { s.cursor = 0 } return cmd } // HandleSearchBarKey processes key events specifically for the sidebar's search bar. func (s *Model) HandleSearchBarKey(msg string) { switch { case slices.Contains(common.Hotkeys.CancelTyping, msg): s.SearchBarBlur() s.searchBar.SetValue("") case slices.Contains(common.Hotkeys.ConfirmTyping, msg): s.SearchBarBlur() s.resetCursor() } } // UpdateDirectories refreshes the list of directories based on the search query or section configuration. func (s *Model) UpdateDirectories() { if s.Disabled() { return } if s.searchBar.Value() != "" { s.directories = getFilteredDirectories(s.searchBar.Value(), s.pinnedMgr, s.sections) } else { s.directories = getDirectories(s.pinnedMgr, s.sections) } // This is needed, as due to filtering, the cursor might be invalid if s.isCursorInvalid() { s.resetCursor() } } // TogglePinnedDirectory adds or removes a directory from the pinned list. func (s *Model) TogglePinnedDirectory(dir string) error { return s.pinnedMgr.Toggle(dir) } // New initializes and returns a new Model for the sidebar correctly set up with configuration. func New() Model { if common.Config.SidebarWidth == 0 { return Model{ disabled: true, } } // pinnedMgr is created here, can be done higher up in the call chain pinnedMgr := NewPinnedFileManager(variable.PinnedFile) s := Model{ renderIndex: 0, searchBar: common.GenerateSearchBar(), pinnedMgr: &pinnedMgr, width: common.Config.SidebarWidth + common.BorderPadding, height: minHeight, disabled: false, sections: common.Config.SidebarSections, } s.directories = getDirectories(&pinnedMgr, s.sections) s.searchBar.Width = s.width - common.BorderPadding - searchBarPadding s.searchBar.Placeholder = "(" + common.Hotkeys.SearchBar[0] + ")" + " Search" return s } ================================================ FILE: src/internal/ui/sidebar/type.go ================================================ package sidebar import "github.com/charmbracelet/bubbles/textinput" type directory struct { Location string `json:"location"` Name string `json:"name"` } type Model struct { directories []directory renderIndex int cursor int rename textinput.Model renaming bool searchBar textinput.Model pinnedMgr *PinnedManager width int height int disabled bool sections []string } ================================================ FILE: src/internal/ui/sidebar/utils.go ================================================ package sidebar import "log/slog" // isDivider returns true if the directory is one of the section dividers. func (d directory) isDivider() bool { return d == homeDividerDir || d == pinnedDividerDir || d == diskDividerDir } // requiredHeight returns the number of terminal lines required to render this item. func (d directory) requiredHeight() int { if d.isDivider() { return dividerDirHeight } return 1 } // NoActualDir returns true if the sidebar contains only dividers and no actual directories. func (s *Model) NoActualDir() bool { for _, d := range s.directories { if !d.isDivider() { return false } } return true } // isCursorInvalid returns true if the current cursor position is out of bounds or points to a divider. func (s *Model) isCursorInvalid() bool { return s.cursor < 0 || s.cursor >= len(s.directories) || s.directories[s.cursor].isDivider() } // resetCursor moves the cursor to the first selectable directory in the sidebar. func (s *Model) resetCursor() { s.cursor = 0 // Move to first non Divider dir for i, d := range s.directories { if !d.isDivider() { s.cursor = i return } } // If all directories are divider, code will reach here. and s.cursor will stay 0 // Or s.directories is empty } // SearchBarFocused returns whether the search bar is focused func (s *Model) SearchBarFocused() bool { return s.searchBar.Focused() } // SearchBarBlur removes focus from the search bar func (s *Model) SearchBarBlur() { s.searchBar.Blur() } // SearchBarFocus sets focus on the search bar func (s *Model) SearchBarFocus() { s.searchBar.Focus() } // IsRenaming returns whether the sidebar is currently in renaming mode func (s *Model) IsRenaming() bool { return s.renaming } // GetCurrentDirectoryLocation returns the location of the currently selected directory func (s *Model) GetCurrentDirectoryLocation() string { if s.isCursorInvalid() || s.NoActualDir() { return "" } return s.directories[s.cursor].Location } // pinnedIndexRange calculates the start and end indices of the pinned directories section. // Returns (-1, -1) if the section is missing or empty. func (s *Model) pinnedIndexRange() (int, int) { // pinned directories start after well-known directories and the divider // Can't use getPinnedDirectories() here, as if we are in search mode, we would be showing // and having less directories in sideBar.directories slice // TODO : This is inefficient to iterate each time for this. // This information can be kept precomputed pinnedDividerIdx := -1 for i, d := range s.directories { if d == pinnedDividerDir { pinnedDividerIdx = i break } } if pinnedDividerIdx == -1 { return -1, -1 } pinnedEndIdx := len(s.directories) - 1 for i := pinnedDividerIdx + 1; i < len(s.directories); i++ { if s.directories[i].isDivider() { pinnedEndIdx = i - 1 break } } if pinnedDividerIdx+1 > pinnedEndIdx { return -1, -1 } return pinnedDividerIdx + 1, pinnedEndIdx } // GetWidth returns the current width of the sidebar. func (m *Model) GetWidth() int { return m.width } // GetHeight returns the current height of the sidebar. func (m *Model) GetHeight() int { return m.height } // SetHeight updates the height of the sidebar, ensuring it meets the minimum requirement. func (m *Model) SetHeight(height int) { if height < minHeight { slog.Error("Attempted to set too low height to sidebar", "height", height) return } m.height = height } // Disabled returns true if the sidebar is currently disabled. func (m *Model) Disabled() bool { return m.disabled } ================================================ FILE: src/internal/ui/sidebar/utils_test.go ================================================ package sidebar import ( "strconv" "testing" "github.com/stretchr/testify/assert" "github.com/yorukot/superfile/src/pkg/utils" ) func defaultTestModel(cursor int, renderIndex int, height int, cntHome int, cntPinned int, cntDisk int) Model { return testModel(cursor, renderIndex, height, defaultSectionSlice, formDirctorySlice(dirSlice(cntHome), dirSlice(cntPinned), dirSlice(cntDisk), defaultSectionSlice)) } func testModel(cursor int, renderIndex int, height int, sections []string, directories []directory) Model { return Model{ directories: directories, cursor: cursor, renderIndex: renderIndex, height: height, sections: sections, } } func dirSlice(count int) []directory { res := make([]directory, count) for i := range count { res[i] = directory{Name: "Dir" + strconv.Itoa(i), Location: "/a/" + strconv.Itoa(i)} } return res } func Test_noActualDir(t *testing.T) { testcases := []struct { name string sidebar Model expected bool }{ { "Empty invalid sidebar should have no actual directories", Model{}, true, }, { "Empty sidebar should have no actual directories", defaultTestModel(0, 0, 10, 0, 0, 0), true, }, { "Non-Empty Sidebar with only pinned directories", defaultTestModel(0, 0, 10, 0, 10, 0), false, }, { "Non-Empty Sidebar with all directories", defaultTestModel(0, 0, 10, 10, 10, 10), false, }, } for _, tt := range testcases { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expected, tt.sidebar.NoActualDir()) }) } } func Test_isCursorInvalid(t *testing.T) { testcases := []struct { name string sidebar Model expected bool }{ { "Empty invalid sidebar", Model{}, true, }, { "Cursor after all directories", defaultTestModel(32, 0, 10, 10, 10, 10), true, }, { "Curson points to pinned divider", defaultTestModel(10, 0, 10, 10, 10, 10), true, }, { "Non-Empty Sidebar with all directories", defaultTestModel(5, 0, 10, 10, 10, 10), false, }, } for _, tt := range testcases { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expected, tt.sidebar.isCursorInvalid()) }) } } func Test_resetCursor(t *testing.T) { data := []struct { name string curSideBar Model expectedCursorPos int }{ { name: "Only Pinned directories", curSideBar: defaultTestModel(0, 0, 10, 0, 10, 0), expectedCursorPos: 1, // After pinned divider }, { name: "All kind of directories", curSideBar: defaultTestModel(0, 0, 10, 10, 10, 10), expectedCursorPos: 0, // First home }, { name: "Only Disk", curSideBar: defaultTestModel(0, 0, 10, 0, 0, 10), expectedCursorPos: 2, // After pinned and dist divider }, { name: "Empty Sidebar", curSideBar: defaultTestModel(0, 0, 10, 0, 0, 0), expectedCursorPos: 0, // Empty sidebar, cursor should reset to 0 }, } for _, tt := range data { t.Run(tt.name, func(t *testing.T) { tt.curSideBar.resetCursor() assert.Equal(t, tt.expectedCursorPos, tt.curSideBar.cursor) }) } } func TestSidebarSectionsVisibility(t *testing.T) { testcases := []struct { name string sections []string homeDirs int pinnedDirs int diskDirs int expectedLen int expectHomeDiv bool }{ { name: "Only one section (pinned)", sections: []string{utils.SidebarSectionPinned}, pinnedDirs: 5, expectedLen: 6, // divider + 5 dirs }, { name: "No sections", sections: []string{}, expectedLen: 0, }, { name: "Reordered sections (pinned, home)", sections: []string{utils.SidebarSectionPinned, utils.SidebarSectionHome}, homeDirs: 3, pinnedDirs: 3, expectedLen: 1 + 3 + 1 + 3, // pinned divider + 3 pinned + home divider + 3 home expectHomeDiv: true, }, } for _, tt := range testcases { t.Run(tt.name, func(t *testing.T) { dirs := formDirctorySlice( dirSlice(tt.homeDirs), dirSlice(tt.pinnedDirs), dirSlice(tt.diskDirs), tt.sections, ) assert.Len(t, dirs, tt.expectedLen) if tt.expectHomeDiv { foundHomeDiv := false for _, d := range dirs { if d == homeDividerDir { foundHomeDiv = true break } } assert.True(t, foundHomeDiv, "Expected home divider to be present") } }) } } ================================================ FILE: src/internal/ui/sortmodel/const.go ================================================ package sortmodel const ( sortOptionsDefaultWidth = 20 sortOptionsDefaultHeight = 4 SortTypeCount = 4 ) ================================================ FILE: src/internal/ui/sortmodel/model.go ================================================ package sortmodel func New() Model { return Model{ Height: sortOptionsDefaultHeight, Width: sortOptionsDefaultWidth, Cursor: 0, open: false, } } ================================================ FILE: src/internal/ui/sortmodel/navigation.go ================================================ package sortmodel func (m *Model) ListUp() { m.Cursor = (m.Cursor - 1 + SortTypeCount) % SortTypeCount } func (m *Model) ListDown() { m.Cursor = (m.Cursor + 1 + SortTypeCount) % SortTypeCount } ================================================ FILE: src/internal/ui/sortmodel/render.go ================================================ package sortmodel import ( "fmt" "strconv" "strings" "github.com/yorukot/superfile/src/config/icon" "github.com/yorukot/superfile/src/internal/common" ) func (m *Model) Render() string { var sortOptionsContent strings.Builder sortOptionsContent.WriteString(common.ModalTitleStyle.Render(" Sort Options") + "\n\n") for i, option := range SortOptionsStr { cursor := " " if i == m.Cursor { cursor = common.FilePanelCursorStyle.Render(icon.Cursor) } sortOptionsContent.WriteString(cursor + common.ModalStyle.Render(" "+option) + "\n") } bottomBorder := common.GenerateFooterBorder( fmt.Sprintf("%s/%s", strconv.Itoa(m.Cursor+1), strconv.Itoa(len(SortOptionsStr))), m.Width-common.BorderPadding) return common.SortOptionsModalBorderStyle(m.Height, m.Width, bottomBorder).Render(sortOptionsContent.String()) } ================================================ FILE: src/internal/ui/sortmodel/types.go ================================================ package sortmodel type SortKind int // NOTE: Update the validation of DefaultSortType config if you make changes here const ( SortByName SortKind = iota SortBySize SortByDate SortByType SortByNatural ) var SortOptionsStr = []string{ //nolint: gochecknoglobals // Effectively const "Name", "Size", "Date Modified", "Type", "Natural", } var SortOptionsShortStr = []string{ //nolint: gochecknoglobals // Effectively const "Name", "Size", "Date", "Type", "Natural", } // Sort options type Model struct { Width int Height int open bool // Cursor has meaning only during open state, its lost on close Cursor int } ================================================ FILE: src/internal/ui/sortmodel/utils.go ================================================ package sortmodel func (m *Model) IsOpen() bool { return m.open } func (m *Model) Open(curSortKind SortKind) { m.Cursor = int(curSortKind) m.open = true } func (m *Model) Close() { m.open = false m.Cursor = 0 } func (m *Model) GetSelectedKind() SortKind { return SortKind(m.Cursor) } ================================================ FILE: src/internal/ui/spf_renderers.go ================================================ package ui import ( "github.com/charmbracelet/lipgloss" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui/rendering" ) func SidebarRenderer(totalHeight int, totalWidth int, sidebarFocused bool) *rendering.Renderer { cfg := rendering.DefaultRendererConfig(totalHeight, totalWidth) cfg.ContentFGColor = common.SidebarFGColor cfg.ContentBGColor = common.SidebarBGColor cfg.BorderRequired = true cfg.BorderBGColor = common.SidebarBGColor cfg.BorderFGColor = common.SidebarBorderColor if sidebarFocused { cfg.BorderFGColor = common.SidebarBorderActiveColor } cfg.Border = DefaultLipglossBorder() cfg.RendererName += "-sidebar" r := rendering.NewRendererWithAutoFixConfig(cfg) return r } func FilePanelRenderer(totalHeight int, totalWidth int, filePanelFocused bool) *rendering.Renderer { cfg := rendering.DefaultRendererConfig(totalHeight, totalWidth) cfg.ContentFGColor = common.FilePanelFGColor cfg.ContentBGColor = common.FilePanelBGColor cfg.BorderRequired = true cfg.BorderBGColor = common.FilePanelBGColor cfg.BorderFGColor = common.FilePanelBorderColor if filePanelFocused { cfg.BorderFGColor = common.FilePanelBorderActiveColor } cfg.Border = DefaultLipglossBorder() cfg.RendererName += "-filepanel" r := rendering.NewRendererWithAutoFixConfig(cfg) return r } func FilePreviewPanelRenderer(totalHeight int, totalWidth int) *rendering.Renderer { cfg := rendering.DefaultRendererConfig(totalHeight, totalWidth) cfg.ContentFGColor = common.FilePanelFGColor cfg.ContentBGColor = common.FilePanelBGColor if common.Config.EnableFilePreviewBorder { cfg.BorderRequired = true cfg.BorderBGColor = common.FilePanelBGColor cfg.BorderFGColor = common.FilePanelBorderColor cfg.Border = DefaultLipglossBorder() } cfg.RendererName += "-preview" r := rendering.NewRendererWithAutoFixConfig(cfg) return r } func PromptRenderer(totalHeight int, totalWidth int) *rendering.Renderer { cfg := rendering.DefaultRendererConfig(totalHeight, totalWidth) cfg.TruncateHeight = true cfg.ContentFGColor = common.ModalFGColor cfg.ContentBGColor = common.ModalBGColor cfg.BorderRequired = true cfg.BorderBGColor = common.ModalBGColor cfg.BorderFGColor = common.ModalBorderActiveColor cfg.Border = DefaultLipglossBorder() r := rendering.NewRendererWithAutoFixConfig(cfg) return r } func ZoxideRenderer(totalHeight int, totalWidth int) *rendering.Renderer { return PromptRenderer(totalHeight, totalWidth) } func HelpMenuRenderer(totalHeight int, totalWidth int) *rendering.Renderer { cfg := rendering.DefaultRendererConfig(totalHeight, totalWidth) cfg.ContentFGColor = common.ModalFGColor cfg.ContentBGColor = common.ModalBGColor cfg.BorderRequired = true cfg.BorderBGColor = common.ModalBGColor cfg.BorderFGColor = common.ModalBorderActiveColor cfg.Border = DefaultLipglossBorder() r := rendering.NewRendererWithAutoFixConfig(cfg) return r } func DefaultFooterRenderer(totalHeight int, totalWidth int, focused bool, name string) *rendering.Renderer { cfg := rendering.DefaultRendererConfig(totalHeight, totalWidth) cfg.ContentFGColor = common.FooterFGColor cfg.ContentBGColor = common.FooterBGColor cfg.BorderRequired = true cfg.BorderBGColor = common.FooterBGColor cfg.BorderFGColor = common.FooterBorderColor if focused { cfg.BorderFGColor = common.FooterBorderActiveColor } cfg.Border = DefaultLipglossBorder() cfg.RendererName = name r := rendering.NewRendererWithAutoFixConfig(cfg) r.SetBorderTitle(name) return r } func ProcessBarRenderer(totalHeight int, totalWidth int, processBarFocused bool) *rendering.Renderer { return DefaultFooterRenderer(totalHeight, totalWidth, processBarFocused, "Processes") } func MetadataRenderer(totalHeight int, totalWidth int, metadataFocused bool) *rendering.Renderer { return DefaultFooterRenderer(totalHeight, totalWidth, metadataFocused, "Metadata") } func ClipboardRenderer(totalHeight int, totalWidth int) *rendering.Renderer { return DefaultFooterRenderer(totalHeight, totalWidth, false, "Clipboard") } func DefaultLipglossBorder() lipgloss.Border { return lipgloss.Border{ Top: common.Config.BorderTop, Bottom: common.Config.BorderBottom, Left: common.Config.BorderLeft, Right: common.Config.BorderRight, TopLeft: common.Config.BorderTopLeft, TopRight: common.Config.BorderTopRight, BottomLeft: common.Config.BorderBottomLeft, BottomRight: common.Config.BorderBottomRight, MiddleLeft: common.Config.BorderMiddleLeft, MiddleRight: common.Config.BorderMiddleRight, } } ================================================ FILE: src/internal/ui/zoxide/README.md ================================================ # zoxide package This is for the Zoxide navigation modal of superfile Handles user input for zoxide queries, integrates with the go-zoxide library, and returns navigation actions to the model. ## Features - Interactive zoxide directory search and navigation - Real-time suggestions with scores from zoxide database - Keyboard navigation with standard superfile hotkeys - Integration with existing file panel navigation system ## Usage The zoxide modal is opened by pressing the `z` hotkey and allows users to: 1. Type directory names to search zoxide's database 2. See top 5 matching directories with relevance scores 3. Navigate to selected directory in the current file panel 4. Close the modal with Escape or successful navigation ## Architecture - `Model`: Main zoxide modal state and behavior - `HandleUpdate()`: Processes keyboard input and zoxide queries - `Render()`: Displays search interface and suggestions - Integration with `*zoxidelib.Client` for zoxide database queries ## Coverage Current test coverage: **0%** No tests have been implemented yet for this package. The package is functional and integrated, but lacks unit test coverage. ```bash cd /path/to/ui/zoxide # Basic coverage (when tests exist) go test -cover # HTML report (when tests exist) go test -coverprofile=coverage.out && go tool cover -html=coverage.out -o coverage.html ``` ================================================ FILE: src/internal/ui/zoxide/consts.go ================================================ package zoxide const ( zoxideHeadlineText = "Zoxide Navigation" ZoxideMinWidth = 15 ZoxideMinHeight = 3 maxVisibleResults = 5 // Maximum number of results visible at once // UI dimension constants for zoxide modal // scoreColumnWidth is width reserved for score display (including padding and separator) scoreColumnWidth = 13 // borders(2) + padding(2) + score(6) + separator(3) // modalInputPadding is total padding for modal input fields modalInputPadding = 6 // 2 + 1 + 2 + 1 (borders and spacing) ) ================================================ FILE: src/internal/ui/zoxide/model.go ================================================ package zoxide import ( "log/slog" "reflect" "slices" "strings" tea "github.com/charmbracelet/bubbletea" zoxidelib "github.com/lazysegtree/go-zoxide" "github.com/yorukot/superfile/src/config/icon" "github.com/yorukot/superfile/src/internal/common" ) func DefaultModel(maxHeight int, width int, zClient *zoxidelib.Client) Model { return GenerateModel(zClient, maxHeight, width) } func GenerateModel(zClient *zoxidelib.Client, maxHeight int, width int) Model { m := Model{ headline: icon.Search + icon.Space + zoxideHeadlineText, open: false, textInput: common.GeneratePromptTextInput(), zClient: zClient, results: []zoxidelib.Result{}, } m.SetMaxHeight(maxHeight) m.SetWidth(width) m.textInput.Prompt = "" return m } func (m *Model) HandleUpdate(msg tea.Msg) (common.ModelAction, tea.Cmd) { slog.Debug("zoxide.Model HandleUpdate()", "msg", msg, "msgType", reflect.TypeOf(msg), "textInput", m.textInput.Value(), "cursorBlink", m.textInput.Cursor.Blink) var action common.ModelAction action = common.NoAction{} var cmd tea.Cmd if !m.IsOpen() { slog.Error("HandleUpdate called on closed zoxide") return action, cmd } switch msg := msg.(type) { case tea.KeyMsg: // If zoxide is not available, only allow confirm/cancel to close modal if m.zClient == nil { switch { case slices.Contains(common.Hotkeys.ConfirmTyping, msg.String()), slices.Contains(common.Hotkeys.CancelTyping, msg.String()), slices.Contains(common.Hotkeys.Quit, msg.String()): m.Close() } return action, cmd } switch { case slices.Contains(common.Hotkeys.ConfirmTyping, msg.String()): action = m.handleConfirm() m.Close() case slices.Contains(common.Hotkeys.CancelTyping, msg.String()): m.Close() // We dont want keys like `j` and `k` to get stuck here // So if its a navigation key, lets specifically ignore // the alphanumeric keys as zoxide panel is in text input // mode by default case slices.Contains(common.Hotkeys.ListUp, msg.String()) && !isKeyAlphaNum(msg): m.navigateUp() case slices.Contains(common.Hotkeys.ListDown, msg.String()) && !isKeyAlphaNum(msg): m.navigateDown() case slices.Contains(common.Hotkeys.OpenZoxide, msg.String()) && m.justOpened: // Ignore the 'z' key that just opened this modal to prevent it from appearing in text input m.justOpened = false default: cmd = m.handleNormalKeyInput(msg) } default: // Non keypress updates like Cursor Blink // Only update text input if zoxide is available if m.zClient != nil { m.textInput, cmd = m.textInput.Update(msg) } } return action, cmd } func (m *Model) handleConfirm() common.ModelAction { // If we have results and a valid selection, navigate to selected result if len(m.results) > 0 && m.cursor >= 0 && m.cursor < len(m.results) { selectedResult := m.results[m.cursor] return common.CDCurrentPanelAction{ Location: selectedResult.Path, } } // No results or invalid selection - close modal return common.NoAction{} } func (m *Model) handleNormalKeyInput(msg tea.KeyMsg) tea.Cmd { var cmd tea.Cmd m.textInput, cmd = m.textInput.Update(msg) return tea.Batch(cmd, m.GetQueryCmd(m.textInput.Value())) } func (m *Model) GetQueryCmd(query string) tea.Cmd { if m.zClient == nil || !common.Config.ZoxideSupport { return nil } reqID := m.reqCnt m.reqCnt++ slog.Debug("Submitting zoxide query request", "query", query, "id", reqID) return func() tea.Msg { queryFields := strings.Fields(query) results, err := m.zClient.QueryAll(queryFields...) if err != nil { slog.Debug("Zoxide query failed", "query", query, "error", err, "id", reqID) return NewUpdateMsg(query, []zoxidelib.Result{}, reqID) } return NewUpdateMsg(query, results, reqID) } } // Apply updates the zoxide modal with query results func (msg UpdateMsg) Apply(m *Model) tea.Cmd { // Ignore stale results - only apply if query matches current input currentQuery := m.textInput.Value() if msg.query != currentQuery { slog.Debug("Ignoring stale zoxide query result", "msgQuery", msg.query, "currentQuery", currentQuery, "id", msg.reqID) return nil } m.results = msg.results m.cursor = 0 m.renderIndex = 0 return nil } ================================================ FILE: src/internal/ui/zoxide/model_test.go ================================================ package zoxide import ( "testing" tea "github.com/charmbracelet/bubbletea" zoxidelib "github.com/lazysegtree/go-zoxide" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/common" ) func TestMain(m *testing.M) { originalZoxideSupport := common.Config.ZoxideSupport common.Config.ZoxideSupport = true defer func() { common.Config.ZoxideSupport = originalZoxideSupport }() m.Run() } func TestHandleConfirmWithValidSelection(t *testing.T) { m := setupTestModelWithResults(3) m.cursor = 1 action := m.handleConfirm() cdAction, ok := action.(common.CDCurrentPanelAction) require.True(t, ok, "action should be CDCurrentPanelAction") assert.Equal(t, m.results[1].Path, cdAction.Location, "action should navigate to results[1].Path") } func TestHandleConfirmWithNoResults(t *testing.T) { m := setupTestModel() action := m.handleConfirm() _, ok := action.(common.NoAction) assert.True(t, ok, "action should be NoAction when there are no results") } func TestHandleConfirmWithInvalidCursor(t *testing.T) { m := setupTestModelWithResults(3) m.cursor = 5 action := m.handleConfirm() _, ok := action.(common.NoAction) assert.True(t, ok, "action should be NoAction when cursor is out of bounds") } func TestJKKeyHandling(t *testing.T) { m := setupTestModelWithClient(t) m.Open() originalHotkeys := common.Hotkeys.ListDown common.Hotkeys.ListDown = []string{"j", "down"} defer func() { common.Hotkeys.ListDown = originalHotkeys }() action, cmd := m.HandleUpdate(utils.TeaRuneKeyMsg("j")) assert.NotNil(t, cmd, "HandleUpdate should return cmd for text input update") _, isNoAction := action.(common.NoAction) assert.True(t, isNoAction, "action should be NoAction for text input") assert.Equal(t, "j", m.textInput.Value(), "'j' should be added to textInput") action, cmd = m.HandleUpdate(utils.TeaRuneKeyMsg("k")) assert.NotNil(t, cmd, "HandleUpdate should return cmd for text input update") _, isNoAction = action.(common.NoAction) assert.True(t, isNoAction, "action should be NoAction for text input") assert.Equal(t, "jk", m.textInput.Value(), "'k' should be added to textInput") m.textInput.SetValue("") m.results = []zoxidelib.Result{ {Path: "/test/path1", Score: 100}, {Path: "/test/path2", Score: 90}, } m.cursor = 0 action, cmd = m.HandleUpdate(tea.KeyMsg{Type: tea.KeyDown}) assert.Nil(t, cmd, "HandleUpdate with down arrow should not return cmd") _, isNoAction = action.(common.NoAction) assert.True(t, isNoAction, "action should be NoAction for navigation") assert.Equal(t, 1, m.cursor, "down arrow should navigate down") assert.Empty(t, m.textInput.Value(), "down arrow should not add to textInput") } func TestApplyWithMatchingQuery(t *testing.T) { m := setupTestModel() m.textInput.SetValue("test") m.cursor = 5 m.renderIndex = 2 results := []zoxidelib.Result{ {Path: "/test/path1", Score: 100}, {Path: "/test/path2", Score: 90}, {Path: "/test/path3", Score: 80}, } msg := NewUpdateMsg("test", results, 1) cmd := msg.Apply(&m) assert.Nil(t, cmd) assert.Len(t, m.results, 3, "results should be updated to 3 items") assert.Equal(t, 0, m.cursor, "cursor should be reset to 0") assert.Equal(t, 0, m.renderIndex, "renderIndex should be reset to 0") assert.Equal(t, results, m.results, "results should match the update message") } func TestApplyWithStaleQuery(t *testing.T) { m := setupTestModel() m.textInput.SetValue("new") m.cursor = 1 m.renderIndex = 1 originalResults := []zoxidelib.Result{ {Path: "/original/path", Score: 50}, } m.results = originalResults staleResults := []zoxidelib.Result{ {Path: "/test/path1", Score: 100}, {Path: "/test/path2", Score: 90}, {Path: "/test/path3", Score: 80}, } msg := NewUpdateMsg("old", staleResults, 1) cmd := msg.Apply(&m) assert.Nil(t, cmd) assert.Equal(t, originalResults, m.results, "results should remain unchanged") assert.Equal(t, 1, m.cursor, "cursor should remain unchanged") assert.Equal(t, 1, m.renderIndex, "renderIndex should remain unchanged") } ================================================ FILE: src/internal/ui/zoxide/navigation.go ================================================ package zoxide func (m *Model) navigateUp() { if len(m.results) == 0 { return } if m.cursor > 0 { m.cursor-- } else { m.cursor = len(m.results) - 1 // Wrap to bottom } m.updateRenderIndex() } func (m *Model) navigateDown() { if len(m.results) == 0 { return } if m.cursor < len(m.results)-1 { m.cursor++ } else { m.cursor = 0 // Wrap to top } m.updateRenderIndex() } func (m *Model) updateRenderIndex() { if len(m.results) == 0 { m.renderIndex = 0 return } // If cursor is above visible range, scroll up if m.cursor < m.renderIndex { m.renderIndex = m.cursor } // If cursor is below visible range, scroll down if m.cursor >= m.renderIndex+maxVisibleResults { m.renderIndex = m.cursor - maxVisibleResults + 1 } // Ensure renderIndex is within bounds if m.renderIndex < 0 { m.renderIndex = 0 } maxRenderIndex := len(m.results) - maxVisibleResults if maxRenderIndex < 0 { maxRenderIndex = 0 } if m.renderIndex > maxRenderIndex { m.renderIndex = maxRenderIndex } } ================================================ FILE: src/internal/ui/zoxide/navigation_test.go ================================================ package zoxide import ( "testing" "github.com/stretchr/testify/assert" ) func TestNavigation(t *testing.T) { testdata := []struct { name string resultCnt int startCursor int navigateUp bool expectedCursor int }{ { name: "navigateUp at position 0 wraps to last position", resultCnt: 5, startCursor: 0, navigateUp: true, expectedCursor: 4, }, { name: "navigateDown at position 0 moves to next position", resultCnt: 5, startCursor: 0, navigateUp: false, expectedCursor: 1, }, { name: "navigateDown at last position wraps to first position", resultCnt: 5, startCursor: 4, navigateUp: false, expectedCursor: 0, }, { name: "navigateUp with empty results keeps cursor at 0", resultCnt: 0, startCursor: 0, navigateUp: true, expectedCursor: 0, }, { name: "navigateDown with empty results keeps cursor at 0", resultCnt: 0, startCursor: 0, navigateUp: false, expectedCursor: 0, }, } for _, td := range testdata { t.Run(td.name, func(t *testing.T) { var m Model if td.resultCnt == 0 { m = setupTestModel() } else { m = setupTestModelWithResults(td.resultCnt) } m.cursor = td.startCursor if td.navigateUp { m.navigateUp() } else { m.navigateDown() } assert.Equal(t, td.expectedCursor, m.cursor) }) } } func TestUpdateRenderIndex(t *testing.T) { testdata := []struct { name string resultCnt int cursor int expectedRenderIndex int }{ { name: "cursor at 0 has renderIndex 0", resultCnt: 10, cursor: 0, expectedRenderIndex: 0, }, { name: "cursor at 5 has renderIndex 1 (visible at bottom)", resultCnt: 10, cursor: 5, expectedRenderIndex: 1, }, { name: "cursor at 9 has renderIndex 5 (last page)", resultCnt: 10, cursor: 9, expectedRenderIndex: 5, }, { name: "cursor back at 0 scrolls back up to renderIndex 0", resultCnt: 10, cursor: 0, expectedRenderIndex: 0, }, { name: "renderIndex stays 0 with 3 results, cursor at 0", resultCnt: 3, cursor: 0, expectedRenderIndex: 0, }, { name: "renderIndex stays 0 with 3 results, cursor at 1", resultCnt: 3, cursor: 1, expectedRenderIndex: 0, }, { name: "renderIndex stays 0 with 3 results, cursor at 2", resultCnt: 3, cursor: 2, expectedRenderIndex: 0, }, } for _, td := range testdata { t.Run(td.name, func(t *testing.T) { m := setupTestModelWithResults(td.resultCnt) m.cursor = td.cursor m.updateRenderIndex() assert.Equal(t, td.expectedRenderIndex, m.renderIndex) }) } } ================================================ FILE: src/internal/ui/zoxide/render.go ================================================ package zoxide import ( "fmt" "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/internal/ui" "github.com/yorukot/superfile/src/internal/ui/rendering" ) func (m *Model) Render() string { r := ui.ZoxideRenderer(m.maxHeight, m.width) r.SetBorderTitle(m.headline) if m.zClient == nil { r.AddSection() r.AddLines(" Zoxide not available (check zoxide_support in config)") return r.Render() } r.AddLines(" " + m.textInput.View()) r.AddSection() if len(m.results) > 0 { m.renderResultList(r) } else { r.AddLines(" No zoxide results found") } return r.Render() } func (m *Model) renderResultList(r *rendering.Renderer) { // Calculate visible range endIndex := m.renderIndex + maxVisibleResults if endIndex > len(m.results) { endIndex = len(m.results) } // Show visible results m.renderVisibleResults(r, endIndex) // Show scroll indicators if needed m.renderScrollIndicators(r, endIndex) } func (m *Model) renderVisibleResults(r *rendering.Renderer, endIndex int) { for i := m.renderIndex; i < endIndex; i++ { result := m.results[i] // Truncate path if too long (account for score, separator, and padding) // Available width: modal width // - borders(2) - padding(2) - score(6) // - separator(3) = width - 13 // 0123456789012345678 => 19 width, path gets 6 // | 9999.9 | | availablePathWidth := m.width - scoreColumnWidth path := common.TruncateTextBeginning(result.Path, availablePathWidth, "...") line := fmt.Sprintf(" %6.1f | %s", result.Score, path) // Highlight the selected item if i == m.cursor { line = common.ModalCursorStyle.Render(line) } r.AddLines(line) } } func (m *Model) renderScrollIndicators(r *rendering.Renderer, endIndex int) { if len(m.results) <= maxVisibleResults { return } if m.renderIndex > 0 { r.AddSection() r.AddLines(" ↑ More results above") } if endIndex < len(m.results) { if m.renderIndex == 0 { r.AddSection() } r.AddLines(" ↓ More results below") } } ================================================ FILE: src/internal/ui/zoxide/render_test.go ================================================ package zoxide import ( "testing" zoxidelib "github.com/lazysegtree/go-zoxide" "github.com/stretchr/testify/assert" ) func TestRenderWithNilZClient(t *testing.T) { m := setupTestModel() output := m.Render() assert.Contains(t, output, "Zoxide not available", "output should contain 'Zoxide not available'") } func TestRenderWithEmptyResults(t *testing.T) { m := setupTestModelWithClient(t) output := m.Render() assert.Contains(t, output, "No zoxide results found", "output should contain 'No zoxide results found'") } func TestRenderWithResults(t *testing.T) { m := setupTestModelWithClient(t) m.results = []zoxidelib.Result{ {Path: "/dir1", Score: 100}, {Path: "/dir2", Score: 90}, {Path: "/dir3", Score: 80}, } output := m.Render() assert.Contains(t, output, "/dir1", "output should contain /dir1") assert.Contains(t, output, "/dir2", "output should contain /dir2") assert.Contains(t, output, "/dir3", "output should contain /dir3") assert.Contains(t, output, "100.0", "output should contain score 100.0") assert.Contains(t, output, "90.0", "output should contain score 90.0") assert.Contains(t, output, "80.0", "output should contain score 80.0") } func TestRenderWithTextInput(t *testing.T) { m := setupTestModelWithClient(t) m.textInput.SetValue("test query") output := m.Render() assert.Contains(t, output, "test query", "output should contain text input value") } func TestRenderScrollIndicator(t *testing.T) { testdata := []struct { name string resultCnt int cursor int expectUp bool expectDown bool }{ { name: "More above", resultCnt: 10, cursor: 9, expectUp: true, expectDown: false, }, { name: "More below", resultCnt: 10, cursor: 0, expectUp: false, expectDown: true, }, { name: "Both directions", resultCnt: 10, cursor: 5, expectUp: true, expectDown: true, }, { name: "No scroll needed", resultCnt: 3, cursor: 1, expectUp: false, expectDown: false, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { m := setupTestModelWithClient(t) m.results = setupTestModelWithResults(tt.resultCnt).results m.cursor = tt.cursor m.updateRenderIndex() rendered := m.Render() if tt.expectUp { assert.Contains(t, rendered, "↑ More results above") } else { assert.NotContains(t, rendered, "↑ More results above") } if tt.expectDown { assert.Contains(t, rendered, "↓ More results below") } else { assert.NotContains(t, rendered, "↓ More results below") } }) } } ================================================ FILE: src/internal/ui/zoxide/test_helpers.go ================================================ package zoxide import ( "runtime" "testing" zoxidelib "github.com/lazysegtree/go-zoxide" "github.com/yorukot/superfile/src/pkg/utils" ) func setupTestModel() Model { return GenerateModel(nil, 50, 80) //nolint:mnd // test dimensions } func setupTestModelWithClient(t *testing.T) Model { t.Helper() zClient, err := zoxidelib.New(zoxidelib.WithDataDir(t.TempDir())) if err != nil { if runtime.GOOS != utils.OsLinux { t.Skipf("Skipping zoxide tests in non-Linux because zoxide client cannot be initialized") } else { t.Fatalf("zoxide initialization failed") } } return GenerateModel(zClient, 50, 80) //nolint:mnd // test dimensions } func setupTestModelWithResults(resultCount int) Model { m := setupTestModel() m.results = make([]zoxidelib.Result, resultCount) for i := range resultCount { m.results[i] = zoxidelib.Result{ Path: "/test/path" + string(rune('0'+i)), Score: float64(100 - i*10), //nolint:mnd // test scores } } return m } ================================================ FILE: src/internal/ui/zoxide/type.go ================================================ package zoxide import ( "github.com/charmbracelet/bubbles/textinput" zoxidelib "github.com/lazysegtree/go-zoxide" ) // No need to name it as ZoxideModel. It will me imported as zoxide.Model type Model struct { // Configuration headline string zClient *zoxidelib.Client // State open bool justOpened bool // Flag to ignore the opening keystroke textInput textinput.Model results []zoxidelib.Result cursor int // Index of currently selected result for keyboard navigation renderIndex int // Index of first visible result in scrollable list // Dimensions - Exported, since model will be dynamically adjusting them width int // Height is dynamically adjusted based on content maxHeight int // Request tracking for async queries reqCnt int } // UpdateMsg represents an async query result type UpdateMsg struct { query string results []zoxidelib.Result reqID int } func NewUpdateMsg(query string, results []zoxidelib.Result, reqID int) UpdateMsg { return UpdateMsg{ query: query, results: results, reqID: reqID, } } func (msg UpdateMsg) GetReqID() int { return msg.reqID } ================================================ FILE: src/internal/ui/zoxide/utils.go ================================================ package zoxide import ( "log/slog" "unicode" tea "github.com/charmbracelet/bubbletea" zoxidelib "github.com/lazysegtree/go-zoxide" ) func (m *Model) Open() tea.Cmd { m.open = true m.justOpened = true m.textInput.SetValue("") _ = m.textInput.Focus() // Return async command for initial query instead of blocking return m.GetQueryCmd("") } func (m *Model) Close() { m.open = false m.textInput.Blur() m.textInput.SetValue("") m.results = []zoxidelib.Result{} m.cursor = 0 m.renderIndex = 0 } func (m *Model) IsOpen() bool { return m.open } func (m *Model) GetWidth() int { return m.width } func (m *Model) GetMaxHeight() int { return m.maxHeight } func (m *Model) SetWidth(width int) { if width < ZoxideMinWidth { slog.Warn("Zoxide initialized with too less width", "width", width) width = ZoxideMinWidth } m.width = width // Excluding borders(2), SpacePadding(1), Prompt(2), and one extra character that is appended // by textInput.View() m.textInput.Width = width - modalInputPadding } func (m *Model) SetMaxHeight(maxHeight int) { if maxHeight < ZoxideMinHeight { slog.Warn("Zoxide initialized with too less maxHeight", "maxHeight", maxHeight) maxHeight = ZoxideMinHeight } m.maxHeight = maxHeight } func (m *Model) GetResults() []zoxidelib.Result { out := make([]zoxidelib.Result, len(m.results)) copy(out, m.results) return out } func (m *Model) GetTextInputValue() string { return m.textInput.Value() } func isKeyAlphaNum(msg tea.KeyMsg) bool { r := []rune(msg.String()) if len(r) != 1 { return false } return unicode.IsLetter(r[0]) || unicode.IsNumber(r[0]) } ================================================ FILE: src/internal/ui/zoxide/utils_test.go ================================================ package zoxide import ( "testing" tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" ) func TestIsKeyAlphaNum(t *testing.T) { assert.True(t, isKeyAlphaNum(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}), "'j' should be alphanumeric") assert.True(t, isKeyAlphaNum(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}), "'k' should be alphanumeric") assert.True(t, isKeyAlphaNum(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'5'}}), "'5' should be alphanumeric") assert.True(t, isKeyAlphaNum(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'A'}}), "'A' should be alphanumeric") assert.False(t, isKeyAlphaNum(tea.KeyMsg{Type: tea.KeyUp}), "up arrow should not be alphanumeric") assert.False(t, isKeyAlphaNum(tea.KeyMsg{Type: tea.KeyEnter}), "enter should not be alphanumeric") assert.False( t, isKeyAlphaNum(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}), "space should not be alphanumeric", ) } func TestOpenResetsState(t *testing.T) { m := setupTestModelWithClient(t) m.textInput.SetValue("old") m.cursor = 2 m.renderIndex = 1 cmd := m.Open() assert.True(t, m.open, "open should be true after Open()") assert.True(t, m.justOpened, "justOpened should be true after Open()") assert.Empty(t, m.textInput.Value(), "textInput should be empty after Open()") assert.NotNil(t, cmd, "Open() should return non-nil Cmd for async query") } func TestCloseClearsState(t *testing.T) { m := setupTestModelWithResults(5) m.open = true m.cursor = 2 m.renderIndex = 1 m.textInput.SetValue("test") m.Close() assert.False(t, m.open, "open should be false after Close()") assert.Empty(t, m.results, "results should be empty after Close()") assert.Equal(t, 0, m.cursor, "cursor should be 0 after Close()") assert.Equal(t, 0, m.renderIndex, "renderIndex should be 0 after Close()") assert.Empty(t, m.textInput.Value(), "textInput should be empty after Close()") } func TestGetResultsReturnsCopy(t *testing.T) { m := setupTestModelWithResults(3) originalPath := m.results[0].Path results := m.GetResults() results[0].Path = "/modified/path" assert.Equal( t, originalPath, m.results[0].Path, "modifying returned results should not affect original model.results", ) } func TestSetWidthBoundsChecking(t *testing.T) { m := setupTestModel() m.SetWidth(5) assert.Equal(t, ZoxideMinWidth, m.width, "width should be set to ZoxideMinWidth when value < ZoxideMinWidth") m.SetWidth(100) assert.Equal(t, 100, m.width, "width should be set to provided value when >= ZoxideMinWidth") } func TestSetMaxHeightBoundsChecking(t *testing.T) { m := setupTestModel() m.SetMaxHeight(1) assert.Equal( t, ZoxideMinHeight, m.maxHeight, "maxHeight should be set to ZoxideMinHeight when value < ZoxideMinHeight", ) m.SetMaxHeight(50) assert.Equal(t, 50, m.maxHeight, "maxHeight should be set to provided value when >= ZoxideMinHeight") } ================================================ FILE: src/internal/validation.go ================================================ package internal import ( "errors" "fmt" "strings" "github.com/charmbracelet/x/ansi" "github.com/yorukot/superfile/src/pkg/utils" "github.com/yorukot/superfile/src/internal/common" ) const minLinesForBorder = 3 // Non fatal Validations. This indicates bug / programming errors, not user configuration mistake func (m *model) validateLayout() error { //nolint:gocognit // cumilation of validations // Validate footer height if 0 < m.footerHeight && m.footerHeight < common.MinFooterHeight { return fmt.Errorf("footerHeight %v is too small", m.footerHeight) } if !m.toggleFooter && m.footerHeight != 0 { return fmt.Errorf("footer closed and footerHeight %v is non zero", m.footerHeight) } if m.toggleFooter && m.footerHeight == 0 { return errors.New("footer open but footerHeight is 0") } // PanelHeight + 2 lines (main border) + actual footer height if m.fullHeight != (m.mainPanelHeight+common.BorderPadding)+utils.FullFooterHeight(m.footerHeight, m.toggleFooter) { return fmt.Errorf( "invalid model layout, total height doesn't sum correctly, fullHeight : %v, mainPanelHeight : %v, footerHeight : %v", m.fullHeight, m.mainPanelHeight, m.footerHeight, ) } // Validate width constraints if m.fullWidth < common.MinimumWidth { return fmt.Errorf("fullWidth %v is below minimum %v", m.fullWidth, common.MinimumWidth) } // Check that file panel width is positive if we have file panels if m.fileModel.PanelCount() == 0 { return errors.New("file model is empty") } // Check total width calculation consistency if m.fullWidth != m.sidebarModel.GetWidth()+m.fileModel.Width { return fmt.Errorf( "width layout inconsistent: fullWidth=%v, sidebar=%v filemodel=%v", m.fullWidth, m.sidebarModel.GetWidth(), m.fileModel.Width) } // Check file panels count if m.fileModel.PanelCount() > m.fileModel.MaxFilePanel { return fmt.Errorf( "too many file panels: %v exceeds maximum %v", m.fileModel.PanelCount(), m.fileModel.MaxFilePanel) } totalFileModelWidth := 0 // Check preview panel dimensions if open if m.fileModel.FilePreview.IsOpen() { if m.fileModel.ExpectedPreviewWidth <= 0 { return fmt.Errorf("preview panel is open but width is %v", m.fileModel.ExpectedPreviewWidth) } if m.fileModel.Height <= 0 { return fmt.Errorf("preview panel is open but height is %v", m.fileModel.Height) } totalFileModelWidth += m.fileModel.ExpectedPreviewWidth } // Check each file panel has correct dimensions set for i, panel := range m.fileModel.FilePanels { totalFileModelWidth += panel.GetWidth() if panel.GetHeight() != m.fileModel.Height { return fmt.Errorf( "file panel %v height mismatch: expected %v, got %v", i, m.fileModel.Height, panel.GetHeight()) } if err := panel.ValidateCursorAndRenderIndex(); err != nil { return fmt.Errorf( "file panel %v error : %w", i, err) } // Validate search bar width matches panel width minus padding if panel.SearchBar.Width != panel.GetWidth()-common.InnerPadding { return fmt.Errorf("file panel %v search bar width mismatch: expected %v, got %v", i, panel.GetWidth()-common.InnerPadding, panel.SearchBar.Width) } } if m.fileModel.Width != totalFileModelWidth { return fmt.Errorf( "file model width mismatch: expected %v, got %v", m.fileModel.Width, totalFileModelWidth) } // Validate focus panel index is within valid range if m.fileModel.FocusedPanelIndex < 0 || m.fileModel.FocusedPanelIndex >= m.fileModel.PanelCount() { return fmt.Errorf("FocusedPanelIndex %v is out of range [0, %v)", m.fileModel.FocusedPanelIndex, m.fileModel.PanelCount()) } // Validate overlay panels have less width and height than total if m.helpMenu.IsOpen() { if m.helpMenu.GetWidth() >= m.fullWidth { return fmt.Errorf("help menu width %v exceeds full width %v", m.helpMenu.GetWidth(), m.fullWidth) } if m.helpMenu.GetHeight() >= m.fullHeight { return fmt.Errorf("help menu height %v exceeds full height %v", m.helpMenu.GetHeight(), m.fullHeight) } } if m.promptModal.IsOpen() { if m.promptModal.GetWidth() >= m.fullWidth { return fmt.Errorf("prompt modal width %v exceeds full width %v", m.promptModal.GetWidth(), m.fullWidth) } } return nil } func validateRender(out string, height int, width int, border bool) error { strippedOut := ansi.Strip(out) // Empty content is not handled correctly // strings.Split("", "\n") will return [""], not []. // Hence we need this separate handling if height == 0 { // zero lines if strippedOut != "" { return fmt.Errorf("render height mismatch: expected empty string for 0 height, got '%v'", strippedOut) } return nil } lines := strings.Split(strippedOut, "\n") if len(lines) != height { return fmt.Errorf("render height mismatch: expected %v lines, got %v", height, len(lines)) } for i, line := range lines { lineWidth := ansi.StringWidth(line) if lineWidth != width { return fmt.Errorf( "render line %v, expected %v width, got %v(line - '%v')", i, width, lineWidth, lines[i], ) } } if !border { return nil } return validateRenderBorderValidations(lines) } func validateRenderBorderValidations(lines []string) error { if len(lines) < minLinesForBorder { return fmt.Errorf("too few lines for border : %v", len(lines)) } // Check first line starts with TopLeft and ends with TopRight if !strings.HasPrefix(lines[0], common.Config.BorderTopLeft) { return fmt.Errorf("render missing top left border, expected %q", common.Config.BorderTopLeft) } if !strings.HasSuffix(lines[0], common.Config.BorderTopRight) { return fmt.Errorf("render missing top right border, expected %q", common.Config.BorderTopRight) } // Check last line starts with BottomLeft and ends with BottomRight lastLine := lines[len(lines)-1] if !strings.HasPrefix(lastLine, common.Config.BorderBottomLeft) { return fmt.Errorf("render missing bottom left border, expected %q", common.Config.BorderBottomLeft) } if !strings.HasSuffix(lastLine, common.Config.BorderBottomRight) { return fmt.Errorf("render missing bottom right border, expected %q", common.Config.BorderBottomRight) } // Check middle lines wrapped with BorderLeft and BorderRight for i := 1; i < len(lines)-1; i++ { if !strings.HasPrefix(lines[i], common.Config.BorderLeft) && !strings.HasPrefix(lines[i], common.Config.BorderMiddleLeft) { return fmt.Errorf("render line '%v' missing left border", lines[i]) } if !strings.HasSuffix(lines[i], common.Config.BorderRight) && !strings.HasSuffix(lines[i], common.Config.BorderMiddleRight) { return fmt.Errorf("render line '%v' missing right border", lines[i]) } } // Check top line contains BorderTop if !strings.Contains(lines[0], common.Config.BorderTop) { return fmt.Errorf("render missing top border character %q", common.Config.BorderTop) } // Check bottom line contains BorderBottom if !strings.Contains(lastLine, common.Config.BorderBottom) { return fmt.Errorf("render missing bottom border character %q", common.Config.BorderBottom) } return nil } // validateComponentRender validates render output of all components func (m *model) validateComponentRender() error { // Validate sidebar render if common.Config.SidebarWidth > 0 { sidebarRender := m.sidebarRender() if err := validateRender( sidebarRender, m.mainPanelHeight+common.BorderPadding, common.Config.SidebarWidth+common.BorderPadding, true, ); err != nil { return fmt.Errorf("sidebar render validation failed: %w", err) } } for i := range m.fileModel.FilePanels { panel := &m.fileModel.FilePanels[i] panelRender := panel.Render(i == m.fileModel.FocusedPanelIndex) if err := validateRender(panelRender, panel.GetHeight(), panel.GetWidth(), true); err != nil { return fmt.Errorf("file panel %v render validation failed: %w", i, err) } } p := &m.fileModel.FilePreview if err := validateRender( p.GetContent(), p.GetContentHeight(), p.GetContentWidth(), common.Config.EnableFilePreviewBorder, ); err != nil { return fmt.Errorf("file preview render validation failed: %w", err) } if err := validateRender(m.fileModel.Render(), m.fileModel.Height, m.fileModel.Width, false); err != nil { return fmt.Errorf("file model render validation failed: %w", err) } // Validate footer components if visible if m.toggleFooter { if err := validateRender( m.processBarRender(), m.processBarModel.GetHeight(), m.processBarModel.GetWidth(), true, ); err != nil { return fmt.Errorf("process bar render validation failed: %w", err) } if err := validateRender( m.fileMetaData.Render(true), m.fileMetaData.GetHeight(), m.fileMetaData.GetWidth(), true, ); err != nil { return fmt.Errorf("metadata render validation failed: %w", err) } if err := validateRender( m.clipboard.Render(), m.clipboard.GetHeight(), m.clipboard.GetWidth(), true, ); err != nil { return fmt.Errorf("clipboard render validation failed: %w", err) } } return nil } func (m *model) validateFinalRender() error { //nolint:gocognit // cumilation of validations mainRender := m.mainComponentsRender() if err := validateRender(mainRender, m.fullHeight, m.fullWidth, false); err != nil { return fmt.Errorf("model rendering failures : %w", err) } strippedOut := ansi.Strip(mainRender) lines := strings.Split(strippedOut, "\n") if common.Config.SidebarWidth != 0 { sidebarPos := compPosition{ stRow: 0, stCol: 0, endRow: m.sidebarModel.GetHeight() - 1, endCol: m.sidebarModel.GetWidth() - 1, } // Note: This wont work when any overlay model is open if err := m.validateComponentPlacement(lines, sidebarPos, true); err != nil { return fmt.Errorf("sidebar position validation failed: %w", err) } } filePanelColStart := 0 if common.Config.SidebarWidth != 0 { filePanelColStart += common.BorderPadding + common.Config.SidebarWidth } for i := range m.fileModel.FilePanels { panel := &m.fileModel.FilePanels[i] panelPos := compPosition{ stRow: 0, endRow: m.mainPanelHeight + 1, stCol: filePanelColStart, endCol: filePanelColStart + panel.GetWidth() - 1, } filePanelColStart += panel.GetWidth() // Note: This wont work when any overlay model is open if err := m.validateComponentPlacement(lines, panelPos, true); err != nil { return fmt.Errorf("file panel %v position validation failed: %w", i, err) } } if m.fileModel.FilePreview.IsOpen() { previewPanelPos := compPosition{ stRow: 0, endRow: m.mainPanelHeight + 1, stCol: m.fullWidth - m.fileModel.ExpectedPreviewWidth, endCol: m.fullWidth - 1, } if err := m.validateComponentPlacement( lines, previewPanelPos, common.Config.EnableFilePreviewBorder, ); err != nil { return fmt.Errorf("preview panel position validation failed: %w", err) } } if m.toggleFooter { processBarPos := compPosition{ stRow: m.mainPanelHeight + common.BorderPadding, stCol: 0, endRow: m.fullHeight - 1, endCol: m.processBarModel.GetWidth() - 1, } if err := m.validateComponentPlacement(lines, processBarPos, true); err != nil { return fmt.Errorf("process bar position validation failed: %w", err) } metadataPos := compPosition{ stRow: m.mainPanelHeight + common.BorderPadding, stCol: m.processBarModel.GetWidth(), endRow: m.fullHeight - 1, endCol: m.processBarModel.GetWidth() + m.fileMetaData.GetWidth() - 1, } if err := m.validateComponentPlacement(lines, metadataPos, true); err != nil { return fmt.Errorf("metadata bar position validation failed: %w", err) } clipboardPos := compPosition{ stRow: m.mainPanelHeight + common.BorderPadding, stCol: m.processBarModel.GetWidth() + m.fileMetaData.GetWidth(), endRow: m.fullHeight - 1, endCol: m.fullWidth - 1, } if err := m.validateComponentPlacement(lines, clipboardPos, true); err != nil { return fmt.Errorf("clipboard position validation failed: %w", err) } } // TODO: programatically ensure that only one of them is open at a time // We may need some sort of overlay model management if m.IsOverlayModelOpen() { finalRender := m.updateRenderForOverlay(mainRender) if err := validateRender(finalRender, m.fullHeight, m.fullWidth, false); err != nil { return fmt.Errorf("model rendering failures : %w", err) } // TODO: Add validations for overlay models } return nil } // Inclusive func (m *model) validateComponentPlacement(lines []string, pos compPosition, border bool) error { extractedLines, err := m.extractComponent(lines, pos) if err != nil { return fmt.Errorf("failure while extracting content : %w", err) } cntRow := pos.endRow - pos.stRow + 1 cntCol := pos.endCol - pos.stCol + 1 extractedOut := strings.Join(extractedLines, "\n") if err := validateRender(extractedOut, cntRow, cntCol, border); err != nil { return fmt.Errorf("failure in extracted content : %w", err) } return nil } // Inclusive func (m *model) extractComponent(lines []string, pos compPosition) ([]string, error) { if 0 > pos.stRow || pos.stRow > pos.endRow || pos.endRow >= len(lines) { return nil, fmt.Errorf("invalid row range [%v, %v], line count : %v", pos.stRow, pos.endRow, len(lines)) } firstLineWidth := ansi.StringWidth(lines[0]) if 0 > pos.stCol || pos.stCol > pos.endCol || pos.endCol >= firstLineWidth { return nil, fmt.Errorf("invalid col range [%v, %v], first line width : %v", pos.stCol, pos.endCol, firstLineWidth) } cntRow := pos.endRow - pos.stRow + 1 extractedLines := make([]string, cntRow) for i := range cntRow { orgIdx := pos.stRow + i extractedLines[i] = ansi.Cut(lines[orgIdx], pos.stCol, pos.endCol+1) } return extractedLines, nil } type compPosition struct { stRow int stCol int endRow int endCol int } func (m *model) IsOverlayModelOpen() bool { return m.zoxideModal.IsOpen() || m.helpMenu.IsOpen() || m.promptModal.IsOpen() || m.sortModal.IsOpen() || m.firstUse || m.typingModal.open || m.notifyModel.IsOpen() } ================================================ FILE: src/internal/wheel_function.go ================================================ package internal import ( "log/slog" "github.com/yorukot/superfile/src/internal/common" ) func wheelMainAction(msg string, m *model) { slog.Debug("wheelMainAction called", "msg", msg, "focusPanel", m.focusPanel) var action func() switch msg { case "wheel up": switch m.focusPanel { case sidebarFocus: action = func() { m.sidebarModel.ListUp() } case processBarFocus: action = func() { m.processBarModel.ListUp() } case metadataFocus: action = func() { m.fileMetaData.ListUp() } case nonePanelFocus: action = func() { m.getFocusedFilePanel().ListUp() } } case "wheel down": switch m.focusPanel { case sidebarFocus: action = func() { m.sidebarModel.ListDown() } case processBarFocus: action = func() { m.processBarModel.ListDown() } case metadataFocus: action = func() { m.fileMetaData.ListDown() } case nonePanelFocus: action = func() { m.getFocusedFilePanel().ListDown() } } default: slog.Error("Unexpected type of mouse action in wheelMainAction", "msg", msg) return } for range common.WheelRunTime { action() } } ================================================ FILE: src/pkg/cache/cache.go ================================================ package cache import ( "sync" "time" ) type cacheItemInternal[T any] struct { obj T Timestamp time.Time } type Cache[T any] struct { cache map[string]cacheItemInternal[T] mutex sync.RWMutex maxEntries int expiration time.Duration } func New[T any](maxEntries int, expiration time.Duration) *Cache[T] { cache := &Cache[T]{ cache: make(map[string]cacheItemInternal[T]), maxEntries: maxEntries, expiration: expiration, } // Start a cleanup goroutine go cache.periodicCleanup() return cache } // periodicCleanup removes expired entries periodically func (c *Cache[T]) periodicCleanup() { //nolint:mnd // half of expiration for cleanup interval ticker := time.NewTicker(c.expiration / 2) defer ticker.Stop() for range ticker.C { c.cleanupExpired() } } // cleanupExpired removes expired cache entries func (c *Cache[T]) cleanupExpired() { now := time.Now() c.mutex.Lock() defer c.mutex.Unlock() for key, entry := range c.cache { if now.Sub(entry.Timestamp) > c.expiration { delete(c.cache, key) } } } func (c *Cache[T]) Get(key string) (T, bool) { c.mutex.RLock() defer c.mutex.RUnlock() if entry, exists := c.cache[key]; exists { return entry.obj, true } var res T return res, false } func (c *Cache[T]) Set(key string, obj T) { c.mutex.Lock() defer c.mutex.Unlock() // Check if we need to evict entries if len(c.cache) >= c.maxEntries { c.evictOldest() } c.cache[key] = cacheItemInternal[T]{ obj: obj, Timestamp: time.Now(), } } // evictOldest removes the oldest entry from the cache func (c *Cache[T]) evictOldest() { var oldestKey string var oldestTime time.Time // Find the oldest entry for key, entry := range c.cache { if oldestKey == "" || entry.Timestamp.Before(oldestTime) { oldestKey = key oldestTime = entry.Timestamp } } // Remove the oldest entry if oldestKey != "" { delete(c.cache, oldestKey) } } ================================================ FILE: src/pkg/file_preview/ansi.go ================================================ package filepreview import ( "fmt" "image" "image/color" "strings" "github.com/muesli/termenv" ) // ConvertImageToANSI converts an image to ANSI escape codes with proper aspect ratio func ConvertImageToANSI(img image.Image, defaultBGColor color.Color) string { width := img.Bounds().Dx() height := img.Bounds().Dy() // TODO: Use renderer here to prevent newline management,and overflows var output strings.Builder cache := newColorCache() defaultBGHex := colorToHex(defaultBGColor) for y := 0; y < height; y += 2 { for x := range width { upperColor := cache.getTermenvColor(img.At(x, y), defaultBGHex) lowerColor := cache.getTermenvColor(defaultBGColor, "") if y+1 < height { lowerColor = cache.getTermenvColor(img.At(x, y+1), defaultBGHex) } // Using the "▄" character which fills the lower half cell := termenv.String("▄").Foreground(lowerColor).Background(upperColor) output.WriteString(cell.String()) } // Only add newline if this is not the last row if y+2 < height { output.WriteByte('\n') } } return output.String() } // Convert image to ansi func (p *ImagePreviewer) ANSIRenderer(img image.Image, defaultBGColor string, maxWidth int, maxHeight int) (string, error) { bgColor, err := hexToColor(defaultBGColor) if err != nil { return "", fmt.Errorf("invalid background color: %w", err) } // For ANSI rendering, resize image appropriately fittedImg := resizeForANSI(img, maxWidth, maxHeight) return ConvertImageToANSI(fittedImg, bgColor), nil } type colorCache struct { rgbaToTermenv map[color.RGBA]termenv.RGBColor } func newColorCache() *colorCache { return &colorCache{ rgbaToTermenv: make(map[color.RGBA]termenv.RGBColor), } } func (c *colorCache) getTermenvColor(col color.Color, fallbackColor string) termenv.RGBColor { rgba, ok := color.RGBAModel.Convert(col).(color.RGBA) if !ok || rgba.A == 0 { return termenv.RGBColor(fallbackColor) } if termenvColor, exists := c.rgbaToTermenv[rgba]; exists { return termenvColor } termenvColor := termenv.RGBColor(fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)) c.rgbaToTermenv[rgba] = termenvColor return termenvColor } ================================================ FILE: src/pkg/file_preview/constants.go ================================================ package filepreview import "time" // Image preview constants const ( // Cache configuration defaultImagePreviewCacheSize = 100 defaultCacheExpiration = 5 * time.Minute // Image processing heightScaleFactor = 2 // Factor for height scaling in terminal display rgbShift16 = 16 // Bit shift for red channel in RGB operations rgbShift8 = 8 // Bit shift for green channel in RGB operations // Kitty protocol kittyHashSeed = 42 // Seed for kitty image ID hashing kittyHashPrime = 31 // Prime multiplier for hash calculation kittyMaxID = 0xFFFF // Maximum ID value for kitty images kittyNonZeroOffset = 1000 // Offset to ensure non-zero IDs // RGB color masks rgbMask = 0xFF // Mask for extracting 8-bit RGB channel values alphaOpaque = 255 // Fully opaque alpha channel value maxVideoFileSizeForThumb = "104857600" // 100MB limit thumbOutputExt = ".jpg" thumbGenerationTimeout = 30 * time.Second ) ================================================ FILE: src/pkg/file_preview/image_preview.go ================================================ package filepreview import ( "fmt" _ "image/gif" // Register GIF decoder _ "image/jpeg" // Register JPEG decoder _ "image/png" // Register PNG decoder "log/slog" "os" "time" _ "golang.org/x/image/webp" // Register WebP decoder "github.com/yorukot/superfile/src/internal/common" "github.com/yorukot/superfile/src/pkg/cache" ) type ImageRenderer int const ( RendererANSI ImageRenderer = iota RendererKitty ) func (f ImageRenderer) String() string { switch f { case RendererANSI: return "ANSI" case RendererKitty: return "Kitty" default: return common.InvalidTypeString } } func getPreviewObjKey(path string, dim string, renderer ImageRenderer) string { return fmt.Sprintf("%s:%s:%s", path, dim, renderer) } // ImagePreviewer encapsulates image preview functionality with caching type ImagePreviewer struct { cache *cache.Cache[string] terminalCap *TerminalCapabilities } // NewImagePreviewer creates a new ImagePreviewer with default cache settings func NewImagePreviewer() *ImagePreviewer { return NewImagePreviewerWithConfig(defaultImagePreviewCacheSize, defaultCacheExpiration) } // NewImagePreviewerWithConfig creates a new ImagePreviewer with custom cache configuration func NewImagePreviewerWithConfig(maxEntries int, expiration time.Duration) *ImagePreviewer { previewer := &ImagePreviewer{ cache: cache.New[string](maxEntries, expiration), terminalCap: NewTerminalCapabilities(), } // Initialize terminal capabilities previewer.terminalCap.InitTerminalCapabilities() return previewer } // ImagePreview generates a preview of an image file func (p *ImagePreviewer) ImagePreview(path string, maxWidth int, maxHeight int, defaultBGColor string, sideAreaWidth int) (string, error) { // Validate dimensions if maxWidth <= 0 || maxHeight <= 0 { return "", fmt.Errorf("dimensions must be positive (maxWidth=%d, maxHeight=%d)", maxWidth, maxHeight) } // Create dimensions string for cache key dimensions := fmt.Sprintf("%d,%d,%s,%d", maxWidth, maxHeight, defaultBGColor, sideAreaWidth) // Try Kitty first as it's more modern if p.IsKittyCapable() { // Check cache for Kitty renderer if preview, exists := p.cache.Get(getPreviewObjKey(path, dimensions, RendererKitty)); exists { return preview, nil } preview, err := p.ImagePreviewWithRenderer( path, maxWidth, maxHeight, defaultBGColor, RendererKitty, sideAreaWidth, ) if err == nil { // Cache the successful result p.cache.Set(getPreviewObjKey(path, dimensions, RendererKitty), preview) return preview, nil } // Fall through to ANSI if Kitty fails slog.Error("Kitty renderer failed, falling back to ANSI", "error", err) } // Check cache for ANSI renderer if preview, found := p.cache.Get(getPreviewObjKey(path, dimensions, RendererANSI)); found { return preview, nil } // Fall back to ANSI preview, err := p.ImagePreviewWithRenderer(path, maxWidth, maxHeight, defaultBGColor, RendererANSI, sideAreaWidth) if err == nil { // Cache the successful result p.cache.Set(getPreviewObjKey(path, dimensions, RendererANSI), preview) } return preview, err } // ImagePreviewWithRenderer generates an image preview using the specified renderer func (p *ImagePreviewer) ImagePreviewWithRenderer(path string, maxWidth int, maxHeight int, defaultBGColor string, renderer ImageRenderer, sideAreaWidth int) (string, error) { info, err := os.Stat(path) if err != nil { return "", err } const maxFileSize = 100 * 1024 * 1024 // 100MB limit if info.Size() > maxFileSize { return "", fmt.Errorf("image file too large: %d bytes", info.Size()) } data, err := os.ReadFile(path) if err != nil { return "", err } // Use the new image preparation pipeline img, originalWidth, originalHeight, err := prepareImageForPreview(data) if err != nil { return "", err } switch renderer { case RendererKitty: result, err := p.renderWithKittyUsingTermCap(img, path, originalWidth, originalHeight, maxWidth, maxHeight, sideAreaWidth) if err != nil { // If kitty fails, fall back to ANSI renderer slog.Error("Kitty renderer failed, falling back to ANSI", "error", err) return p.ANSIRenderer(img, defaultBGColor, maxWidth, maxHeight) } return result, nil case RendererANSI: return p.ANSIRenderer(img, defaultBGColor, maxWidth, maxHeight) default: return "", fmt.Errorf("invalid renderer : %v", renderer) } } ================================================ FILE: src/pkg/file_preview/image_resize.go ================================================ package filepreview import ( "bytes" "image" "log/slog" "github.com/disintegration/imaging" "github.com/rwcarlsen/goexif/exif" ) // prepareImageForPreview handles the complete image preparation pipeline func prepareImageForPreview(data []byte) (image.Image, int, int, error) { imgReader := bytes.NewReader(data) img, _, err := image.Decode(imgReader) if err != nil { return nil, 0, 0, err } // Store original dimensions originalWidth := img.Bounds().Dx() originalHeight := img.Bounds().Dy() // Adjust orientation based on EXIF data exifReader := bytes.NewReader(data) img = adjustImageOrientation(exifReader, img) // Limit resolution to 1080p img = limitImageResolution(img, originalWidth, originalHeight) return img, originalWidth, originalHeight, nil } // limitImageResolution limits image resolution to 1080p while maintaining aspect ratio func limitImageResolution(img image.Image, originalWidth, originalHeight int) image.Image { const maxImageWidth = 1920 const maxImageHeight = 1080 // Only resize if the image is larger than 1080p if originalWidth > maxImageWidth || originalHeight > maxImageHeight { resizedImg := imaging.Fit(img, maxImageWidth, maxImageHeight, imaging.Lanczos) return resizedImg } return img } // adjustImageOrientation adjusts image orientation based on EXIF data func adjustImageOrientation(r *bytes.Reader, img image.Image) image.Image { exifData, err := exif.Decode(r) if err != nil { slog.Error("exif error", "error", err) return img } tag, err := exifData.Get(exif.Orientation) if err != nil { slog.Error("exif orientation error", "error", err) return img } orientation, err := tag.Int(0) if err != nil { slog.Error("exif orientation value error", "error", err) return img } return adjustOrientation(img, orientation) } // adjustOrientation applies the specified orientation transformation to the image func adjustOrientation(img image.Image, orientation int) image.Image { switch orientation { case 1: return img case 2: //nolint:mnd // EXIF orientation: horizontal flip return imaging.FlipH(img) case 3: //nolint:mnd // EXIF orientation: 180 rotation return imaging.Rotate180(img) case 4: //nolint:mnd // EXIF orientation: vertical flip return imaging.FlipV(img) case 5: //nolint:mnd // EXIF orientation: transpose return imaging.Transpose(img) case 6: //nolint:mnd // EXIF orientation: 270 rotation return imaging.Rotate270(img) case 7: //nolint:mnd // EXIF orientation: transverse return imaging.Transverse(img) case 8: //nolint:mnd // EXIF orientation: 90 rotation return imaging.Rotate90(img) default: slog.Error("Invalid orientation value", "error", orientation) return img } } // resizeForANSI resizes image specifically for ANSI rendering func resizeForANSI(img image.Image, maxWidth, maxHeight int) image.Image { // Use maxHeight*2 because each terminal row represents 2 pixel rows in ANSI rendering return imaging.Fit(img, maxWidth, maxHeight*heightScaleFactor, imaging.Lanczos) } ================================================ FILE: src/pkg/file_preview/kitty.go ================================================ package filepreview import ( "bytes" "fmt" "image" "log/slog" "os" "strings" "github.com/BourgeoisBear/rasterm" "github.com/yorukot/superfile/src/internal/common" ) // isKittyCapable checks if the terminal supports Kitty graphics protocol func isKittyCapable() bool { isCapable := rasterm.IsKittyCapable() // Additional detection for terminals that might not be detected by rasterm if !isCapable { termProgram := os.Getenv("TERM_PROGRAM") term := os.Getenv("TERM") // List of known terminal identifiers that support Kitty protocol knownTerminals := []string{ "ghostty", "WezTerm", "iTerm2", "xterm-kitty", "kitty", "Konsole", "WarpTerminal", } for _, knownTerm := range knownTerminals { if strings.EqualFold(termProgram, knownTerm) || strings.EqualFold(term, knownTerm) { isCapable = true break } } } return isCapable } // ClearKittyImages clears all Kitty protocol images from the terminal func ClearKittyImages() string { if !isKittyCapable() { return "" // No need to clear if terminal doesn't support Kitty protocol } return generateKittyClearCommands() } // ClearKittyImages clears all Kitty protocol images from the terminal func (p *ImagePreviewer) ClearKittyImages() string { if !p.IsKittyCapable() { return "" // No need to clear if terminal doesn't support Kitty protocol } return generateKittyClearCommands() } // generateKittyClearCommands generates the clearing commands for Kitty protocol func generateKittyClearCommands() string { var buf bytes.Buffer // Clear all images first clearAllCmd := "\x1b_Ga=d\x1b\\" buf.WriteString(clearAllCmd) // Clear all placements clearPlacementsCmd := "\x1b_Ga=d,p=1\x1b\\" buf.WriteString(clearPlacementsCmd) // Reset text formatting to default buf.WriteString("\x1b[0m") return buf.String() } // generatePlacementID generates a unique placement ID based on file path func generatePlacementID(path string) uint32 { if len(path) == 0 { return kittyHashSeed // Default fallback } hash := 0 for _, c := range path { hash = hash*kittyHashPrime + int(c) } return uint32(hash&kittyMaxID) + //nolint:gosec // Hash is bounded by kittyMaxID mask before conversion kittyNonZeroOffset } // renderWithKittyUsingTermCap renders an image using Kitty graphics protocol with terminal capabilities func (p *ImagePreviewer) renderWithKittyUsingTermCap(img image.Image, path string, originalWidth, originalHeight, maxWidth, maxHeight int, sideAreaWidth int, ) (string, error) { // Validate dimensions if maxWidth <= 0 || maxHeight <= 0 { return "", fmt.Errorf("dimensions must be positive (maxWidth=%d, maxHeight=%d)", maxWidth, maxHeight) } var buf bytes.Buffer // Add clearing commands buf.WriteString(generateKittyClearCommands()) opts := rasterm.KittyImgOpts{ PlacementId: generatePlacementID(path), } // Get terminal cell size from ImagePreviewer's terminal capabilities cellSize := p.terminalCap.GetTerminalCellSize() pixelsPerColumn := cellSize.PixelsPerColumn pixelsPerRow := cellSize.PixelsPerRow slog.Debug("pixelsPerColumn", "pixelsPerColumn", pixelsPerColumn, "pixelsPerRow", pixelsPerRow) imgRatio := float64(originalWidth) / float64(originalHeight) termRatio := float64(maxWidth*pixelsPerColumn) / float64(maxHeight*pixelsPerRow) slog.Debug("imgRatio", "imgRatio", imgRatio, "termRatio", termRatio) if imgRatio > termRatio { dstCols := maxWidth dstRows := int(float64(dstCols*pixelsPerColumn) / imgRatio / float64(pixelsPerRow)) opts.DstCols = uint32(dstCols) //nolint:gosec // Terminal dimensions are bounded by maxWidth/maxHeight opts.DstRows = uint32(dstRows) //nolint:gosec // Terminal dimensions are bounded by maxWidth/maxHeight } else { dstRows := maxHeight dstCols := int(float64(dstRows*pixelsPerRow) * imgRatio / float64(pixelsPerColumn)) opts.DstRows = uint32(dstRows) //nolint:gosec // Terminal dimensions are bounded by maxWidth/maxHeight opts.DstCols = uint32(dstCols) //nolint:gosec // Terminal dimensions are bounded by maxWidth/maxHeight } // Write image using Kitty protocol if err := rasterm.KittyWriteImage(&buf, img, opts); err != nil { return "", err } // TODO: using internal/common package in pkg package is against the standards // We shouldn't use that here. // Other usage of common in `file_preview` should be removed too. // common.VideoExtensions should be moved to fixed_variables // and internal/common/utils shoud move to pkg/utils so that it can // be used by everyone // TODO : Ideally we should not need the kitty previewer to be // aware of full modal width and make decisions based on global config // A better solutions than this is needed for it. row := 1 col := sideAreaWidth + 1 if common.Config.EnableFilePreviewBorder { row++ col++ } buf.WriteString(fmt.Sprintf("\x1b[%d;%dH", row, col)) return buf.String(), nil } // IsKittyCapable checks if the terminal supports Kitty graphics protocol func (p *ImagePreviewer) IsKittyCapable() bool { return isKittyCapable() } ================================================ FILE: src/pkg/file_preview/thumbnail_generator.go ================================================ package filepreview import ( "context" "errors" "fmt" "log/slog" "os" "os/exec" "path/filepath" "strings" "sync" "time" "github.com/yorukot/superfile/src/internal/common" ) type thumbnailGeneratorInterface interface { supportsExt(ext string) bool generateThumbnail(inputPath string, outputPathWithoutExt string) (string, error) } type VideoGenerator struct{} func newVideoGenerator() (*VideoGenerator, error) { if !isFFmpegInstalled() { return nil, errors.New("ffmpeg is not installed") } return &VideoGenerator{}, nil } func (g *VideoGenerator) supportsExt(ext string) bool { return common.VideoExtensions[strings.ToLower(ext)] } func (g *VideoGenerator) generateThumbnail(inputPath string, outputPathWithoutExt string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), thumbGenerationTimeout) defer cancel() outputPath := outputPathWithoutExt + thumbOutputExt // ffmpeg -v warning -t 60 -hwaccel auto -an -sn -dn -skip_frame nokey -i input.mkv -vf scale='min(1024,iw)':'min(720,ih)':force_original_aspect_ratio=decrease:flags=fast_bilinear -vf "thumbnail" -frames:v 1 -y thumb.jpg ffmpeg := exec.CommandContext(ctx, "ffmpeg", "-v", "warning", // set log level to warning "-an", // disable Audio stream "-sn", // disable Subtitle stream "-dn", // disable data stream "-t", "180", // process maximum 180s of the video (the first 3 min) "-hwaccel", "auto", // Use Hardware Acceleration if available "-skip_frame", "nokey", // skip non-key frames "-i", inputPath, // set input file "-vf", "thumbnail", // use ffmpeg default thumbnail filter "-frames:v", "1", // output only one frame (one image) "-f", "image2", // set format to image2 "-fs", maxVideoFileSizeForThumb, // limit the max file size to match image previewer limit "-y", outputPath, // set the outputFile and overwrite it without confirmation if already exists ) err := ffmpeg.Run() if err != nil { return "", fmt.Errorf("error generating video thumbnail, outputPath: %s : %w", outputPath, err) } return outputPath, nil } type pdfGenerator struct{} func newPdfGenerator() (*pdfGenerator, error) { if !isPopplerInstalled() { return nil, errors.New("poppler is not installed") } return &pdfGenerator{}, nil } func (g *pdfGenerator) supportsExt(ext string) bool { return strings.ToLower(ext) == ".pdf" } func (g *pdfGenerator) generateThumbnail(inputPath string, outputPathWithoutExt string) (string, error) { outputPath := outputPathWithoutExt + thumbOutputExt ctx, cancel := context.WithTimeout(context.Background(), thumbGenerationTimeout) defer cancel() // pdftoppm -singlefile -png prefixFilename pdftoppm := exec.CommandContext(ctx, "pdftoppm", "-singlefile", // output only the first page as image "-jpeg", // Image extension inputPath, // Set input file outputPathWithoutExt, // The output prefix. (pdftoppm will add the .jpg ext) ) err := pdftoppm.Run() if err != nil { return "", fmt.Errorf("error generating pdf thumbnail, outputPath: %s : %w", outputPath, err) } return outputPath, nil } type ThumbnailGenerator struct { // This is a cache. Key -> Video file path, Value -> Thumbnail file path // TODO: We can potentially make it persistent, preventing generation // of thumbnail on every launch or superfile tempFilesCache map[string]string tempDirectory string mu sync.Mutex generators []thumbnailGeneratorInterface } func NewThumbnailGenerator() (*ThumbnailGenerator, error) { tmp, err := os.MkdirTemp("", "superfiles-*") if err != nil { return nil, err } generators := []thumbnailGeneratorInterface{} pdf, err := newPdfGenerator() if err != nil { slog.Debug("Error while trying to create pdfGenerator", "error", err) } else { generators = append(generators, pdf) } video, err := newVideoGenerator() if err != nil { slog.Debug("Error while trying to create videoGenerator", "error", err) } else { generators = append(generators, video) } thumbnailGenerator := &ThumbnailGenerator{ tempFilesCache: make(map[string]string), tempDirectory: tmp, generators: generators, } return thumbnailGenerator, nil } func (g *ThumbnailGenerator) SupportsExt(ext string) bool { for i := range g.generators { if g.generators[i].supportsExt(ext) { return true } } return false } func (g *ThumbnailGenerator) GetThumbnailOrGenerate(path string) (string, error) { g.mu.Lock() file, ok := g.tempFilesCache[path] g.mu.Unlock() if ok { _, err := os.Stat(file) if err == nil { return file, nil } g.mu.Lock() delete(g.tempFilesCache, path) g.mu.Unlock() } generatedThumbnailPath, err := g.generateThumbnail(path) if err != nil { return "", err } g.mu.Lock() g.tempFilesCache[path] = generatedThumbnailPath g.mu.Unlock() return generatedThumbnailPath, nil } func (g *ThumbnailGenerator) generateThumbnail(path string) (string, error) { fileExt := filepath.Ext(path) for index := range g.generators { generator := g.generators[index] if !generator.supportsExt(fileExt) { continue } filename := filepath.Base(path) baseName := filename[:len(filename)-len(fileExt)] outputPathWithoutExt := filepath.Join(g.tempDirectory, fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano())) outputPath, err := generator.generateThumbnail(path, outputPathWithoutExt) if err != nil { return "", err } return outputPath, nil } return "", errors.New("unsupported file format") } func (g *ThumbnailGenerator) CleanUp() error { return os.RemoveAll(g.tempDirectory) } func isPopplerInstalled() bool { _, err := exec.LookPath("pdftoppm") return err == nil } func isFFmpegInstalled() bool { _, err := exec.LookPath("ffmpeg") return err == nil } ================================================ FILE: src/pkg/file_preview/utils.go ================================================ package filepreview import ( "errors" "fmt" "image/color" "log/slog" "runtime" "strconv" "sync" ) // Terminal cell to pixel conversion constants // These approximate the pixel dimensions of terminal cells const ( DefaultPixelsPerColumn = 10 // approximate pixels per terminal column DefaultPixelsPerRow = 20 // approximate pixels per terminal row WindowsPixelsPerColumn = 8 // Windows Terminal/CMD typical width WindowsPixelsPerRow = 16 // Windows Terminal/CMD typical height ) // TerminalCellSize represents the pixel dimensions of terminal cells type TerminalCellSize struct { PixelsPerColumn int PixelsPerRow int } // TerminalCapabilities encapsulates terminal capability detection type TerminalCapabilities struct { cellSize TerminalCellSize cellSizeInit sync.Once detectionMutex sync.Mutex } // NewTerminalCapabilities creates a new TerminalCapabilities instance func NewTerminalCapabilities() *TerminalCapabilities { return &TerminalCapabilities{ cellSize: TerminalCellSize{ PixelsPerColumn: DefaultPixelsPerColumn, PixelsPerRow: DefaultPixelsPerRow, }, } } // InitTerminalCapabilities initializes all terminal capabilities detection // including cell size and Kitty Graphics Protocol support // This should be called early in the application startup func (tc *TerminalCapabilities) InitTerminalCapabilities() { // Use a goroutine to avoid blocking the application startup go func() { // Initialize cell size detection tc.cellSizeInit.Do(func() { tc.cellSize = tc.detectTerminalCellSize() slog.Info("Terminal cell size detection", "pixels_per_column", tc.cellSize.PixelsPerColumn, "pixels_per_row", tc.cellSize.PixelsPerRow) }) }() } // GetTerminalCellSize returns the current terminal cell size // If detection hasn't been initialized, it performs detection first func (tc *TerminalCapabilities) GetTerminalCellSize() TerminalCellSize { tc.cellSizeInit.Do(func() { tc.cellSize = tc.detectTerminalCellSize() slog.Info("Terminal cell size detection (lazy init)", "pixels_per_column", tc.cellSize.PixelsPerColumn, "pixels_per_row", tc.cellSize.PixelsPerRow) }) return tc.cellSize } // winsize struct for ioctl TIOCGWINSZ type winsize struct { Row uint16 Col uint16 Xpixel uint16 Ypixel uint16 } // detectTerminalCellSize detects the terminal cell size using ioctl system calls // This method is non-blocking and doesn't interfere with stdin func (tc *TerminalCapabilities) detectTerminalCellSize() TerminalCellSize { tc.detectionMutex.Lock() defer tc.detectionMutex.Unlock() // Try platform-specific detection if runtime.GOOS == "windows" { if cellSize, ok := getTerminalCellSizeWindows(); ok { slog.Info("Successfully detected terminal cell size on Windows", "pixels_per_column", cellSize.PixelsPerColumn, "pixels_per_row", cellSize.PixelsPerRow) return cellSize } } else { // Unix-like systems (Linux, macOS, etc.) if cellSize, ok := getTerminalCellSizeViaIoctl(); ok { slog.Info("Successfully detected terminal cell size via ioctl", "pixels_per_column", cellSize.PixelsPerColumn, "pixels_per_row", cellSize.PixelsPerRow) return cellSize } } // Fallback to default values slog.Info("Using default terminal cell size", "os", runtime.GOOS) return getDefaultCellSize() } // getDefaultCellSize returns default fallback terminal cell size func getDefaultCellSize() TerminalCellSize { return TerminalCellSize{ PixelsPerColumn: DefaultPixelsPerColumn, PixelsPerRow: DefaultPixelsPerRow, } } // InitTerminalCapabilities initializes terminal capabilities for the ImagePreviewer func (p *ImagePreviewer) InitTerminalCapabilities() { p.terminalCap.InitTerminalCapabilities() } // Windows-specific terminal detection functions // getTerminalCellSizeWindows uses Windows Console API to detect terminal cell size func getTerminalCellSizeWindows() (TerminalCellSize, bool) { if runtime.GOOS != "windows" { return TerminalCellSize{}, false } // For Windows, just return reasonable defaults // Windows terminal detection is complex and varies greatly between // different terminal emulators (Windows Terminal, ConEmu, etc.) slog.Info("Using Windows default terminal cell size") // TODO: Implement actual Windows Console API calls when running on Windows return getWindowsDefaultCellSize(), true } // getWindowsDefaultCellSize returns reasonable defaults for Windows func getWindowsDefaultCellSize() TerminalCellSize { return TerminalCellSize{ PixelsPerColumn: WindowsPixelsPerColumn, // Windows Terminal/CMD typical width PixelsPerRow: WindowsPixelsPerRow, // Windows Terminal/CMD typical height } } func hexToColor(hex string) (color.RGBA, error) { if len(hex) != 7 || hex[0] != '#' { return color.RGBA{}, errors.New("invalid hex color format") } values, err := strconv.ParseUint(hex[1:], 16, 32) if err != nil { return color.RGBA{}, err } return color.RGBA{ R: uint8(values >> rgbShift16), //nolint:gosec // RGB values are masked to 8-bit range G: uint8((values >> rgbShift8) & rgbMask), //nolint:gosec // RGB values are masked to 8-bit range B: uint8(values & rgbMask), //nolint:gosec // RGB values are masked to 8-bit range A: alphaOpaque, }, nil } func colorToHex(color color.Color) string { r, g, b, _ := color.RGBA() return fmt.Sprintf( "#%02x%02x%02x", uint8(r>>rgbShift8), //nolint:gosec // RGBA() returns 16-bit values, shifting by 8 gives 8-bit uint8(g>>rgbShift8), //nolint:gosec // RGBA() returns 16-bit values, shifting by 8 gives 8-bit uint8(b>>rgbShift8), //nolint:gosec // RGBA() returns 16-bit values, shifting by 8 gives 8-bit ) } ================================================ FILE: src/pkg/file_preview/utils_unix.go ================================================ //go:build !windows // +build !windows package filepreview import ( "syscall" "unsafe" ) // getTerminalCellSizeViaIoctl uses ioctl system call to get terminal size func getTerminalCellSizeViaIoctl() (TerminalCellSize, bool) { // Try different file descriptors in order of preference fds := []uintptr{ 1, // stdout 0, // stdin 2, // stderr } for _, fd := range fds { if cellSize, ok := getTerminalSizeFromFd(fd); ok { return cellSize, true } } return TerminalCellSize{}, false } // getTerminalSizeFromFd gets terminal size from a specific file descriptor func getTerminalSizeFromFd(fd uintptr) (TerminalCellSize, bool) { var ws winsize // TIOCGWINSZ ioctl call to get window size _, _, errno := syscall.Syscall( syscall.SYS_IOCTL, fd, syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&ws)), ) if errno != 0 { return TerminalCellSize{}, false } // Check if we got valid pixel dimensions if ws.Xpixel > 0 && ws.Ypixel > 0 && ws.Col > 0 && ws.Row > 0 { pixelsPerColumn := int(ws.Xpixel) / int(ws.Col) pixelsPerRow := int(ws.Ypixel) / int(ws.Row) // Sanity check the values if pixelsPerColumn > 0 && pixelsPerRow > 0 && pixelsPerColumn < 100 && pixelsPerRow < 100 { return TerminalCellSize{ PixelsPerColumn: pixelsPerColumn, PixelsPerRow: pixelsPerRow, }, true } } return TerminalCellSize{}, false } ================================================ FILE: src/pkg/file_preview/utils_windows.go ================================================ //go:build windows // +build windows package filepreview // getTerminalCellSizeViaIoctl is not supported on Windows, so always return false func getTerminalCellSizeViaIoctl() (TerminalCellSize, bool) { return TerminalCellSize{}, false } ================================================ FILE: src/pkg/string_function/overplace.go ================================================ // ====================== overplace these is from the lipgloss PR ============== // These code is from the https://github.com/charmbracelet/lipgloss/pull/102 // Thanks a lot!!!!! // Edit - cutLeft has been replaced with charmansi.TruncateLeft. // See https://github.com/charmbracelet/lipgloss/pull/102#issuecomment-2900110821 // ============================================================================= package stringfunction import ( "strings" charmansi "github.com/charmbracelet/x/ansi" ansi "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/truncate" "github.com/muesli/termenv" ) // whitespace is a whitespace renderer. type whitespace struct { style termenv.Style chars string } type WhitespaceOption func(*whitespace) // Render whitespaces. func (w whitespace) render(width int) string { if w.chars == "" { w.chars = " " } r := []rune(w.chars) j := 0 b := strings.Builder{} // Cycle through runes and print them into the whitespace. for i := 0; i < width; { b.WriteRune(r[j]) j++ if j >= len(r) { j = 0 } i += charmansi.StringWidth(string(r[j])) } // Fill any extra gaps white spaces. This might be necessary if any runes // are more than one cell wide, which could leave a one-rune gap. short := width - charmansi.StringWidth(b.String()) if short > 0 { b.WriteString(strings.Repeat(" ", short)) } return w.style.Styled(b.String()) } // PlaceOverlay places fg on top of bg. func PlaceOverlay(x, y int, fg, bg string, opts ...WhitespaceOption) string { fgLines, fgWidth := getLines(fg) bgLines, bgWidth := getLines(bg) bgHeight := len(bgLines) fgHeight := len(fgLines) if fgWidth >= bgWidth && fgHeight >= bgHeight { // FIXME: return fg or bg? return fg } // TODO: allow placement outside of the bg box? x = clamp(x, 0, bgWidth-fgWidth) y = clamp(y, 0, bgHeight-fgHeight) ws := &whitespace{} for _, opt := range opts { opt(ws) } var b strings.Builder for i, bgLine := range bgLines { if i > 0 { b.WriteByte('\n') } if i < y || i >= y+fgHeight { b.WriteString(bgLine) continue } pos := 0 if x > 0 { left := truncate.String(bgLine, uint(x)) pos = ansi.PrintableRuneWidth(left) b.WriteString(left) if pos < x { b.WriteString(ws.render(x - pos)) pos = x } } fgLine := fgLines[i-y] b.WriteString(fgLine) pos += ansi.PrintableRuneWidth(fgLine) right := charmansi.TruncateLeft(bgLine, pos, "") bgWidth = ansi.PrintableRuneWidth(bgLine) rightWidth := ansi.PrintableRuneWidth(right) if rightWidth <= bgWidth-pos { b.WriteString(ws.render(bgWidth - rightWidth - pos)) } b.WriteString(right) } return b.String() } func clamp(v, lower, upper int) int { return min(max(v, lower), upper) } // Split a string into lines, additionally returning the size of the widest // line. func getLines(s string) ([]string, int) { lines := strings.Split(s, "\n") widest := 0 for _, l := range lines { w := charmansi.StringWidth(l) if widest < w { widest = w } } return lines, widest } ================================================ FILE: src/pkg/utils/README.md ================================================ # utils package Independent utilities with zero dependencies with other packages ================================================ FILE: src/pkg/utils/bool_file_store.go ================================================ package utils import ( "log/slog" "os" "strconv" ) // This file provides utilities for storing boolean values in a file // Read file with "true" / "false" as content. In case of issues, return defaultValue func ReadBoolFile(path string, defaultValue bool) bool { data, err := os.ReadFile(path) if err != nil { slog.Error("Error in readBoolFile", "path", path, "error", err) return defaultValue } // Not using strconv.ParseBool() as it allows other values like : "TRUE" // Using exact string comparison with predefined constants ensures // consistent behavior and prevents issues with case-insensitivity or // unexpected values like "yes", "on", etc. that ParseBool would accept switch string(data) { case TrueString: return true case FalseString: return false default: return defaultValue } } func WriteBoolFile(path string, value bool) error { return os.WriteFile(path, []byte(strconv.FormatBool(value)), ConfigFilePerm) } ================================================ FILE: src/pkg/utils/bool_file_store_test.go ================================================ package utils import ( "os" "path/filepath" "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestReadBoolFile(t *testing.T) { tempDir := t.TempDir() tests := []struct { name string fileContent string defaultValue bool createFile bool expected bool }{ { name: "file contains true", fileContent: TrueString, defaultValue: false, createFile: true, expected: true, }, { name: "file contains false", fileContent: FalseString, defaultValue: true, createFile: true, expected: false, }, { name: "file contains invalid value", fileContent: "invalid", defaultValue: true, createFile: true, expected: true, }, { name: "file contains invalid value with default false", fileContent: "invalid", defaultValue: false, createFile: true, expected: false, }, { name: "file contains TRUE (uppercase)", fileContent: "TRUE", defaultValue: false, createFile: true, expected: false, // Should not accept uppercase }, { name: "file contains empty string", fileContent: "", defaultValue: true, createFile: true, expected: true, }, { name: "file does not exist - default true", fileContent: "", defaultValue: true, createFile: false, expected: true, }, { name: "file does not exist - default false", fileContent: "", defaultValue: false, createFile: false, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create a unique file path for each test filePath := filepath.Join(tempDir, tt.name+".txt") // Create and write to the file if needed if tt.createFile { err := os.WriteFile(filePath, []byte(tt.fileContent), 0644) require.NoError(t, err) } // Call the function result := ReadBoolFile(filePath, tt.defaultValue) // Assert the result assert.Equal(t, tt.expected, result) }) } } func TestWriteBoolFile(t *testing.T) { tempDir := t.TempDir() tests := []struct { name string value bool }{ { name: "write true value", value: true, }, { name: "write false value", value: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create a unique file path for each test filePath := filepath.Join(tempDir, tt.name+".txt") // Call the function err := WriteBoolFile(filePath, tt.value) require.NoError(t, err) // Verify file content content, err := os.ReadFile(filePath) require.NoError(t, err) expected := FalseString if tt.value { expected = TrueString } assert.Equal(t, expected, string(content)) // Verify permissions (Unix only) if runtime.GOOS != OsWindows { info, err := os.Stat(filePath) require.NoError(t, err) assert.Equal(t, os.FileMode(ConfigFilePerm), info.Mode().Perm()) } }) } } func TestWriteBoolFileError(t *testing.T) { tempDir := t.TempDir() nonExistentDir := filepath.Join(tempDir, "non_existent_dir", "file.txt") err := WriteBoolFile(nonExistentDir, true) assert.Error(t, err) } func TestReadBoolFilePermissionDenied(t *testing.T) { // Skip on Windows as permission handling differs if runtime.GOOS == OsWindows { t.Skip("Skipping permission test on Windows") } // Skip when running as root since root can read files with 000 permissions if os.Geteuid() == 0 { t.Skip("Skipping permission test when running as root") } tempDir := t.TempDir() // Create a file filePath := filepath.Join(tempDir, "no_read_perm.txt") err := os.WriteFile(filePath, []byte(TrueString), ConfigFilePerm) require.NoError(t, err) // Remove read permissions err = os.Chmod(filePath, 0) require.NoError(t, err) defer os.Chmod(filePath, ConfigFilePerm) // Reset permissions for cleanup // The function should return the default value when it can't read the file result := ReadBoolFile(filePath, false) assert.False(t, result) result = ReadBoolFile(filePath, true) assert.True(t, result) } func TestWriteBoolFilePermissionDenied(t *testing.T) { // Skip on Windows as permission handling differs if runtime.GOOS == OsWindows { t.Skip("Skipping permission test on Windows") } // Skip when running as root since root can write to read-only directories if os.Geteuid() == 0 { t.Skip("Skipping permission test when running as root") } tempDir := t.TempDir() // Make the directory read-only err := os.Chmod(tempDir, 0500) require.NoError(t, err) defer os.Chmod(tempDir, 0700) // Reset permissions for cleanup filePath := filepath.Join(tempDir, "readonly.txt") err = WriteBoolFile(filePath, true) assert.Error(t, err) } func TestReadBoolFile_CornerCases(t *testing.T) { tempDir := t.TempDir() // Test with a directory instead of a file dirPath := filepath.Join(tempDir, "directory") err := os.Mkdir(dirPath, 0755) require.NoError(t, err) // Should return the default value when path is a directory result := ReadBoolFile(dirPath, true) assert.True(t, result) } ================================================ FILE: src/pkg/utils/config_interface.go ================================================ package utils // MissingFieldIgnorer defines the interface for configuration types that can ignore missing fields // during TOML file loading. Types implementing this interface can control whether missing field // warnings are suppressed when parsing configuration files. type MissingFieldIgnorer interface { GetIgnoreMissingFields() bool } ================================================ FILE: src/pkg/utils/consts.go ================================================ package utils const ( TrueString = "true" FalseString = "false" // These are used while comparing with runtime.GOOS // OsWindows represents the Windows operating system identifier OsWindows = "windows" // OsDarwin represents the macOS (Darwin) operating system identifier OsDarwin = "darwin" OsLinux = "linux" // File permissions ConfigFilePerm = 0600 // configuration files (owner read/write only) UserFilePerm = 0644 // user-created files (owner rw, others r) LogFilePerm = 0600 // log files (owner read/write only) // Directory permissions ConfigDirPerm = 0700 // configuration directories (owner only) UserDirPerm = 0755 // user-created directories (owner rwx, others rx) // Extracted file permissions (from archives) ExtractedFileMode = 0644 // extracted files ExtractedDirMode = 0755 // extracted directories // Sidebar sections SidebarSectionHome = "home" SidebarSectionPinned = "pinned" SidebarSectionDisks = "disks" ) ================================================ FILE: src/pkg/utils/detach_unix.go ================================================ //go:build !windows package utils import ( "os/exec" "syscall" ) func DetachFromTerminal(cmd *exec.Cmd) { // Start new session so child isn't tied to the TTY (prevents SIGHUP on terminal close). // This also prevents programs like sudo to read/write to tty cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // Optionally, redirect stdio to avoid terminal hangups cmd.Stdin = nil cmd.Stdout = nil cmd.Stderr = nil } ================================================ FILE: src/pkg/utils/detach_windows.go ================================================ //go:build windows package utils import "os/exec" func DetachFromTerminal(cmd *exec.Cmd) { // No-op: current Windows path uses rundll32 and returns immediately. // If needed later, set CreationFlags/HideWindow via syscall.SysProcAttr. } ================================================ FILE: src/pkg/utils/error.go ================================================ package utils import ( "errors" ) type TomlLoadError struct { userMessage string wrappedError error isFatal bool missingFields bool } func (t *TomlLoadError) Error() string { res := t.userMessage if t.wrappedError != nil { res += " : " + t.wrappedError.Error() } return res } func (t *TomlLoadError) IsFatal() bool { return t.isFatal } func (t *TomlLoadError) MissingFields() bool { return t.missingFields } func (t *TomlLoadError) Unwrap() error { return t.wrappedError } func (t *TomlLoadError) UpdateMessageAndError(msg string, err error) { t.userMessage = msg t.wrappedError = err } // Include another msg. For now we dont need to have this as wrapped error. func (t *TomlLoadError) AddMessageAndError(msg string, err error) { t.userMessage += " " + msg t.wrappedError = errors.Join(t.wrappedError, err) } ================================================ FILE: src/pkg/utils/file_utils.go ================================================ package utils import ( "bufio" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "reflect" "strings" "github.com/adrg/xdg" "github.com/pelletier/go-toml/v2" "github.com/charmbracelet/x/ansi" "golang.org/x/text/encoding/unicode" "golang.org/x/text/transform" ) // Utility functions related to file operations // Note : This is not used anymore as we use os.WriteAt to // fix toml files now, but we will still keep it for later use. func WriteTomlData(filePath string, data interface{}) error { tomlData, err := toml.Marshal(data) if err != nil { // return a wrapped error return fmt.Errorf("error encoding data : %w", err) } err = os.WriteFile(filePath, tomlData, ConfigFilePerm) if err != nil { return fmt.Errorf("error writing file : %w", err) } return nil } // Helper function to load and validate TOML files with field checking // errorPrefix is appended before every error message func LoadTomlFile(filePath string, defaultData string, target interface{}, fixFlag bool, ignoreMissingFields bool) error { // Initialize with default config _ = toml.Unmarshal([]byte(defaultData), target) data, err := os.ReadFile(filePath) if err != nil { return &TomlLoadError{ userMessage: "config file doesn't exist", wrappedError: err, } } // Create a map to track which fields are present var rawData map[string]interface{} err = toml.Unmarshal(data, &rawData) if err != nil { return &TomlLoadError{ userMessage: "error decoding TOML file", wrappedError: err, isFatal: true, } } // Replace default values with file values err = toml.Unmarshal(data, target) if err != nil { var decodeErr *toml.DecodeError if errors.As(err, &decodeErr) { row, col := decodeErr.Position() return &TomlLoadError{ userMessage: fmt.Sprintf("error in field at line %d column %d", row, col), wrappedError: decodeErr, isFatal: true, } } return &TomlLoadError{ userMessage: "error unmarshalling data", wrappedError: err, isFatal: true, } } // Override the default value if it exists default value to false if config, ok := target.(MissingFieldIgnorer); ok { ignoreMissingFields = config.GetIgnoreMissingFields() } // Check for missing fields targetType := reflect.TypeOf(target).Elem() missingFields := []string{} for i := range targetType.NumField() { field := targetType.Field(i) var fieldName string tag := field.Tag.Get("toml") if tag != "" { // Discard options such as ",omitempty" fieldName = strings.Split(tag, ",")[0] } else { fieldName = field.Name } // Skip open_with field as it's an optional table if fieldName == "open_with" { continue } if _, exists := rawData[fieldName]; !exists { missingFields = append(missingFields, fieldName) } } if len(missingFields) == 0 { return nil } if !fixFlag && ignoreMissingFields { // nil error if we dont wanna fix, and dont wanna print return nil } resultErr := &TomlLoadError{ missingFields: true, } if !fixFlag { resultErr.userMessage = fmt.Sprintf("missing fields: %v", missingFields) return resultErr } // Start fixing return fixTomlFile(resultErr, filePath, target) } func fixTomlFile(resultErr *TomlLoadError, filePath string, target interface{}) error { resultErr.isFatal = true // Create a unique backup of the current config file backupFile, err := os.CreateTemp(filepath.Dir(filePath), filepath.Base(filePath)+".bak-") if err != nil { resultErr.UpdateMessageAndError("failed to create backup file", err) return resultErr } backupPath := backupFile.Name() needsBackupFileRemoval := true defer func() { if closeErr := backupFile.Close(); closeErr != nil { if resultErr.wrappedError == nil { resultErr.UpdateMessageAndError("failed to close backup file", closeErr) } } // Remove backup in case of unsuccessful write if needsBackupFileRemoval { if errRem := os.Remove(backupPath); errRem != nil { // Modify result Error resultErr.AddMessageAndError("warning: failed to remove backup file, backupPath : "+backupPath, errRem) } } }() // Copy the original file to the backup // Open it in read write mode origFile, err := os.OpenFile(filePath, os.O_RDWR, ConfigFilePerm) if err != nil { resultErr.UpdateMessageAndError("failed to open original file for backup", err) return resultErr } defer origFile.Close() _, err = io.Copy(backupFile, origFile) if err != nil { resultErr.UpdateMessageAndError("failed to copy original file to backup", err) return resultErr } tomlData, err := toml.Marshal(target) if err != nil { resultErr.UpdateMessageAndError("failed to marshal config to TOML", err) return resultErr } _, err = origFile.WriteAt(tomlData, 0) if err != nil { resultErr.UpdateMessageAndError("failed to write TOML data to original file", err) return resultErr } // Fix done // Inform user about backup location resultErr.userMessage = "config file had issues. Its fixed successfully. Original backed up to : " + backupPath resultErr.isFatal = false // Do not remove backup; user may want to restore manually needsBackupFileRemoval = false return resultErr } // If path is not absolute, then append to currentDir and get absolute path // Resolve paths starting with "~" // currentDir should be an absolute path func ResolveAbsPath(currentDir string, path string) string { if !filepath.IsAbs(currentDir) { slog.Warn("currentDir is not absolute", "currentDir", currentDir) } if strings.HasPrefix(path, "~") { // We dont use variable.HomeDir here, as the util package cannot have dependency // on variable package path = strings.Replace(path, "~", xdg.Home, 1) } if !filepath.IsAbs(path) { path = filepath.Join(currentDir, path) } return filepath.Clean(path) } // Get directory total size // TODO: Uni test this func DirSize(path string) int64 { var size int64 // Its named walkErr to prevent shadowing walkErr := filepath.WalkDir(path, func(_ string, entry os.DirEntry, err error) error { if err != nil { slog.Error("Dir size function error", "error", err) } if !entry.IsDir() { info, infoErr := entry.Info() if infoErr == nil { size += info.Size() } } return err }) if walkErr != nil { slog.Error("errors during WalkDir", "error", walkErr) } return size } // Helper functions // Create all dirs that does not already exists func CreateDirectories(dirs ...string) error { for _, dir := range dirs { if dir == "" { continue } if err := os.MkdirAll(dir, ConfigDirPerm); err != nil { return fmt.Errorf("failed to create directory %s: %w", dir, err) } } return nil } // Create all files if they do not exists yet func CreateFiles(files ...string) error { for _, file := range files { if _, err := os.Stat(file); os.IsNotExist(err) { if err = os.WriteFile(file, nil, ConfigFilePerm); err != nil { return fmt.Errorf("failed to create file %s: %w", file, err) } } } return nil } func ReadFileContent(filepath string, maxLineLength int, previewLine int) (string, error) { var resultBuilder strings.Builder file, err := os.Open(filepath) if err != nil { return resultBuilder.String(), err } defer file.Close() reader := transform.NewReader(file, unicode.BOMOverride(unicode.UTF8.NewDecoder())) scanner := bufio.NewScanner(reader) lineCount := 0 for scanner.Scan() { line := scanner.Text() line = ansi.Truncate(line, maxLineLength, "") resultBuilder.WriteString(line) resultBuilder.WriteRune('\n') lineCount++ if previewLine > 0 && lineCount >= previewLine { break } } // returns the first non-EOF error that was encountered by the [Scanner] return resultBuilder.String(), scanner.Err() } func InitJSONFile(path string) error { if _, err := os.Stat(path); os.IsNotExist(err) { if err = os.WriteFile(path, []byte("null"), ConfigFilePerm); err != nil { return fmt.Errorf("failed to initialize json file %s: %w", path, err) } } return nil } ================================================ FILE: src/pkg/utils/file_utils_test.go ================================================ package utils import ( "fmt" "os" "path/filepath" "runtime" "strings" "testing" "github.com/adrg/xdg" "github.com/pelletier/go-toml/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestResolveAbsPath(t *testing.T) { sep := string(filepath.Separator) dir1 := "abc" dir2 := "def" absPrefix := "" if runtime.GOOS == "windows" { absPrefix = "C:" // Windows absolute path prefix } root := absPrefix + sep testdata := []struct { name string cwd string path string expectedRes string }{ { name: "Path cleaup Test 1", cwd: absPrefix + sep, path: absPrefix + strings.Repeat(sep, 10), expectedRes: absPrefix + sep, }, { name: "Basic test", cwd: filepath.Join(root, dir1), path: dir2, expectedRes: filepath.Join(root, dir1, dir2), }, { name: "Ignore cwd for abs path", cwd: filepath.Join(root, dir1), path: filepath.Join(root, dir2), expectedRes: filepath.Join(root, dir2), }, { name: "Path cleanup Test 2", cwd: absPrefix + strings.Repeat(sep, 4) + dir1, path: "." + sep + "." + sep + dir2, expectedRes: filepath.Join(root, dir1, dir2), }, { name: "Basic test with ~", cwd: root, path: "~", expectedRes: xdg.Home, }, { name: "~ should not be resolved if not first", cwd: dir1, path: filepath.Join(dir2, "~"), expectedRes: filepath.Join(dir1, dir2, "~"), }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expectedRes, ResolveAbsPath(tt.cwd, tt.path)) }) } } // We cannot use ConfigType here, as that is not accessible by "utils" package func TestLoadTomlFile(t *testing.T) { _, curFilename, _, ok := runtime.Caller(0) require.True(t, ok) testdataDir := filepath.Join(filepath.Dir(curFilename), "testdata", "load_toml") defaultDataBytes, err := os.ReadFile(filepath.Join(testdataDir, "default.toml")) require.NoError(t, err) defaultData := string(defaultDataBytes) var defaultTomlVal TestTOMLType err = toml.Unmarshal(defaultDataBytes, &defaultTomlVal) require.NoError(t, err) testdata := []struct { name string // Relative to corr configName string fixFlag bool noError bool // If we have error. It should be TomlLoadError expectedError *TomlLoadError // For checking the result value checkTomlVal bool expectedTomlVal TestTOMLType }{ { name: "Config1 Load Default", configName: "default.toml", fixFlag: false, noError: true, checkTomlVal: true, expectedTomlVal: defaultTomlVal, }, { name: "Config1 Missing fields", configName: "missing_str.toml", fixFlag: false, noError: false, expectedError: &TomlLoadError{ userMessage: "missing fields: [sample_str]", wrappedError: nil, isFatal: false, missingFields: true, }, checkTomlVal: true, expectedTomlVal: defaultTomlVal, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { var tomlVal TestTOMLType err = LoadTomlFile(filepath.Join(testdataDir, tt.configName), defaultData, &tomlVal, tt.fixFlag, false) if tt.noError { require.NoError(t, err) } else { assert.Equal(t, tt.expectedError, err) } if tt.checkTomlVal { assert.Equal(t, tt.expectedTomlVal, tomlVal) } }) } } func TestLoadTomlFileIgnorer(t *testing.T) { _, curFilename, _, ok := runtime.Caller(0) require.True(t, ok) testdataDir := filepath.Join(filepath.Dir(curFilename), "testdata", "load_toml", "ignorer") defaultDataBytes, err := os.ReadFile(filepath.Join(testdataDir, "default.toml")) require.NoError(t, err) defaultData := string(defaultDataBytes) var defaultTomlVal TestTOMLMissingIgnorerType err = toml.Unmarshal(defaultDataBytes, &defaultTomlVal) require.NoError(t, err) // This is for Ignorer Type testdata := []struct { name string // Relative to corr configName string fixFlag bool noError bool // If we have error. It should be TomlLoadError expectedError *TomlLoadError verifyWrappedErr bool // For checking the result value checkTomlVal bool expectedTomlVal TestTOMLMissingIgnorerType }{ { name: "Config2 Load Default", configName: "default.toml", fixFlag: false, noError: true, checkTomlVal: true, expectedTomlVal: defaultTomlVal, }, { name: "Config2 Extra Fields ignored", configName: "default_extra_fields.toml", fixFlag: false, noError: true, checkTomlVal: true, expectedTomlVal: defaultTomlVal, }, { name: "Config2 Missing fields Not Ignored", configName: "missing_str_int.toml", fixFlag: false, noError: false, expectedError: &TomlLoadError{ userMessage: "missing fields: [sample_int sample_str]", wrappedError: nil, isFatal: false, missingFields: true, }, checkTomlVal: true, expectedTomlVal: defaultTomlVal, }, { name: "Config2 Missing fields Ignored", configName: "missing_str_ignore.toml", fixFlag: false, noError: true, checkTomlVal: true, expectedTomlVal: defaultTomlVal.WithIgnoreMissing(true), }, { name: "Config2 Non Existent config", configName: "non_existent_config.toml", fixFlag: false, noError: false, expectedError: &TomlLoadError{ userMessage: "config file doesn't exist", }, verifyWrappedErr: false, checkTomlVal: false, }, { name: "Config2 Invalid format", configName: "invalid_format.toml", fixFlag: false, noError: false, expectedError: &TomlLoadError{ userMessage: "error decoding TOML file", isFatal: true, }, verifyWrappedErr: false, checkTomlVal: false, }, { name: "Config2 Invalid Value Type", configName: "invalid_value_type.toml", fixFlag: false, noError: false, expectedError: &TomlLoadError{ userMessage: "error in field at line 2 column 14", isFatal: true, }, verifyWrappedErr: false, checkTomlVal: false, }, } for _, tt := range testdata { t.Run(tt.name, func(t *testing.T) { var tomlVal TestTOMLMissingIgnorerType err := LoadTomlFile(filepath.Join(testdataDir, tt.configName), defaultData, &tomlVal, tt.fixFlag, false) if tt.noError { require.NoError(t, err) } else { var tomlErr *TomlLoadError require.ErrorAs(t, err, &tomlErr) if tt.verifyWrappedErr { assert.Equal(t, tt.expectedError, tomlErr) } else { assert.Equal(t, tt.expectedError.userMessage, tomlErr.userMessage) assert.Equal(t, tt.expectedError.isFatal, tomlErr.isFatal) assert.Equal(t, tt.expectedError.missingFields, tomlErr.missingFields) } } if tt.checkTomlVal { assert.Equal(t, tt.expectedTomlVal, tomlVal) } }) } // Tests for fixing config file t.Run("Config2 Fixing config file", func(t *testing.T) { // To make sure that other values are kept. expectedVal1 := defaultTomlVal expectedVal2 := defaultTomlVal expectedVal1.SampleInt = -1 expectedVal2.SampleInt = -1 expectedVal2.IgnoreMissing = true tempDir := t.TempDir() actualTest := func(fileName string, expectedVal TestTOMLMissingIgnorerType) { var tomlVal TestTOMLMissingIgnorerType testFile := filepath.Join(testdataDir, fileName) orgFile := filepath.Join(tempDir, fileName) testContent, err := os.ReadFile(testFile) require.NoError(t, err) // Copy to temp directory first to avoid permission errors err = os.WriteFile(orgFile, testContent, 0644) require.NoError(t, err, "Error writing config file to temp directory") err = LoadTomlFile(orgFile, defaultData, &tomlVal, true, false) var tomlErr *TomlLoadError require.ErrorAs(t, err, &tomlErr) assert.True(t, tomlErr.missingFields) assert.Equal(t, expectedVal, tomlVal) pref := "config file had issues. Its fixed successfully. Original backed up to : " assert.True(t, strings.HasPrefix(tomlErr.userMessage, pref), "Unexpectd error : "+tomlErr.Error()) backupFile := strings.TrimPrefix(tomlErr.userMessage, pref) assert.FileExists(t, backupFile) backupContent, err := os.ReadFile(backupFile) require.NoError(t, err) assert.Equal(t, testContent, backupContent) // Validate that if you Load Original File again, it loads without any errors err = LoadTomlFile(orgFile, defaultData, &tomlVal, true, false) require.NoError(t, err) err = os.WriteFile(orgFile, backupContent, 0644) require.NoError(t, err) } actualTest("missing_str2.toml", expectedVal1) actualTest("missing_str_ignore2.toml", expectedVal2) }) } func TestReadFileContent(t *testing.T) { testDir := t.TempDir() curTestDir := filepath.Join(testDir, "TestReadFileContent") SetupDirectories(t, curTestDir) testdata := []struct { name string content []byte maxLineLength int previewLine int expected string }{ { name: "regular UTF-8 file", content: []byte("line1\nline2\nline3"), maxLineLength: 100, previewLine: 5, expected: "line1\nline2\nline3\n", }, { name: "UTF-8 BOM file", content: []byte("\xEF\xBB\xBFline1\nline2\nline3"), maxLineLength: 100, previewLine: 5, expected: "line1\nline2\nline3\n", }, { name: "limited preview lines", content: []byte("line1\nline2\nline3\nline4"), maxLineLength: 100, previewLine: 2, expected: "line1\nline2\n", }, } for i, tt := range testdata { t.Run(tt.name, func(t *testing.T) { testFile := filepath.Join(curTestDir, fmt.Sprintf("test_file_%d.txt", i)) SetupFilesWithData(t, tt.content, testFile) result, err := ReadFileContent(testFile, tt.maxLineLength, tt.previewLine) require.NoError(t, err) assert.Equal(t, tt.expected, result) }) } } func TestReadFileContentBOMHandling(t *testing.T) { testDir := t.TempDir() curTestDir := filepath.Join(testDir, "TestBOMHandling") SetupDirectories(t, curTestDir) // Write a file prefixed with UTF-8 BOM bomContent := []byte("\xEF\xBB\xBFHello, World!\nSecond line") bomFile := filepath.Join(curTestDir, "bom_file.txt") SetupFilesWithData(t, bomContent, bomFile) result, err := ReadFileContent(bomFile, 100, 10) require.NoError(t, err) // Verify BOM is removed and content is correct assert.True(t, strings.HasPrefix(result, "Hello, World!"), "Content should start with expected text, got: %q", result) assert.NotContains(t, result, "\uFEFF", "BOM character should be removed from output: %q", result) } ================================================ FILE: src/pkg/utils/fzf_utils.go ================================================ package utils import "github.com/reinhrst/fzf-lib" // Returning a string slice causes inefficiency in current usage func FzfSearch(query string, source []string) []fzf.MatchResult { fzfSearcher := fzf.New(source, fzf.DefaultOptions()) fzfSearcher.Search(query) // TODO : This is a blocking call, which will cause the UI to freeze if the query is slow. // Need to put a timeout on this fzfResults := <-fzfSearcher.GetResultChannel() fzfSearcher.End() return fzfResults.Matches } ================================================ FILE: src/pkg/utils/log_utils.go ================================================ package utils import ( "fmt" "log/slog" "os" ) // Print line to stderr and exit with status 1 // Cannot use log.Fataln() as slog.SetDefault() causes those lines to // go into log file func PrintlnAndExit(args ...any) { fmt.Fprintln(os.Stderr, args...) os.Exit(1) } // Print formatted output line to stderr and exit with status 1 // Cannot use log.Fataln() as slog.SetDefault() causes those lines to // go into log file func PrintfAndExitf(format string, args ...any) { fmt.Fprintf(os.Stderr, format, args...) os.Exit(1) } // Used in unit test func SetRootLoggerToStdout(debug bool) { level := slog.LevelInfo if debug { level = slog.LevelDebug } slog.SetDefault(slog.New(slog.NewTextHandler( os.Stdout, &slog.HandlerOptions{Level: level}))) } // Used in unit test func SetRootLoggerToDiscarded() { slog.SetDefault(slog.New(slog.DiscardHandler)) } ================================================ FILE: src/pkg/utils/shell_utils.go ================================================ package utils import ( "context" "errors" "fmt" "log/slog" "os/exec" "runtime" "time" ) // Choose correct shell as per OS func ExecuteCommandInShell(timeLimit time.Duration, cmdDir string, shellCommand string) (int, string, error) { // Linux and Darwin baseCmd := "/bin/sh" args := []string{"-c", shellCommand} if runtime.GOOS == OsWindows { baseCmd = "powershell.exe" args[0] = "-Command" } return ExecuteCommand(timeLimit, cmdDir, baseCmd, args...) } func ExecuteCommand(timeLimit time.Duration, cmdDir string, baseCmd string, args ...string) (int, string, error) { ctx, cancel := context.WithTimeout(context.Background(), timeLimit) defer cancel() cmd := exec.CommandContext(ctx, baseCmd, args...) cmd.Dir = cmdDir DetachFromTerminal(cmd) outputBytes, err := cmd.CombinedOutput() retCode := -1 if errors.Is(ctx.Err(), context.DeadlineExceeded) { slog.Error("User's command timed out", "outputBytes", outputBytes, "cmd error", err, "ctx error", ctx.Err()) return retCode, string(outputBytes), ctx.Err() } if err == nil { retCode = 0 } else if exitErr, ok := err.(*exec.ExitError); ok { //nolint: errorlint // We dont expect error to be Wrapped here retCode = exitErr.ExitCode() } else { err = fmt.Errorf("unexpected Error in command execution : %w", err) } return retCode, string(outputBytes), err } ================================================ FILE: src/pkg/utils/tea_utils.go ================================================ package utils import tea "github.com/charmbracelet/bubbletea" func TeaRuneKeyMsg(msg string) tea.KeyMsg { return tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune(msg), } } ================================================ FILE: src/pkg/utils/test_utils.go ================================================ package utils import ( "os" "testing" "github.com/stretchr/testify/require" ) var SampleDataBytes = []byte("This is sample") //nolint: gochecknoglobals // Effectively const type TestTOMLType struct { SampleBool bool `toml:"sample_bool"` SampleInt int `toml:"sample_int"` SampleStr string `toml:"sample_str"` SampleSlice []string `toml:"sample_slice"` } type TestTOMLMissingIgnorerType struct { SampleBool bool `toml:"sample_bool"` SampleInt int `toml:"sample_int"` SampleStr string `toml:"sample_str"` SampleSlice []string `toml:"sample_slice"` IgnoreMissing bool `toml:"ignore_missing"` } func (t TestTOMLMissingIgnorerType) GetIgnoreMissingFields() bool { return t.IgnoreMissing } func (t TestTOMLMissingIgnorerType) WithIgnoreMissing(val bool) TestTOMLMissingIgnorerType { t.IgnoreMissing = val return t } func SetupDirectories(t *testing.T, dirs ...string) { t.Helper() for _, dir := range dirs { if dir == "" { continue } err := os.MkdirAll(dir, UserDirPerm) require.NoError(t, err) } } func SetupFilesWithData(t *testing.T, data []byte, files ...string) { t.Helper() for _, file := range files { err := os.WriteFile(file, data, UserFilePerm) require.NoError(t, err) } } func SetupFiles(t *testing.T, files ...string) { SetupFilesWithData(t, SampleDataBytes, files...) } ================================================ FILE: src/pkg/utils/testdata/load_toml/default.toml ================================================ sample_bool = true sample_int = 2 sample_str = "hello" sample_slice = ['a', 'b'] ================================================ FILE: src/pkg/utils/testdata/load_toml/ignorer/.gitignore ================================================ missing_str2.toml.bak* missing_str_ignore2.toml.bak* ================================================ FILE: src/pkg/utils/testdata/load_toml/ignorer/default.toml ================================================ sample_bool = true sample_int = 2 sample_str = "hello" sample_slice = ['a', 'b'] ignore_missing = false ================================================ FILE: src/pkg/utils/testdata/load_toml/ignorer/default_extra_fields.toml ================================================ sample_bool = true sample_int = 2 sample_str = "hello" sample_slice = ['a', 'b'] ignore_missing = false sample_extra = 1 ================================================ FILE: src/pkg/utils/testdata/load_toml/ignorer/invalid_format.toml ================================================ sample_bool = true sample_int = 2 sample_str = "hello" sample_slice = ignore_missing = false ================================================ FILE: src/pkg/utils/testdata/load_toml/ignorer/invalid_value_type.toml ================================================ sample_bool = true sample_int = "hi" sample_str = "hello" ignore_missing = false ================================================ FILE: src/pkg/utils/testdata/load_toml/ignorer/missing_str2.toml ================================================ sample_bool = true sample_int = -1 sample_slice = ['a', 'b'] ignore_missing = false ================================================ FILE: src/pkg/utils/testdata/load_toml/ignorer/missing_str_ignore.toml ================================================ sample_bool = true sample_int = 2 sample_slice = ['a', 'b'] ignore_missing = true ================================================ FILE: src/pkg/utils/testdata/load_toml/ignorer/missing_str_ignore2.toml ================================================ sample_bool = true sample_int = -1 sample_slice = ['a', 'b'] ignore_missing = true ================================================ FILE: src/pkg/utils/testdata/load_toml/ignorer/missing_str_int.toml ================================================ sample_bool = true sample_slice = ['a', 'b'] ignore_missing = false ================================================ FILE: src/pkg/utils/testdata/load_toml/missing_str.toml ================================================ sample_bool = true sample_int = 2 sample_slice = ['a', 'b'] ================================================ FILE: src/pkg/utils/ui_utils.go ================================================ package utils const CntFooterPanels = 3 const BorderPaddingForFooter = 2 // Including borders func FullFooterHeight(footerHeight int, toggleFooter bool) int { if toggleFooter { return footerHeight + BorderPaddingForFooter } return 0 } ================================================ FILE: src/superfile_config/config.toml ================================================ ############################################## # # # Superfile Configuration # # # ############################################## # This contains the root config file for superfile! More details can be found at # https://superfile.dev/configure/superfile-config/. ############################################################################### # Defaults # ############################################################################### #-- File Editor # Default: $EDITOR editor = "" #-- Directory Editor # dir_editor = "" #-- Auto check for update auto_check_update = true #-- cd on quit # Should we cd the shell to the last directory open in superfile when the # program exits? cd_on_quit = false #-- File Preview # Should we open a file preview by default whenever selection-hovering over a # file? default_open_file_preview = true #-- Image Preview # Should we open an image preview by default whenever selection-hovering over an # image? show_image_preview = true #-- File Info Footer # Should we display a footer in the file panel that provides more file information? show_panel_footer_info = true #-- Default Directory # The initial path that the file panel should navigate to when superfile is # opened. This setting understands relative paths such as ".", "..", etc. default_directory = "." #-- File Size Units # true: SI decimal units of 1000 (kB, MB, GB). # false: IEC binary units of 1024 (KiB, MiB, GiB). file_size_use_si = false #-- Default File Sort Type # (0: Name, 1: Size, 2: Date Modified, 3: Type, 4: Natural). # Natural sort treats numeric sequences as numbers (e.g., file2 before file10). default_sort_type = 0 #-- Sort Order Reversing # true: Descending. # false: Ascending. sort_order_reversed = false #-- Case-Sensitive Sorting # An uppercase "B" comes before a lowercase "a" if true. case_sensitive_sort = false #-- Exit Shell on Success # Whether to exit the shell on successful command execution. shell_close_on_success = false #-- Page Scroll Size # Number of lines to scroll for PgUp/PgDown keys (0: full page, default behavior). page_scroll_size = 0 #-- Debug Mode debug = false #-- Ignore Missing Config Fields # Whether to silence any warnings about missing config fields. ignore_missing_fields = false #-- File Panel Extra Columns Count # Count of extra columns in file panel in addition to file name. When option equal 0 then feature is disabled. file_panel_extra_columns = 0 #-- File name width in File Panel # Percentage of file panel width allocated to file names (25-100). Higher values give more space to names, less to extra columns. file_panel_name_percent = 50 ############################################################################### # Styling # ############################################################################### #-- Theme # Put your theme's name here! theme = "catppuccin-mocha" #-- Code Previewer # Whether to use the builtin syntax highlighting with chroma or use bat. Values: "" for builtin chroma, "bat" for bat code_previewer = "" #-- Nerd Fonts Support # Whether to enable support for Nerd Fonts symbols. # Requires: Font patched with the Nerd Fonts patch. nerdfont = true #-- Show checkbox icons in select mode # Requires: nerdfont = true show_select_icons = true #-- Transparent Background Support # Set to true to enable background transparency. # Requires: terminal support for colour transparency transparent_background = false #-- File Preview Panel Width # Width of the file preview panel will be 1/n of the total width. # Values recommended to be in 2–10. # Default (0): Use the same width as file picker panel. file_preview_width = 0 #-- File Preview Border # Enable border around the file preview panel for better visual separation. # Default: false (no border) enable_file_preview_border = false #-- Sidebar Width # If you don't want to display the sidebar, you can input 0 directly. # Values recommended to be in 5–20. sidebar_width = 20 #-- Sidebar Section Order # Order of sidebar sections (valid values: "home", "pinned", "disks") # Only enabled sections will be displayed sidebar_sections = ["home", "pinned", "disks"] #-- Border # Make sure to add strings that are exactly one character wide! # Use ' ' for borderless. border_top = '─' border_bottom = '─' border_left = '│' border_right = '│' border_top_left = '╭' border_top_right = '╮' border_bottom_left = '╰' border_bottom_right = '╯' border_middle_left = '├' border_middle_right = '┤' ############################################################################### # Plugins # ############################################################################### # This section is for using plugins with superfile, external addons that extend # the default capabilities of the program! More info can be found at # https://superfile.dev/list/plugin-list/. #-- Detailed Metadata # Requires: exiftool metadata = false #-- MD5 Checksum Generation # Requires: md5sum enable_md5_checksum = false # #-- Zoxide Support - Smart directory navigation! # Requires: zoxide zoxide_support = false #-- File opening rules # Map file extensions to commands used to open them. # The file path will be appended as the last argument. # MUST BE IN THE VERY END OF THE FILE BECAUSE TOML CANNOT CLOSE TABLES # Example: # png = "feh" # pdf = "zathura" # conf = "nvim" [open_with] ================================================ FILE: src/superfile_config/hotkeys.toml ================================================ ############################################## # # # Superfile Configuration # # # ############################################## # This contains the hotkey config file for superfile! More details can be found at # https://superfile.dev/configure/custom-hotkeys/. ############################################################################### # Global hotkeys # ############################################################################### # Note: These hotkeys should be unique. #-- Basic Actions confirm = ['enter', 'right', 'l'] cd_quit = ['Q', ''] quit = ['q', 'esc'] #-- Navigation list_down = ['down', 'j'] list_up = ['up', 'k'] page_down = ['pgdown',''] page_up = ['pgup',''] #-- File Panel Controls close_file_panel = ['w', ''] create_new_file_panel = ['n', ''] next_file_panel = ['tab', 'L'] open_sort_options_menu = ['o', ''] pinned_directory = ['P', ''] previous_file_panel = ['shift+left', 'H'] split_file_panel = ['N', ''] toggle_file_preview_panel = ['f', ''] toggle_reverse_sort = ['R', ''] #-- Focus Manipulation focus_on_metadata = ['m', ''] focus_on_process_bar = ['p', ''] focus_on_sidebar = ['s', ''] #-- File/Dir Creation/Renaming file_panel_item_create = ['ctrl+n', ''] file_panel_item_rename = ['ctrl+r', ''] #-- Main File Operations copy_items = ['ctrl+c', ''] cut_items = ['ctrl+x', ''] delete_items = ['ctrl+d', 'delete', ''] paste_items = ['ctrl+v', 'ctrl+w', ''] permanently_delete_items = ['D', ''] #-- Archive Manipulation compress_file = ['ctrl+a', ''] extract_file = ['ctrl+e', ''] #-- Editor Actions open_current_directory_with_editor = ['E', ''] open_file_with_editor = ['e', ''] #-- Other Actions change_panel_mode = ['v', ''] copy_path = ['ctrl+p', ''] copy_present_working_directory = ['c', ''] open_command_line = [':', ''] open_help_menu = ['?', ''] open_spf_prompt = ['>', ''] open_zoxide = ['z', ''] toggle_dot_file = ['.', ''] toggle_footer = ['F', ''] ############################################################################### # Typing hotkeys # ############################################################################### # Note: These hotkeys can override all hotkeys. confirm_typing = ['enter', ''] cancel_typing = ['ctrl+c', 'esc'] ############################################################################### # Mode-Specific Hotkeys # ############################################################################### # Note: These hotkeys can conflict with other modes, but not with global # hotkeys. #-- Normal Mode Actions parent_directory = ['h', 'left', 'backspace'] search_bar = ['/', ''] #-- Selection Mode Actions file_panel_select_mode_items_select_down = ['shift+down', 'J'] file_panel_select_mode_items_select_up = ['shift+up', 'K'] file_panel_select_all_items = ['A', ''] ================================================ FILE: src/superfile_config/theme/0x96f.toml ================================================ ############################################## # # # 0x96f Theme # # # ############################################## # This theme was created by: https://github.com/filipjanevski ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "monokai" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#fcfcfc" full_screen_bg = "#262427" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#ff7272", "#bcdf59"] #-- File Panel file_panel_fg = "#fcfcfc" file_panel_bg = "#262427" file_panel_border = "#757075" file_panel_border_active = "#ffca58" file_panel_top_directory_icon = "#64d2e8" file_panel_top_path = "#fcfcfc" file_panel_item_selected_fg = "#c6e472" file_panel_item_selected_bg = "#262427" #-- Footer footer_fg = "#fcfcfc" footer_bg = "#262427" footer_border = "#757075" footer_border_active = "#ffca58" #-- Sidebar sidebar_fg = "#fcfcfc" sidebar_bg = "#262427" sidebar_title = "#64d2e8" sidebar_border = "#757075" sidebar_border_active = "#baebf6" sidebar_item_selected_fg = "#c6e472" sidebar_item_selected_bg = "#262427" sidebar_divider = "#757075" #-- Modals modal_fg = "#fcfcfc" modal_bg = "#262427" modal_border_active = "#ffca58" modal_cancel_fg = "#262427" modal_cancel_bg = "#ff8787" modal_confirm_fg = "#262427" modal_confirm_bg = "#bcdf59" #-- Help Menu help_menu_hotkey = "#ff8787" help_menu_title = "#64d2e8" #-- Special cursor = "#ffca58" correct = "#c6e472" error = "#ff8787" hint = "#baebf6" cancel = "#fcfcfc" ================================================ FILE: src/superfile_config/theme/ayu-dark.toml ================================================ ############################################## # # # Ayu Dark Theme # # # ############################################## # This theme was created by: https://github.com/rustnomicon ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "ayu-dark" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#b3b1ad" full_screen_bg = "#0f1419" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#FFB454", "#36A3D9"] #-- File Panel file_panel_fg = "#b3b1ad" file_panel_bg = "#0f1419" file_panel_border = "#242936" file_panel_border_active = "#ffcc66" file_panel_top_directory_icon = "#aad94c" file_panel_top_path = "#ffcc66" file_panel_item_selected_fg = "#36a3d9" file_panel_item_selected_bg = "#1f2430" #-- Footer footer_fg = "#b3b1ad" footer_bg = "#0f1419" footer_border = "#242936" footer_border_active = "#aad94c" #-- Sidebar sidebar_fg = "#b3b1ad" sidebar_bg = "#0f1419" sidebar_title = "#36a3d9" sidebar_border = "#1f2430" sidebar_border_active = "#ff7733" sidebar_item_selected_fg = "#36a3d9" sidebar_item_selected_bg = "#1f2430" sidebar_divider = "#242936" #-- Modals modal_fg = "#b3b1ad" modal_bg = "#0f1419" modal_border_active = "#5c6773" modal_cancel_fg = "#0f1419" modal_cancel_bg = "#f07178" modal_confirm_fg = "#0f1419" modal_confirm_bg = "#36a3d9" #-- Help Menu help_menu_hotkey = "#36a3d9" help_menu_title = "#f07178" #-- Special cursor = "#ffcc66" correct = "#aad94c" error = "#ff3333" hint = "#36a3d9" cancel = "#f07178" ================================================ FILE: src/superfile_config/theme/blood.toml ================================================ ############################################## # # # Blood Theme # # # ############################################## # This theme was created by: https://github.com/charlesrocket ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "onedark" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#f8f8f2" full_screen_bg = "#000000" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#720000", "#ff0000"] #-- File Panel file_panel_fg = "#f8f8f2" file_panel_bg = "#000000" file_panel_border = "#9a0000" file_panel_border_active = "#ff0000" file_panel_top_directory_icon = "#ff522e" file_panel_top_path = "#ff9999" file_panel_item_selected_fg = "#ff8d34" file_panel_item_selected_bg = "#524549" #-- Footer footer_fg = "#f8f8f2" footer_bg = "#000000" footer_border = "#790000" footer_border_active = "#ff0000" #-- Sidebar sidebar_fg = "#f8f8f2" sidebar_bg = "#000000" sidebar_title = "#dd0000" sidebar_border = "#790000" sidebar_border_active = "#ff0000" sidebar_item_selected_fg = "#000000" sidebar_item_selected_bg = "#ff8d34" sidebar_divider = "#615250" #-- Modals modal_fg = "#f8f8f2" modal_bg = "#000000" modal_border_active = "#ff0000" modal_cancel_fg = "#f9f9fe" modal_cancel_bg = "#000042" modal_confirm_fg = "#f9f9fe" modal_confirm_bg = "#ffb86c" #-- Help Menu help_menu_hotkey = "#ff8d34" help_menu_title = "#ff6666" #-- Special cursor = "#ff0000" correct = "#47ef7d" error = "#d70000" hint = "#5bd9f3" cancel = "#6575ab" ================================================ FILE: src/superfile_config/theme/catppuccin-frappe.toml ================================================ ############################################## # # # Catppuccin Frappe Theme # # # ############################################## # This theme was created by: https://github.com/GV14982 ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "catppuccin-frappe" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#a5adce" full_screen_bg = "#303446" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#8caaee", "#ca9ee6"] #-- File Panel file_panel_fg = "#a5adce" file_panel_bg = "#303446" file_panel_border = "#737994" file_panel_border_active = "#babbf1" file_panel_top_directory_icon = "#a6d189" file_panel_top_path = "#89b5fa" file_panel_item_selected_fg = "#99d1db" file_panel_item_selected_bg = "#303446" #-- Footer footer_fg = "#a5adce" footer_bg = "#303446" footer_border = "#737994" footer_border_active = "#a6d189" #-- Sidebar sidebar_fg = "#a5adce" sidebar_bg = "#303446" sidebar_title = "#85c1dc" sidebar_border = "#303446" sidebar_border_active = "#e78284" sidebar_item_selected_fg = "#99d1db" sidebar_item_selected_bg = "#303446" sidebar_divider = "#949cbb" #-- Modals modal_fg = "#a5adce" modal_bg = "#303446" modal_border_active = "#949cbb" modal_cancel_fg = "#414559" modal_cancel_bg = "#ea999c" modal_confirm_fg = "#414559" modal_confirm_bg = "#99d1db" #-- Help Menu help_menu_hotkey = "#99d1db" help_menu_title = "#ea999c" #-- Special cursor = "#f2d5cf" correct = "#a6d189" error = "#e78284" hint = "#85c1dc" cancel = "#ea999c" ================================================ FILE: src/superfile_config/theme/catppuccin-latte.toml ================================================ ############################################## # # # Catppuccin Latte Theme # # # ############################################## # This theme was created by: https://github.com/GV14982 ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "catppuccin-latte" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#4c4f69" full_screen_bg = "#eff1f5" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#1e66f5", "#ca9ee6"] #-- File Panel file_panel_fg = "#4c4f69" file_panel_bg = "#eff1f5" file_panel_border = "#9ca0b0" file_panel_border_active = "#7287fd" file_panel_top_directory_icon = "#40a02b" file_panel_top_path = "#89b5fa" file_panel_item_selected_fg = "#04a5e5" file_panel_item_selected_bg = "#eff1f5" #-- Footer footer_fg = "#4c4f69" footer_bg = "#eff1f5" footer_border = "#9ca0b0" footer_border_active = "#40a02b" #-- Sidebar sidebar_fg = "#4c4f69" sidebar_bg = "#eff1f5" sidebar_title = "#209fb5" sidebar_border = "#eff1f5" sidebar_border_active = "#40a02b" sidebar_item_selected_fg = "#04a5e5" sidebar_item_selected_bg = "#eff1f5" sidebar_divider = "#7c7f93" #-- Modals modal_fg = "#4c4f69" modal_bg = "#eff1f5" modal_border_active = "#7c7f93" modal_cancel_fg = "#eff1f5" modal_cancel_bg = "#e64553" modal_confirm_fg = "#eff1f5" modal_confirm_bg = "#04a5e5" #-- Help Menu help_menu_hotkey = "#04a5e5" help_menu_title = "#fe640b" #-- Special cursor = "#dc8a78" correct = "#40a02b" error = "#d20f39" hint = "#209fb5" cancel = "#e64553" ================================================ FILE: src/superfile_config/theme/catppuccin-macchiato.toml ================================================ ############################################## # # # Catppuccin Macchiato Theme # # # ############################################## # This theme was created by: https://github.com/GV14982 ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "catppuccin-macchiato" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#a5adcb" full_screen_bg = "#24273a" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#8aadf4", "#c6a0f6"] #-- File Panel file_panel_fg = "#a5adcb" file_panel_bg = "#24273a" file_panel_border = "#6e738d" file_panel_border_active = "#b7bdf8" file_panel_top_directory_icon = "#a6da95" file_panel_top_path = "#8aadf4" file_panel_item_selected_fg = "#91d7e3" file_panel_item_selected_bg = "#24273a" #-- Footer footer_fg = "#a5adcb" footer_bg = "#24273a" footer_border = "#6e738d" footer_border_active = "#a6da95" #-- Sidebar sidebar_fg = "#a5adcb" sidebar_bg = "#24273a" sidebar_title = "#7dc4e4" sidebar_border = "#24273a" sidebar_border_active = "#ed8796" sidebar_item_selected_fg = "#91d7e3" sidebar_item_selected_bg = "#24273a" sidebar_divider = "#939ab7" #-- Modals modal_fg = "#a5adcb" modal_bg = "#24273a" modal_border_active = "#939ab7" modal_cancel_fg = "#363a4f" modal_cancel_bg = "#ee99a0" modal_confirm_fg = "#363a4f" modal_confirm_bg = "#91d7e3" #-- Help Menu help_menu_hotkey = "#91d7e3" help_menu_title = "#ee99a0" #-- Special cursor = "#f4dbd6" correct = "#a6da95" error = "#ed8796" hint = "#7dc4e4" cancel = "#ee99a0" ================================================ FILE: src/superfile_config/theme/catppuccin-mocha.toml ================================================ ############################################## # # # Catppuccin Mocha Theme # # # ############################################## # This theme was created by: https://github.com/AnshumanNeon ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "catppuccin-mocha" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#a6adc8" full_screen_bg = "#1e1e2e" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#89b4fa", "#cba6f7"] #-- File Panel file_panel_fg = "#a6adc8" file_panel_bg = "#1e1e2e" file_panel_border = "#6c7086" file_panel_border_active = "#b4befe" file_panel_top_directory_icon = "#a6e3a1" file_panel_top_path = "#89b5fa" file_panel_item_selected_fg = "#98D0FD" file_panel_item_selected_bg = "#1e1e2e" #-- Footer footer_fg = "#a6adc8" footer_bg = "#1e1e2e" footer_border = "#6c7086" footer_border_active = "#a6e3a1" #-- Sidebar sidebar_fg = "#a6adc8" sidebar_bg = "#1e1e2e" sidebar_title = "#74c7ec" sidebar_border = "#1e1e2e" sidebar_border_active = "#f38ba8" sidebar_item_selected_fg = "#A6DBF7" sidebar_item_selected_bg = "#1e1e2e" sidebar_divider = "#868686" #-- Modals modal_fg = "#a6adc8" modal_bg = "#1e1e2e" modal_border_active = "#868686" modal_cancel_fg = "#383838" modal_cancel_bg = "#eba0ac" modal_confirm_fg = "#383838" modal_confirm_bg = "#89dceb" #-- Help Menu help_menu_hotkey = "#89dceb" help_menu_title = "#eba0ac" #-- Special cursor = "#f5e0dc" correct = "#a6e3a1" error = "#f38ba8" hint = "#73c7ec" cancel = "#eba0ac" ================================================ FILE: src/superfile_config/theme/dracula.toml ================================================ ############################################## # # # Dracula Theme # # # ############################################## # This theme was created by: https://github.com/BeanieBarrow ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "dracula" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#f8f8f2" full_screen_bg = "#282a36" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#50fa7b", "#ff5555"] #-- File Panel file_panel_fg = "#f8f8f2" file_panel_bg = "#282a36" file_panel_border = "#6272a4" file_panel_border_active = "#44475a" file_panel_top_directory_icon = "#50fa7b" file_panel_top_path = "#8be9fd" file_panel_item_selected_fg = "#ffb86c" file_panel_item_selected_bg = "#282a36" #-- Footer footer_fg = "#f8f8f2" footer_bg = "#282a36" footer_border = "#6272a4" footer_border_active = "#44475a" #-- Sidebar sidebar_fg = "#f8f8f2" sidebar_bg = "#282a36" sidebar_title = "#bd93f9" sidebar_border = "#282a36" sidebar_border_active = "#44475a" sidebar_item_selected_fg = "#ffb86c" sidebar_item_selected_bg = "#282a36" sidebar_divider = "#868686" #-- Modals modal_fg = "#f8f8f2" modal_bg = "#282a36" modal_border_active = "#44475a" modal_cancel_fg = "#f8f8f2" modal_cancel_bg = "#6272a4" modal_confirm_fg = "#f8f8f2" modal_confirm_bg = "#ffb86c" #-- Help Menu help_menu_hotkey = "#ffb86c" help_menu_title = "#bd93f9" #-- Special cursor = "#ff79c6" correct = "#50fa7b" error = "#ff5555" hint = "#8be9fd" cancel = "#6272a4" ================================================ FILE: src/superfile_config/theme/everforest-dark-hard.toml ================================================ ############################################## # # # Everforest Dark Hard Theme # # # ############################################## # This theme was created by: https://github.com/fzahner ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "evergarden" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#d3c6aa" full_screen_bg = "#272e33" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#a7c080", "#e67e80"] #-- File Panel file_panel_fg = "#d3c6aa" file_panel_bg = "#272e33" file_panel_border = "#859289" file_panel_border_active = "#dbbc7f" file_panel_top_directory_icon = "#a7c080" file_panel_top_path = "#7fbbb3" file_panel_item_selected_fg = "#d699b6" file_panel_item_selected_bg = "#232a2e" #-- Footer footer_fg = "#d3c6aa" footer_bg = "#272e33" footer_border = "#859289" footer_border_active = "#d3c6aa" #-- Sidebar sidebar_fg = "#d3c6aa" sidebar_bg = "#272e33" sidebar_title = "#d699b6" sidebar_border = "#2d353b" sidebar_border_active = "#d3c6aa" sidebar_item_selected_fg = "#e69875" sidebar_item_selected_bg = "#2d353b" sidebar_divider = "#859289" #-- Modals modal_fg = "#d3c6aa" modal_bg = "#272e33" modal_border_active = "#859289" modal_cancel_fg = "#d3c6aa" modal_cancel_bg = "#232a2e" modal_confirm_fg = "#d3c6aa" modal_confirm_bg = "#e69875" #-- Help Menu help_menu_hotkey = "#a7c080" help_menu_title = "#e69875" #-- Special cursor = "#a7c080" correct = "#a7c080" error = "#e67e80" hint = "#7fbbb3" cancel = "#859289" ================================================ FILE: src/superfile_config/theme/everforest-dark-medium.toml ================================================ ############################################## # # # Everforest Dark Medium Theme # # # ############################################## # This theme was created by: https://github.com/dotintegral ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "catppuccin-macchiato" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#d3c6aa" full_screen_bg = "#2d353b" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#a7c080", "#e67e80"] #-- File Panel file_panel_fg = "#d3c6aa" file_panel_bg = "#2d353b" file_panel_border = "#859289" file_panel_border_active = "#fff1c5" file_panel_top_directory_icon = "#a7c080" file_panel_top_path = "#7fbbb3" file_panel_item_selected_fg = "#d699b6" file_panel_item_selected_bg = "#232a2e" #-- Footer footer_fg = "#d3c6aa" footer_bg = "#2d353b" footer_border = "#859289" footer_border_active = "#dbbc7f" #-- Sidebar sidebar_fg = "#d3c6aa" sidebar_bg = "#2d353b" sidebar_title = "#d699b6" sidebar_border = "#2d353b" sidebar_border_active = "#dbbc7f" sidebar_item_selected_fg = "#e69875" sidebar_item_selected_bg = "#2d353b" sidebar_divider = "#859289" #-- Modals modal_fg = "#d3c6aa" modal_bg = "#2d353b" modal_border_active = "#859289" modal_cancel_fg = "#d3c6aa" modal_cancel_bg = "#232a2e" modal_confirm_fg = "#d3c6aa" modal_confirm_bg = "#e69875" #-- Help Menu help_menu_hotkey = "#a7c080" help_menu_title = "#e69875" #-- Special cursor = "#a7c080" correct = "#a7c080" error = "#e67e80" hint = "#7fbbb3" cancel = "#859289" ================================================ FILE: src/superfile_config/theme/gruvbox-dark-hard.toml ================================================ ############################################## # # # Gruvbox Dark Hard Theme # # # ############################################## # This theme was created by: https://github.com/frost-phoenix ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "gruvbox" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#fbf1c7" full_screen_bg = "#1d2021" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#fb4934", "#b8bb26"] #-- File Panel file_panel_fg = "#fbf1c7" file_panel_bg = "#1d2021" file_panel_border = "#fbf1c7" file_panel_border_active = "#98971a" file_panel_top_directory_icon = "#689d6a" file_panel_top_path = "#458588" file_panel_item_selected_fg = "#d65d0e" file_panel_item_selected_bg = "" #-- Footer footer_fg = "#fbf1c7" footer_bg = "#1d2021" footer_border = "#928374" footer_border_active = "#d79921" #-- Sidebar sidebar_fg = "#fbf1c7" sidebar_bg = "#1d2021" sidebar_title = "#b16286" sidebar_border = "#928374" sidebar_border_active = "#b16286" sidebar_item_selected_fg = "#d65d0e" sidebar_item_selected_bg = "" sidebar_divider = "#928374" #-- Modals modal_fg = "#fbf1c7" modal_bg = "#1d2021" modal_border_active = "#689d6a" modal_cancel_fg = "#fb4934" modal_cancel_bg = "" modal_confirm_fg = "#b8bb26" modal_confirm_bg = "" #-- Help Menu help_menu_hotkey = "#689d6a" help_menu_title = "#b16286" #-- Special cursor = "#689d6a" correct = "#98971a" error = "#ff6969" hint = "#468588" cancel = "#838383" ================================================ FILE: src/superfile_config/theme/gruvbox.toml ================================================ ############################################## # # # Gruvbox Theme # # # ############################################## # This theme was created by: https://github.com/yorukot ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "gruvbox" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#ebdbb2" full_screen_bg = "#282828" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#689d6a", "#fb4934"] #-- File Panel file_panel_fg = "#ebdbb2" file_panel_bg = "#282828" file_panel_border = "#868686" file_panel_border_active = "#fff1c5" file_panel_top_directory_icon = "#8ec07c" file_panel_top_path = "#458588" file_panel_item_selected_fg = "#d3869b" file_panel_item_selected_bg = "#282828" #-- Footer footer_fg = "#ebdbb2" footer_bg = "#282828" footer_border = "#868686" footer_border_active = "#d79921" #-- Sidebar sidebar_fg = "#ebdbb2" sidebar_bg = "#282828" sidebar_title = "#cc241d" sidebar_border = "#282828" sidebar_border_active = "#d79921" sidebar_item_selected_fg = "#e8751a" sidebar_item_selected_bg = "#282828" sidebar_divider = "#868686" #-- Modals modal_fg = "#ebdbb2" modal_bg = "#282828" modal_border_active = "#868686" modal_cancel_fg = "#ebdbb2" modal_cancel_bg = "#6d6d6d" modal_confirm_fg = "#ebdbb2" modal_confirm_bg = "#ff4d00" #-- Help Menu help_menu_hotkey = "#8ec07c" help_menu_title = "#ff4d00" #-- Special cursor = "#8ec07c" correct = "#8ec07c" error = "#ff6969" hint = "#458588" cancel = "#838383" ================================================ FILE: src/superfile_config/theme/hacks.toml ================================================ ############################################## # # # Hacks Theme # # # ############################################## # This theme was created by: https://github.com/charlesrocket ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "onedark" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#f8f8f2" full_screen_bg = "#000000" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#00ff00", "#afff00"] #-- File Panel file_panel_fg = "#f8f8f2" file_panel_bg = "#000000" file_panel_border = "#afff00" file_panel_border_active = "#6532ff" file_panel_top_directory_icon = "#afff00" file_panel_top_path = "#afff00" file_panel_item_selected_fg = "#ff8d34" file_panel_item_selected_bg = "#524549" #-- Footer footer_fg = "#f8f8f2" footer_bg = "#000000" footer_border = "#afff00" footer_border_active = "#6532ff" #-- Sidebar sidebar_fg = "#f8f8f2" sidebar_bg = "#000000" sidebar_title = "#afff00" sidebar_border = "#afff00" sidebar_border_active = "#6532ff" sidebar_item_selected_fg = "#000000" sidebar_item_selected_bg = "#ff8d34" sidebar_divider = "#615250" #-- Modals modal_fg = "#f8f8f2" modal_bg = "#000000" modal_border_active = "#6532ff" modal_cancel_fg = "#f9f9fe" modal_cancel_bg = "#000042" modal_confirm_fg = "#f9f9fe" modal_confirm_bg = "#ffb86c" #-- Help Menu help_menu_hotkey = "#ff8d34" help_menu_title = "#afff00" #-- Special cursor = "#ff0000" correct = "#47ef7d" error = "#d70000" hint = "#5bd9f3" cancel = "#6575ab" ================================================ FILE: src/superfile_config/theme/kaolin.toml ================================================ ############################################## # # # Kaolin Theme # # # ############################################## # This theme was created by: https://github.com/AnshumanNeon ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "catppuccin-macchiato" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#efefef" full_screen_bg = "#17171a" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#74b09a", "#c74a4d"] #-- File Panel file_panel_fg = "#efefef" file_panel_bg = "#17171a" file_panel_border = "#74b09a" file_panel_border_active = "#74b09a" file_panel_top_directory_icon = "#f5c791" file_panel_top_path = "#d7936d" file_panel_item_selected_fg = "#4fa8a3" file_panel_item_selected_bg = "#17171a" #-- Footer footer_fg = "#efefef" footer_bg = "#17171a" footer_border = "#74b09a" footer_border_active = "#57b2c2" #-- Sidebar sidebar_fg = "#efefef" sidebar_bg = "#17171a" sidebar_title = "#f5c791" sidebar_border = "#17171a" sidebar_border_active = "#57b2c2" sidebar_item_selected_fg = "#ba667d" sidebar_item_selected_bg = "#17171a" sidebar_divider = "#868686" #-- Modals modal_fg = "#efefef" modal_bg = "#17171a" modal_border_active = "#868686" modal_cancel_fg = "#eedcc1" modal_cancel_bg = "#c74a4d" modal_confirm_fg = "#eedcc1" modal_confirm_bg = "#3e594a" #-- Help Menu help_menu_hotkey = "#4fa8a3" help_menu_title = "#f5c791" #-- Special cursor = "#f5c791" correct = "#74b09a" error = "#c74a4d" hint = "#4fa8a3" cancel = "#d7936d" ================================================ FILE: src/superfile_config/theme/monokai.toml ================================================ ############################################## # # # Monokai Theme # # # ############################################## # This theme was created by: https://github.com/CommandJoo ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "monokai" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#f8f8f2" full_screen_bg = "#272822" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#66d9ef", "#ae81ff"] #-- File Panel file_panel_fg = "#f8f8f2" file_panel_bg = "#272822" file_panel_border = "#75715e" file_panel_border_active = "#66d9ef" file_panel_top_directory_icon = "#e6db74" file_panel_top_path = "#e6db74" file_panel_item_selected_fg = "#66d9ef" file_panel_item_selected_bg = "#2e2e2e" #-- Footer footer_fg = "#f8f8f2" footer_bg = "#272822" footer_border = "#75715e" footer_border_active = "#a9dc76" #-- Sidebar sidebar_fg = "#f8f8f2" sidebar_bg = "#272822" sidebar_title = "#66d9ef" sidebar_border = "#75715e" sidebar_border_active = "#f92672" sidebar_item_selected_fg = "#66d9ef" sidebar_item_selected_bg = "#272822" sidebar_divider = "#75715e" #-- Modals modal_fg = "#f8f8f2" modal_bg = "#272822" modal_border_active = "#66d9ef" modal_cancel_fg = "#75715e" modal_cancel_bg = "#f92672" modal_confirm_fg = "#75715e" modal_confirm_bg = "#66d9ef" #-- Help Menu help_menu_hotkey = "#66d9ef" help_menu_title = "#ae81ff" #-- Special cursor = "#66d9ef" correct = "#a6e22e" error = "#f92672" hint = "#66d9ef" cancel = "#e6db74" ================================================ FILE: src/superfile_config/theme/nord.toml ================================================ ############################################## # # # Nord Theme # # # ############################################## # This theme was created by: https://github.com/rames-eltany ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "nord" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#e5e9f0" full_screen_bg = "#2e3440" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#81a1c1", "#bf616a"] #-- File Panel file_panel_fg = "#e5e9f0" file_panel_bg = "#2e3440" file_panel_border = "#4c566a" file_panel_border_active = "#d8dee9" file_panel_top_directory_icon = "#88c0d0" file_panel_top_path = "#88c0d0" file_panel_item_selected_fg = "#bf616a" file_panel_item_selected_bg = "#2e3440" #-- Footer footer_fg = "#e5e9f0" footer_bg = "#2e3440" footer_border = "#4c566a" footer_border_active = "#b48ead" #-- Sidebar sidebar_fg = "#e5e9f0" sidebar_bg = "#2e3440" sidebar_title = "#81a1c1" sidebar_border = "#2e3440" sidebar_border_active = "#b48ead" sidebar_item_selected_fg = "#88c0d0" sidebar_item_selected_bg = "#2e3440" sidebar_divider = "#868686" #-- Modals modal_fg = "#e5e9f0" modal_bg = "#2e3440" modal_border_active = "#868686" modal_cancel_fg = "#e5e9f0" modal_cancel_bg = "#4c566a" modal_confirm_fg = "#e5e9f0" modal_confirm_bg = "#bf616a" #-- Help Menu help_menu_hotkey = "#8fbcbb" help_menu_title = "#81a1c1" #-- Special cursor = "#88c0d0" correct = "#88c0d0" error = "#bf616a" hint = "#8fbcbb" cancel = "#d8dee9" ================================================ FILE: src/superfile_config/theme/onedark.toml ================================================ ############################################## # # # OneDark Theme # # # ############################################## # This theme was created by: https://github.com/CommandJoo ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "onedark" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#a7aab0" full_screen_bg = "#2c2d31" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#68aee8", "#bb70d2"] #-- File Panel file_panel_fg = "#a7aab0" file_panel_bg = "#232326" file_panel_border = "#737994" file_panel_border_active = "#57a5e5" file_panel_top_directory_icon = "#dbb671" file_panel_top_path = "#dbb671" file_panel_item_selected_fg = "#51a8b3" file_panel_item_selected_bg = "#2c2d31" #-- Footer footer_fg = "#a7aab0" footer_bg = "#232326" footer_border = "#737994" footer_border_active = "#8fb573" #-- Sidebar sidebar_fg = "#a7aab0" sidebar_bg = "#232326" sidebar_title = "#57a5e5" sidebar_border = "#737994" sidebar_border_active = "#de5d68" sidebar_item_selected_fg = "#51a8b3" sidebar_item_selected_bg = "#2c2d31" sidebar_divider = "#818387" #-- Modals modal_fg = "#a7aab0" modal_bg = "#35363b" modal_border_active = "#51a8b3" modal_cancel_fg = "#414559" modal_cancel_bg = "#de5d68" modal_confirm_fg = "#414559" modal_confirm_bg = "#51a8b3" #-- Help Menu help_menu_hotkey = "#51a8b3" help_menu_title = "#bb70d2" #-- Special cursor = "#68aee8" correct = "#a6d189" error = "#de5d68" hint = "#68aee8" cancel = "#c49060" ================================================ FILE: src/superfile_config/theme/poimandres.toml ================================================ ############################################## # # # Poimandres Theme # # # ############################################## # This theme was created by: https://github.com/Myles-J ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "catppuccin-mocha" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#cdd6f4" full_screen_bg = "#1b1d24" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#a6e3a1", "#f38ba8"] #-- File Panel file_panel_fg = "#cdd6f4" file_panel_bg = "#1b1d24" file_panel_border = "#6c7086" file_panel_border_active = "#7fbbb3" file_panel_top_directory_icon = "#a6e3a1" file_panel_top_path = "#89b4fa" file_panel_item_selected_fg = "#a6e3a1" file_panel_item_selected_bg = "#2a303c" #-- Footer footer_fg = "#cdd6f4" footer_bg = "#1b1d24" footer_border = "#6c7086" footer_border_active = "#7fbbb3" #-- Sidebar sidebar_fg = "#cdd6f4" sidebar_bg = "#1b1d24" sidebar_title = "#cba6f7" sidebar_border = "#2a303c" sidebar_border_active = "#7fbbb3" sidebar_item_selected_fg = "#a6e3a1" sidebar_item_selected_bg = "#2a303c" sidebar_divider = "#6c7086" #-- Modals modal_fg = "#cdd6f4" modal_bg = "#1b1d24" modal_border_active = "#7fbbb3" modal_cancel_fg = "#cdd6f4" modal_cancel_bg = "#6c7086" modal_confirm_fg = "#cdd6f4" modal_confirm_bg = "#4a4e69" #-- Help Menu help_menu_hotkey = "#a6e3a1" help_menu_title = "#cba6f7" #-- Special cursor = "#74c7ec" correct = "#a6e3a1" error = "#f38ba8" hint = "#89b4fa" cancel = "#6c7086" ================================================ FILE: src/superfile_config/theme/rose-pine.toml ================================================ ############################################## # # # Rose Pine Theme # # # ############################################## # This theme was created by: https://github.com/pearcidar ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "rose-pine" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#e0def4" full_screen_bg = "#191724" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#31784f", "#eb6f92"] #-- File Panel file_panel_fg = "#e0def4" file_panel_bg = "#191724" file_panel_border = "#403d52" file_panel_border_active = "#6e6e86" file_panel_top_directory_icon = "#9ccfd8" file_panel_top_path = "#ebbcba" file_panel_item_selected_fg = "#c4a7e7" file_panel_item_selected_bg = "#191724" #-- Footer footer_fg = "#e0def4" footer_bg = "#191724" footer_border = "#403d52" footer_border_active = "#f6c177" #-- Sidebar sidebar_fg = "#e0def4" sidebar_bg = "#191724" sidebar_title = "#6e6e86" sidebar_border = "#191724" sidebar_border_active = "#c4a7e7" sidebar_item_selected_fg = "#f6c177" sidebar_item_selected_bg = "#191724" sidebar_divider = "#868686" #-- Modals modal_fg = "#e0def4" modal_bg = "#191724" modal_border_active = "#868686" modal_cancel_fg = "#e0def4" modal_cancel_bg = "#524f67" modal_confirm_fg = "#e0def4" modal_confirm_bg = "#eb6f92" #-- Help Menu help_menu_hotkey = "#f6c177" help_menu_title = "#9ccfd8" #-- Special cursor = "#9ccfd8" correct = "#8ec07c" error = "#ff6969" hint = "#31784f" cancel = "#838383" ================================================ FILE: src/superfile_config/theme/sugarplum.toml ================================================ ############################################## # # # Sugarplum Theme # # # ############################################## # This theme was created by: https://github.com/lemonlime0x3C33 ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "catppuccin-macchiato" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#db7ddd" full_screen_bg = "#111147" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#249a84", "#5ca8dc"] #-- File Panel file_panel_fg = "#db7ddd" file_panel_bg = "#111147" file_panel_border = "#a175d4" file_panel_border_active = "#53aaa1" file_panel_top_directory_icon = "#249a84" file_panel_top_path = "#249a84" file_panel_item_selected_fg = "#53b397" file_panel_item_selected_bg = "#53b397" #-- Footer footer_fg = "#5ca8dc" footer_bg = "#111147" footer_border = "#a175d4" footer_border_active = "#53aaa1" #-- Sidebar sidebar_fg = "#d0beee" sidebar_bg = "#111147" sidebar_title = "#db7ddd" sidebar_border = "#a175d4" sidebar_border_active = "#53aaa1" sidebar_item_selected_fg = "#249a84" sidebar_item_selected_bg = "#111147" sidebar_divider = "#565f89" #-- Modals modal_fg = "#98c7a3" modal_bg = "#111147" modal_border_active = "#53aaa1" modal_cancel_fg = "#7c4094" modal_cancel_bg = "#7c4094" modal_confirm_fg = "#7c4094" modal_confirm_bg = "#7c4094" #-- Help Menu help_menu_hotkey = "#7dcfff" help_menu_title = "#73daca" #-- Special cursor = "#53b397" correct = "#524094" error = "#2082a6" hint = "#91d4c2" cancel = "#b53dff" ================================================ FILE: src/superfile_config/theme/tokyonight.toml ================================================ ############################################## # # # Tokyo Night Theme # # # ############################################## # This theme was created by: https://github.com/pearcidar ! Thank you <3 # This contains the theme config file for superfile! For more details see: # https://superfile.dev/configure/custom-theme/ ############################################################################### # Code Syntax Highlighting # ############################################################################### # Find one you like at: https://github.com/alecthomas/chroma/blob/master/styles. code_syntax_highlight = "catppuccin-macchiato" ############################################################################### # Base Colors # ############################################################################### #-- Full Screen full_screen_fg = "#a9b1d6" full_screen_bg = "#1a1b26" #-- Gradient # Note: This currently only supports two colors. gradient_color = ["#7aa2f7", "#bb9af7"] #-- File Panel file_panel_fg = "#a9b1d6" file_panel_bg = "#1a1b26" file_panel_border = "#414868" file_panel_border_active = "#b4befe" file_panel_top_directory_icon = "#73daca" file_panel_top_path = "#7aa2f7" file_panel_item_selected_fg = "#2ac3de" file_panel_item_selected_bg = "#1a1b26" #-- Footer footer_fg = "#a9b1d6" footer_bg = "#1a1b26" footer_border = "#414868" footer_border_active = "#73daca" #-- Sidebar sidebar_fg = "#a9b1d6" sidebar_bg = "#1a1b26" sidebar_title = "#73daca" sidebar_border = "#24283b" sidebar_border_active = "#f7768e" sidebar_item_selected_fg = "#7dcfff" sidebar_item_selected_bg = "#1a1b26" sidebar_divider = "#565f89" #-- Modals modal_fg = "#a9b1d6" modal_bg = "#1a1b26" modal_border_active = "#73daca" modal_cancel_fg = "#24383b" modal_cancel_bg = "#e0af68" modal_confirm_fg = "#24283b" modal_confirm_bg = "#9ece6a" #-- Help Menu help_menu_hotkey = "#7dcfff" help_menu_title = "#73daca" #-- Special cursor = "#ff9e64" correct = "#9ece6a" error = "#f7768e" hint = "#7dcfff" cancel = "#ff9e64" ================================================ FILE: src/superfile_config/vimHotkeys.toml ================================================ ############################################## # # # Superfile vim-like Hotkeys # # # ############################################## #-- Maintainer: nonepork # This contains a hotkey config file for superfile, that's themed around vim # controls! More details can be found at # https://superfile.dev/configure/custom-hotkeys/. ############################################################################### # Global hotkeys # ############################################################################### # Note: These hotkeys should be unique. #-- Basic Actions confirm = ['enter', ''] quit = ['ctrl+c', ''] # a.k.a. "theprimeagen troller" cd_quit = ['Q', ''] #-- Navigation list_up = ['k', ''] list_down = ['j', ''] page_up = ['pgup',''] page_down = ['pgdown',''] #-- File Panel Controls create_new_file_panel = ['n', ''] close_file_panel = ['q', ''] next_file_panel = ['tab', ''] previous_file_panel = ['shift+tab', ''] split_file_panel = ['N', ''] toggle_file_preview_panel = ['f', ''] open_sort_options_menu = ['o', ''] toggle_reverse_sort = ['R', ''] #-- Focus Manipulation focus_on_process_bar = ['ctrl+p', ''] focus_on_sidebar = ['ctrl+s', ''] focus_on_metadata = ['ctrl+d', ''] #-- File/Dir Creation/Renaming file_panel_item_create = ['a', ''] file_panel_item_rename = ['r', ''] #-- Main File Operations copy_items = ['y', ''] cut_items = ['x', ''] paste_items = ['p', ''] delete_items = ['d', ''] permanently_delete_items = ['D', ''] #-- Archive Manipulation extract_file = ['ctrl+e', ''] compress_file = ['ctrl+a', ''] #-- Editor Actions open_file_with_editor = ['e', ''] open_current_directory_with_editor = ['E', ''] #-- Other Actions pinned_directory = ['P', ''] toggle_dot_file = ['.', ''] change_panel_mode = ['m', ''] open_help_menu = ['?', ''] open_spf_prompt = ['>', ''] open_command_line = [':', ''] open_zoxide = ['z', ''] copy_path = ['Y', ''] copy_present_working_directory = ['c', ''] toggle_footer = ['ctrl+f', ''] ############################################################################### # Typing hotkeys # ############################################################################### # Note: These hotkeys can override all hotkeys. confirm_typing = ['enter', ''] cancel_typing = ['esc', ''] ############################################################################### # Mode-Specific Hotkeys # ############################################################################### # Note: These hotkeys can conflict with other modes, but not with global # hotkeys. #-- Normal Mode Actions parent_directory = ['-', ''] search_bar = ['/', ''] #-- Selection Mode Actions file_panel_select_mode_items_select_down = ['J', ''] file_panel_select_mode_items_select_up = ['K', ''] file_panel_select_all_items = ['A', ''] ================================================ FILE: testsuite/.gitignore ================================================ # python venv site packages site-packages/ #python venvs .venv/ # python pycache __pycache__/ *.pyc ================================================ FILE: testsuite/Notes.md ================================================ # Implementation notes - The `pyautogui` sends input to the process in focus, which is the `spf` subprocess. - If `spf` is not exited correcly via `q`, it causes weird vertical tabs in print statements from python - There is some flakiness in sending of input. Many times, `Ctrl+C` is received as `C` in `spf` - If first key is `Ctrl+C`, its always received as `C` - Note : You must keep your focus on the terminal for the entire duration of test run. `pyautogui` sends keypress to process on focus. # To-dos - Write testsuite to validate new files getting created on first launch - We recently had a bug slip into main, where lastVersionFile would not get written ## Input to spf ### Pyautogui alternatives POC with pyautogui as a lot of issues, stated above. #### Linux / macOS - xdotool - Seems complicated. It wont be able to manage spf process that well - mkfifo / Manual linux piping - Too much manual work to send inputs, even if it works - tmux - Supports full terminal programs and has a python wrapper library - See `docs/tmux.md` - Not available for windows - References - https://superuser.com/questions/585398/sending-simulated-keystrokes-in-bash #### Windows - Autohotkey - No better than pyautogui - ControlSend and SendInput utility in windows - Isn't that just for C# / C++ code ? - Python ctypes - https://stackoverflow.com/questions/62189991/how-to-wrap-the-sendinput-function-to-python-using-ctypes - pywin32 library - Create a new GUI window for test - Use `win32gui.SendMessage` or `win32gui.PostMessage` - Probably the correct way, but I havent been able to get it working. - First we need to get it send input to a sample window like notepad, etc. Then we can make superfile work - pywinpty - Heavy installations requirements. Needs Rust, and Visual studio build tools. - Rust cargo not found - Needs rust - link.exe not found (` the msvc targets depend on the msvc linker but link.exe was not found` ) - Needs to install Visual Studio Build Tools (build tools and spectre mitigated libs) - Had to manually find link.exe and put it on the PATH - You might get error of unable to find mspdbcore.dll (I havent been able to solve it so far) - https://stackoverflow.com/questions/67328795/c1356-unable-to-find-mspdbcore-dll - References - https://www.reddit.com/r/tmux/comments/l580mi/is_there_a_tmuxlike_equivalent_for_windows/ ## Directory setup - Programmatic setup is better. - We could keep test directory setup as a config file - json/yaml/toml - or as a hardcoded python dict - Turns out, a in-memory fs is better. We have utilities like copy to actual fs and print tree - Although it has a limitation of not being able to work with large files, as that would consume a lot of RAM - For large files, we could do actually make them only on the actual filesystem, and not use in-memory fs - https://docs.pyfilesystem.org/en/latest/reference/memoryfs.html - https://docs.pyfilesystem.org/en/latest/guide.html ## Tests and Validation - Each tests starts independently, so there is no strict order - Hardcoded validations . Predefined test, where each test has start dir, key press, and validations - We could have a base Class test. where check(), input(), init(), methods would be overrided - It allows greater flexibility in terms of testcases. - Abstraction layer for spf init, teardown and inputm ================================================ FILE: testsuite/README.md ================================================ ## Coding style rules - Prefer using strong typing - Prefer using type hinting for the first time the variable is declared, and for functions paremeters and return types - Use `-> None` to explicitly indicate no return value ### Ideas - Recommended to integrate your IDE with PEP8 to highlight PEP8 violations in real-time - Enforcing PEP8 via `pylint flake8 pycodestyle` and via pre commit hooks ## Writing New testcases - Just create a file ending with `_test.py` in `tests` directory - Any subclass of BaseTest with name ending with `Test` will be executed - see `run_tests` and `get_testcases` in `core/runner.py` for more info ## Setup Requires python 3.9 or later. ## Setup for macOS / Linux ### Install tmux - You need to have tmux installed. See https://github.com/tmux/tmux/wiki ### Python virtual env setup ``` # cd to this directory cd python3 -m venv .venv .venv/bin/pip install --upgrade pip .venv/bin/pip install -r requirements.txt ``` ### Make sure you build spf ``` # cd to the superfile repo root (parent of this) cd ./build.sh ``` ### Running testsuite ``` .venv/bin/python3 main.py ``` ## Setup for Windows Coming soon. ### Python virtual env setup ``` # cd to this directory cd # If your python command refers to python3, you can use 'python' below python3 -m venv .venv .venv\Scripts\python -m pip install --upgrade pip .venv\Scripts\pip install -r requirements.txt ``` ### Make sure you build spf ``` # cd to the superfile repo root (parent of this) cd go build -o bin/spf.exe ``` ### Running testsuite Notes - You must keep your focus on the terminal for the entire duration of test run. `pyautogui` sends keypress to process on focus. ``` .venv\Scripts\python main.py ``` ## Tips while running tests - Use `-d` or `--debug` to enable debug logs during test run. - If you see flakiness in test runs due to superfile being still open, consider using `--close-wait-time` options to increase wait time for superfile to close. Note : For now we have enforcing superfile to close within a specific time window in tests to reduce test flakiness - Make sure that your hotkeys are set to default hotkeys. Tests use default hotkeys for now. - Use `-t` or `--tests` to only run specific tests - Example `python main.py -d -t RenameTest CopyTest` - If you see `libtmux` errors like `libtmux.exc.LibTmuxException: ['no server running on /private/tmp/tmux-501/superfile']` Make sure your python version is up to date ================================================ FILE: testsuite/core/__init__.py ================================================ ================================================ FILE: testsuite/core/base_test.py ================================================ import logging import time from abc import ABC, abstractmethod from core.environment import Environment from pathlib import Path from typing import Union, List, Tuple import core.keys as keys import core.test_constants as tconst class BaseTest(ABC): """Base class for all tests The idea is to have independency among each test. And for each test to have full control on its environment, execution, and validation. """ def __init__(self, test_env : Environment): self.env = test_env self.logger = logging.getLogger() @abstractmethod def setup(self) -> None: """Set up the required things for test """ @abstractmethod def test_execute(self) -> None: """Execute the test """ @abstractmethod def validate(self) -> bool: """Validate that test passed. Log exception if failed. Returns: bool: True if validation passed """ @abstractmethod def cleanup(self) -> None: """Any required cleanup after test is done """ class GenericTestImpl(BaseTest): def __init__(self, *, # Barrier to explicitly require keyword arguements only test_env : Environment, test_root : Path, start_dir : Path, test_dirs : List[Path], key_inputs : List[Union[keys.Keys,str]] = None, test_files : List[Tuple[Path, str]] = None, validate_exists : List[Path] = None, validate_not_exists : List[Path] = None, validate_spf_closed: bool = False, validate_spf_running: bool = False, start_wait_time : float = tconst.START_WAIT_TIME, close_wait_time : float = tconst.CLOSE_WAIT_TIME ): super().__init__(test_env) self.test_root = test_root self.start_dir = start_dir self.spf_opts : List[str] = ["-c", str(tconst.CONFIG_FILE), "--hf", str(tconst.HOTKEY_FILE)] self.test_dirs = test_dirs self.test_files = test_files # TODO fix it : For now first keypress in not being registered, # Need Additional no-operation key press as the first keypress if key_inputs is None: key_inputs = [] key_inputs= ['a'] + key_inputs self.key_inputs = key_inputs self.validate_exists = validate_exists self.validate_not_exists = validate_not_exists self.validate_spf_closed = validate_spf_closed self.validate_spf_running = validate_spf_running if start_wait_time < 0 or close_wait_time < 0: raise ValueError("wait times must be non-negative") self.start_wait_time = start_wait_time self.close_wait_time = close_wait_time def setup(self) -> None: for dir_path in self.test_dirs: self.env.fs_mgr.makedirs(dir_path) if self.test_files is not None: for file_path, data in self.test_files: self.env.fs_mgr.create_file(file_path, data) self.logger.debug("Current file structure : \n%s", self.env.fs_mgr.tree(self.test_root)) def start_spf(self) -> None: self.env.spf_mgr.start_spf(self.env.fs_mgr.abspath(self.start_dir), self.spf_opts) time.sleep(self.start_wait_time) assert self.env.spf_mgr.is_spf_running(), "superfile is not running" def end_execution(self) -> None: self.env.spf_mgr.send_special_input(keys.KEY_ESC) time.sleep(self.close_wait_time) self.logger.debug("Finished Execution") def send_input(self) -> None: if self.key_inputs is not None: for cur_input in self.key_inputs: if isinstance(cur_input, keys.Keys): self.env.spf_mgr.send_special_input(cur_input) else: assert isinstance(cur_input, str), "Invalid input type" self.env.spf_mgr.send_text_input(cur_input) time.sleep(tconst.KEY_DELAY) def test_execute(self) -> None: """Execute the test """ self.start_spf() self.send_input() time.sleep(tconst.OPERATION_DELAY) self.end_execution() def validate(self) -> bool: """Validate that test passed. Log exception if failed. Returns: bool: True if validation passed """ self.logger.debug("spf_manager info : %s, Current file structure : \n%s", self.env.spf_mgr.runtime_info(), self.env.fs_mgr.tree(self.test_root)) try: if self.validate_spf_closed : assert not self.env.spf_mgr.is_spf_running(), "superfile is still running" if self.validate_spf_running : assert self.env.spf_mgr.is_spf_running(), "superfile is not running" if self.validate_exists is not None: for file_path in self.validate_exists: assert self.env.fs_mgr.check_exists(file_path), f"File {file_path} does not exists" if self.validate_not_exists is not None: for file_path in self.validate_not_exists: assert not self.env.fs_mgr.check_exists(file_path), f"File {file_path} exists" except AssertionError as ae: self.logger.debug("Test assertion failed : %s", ae, exc_info=True) return False return True def cleanup(self) -> None: # Cleanup after test is done if self.env.spf_mgr.is_spf_running(): self.env.spf_mgr.close_spf() def __repr__(self) -> str: return f"{self.__class__.__name__}" ================================================ FILE: testsuite/core/environment.py ================================================ from core.spf_manager import BaseSPFManager from core.fs_manager import TestFSManager class Environment: """Manage test environment Manage cleanup of environment and other stuff at a single place """ def __init__(self, spf_manager : BaseSPFManager, fs_manager : TestFSManager ): self.spf_mgr = spf_manager self.fs_mgr = fs_manager def cleanup(self) -> None: self.spf_mgr.close_spf() self.fs_mgr.cleanup() ================================================ FILE: testsuite/core/fs_manager.py ================================================ import logging from tempfile import TemporaryDirectory from pathlib import Path import os from io import StringIO class TestFSManager: """Manage the temporary files for test and the cleanup """ def __init__(self): self.logger = logging.getLogger() self.logger.debug("Initialized %s", self.__class__.__name__) self.temp_dir_obj = TemporaryDirectory() self.temp_dir = Path(self.temp_dir_obj.name) def abspath(self, relative_path : Path) -> Path: return self.temp_dir / relative_path def check_exists(self, relative_path : Path) -> bool: return self.abspath(relative_path).exists() def read_file(self, relative_path: Path) -> str: content = "" try: with open(self.abspath(relative_path), 'r', encoding="utf-8") as f: content = f.read() except FileNotFoundError: self.logger.error("File not found: %s", relative_path) except PermissionError: self.logger.error("Permission denied when reading file: %s", relative_path) return content def makedirs(self, relative_path : Path) -> None: # Overloaded '/' operator os.makedirs(self.temp_dir / relative_path, exist_ok=True) def create_file(self, relative_path : Path, data : str = "") -> None: """Create files Make sure directories exist Args: relative_path (Path): Relative path from test root """ with open(self.temp_dir / relative_path, 'w', encoding="utf-8") as f: f.write(data) def tree(self, relative_root : Path = None) -> str: if relative_root is None: root = self.temp_dir else: root = self.temp_dir / relative_root res = StringIO() for item in root.rglob('*'): path_str = str(item.relative_to(root)) if item.is_dir(): res.write(f"D-{path_str}\n") else: res.write(f"F-{path_str}\n") return res.getvalue() def cleanup(self) -> None: """Cleaup the temporary directory Its okay to forget it though, it will be cleaned on program exit then. """ self.temp_dir_obj.cleanup() def __repr__(self) -> str: return f"{self.__class__.__name__}(temp_dir = {self.temp_dir})" ================================================ FILE: testsuite/core/keys.py ================================================ from abc import ABC import platform class Keys(ABC): def __init__(self, ascii_code : int): self.ascii_code = ascii_code def __repr__(self) -> str: return f"Key(code={self.ascii_code})" # Will isinstance of Keys work for object of CtrlKeys ? class CtrlKeys(Keys): def __init__(self, char : str): # Only allowing single alphabetic character # assert is good here as all objects are defined statically assert len(char) == 1 assert char.isalpha() and char.islower() self.char = char # Ctrl + A starts at 1 super().__init__(ord(char) - ord('a') + 1) # Maybe have keycode class SpecialKeys(Keys): def __init__(self, ascii_code : int, key_name : str): super().__init__(ascii_code) self.key_name = key_name KEY_CTRL_A : Keys = CtrlKeys('a') KEY_CTRL_C : Keys = CtrlKeys('c') KEY_CTRL_E : Keys = CtrlKeys('e') KEY_CTRL_D : Keys = CtrlKeys('d') KEY_CTRL_M : Keys = CtrlKeys('m') KEY_CTRL_P : Keys = CtrlKeys('p') KEY_CTRL_R : Keys = CtrlKeys('r') KEY_CTRL_V : Keys = CtrlKeys('v') KEY_CTRL_W : Keys = CtrlKeys('w') KEY_CTRL_X : Keys = CtrlKeys('x') # Platform specific keys KEY_PASTE : Keys = KEY_CTRL_V if platform.system() == "Windows" : KEY_PASTE = KEY_CTRL_W # See https://vimdoc.sourceforge.net/htmldoc/digraph.html#digraph-table for key codes # If keyname is not the same string as key code in pyautogui, need to handle separately KEY_BACKSPACE : Keys = SpecialKeys(8 , "Backspace") KEY_ENTER : Keys = SpecialKeys(13, "Enter") KEY_ESC : Keys = SpecialKeys(27, "Esc") KEY_DELETE : Keys = SpecialKeys(127 , "Delete") NO_ASCII = -1 # Some keys dont have ascii codes, they have to be handled separately # Make sure key name is the same string as key code for Tmux KEY_DOWN : Keys = SpecialKeys(NO_ASCII, "Down") KEY_UP : Keys = SpecialKeys(NO_ASCII, "Up") KEY_LEFT : Keys = SpecialKeys(NO_ASCII, "Left") KEY_RIGHT : Keys = SpecialKeys(NO_ASCII, "Right") ================================================ FILE: testsuite/core/pyautogui_manager.py ================================================ import time import subprocess import pyautogui import core.keys as keys from core.spf_manager import BaseSPFManager class PyAutoGuiSPFManager(BaseSPFManager): """Manage SPF via subprocesses and pyautogui Cross platform, but it globally takes over the input, so you need the terminal constantly on focus during test run """ SPF_START_DELAY : float = 0.5 def __init__(self, spf_path : str): super().__init__(spf_path) self.spf_process = None def start_spf(self, start_dir : str = None, args : list[str] = None) -> None: spf_args = [self.spf_path] if args : spf_args += args spf_args.append(start_dir) self.spf_process = subprocess.Popen(spf_args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) time.sleep(PyAutoGuiSPFManager.SPF_START_DELAY) # Need to send a sample keypress otherwise it ignores first keypress self.send_text_input('x') def send_text_input(self, text : str, all_at_once : bool = False) -> None: if all_at_once : pyautogui.write(text) else: for c in text: pyautogui.write(c) def send_special_input(self, key : keys.Keys) -> None: if isinstance(key, keys.CtrlKeys): pyautogui.hotkey('ctrl', key.char) elif isinstance(key, keys.SpecialKeys): pyautogui.press(key.key_name.lower()) else: raise Exception(f"Unknown key : {key}") def get_rendered_output(self) -> str: return "[Not supported yet]" def is_spf_running(self) -> bool: self._is_spf_running = (self.spf_process is not None) and (self.spf_process.poll() is None) return self._is_spf_running def close_spf(self) -> None: if self.spf_process is not None: self.spf_process.terminate() # Override def runtime_info(self) -> str: if self.spf_process is None: return "[No process]" else: return f"[PID : {self.spf_process.pid}, poll : {self.spf_process.poll()}]" ================================================ FILE: testsuite/core/runner.py ================================================ from core.spf_manager import BaseSPFManager from core.fs_manager import TestFSManager from core.environment import Environment from core.base_test import BaseTest import logging import platform import importlib from pathlib import Path from typing import List # Preferred importing at the top level if platform.system() == "Windows" : # Conditional import is needed to make it work on linux # importing pyautogui on linux can cause errors. from core.pyautogui_manager import PyAutoGuiSPFManager else: from core.tmux_manager import TmuxSPFManager logger = logging.getLogger() def get_testcases(test_env : Environment, only_run_tests : List[str] = None) -> List[BaseTest]: res : List[BaseTest] = [] test_dir = Path(__file__).parent.parent / "tests" for test_file in test_dir.glob("*_test.py"): # Import dynamically module_name = test_file.stem module = importlib.import_module(f"tests.{module_name}") for attr_name in dir(module): if only_run_tests is not None and attr_name not in only_run_tests: continue attr = getattr(module, attr_name) if isinstance(attr, type) and attr is not BaseTest and issubclass(attr, BaseTest) \ and attr_name.endswith("Test"): logger.debug("Found a testcase %s, in module %s", attr_name, module_name) res.append(attr(test_env)) return res def run_tests(spf_path : Path, stop_on_fail : bool = True, only_run_tests : List[str] = None) -> bool: """Runs tests Args: spf_path (Path): Path of spf binary under test stop_on_fail (bool, optional): Whether to stop on failures. Defaults to True. only_run_tests (List[str], optional): Only specific test to run. Defaults to None. Returns: bool: Whether run was successful """ # is this str conversion needed ? spf_manager : BaseSPFManager = None if platform.system() == "Windows" : spf_manager = PyAutoGuiSPFManager(str(spf_path)) else: spf_manager = TmuxSPFManager(str(spf_path)) fs_manager = TestFSManager() test_env = Environment(spf_manager, fs_manager) cnt_passed : int = 0 cnt_executed : int = 0 try: testcases : List[BaseTest] = get_testcases(test_env, only_run_tests=only_run_tests) logger.info("Testcases : %s", testcases) for t in testcases: logger.info("Running test %s", t) t.setup() t.test_execute() cnt_executed += 1 passed : bool = t.validate() t.cleanup() if passed: logger.info("Passed test %s", t) cnt_passed += 1 else: logger.error("Failed test %s", t) if stop_on_fail: break logger.info("Finished running %s test. %s passed", cnt_executed, cnt_passed) finally: # Make sure of cleanup # This is still not full proof, as if what happens when TestFSManager __init__ fails ? test_env.cleanup() return cnt_passed == cnt_executed ================================================ FILE: testsuite/core/spf_manager.py ================================================ from abc import ABC, abstractmethod import core.keys as keys class BaseSPFManager(ABC): def __init__(self, spf_path : str): self.spf_path = spf_path # _ denotes the internal variables, anyone should not directly read/modify self._is_spf_running : bool = False @abstractmethod def start_spf(self, start_dir : str = None, args : list[str] = None) -> None: pass @abstractmethod def send_text_input(self, text : str, all_at_once : bool = False) -> None: pass @abstractmethod def send_special_input(self, key : keys.Keys) -> None: pass @abstractmethod def get_rendered_output(self) -> str: pass @abstractmethod def is_spf_running(self) -> bool: """ We allow using _is_spf_running variable for efficiency But this method should give the true state, although this might have some calculations """ return self._is_spf_running @abstractmethod def close_spf(self) -> None: """ Close spf if its running and cleanup any other resources """ def runtime_info(self) -> str: return "[No runtime info]" ================================================ FILE: testsuite/core/test_constants.py ================================================ import platform from pathlib import Path FILE_TEXT1 : str = "This is a sample Text\n" KEY_DELAY : float = 0.05 # seconds OPERATION_DELAY : float = 0.3 # seconds # 0.3 second was too less for windows # 0.5 second Github workflow failed for with superfile is still running errors START_WAIT_TIME : float = 0.5 # seconds CLOSE_WAIT_TIME : float = 0.5 # seconds # Platform specific consts FILE_CREATE_COMMAND : str = "touch" if platform.system() == "Windows" : FILE_CREATE_COMMAND = "ni" CONF_DIR = Path(__file__).parent.parent.parent / "src" / "superfile_config" CONFIG_FILE = CONF_DIR / "config.toml" HOTKEY_FILE = CONF_DIR / "hotkeys.toml" ================================================ FILE: testsuite/core/tmux_manager.py ================================================ import libtmux import time import logging import core.keys as keys from core.spf_manager import BaseSPFManager class TmuxSPFManager(BaseSPFManager): """ Tmux based Manager After running spf, you can connect to the session via tmux -L superfile attach -t spf_session Wont work in windows """ # Class variables SPF_START_DELAY : float = 0.1 # seconds SPF_SOCKET_NAME : str = "superfile" # Init should not allocate any resources def __init__(self, spf_path : str): super().__init__(spf_path) # Check libtmux version requirement min_version = (0, 31, 0) current_version_str = libtmux.__version__ # Parse version string to tuple for comparison try: current_version = tuple(map(int, current_version_str.split('.')[:3])) except (ValueError, AttributeError): current_version = (0, 0, 0) if current_version < min_version: raise RuntimeError( f"libtmux version 0.31.0 or higher is required. " f"Current version: {current_version_str}. " f"Please upgrade with: pip install 'libtmux>=0.31.0'" ) self.logger = logging.getLogger() self.server = libtmux.Server(socket_name=TmuxSPFManager.SPF_SOCKET_NAME) self.logger.debug("server object : %s", self.server) self.spf_session : libtmux.Session = None self.spf_pane : libtmux.Pane = None def start_spf(self, start_dir : str = None, args : list[str] = None) -> None: spf_command = self.spf_path if args: spf_command += " " + " ".join(args) self.logger.debug("windows_command : %s", spf_command) self.spf_session= self.server.new_session('spf_session', window_command=spf_command, start_directory=start_dir) time.sleep(TmuxSPFManager.SPF_START_DELAY) self.logger.debug("spf_session initialised : %s", self.spf_session) # If libtmux version is less than 0.3.1, active_pane does not exist. self.spf_pane = self.spf_session.active_pane self._is_spf_running = True def _send_key(self, key : str) -> None: self.logger.debug("sending key : %s", repr(key)) self.spf_pane.send_keys(key, enter=False) def send_text_input(self, text : str, all_at_once : bool = True) -> None: if all_at_once: self._send_key(text) else: for c in text: self._send_key(c) def send_special_input(self, key : keys.Keys) -> str: if key.ascii_code != keys.NO_ASCII: self._send_key(chr(key.ascii_code)) elif isinstance(key, keys.SpecialKeys): self._send_key(key.key_name) else: raise Exception(f"Unknown key : {key}") def get_rendered_output(self) -> str: return "[Not supported yet]" def is_spf_running(self) -> bool: self._is_spf_running = (self.spf_session is not None) \ and (self.spf_session in self.server.sessions) return self._is_spf_running def close_spf(self) -> None: if self.is_spf_running(): self.server.kill_session(self.spf_session.name) # Override def runtime_info(self) -> str: return str(self.server.sessions) def __repr__(self) -> str: return f"{self.__class__.__name__}(server : {self.server}, " + \ f"session : {self.spf_session}, running : {self._is_spf_running})" ================================================ FILE: testsuite/core/utils.py ================================================ import pyperclip # ------ Clipboard utils # This creates a layer of abstraction. # Now the user of the fuction doesn't need to import pyperclip # or need to even know what pyperclip was used. def get_sys_clipboard_text() -> str : return pyperclip.paste() ================================================ FILE: testsuite/docs/tmux.md ================================================ # Overview This is to document the behviour of tmux, and how could we use it in testsuite # Tmux concepts and working info - Tmux creates a main server process, and one new process for each session. image - `-s` and `-n` for window naming. - We have prefix keys to send commands to tmux. # Sample usage with spf ## Sending keys to termux and controlling from outside. image image # Knowledge sharing - `tmux new 'spf'` - Run spf in tmux - `tmux attach -t ` attach to an existing session. You can have two windows duplicating same behaviour. - `tmux kill-session -t ` kill session - `Ctrl+B`+`:` - Enter commands - `Ctrl+B`+`D` - Detach from session - `:source ~/.tmux.conf` - Change the config of running server - We have already a wrapper library for termux in python !!!!! - How to send key press/tmux commands to the process ? # References - https://github.com/tmux/tmux/wiki/Getting-Started - https://tao-of-tmux.readthedocs.io/en/latest/manuscript/10-scripting.html#controlling-tmux-send-keys - https://github.com/tmux-python/libtmux ================================================ FILE: testsuite/main.py ================================================ import argparse import logging import sys from pathlib import Path from core.runner import run_tests import core.test_constants as tconst def configure_logging(debug : bool = False) -> None: # Prefer stdout instead of default stderr handler = logging.StreamHandler(sys.stdout) # 7s to align all log levelnames - WARNING is the largest level, with size 7 handler.setFormatter(logging.Formatter( '[%(asctime)s - %(levelname)7s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S' )) logger = logging.getLogger() logger.addHandler(handler) if debug: logger.setLevel(logging.DEBUG) else: logger.setLevel(logging.INFO) logging.getLogger("libtmux").setLevel(logging.WARNING) def main(): # Setup argument parser parser = argparse.ArgumentParser(description='superfile testsuite') parser.add_argument('-d', '--debug',action='store_true', help='Enable debug logging') parser.add_argument('--close-wait-time', type=float, help='Override default wait time after closing spf') parser.add_argument('--spf-path', type=str, help='Override the default spf executable path(../bin/spf) under test') parser.add_argument('-t', '--tests', nargs='+', help='Specify one or more than one space separated testcases to be run') # Parse arguments args = parser.parse_args() if args.close_wait_time is not None: tconst.CLOSE_WAIT_TIME = args.close_wait_time configure_logging(args.debug) # Default path # We maybe should run this only in main.py file. spf_path = Path(__file__).parent.parent / "bin" / "spf" if args.spf_path is not None: spf_path = Path(args.spf_path) # Resolve any symlinks, and make it absolute spf_path = spf_path.resolve() success = run_tests(spf_path, only_run_tests=args.tests) if success: sys.exit(0) else: sys.exit(1) main() ================================================ FILE: testsuite/requirements.txt ================================================ pyautogui; sys_platform == "win32" libtmux; sys_platform == "linux" or sys_platform == "darwin" pyperclip assertpy ================================================ FILE: testsuite/tests/__init__.py ================================================ ================================================ FILE: testsuite/tests/chooser_file_test.py ================================================ from pathlib import Path import time from core.base_test import GenericTestImpl from core.environment import Environment import core.test_constants as tconst TESTROOT = Path("chooser_file_ops") DIR1 = TESTROOT / "dir1" DIR2 = TESTROOT / "dir2" FILE1 = DIR1 / "file1.txt" CHOOSER_FILE = DIR2 / "chooser_file.txt" class ChooserFileTest(GenericTestImpl): def __init__(self, test_env : Environment): super().__init__( test_env=test_env, test_root=TESTROOT, start_dir=DIR1, test_dirs=[DIR1, DIR2], test_files=[(FILE1, tconst.FILE_TEXT1)], key_inputs=['e'], validate_spf_closed=True, close_wait_time=3 ) self.spf_opts += ["--chooser-file", str(self.env.fs_mgr.abspath(CHOOSER_FILE))] # Override def end_execution(self) -> None: self.logger.debug("Skipping esc key press for Chooser file test") time.sleep(self.close_wait_time) self.logger.debug("Finished Execution") # Override def validate(self) -> bool: if not super().validate(): return False try: assert self.env.fs_mgr.check_exists(CHOOSER_FILE), f"File {CHOOSER_FILE} does not exists" chooser_file_content = self.env.fs_mgr.read_file(CHOOSER_FILE) assert chooser_file_content == str(self.env.fs_mgr.abspath(FILE1)), \ f"Expected '{self.env.fs_mgr.abspath(FILE1)}', got '{chooser_file_content}'" except AssertionError as ae: self.logger.debug("Test assertion failed : %s", ae, exc_info=True) return False return True ================================================ FILE: testsuite/tests/command_test.py ================================================ from pathlib import Path from core.base_test import GenericTestImpl from core.environment import Environment import core.keys as keys import core.test_constants as tconst TESTROOT = Path("cmd_ops") DIR1 = TESTROOT / "dir1" FILE1 = TESTROOT / "file1" class CommandTest(GenericTestImpl): """Test compression and extraction """ def __init__(self, test_env : Environment): super().__init__( test_env=test_env, test_root=TESTROOT, start_dir=TESTROOT, test_dirs=[TESTROOT], key_inputs=[':', 'mkdir dir1', keys.KEY_ENTER, ':', tconst.FILE_CREATE_COMMAND + ' file1', keys.KEY_ENTER], validate_exists=[DIR1, FILE1] ) ================================================ FILE: testsuite/tests/compress_extract_test.py ================================================ from pathlib import Path from core.base_test import GenericTestImpl from core.environment import Environment import core.test_constants as tconst import core.keys as keys TESTROOT = Path("ce_ops") DIR1 = TESTROOT / "dir1" FILE1 = DIR1 / "file1" FILE2 = DIR1 / "file2" DIR1_ZIPPED = TESTROOT / "dir1.zip" DIR1_EXTRACTED = TESTROOT / "dir1(1)" / "dir1" FILE1_EXTRACTED = DIR1_EXTRACTED / "file1" FILE2_EXTRACTED = DIR1_EXTRACTED / "file2" class CompressExtractTest(GenericTestImpl): """Test compression and extraction Args: GenericTestImpl (_type_): _description_ """ def __init__(self, test_env : Environment): super().__init__( test_env=test_env, test_root=TESTROOT, start_dir=TESTROOT, test_dirs=[DIR1], test_files=[(FILE1, tconst.FILE_TEXT1), (FILE2, tconst.FILE_TEXT1)], key_inputs=[keys.KEY_CTRL_A, keys.KEY_DOWN, keys.KEY_CTRL_E], validate_exists=[DIR1, DIR1_ZIPPED, DIR1_EXTRACTED, FILE1_EXTRACTED, FILE2_EXTRACTED] ) ================================================ FILE: testsuite/tests/copy_dir_test.py ================================================ from pathlib import Path from core.base_test import GenericTestImpl from core.environment import Environment import core.test_constants as tconst import core.keys as keys TESTROOT = Path("copy_dir") DIR1 = TESTROOT / "dir1" NESTED_DIR1 = DIR1 / "nested1" NESTED_DIR2 = DIR1 / "nested2" FILE1 = NESTED_DIR1 / "file1.txt" DIR2 = TESTROOT / "dir2" DIR1_COPIED = DIR2 / "dir1" FILE1_COPIED = DIR1_COPIED / "nested1" / "file1.txt" class CopyDirTest(GenericTestImpl): def __init__(self, test_env : Environment): super().__init__( test_env=test_env, test_root=TESTROOT, start_dir=TESTROOT, test_dirs=[DIR1, DIR2, NESTED_DIR1, NESTED_DIR2], test_files=[(FILE1, tconst.FILE_TEXT1)], key_inputs=[keys.KEY_CTRL_C, keys.KEY_DOWN, keys.KEY_ENTER, keys.KEY_PASTE], validate_exists=[DIR1_COPIED, FILE1_COPIED, DIR1, FILE1] ) ================================================ FILE: testsuite/tests/copy_test.py ================================================ from pathlib import Path from core.base_test import GenericTestImpl from core.environment import Environment import core.test_constants as tconst import core.keys as keys TESTROOT = Path("copy_ops") DIR1 = TESTROOT / "dir1" DIR2 = TESTROOT / "dir2" FILE1 = DIR1 / "file1.txt" FILE1_COPY1 = DIR1 / "file1(1).txt" FILE1_COPY2 = DIR2 / "file1.txt" class CopyTest(GenericTestImpl): def __init__(self, test_env : Environment): super().__init__( test_env=test_env, test_root=TESTROOT, start_dir=DIR1, test_dirs=[DIR1, DIR2], test_files=[(FILE1, tconst.FILE_TEXT1)], key_inputs=[keys.KEY_CTRL_C, keys.KEY_PASTE], validate_exists=[FILE1, FILE1_COPY1], # If you want to validate spf being close, wait time needs to be high # Otherwise tests are flaky validate_spf_closed=True, close_wait_time=3 ) ================================================ FILE: testsuite/tests/copyw_test.py ================================================ from pathlib import Path from core.base_test import GenericTestImpl from core.environment import Environment import core.test_constants as tconst import core.keys as keys TESTROOT = Path("copyw_ops") FILE1 = TESTROOT / "file1.txt" FILE1_COPY1 = TESTROOT / "file1(1).txt" class CopyWTest(GenericTestImpl): """Testcase to validate copying with Ctrl+W shortcut """ def __init__(self, test_env : Environment): super().__init__( test_env=test_env, test_root=TESTROOT, start_dir=TESTROOT, test_dirs=[TESTROOT], test_files=[(FILE1, tconst.FILE_TEXT1)], key_inputs=[keys.KEY_CTRL_C, keys.KEY_CTRL_W], validate_exists=[FILE1, FILE1_COPY1] ) ================================================ FILE: testsuite/tests/cut_test.py ================================================ from pathlib import Path from core.base_test import GenericTestImpl from core.environment import Environment import core.test_constants as tconst import core.keys as keys TESTROOT = Path("cut_ops") DIR1 = TESTROOT / "dir1" DIR2 = TESTROOT / "dir2" FILE1 = DIR1 / "file1.txt" FILE1_CUT1 = DIR2 / "file1.txt" class CutTest(GenericTestImpl): def __init__(self, test_env : Environment): super().__init__( test_env=test_env, test_root=TESTROOT, start_dir=DIR1, test_dirs=[DIR1, DIR2], test_files=[(FILE1, tconst.FILE_TEXT1)], key_inputs=[keys.KEY_CTRL_X, keys.KEY_LEFT, keys.KEY_DOWN, keys.KEY_ENTER, keys.KEY_PASTE], validate_exists=[FILE1_CUT1], validate_not_exists=[FILE1] ) ================================================ FILE: testsuite/tests/delete_dir_test.py ================================================ from pathlib import Path from core.base_test import GenericTestImpl from core.environment import Environment import core.test_constants as tconst import core.keys as keys TESTROOT = Path("delete_dir") DIR1 = TESTROOT / "dir1" NESTED_DIR1 = DIR1 / "nested1" NESTED_DIR2 = DIR1 / "nested2" FILE1 = NESTED_DIR1 / "file1.txt" class DeleteDirTest(GenericTestImpl): def __init__(self, test_env : Environment): super().__init__( test_env=test_env, test_root=TESTROOT, start_dir=TESTROOT, test_dirs=[TESTROOT, DIR1, NESTED_DIR1, NESTED_DIR2], test_files=[(FILE1, tconst.FILE_TEXT1)], key_inputs=[keys.KEY_CTRL_D, keys.KEY_ENTER], validate_not_exists=[DIR1, NESTED_DIR1, NESTED_DIR2, FILE1] ) ================================================ FILE: testsuite/tests/delete_test.py ================================================ from pathlib import Path from core.base_test import GenericTestImpl from core.environment import Environment import core.test_constants as tconst import core.keys as keys TESTROOT = Path("delete_ops") FILE1 = TESTROOT / "file_to_delete.txt" class DeleteTest(GenericTestImpl): def __init__(self, test_env : Environment): super().__init__( test_env=test_env, test_root=TESTROOT, start_dir=TESTROOT, test_dirs=[TESTROOT], test_files=[(FILE1, tconst.FILE_TEXT1)], key_inputs=[keys.KEY_CTRL_D, keys.KEY_ENTER], validate_not_exists=[FILE1] ) ================================================ FILE: testsuite/tests/empty_panel_test.py ================================================ from pathlib import Path from core.base_test import GenericTestImpl from core.environment import Environment import core.test_constants as tconst import core.keys as keys import time TESTROOT = Path("empty_panel_ops") DIR1 = TESTROOT / "dir1" class EmptyPanelTest(GenericTestImpl): """ Validate that spf doesn't crashes when we try to perform operations on empty file panel """ def __init__(self, test_env : Environment): super().__init__( test_env=test_env, test_root=TESTROOT, start_dir=DIR1, test_dirs=[DIR1], key_inputs=[ keys.KEY_CTRL_C, # Try copy keys.KEY_CTRL_X, # Try cut keys.KEY_CTRL_D, # Try delete keys.KEY_PASTE, # Try paste keys.KEY_CTRL_R, # Try rename keys.KEY_CTRL_P, # Try copy location 'e', # Try open with editor keys.KEY_ENTER, keys.KEY_RIGHT, keys.KEY_CTRL_A, # Try archiving keys.KEY_CTRL_E, # Try extract 'v', # Try going to Select mode 'J', # Try select down 'K', # Try select up 'A', # select all 'v', '.', # Try toggle dotfiles ], # Makes sure spf doesn't crashes validate_spf_running=True ) # Override def test_execute(self) -> None: self.start_spf() self.send_input() time.sleep(tconst.OPERATION_DELAY) # Intentionally not closing spf to ensure it remains running, # which is verified by the validate_spf_running flag which is set # to true for this testcase ================================================ FILE: testsuite/tests/nav_and_copy_path_test.py ================================================ from pathlib import Path from core.base_test import GenericTestImpl from core.environment import Environment from core.utils import get_sys_clipboard_text import core.test_constants as tconst import core.keys as keys from assertpy import assert_that import time TESTROOT = Path("nav_ops") DIR1 = TESTROOT / "dir1" FILE1 = TESTROOT / "file1" FILE2 = TESTROOT / "file2" # Temporarily disabled, till we fix xclip does not works in github actions class NavCopyPathTest_Disabled(GenericTestImpl): """Test navigation, and Copying of path """ def __init__(self, test_env : Environment): super().__init__( test_env=test_env, test_root=TESTROOT, start_dir=TESTROOT, test_dirs=[TESTROOT, DIR1], test_files=[(FILE1, tconst.FILE_TEXT1), (FILE2, tconst.FILE_TEXT1)] ) # Override def test_execute(self) -> None: self.start_spf() time.sleep(tconst.OPERATION_DELAY) # > dir1 # file1 # file2 self.env.spf_mgr.send_special_input(keys.KEY_CTRL_P) time.sleep(tconst.KEY_DELAY) assert_that(get_sys_clipboard_text()).is_equal_to(str(self.env.fs_mgr.abspath(DIR1))) self.env.spf_mgr.send_special_input(keys.KEY_DOWN) time.sleep(tconst.KEY_DELAY) # dir1 # > file1 # file2 self.env.spf_mgr.send_special_input(keys.KEY_CTRL_P) time.sleep(tconst.KEY_DELAY) assert_that(get_sys_clipboard_text()).is_equal_to(str(self.env.fs_mgr.abspath(FILE1))) self.env.spf_mgr.send_special_input(keys.KEY_DOWN) time.sleep(tconst.KEY_DELAY) # dir1 # file1 # > file2 self.env.spf_mgr.send_special_input(keys.KEY_CTRL_P) time.sleep(tconst.KEY_DELAY) assert_that(get_sys_clipboard_text()).is_equal_to(str(self.env.fs_mgr.abspath(FILE2))) self.env.spf_mgr.send_special_input(keys.KEY_UP) time.sleep(tconst.KEY_DELAY) # dir1 # > file1 # file2 self.env.spf_mgr.send_special_input(keys.KEY_CTRL_P) time.sleep(tconst.KEY_DELAY) assert_that(get_sys_clipboard_text()).is_equal_to(str(self.env.fs_mgr.abspath(FILE1))) self.env.spf_mgr.send_special_input(keys.KEY_DOWN) time.sleep(tconst.KEY_DELAY) self.env.spf_mgr.send_special_input(keys.KEY_DOWN) time.sleep(tconst.KEY_DELAY) # > dir1 # file1 # file2 self.env.spf_mgr.send_special_input(keys.KEY_CTRL_P) time.sleep(tconst.KEY_DELAY) assert_that(get_sys_clipboard_text()).is_equal_to(str(self.env.fs_mgr.abspath(DIR1))) self.end_execution() ================================================ FILE: testsuite/tests/rename_test.py ================================================ from pathlib import Path from core.base_test import GenericTestImpl from core.environment import Environment import core.test_constants as tconst import core.keys as keys TESTROOT = Path("rename_ops") DIR1 = TESTROOT / "dir1" # No extension, as in case of extension, the edit cursor appears before the dot, # not at the end of filename FILE1 = DIR1 / "file1" FILE1_RENAMED = DIR1 / "file2" class RenameTest(GenericTestImpl): def __init__(self, test_env : Environment): super().__init__( test_env=test_env, test_root=TESTROOT, start_dir=DIR1, test_dirs=[DIR1], test_files=[(FILE1, tconst.FILE_TEXT1)], key_inputs=[keys.KEY_CTRL_R, keys.KEY_BACKSPACE, '2', keys.KEY_ENTER], validate_exists=[FILE1_RENAMED], validate_not_exists=[FILE1] ) ================================================ FILE: vhs/demo.tape ================================================ Output asset/demo.gif Set Shell "base" Set FontSize 20 Set Width 1920 Set Height 1080 Set FontFamily "Comic Mono, RobotoMono Nerd Font" Set Framerate 15 Type "spf" Sleep 1500ms Enter Sleep 700ms Type "b" Sleep 500ms Down@600ms 1 Type "l" Ctrl+p Sleep 600ms Ctrl+n Sleep 300ms Ctrl+n Sleep 300ms Ctrl+n Sleep 300ms Ctrl+w Sleep 300ms Ctrl+w Sleep 700ms Type "b" Down@600ms 2 Sleep 600ms Type "l" Sleep 600ms Tab Sleep 600ms Type "c" Sleep 500ms Type "test.txt" Sleep 500ms Enter Sleep 600ms Down@700ms 1 Sleep 600ms Type "d" Sleep 700ms Type "f" Sleep 600ms Type "test folder" Sleep 700ms Enter Sleep 700ms Type "r" Sleep 600ms Backspace 12 Type "rename this folder" Sleep 500ms Enter Sleep 700ms Type "d" Sleep 600ms Tab Sleep 600ms Ctrl+c Sleep 600ms Tab Sleep 600ms Ctrl+v Sleep 600ms Type "l" Sleep 600ms Type "d" Sleep 300ms Type "d" Sleep 300ms Type "d" Sleep 300ms Type "v" Sleep 600ms Type "J" Sleep 500ms Type "J" Sleep 500ms Type "J" Sleep 600ms Ctrl+d Sleep 600ms Type "J" Sleep 400ms Type "j" Sleep 400ms Type "J" Sleep 400ms Type "j" Sleep 400ms Type "J" Sleep 400ms Type "j" Sleep 700ms Ctrl+x Sleep 700ms Tab Sleep 600ms Ctrl+v Sleep 700ms Ctrl+w Sleep 500ms Escape Type "Thanks for watching" Sleep 5s ================================================ FILE: vhs/open_spf_and_quit.tape ================================================ Output asset/demo.gif Set Shell "base" Set FontSize 30 Set Width 2560 Set Height 1500 Set FontFamily "Comic Mono, RobotoMono Nerd Font" Set PlaybackSpeed 0.6 Set Padding 50 Set Theme { "name": "Whimsy", "black": "#535178", "red": "#ef6487", "green": "#5eca89", "yellow": "#fdd877", "blue": "#65aef7", "magenta": "#aa7ff0", "cyan": "#43c1be", "white": "#ffffff", "brightBlack": "#535178", "brightRed": "#ef6487", "brightGreen": "#5eca89", "brightYellow": "#fdd877", "brightBlue": "#65aef7", "brightMagenta": "#aa7ff0", "brightCyan": "#43c1be", "brightWhite": "#ffffff", "background": "#1e1e2e", "foreground": "#b3b0d6", "selection": "#3d3c58", "cursor": "#b3b0d6" } Type "spf" Sleep 1500ms Enter Sleep 1500ms Type "q" Sleep 1500ms ================================================ FILE: vhs/spf_file_panel_movement.tape ================================================ Output asset/demo.gif Set Shell "base" Set FontSize 30 Set Framerate 60 Set Width 2560 Set Height 1500 Set FontFamily "Comic Mono, RobotoMono Nerd Font" Set PlaybackSpeed 0.6 Set Padding 50 Set Theme { "name": "Whimsy", "black": "#535178", "red": "#ef6487", "green": "#5eca89", "yellow": "#fdd877", "blue": "#65aef7", "magenta": "#aa7ff0", "cyan": "#43c1be", "white": "#ffffff", "brightBlack": "#535178", "brightRed": "#ef6487", "brightGreen": "#5eca89", "brightYellow": "#fdd877", "brightBlue": "#65aef7", "brightMagenta": "#aa7ff0", "brightCyan": "#43c1be", "brightWhite": "#ffffff", "background": "#1e1e2e", "foreground": "#b3b0d6", "selection": "#3d3c58", "cursor": "#b3b0d6" } Type "spf" Sleep 1500ms Enter Sleep 1500ms Down@150ms 5 Sleep 150ms Up@150ms 3 Sleep 700ms Enter Sleep 1500ms ================================================ FILE: vhs/spf_file_panel_navigation.tape ================================================ Output asset/demo.gif Set Shell "base" Set FontSize 30 Set Framerate 60 Set Width 2560 Set Height 1500 Set FontFamily "Comic Mono, RobotoMono Nerd Font" Set PlaybackSpeed 0.6 Set Padding 50 Set Theme { "name": "Whimsy", "black": "#535178", "red": "#ef6487", "green": "#5eca89", "yellow": "#fdd877", "blue": "#65aef7", "magenta": "#aa7ff0", "cyan": "#43c1be", "white": "#ffffff", "brightBlack": "#535178", "brightRed": "#ef6487", "brightGreen": "#5eca89", "brightYellow": "#fdd877", "brightBlue": "#65aef7", "brightMagenta": "#aa7ff0", "brightCyan": "#43c1be", "brightWhite": "#ffffff", "background": "#1e1e2e", "foreground": "#b3b0d6", "selection": "#3d3c58", "cursor": "#b3b0d6" } Type "spf" Sleep 1500ms Enter Sleep 1500ms Ctrl+N Sleep 500ms Ctrl+N Sleep 500ms Ctrl+N Sleep 500ms Ctrl+N Sleep 500ms Type "L" Sleep 200ms Type "L" Sleep 200ms Type "L" Sleep 200ms Type "L" Sleep 200ms Type "H" Sleep 200ms Type "H" Sleep 200ms Type "H" Sleep 200ms Type "H" Sleep 200ms Ctrl+W Sleep 500ms Ctrl+W Sleep 500ms Ctrl+W Sleep 500ms Ctrl+W Sleep 500ms ================================================ FILE: vhs/spf_file_panel_selection_mode.tape ================================================ Output asset/demo.gif Set Shell "base" Set FontSize 30 Set Framerate 60 Set Width 2560 Set Height 1500 Set FontFamily "Comic Mono, RobotoMono Nerd Font" Set PlaybackSpeed 0.6 Set Padding 50 Set Theme { "name": "Whimsy", "black": "#535178", "red": "#ef6487", "green": "#5eca89", "yellow": "#fdd877", "blue": "#65aef7", "magenta": "#aa7ff0", "cyan": "#43c1be", "white": "#ffffff", "brightBlack": "#535178", "brightRed": "#ef6487", "brightGreen": "#5eca89", "brightYellow": "#fdd877", "brightBlue": "#65aef7", "brightMagenta": "#aa7ff0", "brightCyan": "#43c1be", "brightWhite": "#ffffff", "background": "#1e1e2e", "foreground": "#b3b0d6", "selection": "#3d3c58", "cursor": "#b3b0d6" } Type "spf" Sleep 1500ms Enter Sleep 1500ms Type "v" Sleep 600ms Type "l" Sleep 300ms Type "l" Sleep 300ms Type "J" Sleep 300ms Type "J" Sleep 300ms Type "J" Sleep 300ms Type "J" Sleep 300ms Type "J" Sleep 300ms Type "l" Sleep 300ms Type "K" Sleep 300ms Type "K" Sleep 300ms Type "K" Sleep 300ms Type "K" Sleep 300ms Ctrl+A Sleep 1500ms Type "v" Sleep 1500ms ================================================ FILE: vhs/spf_panel_navigation.tape ================================================ Output asset/demo.gif Set Shell "base" Set FontSize 30 Set Width 2560 Set Height 1500 Set FontFamily "Comic Mono, RobotoMono Nerd Font" Set PlaybackSpeed 0.6 Set Padding 50 Set Theme { "name": "Whimsy", "black": "#535178", "red": "#ef6487", "green": "#5eca89", "yellow": "#fdd877", "blue": "#65aef7", "magenta": "#aa7ff0", "cyan": "#43c1be", "white": "#ffffff", "brightBlack": "#535178", "brightRed": "#ef6487", "brightGreen": "#5eca89", "brightYellow": "#fdd877", "brightBlue": "#65aef7", "brightMagenta": "#aa7ff0", "brightCyan": "#43c1be", "brightWhite": "#ffffff", "background": "#1e1e2e", "foreground": "#b3b0d6", "selection": "#3d3c58", "cursor": "#b3b0d6" } Type "spf" Sleep 1500ms Enter Sleep 1500ms Type "b" Sleep 1500ms Type "p" Sleep 1500ms Type "m" Sleep 1500ms Type "m" Sleep 1500ms ================================================ FILE: website/README.md ================================================ # Starlight Starter Kit: Basics ``` npm create astro@latest -- --template starlight ``` [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics) [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics) > 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! ## 🚀 Project Structure Inside of your Astro + Starlight project, you'll see the following folders and files: ``` . ├── public/ ├── src/ │ ├── assets/ │ ├── content/ │ │ ├── docs/ │ │ └── config.ts │ └── env.d.ts ├── astro.config.mjs ├── package.json └── tsconfig.json ``` Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name. Images can be added to `src/assets/` and embedded in Markdown with a relative link. Static assets, like favicons, can be placed in the `public/` directory. ## 🧞 Commands All commands are run from the root of the project, from a terminal: | Command | Action | | :------------------------ | :----------------------------------------------- | | `npm install` | Installs dependencies | | `npm run dev` | Starts local dev server at `localhost:3000` | | `npm run build` | Build your production site to `./dist/` | | `npm run preview` | Preview your build locally, before deploying | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | | `npm run astro -- --help` | Get help using the Astro CLI | ## 👀 Want to learn more? Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat). ================================================ FILE: website/astro.config.mjs ================================================ import { defineConfig } from "astro/config"; import starlight from "@astrojs/starlight"; import { pluginLineNumbers } from "@expressive-code/plugin-line-numbers"; import starlightGiscus from "starlight-giscus"; import sitemap from "@astrojs/sitemap"; const site = "https://superfile.dev/"; // https://astro.build/config export default defineConfig({ site: site, integrations: [ sitemap(), starlight({ title: "superfile", description: `superfile is a very fancy and modern terminal file manager that can complete the file operations you need!`, expressiveCode: { themes: ["dracula", "solarized-light"], }, logo: { light: "/src/assets/superfile-day.svg", dark: "/src/assets/superfile-night.svg", replacesTitle: true, }, components: { LastUpdated: "./src/components/LastUpdated.astro", }, plugins: [ starlightGiscus({ repo: "yorukot/superfile", repoId: "R_kgDOLil1MA", category: "Docs Comments", categoryId: "DIC_kwDOLil1MM4CfbH7", mapping: "title", strict: false, reactionsEnabled: true, emitMetadata: false, inputPosition: "top", theme: "preferred_color_scheme", lang: "en", loading: "lazy", }), ], social: [ { icon: "github", label: "GitHub", href: "https://github.com/yorukot/superfile", }, { icon: "discord", label: "Discord", href: "https://discord.gg/YYtJ23Du7B", }, ], head: [ { tag: "meta", attrs: { property: "og:image", content: site + "og.jpg?v=1" }, }, { tag: "meta", attrs: { property: "twitter:image", content: site + "og.jpg?v=1" }, }, { tag: "link", attrs: { rel: "preconnect", href: "https://fonts.googleapis.com" }, }, { tag: "link", attrs: { rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: true, }, }, { tag: "link", attrs: { rel: "preload", href: "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600&display=swap", as: "style", onload: "this.onload=null;this.rel='stylesheet'", }, }, { tag: "noscript", content: '', }, { tag: "script", attrs: { src: "https://cdn.jsdelivr.net/npm/@minimal-analytics/ga4/dist/index.js", async: true, }, }, { tag: "script", content: ` window.minimalAnalytics = { trackingId: 'G-WFLBCRZ7MC', autoTrack: true, };`, }, { tag: "script", attrs: { defer: true, src: "https://umami.yorukot.me/script.js", "data-website-id": "8792ee93-9a4a-47be-9e7f-2b1587c3a3d1", }, }, ], editLink: { baseUrl: "https://github.com/yorukot/superfile/edit/main/website/", }, sidebar: [ { label: "Overview", link: "/overview", }, { label: "Start Here", items: [ { label: "Installation", link: "/getting-started/installation/", }, { label: "Tutorial", link: "/getting-started/tutorial/", }, { label: "Image Preview", link: "/getting-started/image-preview/", }, ], }, { label: "Configure", items: [ { label: "All config file path", link: "/configure/config-file-path", }, { label: "superfile config", link: "/configure/superfile-config/", }, { label: "Custom hotkeys", link: "/configure/custom-hotkeys/", }, { label: "Custom theme", link: "/configure/custom-theme", }, { label: "Enable plugin", link: "/configure/enable-plugin", }, ], }, { label: "List", items: [ { label: "Hotkey list", link: "/list/hotkey-list/", }, { label: "Theme list", link: "/list/theme-list/", }, { label: "Plugin list", link: "/list/plugin-list/", }, ], }, { label: "Contribute", items: [ { label: "How to contribute", link: "/contribute/how-to-contribute", }, { label: "File structure", link: "/contribute/file-struct", }, { label: "Implementation Info", link: "/contribute/implementation-info", }, ], }, { label: "Troubleshooting", link: "/troubleshooting", }, { label: "Special thanks", link: "/special-thanks", }, { label: "Changelog", link: "/changelog", }, ], customCss: ["./src/styles/custom.css"], lastUpdated: true, }), ], // Process images with sharp: https://docs.astro.build/en/guides/assets/#using-sharp image: { service: { entrypoint: "astro/assets/services/sharp", config: { limitInputPixels: false, }, }, }, }); ================================================ FILE: website/ec.config.mjs ================================================ import { pluginCollapsibleSections } from '@expressive-code/plugin-collapsible-sections'; import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers'; /** @type {import('@astrojs/starlight/expressive-code').StarlightExpressiveCodeOptions} */ export default { // Example: Using a custom plugin (which makes this `ec.config.mjs` file necessary) // plugins: [pluginCollapsibleSections(), pluginLineNumbers()], // ... any other options you want to configure }; ================================================ FILE: website/package.json ================================================ { "name": "ossified-orbit", "type": "module", "version": "0.0.1", "scripts": { "dev": "astro dev", "start": "astro dev", "build": "astro build", "preview": "astro preview", "astro": "astro" }, "dependencies": { "@astrojs/sitemap": "^3.4.1", "@astrojs/starlight": "^0.37.0", "@expressive-code/plugin-collapsible-sections": "^0.41.2", "@expressive-code/plugin-line-numbers": "^0.41.2", "@fontsource/ibm-plex-mono": "^5.2.5", "@fontsource/ibm-plex-serif": "^5.2.5", "astro": "^5.15.6", "hast-util-to-html": "^9.0.5", "sharp": "^0.34.1", "starlight-giscus": "^0.8.0" } } ================================================ FILE: website/public/_redirects ================================================ # redirect all /docs requests to the root domain /docs/\* /:splat 301 ================================================ FILE: website/public/google0fdf22175b8dde4d.html ================================================ google-site-verification: google0fdf22175b8dde4d.html ================================================ FILE: website/public/install.ps1 ================================================ param( [switch] $AllUsers ) function FolderIsInPATH($Path_to_directory) { return ([Environment]::GetEnvironmentVariable("PATH", "User") -split ';').TrimEnd('\') -contains $Path_to_directory.TrimEnd('\') } Write-Host -ForegroundColor DarkRed " ______ __ __ " Write-Host -ForegroundColor Red " / \ / |/ | " Write-Host -ForegroundColor DarkYellow " _______ __ __ ______ ______ ______ /`$`$`$`$`$`$ |`$`$/ `$`$ | ______ " Write-Host -ForegroundColor Yellow " / |/ | / | / \ / \ / \ `$`$ |_ `$`$/ / |`$`$ | / \ " Write-Host -ForegroundColor DarkGreen "/`$`$`$`$`$`$`$/ `$`$ | `$`$ |/`$`$`$`$`$`$ |/`$`$`$`$`$`$ |/`$`$`$`$`$`$ |`$`$ | `$`$ |`$`$ |/`$`$`$`$`$`$ |" Write-Host -ForegroundColor Green "`$`$ \ `$`$ | `$`$ |`$`$ | `$`$ |`$`$ `$`$ |`$`$ | `$`$/ `$`$`$`$/ `$`$ |`$`$ |`$`$ `$`$ |" Write-Host -ForegroundColor DarkBlue " `$`$`$`$`$`$ |`$`$ \__`$`$ |`$`$ |__`$`$ |`$`$`$`$`$`$`$`$/ `$`$ | `$`$ | `$`$ |`$`$ |`$`$`$`$`$`$`$`$/ " Write-Host -ForegroundColor Blue " `$`$/ `$`$ `$`$/ `$`$ `$`$/ `$`$ |`$`$ | `$`$ | `$`$ |`$`$ |`$`$ |" Write-Host -ForegroundColor DarkMagenta "`$`$`$`$`$`$`$/ `$`$`$`$`$`$/ `$`$`$`$`$`$`$/ `$`$`$`$`$`$`$/ `$`$/ `$`$/ `$`$/ `$`$/ `$`$`$`$`$`$`$/ " Write-Host -ForegroundColor Magenta " `$`$ | " Write-Host -ForegroundColor DarkRed " `$`$ | " Write-Host -ForegroundColor Red " `$`$/ " Write-Host "" function Get-LatestVersion { try { $release = Invoke-RestMethod -Uri "https://api.github.com/repos/yorukot/superfile/releases/latest" -TimeoutSec 5 $version = $release.tag_name -replace '^v', '' if ([string]::IsNullOrEmpty($version)) { Write-Host "Failed to parse version from GitHub API" exit 1 } return $version } catch { Write-Host "Failed to fetch latest version from GitHub API: $_" exit 1 } } $package = "superfile" $version = if ($env:SPF_INSTALL_VERSION) { $env:SPF_INSTALL_VERSION } else { Get-LatestVersion } $installInstructions = @' This installer is only available for Windows. If you're looking for installation instructions for your operating system, please visit the following link: '@ if ($IsMacOS) { Write-Host @" $installInstructions https://github.com/yorukot/superfile?tab=readme-ov-file#installation "@ exit } if ($IsLinux) { Write-Host @" $installInstructions https://github.com/yorukot/superfile?tab=readme-ov-file#installation "@ exit } $arch = (Get-CimInstance -Class Win32_Processor -Property Architecture).Architecture | Select-Object -First 1 switch ($arch) { 5 { $arch = "arm64" } # ARM 9 { if ([Environment]::Is64BitOperatingSystem) { $arch = "amd64" } } 12 { $arch = "arm64" } # Surface Pro X } if ([string]::IsNullOrEmpty($arch)) { Write-Host @" The installer for system arch ($arch) is not available. "@ exit } $filename = "$package-windows-v$version-$arch.zip" $ProgressPreference = 'SilentlyContinue' #speeds up Download massively, but doesnt show Bits written Write-Host "Checking for superfile installation..." $superfileProgramPath = [Environment]::GetFolderPath("LocalApplicationData") + "\Programs\superfile" $superfileExePath = $superfileProgramPath + "\spf.exe" if (-not (Test-Path $superfileProgramPath)) { New-Item -Path $superfileProgramPath -ItemType Directory -Verbose:$false | Out-Null } else { if (Test-Path $superfileExePath) { $versionOutput = & $superfileExePath --version $versionOutput = $versionOutput.Replace('superfile version v', '') $currentVersionParts = $version -split '\.' | ForEach-Object { [int]$_ } $installedVersionParts = $versionOutput -split '\.' | ForEach-Object { [int]$_ } # Compare versions part by part $isUpToDate = $true for ($i = 0; $i -lt $currentVersionParts.Count; $i++) { if ($currentVersionParts[$i] -gt $installedVersionParts[$i]) { $isUpToDate = $false break } elseif ($currentVersionParts[$i] -lt $installedVersionParts[$i]) { continue } } if ($isUpToDate) { Write-Host "superfile already installed, quitting..." } else { Write-Host "Old version (superfile v$versionOutput) found, removing..." try { if (Test-Path $superfileExePath) { Remove-Item -Path $superfileExePath -Force } } catch { Write-Host "An error occurred: $_" exit } } } else { Write-Host "superfile folder found but not executable :/, please check your %localappdata%\Programs\superfile for conflict." exit } } Write-Host "Downloading superfile...(Version v$version)" $url = "https://github.com/yorukot/superfile/releases/download/v$version/$filename" try { Invoke-WebRequest -OutFile "$superfileProgramPath/$filename" $url } catch { Write-Host "An error occurred: $_" exit } Write-Host "Extracting compressed file..." try { $tempDirectory = "$superfileProgramPath\temp" New-Item -ItemType Directory -Path $tempDirectory -Force | Out-Null Expand-Archive -Path "$superfileProgramPath\$filename" -DestinationPath $tempDirectory Remove-Item -Path "$superfileProgramPath\$filename" $thisisredundant = (Get-ChildItem -Path $tempDirectory -Directory | Sort-Object Name -Descending | Select-Object -First 1).Name $lastFolderName = (Get-ChildItem -Path "$tempDirectory\$thisisredundant" -Directory | Sort-Object Name -Descending | Select-Object -First 1).Name Move-Item -Path "$tempDirectory\$thisisredundant\$lastFolderName\*" -Destination $superfileProgramPath -Force Remove-Item -Path $tempDirectory -Recurse -Force } catch { Write-Host "An error occurred: $_" exit } if (-not (FolderIsInPATH "$superfileProgramPath\")) { $envPath = [Environment]::GetEnvironmentVariable("PATH", "User") $newPath = "$superfileProgramPath\" $updatedPath = $envPath.TrimEnd(";") + ";" + $newPath + ";" [Environment]::SetEnvironmentVariable("PATH", $updatedPath, "User") } Write-Host @' Done! Restart you terminal, and for the love of Get-Command Take a look at tutorial :) https://superfile.dev/getting-started/tutorial/ '@ ================================================ FILE: website/public/install.sh ================================================ #!/bin/bash green='\033[0;32m' red='\033[0;31m' yellow='\033[0;33m' blue='\033[0;34m' purple='\033[0;35m' cyan='\033[0;36m' white='\033[0;37m' bright_red='\033[1;31m' bright_green='\033[1;32m' bright_yellow='\033[1;33m' bright_blue='\033[1;34m' bright_purple='\033[1;35m' bright_cyan='\033[1;36m' bright_white='\033[1;37m' nc='\033[0m' # No Color echo -e ' \033[0;31m ______ __ __ \033[1;31m / \ / |/ | \033[0;33m _______ __ __ ______ ______ ______ /$$$$$$ |$$/ $$ | ______ \033[1;33m / |/ | / | / \ / \ / \ $$ |_ $$/ / |$$ | / \ \033[0;32m/$$$$$$$/ $$ | $$ |/$$$$$$ |/$$$$$$ |/$$$$$$ |$$ | $$ |$$ |/$$$$$$ | \033[1;32m$$ \ $$ | $$ |$$ | $$ |$$ $$ |$$ | $$/ $$$$/ $$ |$$ |$$ $$ | \033[0;34m $$$$$$ |$$ \__$$ |$$ |__$$ |$$$$$$$$/ $$ | $$ | $$ |$$ |$$$$$$$$/ \033[1;34m/ $$/ $$ $$/ $$ $$/ $$ |$$ | $$ | $$ |$$ |$$ | \033[0;35m$$$$$$$/ $$$$$$/ $$$$$$$/ $$$$$$$/ $$/ $$/ $$/ $$/ $$$$$$$/ \033[1;35m $$ | \033[0;31m $$ | \033[1;31m $$/ ' temp_dir=$(mktemp -d) if [ $? -ne 0 ]; then echo -e "${red}❌ Fail install superfile: ${yellow}Unable to create temporary directory${nc}" exit 1 fi fetch_latest_version() { local response if response=$(curl -s --max-time 5 "https://api.github.com/repos/yorukot/superfile/releases/latest"); then local version version=$(echo "$response" | grep '"tag_name"' | cut -d'"' -f4 | sed 's/^v//') if [ -n "$version" ]; then echo "$version" else echo -e "${red}❌ Failed to parse version from GitHub API${nc}" >&2 exit 1 fi else echo -e "${red}❌ Failed to fetch latest version from GitHub API${nc}" >&2 exit 1 fi } package=superfile version=${SPF_INSTALL_VERSION:-$(fetch_latest_version)} arch=$(uname -m) os=$(uname -s) cd "${temp_dir}" if [[ "$arch" == "x86_64" || "$arch" == "amd64" ]]; then arch="amd64" elif [[ "$arch" == "arm"* || "$arch" == "aarch64" || "$arch" == "arm64" ]]; then arch="arm64" else echo -e "${red}❌ Fail install superfile: ${yellow}Unsupported architecture${nc}" exit 1 fi if [[ "$os" == "Linux" ]]; then os="linux" elif [[ "$os" == "Darwin" ]]; then os="darwin" else echo -e "${red}❌ Fail install superfile: ${yellow}Unsupported operating system${nc}" exit 1 fi file_name=${package}-${os}-v${version}-${arch} url="https://github.com/yorukot/superfile/releases/download/v${version}/${file_name}.tar.gz" if command -v curl &> /dev/null; then echo -e "${bright_yellow}Downloading ${cyan}${package} v${version} for ${os} (${arch})...${nc}" curl -sLO "$url" else echo -e "${bright_yellow}Downloading ${cyan}${package} v${version} for ${os} (${arch})...${nc}" wget -q "$url" fi echo -e "${bright_yellow}Extracting ${cyan}${package}...${nc}" tar -xzf "${file_name}.tar.gz" echo -e "${bright_yellow}Installing ${cyan}${package}...${nc}" cd ./dist/${file_name} chmod +x ./spf echo -e "${yellow}Press ctrl+C to not install as sudo and try locally.${nc}" if ! sudo mv ./spf /usr/local/bin/; then echo -e "${yellow}Unable to move binary to /usr/local/bin. Do you have sudo permissions?${nc}" mkdir -p ~/.local/bin if ! mv ./spf ~/.local/bin/; then echo -e "${red}❌ Failed to install superfile: Unable to move to ~/.local/bin as well.${nc}" else if ! [[ ":$PATH:" == *":$HOME/.local/bin:"* ]]; then shell_found_and_not_bash=1 case $SHELL in */bash) echo 'export PATH="${HOME}/.local/bin":${PATH}' >> ~/.bashrc shell_found_and_not_bash=0 ;; */zsh) echo 'export PATH="${HOME}/.local/bin":${PATH}' >> ~/.zshrc ;; */fish) echo 'fish_add_path "${HOME}/.local/bin"' >> ~/.config/fish/config.fish ;; */ksh) echo 'export PATH="${HOME}/.local/bin":${PATH}' >> ~/.kshrc ;; */xonsh) echo '$PATH.prepend("${HOME}/.local/bin")' >> ~/.xonshrc ;; */csh) echo 'setenv PATH "${HOME}/.local/bin":${PATH}' >> ~/.cshrc ;; */tcsh) echo 'setenv PATH "${HOME}/.local/bin":${PATH}' >> ~/.tshrc ;; *) echo -e "${red}Unsupported shell: ${SHELL}. Please add ${white}\"${bright_cyan}\${HOME}/.local/bin${white}\" ${red}to PATH in your shell's config file.${red}" shell_found_and_not_bash=0 ;; esac if [ $shell_found_and_not_bash == 1 ]; then echo -e "${white}\"${bright_purple}${HOME}/.local/bin${white}\"${yellow} has been added to your PATH.${nc}" echo -e "${yellow}Please source your config file/relogin.${nc}" fi fi echo -e "🎉 ${bright_cyan}Local ${bright_green}Installation complete!${nc}" echo -e "${bright_cyan}You can type ${white}\"${bright_yellow}spf${white}\" ${bright_cyan}to start!${nc}" fi else echo -e "🎉 ${bright_green}Installation complete!${nc}" echo -e "${bright_cyan}You can type ${white}\"${bright_yellow}spf${white}\" ${bright_cyan}to start!${nc}" fi rm -rf "$temp_dir" ================================================ FILE: website/public/uninstall.ps1 ================================================ param( [switch] $AllUsers ) function FolderIsInPATH($Path_to_directory) { return ([Environment]::GetEnvironmentVariable("PATH", "User") -split ';').TrimEnd('\') -contains $Path_to_directory.TrimEnd('\') } Write-Host -ForegroundColor DarkRed " ______ __ __ " Write-Host -ForegroundColor Red " / \ / |/ | " Write-Host -ForegroundColor DarkYellow " _______ __ __ ______ ______ ______ /`$`$`$`$`$`$ |`$`$/ `$`$ | ______ " Write-Host -ForegroundColor Yellow " / |/ | / | / \ / \ / \ `$`$ |_ `$`$/ / |`$`$ | / \ " Write-Host -ForegroundColor DarkGreen "/`$`$`$`$`$`$`$/ `$`$ | `$`$ |/`$`$`$`$`$`$ |/`$`$`$`$`$`$ |/`$`$`$`$`$`$ |`$`$ | `$`$ |`$`$ |/`$`$`$`$`$`$ |" Write-Host -ForegroundColor Green "`$`$ \ `$`$ | `$`$ |`$`$ | `$`$ |`$`$ `$`$ |`$`$ | `$`$/ `$`$`$`$/ `$`$ |`$`$ |`$`$ `$`$ |" Write-Host -ForegroundColor DarkBlue " `$`$`$`$`$`$ |`$`$ \__`$`$ |`$`$ |__`$`$ |`$`$`$`$`$`$`$`$/ `$`$ | `$`$ | `$`$ |`$`$ |`$`$`$`$`$`$`$`$/ " Write-Host -ForegroundColor Blue " `$`$/ `$`$ `$`$/ `$`$ `$`$/ `$`$ |`$`$ | `$`$ | `$`$ |`$`$ |`$`$ |" Write-Host -ForegroundColor DarkMagenta "`$`$`$`$`$`$`$/ `$`$`$`$`$`$/ `$`$`$`$`$`$`$/ `$`$`$`$`$`$`$/ `$`$/ `$`$/ `$`$/ `$`$/ `$`$`$`$`$`$`$/ " Write-Host -ForegroundColor Magenta " `$`$ | " Write-Host -ForegroundColor DarkRed " `$`$ | " Write-Host -ForegroundColor Red " `$`$/ " Write-Host "" $package = "superfile" $installInstructions = @' This uninstaller is only available for Windows. '@ if ($IsMacOS) { Write-Host "$installInstructions" exit } if ($IsLinux) { Write-Host "$installInstructions" exit } Write-Host "Removing folder..." $superfileProgramPath = [Environment]::GetFolderPath("LocalApplicationData") + "\Programs\superfile" try { if (Test-Path $superfileProgramPath) { Remove-Item -Path $superfileProgramPath -Recurse -Force } } catch { Write-Host "An error occurred: $_" exit } Write-Host "Removing environment path..." try { if (FolderIsInPATH "$superfileProgramPath\") { $envPath = [Environment]::GetEnvironmentVariable("PATH", "User") $updatedPath =($envPath.Split(';') | Where-Object { $_ -ne "$superfileProgramPath" }) -join ';' [Environment]::SetEnvironmentVariable("PATH", $updatedPath, "User") } } catch { Write-Host "An error occurred: $_" exit } Write-Host @' Uninstall Done! '@ ================================================ FILE: website/src/components/GithubStar.astro ================================================ --- import { Icon } from '@astrojs/starlight/components'; --- ================================================ FILE: website/src/components/LastUpdated.astro ================================================ --- import type { Props } from '@astrojs/starlight/props'; import Default from '@astrojs/starlight/components/LastUpdated.astro'; const { lastUpdated } = Astro.props; --- {lastUpdated && (
)} ================================================ FILE: website/src/components/about.astro ================================================ --- interface Props { title: string; } const { title } = Astro.props; --- ================================================ FILE: website/src/components/code.astro ================================================ --- import { Code as SCode } from '@astrojs/starlight/components' import fs from 'node:fs/promises'; interface Props { file: string; language?: string; meta?: string; } const { file, language, meta } = Astro.props; const fileNamePath = '../' + file; const fileEtension = file.split('.').pop() ?? 'js'; const code = await fs.readFile(fileNamePath, 'utf-8'); const lang = language ?? fileEtension; const metaa = `title="${file}"` + (meta ? ` ${meta}` : '') --- ================================================ FILE: website/src/content/config.ts ================================================ import { defineCollection } from 'astro:content'; import { docsSchema, i18nSchema } from '@astrojs/starlight/schema'; export const collections = { docs: defineCollection({ schema: docsSchema() }), i18n: defineCollection({ type: 'data', schema: i18nSchema() }), }; ================================================ FILE: website/src/content/docs/changelog.md ================================================ --- title: CHANGELOG description: New features, improvements, and bug fixes for the superfile. head: - tag: title content: superfile ChangeLog | superfile --- # ChangeLog All notable changes to this project will be documented in this file. Dates are displayed in UTC(YYYY-MM-DD). # [**v1.5.0**](https://github.com/yorukot/superfile/releases/tag/v1.5.0) > 2026-01-11 #### Update - allow hover to file [`#1177`](https://github.com/yorukot/superfile/pull/1177) - show count selected items in select mode [`#1187`](https://github.com/yorukot/superfile/pull/1187) - Add icon alias for kts to kt [`#1153`](https://github.com/yorukot/superfile/pull/1153) - link icon and metadata [`#1171`](https://github.com/yorukot/superfile/pull/1171) - user configuration of editors by file extension [`#1197`](https://github.com/yorukot/superfile/pull/1197) - add video preview support [`#1178`](https://github.com/yorukot/superfile/pull/1178) - Add pdf preview support [`#1198`](https://github.com/yorukot/superfile/pull/1198) - Add icons in pinned directories [`#1215`](https://github.com/yorukot/superfile/pull/1215) - Enable fast configurable navigation [`#1220`](https://github.com/yorukot/superfile/pull/1220) - add Trash bin to default directories for Linux [`#1236`](https://github.com/yorukot/superfile/pull/1236) - add terminal stdout support for shell commands [`#1250`](https://github.com/yorukot/superfile/pull/1250) - More columns in file panel (MVP) [`#1268`](https://github.com/yorukot/superfile/pull/1268) #### Bug Fix - only calculate checksum on files [`#1119`](https://github.com/yorukot/superfile/pull/1119) - Linter issue with PrintfAndExit [`#1133`](https://github.com/yorukot/superfile/pull/1133) - Remove repeated os.ReadDir calls [`#1155`](https://github.com/yorukot/superfile/pull/1155) - Disable COPYFILE in macOS [`#1194`](https://github.com/yorukot/superfile/pull/1194) - add missing hotkeys to help menu [`#1192`](https://github.com/yorukot/superfile/pull/1192) - Fetch latest version automatically [`#1127`](https://github.com/yorukot/superfile/pull/1127) - Use async methods to prevent test race conditions [`#1201`](https://github.com/yorukot/superfile/pull/1201) - update metadata and process bar sizes when toggling footer [`#1218`](https://github.com/yorukot/superfile/pull/1218) - File panel dimension management [`#1222`](https://github.com/yorukot/superfile/pull/1222) - Layout fixes with full end-to-end tests [`#1227`](https://github.com/yorukot/superfile/pull/1227) - Fix flaky tests [`#1233`](https://github.com/yorukot/superfile/pull/1233) - modal confirmation bug with arrow keys [`#1243`](https://github.com/yorukot/superfile/pull/1243) - small file panel optimization [`#1241`](https://github.com/yorukot/superfile/pull/1241) - use ExtractOperationMsg for extraction [`#1248`](https://github.com/yorukot/superfile/pull/1248) - skip open_with from missing field validation [`#1251`](https://github.com/yorukot/superfile/pull/1251) - border height validation fixes [`#1267`](https://github.com/yorukot/superfile/pull/1267) - fix case with two active panes [`#1271`](https://github.com/yorukot/superfile/pull/1271) - help model formatting [`#1277`](https://github.com/yorukot/superfile/pull/1277) #### Optimization - simplify renameIfDuplicate logic [`#1100`](https://github.com/yorukot/superfile/pull/1100) - separate FilePanel into dedicated package [`#1195`](https://github.com/yorukot/superfile/pull/1195) - File model separation [`#1223`](https://github.com/yorukot/superfile/pull/1223) - Dimension validations [`#1224`](https://github.com/yorukot/superfile/pull/1224) - layout validation and sidebar dimension fixes [`#1228`](https://github.com/yorukot/superfile/pull/1228) - user rendering package and removal of unused preview code [`#1245`](https://github.com/yorukot/superfile/pull/1245) - user rendering package for file preview [`#1249`](https://github.com/yorukot/superfile/pull/1249) #### Documentation - update Fish shell setup docs [`#1142`](https://github.com/yorukot/superfile/pull/1142) - fix macOS typo [`#1212`](https://github.com/yorukot/superfile/pull/1212) - stylistic and linguistic cleanup of config documentation [`#1184`](https://github.com/yorukot/superfile/pull/1184) #### Dependencies - update astro monorepo [`#1010`](https://github.com/yorukot/superfile/pull/1010) - update starlight-giscus [`#1020`](https://github.com/yorukot/superfile/pull/1020) - bump astro versions [`#1138`](https://github.com/yorukot/superfile/pull/1138), [`#1157`](https://github.com/yorukot/superfile/pull/1157), [`#1158`](https://github.com/yorukot/superfile/pull/1158) - bump vite [`#1134`](https://github.com/yorukot/superfile/pull/1134) - update setup-go action [`#1038`](https://github.com/yorukot/superfile/pull/1038) - update expressive-code plugins [`#1189`](https://github.com/yorukot/superfile/pull/1189), [`#1246`](https://github.com/yorukot/superfile/pull/1246) - update sharp [`#1256`](https://github.com/yorukot/superfile/pull/1256) - update fontsource monorepo [`#1257`](https://github.com/yorukot/superfile/pull/1257) - update urfave/cli [`#1136`](https://github.com/yorukot/superfile/pull/1136), [`#1190`](https://github.com/yorukot/superfile/pull/1190) - update astro / starlight / ansi / toolchain deps [`#1275`](https://github.com/yorukot/superfile/pull/1275), [`#1278`](https://github.com/yorukot/superfile/pull/1278), [`#1280`](https://github.com/yorukot/superfile/pull/1280) - update python and go versions [`#1276`](https://github.com/yorukot/superfile/pull/1276), [`#1191`](https://github.com/yorukot/superfile/pull/1191) - update golangci-lint action [`#1286`](https://github.com/yorukot/superfile/pull/1286) #### Misc - update CI input names [`#1120`](https://github.com/yorukot/superfile/pull/1120) - Everforest Dark Hard theme [`#1114`](https://github.com/yorukot/superfile/pull/1114) - migrate tutorial demo assets to local [`#1140`](https://github.com/yorukot/superfile/pull/1140) - new logo asset [`#1145`](https://github.com/yorukot/superfile/pull/1145) - mirror repository to codeberg [`#1141`](https://github.com/yorukot/superfile/pull/1141) - sync package lock [`#1143`](https://github.com/yorukot/superfile/pull/1143) - bump golangci-lint version [`#1135`](https://github.com/yorukot/superfile/pull/1135) - add gosec linter [`#1185`](https://github.com/yorukot/superfile/pull/1185) - enable MND linter and clean magic numbers [`#1180`](https://github.com/yorukot/superfile/pull/1180) - skip permission tests when running as root [`#1186`](https://github.com/yorukot/superfile/pull/1186) - release v1.4.1-rc [`#1203`](https://github.com/yorukot/superfile/pull/1203) - 1.5.0-rc1 housekeeping changes [`#1264`](https://github.com/yorukot/superfile/pull/1264) # [**v1.4.0**](https://github.com/yorukot/superfile/releases/tag/v1.4.0) > 2025-10-10 #### Update - feat: File operation via tea cmd by [`#963`](https://github.com/yorukot/superfile/pull/963) - feat: processbar improvements, package separation, better channel management by [`#970`](https://github.com/yorukot/superfile/pull/970) - feat: processbar improvements, package separation, better channel management by [`#973`](https://github.com/yorukot/superfile/pull/973) - feat: enable lll and recvcheck linter, fix tests, more refactors by [`#977`](https://github.com/yorukot/superfile/pull/977) - feat: Remove channel for notification models by [`#979`](https://github.com/yorukot/superfile/pull/979) - feat: enable cyclop, funlen, gocognit, gocyclo linters, and refactor large functions by [`#984`](https://github.com/yorukot/superfile/pull/984) - feat: Add a new hotkey to handle cd-on-quit whenever needed by [`#924`](https://github.com/yorukot/superfile/pull/924) - feat: added option to permanently delete files by [`#987`](https://github.com/yorukot/superfile/pull/987) - feat: Preview panel separation by [`#1021`](https://github.com/yorukot/superfile/pull/1021) - feat: Add search functionality to help menu by [`#1011`](https://github.com/yorukot/superfile/pull/1011) - feat: Use zoxide lib by [`#1036`](https://github.com/yorukot/superfile/pull/1036) - feat: Add zoxide directory tracking on navigation by [`#1041`](https://github.com/yorukot/superfile/pull/1041) - feat: Zoxide integration by [`#1039`](https://github.com/yorukot/superfile/pull/1039) - feat: Select mode with better feedback by [`#1074`](https://github.com/yorukot/superfile/pull/1074) - feat: owner/group in the metadata by [`#1093`](https://github.com/yorukot/superfile/pull/1093) - feat: Async zoxide by [`#1104`](https://github.com/yorukot/superfile/pull/1104) #### Bug Fix - fix: sorting in searchbar by [`#985`](https://github.com/yorukot/superfile/pull/985) - fix: Async rendering, Include clipboard check in paste items, and update linter configs by [`#997`](https://github.com/yorukot/superfile/pull/997) - fix: Move utility functions to utils package by [`#1012`](https://github.com/yorukot/superfile/pull/1012) - fix: Refactoring and separation of preview panel and searchbar in help menu by [`#1013`](https://github.com/yorukot/superfile/pull/1013) - fix(filePanel): allow focusType to be set correctly by [`#1033`](https://github.com/yorukot/superfile/pull/1033) - fix(ci): Update gomod2nix.toml, allow pre release in version output, release 1.4.0-rc1, bug fixes, and improvements by [`#1054`](https://github.com/yorukot/superfile/pull/1054) - fix(nix): resolve build failures in the nix flake by [`#1068`](https://github.com/yorukot/superfile/pull/1068) - fix: Retry the file deletion to prevent flakies (#938) by [`#1076`](https://github.com/yorukot/superfile/pull/1076) - fix(issue-1066): Fixed issue where enter was not searchable by [`#1078`](https://github.com/yorukot/superfile/pull/1078) - fix(#1073): Tech debt fix by [`#1077`](https://github.com/yorukot/superfile/pull/1077) - fix: fix deleted directory not able to remove from pins (#1067) by [`#1081`](https://github.com/yorukot/superfile/pull/1081) - fix: fix child process spawning attached by [`#1084`](https://github.com/yorukot/superfile/pull/1084) - fix: always clear images when showing a FullScreenStyle by [`#1094`](https://github.com/yorukot/superfile/pull/1094) - fix: Allow j and k keys in zoxide by [`#1102`](https://github.com/yorukot/superfile/pull/1102) - fix: Zoxide improvements and 1.4.0-rc2 by [`#1105`](https://github.com/yorukot/superfile/pull/1105) - fix: rename cursor beginning on wrong character because of multiple dots in name (#813) by [`#1112`](https://github.com/yorukot/superfile/pull/1112) - fix: check and fix file panel scroll position on height changes by [`#1095`](https://github.com/yorukot/superfile/pull/1095) #### Optimization - perf(website): optimize font loading and asset organization by [`#1089`](https://github.com/yorukot/superfile/pull/1089) #### Documentation - docs: fix incorrect zoxide plugin config name by [`#1049`](https://github.com/yorukot/superfile/pull/1049) - docs(hotkeys): Fix typo in vimHotkeys.toml comments by [`#1080`](https://github.com/yorukot/superfile/pull/1080) - docs: add section for core maintainers in README.md by [`#1088`](https://github.com/yorukot/superfile/pull/1088) - chore: add winget install instruction to readme and website by [`#943`](https://github.com/yorukot/superfile/pull/943) #### Dependencies - chore(deps): update dependency go to v1.25.0, golangci-lint to v2, golangci-lint actions to v8 by [`#750`](https://github.com/yorukot/superfile/pull/750) - chore(deps): update amannn/action-semantic-pull-request action to v6 by [`#1006`](https://github.com/yorukot/superfile/pull/1006) - chore(deps): update actions/first-interaction action to v3 by [`#1005`](https://github.com/yorukot/superfile/pull/1005) - chore(deps): update actions/checkout action to v5 by [`#1004`](https://github.com/yorukot/superfile/pull/1004) - chore(deps): bump astro from 5.10.1 to 5.12.8 by [`#982`](https://github.com/yorukot/superfile/pull/982) - fix(deps): update module golang.org/x/mod to v0.27.0 by [`#989`](https://github.com/yorukot/superfile/pull/989) - fix(deps): update dependency @expressive-code/plugin-collapsible-sections to v0.41.3 by [`#990`](https://github.com/yorukot/superfile/pull/990) - fix(deps): update dependency sharp to v0.34.3 by [`#992`](https://github.com/yorukot/superfile/pull/992) - fix(deps): update dependency @expressive-code/plugin-line-numbers to v0.41.3 by [`#991`](https://github.com/yorukot/superfile/pull/991) - chore(deps): update dependency go to v1.25.0 by [`#994`](https://github.com/yorukot/superfile/pull/994) - fix(deps): update astro monorepo by [`#995`](https://github.com/yorukot/superfile/pull/995) - fix(deps): update dependency @astrojs/starlight to ^0.35.0 by [`#1000`](https://github.com/yorukot/superfile/pull/1000) - fix(deps): update module github.com/urfave/cli/v3 to v3.4.1 by [`#1001`](https://github.com/yorukot/superfile/pull/1001) - fix(deps): update module golang.org/x/text to v0.28.0 by [`#1003`](https://github.com/yorukot/superfile/pull/1003) #### Misc - chore: migrate from superfile.netlify.app to superfile.dev by [`#1087`](https://github.com/yorukot/superfile/pull/1087) - refactor(filepanel): replace filePanelFocusType with isFocused boolean by [`#1040`](https://github.com/yorukot/superfile/pull/1040) - refactor(ansi): Migrate from github.com/charmbracelet/x/exp/term/ansi to github.com/charmbracelet/x/ansi by [`#1044`](https://github.com/yorukot/superfile/pull/1044) - refactor: common operation on pinned directory file using PinnedManager by [`#1085`](https://github.com/yorukot/superfile/pull/1085) - test: unit tests for pinned manager by [`#1090`](https://github.com/yorukot/superfile/pull/1090) # [**v1.3.3**](https://github.com/yorukot/superfile/releases/tag/v1.3.3) > 2025-07-25 #### Update - feat: Metadata loading via bubbletea's tea.Cmd method, removed usage channels and custom goroutines by [`#947`](https://github.com/yorukot/superfile/pull/947) - feat: Metadata panel into separate package, UI bug fixes, Code improvements[`#950`](https://github.com/yorukot/superfile/pull/950) #### Bug Fix - fix: windows test ci by [`#941`](https://github.com/yorukot/superfile/pull/941) - fix: fixing `config.toml` by [`#952`](https://github.com/yorukot/superfile/pull/952) #### Misc - chore: update pnpm-lcok.yaml by [`#937`](https://github.com/yorukot/superfile/pull/937) - feat: add support for Python virtual environment in testsuite setup[`#956`](https://github.com/yorukot/superfile/pull/956) # [**v1.3.2**](https://github.com/yorukot/superfile/releases/tag/v1.3.2) > 2025-07-16 #### Update - Normalize user-facing naming to superfile [`#880`](https://github.com/yorukot/superfile/pull/880) - Add kitty protocol for image preview [`#841`](https://github.com/yorukot/superfile/pull/841) - feat: add Zoxide support for path resolution in initial configuration [`#892`](https://github.com/yorukot/superfile/pull/892) - feat: update superfile's help output [`#908`](https://github.com/yorukot/superfile/pull/908) - feat: Add Action to Publish to Winget [`#925`](https://github.com/yorukot/superfile/pull/925) - feat: update superfile build test for the windows and macOS [`#922`](https://github.com/yorukot/superfile/pull/922) - Compress all files selected [`#821`](https://github.com/yorukot/superfile/pull/821) - Theme: add 0x96f theme [`#860`](https://github.com/yorukot/superfile/pull/860) #### Bug fix - fix: outdated and broken nix flake [`#846`](https://github.com/yorukot/superfile/pull/846) - fix: handle UTF-8 BOM in file reader [`#865`](https://github.com/yorukot/superfile/pull/865) - fix icon displayed on spf prompt when nerdfont disabled [`#878`](https://github.com/yorukot/superfile/pull/878) - fix: create item check for dot-entries [`#817`](https://github.com/yorukot/superfile/pull/817) - fix: prevent pasting a directory into itself, avoiding infinite loop [`#887`](https://github.com/yorukot/superfile/pull/887) - fix: clear search bar value on parent directory reset [`#906`](https://github.com/yorukot/superfile/pull/906) - fix: enhance terminal pixel detection and response handling [`#904`](https://github.com/yorukot/superfile/pull/904) - fix: Cannot Build superfile on Windows [`#921`](https://github.com/yorukot/superfile/pull/921) - fix: Improve command tokenization to handle quotes and escapes [`#931`](https://github.com/yorukot/superfile/pull/931) - fix: Dont read special files, and prevent freeze [`#932`](https://github.com/yorukot/superfile/pull/932) #### Optimization - Metadata and filepanel rendering refactor [`#867`](https://github.com/yorukot/superfile/pull/867) - refactor: simplify panel mode handling in file movement logic [`#907`](https://github.com/yorukot/superfile/pull/907) - refactor: standardize TODO comments and ReadMe to README [`#913`](https://github.com/yorukot/superfile/pull/913) #### Documentation - enhance: add detailed documentation for InitIcon function and update … [`#879`](https://github.com/yorukot/superfile/pull/879) - docs: add documentation for image preview [`#882`](https://github.com/yorukot/superfile/pull/882) - docs: update contributing guide and PR template [`#885`](https://github.com/yorukot/superfile/pull/885) - docs: update README and plugin documentation for clarity and structure [`#902`](https://github.com/yorukot/superfile/pull/902) - feat(docs): Update arch install package docs [`#929`](https://github.com/yorukot/superfile/pull/929) #### CI/CD - ci: add PR title linting with semantic-pull-request action [`#884`](https://github.com/yorukot/superfile/pull/884) - ci: improve PR workflows with contributor greeting and title linter fix [`#886`](https://github.com/yorukot/superfile/pull/886) #### Dependencies - build(deps): bump prismjs from 1.29.0 to 1.30.0 in /website [`#786`](https://github.com/yorukot/superfile/pull/786) - fix(deps): update dependency astro to v5.8.0 [`#787`](https://github.com/yorukot/superfile/pull/787) - chore(deps): bump vite from 6.3.3 to 6.3.5 in /website [`#822`](https://github.com/yorukot/superfile/pull/822) - fix(deps): update dependency sharp to v0.34.2 [`#909`](https://github.com/yorukot/superfile/pull/909) - fix(deps): update astro monorepo [`#894`](https://github.com/yorukot/superfile/pull/894) - fix(deps): update fontsource monorepo to v5.2.6 [`#910`](https://github.com/yorukot/superfile/pull/910) #### Misc - chore(license): update copyright year [`#895`](https://github.com/yorukot/superfile/pull/895) - feat: add ignore missing field flag [`#881`](https://github.com/yorukot/superfile/pull/881) - feat: add sitemap integration and update giscus input position [`#912`](https://github.com/yorukot/superfile/pull/912) # [**v1.3.1**](https://github.com/yorukot/superfile/releases/tag/v1.3.1) > 2025-05-27 #### Update - Replace custom giscus implementation with official starlight-giscus plugin [`#843`](https://github.com/yorukot/superfile/pull/843) - Add 'Type' option for sorting by file extension with fallback [`#829`](https://github.com/yorukot/superfile/pull/829) #### Bug Fixes - Correct icons for clipboard files [`#845`](https://github.com/yorukot/superfile/pull/845) - Replace mattn/rundwidth with ansi package for more robust StringWidth [`#848`](https://github.com/yorukot/superfile/pull/848) - Purego package update [`#837`](https://github.com/yorukot/superfile/pull/837) #### Optimization - Update main.go [`#839`](https://github.com/yorukot/superfile/pull/839) # [**v1.3.0**](https://github.com/yorukot/superfile/releases/tag/v1.3.0) > 2025-05-22 #### Update - Added a Command-Prompt for SuperFile specific actions [`#752`](https://github.com/yorukot/superfile/pull/752) - Allow specifying multiple panels at startup [`#759`](https://github.com/yorukot/superfile/pull/759) - Initial draft of rendering package [`#775`](https://github.com/yorukot/superfile/pull/775) - Render unit tests for prompt model [`#809`](https://github.com/yorukot/superfile/pull/809) - Chooser file option, --lastdir-file option, and improvements in quit, and bug fixes [`#812`](https://github.com/yorukot/superfile/pull/812) - Prompt feature leftover items [`#804`](https://github.com/yorukot/superfile/pull/804) - SPF Prompt tutorial and fixes [`#814`](https://github.com/yorukot/superfile/pull/814) - Write prompt tutorial, rename prompt mode to spf mode, add develop branch in GitHub workflow, show_panel_footer_info flag [`#815`](https://github.com/yorukot/superfile/pull/815) - Theme: Add gruvbox-dark-hard [`#828`](https://github.com/yorukot/superfile/pull/828) - Sidebar separation [`#767`](https://github.com/yorukot/superfile/pull/767) - Sidebar code separation [`#770`](https://github.com/yorukot/superfile/pull/770) - Rendering package and rendering bug fixes [`#781`](https://github.com/yorukot/superfile/pull/781) - Refactor CheckForUpdates [`#797`](https://github.com/yorukot/superfile/pull/797) - Rename metadata strings [`#731`](https://github.com/yorukot/superfile/pull/731) #### Bug Fixes - Fix crash with opening file with editor on an empty panel [`#730`](https://github.com/yorukot/superfile/pull/730) - Fix: Add some of the remaining linter and fix errors [`#756`](https://github.com/yorukot/superfile/pull/756) - Golangci lint fixes [`#757`](https://github.com/yorukot/superfile/pull/757) - Fix: Remove redundant function containsKey [`#765`](https://github.com/yorukot/superfile/pull/765) - Fix: Correctly resolve path in open and cd prompt actions [`#802`](https://github.com/yorukot/superfile/pull/802) - Prompt dynamic dimensions and unit tests fix [`#805`](https://github.com/yorukot/superfile/pull/805) - Fix: Convert unicode space to normal space, use rendered in file preview to fix layout bugs, Release 1.3.0 [`#825`](https://github.com/yorukot/superfile/pull/825) #### Optimization - Adding linter to CI/CD and fix some lint issues [`#739`](https://github.com/yorukot/superfile/pull/739) - Linter fixes, new feature of allowing multiple directories at startup, other code improvements [`#764`](https://github.com/yorukot/superfile/pull/764) - Model unit tests [`#803`](https://github.com/yorukot/superfile/pull/803) # [**v1.2.1**](https://github.com/yorukot/superfile/releases/tag/v1.2.1) > 2025-03-26 #### Update - Add show_image_preview flag [`#728`](https://github.com/yorukot/superfile/pull/728) - Allow specifying directory icon color in theme files [`#709`](https://github.com/yorukot/superfile/pull/709) - --hotkey-file flag and fix in configFileFlag [`#700`](https://github.com/yorukot/superfile/pull/700) - File preview: Add bat as plugin [`#686`](https://github.com/yorukot/superfile/pull/686) - Monokai Theme [`#673`](https://github.com/yorukot/superfile/pull/673) #### Bug fix - Fix broken link in website causing 404 [`#714`](https://github.com/yorukot/superfile/pull/714) - Fix sidebar disk listing [`#708`](https://github.com/yorukot/superfile/pull/708) - Switch to semver for newer 1.2.1 release [`#687`](https://github.com/yorukot/superfile/pull/687) #### Optimization - Fix: icon consts [`#719`](https://github.com/yorukot/superfile/pull/719) - Refactor and unit tests for scrolling [`#710`](https://github.com/yorukot/superfile/pull/710) - Refactor of wheel functions [`#695`](https://github.com/yorukot/superfile/pull/695) #### Documentation - Add info about auto update [`#721`](https://github.com/yorukot/superfile/pull/721) - add cd_on_quit for fish shell [`#696`](https://github.com/yorukot/superfile/pull/696) - Add Pixi installation instructions [`#690`](https://github.com/yorukot/superfile/pull/690) # [**v1.2.0.0**](https://github.com/yorukot/superfile/releases/tag/v1.2.0.0) > 2025-03-05 #### Update - Added direnv support for nix flake dev shell [`#568`](https://github.com/yorukot/superfile/pull/568) - Move rename cursor to start before the extension [`#565`](https://github.com/yorukot/superfile/pull/565) - Renaming feature for pinned directories [`#579`](https://github.com/yorukot/superfile/pull/579) - Add python testsuite [`#581`](https://github.com/yorukot/superfile/pull/581) - Add build instructions for windows [`#583`](https://github.com/yorukot/superfile/pull/583) - Add `--config-file` flag support [`#592`](https://github.com/yorukot/superfile/pull/592) - Document Windows scoop installation option [`#595`](https://github.com/yorukot/superfile/pull/595) - Rotate image using EXIF metadata [`#607`](https://github.com/yorukot/superfile/pull/607) - Upgrade sidebar search [`#614`](https://github.com/yorukot/superfile/pull/614) - Change all outPutLog to slog.Error or slog.Info [`#628`](https://github.com/yorukot/superfile/pull/628) - Add install.sh files link for more trust [`#645`](https://github.com/yorukot/superfile/pull/645) - Update README.md and added a Run the app title [`#550`](https://github.com/yorukot/superfile/pull/550) #### Bug fix - Fix sort options hotkey [`#548`](https://github.com/yorukot/superfile/pull/548) - Fix wrong log line, Fatalln was used with formatting verbs [`#555`](https://github.com/yorukot/superfile/pull/555) - Fix incorrect failure reporting in delete operation [`#558`](https://github.com/yorukot/superfile/pull/558) - Fix previews for text file with control characters [`#557`](https://github.com/yorukot/superfile/pull/557) - Fix search field key blocking [`#569`](https://github.com/yorukot/superfile/pull/569) - Fix windows operations and other improvements [`#564`](https://github.com/yorukot/superfile/pull/564) - Fix crash when searching on WSL mounted drives [`#576`](https://github.com/yorukot/superfile/pull/576) - Fix arch install instructions [`#580`](https://github.com/yorukot/superfile/pull/580) - Fix windows delete, open file and other improvements [`#584`](https://github.com/yorukot/superfile/pull/584) - Fix UI issue of spf stuck with terminal size too small [`#594`](https://github.com/yorukot/superfile/pull/594) - Fix wrong path separator in windows [`#597`](https://github.com/yorukot/superfile/pull/597) - Fix command line not working for windows [`#601`](https://github.com/yorukot/superfile/pull/601) - Fix error while reading last check version file in new time zone [`#634`](https://github.com/yorukot/superfile/pull/634) - Fix discrete timeout for HTTP get version [`#632`](https://github.com/yorukot/superfile/pull/632) - Fix initial pinned.json having invalid JSON [`#652`](https://github.com/yorukot/superfile/pull/652) - Fix loadConfigFile and loadHotkeysFile functions [`#650`](https://github.com/yorukot/superfile/pull/650) - Fix issue when trying to extract a file with .zip_ extension [`#636`](https://github.com/yorukot/superfile/pull/636) - Fix openFileWithEditor bug [`#635`](https://github.com/yorukot/superfile/pull/635) - Fix partial overwrite issue by ensuring full file rewrite [`#665`](https://github.com/yorukot/superfile/pull/665) #### Optimization - Improving file panel rendering [`#589`](https://github.com/yorukot/superfile/pull/589) - Improve formatting, error handling, and fix typos [`#600`](https://github.com/yorukot/superfile/pull/600) - Go formatting fixes [`#618`](https://github.com/yorukot/superfile/pull/618) - Testsuite in GitHub Actions [`#602`](https://github.com/yorukot/superfile/pull/602) #### Documentation - Revert changes in website that were not yet released [`#611`](https://github.com/yorukot/superfile/pull/611) - Docs contribute [`#610`](https://github.com/yorukot/superfile/pull/610) - Remove godocs badge [`#627`](https://github.com/yorukot/superfile/pull/627) - Update installation.md to note setting nerd-font in terminal application [`#658`](https://github.com/yorukot/superfile/pull/658) - Fix README typos [`#653`](https://github.com/yorukot/superfile/pull/653) # [**v1.1.7.1**](https://github.com/yorukot/superfile/releases/tag/v1.1.7) > 2024-01-06 NOTE: This release is a hotfix to resolve an unusual issue on Windows. #### Bug fix - Fix can't run on windows [`#534`](https://github.com/yorukot/superfile/issues/534) # [**v1.1.7**](https://github.com/yorukot/superfile/releases/tag/v1.1.7) > 2024-01-05 #### Update - OneDark Theme added [`#477`](https://github.com/yorukot/superfile/pull/477) - Add keys PageUp and PageDown for better navigation [`#498`](https://github.com/yorukot/superfile/pull/498) - Add hotkey for copying PWD to clipboard [`#510`](https://github.com/yorukot/superfile/pull/510) - Add desktop entry [`#501`](https://github.com/yorukot/superfile/pull/501) - Enable cd_on_quit when current directory is home directory [`#518`](https://github.com/yorukot/superfile/pull/518) - Edit superfile config [`#509`](https://github.com/yorukot/superfile/pull/509) #### Bug fix - Fix rendering directory symlinks as directories, not files [`#481`](https://github.com/yorukot/superfile/pull/481) - Fix opening files on Windows [`#496`](https://github.com/yorukot/superfile/pull/496) - Fix lag in dotfile toggle with multiple panels [`#499`](https://github.com/yorukot/superfile/pull/499) - Fix parent directory navigation on Windows [`#502`](https://github.com/yorukot/superfile/pull/502) - Fix panic when deleting last file in directory [`#529`](https://github.com/yorukot/superfile/pull/529) - Fix panic when scrolling through an empty metadata list [`#531`](https://github.com/yorukot/superfile/pull/531) - Fix panic when trying to get folder size without needed permissions [`#532`](https://github.com/yorukot/superfile/pull/532) - Fix lag when navigating directories with large image files [`#525`](https://github.com/yorukot/superfile/pull/525) - Fix typo in welcome message [`#494`](https://github.com/yorukot/superfile/pull/494) #### Optimization - Optimize file move operation [`#522`](https://github.com/yorukot/superfile/pull/522) - Optimize file extraction [`#524`](https://github.com/yorukot/superfile/pull/524) - Warn overwrite when renaming files [`#526`](https://github.com/yorukot/superfile/pull/526) - Work without trash [`#527`](https://github.com/yorukot/superfile/pull/527) # [**v1.1.6**](https://github.com/yorukot/superfile/releases/tag/v1.1.6) > 2024-11-21 #### Update - Add sort case toggle [`#469`](https://github.com/yorukot/superfile/issues/469) - Add Sort options [`#420`](https://github.com/yorukot/superfile/pull/420) - Fix flashing when switching between panels [`#122`](https://github.com/yorukot/superfile/issues/122) #### Bug fix - Fix some hotkey broken - Fix the searchbar to automatically put the open key into the searchbar [`ec9e256`](https://github.com/yorukot/superfile/commit/b20bc70fe9d4e0ee96931092a6522e8604cc017b) # [**v1.1.5**](https://github.com/yorukot/superfile/releases/tag/v1.1.5) > 2024-10-03 #### Update - Stop automatically updating config file. Add fix-hotkeys flag, feedback for missing hotkeys [`#333`](https://github.com/yorukot/superfile/issues/333) - Update installation.md: Add x-cmd method to install superfile [`#371`](https://github.com/yorukot/superfile/issues/333) - Added option to change default editor [`#396`](https://github.com/yorukot/superfile/pull/396) - Support Shell access but cant read history [`#127`](https://github.com/yorukot/superfile/issues/127) - shortcut to copy path to currently selected file [`#196`](https://github.com/yorukot/superfile/issues/196) #### Bug fix - fixed typo in hotkeys.toml [`#341`](https://github.com/yorukot/superfile/issues/341) - Fixes issue #360 + Typo fixes by [`#379`](https://github.com/yorukot/superfile/pull/379) - fixed spelling mistake : varibale to variable [`#394`](https://github.com/yorukot/superfile/pull/394) - fixed exiftool session left open after use [`#400`](https://github.com/yorukot/superfile/pull/400) - Show unsupported format in preview panel over a torrent file [`#408`](https://github.com/yorukot/superfile/pull/408) - Vim bindings in docs cause error on nixos [`#325`](https://github.com/yorukot/superfile/issues/325) - fix spf help flag error [`#368`](https://github.com/yorukot/superfile/issues/368) - You cannot access the disks section in the side panel when only have one disk [`#409`](https://github.com/yorukot/superfile/issues/409) - "Unsupported formats" message has an extra space for .pdf files [`#392`](https://github.com/yorukot/superfile/issues/392) # [**v1.1.4**](https://github.com/yorukot/superfile/releases/tag/v1.1.4) > 2024-08-01 #### Update - Added option to change default directory [`#211`](https://github.com/yorukot/superfile/issues/211) - Added quotes around dir in lastdir to support special characters [`#218`](https://github.com/yorukot/superfile/pull/218) - Make Hotkey settings unlimited [`423a96a`](https://github.com/yorukot/superfile/commit/423a96a0aeca4ea2c30447d8b4010868045bb7e8) - Selection should start on currently positioned/pointed item [`#226`](https://github.com/yorukot/superfile/issues/226) - Make Nerdfont optional [`#6`](https://github.com/yorukot/superfile/issues/6) - Confirm before quit [`#155`](https://github.com/yorukot/superfile/issues/155) - Added file permissions to metadata [`#279`](https://github.com/yorukot/superfile/pull/279) - Better fuzzy file search [`#115`](https://github.com/yorukot/superfile/issues/115) - MD5 checksum in Metadata [`#255`](https://github.com/yorukot/superfile/pull/225) - An option to display the filesize in decimal or binary sizes [`#220`](https://github.com/yorukot/superfile/issues/220) #### Bug fix - An option to display the filesize in decimal or binary sizes [`#220`](https://github.com/yorukot/superfile/issues/220) - Fix Transparent Background issue [`#76`](https://github.com/yorukot/superfile/issues/76) - Big text file makes the program freeze for a while [`#255`](https://github.com/yorukot/superfile/issues/255) - Text in file preview has a background color behind it when using transparency [`#76`](https://github.com/yorukot/superfile/issues/76) # [**v1.1.3**](https://github.com/yorukot/superfile/releases/tag/v1.1.3) > 2024-05-26 #### Update - Update print path list [`37c8864`](https://github.com/yorukot/superfile/commit/37c8864eb2b0dc73fbf8928dd40b3b7573e9a11dw) - Make theme files embed [`0f53a12`](https://github.com/yorukot/superfile/commit/7fa775dd7db175fef694e514bd77ebd75c801fae) - Disable update check via config [`#131`](https://github.com/yorukot/superfile/issues/131) - Redesign hotkeys [`#116`](https://github.com/yorukot/superfile/issues/116) - Create file or folder using same hotkey [`#116`](https://github.com/yorukot/superfile/issues/116) - More dynamic footer height adaptive [`66a3fb4`](https://github.com/yorukot/superfile/commit/66a3fb4feba31ead2224938b1a18a431a55ac9cc) - Confirm delete files [``]() - Support windows for get well known directories [`d4db820`](https://github.com/yorukot/superfile/commit/d4db820ba839603df209dcce05468902739f301f) - Support text file preview [`#26`](https://github.com/yorukot/superfile/issues/26) - Support directory preview [`#26`](https://github.com/yorukot/superfile/issues/26) - Improve mouse scrolling delay [`f734292`](https://github.com/yorukot/superfile/commit/f7342921d49d87f1bc633c9f8e19fe6845fbbf26) - Support image preview with ansi [`#26`](https://github.com/yorukot/superfile/issues/26) - Clear search after opening directory [`#146`](https://github.com/yorukot/superfile/issues/146) #### Bug fix - Recursive symlink crashes superfile [`#109`](https://github.com/yorukot/superfile/issues/109) - Timemachine snapshots listed in Disks section [`#126`](https://github.com/yorukot/superfile/issues/126) - There will be a bug in the layout under a specific terminal height [`#105`](https://github.com/yorukot/superfile/issues/105) - Fix lag when there are a lot of files [`#124`](https://github.com/yorukot/superfile/issues/124) - Rendering will be blocked while executing a task that uses a progress bar [`#104`](https://github.com/yorukot/superfile/issues/104) # [**v1.1.2**](https://github.com/yorukot/superfile/releases/tag/v1.1.2) > 2024-05-08 #### Update - Update help menu [`#75`](https://github.com/yorukot/superfile/issues/75) - Update all modal, make other panel still show on background [`#79`](https://github.com/yorukot/superfile/pull/79) - Support extract gz tar file [`b9aed84`](https://github.com/yorukot/superfile/commit/b9aed847804421e1fc4f03dcaefb0e27f1260ea3) - Support transparent background [`4108d40`](https://github.com/yorukot/superfile/commit/4108d40bc0b93656eca2da98253a83dbc0cb27a9) - Support custom border style [`6ff0576`](https://github.com/yorukot/superfile/commit/6ff05765823cbd25e6fdc4d3f7370e435114acbb) - Enhancement when cutting and pasting, the file should be moved instead of copied and deleted. [`#100`](https://github.com/yorukot/superfile/issues/100) - Support extract almost compression formats [`e57cb78`](https://github.com/yorukot/superfile/commit/e57cb78d602d62b47662e2069b75059d908147db) - Update XDG_CACHE to XDG_STATE_HOME [`#90`](https://github.com/yorukot/superfile/issues/90) #### Bug fix - Fix Cut -> Paste file causes go panic [`#77`](https://github.com/yorukot/superfile/issues/77) - Fix symlinked folders don't open within superfile [`#88`](https://github.com/yorukot/superfile/issues/88) # [**v1.1.1**](https://github.com/yorukot/superfile/releases/tag/v1.1.1) > 2024-04-23 #### Update - Open directory with default application [`#33`](https://github.com/yorukot/superfile/issues/33) - Auto update config file if missing config [`1498c92`](https://github.com/yorukot/superfile/commit/1498c92d2166c8c25989be9ce5a15dc6d1ffb073) #### Bug fix - key `l` deletes files in macOS [`#72`](https://github.com/yorukot/superfile/issues/72) # [**v1.1.0**](https://github.com/yorukot/superfile/releases/tag/v1.1.0) > 2024-04-20 #### Update - Update data folder from `$XDG_CONFIG_HOME/superfile/data` to `$XDG_DATA_HOME/superfile` [`9fff97a`](https://github.com/yorukot/superfile/commit/9fff97a362bcd5bec1c19709b7a5aeb59cdeaa34) - Toggle dot file display [`9fff97a`](https://github.com/yorukot/superfile/commit/9fff97a362bcd5bec1c19709b7a5aeb59cdeaa34/9fff97a362bcd5bec1c19709b7a5aeb59cdeaa34) - Update log file from `$XDG_CONFIG_HOME/superfile/data/superfile.log` to `$XDG_CACHE_DATA` [`#27`](https://github.com/yorukot/superfile/pull/27) - Update theme background [`#42`](https://github.com/yorukot/superfile/pull/42) - Update unzip function [`#55`](https://github.com/yorukot/superfile/pull/55) - Update zip function [`60c490a`](https://github.com/yorukot/superfile/commit/60c490aa06019fb1a5382b1e241c6b0a72ec51a4) - Update all config file from `json` to `toml` format file [`a018128`](https://github.com/yorukot/superfile/commit/a018128ffd431d76a06f379fffbe0aa20d3e78cc) - Update search bar [`#61`](https://github.com/yorukot/superfile/pull/61) - Update theme config format [`#66`](https://github.com/yorukot/superfile/pull/66) - Update metadata to plugins [`c1f942d`](https://github.com/yorukot/superfile/commit/c1f942da366919f114b094ce512ff95002b6a08c) #### Bug fix - Fix interface lag when selecting zip files or large files [`#29`](https://github.com/yorukot/superfile/issues/29) - Fix external media error [`#46`](https://github.com/yorukot/superfile/pull/46) - Fix can't find trash can folder [`396674f`](https://github.com/yorukot/superfile/commit/396674f33e302369790bcb88d84df0d3830d3543) - Fix Crashes when truncating metadata [`#50`](https://github.com/yorukot/superfile/issues/50) # [**v1.0.1**](https://github.com/yorukot/superfile/releases/tag/v1.0.1) > 2024-04-08 #### Update - Update `$HOME/.superfile` to `$XDG_CONFIG_HOME/superfile` [`886dbfb`](https://github.com/yorukot/superfile/commit/886dbfb276407db36e9fb7369ec31053e7aabcf4) - Follow [The FreeDesktop.org Trash specification](https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html) to update the trash bin path in local path [`886dbfb`](https://github.com/yorukot/superfile/commit/886dbfb276407db36e9fb7369ec31053e7aabcf4) - The external hard drive will be deleted directly ,But macOS for now not support trash can[`a4232a8`](https://github.com/yorukot/superfile/commit/a4232a88bef4b5c3e99456fd198eabb953dc324c) - The user can enter the path, which will be the path of the first file panel [`14620b3`](https://github.com/yorukot/superfile/commit/14620b33b09edfce80a95e1f52f7f66b3686a9d0) - Make user can open file with default browser text-editor etc [`f47d291`](https://github.com/yorukot/superfile/commit/f47d2915bf637da0cf99a4b15fa0bea8edc8d380) - Can open terminal in focused file panel path [`f47d291`](https://github.com/yorukot/superfile/commit/f47d2915bf637da0cf99a4b15fa0bea8edc8d380) #### Bug fix - Fix processes bar cursor index display error [`f6eb9d8`](https://github.com/yorukot/superfile/commit/f6eb9d879f9f7ef31859e3f84c8792e2f0fc543a) - Fix [Crash when selecting a broken symlink](https://github.com/yorukot/superfile/issues/9) [`e89722b`](https://github.com/yorukot/superfile/commit/e89722b3717cc669c2e14bb310d1b96c1727b63f) # [**v1.0.0**](https://github.com/yorukot/superfile/releases/tag/v1.0.0) > 2024-04-06 ##### Update - Auto download folder [`96a3a71`](https://github.com/yorukot/superfile/commit/96a3a7108eb7c4327bad3424ed55e472ec78049f) - Auto initialize configuration [`96a3a71`](https://github.com/yorukot/superfile/commit/96a3a7108eb7c4327bad3424ed55e472ec78049f) - Add version sub-command [`ee22df3`](https://github.com/yorukot/superfile/commit/ee22df3c7700adddb859ada8623f6c8b038e8087) ##### Bug fix - Fix creating an Item when the file panel has no Item will cause an error [`9ee1d86`](https://github.com/yorukot/superfile/commit/9ee1d860192182803d408c5046ca9f5255121698) - Fix delete mupulate Item will cause cursor error [`ee22df3`](https://github.com/yorukot/superfile/commit/ee22df3c7700adddb859ada8623f6c8b038e8087) # [**Beta 0.1.0**](https://github.com/yorukot/superfile/releases/tag/v0.1.0-beta) > 2024-04-06 - FIRST RELEASE COME UP! NO ANY CHANGE ================================================ FILE: website/src/content/docs/configure/config-file-path.md ================================================ --- title: Config file path description: All superfile config file path head: - tag: title content: Config file path | superfile --- :::tip If you want to get the set path you can try `spf pl` which will print out the file locations of all superfile. ::: ## Directories #### Config directory | Linux | macOS | Windows | | :-------------------: | :-----------------------------: | :------------------------: | | `~/.config/superfile` | `~/Library/Application Support/superfile` | `%LOCALAPPDATA%/superfile` | #### Theme directory | Linux | macOS | Windows | | :-------------------------: | :---------------------------------------------: | :------------------------------: | | `~/.config/superfile/theme` | `~/Library/Application Support/superfile/theme` | `%LOCALAPPDATA%/superfile/theme` | #### Data directory | Linux | macOS | Windows | | :------------------------: | :----------------------------------------: | :------------------------: | | `~/.local/share/superfile` | `~/Library/Application Support/superfile/` | `%LOCALAPPDATA%/superfile` | ### Changing Config File Path You can use the `-c` or `--config-file` flag to specify a different path for the `config.toml` file: ```bash spf -c /path/to/your/config.toml ``` You can use the `--hotkey-file` flag to specify a different path for the `hotkey.toml` file: ```bash spf --hotkey-file /path/to/your/hotkey.toml ``` #### Log directory | Linux | macOS | Windows | | :------------------------: | :---------------------------------------: | :------------------------: | | `~/.local/state/superfile` | `~/Library/Application Support/superfile` | `%LOCALAPPDATA%/superfile` | --- ## All config file path #### Config | Linux | macOS | Windows | | :-------------------------------: | :-----------------------------------------: | :------------------------------------: | | `~/.config/superfile/config.toml` | `~/Library/Application Support/superfile/config.toml` | `%LOCALAPPDATA%/superfile/config.toml` | #### Hotkeys | Linux | macOS | Windows | | :--------------------------------: | :------------------------------------------: | :-------------------------------------: | | `~/.config/superfile/hotkeys.toml` | `~/Library/Application Support/superfile/hotkeys.toml` | `%LOCALAPPDATA%/superfile/hotkeys.toml` | #### Log file | Linux | macOS | Windows | | :--------------------------------------: | :-----------------------------------------------------: | :--------------------------------------: | | `~/.local/state/superfile/superfile.log` | `~/Library/Application Support/superfile/superfile.log` | `%LOCALAPPDATA%/superfile/superfile.log` | ================================================ FILE: website/src/content/docs/configure/custom-hotkeys.mdx ================================================ --- title: Custom hotkeys description: Customize your own hotkeys head: - tag: title content: Custom hotkeys | superfile --- import CodeBlock from '../../../components/code.astro'; You can enter the following command to set it up; [Click me to know where is HOTKEYS_PATH](/configure/config-file-path#hotkeys) ```bash $EDITOR HOTKEYS_PATH ``` :::caution Please do not use hotkeys with ascii codes conflicting with keys used to control superfile : - `Ctrl+M` - conflicts with `Enter` Key - `Ctrl+I` - conflicts with `Tab` Key - `Ctrl+?`, `Ctrl+[` - conflicts with `Delete` and `Backspace` Key ::: ### Default superfile hotkeys :::caution If you are a vim user, the default hotkeys may make you hate superfile. ::: superfile default hotkeys design concept: - All hotkeys that will change to files use `ctrl+key` (As long as you don't press ctrl your files will always be safe). - Non-control file classes use the first letters of words as hotkeys. ### Vim like superfile hotkeys ================================================ FILE: website/src/content/docs/configure/custom-theme.mdx ================================================ --- title: Custom theme description: Custom your own superfile theme head: - tag: title content: Custom theme | superfile --- import CodeBlock from '../../../components/code.astro'; ### Use an existing theme You can enter the following command to set it up; [Click me to know where is CONFIG_PATH](/configure/config-file-path#config) ```bash $EDITOR CONFIG_PATH ``` You can first go to the [theme list](/list/theme-list) to find a theme you like (or if you don't have one you like, you can make one yourself!) Once you find one you like, copy it and paste it into the theme in the config_path file. ```diff - theme = 'catppuccin' + theme = 'theme_name_you_like' ``` ### Create your own theme [Click me to know where is THEME_DIRECTORY](/configure/config-file-path#config) If you want to customize your own theme, you can go to `THEME_DIRECTORY/YOUR_THEME_NAME.toml` and copy the existing theme's json to your own theme file Don't forget to change the `theme` variable in `config.toml` to your theme name. [If you are satisfied with your theme, you might as well put it into the default theme list!](/how-to-contribute) ### Default theme ================================================ FILE: website/src/content/docs/configure/enable-plugin.md ================================================ --- title: Enable Plugin description: How to enable and configure superfile plugins head: - tag: title content: Enable Plugins | superfile --- Plugins extend superfile's functionality by integrating with external tools. This guide shows you how to enable and configure plugins. ## Prerequisites Before enabling any plugin, ensure you have: 1. **Installed the required dependencies** for the specific plugin 2. **Located your config file** - see [config file path guide](/configure/config-file-path#config) ## How to Enable Plugins ### Step 1: Install Required Dependencies Each plugin has specific requirements. Check the [plugin list](/list/plugin-list) for the dependencies needed for your desired plugin. ### Step 2: Edit Configuration File Open your `config.toml` file: ```bash $EDITOR CONFIG_PATH ``` ### Step 3: Enable the Plugin Find the plugin section in your config and change its value from `false` to `true`: ```diff [plugins] - metadata = false + metadata = true ``` ### Example: Enabling Metadata Plugin 1. **Install exiftool** (required for metadata plugin) 2. **Edit your config file:** ```bash $EDITOR CONFIG_PATH ``` 3. **Enable the plugin:** ```toml metadata = true ``` ## Configuration Format ```toml metadata = false zoxide_support = false ``` Set any plugin to `true` to enable it, or `false` to disable it. ## Available Plugins For a complete list of available plugins and their requirements, see the [plugin list](/list/plugin-list). ## Troubleshooting If a plugin isn't working after enabling it: 1. **Verify dependencies** - Make sure all required tools are installed and accessible in your PATH 2. **Restart superfile** - Changes require restarting the application 3. **Check configuration** - Ensure the plugin name is spelled correctly in your config file ================================================ FILE: website/src/content/docs/configure/superfile-config.mdx ================================================ --- title: superfile config description: Configure your superfile head: - tag: title content: superfile config | superfile --- import CodeBlock from "../../../components/code.astro"; You can edit your superfile config file with the following command: ```bash $EDITOR config_path ``` :::tip To see the path locations of your superfile files, use the command `spf pl`. ::: ### Setting - ###### theme [Click here](/configure/custom-theme) for instructions to edit the theme. - ###### editor The editor your files will be opened with (Leave blank to use the EDITOR environment variable. If EDITOR environment variable is not set, it will default to `nano` for macOS/Linux, and `notepad` for Windows. - ###### dir_editor The editor your directories will be opened with (Leave blank to use defaults : `vi` - Linux, `open` - macOS, `explorer` - Windows). - ###### auto_check_update `true` => Checks whether updates are needed when you exit superfile (only checks once a day). `false` => No checks performed. - ###### cd_on_quit `true` => When you exit superfile, changes the terminal path to the last file panel you used. `false` => When you exit superfile, the terminal path remains the same prior to superfile. After setting to `true`, you need to update your shell config file. Sample changes : ##### macOS/Linux (bash or fish) ###### Bash Open the file: ```bash $EDITOR ~/.bashrc ``` Copy the following code into the file: Save, exit, and reload your `.bashrc` file: ```bash source ~/.bashrc ``` ###### Fish Open the file: ```bash $EDITOR ~/.config/fish/config.fish ``` If you suspect your `config.fish` file is located somewhere else, read [the Fish shell documentation](https://fishshell.com/docs/current/language.html#configuration) Copy the following code into the file: Save, exit, and reload `config.fish`: ```bash source ~/.config/fish/config.fish ``` ##### Windows (Powershell) Open the file: ```powershell notepad $PROFILE ``` Copy the following code into the file: Save, exit, and reload your profile. ```powershell . $PROFILE ``` :::note You need to make sure powershell is allowed to execute script. If you get error like `running scripts is disabled on this system`. You need to allow it. Example command to enable - `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned` ::: - ###### default_open_file_preview `true` => Shows the file preview window when you run superfile. `false` => Hides the file preview window when you run a superfile. - ###### show_image_preview `true` => Shows the image preview in file preview panel when an image file is selected. `false` => Does not show the image preview. - ###### show_panel_footer_info `true` => Shows additional footer info for file panel like panel mode and sort type. `false` => Does not show additional footer info for file panel - ###### file_size_use_si `true` => Displays the file/directory sizes using powers of 1000 (kB, MB, GB). `false` => Displays the file/directory sizes using powers of 1024 (KiB, MiB, GiB). - ###### default_directory The default location every time superfile is opened. Supports `~` and `.` - ###### default_sort_type File panel sorting type. Directories will always be displayed at the top. `0` => Name `1` => Size `2` => Date Modified `3` => Type `4` => Natural - ###### sort_order_reversed File panel sorting order. `false` => Ascending (a-z) `true` => Descending (z-a) - ###### case_sensitive_sort File panel sorting case sensitivity (if `true`, uppercase letters come before lowercase letters). `true` => Case sensitive ("B" comes before "a") `false` => Case insensitive ("a" comes before "B") - ###### debug Whether to enable debug mode. (if `true`, more verbose logs are written in log file). `true` => DEBUG, INFO, WARN, ERROR logs are written to log file `false` => INFO, WARN, ERROR logs are written to log file - ###### ignore_missing_fields Controls whether warnings about missing fields in the config file are displayed. `true` => No warnings will be shown when fields are missing from the config file `false` => Warnings will be shown for any missing fields in the config file - ###### page_scroll_size Number of lines to scroll when using PgUp/PgDown keys. `0` => Full page scroll (default behavior) `n` (where n > 0) => Scroll exactly n lines - ###### file_panel_extra_columns Count of extra columns in file panel in addition to file name. `0` => Extra columns feature is disabled `1` => Also show size column `2` => Also show modify date `3` => Also show permission column :::caution Even though the extra columns are enabled, they will be hidden if the size of the filepanel is small. Please either increase the size or adjust the file_panel_name_percent config if you don't see the columns. ::: - ###### file_panel_name_percent Percentage of file panel width allocated to file names (25-100). Higher values give more space to names, less to extra columns. ### Style - ###### code_previewer `''` => Use the builtin syntax highlighting for code files with _chroma_. `'bat'` => Use syntax highlighting provided by the [`bat`](https://github.com/sharkdp/bat) command line tool. - ###### nerdfont `true` => Use nerdfont for directories and file icons. `false` => Dont use nerdfont. If you don't have or don't want Nerdfont installed you can turn this off - ###### show_select_icons `true` => Show checkbox icons in select mode. `false` => Don't show checkbox icons in select mode. :::note This setting is ignored unless `nerdfont = true`. Both `nerdfont` and `show_select_icons` must be `true` for checkbox icons to appear. ::: - ###### transparent_background `true` => The background color is not rendered (transparent). This is useful if your terminal background is transparent. `false` => The background is rendered (with color) to maintain theme consistency. - ###### file_preview_width This setting is an integer. `0` => The width of the file preview window is the same as the file panel. `X` => The width of the file preview window is 1/`X` of the terminal width (minus the sidebar width). It is calculated as: (terminal width - sidebar width) / `X` :::caution `X` must be from 2 to 10. ::: - ###### enable_file_preview_border `true` => Enable border around the file preview panel `false` => Disable border around the file preview panel - ###### sidebar_width This setting is an integer. `0` => The sidebar will not display. `X` => The width of the sidebar(excluding borders). :::caution `X` must be from 5 to 20. ::: - ###### sidebar_sections Order of sidebar sections. `["home", "pinned", "disks"]` => Default order. Only sections included in this list will be displayed. You can remove sections or change their order, for example: `["pinned", "home"]`. - ###### Border style Here are a few suggested styles, of course you can change them to your own: :::caution Make sure to add strings exactly one character wide. Use ' ' for borderless ::: ```toml # ... border_top = "━" border_bottom = "━" border_left = "┃" border_right = "┃" border_top_left = "┏" border_top_right = "┓" border_bottom_left = "┗" border_bottom_right = "┛" border_middle_left = "┣" border_middle_right = "┫" #... ``` ```toml # ... border_top = "─" border_bottom = "─" border_left = "│" border_right = "│" border_top_left = "╭" border_top_right = "╮" border_bottom_left = "╰" border_bottom_right = "╯" border_middle_left = "├" border_middle_right = "┤" #... ``` - ###### open_with Allows users to map file extensions to commands used to open them. The file path will be appended as the last argument. :::caution Must be at the very end of the file ::: ```toml [open_with] xopp = "xournalpp" conf = "nvim" ``` ### Default superfile config ================================================ FILE: website/src/content/docs/contribute/file-struct.md ================================================ --- title: superfile Project Structure Guide description: A detailed guide to understanding superfile's codebase organization head: - tag: title content: superfile Project Structure Guide | superfile --- # superfile Project Structure Guide The project follows a standard Go project layout with clear separation of concerns. Here's a detailed breakdown of the main directories and their purposes: ## Core Directories ### `src/` - Main Source Code The main source code is organized into several key directories: #### `cmd/` - Entry Point - `main.go` - The main entry point of the application that handles: - CLI argument parsing - Configuration initialization - Application startup #### `config/` - Configuration Management - `fixed_variable.go` - Contains constant values and configuration paths - `icon/` - Icon-related configuration - `function.go` - Icon initialization and management functions - `icon.go` - Icon definitions and mappings #### `internal/` - Core Application Logic Contains the main business logic of the application, organized by functionality: **Configuration & Types:** - `config_function.go` - Configuration loading and management - `config_type.go` - Configuration-related type definitions - `default_config.go` - Default configuration values - `type.go` - Core type definitions **File Operations:** - `file_operations.go` - Basic file operation functions - `file_operations_compress.go` - File compression functionality - `file_operations_extract.go` - File extraction functionality - `handle_file_operations.go` - File operation handlers **UI & Interaction:** - `handle_modal.go` - Modal dialog management - `handle_panel_movement.go` - Panel navigation logic - `handle_panel_navigation.go` - Panel focus management - `handle_pinned_operations.go` - Pinned items functionality - `key_function.go` - Keyboard input handling - `model.go` - Core application model - `model_render.go` - UI rendering logic **Utilities:** - `function.go` - General utility functions - `get_data.go` - Data retrieval functions - `string_function.go` - String manipulation utilities - `string_function_test.go` - String utility tests - `style.go` - UI styling definitions - `style_function.go` - UI styling functions - `string_function_test.go` - String utility tests - `style.go` - UI styling definitions - `style_function.go` - UI styling functions ### `testsuite/` - superfile's testsuite written in Python - Automatically tests superfile's functionality. - See `testsuite/ReadMe.md` for more info ## Code Organization Principles 1. **Separation of Concerns:** - Configuration management is isolated in the `config/` directory - Core business logic lives in `internal/` - UI-related code is separated from business logic 2. **Modular Design:** - Each file has a specific responsibility - Related functionality is grouped together - Clear dependencies between components 3. **Testing:** - Test files are placed alongside the code they test - Example: `string_function_test.go` tests `string_function.go` ## Contributing Guidelines When contributing to superfile: 1. **Adding New Features:** - Place new business logic in appropriate `internal/` subdirectories - Keep UI-related code separate from business logic - Follow existing naming conventions 2. **Making Changes:** - Maintain the existing file structure - Add tests for new functionality - Update configuration files if needed 3. **Code Style:** - Follow Go best practices - Maintain consistent formatting - Add appropriate documentation This structure helps maintain code organization and makes it easier for new contributors to understand where to make changes. ================================================ FILE: website/src/content/docs/contribute/how-to-contribute.md ================================================ --- title: How to Contribute description: How to contribute to the project, including ways to show your support, report bugs, and more. head: - tag: title content: How to Contribute | superfile --- # Contributing to superfile Welcome to **superfile**! This guide will help you get started contributing to the project, whether you're fixing bugs, building features, or just sharing ideas. There are many ways to contribute: * Reporting bugs * Fixing issues * Adding a theme * Suggesting and implementing new features * Sharing ideas or feedback --- ## 🐞 Issues ### Found a bug? Check if there's already an open or closed issue for it. If not, open a new one and describe the problem clearly. ### Want to fix an issue? 1. Fork this repository 2. Create a new branch for the issue you're working on 3. Commit your changes with clear messages 4. Open a pull request (PR) with a description of the problem and your solution Maintainers may request changes before merging. --- ## 🎨 Adding a Theme Before starting, make sure the theme you want to add doesn’t already exist. 1. Copy an existing theme's `.toml` file as a base 2. Customize it to your needs 3. Test it by editing your `~/.config/superfile/config/config.toml` 4. When ready, submit a pull request 5. To ensure the theme looks consistent and functions properly, please include the following screenshots in your PR: - Full view of superfile (Including sidebar, file previewer, process panel, metadata panel, and clipboard panel ) - Make sure that file previewer is non empty, process panel has at least one process, and clipboard has at least one entry - Add a screenshot of these individual panel being focused (To make sure border focus color is good) - Sidebar - Processbar - Add a screenshot of help menu (Press ?) - Add a screenshot of popup that opens when you create a new file (Ctrl+n) - Add a screenshot of image being preview using your theme. - Add a screenshot of successful and unsuccessful shell command.
Example: - Full view of superfile (Including sidebar, file previewer, process panel, metadata panel, and clipboard panel) - Make sure that file previewer is non empty, process panel has at least one process, and clipboard has at least one entry ![Full view of superfile](https://raw.githubusercontent.com/yorukot/superfile/main/asset/theme-example/1.png) - Add a screenshot of these individual panels being focused (To make sure border focus color is good) - Sidebar - Processbar ![Sidebar focused](https://raw.githubusercontent.com/yorukot/superfile/main/asset/theme-example/2.png) ![Processbar focused](https://raw.githubusercontent.com/yorukot/superfile/main/asset/theme-example/3.png) - Add a screenshot of help menu (Press `?`) ![Help menu](https://raw.githubusercontent.com/yorukot/superfile/main/asset/theme-example/4.png) - Add a screenshot of popup that opens when you create a new file (Ctrl+n) ![New file popup](https://raw.githubusercontent.com/yorukot/superfile/main/asset/theme-example/5.png) - Add a screenshot of image being previewed using your theme ![Image preview](https://raw.githubusercontent.com/yorukot/superfile/main/asset/theme-example/6.png) - Add a screenshot of successful and unsuccessful shell command ![Successful shell command](https://raw.githubusercontent.com/yorukot/superfile/main/asset/theme-example/7.png) ![Failed shell command](https://raw.githubusercontent.com/yorukot/superfile/main/asset/theme-example/8.png)
--- ## 💡 Sharing Ideas Got a new idea? Awesome! 1. Check if similar ideas exist in Discussions or Issues 2. Open a discussion at: [https://github.com/yorukot/superfile/discussions](https://github.com/yorukot/superfile/discussions) 3. If you want to implement it yourself, follow the PR steps above --- ## 🧩 Don’t Know Where to Start? Check out GitHub’s official guide: [https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-a-project](https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-a-project) Still unsure? Open a discussion — we’re happy to help. --- ## ✅ Pull Request Checklist Please make sure your PR follows these steps: * [ ] I have run `go fmt ./...` to format the code * [ ] I have run `golangci-lint run` and fixed any reported issues * [ ] I have tested my changes and verified they work as expected * [ ] I have reviewed the diff to make sure I’m not committing any debug logs or TODOs * [ ] I have filled out the PR template with description, context, and screenshots if needed - [ ] I have checked that the PR title follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) format --- ## 🙏 Thank You Thank you for contributing to superfile! We appreciate every issue, pull request, and idea. Your help makes this project better for everyone. ================================================ FILE: website/src/content/docs/contribute/implementation-info.md ================================================ --- title: Implementation info description: A collection of general information regarding how various things work head: - tag: title content: Implementation info | superfile --- # Implmentation info The purpose of this document is to provide some implementation details to the reader that are not so obvious from the code and not very straightforward to figure out. ## How default configuration files are packaged with app We use golangs `embed.FS` and embed all files in `src/superfile_config/` into our spf binary. In `src/internal/config_function.go`, the function `LoadAllDefaultConfig()` reads these embedded files, and write them to disk / in memory configuratin variables. ================================================ FILE: website/src/content/docs/getting-started/image-preview.md ================================================ --- title: Image Preview description: Learn how image preview works in superfile and how terminal compatibility is determined. head: - tag: title content: Image Preview | superfile --- This tutorial will teach you how to use superfile’s image preview feature step by step. ## What is Image Preview? superfile supports image previews directly in your terminal using several display protocols. When supported, images can be shown inline without any external viewer. --- ## Terminal Compatibility superfile automatically detects your terminal using the `$TERM` and `$TERM_PROGRAM` environment variables. We support rendering on the following terminals: | Terminal | Protocol | Image Preview Support | |-----------------------|------------------|------------------------| | **kitty** | Kitty protocol | ✅ | | **WezTerm** | Kitty protocol | ✅ | | **Ghostty** | Kitty protocol | ✅ | | **iTerm2** | Inline images | ❌ | | **Konsole** | Inline images | ❌ | | **VSCode** | Inline images | ❌ | | **Tabby** | Inline images | ❌ | | **Hyper** | Inline images | ❌ | | **Mintty** | Inline images | ❌ | | **foot** | Sixel graphics | ❌ | | **Black Box** | Sixel graphics | ❌ | > ✅ means full support for inline image preview using Kitty protocol > ❌ means image preview is currently not supported --- ## Supported Protocols superfile supports the following rendering protocols and will automatically choose the best one based on your terminal: | Protocol Name | Description | Status | |-------------------|-----------------------------------------------------------------------------------------------|-------------| | **Kitty protocol** | Most capable, pixel-accurate rendering with transparency and scaling support. | ✅ Preferred| | **Sixel** | Old standard used in DEC terminals and some modern ones like foot. | ❌ | | **iTerm2 inline** | iTerm2’s proprietary image format, used in Tabby, Hyper, etc. | ❌ | | **ANSI** | Fallback text rendering using ANSI blocks or metadata only. | ✅ Always | --- ## Terminal Detection and Pixel Size superfile detects terminal capabilities by inspecting: - `$TERM` - `$TERM_PROGRAM` These variables help us decide whether advanced rendering might be possible. However, real support is confirmed at runtime using terminal queries. To scale images correctly, superfile sends the following escape code: ``` \x1b[16t ``` This sequence queries the terminal for the size of each **cell in pixels**. superfile uses the result to: - Maintain correct image aspect ratio - Avoid distortions in previews - Adapt to terminal resizes If your terminal does not support `\x1b[16t`, we fallback to default assumptions like `10×20 px per cell`. ## Graceful Fallback to ANSI When advanced image preview isn't supported (for example, when the terminal doesn't support the Kitty protocol), superfile gracefully falls back to an ANSI-based preview using color-coded blocks. This ensures a consistent and reliable experience across all terminal environments. ================================================ FILE: website/src/content/docs/getting-started/installation.md ================================================ --- title: Install superfile description: Let's install superfile to your computer.. head: - tag: title content: Install superfile | superfile --- ## Before install First make sure you have the following tools installed on your machine: - [Any Nerd-font ](https://www.nerdfonts.com/font-downloads), and set the font for your terminal application to use the installed Nerd-font :::tip If you don't install `Nerd font`, superfile will still work, but the UI may look a bit off. It's recommended to disable the Nerd font option to avoid this issue. ::: ## Installation Scripts Copy and paste the following one-line command into your machine's terminal. ### Linux / MacOs With `curl`: ```bash bash -c "$(curl -sLo- https://superfile.dev/install.sh)" ``` Or with `wget`: ```bash bash -c "$(wget -qO- https://superfile.dev/install.sh)" ``` Use `SPF_INSTALL_VERSION` to specify a version : ```bash SPF_INSTALL_VERSION=1.2.1 bash -c "$(curl -sLo- https://superfile.dev/install.sh)" ``` ### Windows With `powershell`: ```bash powershell -ExecutionPolicy Bypass -Command "Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://superfile.dev/install.ps1'))" ``` :::note To uninstall, run the above `powershell` command with the modified URL: `https://superfile.dev/uninstall.ps1` ::: Use `SPF_INSTALL_VERSION` to specify a version : ```bash powershell -ExecutionPolicy Bypass -Command "$env:SPF_INSTALL_VERSION=1.2.1; Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://superfile.dev/install.ps1'))" ``` With [Winget](https://winget.run/): ```powershell winget install --id yorukot.superfile `````` With [Scoop](https://scoop.sh/): ```bash scoop install superfile ``` ## Community maintained packages [![Packaging status](https://repology.org/badge/vertical-allrepos/superfile.svg)](https://repology.org/project/superfile/versions) > Sort by letter ### Arch ###### Builds package from sources ```bash sudo pacman -S superfile ``` ###### Builds most recent version from GitHub ```bash yay -S superfile-git ``` ### Homebrew Install [Homebrew](https://brew.sh/) and then run the following command: ```bash brew install superfile ``` ### NixOS ###### Install with nix command-line ```bash nix profile install github:yorukot/superfile#superfile ``` ###### Install with flake Add superfile to your flake inputs: ```nix inputs = { superfile = { url = "github:yorukot/superfile"; }; # ... }; ``` Then you can add it to your packages: ```nix let system = "x86_64-linux"; in { environment.systemPackages = with pkgs; [ # ... inputs.superfile.packages.${system}.default ]; } ``` ### Pixi Install [Pixi](https://pixi.sh/latest/) and then run the following command: ```bash pixi global install superfile ``` ### X-CMD [x-cmd](https://www.x-cmd.com/) is a **toolbox for Posix Shell**, offering a lightweight package manager built using shell and awk. ```sh x env use superfile ``` ## Start superfile After completing the installation, you can restart the terminal (if necessary). Run `spf` to start superfile ```bash spf ``` ## Next steps - [Tutorial](/getting-started/tutorial) - [Hotkey list](/list/hotkey-list) ================================================ FILE: website/src/content/docs/getting-started/tutorial.md ================================================ --- title: Learn how to use tutorial description: Quickly get started with superfile head: - tag: title content: Tutorial | superfile --- This tutorial will teach you how to use superfile step by step. :::caution If you haven't installed superfile yet, please [click here](/getting-started/installation). ::: :::tip A full list of hotkeys are available [here](/list/hotkey-list) ::: ## Hotkeys tutorial Let's start by running superfile! Open a terminal, type `spf` and press `enter`. To exit, press `q` or `esc`. ![hotkeys-demo](../../../assets/demo/hotkeys-demo.gif) ### Panel navigation Once superfile is running, it displays five panels: - sidebar - file - processes - metadata - clipboard - command execution bar The file panel is the focused view by default. You can change focus onto three other panels. Press `s` to focus on the sidebar. Press `p` to focus on the processes. Press `m` to focus on the metadata. Press `:` to open command execution bar. To return focus back onto the file panel, press the same hotkey again. > For command execution bar you need press `esc` or `ctrl+c` You can also press `f` to show or hide the preview window. Also press `F` to hide or show all footer panel. ![panel-navigation-demo](../../../assets/demo/panel-navigation-demo.gif) :::tip The size of the folder will only be shown when you focus on the metadata. For more detailed metadata, [click here](/configure/enable-plugin) to install the metadata plugin. ::: To create more file panels, press `n`. Press `w` to close the focused file panel. To move through multiple file panels, press `tab` or `L` (shift+l). To move to the previous panel, press `shift`+`left` or `H` (shift+h). ![multiple-panels-demo](../../../assets/demo/multiple-panels-demo.gif) ### Panel movement superfile provides multiple hotkeys to move through directories. The angle bracket cursor `>` tells you where you are. While focused on the file panel, move the cursor up with `up` or `k` and down with `down` or `j`. After navigating to the your file/folder, press `enter` or `l` to confirm your selection. Files are opened with your default application (if none set, there will be no response) and folders are opened for viewing. Press `h` or `backspace` to return to the parent directory. ![panel-movement-demo](../../../assets/demo/panel-movement-demo.gif) Folders can be pinned to the sidebar panel. Navigate to and open your folder. Press `P` (shift+p) to pin or unpin it. Press `o` to bring up the sort options menu. You can sort by: - `Name` - `Size` - `Date Modified` Press `enter` to confirm your sort option. Press `esc`, `o`, or `ctrl`+`c` to cancel. To reverse the order of the sort, press `R` (shift+r). Press `/` to bring up the search bar. Type the name (you may need to first delete the `/` if it auto-populates). superfile searches in the current directory and dynamically displays the results. To exit the search bar, press `ctrl`+`c` or `esc`. Press `.` to show or hide dotfiles. #### Selection mode Use selection mode for bulk operations. If you are familiar with Vim, selection mode is similar to Vim's [visual mode](https://vimhelp.org/visual.txt.html#Visual). Press `v` to toggle between selection mode and normal (browser) mode. Once in selection mode, you can perform [file operations](#file-operations) on all selected files/folders. [Panel movement](#panel-movement) hotkeys also work in selection mode. :::tip The following operations can only be performed while in selection mode. Your current mode is displayed in the lower-right corner of the file panel (Select or Browser). ::: To make selections, navigate to your file/folder and press `enter` or `L` (shift+l). Press the same key again to deselect. This may become tedious when you have a large number of items. Instead, you can press `shift`+`up` or `K` (shift+k) to select everything above the cursor. Press `shift`+`down` or `J` (shift+j) to select everything below the cursor. You can also press `A` (shift+a) to select everything in the current directory. ![selection-mode-demo](../../../assets/demo/selection-mode-demo.gif) ### File operations :::note Only copy, cut and delete can be used in selection mode. ::: Now let's learn how to perform file operations. Create a new file with `ctrl`+`n`. Type your new file's name and press `enter`. To create a new folder, add `/` to the end of the name. :::tip You can create a directory, subdirectory and file in one string. For example: `directory/subdirectory/filename` ::: To rename, point your cursor at a file/folder and press `ctrl`+`r`. To copy, you can press `ctrl`+`c`. To cut, you can press `ctrl`+`x`. Both cut and copied items are shown in the clipboard panel (lower-right corner). The progress of your operations is displayed in the processes panel (lower-left corner). To paste, you can press `ctrl`+`v`. :::note In some terminals, for example Windows Powershell, `ctrl`+`v` pastes input from clipboard to terminal. So, `ctrl`+`v` might not work for paste. Either you can add `ctrl`+`w` hotkey for paste, or override default behaviour of `ctrl`+`v` on your terminal. ::: To delete, you can press `ctrl`+`d` :::note The deletion here is not direct deletion, but will be placed in the trash can. However, when you use an external hard drive, it will be deleted directly. ::: To compress, press `ctrl`+`a`. To decompress, press `ctrl`+`e`. To open a file with an editor, press `e`. To open the current directory with an editor, press `E` (shift+e). To change the default file editor, you can set the `EDITOR` environment variable in your terminal or you can use the `editor` config option (take priority over `EDITOR` environment variable). To change the default directory editor, you can use the `dir_editor` config option. For example: ```bash EDITOR=nvim ``` This will set Neovim as your default editor. After setting this, Neovim will be used when opening files with the `e` key bindings. ``` editor = "nano" dir_editor = "vi" ``` These are changes in config file. See [superfile-config](/configure/superfile-config) for more info. This will set `nano` as your default editor, and `vi` as your default directory editor. After setting this, `nano` will be used when opening files with the `e` key bindings, and `vi` will be used to open current directory with `E` key bindings. :::caution If your directory editor does not support opening the current directory with an editor, you may encounter an error when pressing `E`. ::: ![file-operations-demo](../../../assets/demo/file-operations-demo.gif) ### SPF Prompt #### Shell Mode Press `:` to open the prompt in shell mode, and execute any shell command in the current directory. ![Prompt-Shell-Mode](../../../assets/git-assets/prompt_shell_mode.png) :::note You won't receive any stdout outputs. For now, this is meant for executing more complex file manipulations via the shell, rather than handling interactive outputs. You will be able to see the exit code of the command. ::: #### SPF Mode Press `>` to open the prompt in SPF mode. ![Prompt-SPF-Mode](../../../assets/git-assets/prompt_spf_mode.png) In this mode, you can execute these spf commands : - `split` - Open a new panel at a current file panel's path. - `open ` - Open a new panel at a specified path. - `cd ` - Change directory of current panel. In this mode, You can substitute shell environment variables via `${}`, shell commands via `$()` and prefix path with `~` to get substituted to home directory For example - `cd ${HOME}` or `cd ~/xyz` - `open $(dirname $(which bash))` Press `esc` or `ctrl`+`c` to exit Prompt. ================================================ FILE: website/src/content/docs/index.mdx ================================================ --- title: superfile | terminal-based file manager description: "superfile is a very fancy and modern terminal file manager that can complete the file operations you need!!" template: splash lastUpdated: false editUrl: false hero: title: Perfect Terminal-based file manager 🚀! tagline: "superfile is a very fancy and modern terminal file manager that can complete the file operations you need!!" image: file: ../../assets/logo.png actions: - text: Get Started link: /overview icon: right-arrow variant: primary - text: View on GitHub link: https://github.com/yorukot/superfile icon: external --- import { Card, CardGrid } from '@astrojs/starlight/components'; import GithubStar from '../../components/GithubStar.astro'; import About from '../../components/about.astro'; ## Features It can be said that good-looking is the original intention of superfile, so the entire superfile should be as beautiful as possible. This file manager allows you to do almost everything you want to do on a file manager. From basic Hotkey, the entire theme color and even the border Style can be customized. Multiple panel allows you to view multiple directories at the same time and copy and paste in just a few simple steps without having to return to the main directory. ================================================ FILE: website/src/content/docs/list/hotkey-list.md ================================================ --- title: Hotkey list description: superfile hotkey list head: - tag: title content: Hotkey list | superfile --- :::tip These are the default hotkeys and you can [change](/configure/custom-hotkeys) them all! ::: ## General | Function | Key | Variable name | | --------------------------------------- | ---------------- | ---------------- | | Open superfile | `spf` | | | Confirm your select or typing | `enter`, `right` | `confirm_typing` | | Quit typing, modal or superfile | `esc`, `q` | `quit` | | Quit superfile and cd to current folder | `Q` | `cd_quit` | | Cancel typing | `ctrl+c`, `esc` | `cancel_typing` | | Open help menu(hotkeylist) | `?` | `open_help_menu` | | Toggle footer | `F` | `toggle_footer` | :::note Quit superfile and cd to current folder "cd_quit" require the same scripts as ["cd_on_quit"](/configure/superfile-config/#cd_on_quit) setting ::: ## Panel navigation | Function | Key | Variable name | | -------------------------------- | -------------------------- | --------------------------- | | Create new file panel | `n` | `create_new_file_panel` | | Split focused file panel | `N` (shift+n) | `split_file_panel` | | Close the focused file panel | `w` | `close_file_panel` | | Toggle file preview panel | `f` | `toggle_file_preview_panel` | | Focus on the next file panel | `tab`, `L`(shift+l) | `next_file_panel` | | Focus on the previous file panel | `shift+left`, `H`(shift+h) | `previous_file_panel` | | Focus on the processbar panel | `p` | `focus_on_process_bar` | | Focus on the sidebar | `s` | `focus_on_side_bar` | | Focus on the metadata panel | `m` | `focus_on_metadata` | | Open prompt in shell mode | `:` | `open_command_line` | | Open prompt in spf mode | `>` | `open_spf_prompt` | | Open zoxide navigation modal | `z` | `open_zoxide` | ## Panel movement | Function | Key | Variable name | | -------------------------------------------------- | --------------------------- | --------------------------------------------------------------- | | Up | `up`, `k` | `list_up` | | Down | `down`, `j` | `list_down` | | Return to parent folder | `h`, `left`, `backspace` | `parent_folder` | | Toggle sort options menu | `o` | `open_sort_options_menu` | | Select all items in focused file panel | `A` (shift+a) | `file_panel_select_all_item` (selection mode only) | | Select up with your course | `shift+up`, `K` (shift+k) | `file_panel_select_mode_item_select_up` (selection mode only) | | Select down with your course | `shift+down`, `J` (shift+j) | `file_panel_select_mode_item_select_down` (selection mode only) | | Toggle dot file display | `.` | `toggle_dot_file` | | Toggle active search bar | `/` | `search_bar` | | Change between selection mode or normal mode | `v` | `change_panel_mode` | | Pin or Unpin folder to sidebar (can be auto saved) | `P` (shift+p) | `pinned_folder` | ## File operations | Function | Key | Variable name | | ---------------------------------------------------- | ------------------ | -------------------------------------------------------------------------------------- | | Create file or folder(/ ends with creating a folder) | `ctrl+n` | `file_panel_item_create` | | Rename file or folder | `ctrl+r` | `file_panel_item_rename` | | Copy file or folder (or both) | `ctrl+c` | `copy_single_item` (normal mode)
`file_panel_select_mode_item_copy` (select mode) | | Cut file or folder (or both) | `ctrl+x` | `file_panel_select_mode_item_cut` | | Paste all items in your clipboard | `ctrl+v`, `ctrl+w` | `paste_item` | | Delete file or folder (or both) | `ctrl+d`, `delete` | `delete_item` (normal mode)
`file_panel_select_mode_item_delete` (select mode) | | Copy current file or directory path | `ctrl+p` | `copy_path` | | Extract zip file | `ctrl+e` | `extract_file` (normal mode) | | Zip file or folder to .zip file | `ctrl+a` | `compress_file` (normal mode) | | Open file with your default editor | `e` | `open_file_with_editor` (normal node) | | Open current directory with default editor | `E` (shift+e) | `current_directory_with_editor` (normal node) | | Permanently Delete file or folder (or both) | `D` (shift+d) | `permanently_delete_items` (normal mode)
`file_panel_select_mode_item_delete` (select mode) | ================================================ FILE: website/src/content/docs/list/plugin-list.md ================================================ --- title: Plugin List description: Complete list of available superfile plugins head: - tag: title content: Plugin List | superfile --- Superfile supports various plugins to extend its functionality. Below is a complete list of available plugins and their requirements. ### Metadata - **Description:** Show more detailed metadata for files and directories - **Requirements:** [`exiftool`](https://exiftool.org) - **Config name:** `metadata` ### Zoxide - **Description:** Smart directory jumping integration with zoxide. Navigate to frequently used directories quickly with a searchable modal interface. - **Requirements:** [`zoxide`](https://github.com/ajeetdsouza/zoxide) - **Config name:** `zoxide_support` - **Usage:** Press `z` to open the zoxide navigation modal. Start typing to search directories, use arrow keys to navigate results, and press Enter to jump to a directory. ================================================ FILE: website/src/content/docs/list/theme-list.md ================================================ --- title: Theme list description: List themes currently owned by superfile head: - tag: title content: Theme list | superfile --- > Sort by A-Z ## 0x96f - Theme name: `0x96f` - Ported by: https://github.com/filipjanevski - Original Author: https://github.com/filipjanevski/ ![0x96f theme preview showing dark color scheme with blue accents](../../../assets/git-assets/theme/0x96f.png) ## Ayu Dark - Theme name: `ayu-dark` - Ported by: https://github.com/rustnomicon - Original Author: https://github.com/ayu-theme/ ![Ayu Dark theme preview showing warm dark color palette](../../../assets/git-assets/theme/ayu-dark.png) ## Blood - Theme name: `blood` - Ported by: https://github.com/charlesrocket - Original Author: https://github.com/charlesrocket ![Blood theme preview showing dark red color scheme](../../../assets/git-assets/theme/blood.png) ## Catppuccin Frappe - Theme name: `catppuccin-frappe` - Ported by: https://github.com/GV14982 - Original Author: https://github.com/catppuccin ![Catppuccin Frappe theme preview showing muted dark colors](../../../assets/git-assets/theme/catppuccin-frappe.png) ## Catppuccin Latte - Theme name: `catppuccin-latte` - Ported by: https://github.com/GV14982 - Original Author: https://github.com/catppuccin ![Catppuccin Latte theme preview showing light color scheme](../../../assets/git-assets/theme/catppuccin-latte.png) ## Catppuccin Macchiato - Theme name: `catppuccin-macchiato` - Ported by: https://github.com/GV14982 - Original Author: https://github.com/catppuccin ![Catppuccin Macchiato theme preview showing medium dark colors](../../../assets/git-assets/theme/catppuccin-macchiato.png) ## Catppuccin Mocha - Theme name: `catppuccin-mocha` - Ported by: https://github.com/AnshumanNeon - Original Author: https://github.com/catppuccin ![Catppuccin theme preview showing pastel color palette](../../../assets/git-assets/theme/catppuccin.png) ## Dracula - Theme name: `dracula` - Ported by: https://github.com/BeanieBarrow - Original Author: https://github.com/zenorocha ![Dracula theme preview showing purple and pink dark color scheme](../../../assets/git-assets/theme/dracula.png) ## Everforest Dark Medium - Theme name: `everforest-dark-medium` - Ported by: https://github.com/dotintegral - Original Author: https://github.com/sainnhe/ ![Everforest Dark Medium theme preview showing nature-inspired green colors](../../../assets/git-assets/theme/everforest-dark-medium.png) ## Everforest Dark Hard - Theme name: `everforest-dark-hard` - Ported by: https://github.com/fzahner - Original Author: https://github.com/sainnhe/ ![Everforest Dark hard theme preview showing nature-inspired green colors](../../../assets/git-assets/theme/everforest-dark-hard.png) ## Gruvbox - Theme name: `gruvbox` - Ported by: https://github.com/yorukot - Original Author: https://github.com/morhetz/ ![Gruvbox theme preview showing retro warm color palette](../../../assets/git-assets/theme/gruvbox.png) ## Gruvbox Dark Hard - Theme name: `gruvbox-dark-hard` - Ported by: https://github.com/frost-phoenix - Original Author: https://github.com/morhetz/ ![Gruvbox Dark Hard theme preview showing high contrast warm colors](../../../assets/git-assets/theme/gruvbox-dark-hard.png) ## Hacks - Theme name: `hacks` - Ported by: https://github.com/charlesrocket - Original Author: https://github.com/charlesrocket ![Hacks theme preview showing cyberpunk-inspired color scheme](../../../assets/git-assets/theme/hacks.png) ## Kaolin - Theme name: `kaolin` - Ported by: https://github.com/AnshumqanNeon - Original Author: https://github.com/ogdenwebb/ ![Kaolin theme preview showing brown and orange earth tones](../../../assets/git-assets/theme/kaolin.png) ## Monokai - Theme name: `monokai` - Ported by: https://github.com/CommandJoo - Original Author: https://github.com/monokai ![Monokai theme preview showing classic dark syntax highlighting colors](../../../assets/git-assets/theme/monokai.png) ## Nord - Theme name: `nord` - Ported by: https://github.com/ramses-eltany - Original Author: https://github.com/nordtheme ![Nord theme preview showing cool blue and white arctic colors](../../../assets/git-assets/theme/nord.png) ## OneDark - Theme name: `onedark` - Ported by: https://github.com/CommandJoo - Original Author: https://github.com/one-dark ![OneDark theme preview showing dark background with blue accents](../../../assets/git-assets/theme/onedark.png) ## Poimandres - Theme name: `poimandres` - Ported by: https://github.com/Myles-J - Original Author: https://github.com/drcmda/ ![Poimandres theme preview showing dark purple and teal color scheme](../../../assets/git-assets/theme/poimandres.png) ## Rosé Pine - Theme name: `rose-pine` - Ported by: https://github.com/pearcidar - Original Author: https://github.com/rose-pine ![Rosé Pine theme preview showing soft pink and purple colors](../../../assets/git-assets/theme/rose-pine.png) ## Sugarplum - Theme name: `sugarplum` - Ported by: https://github.com/lemonlime0x3C33 - Original Author: https://github.com/lemonlime0x3C33 ![Sugarplum theme preview showing sweet purple and pink color palette](../../../assets/git-assets/theme/sugarplum.png) ## Tokyonight - Theme name: `tokyonight` - Ported by: https://github.com/pearcidar - Original Author: https://github.com/enkia/ ![Tokyonight theme preview showing dark blue nighttime color scheme](../../../assets/git-assets/theme/tokyonight.png) ================================================ FILE: website/src/content/docs/overview.md ================================================ --- title: Overview description: An overview of why we built this starter, including its features, the libraries used, and more. head: - tag: title content: Overview | superfile --- ![Demo of superfile terminal file manager interface](../../assets/git-assets/demo.png) # What is superfile? superfile is a modern terminal file manager crafted with a strong focus on user interface, functionality, and ease of use. Built with [Go](https://go.dev/) and [Bubble Tea](https://github.com/charmbracelet/bubbletea), it combines a visually appealing design with the simplicity of terminal tools, providing a fresh, accessible approach to file management. # Why was superfile built? Before creating superfile, I tried a lot of terminal file managers, but I was often disappointed by their UI design. So, I built superfile with a primary focus on delivering a refined, user-friendly interface. # Why should I use superfile? superfile is sleek and visually appealing, making it a great choice for lightweight file or directory tasks. While it may not be as feature-packed as some other terminal file managers, it excels in usability and design. If you’re looking for a full-featured file manager, I’d recommend tools like [Yazi](https://github.com/sxyazi/yazi) or others. However, for straightforward tasks with a clean interface, superfile is an excellent option. ================================================ FILE: website/src/content/docs/special-thanks.mdx ================================================ --- title: Special Thanks description: A special thanks to the people, projects, and media that made superfile possible. head: - tag: title content: Special Thanks | superfile --- ## Core Team - [Yorukot](https://github.com/yorukot) - Creator of superfile. - [Lazysegtree](https://github.com/lazysegtree) - Currently a primary contributor to superfile. ## Contributors Contributors ## Sponsors - [Supporter at Ko-fi](https://ko-fi.com/yorukot) - Thank you to all the supporters who bought me a coffee on Ko-fi. - [Warp](https://www.warp.dev/) - Special thanks to Warp for sponsoring the development of superfile. - [JetBrains](https://www.jetbrains.com/) - Special thanks to JetBrains for providing free licenses for open-source projects. ## Media - Thanks to all the bloggers, writers, streamers, and YouTubers who created content about superfile—your support has helped spread the word and grow the community! ================================================ FILE: website/src/content/docs/troubleshooting.md ================================================ --- title: Troubleshooting description: Have you encountered any problems? Come here and take a look. head: - tag: title content: Troubleshooting | superfile --- ## My superfile icon doesn't display correctly Try these things below: - Make sure you already install [nerdfont](https://www.nerdfonts.com/font-downloads) (You can choose whatever font you like!) - Apply this font to your terminal,This may require different settings depending on the terminal.You can check how to set it up! ## Help! My superfile's rendering is all messed up! Try these things below: - Set your locale to utf-8 - chcp 65001 ( If that's an option for your shell ) - Set environment variable RUNEWIDTH_EASTASIAN to 0 (`RUNEWIDTH_EASTASIAN=0`) ================================================ FILE: website/src/env.d.ts ================================================ /// /// ================================================ FILE: website/src/styles/custom.css ================================================ :root { --sl-font: 'IBM Plex Mono', 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; } /* Dark mode colors. */ :root { --sl-color-accent-low: #2c230a; --sl-color-accent: #846500; --sl-color-accent-high: #d4c8ab; --sl-color-white: #ffffff; --sl-color-gray-1: #eceef2; --sl-color-gray-2: #c0c2c7; --sl-color-gray-3: #888b96; --sl-color-gray-4: #545861; --sl-color-gray-5: #353841; --sl-color-gray-6: #24272f; --sl-color-black: #17181c; } /* Light mode colors. */ :root[data-theme='light'] { --sl-color-accent-low: #dfd6c0; --sl-color-accent: #866700; --sl-color-accent-high: #3f3003; --sl-color-white: #17181c; --sl-color-gray-1: #24272f; --sl-color-gray-2: #353841; --sl-color-gray-3: #545861; --sl-color-gray-4: #888b96; --sl-color-gray-5: #c0c2c7; --sl-color-gray-6: #eceef2; --sl-color-gray-7: #f5f6f8; --sl-color-black: #ffffff; } :root { --purple-hsl: 205, 60%, 60%; --overlay-blurple: hsla(var(--purple-hsl), 0.4); } :root[data-theme='light'] { --purple-hsl: 255, 85%, 65%; } [data-has-hero] .page { background: linear-gradient(215deg, var(--overlay-blurple), transparent 40%), radial-gradient(var(--overlay-blurple), transparent 40%) no-repeat -60vw -40vh / 105vw 200vh, radial-gradient(var(--overlay-blurple), transparent 65%) no-repeat 50% calc(100% + 20rem) / 60rem 30rem; } [data-has-hero] header { border-bottom: 1px solid transparent; background-color: transparent; -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); } [data-has-hero] .hero > img { filter: drop-shadow(0 0 3rem var(--overlay-blurple)); } [data-page-title] { font-size: 3rem; } /* date page title onl 2.5rem on mobile devices */ @media (max-width: 768px) { [data-page-title] { font-size: 2.5rem; } } .card-grid > .card { border-radius: 10px; } .card > .title { font-size: 1.3rem; font-weight: 600; line-height: 1.2; } ================================================ FILE: website/tsconfig.json ================================================ { "extends": "astro/tsconfigs/strict" }