Repository: folke/snacks.nvim Branch: main Commit: ad9ede6a9cdd Files: 272 Total size: 2.4 MB Directory structure: gitextract_ihjv8qnw/ ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── dependabot.yml │ ├── labeler.yml │ ├── release-please-config.json │ ├── release-please-manifest.json │ └── workflows/ │ ├── ci.yml │ ├── labeler.yml │ ├── pr.yml │ ├── stale.yml │ └── update.yml ├── .gitignore ├── .markdownlint-cli2.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── doc/ │ ├── snacks.nvim-animate.txt │ ├── snacks.nvim-bigfile.txt │ ├── snacks.nvim-bufdelete.txt │ ├── snacks.nvim-dashboard.txt │ ├── snacks.nvim-debug.txt │ ├── snacks.nvim-dim.txt │ ├── snacks.nvim-explorer.txt │ ├── snacks.nvim-gh.txt │ ├── snacks.nvim-git.txt │ ├── snacks.nvim-gitbrowse.txt │ ├── snacks.nvim-health.txt │ ├── snacks.nvim-image.txt │ ├── snacks.nvim-indent.txt │ ├── snacks.nvim-init.txt │ ├── snacks.nvim-input.txt │ ├── snacks.nvim-keymap.txt │ ├── snacks.nvim-layout.txt │ ├── snacks.nvim-lazygit.txt │ ├── snacks.nvim-meta.txt │ ├── snacks.nvim-notifier.txt │ ├── snacks.nvim-notify.txt │ ├── snacks.nvim-picker.txt │ ├── snacks.nvim-profiler.txt │ ├── snacks.nvim-quickfile.txt │ ├── snacks.nvim-rename.txt │ ├── snacks.nvim-scope.txt │ ├── snacks.nvim-scratch.txt │ ├── snacks.nvim-scroll.txt │ ├── snacks.nvim-statuscolumn.txt │ ├── snacks.nvim-styles.txt │ ├── snacks.nvim-terminal.txt │ ├── snacks.nvim-toggle.txt │ ├── snacks.nvim-util.txt │ ├── snacks.nvim-win.txt │ ├── snacks.nvim-words.txt │ ├── snacks.nvim-zen.txt │ └── snacks.nvim.txt ├── docs/ │ ├── animate.md │ ├── bigfile.md │ ├── bufdelete.md │ ├── dashboard.md │ ├── debug.md │ ├── dim.md │ ├── examples/ │ │ ├── dashboard.lua │ │ ├── init.lua │ │ └── picker.lua │ ├── explorer.md │ ├── gh.md │ ├── git.md │ ├── gitbrowse.md │ ├── health.md │ ├── image.md │ ├── indent.md │ ├── init.md │ ├── input.md │ ├── keymap.md │ ├── layout.md │ ├── lazygit.md │ ├── meta.md │ ├── notifier.md │ ├── notify.md │ ├── picker.md │ ├── profiler.md │ ├── quickfile.md │ ├── rename.md │ ├── scope.md │ ├── scratch.md │ ├── scroll.md │ ├── statuscolumn.md │ ├── styles.md │ ├── terminal.md │ ├── toggle.md │ ├── util.md │ ├── win.md │ ├── words.md │ └── zen.md ├── lua/ │ ├── snacks/ │ │ ├── animate/ │ │ │ ├── easing.lua │ │ │ └── init.lua │ │ ├── bigfile.lua │ │ ├── bufdelete.lua │ │ ├── compat.lua │ │ ├── dashboard.lua │ │ ├── debug.lua │ │ ├── dim.lua │ │ ├── explorer/ │ │ │ ├── actions.lua │ │ │ ├── diagnostics.lua │ │ │ ├── git.lua │ │ │ ├── init.lua │ │ │ ├── tree.lua │ │ │ └── watch.lua │ │ ├── gh/ │ │ │ ├── actions.lua │ │ │ ├── api.lua │ │ │ ├── buf.lua │ │ │ ├── init.lua │ │ │ ├── item.lua │ │ │ ├── render/ │ │ │ │ └── init.lua │ │ │ └── types.lua │ │ ├── git.lua │ │ ├── gitbrowse.lua │ │ ├── health.lua │ │ ├── image/ │ │ │ ├── buf.lua │ │ │ ├── convert.lua │ │ │ ├── doc.lua │ │ │ ├── image.lua │ │ │ ├── init.lua │ │ │ ├── inline.lua │ │ │ ├── placement.lua │ │ │ ├── terminal.lua │ │ │ └── util.lua │ │ ├── indent.lua │ │ ├── init.lua │ │ ├── input.lua │ │ ├── keymap.lua │ │ ├── layout.lua │ │ ├── lazygit.lua │ │ ├── meta/ │ │ │ ├── docs.lua │ │ │ ├── init.lua │ │ │ └── types.lua │ │ ├── notifier.lua │ │ ├── notify.lua │ │ ├── picker/ │ │ │ ├── actions.lua │ │ │ ├── config/ │ │ │ │ ├── defaults.lua │ │ │ │ ├── highlights.lua │ │ │ │ ├── init.lua │ │ │ │ ├── layouts.lua │ │ │ │ └── sources.lua │ │ │ ├── core/ │ │ │ │ ├── _health.lua │ │ │ │ ├── actions.lua │ │ │ │ ├── filter.lua │ │ │ │ ├── finder.lua │ │ │ │ ├── frecency.lua │ │ │ │ ├── input.lua │ │ │ │ ├── list.lua │ │ │ │ ├── main.lua │ │ │ │ ├── matcher.lua │ │ │ │ ├── picker.lua │ │ │ │ ├── preview.lua │ │ │ │ └── score.lua │ │ │ ├── format.lua │ │ │ ├── init.lua │ │ │ ├── preview.lua │ │ │ ├── resume.lua │ │ │ ├── select.lua │ │ │ ├── sort.lua │ │ │ ├── source/ │ │ │ │ ├── buffers.lua │ │ │ │ ├── diagnostics.lua │ │ │ │ ├── diff.lua │ │ │ │ ├── explorer.lua │ │ │ │ ├── files.lua │ │ │ │ ├── gh.lua │ │ │ │ ├── git.lua │ │ │ │ ├── grep.lua │ │ │ │ ├── help.lua │ │ │ │ ├── icons.lua │ │ │ │ ├── lazy.lua │ │ │ │ ├── lines.lua │ │ │ │ ├── lsp/ │ │ │ │ │ ├── config.lua │ │ │ │ │ └── init.lua │ │ │ │ ├── meta.lua │ │ │ │ ├── proc.lua │ │ │ │ ├── qf.lua │ │ │ │ ├── recent.lua │ │ │ │ ├── scratch.lua │ │ │ │ ├── snacks.lua │ │ │ │ ├── system.lua │ │ │ │ ├── treesitter.lua │ │ │ │ └── vim.lua │ │ │ ├── transform.lua │ │ │ ├── types.lua │ │ │ └── util/ │ │ │ ├── async.lua │ │ │ ├── db.lua │ │ │ ├── diff.lua │ │ │ ├── highlight.lua │ │ │ ├── history.lua │ │ │ ├── init.lua │ │ │ ├── kv.lua │ │ │ ├── markdown.lua │ │ │ ├── minheap.lua │ │ │ ├── queue.lua │ │ │ └── spinner.lua │ │ ├── profiler/ │ │ │ ├── core.lua │ │ │ ├── init.lua │ │ │ ├── loc.lua │ │ │ ├── picker.lua │ │ │ ├── tracer.lua │ │ │ └── ui.lua │ │ ├── quickfile.lua │ │ ├── rename.lua │ │ ├── scope.lua │ │ ├── scratch.lua │ │ ├── scroll.lua │ │ ├── statuscolumn.lua │ │ ├── terminal.lua │ │ ├── toggle.lua │ │ ├── util/ │ │ │ ├── init.lua │ │ │ ├── job.lua │ │ │ ├── lsp.lua │ │ │ └── spawn.lua │ │ ├── win.lua │ │ ├── words.lua │ │ └── zen.lua │ └── trouble/ │ └── sources/ │ └── profiler.lua ├── plugin/ │ └── snacks.lua ├── queries/ │ ├── css/ │ │ └── images.scm │ ├── html/ │ │ └── images.scm │ ├── javascript/ │ │ └── images.scm │ ├── latex/ │ │ └── images.scm │ ├── lua/ │ │ ├── highlights.scm │ │ └── injections.scm │ ├── markdown/ │ │ ├── images.scm │ │ └── injections.scm │ ├── markdown_inline/ │ │ └── images.scm │ ├── norg/ │ │ └── images.scm │ ├── scss/ │ │ └── images.scm │ ├── svelte/ │ │ └── images.scm │ ├── tsx/ │ │ └── images.scm │ ├── typst/ │ │ └── images.scm │ └── vue/ │ └── images.scm ├── scripts/ │ ├── docs │ ├── docs-post │ └── test ├── selene.toml ├── stylua.toml ├── tests/ │ ├── config_spec.lua │ ├── gitbrowse_spec.lua │ ├── image/ │ │ ├── big.md │ │ ├── math.md │ │ ├── test-mermaid.md │ │ ├── test.aux │ │ ├── test.css │ │ ├── test.html │ │ ├── test.jsx │ │ ├── test.md │ │ ├── test.mmd │ │ ├── test.norg │ │ ├── test.org │ │ ├── test.scss │ │ ├── test.svelte │ │ ├── test.tex │ │ ├── test.tsx │ │ ├── test.typ │ │ ├── test.vue │ │ └── test2.md │ ├── minit.lua │ ├── picker/ │ │ ├── diff_spec.lua │ │ ├── git_status_spec.lua │ │ ├── matcher_spec.lua │ │ ├── minheap_spec.lua │ │ └── util_spec.lua │ ├── scope_spec.lua │ ├── terminal_spec.lua │ └── util_spec.lua └── vim.yml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] insert_final_newline = true indent_style = space indent_size = 2 charset = utf-8 ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: File a bug/issue title: "bug: " labels: [bug] body: - type: markdown attributes: value: | **Before** reporting an issue, make sure to read the [documentation](https://github.com/folke/snacks.nvim) and search [existing issues](https://github.com/folke/snacks.nvim/issues). Usage questions such as ***"How do I...?"*** belong in [Discussions](https://github.com/folke/snacks.nvim/discussions) and will be closed. - type: checkboxes attributes: label: Did you check docs and existing issues? description: Make sure you checked all of the below before submitting an issue options: - label: I have read all the snacks.nvim docs required: true - label: I have updated the plugin to the latest version before submitting this issue required: true - label: I have searched the existing issues of snacks.nvim required: true - label: I have searched the existing issues of plugins related to this issue required: true - type: input attributes: label: "Neovim version (nvim -v)" placeholder: "0.8.0 commit db1b0ee3b30f" validations: required: true - type: input attributes: label: "Operating system/version" placeholder: "MacOS 11.5" validations: required: true - type: textarea attributes: label: Describe the bug description: A clear and concise description of what the bug is. Please include any related errors you see in Neovim. validations: required: true - type: textarea attributes: label: Steps To Reproduce description: Steps to reproduce the behavior. placeholder: | 1. 2. 3. validations: required: true - type: textarea attributes: label: Expected Behavior description: A concise description of what you expected to happen. validations: required: true - type: textarea attributes: label: Repro description: Minimal `init.lua` to reproduce this issue. Save as `repro.lua` and run with `nvim -u repro.lua` value: | vim.env.LAZY_STDPATH = ".repro" load(vim.fn.system("curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua"))() require("lazy.minit").repro({ spec = { { "folke/snacks.nvim", opts = {} }, -- add any other plugins here }, }) render: lua validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Ask a question url: https://github.com/folke/snacks.nvim/discussions about: Use Github discussions instead ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature Request description: Suggest a new feature title: "feature: " labels: [enhancement] body: - type: checkboxes attributes: label: Did you check the docs? description: Make sure you read all the docs before submitting a feature request options: - label: I have read all the snacks.nvim docs required: true - type: textarea validations: required: true attributes: label: Is your feature request related to a problem? Please describe. description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - type: textarea validations: required: true attributes: label: Describe the solution you'd like description: A clear and concise description of what you want to happen. - type: textarea validations: required: true attributes: label: Describe alternatives you've considered description: A clear and concise description of any alternative solutions or features you've considered. - type: textarea validations: required: false attributes: label: Additional context description: Add any other context or screenshots about the feature request here. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Description ## Related Issue(s) ## Screenshots ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" ================================================ FILE: .github/labeler.yml ================================================ # .github/labeler.yml # Label for any files under the `doc/` directory docs-vim: - changed-files: - any-glob-to-any-file: "doc/**" # Label for any files under the `docs/` directory and `README.md` docs: - changed-files: - any-glob-to-any-file: - "docs/**" - "README.md" core: - changed-files: - any-glob-to-any-file: - "lua/snacks/init.lua" - "lua/snacks/health.lua" - "plugins/**" - "queries/**" - "scripts/**" # Dynamic labels for each module under `lua/snacks/` animate: - changed-files: - any-glob-to-any-file: "lua/snacks/animate/**" bigfile: - changed-files: - any-glob-to-any-file: "lua/snacks/bigfile.lua" bufdelete: - changed-files: - any-glob-to-any-file: "lua/snacks/bufdelete.lua" compat: - changed-files: - any-glob-to-any-file: "lua/snacks/compat.lua" dashboard: - changed-files: - any-glob-to-any-file: "lua/snacks/dashboard.lua" debug: - changed-files: - any-glob-to-any-file: "lua/snacks/debug.lua" dim: - changed-files: - any-glob-to-any-file: "lua/snacks/dim.lua" explorer: - changed-files: - any-glob-to-any-file: "lua/snacks/explorer/**" git: - changed-files: - any-glob-to-any-file: "lua/snacks/git.lua" gitbrowse: - changed-files: - any-glob-to-any-file: "lua/snacks/gitbrowse.lua" image: - changed-files: - any-glob-to-any-file: "lua/snacks/image/**" indent: - changed-files: - any-glob-to-any-file: "lua/snacks/indent.lua" init: - changed-files: - any-glob-to-any-file: "lua/snacks/init.lua" input: - changed-files: - any-glob-to-any-file: "lua/snacks/input.lua" layout: - changed-files: - any-glob-to-any-file: "lua/snacks/layout.lua" lazygit: - changed-files: - any-glob-to-any-file: "lua/snacks/lazygit.lua" notifier: - changed-files: - any-glob-to-any-file: "lua/snacks/notifier.lua" notify: - changed-files: - any-glob-to-any-file: "lua/snacks/notify.lua" picker: - changed-files: - any-glob-to-any-file: "lua/snacks/picker/**" profiler: - changed-files: - any-glob-to-any-file: "lua/snacks/profiler/**" quickfile: - changed-files: - any-glob-to-any-file: "lua/snacks/quickfile.lua" rename: - changed-files: - any-glob-to-any-file: "lua/snacks/rename.lua" scope: - changed-files: - any-glob-to-any-file: "lua/snacks/scope.lua" scratch: - changed-files: - any-glob-to-any-file: "lua/snacks/scratch.lua" scroll: - changed-files: - any-glob-to-any-file: "lua/snacks/scroll.lua" statuscolumn: - changed-files: - any-glob-to-any-file: "lua/snacks/statuscolumn.lua" terminal: - changed-files: - any-glob-to-any-file: "lua/snacks/terminal.lua" toggle: - changed-files: - any-glob-to-any-file: "lua/snacks/toggle.lua" win: - changed-files: - any-glob-to-any-file: "lua/snacks/win.lua" words: - changed-files: - any-glob-to-any-file: "lua/snacks/words.lua" zen: - changed-files: - any-glob-to-any-file: "lua/snacks/zen.lua" ================================================ FILE: .github/release-please-config.json ================================================ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", "packages": { ".": { "extra-files": ["lua/snacks/init.lua"], "release-type": "simple" } } } ================================================ FILE: .github/release-please-manifest.json ================================================ { ".": "2.31.0" } ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [main, master] pull_request: jobs: ci: uses: folke/github/.github/workflows/ci.yml@main secrets: inherit with: plugin: snacks.nvim repo: folke/snacks.nvim ================================================ FILE: .github/workflows/labeler.yml ================================================ name: "PR Labeler" on: - pull_request_target jobs: labeler: uses: folke/github/.github/workflows/labeler.yml@main secrets: inherit ================================================ FILE: .github/workflows/pr.yml ================================================ name: PR Title on: pull_request_target: types: - opened - edited - synchronize - reopened - ready_for_review permissions: pull-requests: read jobs: pr-title: uses: folke/github/.github/workflows/pr.yml@main secrets: inherit ================================================ FILE: .github/workflows/stale.yml ================================================ name: Stale Issues & PRs on: schedule: - cron: "30 1 * * *" jobs: stale: if: contains(fromJSON('["folke", "LazyVim"]'), github.repository_owner) uses: folke/github/.github/workflows/stale.yml@main secrets: inherit ================================================ FILE: .github/workflows/update.yml ================================================ name: Update Repo on: workflow_dispatch: schedule: # Run every hour - cron: "0 * * * *" jobs: update: if: contains(fromJSON('["folke", "LazyVim"]'), github.repository_owner) uses: folke/github/.github/workflows/update.yml@main secrets: inherit ================================================ FILE: .gitignore ================================================ *.log /.repro /.tests /build /debug /doc/tags foo.* node_modules tt.* ================================================ FILE: .markdownlint-cli2.yaml ================================================ config: MD013: false MD033: false ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## [2.31.0](https://github.com/folke/snacks.nvim/compare/v2.30.0...v2.31.0) (2026-03-20) ### Features * **gh:** added `Start Review`. Closes [#2463](https://github.com/folke/snacks.nvim/issues/2463) ([ac5f497](https://github.com/folke/snacks.nvim/commit/ac5f49700527ee5fd14473c5a759460c58d2789d)) * **gh:** added cycle win and scrolling to scratch buffers when opened in a picker. Closes [#2480](https://github.com/folke/snacks.nvim/issues/2480) ([021855c](https://github.com/folke/snacks.nvim/commit/021855cf29eae9e41107ba28786e5e68b748e8c3)) * **gh:** make copilot authors as bots ([c6ab189](https://github.com/folke/snacks.nvim/commit/c6ab18964b587edab0e4046719fbc590a59ee042)) * **gh:** make markview.nvim play nicely with snacks. See [#2467](https://github.com/folke/snacks.nvim/issues/2467) ([deeb1e0](https://github.com/folke/snacks.nvim/commit/deeb1e03e22d83a18c04d1230e628d98a490b6ec)) * **gh:** open scratch buffers at the bottom of the window. Closes [#2476](https://github.com/folke/snacks.nvim/issues/2476) ([a6a9678](https://github.com/folke/snacks.nvim/commit/a6a967810e185ecc94304529dc09d57e18ba9b68)) * **gh:** special formatting for code suggestions in review comments. See [#2463](https://github.com/folke/snacks.nvim/issues/2463) ([f1c7f62](https://github.com/folke/snacks.nvim/commit/f1c7f62f9fd6974da3dc8526f454cd364cd73eeb)) * **gh:** when selecting lines in a diff to add a review comment, you can now suggest code changes. See [#2463](https://github.com/folke/snacks.nvim/issues/2463) ([e896fb9](https://github.com/folke/snacks.nvim/commit/e896fb93f66d6f60176d6c014ddec4352ced4c61)) * **lua:** add any treesitter injection to a string with a comment like -- inject:graphql ([1d5b12d](https://github.com/folke/snacks.nvim/commit/1d5b12d0c67071320e5572a1f2ac1265904426b3)) * **picker.actions:** allow specifying an additional window for `cycle_win` ([197f393](https://github.com/folke/snacks.nvim/commit/197f393bbb30684d33165106482aad0a663964c8)) * **picker.lspconfig:** show available dynamic registered code actions ([521ef46](https://github.com/folke/snacks.nvim/commit/521ef46ae9d38f1b31ca8a05b39647fda13a56be)) * **picker.lspconfig:** show available server commands and code actions ([7a90a08](https://github.com/folke/snacks.nvim/commit/7a90a089b781a3fc3c5cd179cdc095a0d244d5fa)) * **win:** `opts.footer_keys` can now be an array of lhs to show instead of all. See [#2469](https://github.com/folke/snacks.nvim/issues/2469) ([6d72138](https://github.com/folke/snacks.nvim/commit/6d721388cc5760db215abd875606a273c1c685f9)) * **win:** better zindex calculation ([08c0951](https://github.com/folke/snacks.nvim/commit/08c09515234b1ecc285d63aa56915caafa1d72d3)) * **win:** new border `top_bottom` ([6134c98](https://github.com/folke/snacks.nvim/commit/6134c98d48657b457a7d4b1e2cd2c7ce37d98ea4)) ### Bug Fixes * **gh.item:** timestamps should be in UTC, not local time ([1ba0bf8](https://github.com/folke/snacks.nvim/commit/1ba0bf8a10b117d08c2a97347bd666f995600d8a)) * **gh.scratch:** if scratch would hide the cursor, then scroll preview up taking scrolloff into account. See [#2480](https://github.com/folke/snacks.nvim/issues/2480) ([a271610](https://github.com/folke/snacks.nvim/commit/a2716102c8bd7d25693201af0942552f10e9a0c3)) * **gh:** better integration with render-markdown. Closes [#2467](https://github.com/folke/snacks.nvim/issues/2467) ([717073d](https://github.com/folke/snacks.nvim/commit/717073df1a515a1564e855cc6ae8986025611e4b)) * **gh:** diff comment action should only show when available ([fe20e95](https://github.com/folke/snacks.nvim/commit/fe20e9578033a1b726983d6f410a5cc8098fb3c2)) * **gh:** render gh comments as markdown when displaying in a non-markdown buffer. Closes [#2481](https://github.com/folke/snacks.nvim/issues/2481) ([06e9ca9](https://github.com/folke/snacks.nvim/commit/06e9ca95f81f528c4314afb80a59ce317f12ac5d)) * **gh:** rendering of markdown comments. Closes [#2488](https://github.com/folke/snacks.nvim/issues/2488) ([dec29f5](https://github.com/folke/snacks.nvim/commit/dec29f55666f8f4545835636077a86b150faf630)) * **gh:** set default scratch `height=15` and fix bottom offset for custom height ([6900f3f](https://github.com/folke/snacks.nvim/commit/6900f3feaa397e8bd671be39411a370188f856c6)) * **grep:** remove `MATCH_SEP` before sending to notify ([#2744](https://github.com/folke/snacks.nvim/issues/2744)) ([9912042](https://github.com/folke/snacks.nvim/commit/9912042fc8bca2209105526ac7534e9a0c2071b2)) * **grep:** remove nul bytes from warning messages in grep output. Fixes [#2744](https://github.com/folke/snacks.nvim/issues/2744), Closes [#2768](https://github.com/folke/snacks.nvim/issues/2768) ([b2cb00e](https://github.com/folke/snacks.nvim/commit/b2cb00ef7d12da7f2d6e0684c43e2965896309dd)) * **grep:** use %z to replace nul bytes ([a049339](https://github.com/folke/snacks.nvim/commit/a049339328e2599ad6e85a69fa034ac501e921b2)) * **input:** fixed completion. Closes [#2472](https://github.com/folke/snacks.nvim/issues/2472) ([3024376](https://github.com/folke/snacks.nvim/commit/30243765808a6ea92da9886b50b4e2e01ff262e3)) * **lspconfig:** handle complex values in lsp config preview. Closes [#2711](https://github.com/folke/snacks.nvim/issues/2711) ([25f53f7](https://github.com/folke/snacks.nvim/commit/25f53f762cc391591285adf0974a8d6e4b4b973e)) * **markdown:** use new markview API ([9b86d57](https://github.com/folke/snacks.nvim/commit/9b86d57cc580e976ee3c89fdf20477873bd5f0c2)) * **picker.actions:** `vim.v.count1` should be `1` in insert mode. Closes [#2492](https://github.com/folke/snacks.nvim/issues/2492) ([d902c0a](https://github.com/folke/snacks.nvim/commit/d902c0a415ffbf66321f40ecb07e73fea283d0ab)) * **picker.confirm:** better layout for confirm ([7f62aa6](https://github.com/folke/snacks.nvim/commit/7f62aa6c6c78a1fcbe207dbb59f6b3105f756e79)) * **picker.diff:** make diff filename extmarks play nicely with markview / markdown-renderer ([4f749ab](https://github.com/folke/snacks.nvim/commit/4f749ab355cd62bbffbb2f6cc4ddcf0fc274fece)) * **picker.git:** fix cwd for git diff. Closes [#2483](https://github.com/folke/snacks.nvim/issues/2483) ([9076793](https://github.com/folke/snacks.nvim/commit/907679381ba5ed36a24b0176930e3ceb97ca4755)) * **picker.lsp:** wait for pending requests. See [#2527](https://github.com/folke/snacks.nvim/issues/2527) ([fe7cfe9](https://github.com/folke/snacks.nvim/commit/fe7cfe9800a182274d0f868a74b7263b8c0c020b)) * **picker.preview:** remove `--no-ext-diff` option for git diff preview ([836e073](https://github.com/folke/snacks.nvim/commit/836e07336ba523d4da480cd66f0241815393e98e)) * **picker.spinner:** when parent win is not float win ([#2487](https://github.com/folke/snacks.nvim/issues/2487)) ([8ca098c](https://github.com/folke/snacks.nvim/commit/8ca098ca360b45a361c475be6ea7dd81e438cc35)) * **picker:** fix nowait for `` ([685c433](https://github.com/folke/snacks.nvim/commit/685c433e61812eb21a890f14dac38a8c573931df)) * **scratch:** set filetype correctly. Closes [#2510](https://github.com/folke/snacks.nvim/issues/2510) ([3c5c23b](https://github.com/folke/snacks.nvim/commit/3c5c23ba91e608bd89bb36d76cb005aa63d20dbf)) * **util.diff:** proper linebreak repeat for annotation boxes ([64179b9](https://github.com/folke/snacks.nvim/commit/64179b96f547bc10211de3360a1e17a237cdb434)) * **win:** allow scrolling beyond eob ([8b5f762](https://github.com/folke/snacks.nvim/commit/8b5f76292becf9ad76ef1507cbdcec64a49ff3f4)) * **win:** use normkey instead of keytrans for footer keys ([9bd41bb](https://github.com/folke/snacks.nvim/commit/9bd41bb2ff5acd68d81e7323a80d18aa9efb7ca9)) * **win:** when a floating win becomes non-floating, remove its backdrop ([c1e1500](https://github.com/folke/snacks.nvim/commit/c1e15001c0da18f740bc8bbe55fc0509f41bd9c6)) ### Performance Improvements * **bigfile:** disable completion to avoid lag when entering insert mode ([#2475](https://github.com/folke/snacks.nvim/issues/2475)) ([c49c3f3](https://github.com/folke/snacks.nvim/commit/c49c3f364cde3bb1bbf79e501d1492ea8e2397bc)) ## [2.30.0](https://github.com/folke/snacks.nvim/compare/v2.29.0...v2.30.0) (2025-11-06) ### Features * **diff:** prettier commit rendering (git show, diff with header) ([dc2186e](https://github.com/folke/snacks.nvim/commit/dc2186e57221cd834487e5c3fbd548180e836d1c)) * **gh:** add inline review comment annotations to diff viewer ([c83ff8d](https://github.com/folke/snacks.nvim/commit/c83ff8d5982e6ebf92623911e232f1dbd0b0a00c)) * **gh:** create review comments in GitHub PR diff, on diff lines. Closes [#2446](https://github.com/folke/snacks.nvim/issues/2446) ([85bf3f0](https://github.com/folke/snacks.nvim/commit/85bf3f0123cd6aaa1e06569283ffca10b59fcc3b)) * **layout:** allow resizing split layouts. See [#2390](https://github.com/folke/snacks.nvim/issues/2390) ([913379c](https://github.com/folke/snacks.nvim/commit/913379ccd2679fc11462479205897e584496c855)) * **picker.gh_diff:** you can now reply to review comments in diffs with `a`. See [#2446](https://github.com/folke/snacks.nvim/issues/2446) ([c3bda87](https://github.com/folke/snacks.nvim/commit/c3bda8709ab9f300e9dd15c2e0ec47f17caff1d5)) * **picker.icons:** make it easier to add custom icon sources ([82e6966](https://github.com/folke/snacks.nvim/commit/82e69661cd0766893184dcc3ec3684b492772854)) * **picker.marks:** added `` to delete a mark from the list. See [#2390](https://github.com/folke/snacks.nvim/issues/2390) ([9a04605](https://github.com/folke/snacks.nvim/commit/9a04605664be0613963910d545d4164eb2f57bab)) * **picker:** when picker was started from insert mode, return to insert after paste ([a417630](https://github.com/folke/snacks.nvim/commit/a4176301e323d9689674764da62f628742dc744a)) * **util.async:** add proper backtrace to unhandled async errors ([01f6cac](https://github.com/folke/snacks.nvim/commit/01f6cac48fd7a3ec9bf7e5fc8a5ae22381861baf)) ### Bug Fixes * **gh:** force `fancy` diff style for gh pr diff, since that's needed to render/interact with review comments ([bd71cd4](https://github.com/folke/snacks.nvim/commit/bd71cd4b007fd414f2bd3e4ce7d292fde4b8849f)) * **gh:** only skip empty comment reviews. Closes [#2445](https://github.com/folke/snacks.nvim/issues/2445) ([1848d74](https://github.com/folke/snacks.nvim/commit/1848d74e7c01a0c4686b6e8353158507a73dfe10)) * **gh:** pass correct context in gh_perform_action. Closes [#2442](https://github.com/folke/snacks.nvim/issues/2442). Closes [#2443](https://github.com/folke/snacks.nvim/issues/2443) ([13edbc6](https://github.com/folke/snacks.nvim/commit/13edbc681c727adfff72c49d07fedaec566404f8)) * **gh:** properly handly pending requests ([7a15e16](https://github.com/folke/snacks.nvim/commit/7a15e16d0165fa3b22486066795eeca788ec0c8d)) * **gh:** use lua to parse dates so we can do this in a fast context ([cd0d6fe](https://github.com/folke/snacks.nvim/commit/cd0d6fe86465394af8ba5037bc87d7ebfecc10fb)) * **image:** run terminal capability detection synchronous when needed. Closes [#2439](https://github.com/folke/snacks.nvim/issues/2439) ([58f1152](https://github.com/folke/snacks.nvim/commit/58f11527fe8b63ccabb1d75bb8f14f5ebbdc6a14)) * **layout:** ignore very zindex windows for calulating layout zindex, so that it stays below things like notifications ([47340e6](https://github.com/folke/snacks.nvim/commit/47340e6b0b24773ef4789aa677394a2499762841)) * **lsp:** properly detach buffers on LspDetach. Closes [#2457](https://github.com/folke/snacks.nvim/issues/2457) ([beb995e](https://github.com/folke/snacks.nvim/commit/beb995e1c6a554d53f6d23fc3aadea568dab2534)) * **picker.actions:** don't open a new tab if the current tab is empty. Closes [#2461](https://github.com/folke/snacks.nvim/issues/2461) ([4e2424e](https://github.com/folke/snacks.nvim/commit/4e2424eca7ffaa6e36ebf4ac4d136d05b79edc41)) * **picker.diff:** added `showbreak=""` for fancy diff. Closes [#2441](https://github.com/folke/snacks.nvim/issues/2441) ([fb55f7b](https://github.com/folke/snacks.nvim/commit/fb55f7bf2b6521eb6d7adf6a6b4b437a80882cd0)) * **picker.format:** use file for icon. fallback to ft when buffer is not a file. See [#2390](https://github.com/folke/snacks.nvim/issues/2390) ([20ac8bf](https://github.com/folke/snacks.nvim/commit/20ac8bfc4ac615a46f64b7806ff367b114bc942f)) * **picker.grep_word:** pass `--word-regexp` to `ripgrep` ([6aad368](https://github.com/folke/snacks.nvim/commit/6aad36810a8b49041b8a7d3ef6b9050549be0617)) * **picker.highlight:** resolve ([4438ee4](https://github.com/folke/snacks.nvim/commit/4438ee4770edad9fb843d841b9fdf5ef04d9f479)) * **picker.input:** startinsert when starting the picker from terminal mode. See [#2390](https://github.com/folke/snacks.nvim/issues/2390) ([b2054a3](https://github.com/folke/snacks.nvim/commit/b2054a3a734631f33b3b08af88b171930666bffe)) * **picker.lsp_config:** nil on lsp info ([#2459](https://github.com/folke/snacks.nvim/issues/2459)) ([c5257fa](https://github.com/folke/snacks.nvim/commit/c5257fa690fe7e7de7d0da29a6f46f2236b355f0)) * **scope:** textobjects should use synchronous treesitter parsing. Closes [#2448](https://github.com/folke/snacks.nvim/issues/2448) ([9737c25](https://github.com/folke/snacks.nvim/commit/9737c25f2937ab4cd4751b7d041268949d9e0d97)) * **toggle:** set/get raw values for option toggles. See [#2390](https://github.com/folke/snacks.nvim/issues/2390) ([41da728](https://github.com/folke/snacks.nvim/commit/41da728f0280033c13aaa4e2820bd3e926790a28)) * **win:** set `foldcolumn='0'` for minimal style. See [#2390](https://github.com/folke/snacks.nvim/issues/2390) ([195faa0](https://github.com/folke/snacks.nvim/commit/195faa0646df9e9c765c3b86c58407021decdb7c)) ## [2.29.0](https://github.com/folke/snacks.nvim/compare/v2.28.0...v2.29.0) (2025-11-04) ### Features * **gh.diff:** show git status in PR diff ([c671d06](https://github.com/folke/snacks.nvim/commit/c671d062d163a31894453dbca15087ea9149ac38)) * **gh:** added reviews and nice diffs to gh buffer views. See [#2411](https://github.com/folke/snacks.nvim/issues/2411) ([1335ca1](https://github.com/folke/snacks.nvim/commit/1335ca1956fa81ddb3e249721e8f422c4cd0c329)) * **gh:** allow to update pr branch ([#2419](https://github.com/folke/snacks.nvim/issues/2419)) ([f75f307](https://github.com/folke/snacks.nvim/commit/f75f307af3230c9872939aabd2fb484d8ad3cb5f)) * **gh:** use new diff renderer for gh pr reviews ([714edec](https://github.com/folke/snacks.nvim/commit/714edec900334130a274ef1a21dd2b6edb7997fe)) * **gh:** when on a review comment, the `gh_comment` action will now do a reply instead of a top-level comment. Fixes [#2410](https://github.com/folke/snacks.nvim/issues/2410) ([a4f2b9d](https://github.com/folke/snacks.nvim/commit/a4f2b9da2d0dd73e127459fb372e0eb695d70cb2)) * **gh:** you can now use `Snacks.picker.gh_actions()` directly to see actions for the checked out PR ([d0d10f6](https://github.com/folke/snacks.nvim/commit/d0d10f6d13a4285a83ccf225d2a8938152efcef8)) * **picker.diff:** new fancy diff renderer ([22eea90](https://github.com/folke/snacks.nvim/commit/22eea90a9548e692c80a20740934720e6d095be1)) * **picker.git_diff:** show proper git status for git diff files ([ab48eeb](https://github.com/folke/snacks.nvim/commit/ab48eebeb37cc149907d13c904008712a858212b)) * **picker.git_diff:** show renames ([77609a0](https://github.com/folke/snacks.nvim/commit/77609a00133cf56b69a7fee9b677a1f0c877e37b)) * **picker.lsp_config:** added server/dynamic capabilities to preview ([da14fac](https://github.com/folke/snacks.nvim/commit/da14fac1e54dc0022b9ba724a50ae93e43f5f271)) * **picker:** consolidate all diff options under `opts.previewers.diff`. Default style is `fancy` ([b65b06c](https://github.com/folke/snacks.nvim/commit/b65b06ca0ec7ea4730a7a06e71edbc9c1aa32980)) * **zen:** added `center` option that defaults to `true` for zen mode and `false` for zoom mode. Closes [#2422](https://github.com/folke/snacks.nvim/issues/2422) ([3c2d791](https://github.com/folke/snacks.nvim/commit/3c2d79162f8174d5e1c33539a72025a25f4af590)) ### Bug Fixes * **dashboard:** start job after the terminal window is shown to make sure it has the correct size. Closes [#2421](https://github.com/folke/snacks.nvim/issues/2421) ([e440df3](https://github.com/folke/snacks.nvim/commit/e440df387d448a2ec332442a0eca6ece685f2b4d)) * **diff:** fallback if `Normal` has no fg color. Closes [#2436](https://github.com/folke/snacks.nvim/issues/2436) ([7f453c4](https://github.com/folke/snacks.nvim/commit/7f453c4f322ea2664655cc78c70ff4a9b6238c75)) * **diff:** improved diff parsing. Closes [#2424](https://github.com/folke/snacks.nvim/issues/2424). Closes [#2420](https://github.com/folke/snacks.nvim/issues/2420) ([b6e4eb7](https://github.com/folke/snacks.nvim/commit/b6e4eb7e608924a0d116bb01dabb575365bcfd75)) * **diff:** remove diff injections. Closes [#2406](https://github.com/folke/snacks.nvim/issues/2406) ([ecc21bb](https://github.com/folke/snacks.nvim/commit/ecc21bbb9b6969b039676ad7f5d34df5974b1580)) * **gh.api:** get repo from upstream remote if availble. fallback to origin ([5043637](https://github.com/folke/snacks.nvim/commit/50436373c277906cf40e47380f3dc1bd7769a885)) * **gh.api:** pass repo to cmd. Closes [#2415](https://github.com/folke/snacks.nvim/issues/2415) ([78046eb](https://github.com/folke/snacks.nvim/commit/78046eb4817e30e216d2456c84005d5264aad67f)) * **gh.diff:** fixed rendering of diff header when wrap=true ([07c569d](https://github.com/folke/snacks.nvim/commit/07c569dfd5f869dbe23d32d7ce1a7547a6abe69a)) * **gh.item:** better method to extract repo from gh url. Closes [#2418](https://github.com/folke/snacks.nvim/issues/2418) ([52d544c](https://github.com/folke/snacks.nvim/commit/52d544cc64da4e9cae4f3df5adf7a777b2ed0217)) * **gh.render:** added support for older `StatusContext` checks. Closes [#2407](https://github.com/folke/snacks.nvim/issues/2407) ([74864a7](https://github.com/folke/snacks.nvim/commit/74864a7bb8390684a5132e4025a77a1de92865e8)) * **gh.render:** use check name. See [#2407](https://github.com/folke/snacks.nvim/issues/2407) ([6f60105](https://github.com/folke/snacks.nvim/commit/6f60105302fcae45524a5b6232beb52829e93e3f)) * **gh:** better way of determining current PR ([bd3c1a0](https://github.com/folke/snacks.nvim/commit/bd3c1a071483c943ce98723ef7ac79fda0c7ee16)) * **gh:** input for api should be a table, not a string. Closes [#2427](https://github.com/folke/snacks.nvim/issues/2427) ([1b3e409](https://github.com/folke/snacks.nvim/commit/1b3e4090a01331ef632ce904d364daf2b811bf54)) * **image:** allow to fully disable all image rendering with `opts.image.enabled = false`. Closes [#2404](https://github.com/folke/snacks.nvim/issues/2404) ([34a6591](https://github.com/folke/snacks.nvim/commit/34a6591a616836d727140afcd9896981c64281e0)) * **image:** disable image conversion error notifications by default ([cfcf525](https://github.com/folke/snacks.nvim/commit/cfcf52520765fb18113d89b970bd26a6aa6f543b)) * **lsp:** check lsp handlers after LspAttach, since attached_buffers won't have been set ([1861b0a](https://github.com/folke/snacks.nvim/commit/1861b0a8eaee849fb8ed67a6764ef5021196bb58)) * **picker.actions:** only allow stage/unstage/restore for some diffs ([9cde35b](https://github.com/folke/snacks.nvim/commit/9cde35b7b16244fee5c6f73749523e95e4a2b432)) * **picker.diff:** move git status calc based on diff to format ([b553c18](https://github.com/folke/snacks.nvim/commit/b553c18c263b156f12bfc2a80124cf8edfa04dd3)) * **picker.diff:** parse full diff including diff and hunk headers. Closes [#2429](https://github.com/folke/snacks.nvim/issues/2429) ([53d8012](https://github.com/folke/snacks.nvim/commit/53d8012e5e4b2115ade2c15d040223ef97ffb05c)) * **picker.git_diff:** don't show git status, disable stage/unstage/restore when merge-base is set. Closes [#2397](https://github.com/folke/snacks.nvim/issues/2397) ([6ff82ca](https://github.com/folke/snacks.nvim/commit/6ff82cab7bd413bfe30cf1b1856729a59392c405)) * **picker.highlight:** resolve all text chunks when needed. Not just the first. ([962aadd](https://github.com/folke/snacks.nvim/commit/962aadd3103496c7d2a02cc358a13f773f03a059)) * **picker.undo:** cleanup tmp files in `async:on("done")`. Closes [#2434](https://github.com/folke/snacks.nvim/issues/2434) ([3038dac](https://github.com/folke/snacks.nvim/commit/3038dac46009a778881a7ca98287a6c1cba1d160)) * **picker.undo:** set initial target to the current undo entry. See [#2434](https://github.com/folke/snacks.nvim/issues/2434) ([dc245ef](https://github.com/folke/snacks.nvim/commit/dc245ef04e19b34510fa1edbe52abd61d28fea37)) * **picker:** don't focus a picker window when toggling a window and picker wasn't current. closes [#2417](https://github.com/folke/snacks.nvim/issues/2417) ([b80b330](https://github.com/folke/snacks.nvim/commit/b80b330091b488e8f41f9e5228d9c9acbe333f7b)) * revert rename of actions.lua to tomdar87@outlook.com ([#2423](https://github.com/folke/snacks.nvim/issues/2423)) ([8bb3ad6](https://github.com/folke/snacks.nvim/commit/8bb3ad6c53232b4c7f0cf430ef3df1bef69fd6ee)) * **win:** fixed fixbuf. Closes [#2409](https://github.com/folke/snacks.nvim/issues/2409) ([2099572](https://github.com/folke/snacks.nvim/commit/2099572fe8b7296ecda13e20b553e9cd873cf165)) ## [2.28.0](https://github.com/folke/snacks.nvim/compare/v2.27.0...v2.28.0) (2025-11-01) ### Features * **gh:** new `gh` (GitHub cli) integration ([85b8ec2](https://github.com/folke/snacks.nvim/commit/85b8ec210975aa137af4b7bef1fb7b7098be331a)) * **image:** when opts.conceal, conceal remainig lines that are not covered by the image. See [#2391](https://github.com/folke/snacks.nvim/issues/2391) ([404027c](https://github.com/folke/snacks.nvim/commit/404027c973c0a54a369931d36263996a34162ab5)) * **picker.buffers:** add filetype/buftype to search text ([a249c86](https://github.com/folke/snacks.nvim/commit/a249c86cf1ed3b8434bc004af3a865997706c22f)) * **picker.buffers:** added buftype and filetype for scratch buffers ([6a13271](https://github.com/folke/snacks.nvim/commit/6a132716af145a109800a129300e43104789b5c0)) * **picker.diff:** moved git_diff finder to separate file so it can be re-used + made it more robust. Closes [#2366](https://github.com/folke/snacks.nvim/issues/2366) ([3049ad8](https://github.com/folke/snacks.nvim/commit/3049ad8beba417924b2d4ff8f77edec53d3472f6)) * **picker.diff:** native diff now also highlights the language of the diffed code in the diff ([7260957](https://github.com/folke/snacks.nvim/commit/726095723d0ceae2ffeedf722620238491aeaa30)) * **picker.git_diff:** `git_diff` now also shows staged hunks and added stage/unstage/restore actions for hunks. Closes [#2382](https://github.com/folke/snacks.nvim/issues/2382) ([1fb3f4d](https://github.com/folke/snacks.nvim/commit/1fb3f4de49962a80cb88a9b143bc165042c72165)) * **picker.git_diff:** added `staged` flag ([118648c](https://github.com/folke/snacks.nvim/commit/118648ce93b9fc3a4493783fe3efce60fcdb59a3)) * **picker.highlights:** badges ([202e595](https://github.com/folke/snacks.nvim/commit/202e595e553b8c5865c080cc375381e6b096804c)) * **picker.preview:** allow items to define a title used in the preview window ([4b572f4](https://github.com/folke/snacks.nvim/commit/4b572f4785df68b60234996f03b91d4581a8ad47)) * **picker.preview:** support for images and render markdown ([9585da6](https://github.com/folke/snacks.nvim/commit/9585da6c57ed4e06c52a84d680d6b700cab42d6c)) * **picker.util:** cmdline parser used to properly parse diff args ([5025989](https://github.com/folke/snacks.nvim/commit/502598953fa70cd4507ba39f3e9b4babd7e4df9d)) * **picker:** better integration with markview and render-markdown when previewing ([4708e9a](https://github.com/folke/snacks.nvim/commit/4708e9a38657e71b2743a35a4530d0118c21d4fe)) * **scratch:** store scratch info in meta files, instead of the filename + custom filekeys ([85f8e22](https://github.com/folke/snacks.nvim/commit/85f8e22281bee237d5e29746019ec21b1624925c)) * **util.spawn:** `Proc:json()` ([5589c9d](https://github.com/folke/snacks.nvim/commit/5589c9d355606a026001fe589bc0329077951f45)) * **util:** `Snacks.util.stop()` to safely stop/close a luv handle ([ce9e299](https://github.com/folke/snacks.nvim/commit/ce9e2993dd4d8289cfdbc129efed74a3394841b9)) ### Bug Fixes * **explorer.tree:** only strip trasiling forward slashes if not at root. Closes [#2375](https://github.com/folke/snacks.nvim/issues/2375) ([72dc621](https://github.com/folke/snacks.nvim/commit/72dc6213f758da8484094cb975bfdfc2f8f61621)) * **explorer:** differentiate if file or folder when deleting on Windows ([#2373](https://github.com/folke/snacks.nvim/issues/2373)) ([59c5545](https://github.com/folke/snacks.nvim/commit/59c5545e91878c1f6218b032a881832bc98a46f3)) * **explorer:** do reveal in on_show if explorer is not open yet. Closes [#2388](https://github.com/folke/snacks.nvim/issues/2388) ([ba529d4](https://github.com/folke/snacks.nvim/commit/ba529d4f5d409639e082aff916c9b8e71b480531)) * **explorer:** schedule `on_find` (typically reveal), for both files finder and when git status updates the finder. Closes [#2388](https://github.com/folke/snacks.nvim/issues/2388) ([a9b57b2](https://github.com/folke/snacks.nvim/commit/a9b57b2a7ee0642f5c5c2c3f39d7e57fadafa1af)) * **gh:** add action idx to `gh_actions` text ([d94184d](https://github.com/folke/snacks.nvim/commit/d94184d1d91a9b8794931538da8f9c76871b3265)) * **image.inline:** off-by-one for finding visible images at the last line of the buffer ([04b3a54](https://github.com/folke/snacks.nvim/commit/04b3a54576757215a02d306e47b7fb9a542c37b2)) * **image:** avoid nested math environments ([#2345](https://github.com/folke/snacks.nvim/issues/2345)) ([66e3dc4](https://github.com/folke/snacks.nvim/commit/66e3dc46190992048b571e1225b5a5c2712d2ec6)) * **image:** check for invalid buffer ([9ad4178](https://github.com/folke/snacks.nvim/commit/9ad41782eced6a06034e568357cdad35cbf52ffa)) * **image:** check to update on BufWinEnter and attach to buffer changes ([e18e4f6](https://github.com/folke/snacks.nvim/commit/e18e4f6452c62035289d28156dbd0966af13a046)) * **image:** don't add placements to concealed lines. Closes [#2391](https://github.com/folke/snacks.nvim/issues/2391) ([13963b1](https://github.com/folke/snacks.nvim/commit/13963b1ec41c21aa81698ed042bb48124325f61d)) * **image:** guard against invalid buffers. Closes [#2383](https://github.com/folke/snacks.nvim/issues/2383) ([4bb1ce1](https://github.com/folke/snacks.nvim/commit/4bb1ce16ed2882978e9524ad057cfa892a226887)) * keymap docs ([583a0c1](https://github.com/folke/snacks.nvim/commit/583a0c1c06865b4cf64e1104c5250516f5cc6d31)) * **keymap:** make sure opts are a table. Closes [#2392](https://github.com/folke/snacks.nvim/issues/2392) ([367d1bd](https://github.com/folke/snacks.nvim/commit/367d1bd385fd43fe40defd0a901c8597e5aee1ec)) * **layout:** only max zindex for snacks windows/layouts ([8eddc0b](https://github.com/folke/snacks.nvim/commit/8eddc0b3809b5af68bb0fc14dcf6c7a1854133cf)) * **picker.actions:** `drop` and `tabdrop` should never reload existing buffers ([#2368](https://github.com/folke/snacks.nvim/issues/2368)) ([6cf2fee](https://github.com/folke/snacks.nvim/commit/6cf2fee619e81e519ad900542b38ed3491dc45de)) * **picker.actions:** use `buffer!` instead of `buffer` for edit. Closes [#2378](https://github.com/folke/snacks.nvim/issues/2378) ([2a1a001](https://github.com/folke/snacks.nvim/commit/2a1a001d3a3ed66e39da353efbe37b4ab0a7db93)) * **picker.diff:** better filename parsing. See [#2366](https://github.com/folke/snacks.nvim/issues/2366) ([377f3bf](https://github.com/folke/snacks.nvim/commit/377f3bfeca716ada246720a7974c49aee56fd382)) * **picker.diff:** first line of header ([fb011c2](https://github.com/folke/snacks.nvim/commit/fb011c257f29cb7a5f4098f7a7e79ac76870761d)) * **picker.diff:** only process `---` diffs directly if it doesn't start with a diff header ([0a33aec](https://github.com/folke/snacks.nvim/commit/0a33aec0c62425031efc7867be5c466b83aa82cf)) * **picker.filter:** get cwd from active tabpage if available ([c1b517f](https://github.com/folke/snacks.nvim/commit/c1b517f545fffcf401217bd41202833ee6465f31)) * **picker.finder:** mutate existing opts ([c91e230](https://github.com/folke/snacks.nvim/commit/c91e23060c73432cb25f99d6ed632c22fce87d88)) * **picker.finder:** tmp fix for [#2386](https://github.com/folke/snacks.nvim/issues/2386) ([5eea5f9](https://github.com/folke/snacks.nvim/commit/5eea5f94280ef9034c7da8bbb5ec12dc71b6916f)) * **picker.git_branches:** git log preview. Closes [#2360](https://github.com/folke/snacks.nvim/issues/2360) ([597ebd4](https://github.com/folke/snacks.nvim/commit/597ebd411529a15b23cba1bc45b20db8f6adbbae)) * **picker.git_diff:** remove `--default-prefix`, since that's no longer needed. See [#2382](https://github.com/folke/snacks.nvim/issues/2382) ([40774ae](https://github.com/folke/snacks.nvim/commit/40774ae6cabd4b2b76705295338c2a6a71976b98)) * **picker.git_diff:** set `group=false` by default, since we also have `git_status` ([530e591](https://github.com/folke/snacks.nvim/commit/530e5913453d2501f79caf4d909c1932334bc9f6)) * **picker.highlights:** modifiable for set_lines ([98345fb](https://github.com/folke/snacks.nvim/commit/98345fb66753283ee4a091bb444df537e4012233)) * **picker.keymaps:** try to locate neovim compiled lua source files for keymaps ([76160be](https://github.com/folke/snacks.nvim/commit/76160be5d38cd67e46557cb5d0b3e36ececdfa3c)) * **picker.lsp:** fixed `vim.str_byteindex` capability check. Closes [#2389](https://github.com/folke/snacks.nvim/issues/2389) ([46917d0](https://github.com/folke/snacks.nvim/commit/46917d0629595281e7d2de3834af8fd95584befd)) * **picker.lsp:** some LSP servers notify completion before sending the actual result. See [#2372](https://github.com/folke/snacks.nvim/issues/2372) ([aa8a318](https://github.com/folke/snacks.nvim/commit/aa8a318779ca6b5f0bba2cf383ddf596db199c09)) * **picker.lsp:** use `LspRequest` to track completed and cancelled requests. Fixes [#2364](https://github.com/folke/snacks.nvim/issues/2364) ([8afb609](https://github.com/folke/snacks.nvim/commit/8afb609333026e1a3a27d57ada1cf849b2adbcc9)) * **picker.preview:** again. docgen seems broken ([758bbfa](https://github.com/folke/snacks.nvim/commit/758bbfa13a3c26d80069a9f621fcfd0f0dfc608e)) * **picker.preview:** don't show locations for diff preview ([b064488](https://github.com/folke/snacks.nvim/commit/b0644884ef3aa589df609c95565220da7eef5cce)) * **picker.preview:** fckup ([fd7795e](https://github.com/folke/snacks.nvim/commit/fd7795e9cd615d5262862c819b5058b42869406b)) * **picker.preview:** fix ([e2c1c52](https://github.com/folke/snacks.nvim/commit/e2c1c527e40aecd6d1ac011aef6d3c28a208a9ec)) * **picker.preview:** show proper preview message for deleted scratch buffers ([4ad8a41](https://github.com/folke/snacks.nvim/commit/4ad8a41eac2fb636e12a11e0129d6d2d10ffb60a)) * **picker.util:** better relative time format ([3e30fb6](https://github.com/folke/snacks.nvim/commit/3e30fb6c705c94cf29567cc6446bd9f9284c8c4d)) * **picker.util:** ignore errors from corrupted kv stores. Closes [#2394](https://github.com/folke/snacks.nvim/issues/2394) ([b3d01c5](https://github.com/folke/snacks.nvim/commit/b3d01c59ba4ab4183b12e9e10bc1fcbbef1b02be)) * **picker.watch:** check again for closed picker after schedule. See [#2365](https://github.com/folke/snacks.nvim/issues/2365) ([8ad80de](https://github.com/folke/snacks.nvim/commit/8ad80de67b68db773cd50599f93dfada85e00eae)) * **picker:** close picker when layout closes. Closes [#2365](https://github.com/folke/snacks.nvim/issues/2365) ([779746f](https://github.com/folke/snacks.nvim/commit/779746f9a82e3a925393557ead441a3d22606534)) * **picker:** dont watch files for closed pickers. Fixes [#2365](https://github.com/folke/snacks.nvim/issues/2365) ([c4ec8b6](https://github.com/folke/snacks.nvim/commit/c4ec8b6d12a9b671e65709ec64b9beb6969815c5)) * **picker:** increase default show_delay to 5s. Closes [#2364](https://github.com/folke/snacks.nvim/issues/2364) ([b3197e3](https://github.com/folke/snacks.nvim/commit/b3197e3a2a2cec8e090a74308b630a1f451a35f0)) * **picker:** only trim space in the title if it's preceded by a word like character (skips icons) ([2439c49](https://github.com/folke/snacks.nvim/commit/2439c493a5dbd9fd880bf70c7986ebafd6f0c9f6)) * **picker:** pause input progress info for 60ms to prevent flickering when finder is too fast ([ecde81f](https://github.com/folke/snacks.nvim/commit/ecde81fc0ce7c4834def0ce710dd5dc62b0822fc)) * **scratch:** make sure zindex of scratch window is higher than existing floating windows ([c8422da](https://github.com/folke/snacks.nvim/commit/c8422da50dee7d725e1a66a5dc6a930e6ac57625)) * **scroll:** only reset count when needed ([551d79f](https://github.com/folke/snacks.nvim/commit/551d79f1c0bd5400bcf00d2133832c20b1fb29f2)) * **util.job:** scroll to top when process exits ([b544157](https://github.com/folke/snacks.nvim/commit/b5441575e07af9f179cbeb8ea6d0b9951b28481a)) * **util.job:** stop on BufWipeout and BufDelete ([c956b37](https://github.com/folke/snacks.nvim/commit/c956b372467467dafb32713a95d3dbc22ae5c3bc)) * **util.job:** stop when attached buffer is no longer valid ([221d4b1](https://github.com/folke/snacks.nvim/commit/221d4b17475c36fd92b2e26baac5515f8260ef88)) * **util.job:** use nvim_win_set_cursor instead of `gg` ([5faed2f](https://github.com/folke/snacks.nvim/commit/5faed2f7abed7fb97aed0425b2b1b03fb6048fa9)) * **util.lsp:** `Snacks.util.lsp.on()` should trigger for each lsp client per buffer ([52f30a1](https://github.com/folke/snacks.nvim/commit/52f30a198a19bf5da6aa95cc642bfbb99b9bbfbf)) * **util:** color() should not create hl groups ([17033e6](https://github.com/folke/snacks.nvim/commit/17033e67ef1c42a2295e2921c201f1b404d625d8)) * **win:** ignore errors on destroy. Closes [#2381](https://github.com/folke/snacks.nvim/issues/2381) ([a8930bd](https://github.com/folke/snacks.nvim/commit/a8930bdb619024e8ba3e1fc7efc1bd8ea9a27a5a)) * **win:** scratch buffers were sometimes not deleted ([0387297](https://github.com/folke/snacks.nvim/commit/03872973b3326ab2caff2b10d983b4cb775944f0)) * **win:** when fixbuf triggers in a floating window, just close it. Closes [#2380](https://github.com/folke/snacks.nvim/issues/2380) ([de35242](https://github.com/folke/snacks.nvim/commit/de352425f7acd4dd1ed3ee06f9479129017da087)) ### Performance Improvements * **animate:** smoother animations ([b7a3fed](https://github.com/folke/snacks.nvim/commit/b7a3fed8d9822f448122af441018f66febbc50f4)) * **notifier:** stop trying to fit more notifs in the layout after skipping max 10 ([3a8ecf5](https://github.com/folke/snacks.nvim/commit/3a8ecf591263e4706d9b3a45da590df914ea5505)) * **picker.util:** cache badge hl groups ([cb85844](https://github.com/folke/snacks.nvim/commit/cb85844e8404a95c3ac0d509ec7cedd0f3d5375c)) * **scroll:** combine all scrolling commands in one command + restore vim.v.count ([0fbea13](https://github.com/folke/snacks.nvim/commit/0fbea13c9d5ba2887ad8c1ffb20d77e11174b390)) * **scroll:** smoother scrolling using new animations ([2221fe6](https://github.com/folke/snacks.nvim/commit/2221fe616657b9ed82bdea8566813939a7b25918)) * **statuscolumn:** only calculate components that are actually needed ([bb80317](https://github.com/folke/snacks.nvim/commit/bb803176478dc603c1a2d09ca717964c6a27bfae)) ### Reverts * jump `buffer` -> `buffer!`. See [#2378](https://github.com/folke/snacks.nvim/issues/2378) ([143e9b5](https://github.com/folke/snacks.nvim/commit/143e9b58c7b8301bdc36b1b8a03449078beb49d1)) ## [2.27.0](https://github.com/folke/snacks.nvim/compare/v2.26.0...v2.27.0) (2025-10-26) ### Features * **keymap:** added new `enabled` option ([b0f21fa](https://github.com/folke/snacks.nvim/commit/b0f21fa745953ac6bb096a4811cb32e42d7ca714)) * **picker.proc:** finder to process json ([5294c4f](https://github.com/folke/snacks.nvim/commit/5294c4f39ed9bdc0f2c483885d9a1834a4df4d21)) * **util.job:** simple wrapper around jobstart to work with terminals (used in dashboards and pickers) ([de05631](https://github.com/folke/snacks.nvim/commit/de05631e6a656a88d1eebf078c44e5e4b9747742)) * **util.lsp:** added overload for `Snacks.util.lsp.on(cb)` ([f33aa20](https://github.com/folke/snacks.nvim/commit/f33aa2017a2671fb4a0e71316f385c8010c8b81b)) ### Bug Fixes * **dashboard:** don't add sleep in nushell. Closes [#1706](https://github.com/folke/snacks.nvim/issues/1706) ([44f71d2](https://github.com/folke/snacks.nvim/commit/44f71d2113866c0a6f16a8fa70af8933c1d87919)) * **explorer:** refresh git status on all tabs when needed. Closes [#2348](https://github.com/folke/snacks.nvim/issues/2348) ([1472211](https://github.com/folke/snacks.nvim/commit/1472211f9ccd171f69ec7f33764620dd935b5ccf)) * **explorer:** windows path fixes ([e1dc6b3](https://github.com/folke/snacks.nvim/commit/e1dc6b3bddd0d16d0faa5d6802a975f7a7165b2a)) ## [2.26.0](https://github.com/folke/snacks.nvim/compare/v2.25.0...v2.26.0) (2025-10-25) ### Features * **explorer:** add cross-platform trash support ([ed08ef1](https://github.com/folke/snacks.nvim/commit/ed08ef1a630508ebab098aa6e8814b89084f8c03)) * **keymap:** add filetype and LSP-aware keymap management ([0bf34af](https://github.com/folke/snacks.nvim/commit/0bf34afe34ee297430f23d2aba0b104c5379dc15)) * **util:** add LSP utility module with dynamic capability handlers ([7a63ba5](https://github.com/folke/snacks.nvim/commit/7a63ba5d374acaa7317833b6e03d2603e90e0983)) * **win:** add `SnacksWinSeparator` to default `win.wo.winhighlight` ([#2340](https://github.com/folke/snacks.nvim/issues/2340)) ([869709d](https://github.com/folke/snacks.nvim/commit/869709dd658b53ea5706f086f93a50e89a429a5d)), closes [#2336](https://github.com/folke/snacks.nvim/issues/2336) * **win:** add default *Snacks* prefixed `WinSeparator` ([#2338](https://github.com/folke/snacks.nvim/issues/2338)) ([381265b](https://github.com/folke/snacks.nvim/commit/381265b5430c991f4343a2f4530bc2de37abac18)), closes [#2336](https://github.com/folke/snacks.nvim/issues/2336) ### Bug Fixes * **dahboard:** do full terminal reset when receiving first output and displayed cached contents ([c952834](https://github.com/folke/snacks.nvim/commit/c9528341a6ef9dc9cb404b1c901b1276af331ccf)) * **dashboard:** don't write to closed terminal buffer ([f75eaf1](https://github.com/folke/snacks.nvim/commit/f75eaf1e18cea03605e626eca2a1b9c4345071d4)) * **dashboard:** work-around for jobstart+pty issue where not all output is processed before exit. Closes [#1706](https://github.com/folke/snacks.nvim/issues/1706) ([4d776bd](https://github.com/folke/snacks.nvim/commit/4d776bdd1d6d7998f2c7c7f08c2e964419eb74be)) * **explorer:** macos has `trash` pre-installed, so no need to try `osascript` and move to first. Closes [#2349](https://github.com/folke/snacks.nvim/issues/2349) ([d569072](https://github.com/folke/snacks.nvim/commit/d569072b2e39e0078b55ea56b133fb9a30d78bad)) * **image:** detach image when reloading image file. Closes [#2343](https://github.com/folke/snacks.nvim/issues/2343) ([7bf4175](https://github.com/folke/snacks.nvim/commit/7bf4175588a784bbf7463b68351833ed64f5c6cc)) * **image:** increase timeout for querying the terminal. Closes [#2344](https://github.com/folke/snacks.nvim/issues/2344) ([4122143](https://github.com/folke/snacks.nvim/commit/4122143240fb7f43b27dddec670b844404cb08db)) * **image:** let healthcheck wait till terminal detection is done ([b029511](https://github.com/folke/snacks.nvim/commit/b029511abb1359da28de45faeeec400f419d7ee7)) * **image:** only attach to a buffer once. Closes [#2343](https://github.com/folke/snacks.nvim/issues/2343) ([6f72643](https://github.com/folke/snacks.nvim/commit/6f726433232422d26157adfd0df3dd464341222b)) * **image:** work around tmux extended-keys breaking TermResponse. Closes [#2332](https://github.com/folke/snacks.nvim/issues/2332) ([e93dcfd](https://github.com/folke/snacks.nvim/commit/e93dcfdf394ef16732f06021d941146be912043c)) * **layout:** provide parent win width/height when relative to win ([#2346](https://github.com/folke/snacks.nvim/issues/2346)) ([602393a](https://github.com/folke/snacks.nvim/commit/602393aed2dd8059e74afff6712a423b6f048cfe)) * **picker:** fix race condition causing "Finder yielded after done" error. Closes [#2327](https://github.com/folke/snacks.nvim/issues/2327) ([c9ccbe5](https://github.com/folke/snacks.nvim/commit/c9ccbe56179f1d4adb06fea47f4eea0c57736c2d)) * **picker:** set min file width to 40 ([69417ac](https://github.com/folke/snacks.nvim/commit/69417ac68152bc08d0ea0640e211f2a3eb48bac6)) * **win:** use `sbuffer` instead of `split` for split windows ([bbd6d42](https://github.com/folke/snacks.nvim/commit/bbd6d42a9738c3a4c7c35f5ebde91a5ede8bec3a)) ### Performance Improvements * **picker:** don't use treesitter string parser, since a change in nightly creates thousands of unlisted buffers in that case ([ad6c0a5](https://github.com/folke/snacks.nvim/commit/ad6c0a5e542b6b47b4ac5e2ebcbdd663b8a7e908)) * **picker:** re-use existing string parsers per language to prevent needing to create new parsers ([efa304a](https://github.com/folke/snacks.nvim/commit/efa304a078993198e6fa088845fe8925708abb4e)) ## [2.25.0](https://github.com/folke/snacks.nvim/compare/v2.24.0...v2.25.0) (2025-10-23) ### Features * **notifier:** added `gap` option. Closes [#2331](https://github.com/folke/snacks.nvim/issues/2331) ([b1acbb0](https://github.com/folke/snacks.nvim/commit/b1acbb0fcce9ed1ead3fd511eb934eeefe238b69)) * **select:** allow configuring options for specific vim.ui.select kinds ([bca5b05](https://github.com/folke/snacks.nvim/commit/bca5b058388fb381f6d04c3624a541f7c0637382)) * **snacks:** added `Snacks.version`. auto updated by the release workflow ([a283beb](https://github.com/folke/snacks.nvim/commit/a283beb6dc94f7a17c48dcb6878e0dd3493bf370)) ### Bug Fixes * **dashboard:** fix issue with opening file at location due to splitkeep and restoring laststatus/showtabline ([1a2b34d](https://github.com/folke/snacks.nvim/commit/1a2b34dffd524b0f7373c5868dbb7597360e1a8c)) * **scroll:** stop animations when buf/changedtick changes ([a42b376](https://github.com/folke/snacks.nvim/commit/a42b3761f702e770d745709682dfe3d7e3ef1bb6)) ## [2.24.0](https://github.com/folke/snacks.nvim/compare/v2.23.0...v2.24.0) (2025-10-23) ### Features * **bigfile:** disable mini-hipatterns ([#2170](https://github.com/folke/snacks.nvim/issues/2170)) ([3d4dd13](https://github.com/folke/snacks.nvim/commit/3d4dd13d2e7e33b81ffda9baa58f8852e4ca84f6)) * **dashboard:** optional `filter` for projects. Closes [#798](https://github.com/folke/snacks.nvim/issues/798) ([fe88a07](https://github.com/folke/snacks.nvim/commit/fe88a07d5337e21317ab1a7613add6c364bb9eae)) * **debug:** allow debug evaluation of block selections ([#1331](https://github.com/folke/snacks.nvim/issues/1331)) ([231ffae](https://github.com/folke/snacks.nvim/commit/231ffae08d956039899fb56000a6e03a99819905)) * **git:** allow configuring extra git args and git cmd args for all git sources. See [#2178](https://github.com/folke/snacks.nvim/issues/2178) ([5782b5c](https://github.com/folke/snacks.nvim/commit/5782b5cda0a2dcc032ec16cbdd6e39fb2baedaa6)) * **image:** add icns support ([#2120](https://github.com/folke/snacks.nvim/issues/2120)) ([9df47bc](https://github.com/folke/snacks.nvim/commit/9df47bce6a3b752831b4970c26a8886b2843e9bb)) * **image:** added clear fun. Closes [#1394](https://github.com/folke/snacks.nvim/issues/1394) ([30687d1](https://github.com/folke/snacks.nvim/commit/30687d195b060e1857cbf905b672af6e48dacc2a)) * **image:** added support for base64 encoded images in url. Closes [#2304](https://github.com/folke/snacks.nvim/issues/2304) ([2c56e10](https://github.com/folke/snacks.nvim/commit/2c56e10b1dd69dcebf4d0915af396dd0bd7123a6)) * **image:** allow specifying a page number for inlined pdfs ([#1806](https://github.com/folke/snacks.nvim/issues/1806)) ([3f0fe34](https://github.com/folke/snacks.nvim/commit/3f0fe34308b06e66c8ce1ce07f0b18a4d9525bdc)) * **indent:** pass win to filter func. Closes [#2307](https://github.com/folke/snacks.nvim/issues/2307) ([8116e0b](https://github.com/folke/snacks.nvim/commit/8116e0b380701a3b1deafe8d14416be6ee403f6f)) * **input:** added support for a custom highlight functions. Closes [#2216](https://github.com/folke/snacks.nvim/issues/2216) ([9b80137](https://github.com/folke/snacks.nvim/commit/9b80137aced62886de5e1399eb6e4000e57683de)) * **layout:** height=0.7 for preview in vscode layout ([c3d6c01](https://github.com/folke/snacks.nvim/commit/c3d6c019165e55d704f2596562dd310c7b0a8a10)) * **layout:** static (non-flex) layouts now shrink the root box to fit the contents. See [#2035](https://github.com/folke/snacks.nvim/issues/2035) ([ba7845b](https://github.com/folke/snacks.nvim/commit/ba7845bb85a0cfad3c7c5f75b3bc05c68679a090)) * **picker.finder:** added assertions that finder is still running when receiving results ([a45503b](https://github.com/folke/snacks.nvim/commit/a45503b95752055e19186b75a4f9874cd39aa834)) * **picker.git_diff:** add `base` option to show diff against a merge base. Useful to see changes on a branch/PR ([7964f04](https://github.com/folke/snacks.nvim/commit/7964f040bf605b2a3e8d66d02c453469352e005e)) * **picker.git:** allow passing extra args to git log command for file renames ([#1964](https://github.com/folke/snacks.nvim/issues/1964)) ([2aee35d](https://github.com/folke/snacks.nvim/commit/2aee35d0591f80b4a186e0ad3c600cd05c3f2a4d)) * **picker.git:** use default previewer args in git_show ([#1736](https://github.com/folke/snacks.nvim/issues/1736)) ([f324f96](https://github.com/folke/snacks.nvim/commit/f324f96bea48727d4a5967d443c3c2314fe4af60)) * **picker.layout:** added `config` hook for resolved layouts. See [#2035](https://github.com/folke/snacks.nvim/issues/2035) ([722f9ea](https://github.com/folke/snacks.nvim/commit/722f9eac7c459364a37d81e6a8df92fe0ee9d6da)) * **picker.lsp_config:** added more info to lsp picker ([636be5c](https://github.com/folke/snacks.nvim/commit/636be5c3d1b35b2041123efcc5b2a86df0dc9f93)) * **picker.lsp:** added lsp_incoming_calls and lsp_outgoing_calls. Closes [#1843](https://github.com/folke/snacks.nvim/issues/1843) ([55d6670](https://github.com/folke/snacks.nvim/commit/55d6670a7eb2667d467489b5c6c6a2ed428cead2)) * **picker.lsp:** added option `keep_parents` to `lsp_symbols` (default `false`). See [#2083](https://github.com/folke/snacks.nvim/issues/2083). closes [#2266](https://github.com/folke/snacks.nvim/issues/2266) ([2b9d522](https://github.com/folke/snacks.nvim/commit/2b9d52258d83361479b5a4a6fca6845c58e08050)) * **picker.projects:** make max_depth customizable ([#2253](https://github.com/folke/snacks.nvim/issues/2253)) ([3e9e2e2](https://github.com/folke/snacks.nvim/commit/3e9e2e2d71cb869467072bfd6864aa5179f8749c)) * **picker.scratch:** add scratch picker with grep, new and delete keybinds ([#1019](https://github.com/folke/snacks.nvim/issues/1019)) ([ca0f8b2](https://github.com/folke/snacks.nvim/commit/ca0f8b2c09a6b437479e7d12bdb209731d9eb621)) * **picker.select:** select now fits the list to the items independent of the layout. Closes [#2035](https://github.com/folke/snacks.nvim/issues/2035) ([5c63614](https://github.com/folke/snacks.nvim/commit/5c63614880f6a1c911cd2e0cf9291bcea7612950)) * **picker:** add author field to git log ([#2295](https://github.com/folke/snacks.nvim/issues/2295)) ([2cf864a](https://github.com/folke/snacks.nvim/commit/2cf864aaa1bc31a4d030a52fe03ebac3e65be65d)) * **picker:** add exact match position highlighting for grep results ([3b54c8d](https://github.com/folke/snacks.nvim/commit/3b54c8d3d1f0cd5b2698e343b218a01a42f4388f)) * **picker:** add git_restore action for git_status picker ([2b22fe7](https://github.com/folke/snacks.nvim/commit/2b22fe78614a001c51c0b4025236770817ac999e)) * **picker:** add toggle_regex for grep ([#1594](https://github.com/folke/snacks.nvim/issues/1594)) ([bd6ee23](https://github.com/folke/snacks.nvim/commit/bd6ee235463dc55c650396fae2ea02e32d4c1496)) * **picker:** added `Snacks.picker.tags()` a picker for ctags. Closes [#1728](https://github.com/folke/snacks.nvim/issues/1728) ([4290287](https://github.com/folke/snacks.nvim/commit/42902871f5ff95bd0b87790abdadb1eb10a42fd1)) * **picker:** added custom options to `vim.ui.select` that snacks can use for a better select ([264cab1](https://github.com/folke/snacks.nvim/commit/264cab138039412a151b21fdc30d4928f50d79b4)) * **picker:** added live support to `git_log`, which uses `-S` (pickaxe) to search. Closes [#1544](https://github.com/folke/snacks.nvim/issues/1544) ([c9fa6f7](https://github.com/folke/snacks.nvim/commit/c9fa6f7b0724587d4c4974817aad96d93f469437)) * **picker:** allow configuring pathspec for git grep ([#2311](https://github.com/folke/snacks.nvim/issues/2311)) ([57fbda7](https://github.com/folke/snacks.nvim/commit/57fbda70d66d808c10974448d4003f567b78e784)) * **picker:** also ignore dot bare git files ([#2058](https://github.com/folke/snacks.nvim/issues/2058)) ([4bb0dae](https://github.com/folke/snacks.nvim/commit/4bb0dae95d1ecc10daf50fd490deaf9a28b26f1c)) * **picker:** enhanced resume with multi-state support and flexible API ([bc6c446](https://github.com/folke/snacks.nvim/commit/bc6c446c11a92bc5b6d5a960bcf3488c519c647a)) * **picker:** flexible filename format ([#2294](https://github.com/folke/snacks.nvim/issues/2294)) ([9ad5d53](https://github.com/folke/snacks.nvim/commit/9ad5d5374ac7cd24c79e99a4645add1960eb93fa)) * **picker:** mapped `` to `print_cwd` in list. See [#2244](https://github.com/folke/snacks.nvim/issues/2244) ([faa6aba](https://github.com/folke/snacks.nvim/commit/faa6abacb40f2e02203f2baabc988e3564d63952)) * **picker:** Support rmagatti/autosession session manager ([#1825](https://github.com/folke/snacks.nvim/issues/1825)) ([fc06234](https://github.com/folke/snacks.nvim/commit/fc06234ce13b7e653e0a5947a266abf016dc163f)) * **picker:** updated Snacks.picker.lsp_config to work with `vim.lsp.config` ([292d46f](https://github.com/folke/snacks.nvim/commit/292d46f773af05aaea6a21f13fcc179adea95494)) * **picker:** when resuming a source that has nothing to resume, start a picker with the source instead ([db3c13c](https://github.com/folke/snacks.nvim/commit/db3c13c28e0e7231bc3a45cd0db0e6683fc6c2c5)) * **terminal:** minor improvements for user experience ([#2276](https://github.com/folke/snacks.nvim/issues/2276)) ([39b14c4](https://github.com/folke/snacks.nvim/commit/39b14c400653f320133b3f8c65cdb612e42f9ca1)) * **toggle:** allow notification customization via function ([#2247](https://github.com/folke/snacks.nvim/issues/2247)) ([3ccab97](https://github.com/folke/snacks.nvim/commit/3ccab9736b298c8a8ef13aca5e3e9e7dc64c73bd)) * **win:** added support for `vim.o.winborder`. Set win.border = true to use it ([b30523c](https://github.com/folke/snacks.nvim/commit/b30523c89fda32efe43e99fe71235d63c9a44a3b)) * **win:** all existing snacks windows for all plugins now honor `vim.o.winborder`. Defaults to `rounded` if not set. ([c1737d8](https://github.com/folke/snacks.nvim/commit/c1737d866ebddb8270e3bdce9ec6f425ff24fb48)) * **win:** generalize footer options for keys ([#363](https://github.com/folke/snacks.nvim/issues/363)) ([b8d1719](https://github.com/folke/snacks.nvim/commit/b8d17192b663305398df98930ac79b3c7612b809)) * **win:** make split window "stacking" configurable ([e46a094](https://github.com/folke/snacks.nvim/commit/e46a09427cfed62ea7f37039b76b2b2a13fddec8)) ### Bug Fixes * **bigfile:** bigfile doesn't work on windows. ([#1969](https://github.com/folke/snacks.nvim/issues/1969)) ([b4944ff](https://github.com/folke/snacks.nvim/commit/b4944ff320ca23ba10a7498da4dfe13e8065395e)), closes [#1722](https://github.com/folke/snacks.nvim/issues/1722) * **bufdelete:** try alternate buffer first and otherwise last used buffer ([914c900](https://github.com/folke/snacks.nvim/commit/914c9004be843c96b43fd86a1010c00dc147e5b4)) * **dashboard:** fix path filtering for `recent_files` with `cwd` option ([#2201](https://github.com/folke/snacks.nvim/issues/2201)) ([057d4ab](https://github.com/folke/snacks.nvim/commit/057d4ab80e42d76ae0d24d0582d27cf3630c0ec1)) * **dashboard:** oldfiles filter should return a boolean instead of the result of find. Fixes [#2283](https://github.com/folke/snacks.nvim/issues/2283) ([fcd309f](https://github.com/folke/snacks.nvim/commit/fcd309f9ea8529d5128474720ecf275bd8ee4ce5)) * **dashboard:** pcall chansend for dashoard terminal widgets ([dc65ffd](https://github.com/folke/snacks.nvim/commit/dc65ffd4f591fd68f1433e4bd815af832ed737b8)) * **dashboard:** recent cwd filter matching ([5c4365e](https://github.com/folke/snacks.nvim/commit/5c4365e99398fc67f0b4379d6e4a4b581bc3f485)) * **dashboard:** recent_files section not displaying files without cwd parameter ([#2284](https://github.com/folke/snacks.nvim/issues/2284)) ([1ed737e](https://github.com/folke/snacks.nvim/commit/1ed737e46502ed2e47dd9cc9825d881ae9844b7f)) * **dashboard:** replace deprecated AutoSession command ([#2288](https://github.com/folke/snacks.nvim/issues/2288)) ([e9228d6](https://github.com/folke/snacks.nvim/commit/e9228d6b2f64631b49619466ebdd75c0da37e1f8)) * **dashboard:** restore showtabline/laststatus when entering another non-float window. Closes [#1774](https://github.com/folke/snacks.nvim/issues/1774) ([cc69a93](https://github.com/folke/snacks.nvim/commit/cc69a9304bfdd047b7ea9ffa879985c351c6327b)) * **dashboard:** set `border = "none"` on `terminal` sections ([#1643](https://github.com/folke/snacks.nvim/issues/1643)) ([83f364f](https://github.com/folke/snacks.nvim/commit/83f364f8334801d163ecc02c717dd8ee1e07ad53)) * **dashboard:** update cursor on loading the dashboard. Closes [#2004](https://github.com/folke/snacks.nvim/issues/2004) ([29682a0](https://github.com/folke/snacks.nvim/commit/29682a0a724cb81a109f70bf0cbadb0cf6bcc630)) * **dashboard:** use fqn for icon. Closes [#1496](https://github.com/folke/snacks.nvim/issues/1496) ([24e92e0](https://github.com/folke/snacks.nvim/commit/24e92e0c947f6a22e6b131d405549c607dc9f5f0)) * **dim:** fixed the issue of dim's scope variable being nil and outputting… ([#1938](https://github.com/folke/snacks.nvim/issues/1938)) ([943a3c7](https://github.com/folke/snacks.nvim/commit/943a3c7d4a97950900fdc16784f3d11f3ca574ea)) * **explorer.git:** don't propagate deletes to parent dirs that don't exist ([835c4cb](https://github.com/folke/snacks.nvim/commit/835c4cbfc6043a3abab8c8f01cd67e368a90cd93)) * **explorer.watch:** handle systems where fs_event doesn't return file names. Closes [#2190](https://github.com/folke/snacks.nvim/issues/2190). Closes [#2032](https://github.com/folke/snacks.nvim/issues/2032) ([d6e34b1](https://github.com/folke/snacks.nvim/commit/d6e34b158d892cd774b36503cbd3a9e62c7951e3)) * **explorer:** mounted directories being detected as non-directories in Tree:expand ([#2053](https://github.com/folke/snacks.nvim/issues/2053)) ([7a5eb10](https://github.com/folke/snacks.nvim/commit/7a5eb1036a313db1880e2e11f42aa6a1c20f1a23)) * **explorer:** reset main when entering another window. Closes [#1587](https://github.com/folke/snacks.nvim/issues/1587) ([a5d45d5](https://github.com/folke/snacks.nvim/commit/a5d45d543e1c506fb0d49ac569c2a0ce5403ac37)) * **git:** always check parents for git root to fix an issue with git submodules. Closes [#2143](https://github.com/folke/snacks.nvim/issues/2143) ([14dd362](https://github.com/folke/snacks.nvim/commit/14dd362d5dd500806c2f5357f4afd8145a268a01)) * **gitbrowse:** fixed urls for gitlab ([#2073](https://github.com/folke/snacks.nvim/issues/2073)) ([9ebf052](https://github.com/folke/snacks.nvim/commit/9ebf052feff78411c2f68bfa94d0a17cbf1e6d85)) * **gitbrowse:** send commit as a opt when calling gitbrowse ([#2289](https://github.com/folke/snacks.nvim/issues/2289)) ([a466429](https://github.com/folke/snacks.nvim/commit/a4664298ba6669ec14f704b9602339f448bd45c9)) * **git:** set `diff.noprefix=false` for `git diff` to ensure correct format ([#2174](https://github.com/folke/snacks.nvim/issues/2174)) ([93f43ca](https://github.com/folke/snacks.nvim/commit/93f43ca10f77e7b22f1e58cbeba6d7e3e8b04d9f)) * **image.terminal:** do only terminal detection for now. Closes [#2323](https://github.com/folke/snacks.nvim/issues/2323) ([6c7ddae](https://github.com/folke/snacks.nvim/commit/6c7ddae887ca6f0776eb46b57c6f78f25ddf0238)) * **image:** correct off by one issue in render fallback ([#1560](https://github.com/folke/snacks.nvim/issues/1560)) ([441bdcd](https://github.com/folke/snacks.nvim/commit/441bdcd2103bb43f3275f4aca6d76d93fd1aaa92)) * **image:** correct render fallback to handle "editor" relative position ([#2296](https://github.com/folke/snacks.nvim/issues/2296)) ([c552cea](https://github.com/folke/snacks.nvim/commit/c552cea13199e518c167c8815156ae9c01577b27)) * **image:** correct render fallback to handle "editor" relative position ([#2297](https://github.com/folke/snacks.nvim/issues/2297)) ([1c3f15c](https://github.com/folke/snacks.nvim/commit/1c3f15cb54c0ee12b8cd4fd59a8ddc5ebe1fdd3c)) * **image:** detect kitty image protocol through terminal capability request. Closes [#1695](https://github.com/folke/snacks.nvim/issues/1695) ([43261ba](https://github.com/folke/snacks.nvim/commit/43261baf87fdc5f970c12e9a6c795ba8a4e7595c)) * **image:** do not save remote image if fetch fails ([#1915](https://github.com/folke/snacks.nvim/issues/1915)) ([cb6bf05](https://github.com/folke/snacks.nvim/commit/cb6bf052daa11c287d0d8fa8f168190eb40c0c8d)) * **image:** ENOENT on preview ([#2301](https://github.com/folke/snacks.nvim/issues/2301)) ([5173e96](https://github.com/folke/snacks.nvim/commit/5173e96f3359121233e817c12307d531a8622e4f)) * **image:** hover close in insert mode ([#2215](https://github.com/folke/snacks.nvim/issues/2215)) ([ef59af0](https://github.com/folke/snacks.nvim/commit/ef59af0ffc1289602a0792ee03724d4e36a0a229)) * **image:** markdown inline link query for shortened urls ([#1481](https://github.com/folke/snacks.nvim/issues/1481)) ([2daa1b2](https://github.com/folke/snacks.nvim/commit/2daa1b28b2151f3add39863de4df245a5140badf)) * **image:** set winblend=0 for floatwin when use unicode placeholders ([#1615](https://github.com/folke/snacks.nvim/issues/1615)) ([758e64c](https://github.com/folke/snacks.nvim/commit/758e64c18fc5934244ab48be8d17b90fa36ad16e)) * **image:** skip `\usepackage` in comments and body ([#2325](https://github.com/folke/snacks.nvim/issues/2325)) ([90227af](https://github.com/folke/snacks.nvim/commit/90227af4977504ae2d4294fe76a63117dfd18498)) * **image:** work-around for sha256 not allowed to be a Blob ([92a08ce](https://github.com/folke/snacks.nvim/commit/92a08cece72aeb67cf2a527991cbffdab093db5e)) * **indent:** check that win is valid in step. Closes [#1943](https://github.com/folke/snacks.nvim/issues/1943) ([e409f31](https://github.com/folke/snacks.nvim/commit/e409f31cc968f90139ad66941827b42ef95de7fd)) * **indent:** nil check before setting extmark ([#1635](https://github.com/folke/snacks.nvim/issues/1635)) ([02bf7d2](https://github.com/folke/snacks.nvim/commit/02bf7d2205ea7a4b903fa5266668f9fc7768f6c9)) * **input:** schedule stopinsert. Fixes [#1841](https://github.com/folke/snacks.nvim/issues/1841) ([ad6cbc8](https://github.com/folke/snacks.nvim/commit/ad6cbc8d5d4b49c8030083c1f55fc7c3679f3ac4)) * **input:** zindex ([67d690d](https://github.com/folke/snacks.nvim/commit/67d690d3625ff2899a7505a418bda91cc59042f7)) * **input:** zindex. Closes [#2302](https://github.com/folke/snacks.nvim/issues/2302) ([d491236](https://github.com/folke/snacks.nvim/commit/d49123694157597e64c284e5bd541cdd31538ba8)) * **layout:** allocate at least 1 cell for a widget and enlarge/shrink the root box when needed. Closes [#2261](https://github.com/folke/snacks.nvim/issues/2261) ([71d6d3c](https://github.com/folke/snacks.nvim/commit/71d6d3cad4cb842e4053f7bb39894144ee0dc81b)) * **layout:** allow width/height to be a function. Closes [#2184](https://github.com/folke/snacks.nvim/issues/2184) ([c757d4d](https://github.com/folke/snacks.nvim/commit/c757d4dc28c5baedaab44a59545760d905d87b20)) * **lazygit:** allow extensible user args ([#789](https://github.com/folke/snacks.nvim/issues/789)) ([da655a3](https://github.com/folke/snacks.nvim/commit/da655a353849bccb73d66dbb3caa9c238e7b0cae)) * **lazygit:** check if default config file exists before adding to LG_CONFIG_FILE ([#2256](https://github.com/folke/snacks.nvim/issues/2256)) ([3731644](https://github.com/folke/snacks.nvim/commit/3731644e38fe494399e75dc893215d1a801654ff)) * **main:** get correct winid for prev window ([db399b1](https://github.com/folke/snacks.nvim/commit/db399b1332848477b0cd881faabe95a0efddf1c6)) * **notifier:** include icon in padding in minimal style ([#2239](https://github.com/folke/snacks.nvim/issues/2239)) ([6daef52](https://github.com/folke/snacks.nvim/commit/6daef528c1422b33b2f2f713822602f9d66a5d51)) * **notifier:** keep filtered notifications in history ([#2209](https://github.com/folke/snacks.nvim/issues/2209)) ([ac61546](https://github.com/folke/snacks.nvim/commit/ac6154688baa79ec099fd662365fccf1a2feefd1)) * **picker.actions:** `` in list view now prints file path instead of cwd. Fallback to cwd ([0b0a58a](https://github.com/folke/snacks.nvim/commit/0b0a58ae4aa643e66ff2b87ce5087857bcab1756)) * **picker.actions:** ensure the current window is updated after tabdrop ([#2326](https://github.com/folke/snacks.nvim/issues/2326)) ([b30121b](https://github.com/folke/snacks.nvim/commit/b30121bfce84fdcbe53cb724c97388cbe4e18980)) * **picker.actions:** multi-action descriptions. Fixes [#1501](https://github.com/folke/snacks.nvim/issues/1501) ([4edf207](https://github.com/folke/snacks.nvim/commit/4edf207bfeef70e3062a604825766c81d8809359)) * **picker.actions:** take into account if source is `recent` explicitly ([#1920](https://github.com/folke/snacks.nvim/issues/1920)) ([b9bd8ae](https://github.com/folke/snacks.nvim/commit/b9bd8ae98213973131d21abf806e56c3f1c8f0a9)) * **picker.core:** respect camelCase for scoring when ignorecase is true ([#1601](https://github.com/folke/snacks.nvim/issues/1601)) ([a32735b](https://github.com/folke/snacks.nvim/commit/a32735b9e8513bd71d82744597490ac10e343c6e)) * **picker.format:** added min_width for truncated paths ([b7f8116](https://github.com/folke/snacks.nvim/commit/b7f811613a0a999f6a275260ef2963ecff3a16e8)) * **picker.format:** apply hidden file hl group last. Fixes [#2127](https://github.com/folke/snacks.nvim/issues/2127) ([0bf8fe4](https://github.com/folke/snacks.nvim/commit/0bf8fe4ece5d8a2a4b87f098b014fd9901475a1b)) * **picker.format:** correcter max_width for truncpath ([a5d2964](https://github.com/folke/snacks.nvim/commit/a5d29646e593d52e952183021d5902e2a1ebc583)) * **picker.format:** simplified resolvable formatters and more correct ([d5b6d30](https://github.com/folke/snacks.nvim/commit/d5b6d30b5e9acd3279406e0a3d382d37d657a28f)) * **picker.git_diff:** use absolute path when adding buffer to avoid duplicates ([#1819](https://github.com/folke/snacks.nvim/issues/1819)) ([a012f39](https://github.com/folke/snacks.nvim/commit/a012f394c9988ec30ec03474d5b971e1796ee3dd)) * **picker.git:** add `ignorecase` for `git_grep` ([#1629](https://github.com/folke/snacks.nvim/issues/1629)) ([7502e77](https://github.com/folke/snacks.nvim/commit/7502e7780363217ffcf3a998fd7d661ce37c1f01)) * **picker.git:** use unmerged icon for unmerged. Fixes [#1531](https://github.com/folke/snacks.nvim/issues/1531) ([abee3c9](https://github.com/folke/snacks.nvim/commit/abee3c9eff1c77eb66151800b3683d4043527f88)) * **picker.grep:** better line/col parsing. Closes [#2126](https://github.com/folke/snacks.nvim/issues/2126). Fixes [#2123](https://github.com/folke/snacks.nvim/issues/2123) ([1fee799](https://github.com/folke/snacks.nvim/commit/1fee799ad67d004c0cd8412a496bb20cb2f03e9b)) * **picker.grep:** faulty rg cmd. Closes [#2280](https://github.com/folke/snacks.nvim/issues/2280) ([65a5c8b](https://github.com/folke/snacks.nvim/commit/65a5c8b3d05b0c08838aab9db8427b7f62342ef8)) * **picker.list:** resize when needed. Closes [#2290](https://github.com/folke/snacks.nvim/issues/2290) ([df018ed](https://github.com/folke/snacks.nvim/commit/df018edfdbc5df832b46b9bdc9eafb1d69ea460b)) * **picker.lsp_config:** cmd can be a function ([ba745ba](https://github.com/folke/snacks.nvim/commit/ba745ba281c02b12dc898de9e652a408c48b2bbe)) * **picker.lsp:** don't process lsp request results when aborted. Closes [#2327](https://github.com/folke/snacks.nvim/issues/2327) ([4e10708](https://github.com/folke/snacks.nvim/commit/4e1070867a701b863d4bce4fd54a6c11314de506)) * **picker.lsp:** move get_clients inside vim.schedule to prevent issues on Neovim 0.11. Closes [#2320](https://github.com/folke/snacks.nvim/issues/2320) ([79f3a8d](https://github.com/folke/snacks.nvim/commit/79f3a8d8b3c19bb27d1c34381d27d55a87a374dc)) * **picker.lsp:** trigger docs workflow ([6f1158f](https://github.com/folke/snacks.nvim/commit/6f1158fe9bada1cb467defcdfb55f5217a90d709)) * **picker.man:** make tab/split/vsplit work. Closes [#2171](https://github.com/folke/snacks.nvim/issues/2171) ([f39d114](https://github.com/folke/snacks.nvim/commit/f39d1144e707d59857dfa5d78162748d1e8cef4a)) * **picker.marks:** fix buffer checking ([#2287](https://github.com/folke/snacks.nvim/issues/2287)) ([ca0858a](https://github.com/folke/snacks.nvim/commit/ca0858a30a88e8a28325c0c1edc0cd24b905c4e4)) * **picker.preview:** better hack to deal with buffer local option weirdness ([c968d4d](https://github.com/folke/snacks.nvim/commit/c968d4def4ee3769e6523cd4d8599695b7183a3f)) * **picker.preview:** directory preview should use cwd. Closes [#2212](https://github.com/folke/snacks.nvim/issues/2212). Fixes [#2093](https://github.com/folke/snacks.nvim/issues/2093) ([d050712](https://github.com/folke/snacks.nvim/commit/d05071255c865eb2cb3cd493235bf25ad6947513)) * **picker.preview:** don't record previeww searches in history and prevent scrolling from the top. Closes [#2305](https://github.com/folke/snacks.nvim/issues/2305) ([080320b](https://github.com/folke/snacks.nvim/commit/080320bb820ffdb6103f993da076b100ea68333c)) * **picker.preview:** dont do win-local hack for floating windows ([12b2f0d](https://github.com/folke/snacks.nvim/commit/12b2f0d2bdf18e50e8caa4e1ad3c6f6cc9365833)) * **picker.qflist:** error with qflist picker when the list contains invalid items ([#2293](https://github.com/folke/snacks.nvim/issues/2293)) ([6af1e76](https://github.com/folke/snacks.nvim/commit/6af1e76758c6e9ad8792202de2ba069da2a93a68)) * **picker.recent:** include closed / unlisted buffers in recent. Closes [#1745](https://github.com/folke/snacks.nvim/issues/1745) ([5959631](https://github.com/folke/snacks.nvim/commit/595963140e464e9bd8244b758a590a7c0b5d0798)) * **picker:** add type field to qflist item ([#1538](https://github.com/folke/snacks.nvim/issues/1538)) ([#1539](https://github.com/folke/snacks.nvim/issues/1539)) ([125978b](https://github.com/folke/snacks.nvim/commit/125978b57ac484d7c3487bd97b672c9157ba1ff0)) * **picker:** added show_delay to config max ms to wait to show if no results found yet. Closes [#2206](https://github.com/folke/snacks.nvim/issues/2206) ([64583a0](https://github.com/folke/snacks.nvim/commit/64583a0386e79b226bde4ce04432220836343e57)) * **picker:** allow some sources to use the current window as main. Closes [#2012](https://github.com/folke/snacks.nvim/issues/2012). See [#1941](https://github.com/folke/snacks.nvim/issues/1941) ([5cda953](https://github.com/folke/snacks.nvim/commit/5cda9532ca215f7065dc12be1cc23458e3137c2a)) * **picker:** correct z-index for preview="main" layout ([e796aef](https://github.com/folke/snacks.nvim/commit/e796aef0fabc791cdb4a7ec6ecfc91b0eccce1d7)) * **picker:** do not record consecutive duplicate history ([#2040](https://github.com/folke/snacks.nvim/issues/2040)) ([d0a5310](https://github.com/folke/snacks.nvim/commit/d0a53104172d48951e47e686c9e913ae5a6efb6f)) * **picker:** fixup for pickers that dont display files ([1b4205e](https://github.com/folke/snacks.nvim/commit/1b4205eb1a224f668e85abeda2c0b1f0f73f477d)) * **picker:** load correct actions in list of action names. Closes [#1501](https://github.com/folke/snacks.nvim/issues/1501) ([b064be2](https://github.com/folke/snacks.nvim/commit/b064be2882a0a081caa2464a57379d0ac58f4f0f)) * **picker:** lsp_config now includes any configfured LSP and excludes deprecated servers ([a0d6eba](https://github.com/folke/snacks.nvim/commit/a0d6eba1a22719ffaed9b1ac2cf79e33b1c64e4c)) * **picker:** prevent WinEnter handling during startup ([756a791](https://github.com/folke/snacks.nvim/commit/756a791131304a9063ff8e3af52811efbcaef688)) * **picker:** show_delay config value ([67bb3a7](https://github.com/folke/snacks.nvim/commit/67bb3a7ba0478c892a4f06ac0446ca101af787c9)) * **picker:** show_delay is in ms. Also increase it to allow auto_confirm to work properly ([924a930](https://github.com/folke/snacks.nvim/commit/924a9304e92bd29da4bd63a87cb0b05524254a87)) * **picker:** use nvim_paste instead of nvim_put. Closes [#1941](https://github.com/folke/snacks.nvim/issues/1941) ([021e04f](https://github.com/folke/snacks.nvim/commit/021e04fa6fef78f52ac189592d176e7febdd09d4)) * **projects:** normalize item.text for correct Windows support ([#2275](https://github.com/folke/snacks.nvim/issues/2275)) ([457596b](https://github.com/folke/snacks.nvim/commit/457596be6d53d4cd4b6bad8614dfde0c11549c41)) * **rename:** made rename more robust and make sure target directory exists. Closes [#2252](https://github.com/folke/snacks.nvim/issues/2252) ([c494447](https://github.com/folke/snacks.nvim/commit/c494447737900d3ed56a51aa4e6f2aa118c2c518)) * **scope:** allow user to disable keys ([#1918](https://github.com/folke/snacks.nvim/issues/1918)) ([bebf0bd](https://github.com/folke/snacks.nvim/commit/bebf0bd38e3e7071abc4085ad46f1ebc32cdfe17)) * **scratch:** branch fallback for detached head ([#1519](https://github.com/folke/snacks.nvim/issues/1519)) ([98345c7](https://github.com/folke/snacks.nvim/commit/98345c70126147f871d90ab23787b0dc00937b84)) * **scratch:** hide buffer after formatting when close ([#1523](https://github.com/folke/snacks.nvim/issues/1523)) ([4379085](https://github.com/folke/snacks.nvim/commit/43790856166685c4541397bd59b5927146be8f82)) * **scratch:** use icon[1] when icon is a table to avoid table.concat error ([#2242](https://github.com/folke/snacks.nvim/issues/2242)) ([ba90011](https://github.com/folke/snacks.nvim/commit/ba90011a1481b9b9314a7cebbb319f7472ea25a8)) * **scroll:** don't animate 1 line scrolls (jk). Closes [#1620](https://github.com/folke/snacks.nvim/issues/1620) ([d293b21](https://github.com/folke/snacks.nvim/commit/d293b21fe1a603dfb4757feb82ab3e67b78589f2)) * **scroll:** stop anim and reset state when win has new buf, or buf was changed. Closes [#1820](https://github.com/folke/snacks.nvim/issues/1820). Closes [#2221](https://github.com/folke/snacks.nvim/issues/2221) ([766f7b8](https://github.com/folke/snacks.nvim/commit/766f7b87aa1d94f356a77755886150dc7c4c756e)) * **statuscolumn:** show open folds in consecutive levels ([#1534](https://github.com/folke/snacks.nvim/issues/1534)) ([7bcd3ba](https://github.com/folke/snacks.nvim/commit/7bcd3baaf8e9fbea1c51e0690e67e7be69441311)) * **terminal:** check win valid before creating a new terminal ([#1927](https://github.com/folke/snacks.nvim/issues/1927)) ([ba7bbcd](https://github.com/folke/snacks.nvim/commit/ba7bbcd0df45f1f4e390fc79dcaa974b4c4ea3c6)) * **terminal:** make sure terminals opend with `open()` can be found with `list()`. Closes [#2172](https://github.com/folke/snacks.nvim/issues/2172). Closes [#2173](https://github.com/folke/snacks.nvim/issues/2173) ([13f3006](https://github.com/folke/snacks.nvim/commit/13f3006dbf69e0a1a3189775d9c70ee3456ed7e0)) * **terminal:** set buffer when opening terminal with position='current' ([#2162](https://github.com/folke/snacks.nvim/issues/2162)) ([2aacf55](https://github.com/folke/snacks.nvim/commit/2aacf550820430594a74ccf995a11b0be9184eea)), closes [#2148](https://github.com/folke/snacks.nvim/issues/2148) * **terminal:** stack only terminal splits by default. Closes [#2137](https://github.com/folke/snacks.nvim/issues/2137) ([8c50196](https://github.com/folke/snacks.nvim/commit/8c501965beff9a741b29eea53c7f876b039bddea)) * **util:** fix invalid window error ([#1996](https://github.com/folke/snacks.nvim/issues/1996)) ([32e5bf1](https://github.com/folke/snacks.nvim/commit/32e5bf17309ca26e6075a14c3907b0959188d781)) * **util:** only use mini.icons if it has been setup. Closes [#2199](https://github.com/folke/snacks.nvim/issues/2199) ([774bf9d](https://github.com/folke/snacks.nvim/commit/774bf9d8c8d9bc401355f53605f91d469236f6c6)) * **win:** check parent win is valid before getting size ([#2315](https://github.com/folke/snacks.nvim/issues/2315)) ([471eb03](https://github.com/folke/snacks.nvim/commit/471eb036c47abf9e71c33b9e01ebb1b2d464b791)) * **zen:** make zoom and minimizing work in terminal mode ([#1912](https://github.com/folke/snacks.nvim/issues/1912)) ([fb54927](https://github.com/folke/snacks.nvim/commit/fb54927ab0d5b4ce3a377d9bcd7b172d0692725d)) ### Performance Improvements * **dashboard:** add basic OSC11 and CSI6n support to terminal sections (gh 10 seconds faster) ([fb016d2](https://github.com/folke/snacks.nvim/commit/fb016d20c2a415450708e3eb837462f6dcea46ba)) * **git:** invoke `git status` with `--no-optional-locks` ([#2175](https://github.com/folke/snacks.nvim/issues/2175)) ([e441c64](https://github.com/folke/snacks.nvim/commit/e441c641eb3ff4ffce7535578399b7f6a7a0b2e1)) * **grep:** move match parsing to resolve and fix an issue with `.*` results. Closes [#2308](https://github.com/folke/snacks.nvim/issues/2308) ([1417701](https://github.com/folke/snacks.nvim/commit/1417701af6e98ece624db386361b006d1e13d1fa)) * **picker:** set `limit_live=10000` by default. Makes no sense to load millions of matches when doing live searches. ([04990d0](https://github.com/folke/snacks.nvim/commit/04990d042c7e95d1f2cea5bf26892cb4a5f024fd)) ## [2.23.0](https://github.com/folke/snacks.nvim/compare/v2.22.0...v2.23.0) (2025-09-15) ### Features * **image.doc:** enable inline math rendering for neorg files. Closes [#1438](https://github.com/folke/snacks.nvim/issues/1438) ([ec487f5](https://github.com/folke/snacks.nvim/commit/ec487f5d85c12e4cee27d4e1235a1f82f99bfe03)) * **image.inline:** honor `concealcursor` and hide conceal when selecting lines. Closes [#1478](https://github.com/folke/snacks.nvim/issues/1478) ([bc0630e](https://github.com/folke/snacks.nvim/commit/bc0630e43be5699bb94dadc302c0d21615421d93)) * **image:** proper inline rendering of math expressions. Closes [#1318](https://github.com/folke/snacks.nvim/issues/1318). Closes [#1454](https://github.com/folke/snacks.nvim/issues/1454) ([6ea4fa7](https://github.com/folke/snacks.nvim/commit/6ea4fa72dccd6db0713c13e6672f9e8fb4007417)) * **picker.git:** added `all` option to also list remote branched for `git_branches`. Closes [#1465](https://github.com/folke/snacks.nvim/issues/1465) ([3d695ab](https://github.com/folke/snacks.nvim/commit/3d695ab7d062d40c980ca5fd9fe6e593c8f35b12)) * **scope:** allow disabling scopes with `vim.g.snacks_scope = false` or `vim.b.snacks_scope = false`. Closes [#1463](https://github.com/folke/snacks.nvim/issues/1463) ([5315e26](https://github.com/folke/snacks.nvim/commit/5315e267fffad9a257ab6909e87eb43e2f636049)) * **util:** small ts parse wrapper that parses async when available ([9f0aa20](https://github.com/folke/snacks.nvim/commit/9f0aa2048945604d7f87bbc2594efa42c0f78c23)) ### Bug Fixes * **dashboard:** escape filenames for edit. Closes [#1453](https://github.com/folke/snacks.nvim/issues/1453) ([8b0e79a](https://github.com/folke/snacks.nvim/commit/8b0e79ab4cbb0bc5b22dd00a471d0b2bafb1c6f0)) * **explorer:** confirm prompt now defaults to `No` ([f970cbb](https://github.com/folke/snacks.nvim/commit/f970cbb258d23942906be83b808d2ca2fcc24ab2)) * **image.inline:** remove debug ([d9bb639](https://github.com/folke/snacks.nvim/commit/d9bb639feda0daf4e4df6eaa47989099b74dde46)) * **image.latex:** don't nest image nodes ([714d761](https://github.com/folke/snacks.nvim/commit/714d7616f0b76ff7c099b5604b19a1a6ab909511)) * **image.queries:** add image type ([1bbd479](https://github.com/folke/snacks.nvim/commit/1bbd47973df1ae2127576de8fcea720499c159ad)) * **image:** only show anchor icon for inline images shown on the lines below. Closes [#1479](https://github.com/folke/snacks.nvim/issues/1479) ([bc2ed15](https://github.com/folke/snacks.nvim/commit/bc2ed15c41cb147f957db02959c9422b6a9b84ba)) * **indent:** zero indent for blank lines. Closes [#1477](https://github.com/folke/snacks.nvim/issues/1477) ([cddf714](https://github.com/folke/snacks.nvim/commit/cddf714dd66a14b0cf556f9be82165b22517de1a)) * **input:** add cr keybind for normal mode. Closes [#1468](https://github.com/folke/snacks.nvim/issues/1468). Closes [#1466](https://github.com/folke/snacks.nvim/issues/1466) ([78f0ad6](https://github.com/folke/snacks.nvim/commit/78f0ad6ce7283b0e2d6ac2b9b82ac731c7c30b93)) * **lsp:** fix deprecated warnings related to lsp client ([07fefd2](https://github.com/folke/snacks.nvim/commit/07fefd2a99b2ae376f9704a8a3885c838cfc31c8)) * **picker.preview:** always use builtin for git log preview ([f0d3433](https://github.com/folke/snacks.nvim/commit/f0d34336dbac2909654ca05aabc472edc73c7c8a)) * **statuscolumn:** better way of determining open folds. Closes [#1445](https://github.com/folke/snacks.nvim/issues/1445) ([1239fb8](https://github.com/folke/snacks.nvim/commit/1239fb84bc426d4fcd1c8dc9dde8503c17501842)) * **util.spawn:** correctly mark as faild on abort ([6917597](https://github.com/folke/snacks.nvim/commit/6917597f6d22d79fcd0bf9b0eb7845f7ffdc80a0)) * **win:** make sure the border is set when setting the title ([76311ab](https://github.com/folke/snacks.nvim/commit/76311aba31182adcd85cc3381abca76b917668b7)) ### Performance Improvements * **image:** async treesitter parsing for images ([e55ae37](https://github.com/folke/snacks.nvim/commit/e55ae37bebd53ab0e24a47d88ef50267207ffd91)) ### Reverts * dont always set border when setting title. Closes [#1436](https://github.com/folke/snacks.nvim/issues/1436) ([fa29c6c](https://github.com/folke/snacks.nvim/commit/fa29c6c92631026a7ee41249c78bd91562e67a09)) ## [2.22.0](https://github.com/folke/snacks.nvim/compare/v2.21.0...v2.22.0) (2025-02-25) ### Features * **image:** allow disabling math rendering. Closes [#1247](https://github.com/folke/snacks.nvim/issues/1247) ([1543a06](https://github.com/folke/snacks.nvim/commit/1543a063fbd3a462879d696b2885f4aa90c55896)) * **image:** configurable templates for math expressions. Closes [#1338](https://github.com/folke/snacks.nvim/issues/1338) ([e039139](https://github.com/folke/snacks.nvim/commit/e039139291f85eebf3eeb41cc5ad9dc4265cafa4)) * **image:** removed `org` integration, since that is now handled by the org mode plugin directly. ([956fe69](https://github.com/folke/snacks.nvim/commit/956fe69df328d2da924a04061802fb7d2ec5fef6)) * **picker.input:** added some ctrl+r keymaps similar to cmdline. Closes [#1420](https://github.com/folke/snacks.nvim/issues/1420) ([c864a7d](https://github.com/folke/snacks.nvim/commit/c864a7d378da2a11afb09302b3220264e2aa3409)) * **util:** util method to check if ts lang is available on any Neovim version. See [#1422](https://github.com/folke/snacks.nvim/issues/1422) ([e2cb9df](https://github.com/folke/snacks.nvim/commit/e2cb9df7d0695911f60a4510191aaf4a3d0d81ad)) ### Bug Fixes * **compat:** fixup ([ceabfc1](https://github.com/folke/snacks.nvim/commit/ceabfc1b89fe8e46b5138ae2417890121f5dfa02)) * **compat:** properly detect async treesitter parsing ([842605f](https://github.com/folke/snacks.nvim/commit/842605f072e5d124a47eeb212bc2f78345bec4c4)) * **compat:** vim.fs.normalize. Closes [#1321](https://github.com/folke/snacks.nvim/issues/1321) ([2295cfc](https://github.com/folke/snacks.nvim/commit/2295cfcca5bc749f169fb83ca4bdea9a85ad79a3)) * **dim:** check that win is valid when animating dim. Closes [#1342](https://github.com/folke/snacks.nvim/issues/1342) ([47e1440](https://github.com/folke/snacks.nvim/commit/47e1440d547233772a3958580d429b38b5959edd)) * **image.placement:** max width/height in cells is 297. Closes [#1345](https://github.com/folke/snacks.nvim/issues/1345) ([5fa93cb](https://github.com/folke/snacks.nvim/commit/5fa93cb6846b5998bc0b4b4ac9de47108fe39ce6)) * **image.terminal:** reset queue when timer runs ([2b34c4d](https://github.com/folke/snacks.nvim/commit/2b34c4dc05aa4cbccc6171fa530e95c218e9bc9c)) * **image.terminal:** write queued terminal output on main ([1b63b18](https://github.com/folke/snacks.nvim/commit/1b63b1811c58f661ad22f390a52aa6723703dc3d)) * **picker.buffers:** add `a` flag when buffer is visible in a window. See [#1417](https://github.com/folke/snacks.nvim/issues/1417) ([91c3da0](https://github.com/folke/snacks.nvim/commit/91c3da0b4b286967d6d0166c0fc5769795a78918)) * **picker.recent:** expand to full path before normalizing. Closes [#1406](https://github.com/folke/snacks.nvim/issues/1406) ([cf47fa7](https://github.com/folke/snacks.nvim/commit/cf47fa7cee80b0952706aacd4068310fe041761e)) * **picker:** allow overriding winhl of layout box wins. Closes [#1424](https://github.com/folke/snacks.nvim/issues/1424) ([b0f983e](https://github.com/folke/snacks.nvim/commit/b0f983ef9aa9b9855ff0b72350cd3dc80de70675)) * **picker:** disable regex for grep_word ([#1363](https://github.com/folke/snacks.nvim/issues/1363)) ([54298eb](https://github.com/folke/snacks.nvim/commit/54298eb624bd89f10f288b92560861277a34116d)) * **picker:** remove unused keymaps for mouse scrolling ([33df54d](https://github.com/folke/snacks.nvim/commit/33df54dae71df7f7ec17551c23ad0ffc677e6ad1)) * **picker:** update titles before showing. Closes [#1337](https://github.com/folke/snacks.nvim/issues/1337) ([3ae9863](https://github.com/folke/snacks.nvim/commit/3ae98636aaaf8f1b2f55b264f5745ae268de532f)) * **scope:** use `rawequal` to check if scope impl is treesitter. Closes [#1413](https://github.com/folke/snacks.nvim/issues/1413) ([4ce197b](https://github.com/folke/snacks.nvim/commit/4ce197bff9cb9b78a0bdcebb6f7ebbf22cd48c6a)) * **scroll:** compat with Neovim 0.9.4 ([4c52b7f](https://github.com/folke/snacks.nvim/commit/4c52b7f25da0ce6b2b830ce060dbd162706acf33)) * **statuscolumn:** right-align the current line number when relativenumber=true. Closes [#1376](https://github.com/folke/snacks.nvim/issues/1376) ([dd15e3a](https://github.com/folke/snacks.nvim/commit/dd15e3a05a2111231c53726f18e39a147162c20f)) * **win:** don't update title is relative win is invalid. Closes [#1348](https://github.com/folke/snacks.nvim/issues/1348) ([a00c323](https://github.com/folke/snacks.nvim/commit/a00c323d4b244f781df6df8b11bbfa47f63202d4)) * **win:** use correct keys for displaying help. Closes [#1364](https://github.com/folke/snacks.nvim/issues/1364) ([b100c93](https://github.com/folke/snacks.nvim/commit/b100c937177536cf2aa634ddd2aa5b8a1dd23ace)) * **zen:** always count cmdheight towards Zen bottom offset ([#1402](https://github.com/folke/snacks.nvim/issues/1402)) ([041bf1d](https://github.com/folke/snacks.nvim/commit/041bf1da9ed12498cbe3273dd90ef83e0a4913fa)) ### Performance Improvements * **scope:** use async treesitter parsing when available ([e0f882e](https://github.com/folke/snacks.nvim/commit/e0f882e6d6464666319502151cc244a090d4377f)) ## 2.21.0 (2025-02-20) ### Features * added new `image` snacks plugin for the kitty graphics protocol ([4e4e630](https://github.com/folke/snacks.nvim/commit/4e4e63048e5ddae6f921f1a1b4bd11a53016c7aa)) * **bigfile:** configurable average line length (default = 1000). Useful for minified files. Closes [#576](https://github.com/folke/snacks.nvim/issues/576). Closes [#372](https://github.com/folke/snacks.nvim/issues/372) ([7fa92a2](https://github.com/folke/snacks.nvim/commit/7fa92a24501fa85b567c130b4e026f9ca1efed17)) * **compat:** added `svim`, a compatibility layer for Neovim. Closes [#1321](https://github.com/folke/snacks.nvim/issues/1321) ([bc902f7](https://github.com/folke/snacks.nvim/commit/bc902f7032df305df7dc48104cfa4e37967b3bdf)) * **debug:** graduate proc debug to Snacks.debug.cmd ([eced303](https://github.com/folke/snacks.nvim/commit/eced3033ea29bf9154a5f2c5207bf9fc97368599)) * **explorer:** `opts.include` and `opts.exclude`. Closes [#1068](https://github.com/folke/snacks.nvim/issues/1068) ([ab1889c](https://github.com/folke/snacks.nvim/commit/ab1889c35b1845f487f31f0399ec0c8bd2c6e521)) * **explorer:** added `Snacks.explorer.reveal()` to reveal the current file in the tree. ([b4cf6bb](https://github.com/folke/snacks.nvim/commit/b4cf6bb48d882a873a6954bff2802d88e8e19e0d)) * **explorer:** added copy/paste (yank/paste) for files. Closes [#1195](https://github.com/folke/snacks.nvim/issues/1195) ([938aee4](https://github.com/folke/snacks.nvim/commit/938aee4a02119ad693a67c38b64a9b3232a72565)) * **explorer:** added ctrl+f to grep in the item's directory ([0454b21](https://github.com/folke/snacks.nvim/commit/0454b21165cb84d2f59a1daf6226de065c90d4f7)) * **explorer:** added ctrl+t to open a terminal in the item's directory ([81f9006](https://github.com/folke/snacks.nvim/commit/81f90062c50430c1bad9546fcb65c3e43a76be9b)) * **explorer:** added diagnostics file/directory status ([7f1b60d](https://github.com/folke/snacks.nvim/commit/7f1b60d5576345af5e7b990f3a9e4bca49cd3686)) * **explorer:** added quick nav with `[`, `]` with `d/w/e` for diagnostics ([d1d5585](https://github.com/folke/snacks.nvim/commit/d1d55850ecb4aac1396c314a159db1e90a34bd79)) * **explorer:** added support for live search ([82c4a50](https://github.com/folke/snacks.nvim/commit/82c4a50985c9bb9f4b1d598f10a30e1122a35212)) * **explorer:** allow disabling untracked git status. Closes [#983](https://github.com/folke/snacks.nvim/issues/983) ([a3b083b](https://github.com/folke/snacks.nvim/commit/a3b083b8443b1ae1299747fdac8da51c3160835b)) * **explorer:** deal with existing buffers when renaming / deleting files. Closes [#1315](https://github.com/folke/snacks.nvim/issues/1315) ([6614a2c](https://github.com/folke/snacks.nvim/commit/6614a2c84f1ad8528aa03caeb2574b274ee0c20b)) * **explorer:** different hl group for broken links ([1989921](https://github.com/folke/snacks.nvim/commit/1989921466e6b5234ae8f71add41b8defd55f732)) * **explorer:** disable fuzzy searches by default for explorer since it's too noisy and we can't sort on score due to tree view ([b07788f](https://github.com/folke/snacks.nvim/commit/b07788f14a28daa8d0b387c1258f8f348f47420f)) * **explorer:** file watching that works on all platforms ([8399465](https://github.com/folke/snacks.nvim/commit/8399465872c51fab54ad5d02eb315e258ec96ed1)) * **explorer:** focus on first file when searching in the explorer ([1d4bea4](https://github.com/folke/snacks.nvim/commit/1d4bea4a9ee8a5258c6ae085ac66dd5cc05a9749)) * **explorer:** git index watcher ([4c12475](https://github.com/folke/snacks.nvim/commit/4c12475e80528d8d48b9584d78d645e4a51c3298)) * **explorer:** show symlink target ([dfa79e0](https://github.com/folke/snacks.nvim/commit/dfa79e04436ebfdc83ba71c0048fc1636b4de5aa)) * **git_log:** add author filter ([#1091](https://github.com/folke/snacks.nvim/issues/1091)) ([8c11661](https://github.com/folke/snacks.nvim/commit/8c1166165b17376ed87f0dedfc480c7cb8e42b7c)) * **gitbrowse:** add support for git.sr.ht ([#1297](https://github.com/folke/snacks.nvim/issues/1297)) ([a3b47e5](https://github.com/folke/snacks.nvim/commit/a3b47e5202d924e6a6d4386bb5f94cc5857c4f8c)) * **gitbrowse:** open permalinks to files. Fixes [#320](https://github.com/folke/snacks.nvim/issues/320) ([#438](https://github.com/folke/snacks.nvim/issues/438)) ([2a06e4c](https://github.com/folke/snacks.nvim/commit/2a06e4ce9957dea555d38b4f52024ea9e2902d8e)) * **image.doc:** allow configuring the header for latex / typst inline in the document. Closes [#1303](https://github.com/folke/snacks.nvim/issues/1303) ([bde3add](https://github.com/folke/snacks.nvim/commit/bde3adddc7d787c5e93eb13af55b6e702d86418b)) * **image.doc:** allow setting `image.src` with `#set!`. Closes [#1276](https://github.com/folke/snacks.nvim/issues/1276) ([65f89e2](https://github.com/folke/snacks.nvim/commit/65f89e2d6f3790b0687f09ebe2811d953bd09e0c)) * **image.doc:** check for `image.ignore` in ts meta. See [#1276](https://github.com/folke/snacks.nvim/issues/1276) ([29c777a](https://github.com/folke/snacks.nvim/commit/29c777a0a0291a0caba17f2e9aeb86b6097fc83c)) * **image:** `conceal` option for inline rendering (disabled by default) ([684666f](https://github.com/folke/snacks.nvim/commit/684666f6432eae139b8ca6813b1a88679f8febc1)) * **image:** `Snacks.image.hover()` ([5f466be](https://github.com/folke/snacks.nvim/commit/5f466becd96ebcd0a52352f2d53206e0e86de35a)) * **image:** add support for `svelte` ([#1277](https://github.com/folke/snacks.nvim/issues/1277)) ([54ab77c](https://github.com/folke/snacks.nvim/commit/54ab77c5d2b2edefa29fc63de73c7b2b60d2651b)) * **image:** adde support for `Image` in jsx ([95878ad](https://github.com/folke/snacks.nvim/commit/95878ad32aaf310f465a004ef12e9edddf939287)) * **image:** added `opts.img_dirs` to configure the search path for resolving images. Closes [#1222](https://github.com/folke/snacks.nvim/issues/1222) ([ad0b88d](https://github.com/folke/snacks.nvim/commit/ad0b88dc0814dc760c7b6ed4efc7c2fa8d27ba76)) * **image:** added `Snacks.image.doc.at_cursor()`. See [#1108](https://github.com/folke/snacks.nvim/issues/1108) ([6348ccf](https://github.com/folke/snacks.nvim/commit/6348ccf1209739552f70800910c206637f3b2d2c)) * **image:** added fallback image rendering for wezterm. Closes [#1063](https://github.com/folke/snacks.nvim/issues/1063) ([9e6b1a6](https://github.com/folke/snacks.nvim/commit/9e6b1a62a87aa201dea13a755f6ac1ed680a20d1)) * **image:** added math rendering for typst. Closes [#1260](https://github.com/folke/snacks.nvim/issues/1260) ([e225823](https://github.com/folke/snacks.nvim/commit/e2258236a2fc770a87a81125232ca786ec7a1cf1)) * **image:** added proper support for tmux ([b1a3b66](https://github.com/folke/snacks.nvim/commit/b1a3b66fade926e9d211453275ddf1be19a847a5)) * **image:** added support for `.image` tags in neorg ([59bbe8d](https://github.com/folke/snacks.nvim/commit/59bbe8d90e91d4b4f63cc5fcb36c81bd8eeee850)) * **image:** added support for `typst`. Closes [#1235](https://github.com/folke/snacks.nvim/issues/1235) ([507c183](https://github.com/folke/snacks.nvim/commit/507c1836e3c5cfc5194bb6350ece1a1e0a1edf14)) * **image:** added support for a bunch of aditional languages ([a596f8a](https://github.com/folke/snacks.nvim/commit/a596f8a9ea0a058490bca8aca70f935cded18d22)) * **image:** added support for angle bracket urls. Closes [#1209](https://github.com/folke/snacks.nvim/issues/1209) ([14a1f32](https://github.com/folke/snacks.nvim/commit/14a1f32eafd50b5b6ae742052e6da04b1e0167b2)) * **image:** added support for math expressions in latex and markdown doc + images in latex. Closes [#1223](https://github.com/folke/snacks.nvim/issues/1223) ([1bca71a](https://github.com/folke/snacks.nvim/commit/1bca71a1332e3119ece0e62d668b75e6c98d948c)) * **image:** added support for mermaid diagrams in markdown ([f8e7942](https://github.com/folke/snacks.nvim/commit/f8e7942d6c83a1b1953320054102eb32bf536d98)) * **image:** added support for remote image viewing. Closes [#1156](https://github.com/folke/snacks.nvim/issues/1156) ([#1165](https://github.com/folke/snacks.nvim/issues/1165)) ([a5748ea](https://github.com/folke/snacks.nvim/commit/a5748ea8db2ac14fbc9c05376cc6d154d749f881)) * **image:** added support for tsx, jsx, vue and angular ([ab0ba5c](https://github.com/folke/snacks.nvim/commit/ab0ba5cb22d7bf62fa204f08426e601a20750f29)) * **image:** added support for wikilink style images. Closes [#1210](https://github.com/folke/snacks.nvim/issues/1210) ([3fda272](https://github.com/folke/snacks.nvim/commit/3fda27200d9af7ed181e9ee0a841c50137e9a5be)) * **image:** allow customizing font size for math expressions ([b052eb9](https://github.com/folke/snacks.nvim/commit/b052eb93728df6cc0c09b7ee42fec6d93477fc3e)) * **image:** allow customizing the default magick args for vector images ([2096fcd](https://github.com/folke/snacks.nvim/commit/2096fcdd739500ba8275d791b20d60f306c61b33)) * **image:** allow forcing image rendering even when the terminal support detection fails ([d17a6e4](https://github.com/folke/snacks.nvim/commit/d17a6e4af888c43ba3faddc30231aa2aebc699d4)) * **image:** apply image window options ([73366fa](https://github.com/folke/snacks.nvim/commit/73366fa17018d7fd4d115cec2466b2d8e7233341)) * **image:** better detection of image capabilities of the terminal/mux environment ([1795d4b](https://github.com/folke/snacks.nvim/commit/1795d4b1ec767886300faa4965539fe67318a06a)) * **image:** better error handling + option to disable error notifications ([1adfd29](https://github.com/folke/snacks.nvim/commit/1adfd29af3d1b4db2ba46f7a292410a2f9105fd6)) * **image:** better health checks ([d389c5d](https://github.com/folke/snacks.nvim/commit/d389c5df14d83b6aff9eb6734906888780e8ca71)) * **image:** check for `magick` in health check ([1284835](https://github.com/folke/snacks.nvim/commit/12848356c4fd672476f47d9dea9999784c140c05)) * **image:** custom `src` resolve function ([af21ea3](https://github.com/folke/snacks.nvim/commit/af21ea3ccf6c11246cfbb1bef061caa4f387f1f0)) * **image:** enabled pdf previews ([39bf513](https://github.com/folke/snacks.nvim/commit/39bf5131c4f8cd79c1779a5cb80e526cf9e4fffe)) * **image:** floats in markdown. Closes [#1151](https://github.com/folke/snacks.nvim/issues/1151) ([4e10e31](https://github.com/folke/snacks.nvim/commit/4e10e31398e6921ac19371099f06640b8753bc8a)) * **image:** health checks ([0d5b106](https://github.com/folke/snacks.nvim/commit/0d5b106d4eae756cd612fdabde36aa795a444546)) * **image:** images are now properly scaled based on device DPI and image DPI. Closing [#1257](https://github.com/folke/snacks.nvim/issues/1257) ([004050c](https://github.com/folke/snacks.nvim/commit/004050c43533ac38a224649268e913c6fb0c4caa)) * **image:** make manual hover work correctly ([942cb92](https://github.com/folke/snacks.nvim/commit/942cb9291e096d8604d515499e295ec67578b71a)) * **image:** make math packages configurable. Closes [#1295](https://github.com/folke/snacks.nvim/issues/1295) ([e27ba72](https://github.com/folke/snacks.nvim/commit/e27ba726b15e71eca700141c2030ac858bc8025c)) * **image:** markdown inline image preview. `opts.image` must be enabled and terminal needs support ([001f300](https://github.com/folke/snacks.nvim/commit/001f3002cabb9e23d8f1b23e0567db2d41c098a6)) * **image:** refactor + css/html + beter image fitting ([e35d6cd](https://github.com/folke/snacks.nvim/commit/e35d6cd4ba87e8ff71d6ebe52b7be53408e13538)) * **image:** refactor of treesitter queries to support inline image data ([0bf0c62](https://github.com/folke/snacks.nvim/commit/0bf0c6223d71ced4e5dc7ab7357b0a36a91a0a67)) * **images:** added support for org-mode. Closes [#1276](https://github.com/folke/snacks.nvim/issues/1276) ([10387af](https://github.com/folke/snacks.nvim/commit/10387af009e51678788506527b46240b2139fd7f)) * **image:** show progress indicator when converting image files ([b65178b](https://github.com/folke/snacks.nvim/commit/b65178b470385f0a81256d54c9d80f153cd14efd)) * **image:** try resolving paths relative to the document and to the cwd. See [#1203](https://github.com/folke/snacks.nvim/issues/1203) ([668cbbb](https://github.com/folke/snacks.nvim/commit/668cbbba473d757144e24188b458e57dc0e98943)) * **image:** url_decode strings ([d41704f](https://github.com/folke/snacks.nvim/commit/d41704f3daae823513c90adb913976bfabc36387)) * **image:** use `tectonic` when available ([8d073cc](https://github.com/folke/snacks.nvim/commit/8d073ccc0ca984f844cc2a8f8506f23f3fcea56a)) * **image:** use kitty's unicode placeholder images ([7d655fe](https://github.com/folke/snacks.nvim/commit/7d655fe09d2c705ff5707902f4ed925a62a61d3b)) * **image:** use search dirs to resolve file from both cwd and dirname of file. Closes [#1305](https://github.com/folke/snacks.nvim/issues/1305) ([bf01460](https://github.com/folke/snacks.nvim/commit/bf01460e6d82b720fb50664d372c1daf5ade249d)) * **image:** utility function to get a png dimensions from the file header ([a6d866a](https://github.com/folke/snacks.nvim/commit/a6d866ab72e5cad7840d69a7354cc67e2699f46e)) * **matcher:** call on_match after setting score ([23ce529](https://github.com/folke/snacks.nvim/commit/23ce529fb663337f9dc17ca08aa601b172469031)) * **picker.actions:** `cmd` action now always allows to edit the command. Closes [#1033](https://github.com/folke/snacks.nvim/issues/1033) ([a177885](https://github.com/folke/snacks.nvim/commit/a17788539a5e66784535d0c973bdc08728f16c46)) * **picker.actions:** option to disable notify for yank action. Closes [#1117](https://github.com/folke/snacks.nvim/issues/1117) ([f6a807d](https://github.com/folke/snacks.nvim/commit/f6a807da6d4e6ab591f85592a472bbb5bc6583f7)) * **picker.config:** better source field spec ([6c58b67](https://github.com/folke/snacks.nvim/commit/6c58b67890bbd2076a7f5b69f57ab666cb9b7410)) * **picker.db:** allow configuring the sqlite3 lib path. Closes [#1025](https://github.com/folke/snacks.nvim/issues/1025) ([b990044](https://github.com/folke/snacks.nvim/commit/b9900444d2ea494bba8857e5224059002ee8c465)) * **picker.files:** added `ft` option to filter by extension(s) ([12a7ea2](https://github.com/folke/snacks.nvim/commit/12a7ea28b97827575a1768d6013dd3c7bedd5ebb)) * **picker.format:** `opts.formatters.file.use_git_status_hl` defaults to `true` and adds git status hl to filename ([243eeca](https://github.com/folke/snacks.nvim/commit/243eecaca5f465602a9ba68e5c0fa375b90a13fb)) * **picker.git_diff:** use the `diff` previewer for `git_diff` so that `delta` can be used. See [#1302](https://github.com/folke/snacks.nvim/issues/1302) ([92786c5](https://github.com/folke/snacks.nvim/commit/92786c5b03ae4772521050acabcb619283eeb94a)) * **picker.git:** add confirmation before deleting a git branch ([#951](https://github.com/folke/snacks.nvim/issues/951)) ([337a3ae](https://github.com/folke/snacks.nvim/commit/337a3ae7eebb95020596f15a349a85d2f6be31a4)) * **picker.git:** add create and delete branch to git_branches ([#909](https://github.com/folke/snacks.nvim/issues/909)) ([8676c40](https://github.com/folke/snacks.nvim/commit/8676c409e148e28eff93c114aca0c1bf3d42281a)) * **picker.git:** allow passing extra args to git grep. Closes [#1184](https://github.com/folke/snacks.nvim/issues/1184) ([7122a03](https://github.com/folke/snacks.nvim/commit/7122a03fdf0b7bb9a5c6645b0e86f9e3a9f9290b)) * **picker.git:** allow passing extra args to other git pickers ([#1205](https://github.com/folke/snacks.nvim/issues/1205)) ([4d46574](https://github.com/folke/snacks.nvim/commit/4d46574b247d72bf1a602cdda2ddd8da39854234)) * **picker.lazy:** don't use `grep`. Parse spec files manually. Closes [#972](https://github.com/folke/snacks.nvim/issues/972) ([0928007](https://github.com/folke/snacks.nvim/commit/09280078e8339f018be5249fe0e1d7b9d32db7f7)) * **picker.lsp:** added original symbol to item.item. Closes [#1171](https://github.com/folke/snacks.nvim/issues/1171) ([45a6f8d](https://github.com/folke/snacks.nvim/commit/45a6f8d1ee0c323246413d1e1d43b0f0c9da18a2)) * **picker.lsp:** use existing buffers for preview when opened ([d4e6353](https://github.com/folke/snacks.nvim/commit/d4e63531c9fba63ded6fb470a5d53c98af110478)) * **picker.preview:** allow confguring `preview = {main = true, enabled = false}` ([1839c65](https://github.com/folke/snacks.nvim/commit/1839c65f6784bedb7ae96a84ee741fa5c0023226)) * **picker.preview:** allow passing additional args to the git preview command ([910437f](https://github.com/folke/snacks.nvim/commit/910437f1451ccaaa495aa1eca99e0a73fc798d40)) * **picker.proc:** added proc debug mode ([d870f16](https://github.com/folke/snacks.nvim/commit/d870f164534d1853fd8c599d7933cc5324272a09)) * **picker.undo:** `ctrl+y` to yank added lines, `ctrl+shift+y` to yank deleted lines ([3baf95d](https://github.com/folke/snacks.nvim/commit/3baf95d3a1005105b57ce53644ff6224ee3afa1c)) * **picker.undo:** added ctrl+y to yank added lines from undo ([811a24c](https://github.com/folke/snacks.nvim/commit/811a24cc16a8e9b7ec947c95b73e1fe05e4692d1)) * **picker.util:** lua globber ([97dcd9c](https://github.com/folke/snacks.nvim/commit/97dcd9c168c667538a4c6cc1384c4981a37afcad)) * **picker.util:** utility function to get all bins on the PATH ([5d42c7e](https://github.com/folke/snacks.nvim/commit/5d42c7e5e480bde04fd9506b3df64b579446c4f9)) * **picker:** `opts.focus` can be used to set default focus window. `opts.enter` if picker should be focused on enter. Closes [#1162](https://github.com/folke/snacks.nvim/issues/1162) ([e8de28b](https://github.com/folke/snacks.nvim/commit/e8de28b56ec85ad45cdb3c303c5ee5da0e070baf)) * **picker:** add LSP symbol range to result item ([#1123](https://github.com/folke/snacks.nvim/issues/1123)) ([c0481ab](https://github.com/folke/snacks.nvim/commit/c0481ab0b69c6111bfc5077bd1550acbb480f05d)) * **picker:** added `c-q` to list ([6d0d2dc](https://github.com/folke/snacks.nvim/commit/6d0d2dc2a7e07de9704a172bd5295f4920eb965f)) * **picker:** added `git_grep` picker. Closes [#986](https://github.com/folke/snacks.nvim/issues/986) ([2dc9016](https://github.com/folke/snacks.nvim/commit/2dc901634b250059cc9b7129bdeeedd24520b86c)) * **picker:** added `lsp_config` source ([0d4aa98](https://github.com/folke/snacks.nvim/commit/0d4aa98cea0de6144853d820e52e6e35d0f0c609)) * **picker:** added treesitter symbols picker ([a6beb0f](https://github.com/folke/snacks.nvim/commit/a6beb0f280d3f43513998882faf199acf3818ddf)) * **picker:** allow complex titles ([#1112](https://github.com/folke/snacks.nvim/issues/1112)) ([f200b3f](https://github.com/folke/snacks.nvim/commit/f200b3f6c8f84147e1a80f70b8f1714645c59af6)) * **picker:** allow configuring file icon width. Closes [#981](https://github.com/folke/snacks.nvim/issues/981) ([52c1086](https://github.com/folke/snacks.nvim/commit/52c1086ecdf410dfec3317144d46de7c6f86c1ad)) * **picker:** allow overriding default file/dir/dir_open icons. Closes [#1199](https://github.com/folke/snacks.nvim/issues/1199) ([41c4391](https://github.com/folke/snacks.nvim/commit/41c4391b72ff5ef3dfa8216fc608bbe02bbd4d1c)) * **picker:** default `c-t` keymap to open in tab ([ffc6fe3](https://github.com/folke/snacks.nvim/commit/ffc6fe3965cb176c2b3e2bdb0aee4478e4dc2b94)) * **picker:** each window can now be `toggled` (also input), `hidden` and have `auto_hide` ([01efab2](https://github.com/folke/snacks.nvim/commit/01efab2ddb75d2077229231201c5a69ab2df3ad8)) * **picker:** get filetype from modeline when needed. Closes [#987](https://github.com/folke/snacks.nvim/issues/987) ([5af04ab](https://github.com/folke/snacks.nvim/commit/5af04ab6672ae38bf7d72427e75f925615f93904)) * **picker:** image previewer using kitty graphics protocol ([2b0aa93](https://github.com/folke/snacks.nvim/commit/2b0aa93efc9aa662e0cb9446cc4639f3be1a9d1e)) * **picker:** new native diff mode (disabled by default). Can be used to show delta diffs for undo. Closes [#1288](https://github.com/folke/snacks.nvim/issues/1288) ([d6a38ac](https://github.com/folke/snacks.nvim/commit/d6a38acbf5765eeb5ca2558bcb0d1ae1428dd2ca)) * **picker:** pin picker as a split to left/bottom/top/right with `ctrl+z+(hjkl)` ([27cba53](https://github.com/folke/snacks.nvim/commit/27cba535a6763cbca3f3162c5c4bb48c6f382005)) * **picker:** renamed `native` -> `builtin` + fixed diff mode used for undo. Closes [#1302](https://github.com/folke/snacks.nvim/issues/1302) ([bd6a62a](https://github.com/folke/snacks.nvim/commit/bd6a62af12ca5e8cab88b94912e65bff26c9feba)) * **scope:** allow injected languages to be parsed by treesitter ([#823](https://github.com/folke/snacks.nvim/issues/823)) ([aba21dd](https://github.com/folke/snacks.nvim/commit/aba21ddc712b12db8469680dd7f2080063cb6d5c)) * **scroll:** big rework to make scroll play nice with virtual lines ([e71955a](https://github.com/folke/snacks.nvim/commit/e71955a941300cd81bf6d7ab36d1352b62d6f568)) * **scroll:** scroll improvements. Closes [#1024](https://github.com/folke/snacks.nvim/issues/1024) ([73d2f0f](https://github.com/folke/snacks.nvim/commit/73d2f0f40c702acaf7a1a3e833fc5460cb552578)) * **statuscolumn:** added mouse click handler to open/close folds. Closes [#968](https://github.com/folke/snacks.nvim/issues/968) ([98a7b64](https://github.com/folke/snacks.nvim/commit/98a7b647c9e245ef02d57d566bf8461c2f7beb56)) * **terminal:** added `Snacks.terminal.list()`. Closes [#421](https://github.com/folke/snacks.nvim/issues/421). Closes [#759](https://github.com/folke/snacks.nvim/issues/759) ([73c4b62](https://github.com/folke/snacks.nvim/commit/73c4b628963004760ccced0192d1c2633c9e3657)) * **terminal:** added `start_insert` ([64129e4](https://github.com/folke/snacks.nvim/commit/64129e4c3c5b247c61b1f46bc0faaa1e69e7eef8)) * **terminal:** auto_close and auto_insert. Closes [#965](https://github.com/folke/snacks.nvim/issues/965) ([bb76cae](https://github.com/folke/snacks.nvim/commit/bb76cae87e81a871435570b91c8c6f6e27eb9955)) * **terminal:** don't use deprecated `vim.fn.termopen` on Neovim >= 0.10 ([37f6665](https://github.com/folke/snacks.nvim/commit/37f6665c488d90bf50b99cfe0b0fab40f990c497)) * test ([520ed85](https://github.com/folke/snacks.nvim/commit/520ed85169c873a8492077520ff37a5f0233c67d)) * **toggle:** allow user to add custom which-key description ([#1121](https://github.com/folke/snacks.nvim/issues/1121)) ([369732e](https://github.com/folke/snacks.nvim/commit/369732e65e0077e51487547131045526ccbdad1b)) * **treesitter:** add `tree` boolean to toggle on/off tree symbols ([#1105](https://github.com/folke/snacks.nvim/issues/1105)) ([c61f9eb](https://github.com/folke/snacks.nvim/commit/c61f9eb28695b9f96682d6dbf67072947dfa2737)) * **util:** `Snacks.util.winhl` helper to deal with `vim.wo.winhighlight` ([4c1d7b4](https://github.com/folke/snacks.nvim/commit/4c1d7b4720218122885877877e7883cc491133ed)) * **util:** base64 shim for Neovim < 0.10 ([96f1227](https://github.com/folke/snacks.nvim/commit/96f12274a49bb2e6d0d558e652c728d27d4c3ff8)) * **util:** Snacks.util.color can now get the color from a list of hl groups ([a33f65d](https://github.com/folke/snacks.nvim/commit/a33f65d936a85efa9aaee9e44bcd70069134a816)) * **util:** util.spawn ([a76fe13](https://github.com/folke/snacks.nvim/commit/a76fe13148a899274484972a8705052bef4baa93)) * **words:** add `filter` function for user to disable specific filetypes ([#1296](https://github.com/folke/snacks.nvim/issues/1296)) ([d62e752](https://github.com/folke/snacks.nvim/commit/d62e7527a5e9608ab0033bc63a329baf8757ea6d)) ### Bug Fixes * **all:** better support for opening windows / pickers / ... on multiple tab pages. Closes [#1043](https://github.com/folke/snacks.nvim/issues/1043) ([8272c1c](https://github.com/folke/snacks.nvim/commit/8272c1c66f43390294debb24759c32627653aedb)) * **bigfile:** check that passed path is the one from the buffer ([8deea64](https://github.com/folke/snacks.nvim/commit/8deea64dba3b9b8f57e52bb6b0133263f6ff171f)) * **buffers:** use `"` mark for full buffer position when set. Closes [#1160](https://github.com/folke/snacks.nvim/issues/1160) ([7d350bc](https://github.com/folke/snacks.nvim/commit/7d350bc0c7e897ad0d7bd7fc9a470dabecab32ca)) * **compat:** correct Neovim 0.11 check ([448a55a](https://github.com/folke/snacks.nvim/commit/448a55a0e3c437bacc945c4ea98a6342ccb2b769)) * **dashboard:** allow dashboard to be the main editor window ([e3ead3c](https://github.com/folke/snacks.nvim/commit/e3ead3c648b3b6c8af0557c6412ae0307cc92018)) * **dashboard:** dashboard can be a main editor window ([f36c70a](https://github.com/folke/snacks.nvim/commit/f36c70a912c2893b10336b4645d3447264813a34)) * **dashboard:** use `Snacks.util.icon` for icons. Closes [#1192](https://github.com/folke/snacks.nvim/issues/1192) ([c2f06da](https://github.com/folke/snacks.nvim/commit/c2f06daeca6c3304d1e94225323dbe8c2f7f797d)) * **debug:** better args handling for debugging cmds ([48a3fed](https://github.com/folke/snacks.nvim/commit/48a3fed3c51390650d134bc5d76d15ace8d614ea)) * **explorer.git:** always at `.git` directory to ignored ([f7a35b8](https://github.com/folke/snacks.nvim/commit/f7a35b8214f393e2412adc0c8f2fe85d956c4b02)) * **explorer.git:** better git status watching ([09349ec](https://github.com/folke/snacks.nvim/commit/09349ecd44040666db9d4835994a378a9ff53e8c)) * **explorer.git:** dont reset cursor when git status is done updating ([bc87992](https://github.com/folke/snacks.nvim/commit/bc87992e712c29ef8e826f3550f9b8e3f1a9308d)) * **explorer.git:** vim.schedule git updates ([3aad761](https://github.com/folke/snacks.nvim/commit/3aad7616209951320d54f83dd7df35d5578ea61f)) * **explorer.tree:** fix linux ([6f5399b](https://github.com/folke/snacks.nvim/commit/6f5399b47c55f916fcc3a82dcc71cce0eb5d7c92)) * **explorer.tree:** symlink directories ([e5f1e91](https://github.com/folke/snacks.nvim/commit/e5f1e91249b468ff3a7d14a8650074c27f1fdb30)) * **explorer.watch:** pcall watcher, since it can give errors on windows ([af96818](https://github.com/folke/snacks.nvim/commit/af968181af6ce6a988765fe51558b2caefdcf863)) * **explorer:** always refresh state when opening the picker since changes might have happened that were not monitored ([c61114f](https://github.com/folke/snacks.nvim/commit/c61114fb32910863a543a4a7a1f63e9915983d26)) * **explorer:** call original `on_close`. Closes [#971](https://github.com/folke/snacks.nvim/issues/971) ([a0bee9f](https://github.com/folke/snacks.nvim/commit/a0bee9f662d4e22c6533e6544b4daedecd2aacc0)) * **explorer:** change grep in item dir keymap to leader-/. Closes [#1000](https://github.com/folke/snacks.nvim/issues/1000) ([9dfa276](https://github.com/folke/snacks.nvim/commit/9dfa276ea424a091f5d5bdc008aff127850441b2)) * **explorer:** check that picker is still open ([50fa1be](https://github.com/folke/snacks.nvim/commit/50fa1be38ee8366d79e1fa58b38abf31d3955033)) * **explorer:** disable follow for explorer search by default. No longer needed. Link directories may show as files then though, but that's not an issue. See [#960](https://github.com/folke/snacks.nvim/issues/960) ([b9a17d8](https://github.com/folke/snacks.nvim/commit/b9a17d82a726dc6cfd9a0b4f8566178708073808)) * **explorer:** dont focus first file when not searching ([3fd437c](https://github.com/folke/snacks.nvim/commit/3fd437ccd38d79b876154097149d130cdb01e653)) * **explorer:** dont process git when picker closed ([c255d9c](https://github.com/folke/snacks.nvim/commit/c255d9c6a02f070f0048c5eaa40921f71e9f2acb)) * **explorer:** last status for indent guides taking hidden / ignored files into account ([94bd2ef](https://github.com/folke/snacks.nvim/commit/94bd2eff74acd7faa78760bf8a55d9c269e99190)) * **explorer:** strip cwd from search text for explorer items ([38f392a](https://github.com/folke/snacks.nvim/commit/38f392a8ad75ced790f89c8ef43a91f98a2bb6e3)) * **explorer:** windows ([b560054](https://github.com/folke/snacks.nvim/commit/b56005466952b759a2f610e8b3c8263444402d76)) * **exporer.tree:** and now hopefully on windows ([ef9b12d](https://github.com/folke/snacks.nvim/commit/ef9b12d68010a931c76533925a8c730123241635)) * **gitbrowse:** add support for GitHub Enterprise Cloud repo url ([#1089](https://github.com/folke/snacks.nvim/issues/1089)) ([97fd57e](https://github.com/folke/snacks.nvim/commit/97fd57e8a0555023d2968354ca5f2b62de988103)) * **gitbrowse:** cwd for permalinks ([#1038](https://github.com/folke/snacks.nvim/issues/1038)) ([0bf47dc](https://github.com/folke/snacks.nvim/commit/0bf47dc319e4d6848366aff5c1a42cd08672d3e3)) * **gitbrowse:** previous logic always overwrote 'commit' ([#1127](https://github.com/folke/snacks.nvim/issues/1127)) ([2f3f080](https://github.com/folke/snacks.nvim/commit/2f3f080ede4d5f75c0b02d1698156648832cb974)) * **git:** use nul char as separator for git status ([8e0dfd2](https://github.com/folke/snacks.nvim/commit/8e0dfd285665bedf67441efe11c9c1318781826f)) * **health:** skip dot dirs... Closes [#1293](https://github.com/folke/snacks.nvim/issues/1293) ([aaed4a9](https://github.com/folke/snacks.nvim/commit/aaed4a94111ddfd9d23cdecb01e4ae53030c2c3e)) * **image.doc:** crop inline typst equations properly ([#1320](https://github.com/folke/snacks.nvim/issues/1320)) ([4f8b9eb](https://github.com/folke/snacks.nvim/commit/4f8b9ebf717b8acf41be02b0bd5a6d75f6038ea7)) * **image.doc:** fixed at_cursor. Closes [#1258](https://github.com/folke/snacks.nvim/issues/1258) ([76f5ee4](https://github.com/folke/snacks.nvim/commit/76f5ee4a1bd2566fc1460a1b11aa6a0bc36d2f5d)) * **image.health:** add check for ghost-script to render pdfs. Closes [#1248](https://github.com/folke/snacks.nvim/issues/1248) ([2b52d89](https://github.com/folke/snacks.nvim/commit/2b52d89508a448a1ca0c500afbd325e77023afc1)) * **image.health:** allow `convert` if `magick` not available ([4589e25](https://github.com/folke/snacks.nvim/commit/4589e2575894090a1e62aae11cf17856f5b84ea5)) * **image.hover:** close when needed. Closes [#1229](https://github.com/folke/snacks.nvim/issues/1229) ([1f9ba12](https://github.com/folke/snacks.nvim/commit/1f9ba127554bd3bd9780bfb925adfdf1e0ee73f9)) * **image.latex:** include doc packages for math rendering. Closes [#1262](https://github.com/folke/snacks.nvim/issues/1262) ([2ee6488](https://github.com/folke/snacks.nvim/commit/2ee64887c2be80c6b7b8fac4bb0617c827fde0d0)) * **image.latex:** inline math formulas. Closes [#1246](https://github.com/folke/snacks.nvim/issues/1246) ([9e422e1](https://github.com/folke/snacks.nvim/commit/9e422e12876002cba59ac4825bbeea89996e0196)) * **image.markdown:** fix image treesitter query. Closes [#1300](https://github.com/folke/snacks.nvim/issues/1300) ([830ac62](https://github.com/folke/snacks.nvim/commit/830ac62815e70f3db35ed7a295710c836578c6e3)) * **image.terminal:** set passthrough=all instead of on for tmux. Closes [#1249](https://github.com/folke/snacks.nvim/issues/1249) ([efcc25d](https://github.com/folke/snacks.nvim/commit/efcc25dcfa3ccaa7aa0a11b6cf065f8b8d32e485)) * **image:** added support for relative paths. Closes [#1143](https://github.com/folke/snacks.nvim/issues/1143) ([2ef6375](https://github.com/folke/snacks.nvim/commit/2ef63754b9c2a835a1d464766a22ba1bd4b16ea3)) * **image:** better cell size calculation for non-HDPI displays ([e146a66](https://github.com/folke/snacks.nvim/commit/e146a66cb767c60c6e84b2ab9a4522abdb6a5cc0)) * **image:** better image position caluclation. Closes [#1268](https://github.com/folke/snacks.nvim/issues/1268) ([5c0607e](https://github.com/folke/snacks.nvim/commit/5c0607e31a76317bc34f840fe8cc283b6a8d00c5)) * **image:** create cache dir ([f8c4e03](https://github.com/folke/snacks.nvim/commit/f8c4e03d025de17fb2302b3253bc72b8c0693c24)) * **image:** delay sending first image, to make ghostty happy. Closes [#1333](https://github.com/folke/snacks.nvim/issues/1333) ([9aa8cbb](https://github.com/folke/snacks.nvim/commit/9aa8cbb8031750fce640d476df67d88f60fd7c4e)) * **image:** delete terminal image on exit, just to be sure ([317bfac](https://github.com/folke/snacks.nvim/commit/317bfaca65dc53aa0a74885cf0c48c64fdfc30a9)) * **image:** do not attach to invalid buffers ([#1238](https://github.com/folke/snacks.nvim/issues/1238)) ([9a5e4de](https://github.com/folke/snacks.nvim/commit/9a5e4deaec451e77464759b4d78e7207caee14a7)) * **image:** don't fallback to `convert` on windows, since that is a system tool ([c1a1984](https://github.com/folke/snacks.nvim/commit/c1a1984fdb537017b6239d5592d1f7d25a77caa9)) * **image:** failed state ([5a37d83](https://github.com/folke/snacks.nvim/commit/5a37d838973f216822448a9dae935724754acbf0)) * **image:** fix disappearing images when changing colorscheme ([44e2f8e](https://github.com/folke/snacks.nvim/commit/44e2f8e573a8e4971badb8c7d3c1181fed7d5de3)) * **image:** fixed gsub for angle brackets. Closes [#1301](https://github.com/folke/snacks.nvim/issues/1301) ([beaa1c2](https://github.com/folke/snacks.nvim/commit/beaa1c2efcc598a9752b4536ef95606a10835aaa)) * **image:** fixup ([de3cba5](https://github.com/folke/snacks.nvim/commit/de3cba5158509b82e2f0ff9fc9101effccc1a863)) * **image:** handle file uppercase file extensions. Closes [#1202](https://github.com/folke/snacks.nvim/issues/1202) ([356f621](https://github.com/folke/snacks.nvim/commit/356f6216b90b85878af2c0134c8f3955349cae18)) * **image:** handle inline images at the same TS node, but that changed url. See [#1203](https://github.com/folke/snacks.nvim/issues/1203) ([86e3ddf](https://github.com/folke/snacks.nvim/commit/86e3ddf2e4f7a08d8172d2b2383eb51b4c0bbb5f)) * **image:** hide progress when finished loading in for wezterm ([526896a](https://github.com/folke/snacks.nvim/commit/526896ad3e736786c4520efce6f97c831677ca69)) * **image:** let text conversion continue on errors. See [#1303](https://github.com/folke/snacks.nvim/issues/1303) ([6d1cda4](https://github.com/folke/snacks.nvim/commit/6d1cda4a6df71f146daf171fbd6d95e53123a61e)) * **image:** mermaid theme. Closes [#1282](https://github.com/folke/snacks.nvim/issues/1282) ([8117fb4](https://github.com/folke/snacks.nvim/commit/8117fb4cbbaec9fbcfe7fe0b6c3a9c933d6c27ee)) * **image:** move assertion for src/content. See [#1276](https://github.com/folke/snacks.nvim/issues/1276) ([31e21cc](https://github.com/folke/snacks.nvim/commit/31e21ccef857e600a72fc059ee660fd134595f9d)) * **image:** only load image when the file exists. Closes [#1143](https://github.com/folke/snacks.nvim/issues/1143) ([298499d](https://github.com/folke/snacks.nvim/commit/298499dcb943ab49946e648ab79bf14868480560)) * **image:** only setup tmux pass-through on supported terminals. Fixes [#1054](https://github.com/folke/snacks.nvim/issues/1054) ([78e692c](https://github.com/folke/snacks.nvim/commit/78e692cd07b752e29e021635a70f353024d9c9b4)) * **image:** prevent image id collisions by interleaving the nvim pid hash in the image id ([31788ba](https://github.com/folke/snacks.nvim/commit/31788ba74e12081e79165f4447f6ff0f7e33b696)) * **image:** relax check for wezterm. Closes [#1076](https://github.com/folke/snacks.nvim/issues/1076) ([8d5ae25](https://github.com/folke/snacks.nvim/commit/8d5ae25806f88ec6c79f094eb7f3cc3413584309)) * **image:** remove `wezterm` from supported terminals, since they don't support unicode placeholders. Closes [#1063](https://github.com/folke/snacks.nvim/issues/1063) ([345260f](https://github.com/folke/snacks.nvim/commit/345260f39f70d63625e63d3c6771b2a8224f45c9)) * **image:** remove debug ([13863ea](https://github.com/folke/snacks.nvim/commit/13863ea25d169ef35f939b836c5edf8116042b89)) * **image:** remove ft check, since we use lang already. Closes [#1177](https://github.com/folke/snacks.nvim/issues/1177) ([4bcd26a](https://github.com/folke/snacks.nvim/commit/4bcd26aca8150a70b40b62673731046f85a205ff)) * **image:** remove some default latex packages ([f45dd6c](https://github.com/folke/snacks.nvim/commit/f45dd6c44c1319a2660b3b390d8d39ec5f2d73dc)) * **image:** remove test ([462578e](https://github.com/folke/snacks.nvim/commit/462578edb8fb13f0c158d2c9ac9479109dfdab31)) * **image:** return converted filename instead of original src. Closes [#1213](https://github.com/folke/snacks.nvim/issues/1213) ([118eab0](https://github.com/folke/snacks.nvim/commit/118eab0cfd4093bcd2b120378e5ea0685a333950)) * **image:** show full size when not showing image inline ([d7c8fd9](https://github.com/folke/snacks.nvim/commit/d7c8fd9a482a98e44442071d1d02342ebb256be4)) * **image:** support Neovim < 0.10 ([c067ffe](https://github.com/folke/snacks.nvim/commit/c067ffe86ce931702f82d2a1bd4c0ea98c3bfdd0)) * **image:** wrong return when trying second command ([74c4298](https://github.com/folke/snacks.nvim/commit/74c42985be207f6c9ed164bd1fae6be81fecd5bb)) * **input:** add missing hl group for input title ([#1164](https://github.com/folke/snacks.nvim/issues/1164)) ([7014b91](https://github.com/folke/snacks.nvim/commit/7014b91b927a384a7219629cf53e19573c832c23)) * **layout:** deep merge instead of shallow merge for window options. Closes [#1166](https://github.com/folke/snacks.nvim/issues/1166) ([27256cf](https://github.com/folke/snacks.nvim/commit/27256cf989475e3305713341930a7709e3670eac)) * **layout:** just hide any layouts below a backdrop. easier and looks better. ([0dab071](https://github.com/folke/snacks.nvim/commit/0dab071dbabaea642f42b2a13d5fc8f00a391963)) * **layout:** make sure width/height are at least `1`. Closes [#1090](https://github.com/folke/snacks.nvim/issues/1090) ([c554097](https://github.com/folke/snacks.nvim/commit/c5540974fa7a55720f0a1e55d0afe948c8f8fe0a)) * **layout:** take winbar into account for split layouts. Closes [#996](https://github.com/folke/snacks.nvim/issues/996) ([e4e5040](https://github.com/folke/snacks.nvim/commit/e4e5040d9b9b58ac3bc44a6709bbb5e55e58adea)) * **layout:** zindex weirdness on stable. Closes [#1180](https://github.com/folke/snacks.nvim/issues/1180) ([72ffb3d](https://github.com/folke/snacks.nvim/commit/72ffb3d1a2812671bb3487e490a3b1dd380bc234)) * **notifier:** keep notif when current buf is notif buf ([a13c891](https://github.com/folke/snacks.nvim/commit/a13c891a59ec0e67a75824fe1505a9e57fbfca0f)) * **picker.actions:** better set cmdline. Closes [#1291](https://github.com/folke/snacks.nvim/issues/1291) ([570c035](https://github.com/folke/snacks.nvim/commit/570c035b9417aaa2f02cadf00c83f5b968a70b6c)) * **picker.actions:** check that plugin exists before loading it in help. Closes [#1134](https://github.com/folke/snacks.nvim/issues/1134) ([e326de9](https://github.com/folke/snacks.nvim/commit/e326de9e0ce4f97c974359568617dc69a0cd6d67)) * **picker.actions:** don't delete empty buffer when its in another tabpage. Closes [#1005](https://github.com/folke/snacks.nvim/issues/1005) ([1491b54](https://github.com/folke/snacks.nvim/commit/1491b543ef1d8a0eb29a6ebc35db4fb808dcb47f)) * **picker.actions:** don't reuse_win in floating windows (like the picker preview) ([4b9ea98](https://github.com/folke/snacks.nvim/commit/4b9ea98007cddc0af80fa0479a86a1bf2e880b66)) * **picker.actions:** fix qflist position ([#911](https://github.com/folke/snacks.nvim/issues/911)) ([6d3c135](https://github.com/folke/snacks.nvim/commit/6d3c1352358e0e2980f9f323b6ca8a62415963bc)) * **picker.actions:** keymap confirm. Closes [#1252](https://github.com/folke/snacks.nvim/issues/1252) ([a9a84dd](https://github.com/folke/snacks.nvim/commit/a9a84dde2e474eb9ee57630ab2f6418bfe1b380f)) * **picker.actions:** reverse prev/next on select with a reversed list layout. Closes [#1124](https://github.com/folke/snacks.nvim/issues/1124) ([eae55e7](https://github.com/folke/snacks.nvim/commit/eae55e7ca3b7c33882884a439e12d26200403a66)) * **picker.actions:** use `vim.v.register` instead of `+` as default. ([9ab6637](https://github.com/folke/snacks.nvim/commit/9ab6637df061fb03c6c5ba937dee5bfef92a6633)) * **picker.buffers:** remove `dd` to delete buffer from input keymaps. Closes [#1193](https://github.com/folke/snacks.nvim/issues/1193) ([f311d1c](https://github.com/folke/snacks.nvim/commit/f311d1c83a25fbce63e322c72ad6c99a02f84a2f)) * **picker.colorscheme:** use wildignore. Closes [#969](https://github.com/folke/snacks.nvim/issues/969) ([ba8badf](https://github.com/folke/snacks.nvim/commit/ba8badfe74783e97934c21a69e0c44883092587f)) * **picker.config:** use `<c-w>HJKL` to move float to far left/bottom/top/right. Only in normal mode. ([34dd83c](https://github.com/folke/snacks.nvim/commit/34dd83c2572658c3f6140e8a8acc1bcfbf7cf32b)) * **picker.explorer:** ensure diagnostics can be disabled ([#1145](https://github.com/folke/snacks.nvim/issues/1145)) ([885c140](https://github.com/folke/snacks.nvim/commit/885c1409e898b2f6f806cdb31f6ca9d7d84b4ff3)) * **picker.git:** account for deleted files in git diff. Closes [#1001](https://github.com/folke/snacks.nvim/issues/1001) ([e9e2e69](https://github.com/folke/snacks.nvim/commit/e9e2e6976e3cc7c1110892c9c4a6882dd88ca6fd)) * **picker.git:** apply args to `git`, and not `git grep`. ([2e284e2](https://github.com/folke/snacks.nvim/commit/2e284e23d956767a50321de9c9bb0c005ea7c51f)) * **picker.git:** better handling of multi file staging ([b39a3ba](https://github.com/folke/snacks.nvim/commit/b39a3ba40af7c63e0cf0f5e6a2c242c6d3f22591)) * **picker.git:** correct root dir for git log ([c114a0d](https://github.com/folke/snacks.nvim/commit/c114a0da1a3984345c3035474b8a688592288c9d)) * **picker.git:** formatting of git log ([f320026](https://github.com/folke/snacks.nvim/commit/f32002607a5a81a1d25eda27b954fc6ba8e9fd1b)) * **picker.git:** handle git status renames. Closes [#1003](https://github.com/folke/snacks.nvim/issues/1003) ([93ad23a](https://github.com/folke/snacks.nvim/commit/93ad23a0abb4c712722b74e3c066e6b42881fc81)) * **picker.git:** preserve chronological order when matching ([#1216](https://github.com/folke/snacks.nvim/issues/1216)) ([8b19fd0](https://github.com/folke/snacks.nvim/commit/8b19fd0332835d48f7fe9fe203fa1c2b27976cd2)) * **picker.git:** properly handle file renames for git log. Closes [#1154](https://github.com/folke/snacks.nvim/issues/1154) ([9c436cb](https://github.com/folke/snacks.nvim/commit/9c436cb273c9b6984da275ba449fda2780d4fa2e)) * **picker.help:** make sure plugin is loaded for which we want to view the help ([3841a87](https://github.com/folke/snacks.nvim/commit/3841a8705a5e433d88539176d7c67a0ee6a9a92c)) * **picker.highlight:** lower case treesitter parser name ([3367983](https://github.com/folke/snacks.nvim/commit/336798345c1503689917a4a4a03a03a3da33119a)) * **picker.highlights:** close on confirm. Closes [#1096](https://github.com/folke/snacks.nvim/issues/1096) ([76f6e4f](https://github.com/folke/snacks.nvim/commit/76f6e4f81cff6f00c8ff027af9351f38ffa6d9f0)) * **picker.input:** prevent save dialog ([fcb2f50](https://github.com/folke/snacks.nvim/commit/fcb2f508dd6b58c98b781229db895d22c69e6f21)) * **picker.lines:** use original buf instead of current (which can be the picker on refresh) ([7ccf9c9](https://github.com/folke/snacks.nvim/commit/7ccf9c9d6934a76d5bd835bbd6cf1e764960f14e)) * **picker.list:** `list:view` should never transform reverse. Closes [#1016](https://github.com/folke/snacks.nvim/issues/1016) ([be781f9](https://github.com/folke/snacks.nvim/commit/be781f9fcb3d99db03c9c6979386565b65f8801b)) * **picker.list:** allow horizontal scrolling in the list ([572436b](https://github.com/folke/snacks.nvim/commit/572436bc3f16691172a6a0e94c8ffaf16b4170f0)) * **picker.list:** better wrap settings for when wrapping is enabled ([a542ea4](https://github.com/folke/snacks.nvim/commit/a542ea4d3487bd1aa449350c320bfdbe0c23083b)) * **picker.list:** correct offset calculation for large scrolloff. Closes [#1208](https://github.com/folke/snacks.nvim/issues/1208) ([f4ca368](https://github.com/folke/snacks.nvim/commit/f4ca368672e2231cc34abbd96208812cc6bb1aa1)) * **picker.list:** don't return non-matching items. Closes [#1133](https://github.com/folke/snacks.nvim/issues/1133) ([d07e7ac](https://github.com/folke/snacks.nvim/commit/d07e7ac6209356f74405bdd9d881fcacdf80f5ad)) * **picker.list:** don't show preview when target cursor/top not yet reached. Closes [#1204](https://github.com/folke/snacks.nvim/issues/1204) ([b02cb5e](https://github.com/folke/snacks.nvim/commit/b02cb5e8826179b385b870edbda1631213391cf1)) * **picker.list:** dont transform with reverse for resolving target. Closes [#1142](https://github.com/folke/snacks.nvim/issues/1142) ([0e36317](https://github.com/folke/snacks.nvim/commit/0e363177bd4a8037a127bc3fab6bf9d442da1123)) * **picker.list:** keep existing target if it exists unless `force = true`. Closes [#1152](https://github.com/folke/snacks.nvim/issues/1152) ([121e74e](https://github.com/folke/snacks.nvim/commit/121e74e4a5b7962ee370a8d8ae75d1c7b4c2e11c)) * **picker.list:** let user override wrap ([22da4bd](https://github.com/folke/snacks.nvim/commit/22da4bd5118a63268e6516ac74a8c3dc514218d3)) * **picker.list:** reset preview when no results. Closes [#1133](https://github.com/folke/snacks.nvim/issues/1133) ([f8bc119](https://github.com/folke/snacks.nvim/commit/f8bc1192cb3f740913f9198fabaf87b46434a926)) * **picker.lsp:** fix indent guides for sorted document symbols ([17360e4](https://github.com/folke/snacks.nvim/commit/17360e400905f50c5cc513b072c207233f825a73)) * **picker.lsp:** handle invalid lsp kinds. Closes [#1182](https://github.com/folke/snacks.nvim/issues/1182) ([f3cdd02](https://github.com/folke/snacks.nvim/commit/f3cdd02620bd5075e453be7451a260dbbee68cab)) * **picker.lsp:** only sort when not getting workspace symbols. Closes [#1071](https://github.com/folke/snacks.nvim/issues/1071) ([d607d2e](https://github.com/folke/snacks.nvim/commit/d607d2e050d9ab19ebe51db38aab807958f05bad)) * **picker.lsp:** sort document symbols by position ([cc22177](https://github.com/folke/snacks.nvim/commit/cc22177dcf288195022b0f739da3d00fcf56e3d7)) * **picker.matcher:** don't optimize pattern subsets when pattern has a negation ([a6b3d78](https://github.com/folke/snacks.nvim/commit/a6b3d7840baef2cc9207353a7c1a782fc8508af9)) * **picker.matcher:** only consider subset patterns that contain only whitespace and alpha numeric chars. Closes [#1013](https://github.com/folke/snacks.nvim/issues/1013) ([fcf2311](https://github.com/folke/snacks.nvim/commit/fcf2311c0e68d91b71bc1be114ad13e84cd7771d)) * **picker.notifications:** close on confirm. Closes [#1092](https://github.com/folke/snacks.nvim/issues/1092) ([a8dda99](https://github.com/folke/snacks.nvim/commit/a8dda993e5f2a0262a2be1585511a6df7e5dcb8c)) * **picker.preview:** clear namespace on reset ([a6d418e](https://github.com/folke/snacks.nvim/commit/a6d418e877033de9a12288cdbf7e78d2f0f5d661)) * **picker.preview:** don't clear preview state on close so that colorscheme can be restored. Closes [#932](https://github.com/folke/snacks.nvim/issues/932) ([9688bd9](https://github.com/folke/snacks.nvim/commit/9688bd92cda4fbe57210bbdfbb9c940516382f9a)) * **picker.preview:** don't reset preview when filtering and the same item is previewed ([c8285c2](https://github.com/folke/snacks.nvim/commit/c8285c2ca2c4805019e105967f17e60f82faf106)) * **picker.preview:** fix newlines before setting lines of a buffer ([62c2c62](https://github.com/folke/snacks.nvim/commit/62c2c62671cf88ace1bd9fdd26411158d7072e0b)) * **picker.preview:** hide line numbers / status column for directory preview. Closes [#1029](https://github.com/folke/snacks.nvim/issues/1029) ([f9aca86](https://github.com/folke/snacks.nvim/commit/f9aca86bf3ddbbd56cb53f71250c301f90af35a2)) * **picker.preview:** preview for uris. Closes [#1075](https://github.com/folke/snacks.nvim/issues/1075) ([c1f93e2](https://github.com/folke/snacks.nvim/commit/c1f93e25bb927dce2e1eb46610b6347460f0c69b)) * **picker.preview:** update titles on layout update. Closes [#1113](https://github.com/folke/snacks.nvim/issues/1113) ([89b3ce1](https://github.com/folke/snacks.nvim/commit/89b3ce11ca700badc92af0e1a37be2e19b79cd55)) * **picker.preview:** work-around for Neovim's messy window-local options (that are never truly local). Closes [#1100](https://github.com/folke/snacks.nvim/issues/1100) ([e5960d8](https://github.com/folke/snacks.nvim/commit/e5960d8e32ed2771a1d84ce4532bf0e2dc4dc8ca)) * **picker.proc:** don't close stdout on proc exit, since it might still contain buffered output. Closes [#966](https://github.com/folke/snacks.nvim/issues/966) ([3b7021e](https://github.com/folke/snacks.nvim/commit/3b7021e7fdf88e13fdf06643ae9a7224e1291495)) * **picker.proc:** make sure to emit the last line when done. Closes [#1095](https://github.com/folke/snacks.nvim/issues/1095) ([b94926e](https://github.com/folke/snacks.nvim/commit/b94926e5cc697c54c73e7c2b3759c8432afca91d)) * **picker.projects:** add custom project dirs ([c7293bd](https://github.com/folke/snacks.nvim/commit/c7293bdfe7664eca6f49816795ffb7f2af5b8302)) * **picker.projects:** use fd or fdfind ([270250c](https://github.com/folke/snacks.nvim/commit/270250cf4646dbb16c3d1a453257a3f024b8f362)) * **picker.watch:** schedule_wrap. Closes [#1049](https://github.com/folke/snacks.nvim/issues/1049) ([f489d61](https://github.com/folke/snacks.nvim/commit/f489d61f54c3a32c35c439a16ff0f097dbe93028)) * **picker.zoxide:** directory icon ([#1031](https://github.com/folke/snacks.nvim/issues/1031)) ([33dbebb](https://github.com/folke/snacks.nvim/commit/33dbebb75395b5e80e441214985c0d9143d323d6)) * **picker:** `nil` on `:quit`. Closes [#1107](https://github.com/folke/snacks.nvim/issues/1107) ([1219f5e](https://github.com/folke/snacks.nvim/commit/1219f5e43baf1c17e305d605d3db8972aae19bf5)) * **picker:** `opts.focus = false` now works again ([031f9e9](https://github.com/folke/snacks.nvim/commit/031f9e96fb85cd417868ab2ba03946cb98fd06c8)) * **picker:** closed check for show preview. Closes [#1181](https://github.com/folke/snacks.nvim/issues/1181) ([c1f4d30](https://github.com/folke/snacks.nvim/commit/c1f4d3032529a7af3d0863c775841f6dc13e03d6)) * **picker:** consider zen windows as main. Closes [#973](https://github.com/folke/snacks.nvim/issues/973) ([b1db65a](https://github.com/folke/snacks.nvim/commit/b1db65ac61127581cbe3bca8e54a8faf8ce16e5f)) * **picker:** disabled preview main ([9fe43bd](https://github.com/folke/snacks.nvim/commit/9fe43bdf9b6c04b129e84bd7c2cb7ebd8e04bfae)) * **picker:** don't render list when closed. See [#1308](https://github.com/folke/snacks.nvim/issues/1308) ([681ae6e](https://github.com/folke/snacks.nvim/commit/681ae6e3078503d2c4cc137a492782a0ee3977b3)) * **picker:** exit insert mode before closing with `<c-c>` to prevent cursor shifting left. Close [#956](https://github.com/folke/snacks.nvim/issues/956) ([71eae96](https://github.com/folke/snacks.nvim/commit/71eae96bfa5ccafad9966a7bc40982ebe05d8f5d)) * **picker:** go back to last window on cancel instead of main ([4551f49](https://github.com/folke/snacks.nvim/commit/4551f499c7945036761fd48927cc07b9720fce56)) * **picker:** initial preview state when main ([cd6e336](https://github.com/folke/snacks.nvim/commit/cd6e336ec0dc8b95e7a75c86cba297a16929370e)) * **picker:** only show extmark errors when debug is enabled. Closes [#988](https://github.com/folke/snacks.nvim/issues/988) ([f6d9af7](https://github.com/folke/snacks.nvim/commit/f6d9af7410963780c48772f7bd9ee3f5e7be8599)) * **picker:** remove debug ([a23b10e](https://github.com/folke/snacks.nvim/commit/a23b10e6cafeae7b9e06be47ba49295d0c921a97)) * **picker:** remove debug :) ([3d53a73](https://github.com/folke/snacks.nvim/commit/3d53a7364e438a7652bb6b90b95c334c32cab938)) * **picker:** save toggles for resume. Closes [#1085](https://github.com/folke/snacks.nvim/issues/1085) ([e390713](https://github.com/folke/snacks.nvim/commit/e390713ac6f92d0076f38b518645b55222ecf4d1)) * **picker:** sometimes main layout win gets selected. Closes [#1015](https://github.com/folke/snacks.nvim/issues/1015) ([4799f82](https://github.com/folke/snacks.nvim/commit/4799f829683272b06ad9bf8b8e9816f28b3a46ef)) * **picker:** update titles last on show. Closes [#1113](https://github.com/folke/snacks.nvim/issues/1113) ([96796db](https://github.com/folke/snacks.nvim/commit/96796db21e474eff0d0ddeee2afa6c2c346756c7)) * **picker:** vim.ui.select callback is called when canceling selection ([#1115](https://github.com/folke/snacks.nvim/issues/1115)) ([4c3bfa2](https://github.com/folke/snacks.nvim/commit/4c3bfa29f3122c4fb855c1adaef01cf22612624a)) * **scroll:** added `keepjumps` ([7161dc1](https://github.com/folke/snacks.nvim/commit/7161dc1b570849324bb2b0b808c6f2cc46ef6f84)) * **statuscolumn:** only execute `za` when fold exists ([#1093](https://github.com/folke/snacks.nvim/issues/1093)) ([345eeb6](https://github.com/folke/snacks.nvim/commit/345eeb69417f5568930f283e3be01f0ef55bee63)) * **terminal:** check for 0.11 ([6e45829](https://github.com/folke/snacks.nvim/commit/6e45829879da987cb4ed01d3098eb2507da72343)) * **terminal:** softer check for using jobstart with `term=true` instead of deprecated termopen ([544a2ae](https://github.com/folke/snacks.nvim/commit/544a2ae01c28056629a0c90f8d0ff40995c84e42)) * **toggle:** hide toggle when real keymap does not exist. Closes [#378](https://github.com/folke/snacks.nvim/issues/378) ([ee9e617](https://github.com/folke/snacks.nvim/commit/ee9e6179fe18a2bf36ebb5e81ddf1052e04577dc)) * **win:** apply win-local window options for new buffers displayed in a window. Fixes [#925](https://github.com/folke/snacks.nvim/issues/925) ([cb99c46](https://github.com/folke/snacks.nvim/commit/cb99c46fa171134f582f6b13bef32f6d25ebda59)) * **win:** better handling when the command window is open. Closes [#1245](https://github.com/folke/snacks.nvim/issues/1245) ([7720410](https://github.com/folke/snacks.nvim/commit/77204102a1f5869bf37d8ccbc5e8e0769cfe8db4)) * **win:** call `on_close` before actually closing so that prev win can be set. Closes [#962](https://github.com/folke/snacks.nvim/issues/962) ([a1cb54c](https://github.com/folke/snacks.nvim/commit/a1cb54cc9e579c53bbd4b96949acf2341b31a3ee)) * **words:** default count to 1. Closes [#1307](https://github.com/folke/snacks.nvim/issues/1307) ([45ec90b](https://github.com/folke/snacks.nvim/commit/45ec90bdd91d7730b81662ee3bfcdd4a88ed908f)) * **zen:** properly get zoom options. Closes [#1207](https://github.com/folke/snacks.nvim/issues/1207) ([3100333](https://github.com/folke/snacks.nvim/commit/3100333fdb777853c77aeac46b92fcdaba8e3e57)) ### Performance Improvements * **dashboard:** speed up filtering for recent_files ([#1250](https://github.com/folke/snacks.nvim/issues/1250)) ([b91f417](https://github.com/folke/snacks.nvim/commit/b91f417670e8f35ac96a2ebdecceeafdcc43ba4a)) * **explorer:** disable watchdirs fallback watcher ([5d34380](https://github.com/folke/snacks.nvim/commit/5d34380310861cd42e32ce0865bd8cded9027b41)) * **explorer:** early exit for tree calculation ([1a30610](https://github.com/folke/snacks.nvim/commit/1a30610ab78cce8bb184166de2ef35ee2ca1987a)) * **explorer:** no need to get git status with `-uall`. Closes [#983](https://github.com/folke/snacks.nvim/issues/983) ([bc087d3](https://github.com/folke/snacks.nvim/commit/bc087d36d6126ccf25f8bb3ead405ec32547d85d)) * **explorer:** only update tree if git status actually changed ([5a2acf8](https://github.com/folke/snacks.nvim/commit/5a2acf82b2aff0b6f7121ce953c5754de6fd1e01)) * **explorer:** only update tree when diagnostics actually changed ([1142f46](https://github.com/folke/snacks.nvim/commit/1142f46a27358c8f48023382389a8b31c9628b6b)) * **image.convert:** identify during convert instead of calling identify afterwards ([7b7f42f](https://github.com/folke/snacks.nvim/commit/7b7f42fb3bee6083677d66b301424c26b4ff41c2)) * **image:** no need to run identify before convert for local files ([e2d9941](https://github.com/folke/snacks.nvim/commit/e2d99418968b0dc690ca6b56dac688d70e9b5e40)) * **picker.list:** only re-render when visible items changed ([c72e62e](https://github.com/folke/snacks.nvim/commit/c72e62ef9012161ec6cd86aa749d780f77d1cc87)) * **picker:** cache treesitter line highlights ([af31c31](https://github.com/folke/snacks.nvim/commit/af31c312872cab2a47e17ed2ee67bf5940a522d4)) * **picker:** cache wether ts lang exists and disable smooth scrolling on big files ([719b36f](https://github.com/folke/snacks.nvim/commit/719b36fa70c35a7015537aa0bfd2956f6128c87d)) * **scroll:** much better/easier/faster method for vertical cursor positioning ([a3194d9](https://github.com/folke/snacks.nvim/commit/a3194d95199c4699a4da0d4c425a19544ed8d670)) ### Documentation * docgen ([b503e3e](https://github.com/folke/snacks.nvim/commit/b503e3ee9fdd57202e5815747e67d1f6259468a4)) ## [2.20.0](https://github.com/folke/snacks.nvim/compare/v2.19.0...v2.20.0) (2025-02-08) ### Features * **picker.undo:** `ctrl+y` to yank added lines, `ctrl+shift+y` to yank deleted lines ([3baf95d](https://github.com/folke/snacks.nvim/commit/3baf95d3a1005105b57ce53644ff6224ee3afa1c)) * **picker:** added treesitter symbols picker ([a6beb0f](https://github.com/folke/snacks.nvim/commit/a6beb0f280d3f43513998882faf199acf3818ddf)) * **terminal:** added `Snacks.terminal.list()`. Closes [#421](https://github.com/folke/snacks.nvim/issues/421). Closes [#759](https://github.com/folke/snacks.nvim/issues/759) ([73c4b62](https://github.com/folke/snacks.nvim/commit/73c4b628963004760ccced0192d1c2633c9e3657)) ### Bug Fixes * **explorer:** change grep in item dir keymap to leader-/. Closes [#1000](https://github.com/folke/snacks.nvim/issues/1000) ([9dfa276](https://github.com/folke/snacks.nvim/commit/9dfa276ea424a091f5d5bdc008aff127850441b2)) * **layout:** take winbar into account for split layouts. Closes [#996](https://github.com/folke/snacks.nvim/issues/996) ([e4e5040](https://github.com/folke/snacks.nvim/commit/e4e5040d9b9b58ac3bc44a6709bbb5e55e58adea)) * **picker.git:** account for deleted files in git diff. Closes [#1001](https://github.com/folke/snacks.nvim/issues/1001) ([e9e2e69](https://github.com/folke/snacks.nvim/commit/e9e2e6976e3cc7c1110892c9c4a6882dd88ca6fd)) * **picker.git:** handle git status renames. Closes [#1003](https://github.com/folke/snacks.nvim/issues/1003) ([93ad23a](https://github.com/folke/snacks.nvim/commit/93ad23a0abb4c712722b74e3c066e6b42881fc81)) * **picker.lines:** use original buf instead of current (which can be the picker on refresh) ([7ccf9c9](https://github.com/folke/snacks.nvim/commit/7ccf9c9d6934a76d5bd835bbd6cf1e764960f14e)) * **picker.proc:** don't close stdout on proc exit, since it might still contain buffered output. Closes [#966](https://github.com/folke/snacks.nvim/issues/966) ([3b7021e](https://github.com/folke/snacks.nvim/commit/3b7021e7fdf88e13fdf06643ae9a7224e1291495)) ## [2.19.0](https://github.com/folke/snacks.nvim/compare/v2.18.0...v2.19.0) (2025-02-07) ### Features * **bigfile:** configurable average line length (default = 1000). Useful for minified files. Closes [#576](https://github.com/folke/snacks.nvim/issues/576). Closes [#372](https://github.com/folke/snacks.nvim/issues/372) ([7fa92a2](https://github.com/folke/snacks.nvim/commit/7fa92a24501fa85b567c130b4e026f9ca1efed17)) * **explorer:** add hl groups for ignored / hidden files. Closes [#887](https://github.com/folke/snacks.nvim/issues/887) ([85e1b34](https://github.com/folke/snacks.nvim/commit/85e1b343b0cc6d2facc0763d9e1c1de4b63b99ac)) * **explorer:** added ctrl+f to grep in the item's directory ([0454b21](https://github.com/folke/snacks.nvim/commit/0454b21165cb84d2f59a1daf6226de065c90d4f7)) * **explorer:** added ctrl+t to open a terminal in the item's directory ([81f9006](https://github.com/folke/snacks.nvim/commit/81f90062c50430c1bad9546fcb65c3e43a76be9b)) * **explorer:** added diagnostics file/directory status ([7f1b60d](https://github.com/folke/snacks.nvim/commit/7f1b60d5576345af5e7b990f3a9e4bca49cd3686)) * **explorer:** added quick nav with `[`, `]` with `d/w/e` for diagnostics ([d1d5585](https://github.com/folke/snacks.nvim/commit/d1d55850ecb4aac1396c314a159db1e90a34bd79)) * **explorer:** added support for live search ([82c4a50](https://github.com/folke/snacks.nvim/commit/82c4a50985c9bb9f4b1d598f10a30e1122a35212)) * **explorer:** allow disabling untracked git status. Closes [#983](https://github.com/folke/snacks.nvim/issues/983) ([a3b083b](https://github.com/folke/snacks.nvim/commit/a3b083b8443b1ae1299747fdac8da51c3160835b)) * **explorer:** different hl group for broken links ([1989921](https://github.com/folke/snacks.nvim/commit/1989921466e6b5234ae8f71add41b8defd55f732)) * **explorer:** disable fuzzy searches by default for explorer since it's too noisy and we can't sort on score due to tree view ([b07788f](https://github.com/folke/snacks.nvim/commit/b07788f14a28daa8d0b387c1258f8f348f47420f)) * **explorer:** file watcher when explorer is open ([6936c14](https://github.com/folke/snacks.nvim/commit/6936c1491d4aa8ffb4448acca677589a1472bb3a)) * **explorer:** file watching that works on all platforms ([8399465](https://github.com/folke/snacks.nvim/commit/8399465872c51fab54ad5d02eb315e258ec96ed1)) * **explorer:** focus on first file when searching in the explorer ([1d4bea4](https://github.com/folke/snacks.nvim/commit/1d4bea4a9ee8a5258c6ae085ac66dd5cc05a9749)) * **explorer:** git index watcher ([4c12475](https://github.com/folke/snacks.nvim/commit/4c12475e80528d8d48b9584d78d645e4a51c3298)) * **explorer:** rewrite that no longer depends on `fd` for exploring ([6149a7b](https://github.com/folke/snacks.nvim/commit/6149a7babbd2c6d9cd924bb70102d80a7f045287)) * **explorer:** show symlink target ([dfa79e0](https://github.com/folke/snacks.nvim/commit/dfa79e04436ebfdc83ba71c0048fc1636b4de5aa)) * **matcher:** call on_match after setting score ([23ce529](https://github.com/folke/snacks.nvim/commit/23ce529fb663337f9dc17ca08aa601b172469031)) * **picker.git:** add confirmation before deleting a git branch ([#951](https://github.com/folke/snacks.nvim/issues/951)) ([337a3ae](https://github.com/folke/snacks.nvim/commit/337a3ae7eebb95020596f15a349a85d2f6be31a4)) * **picker.git:** add create and delete branch to git_branches ([#909](https://github.com/folke/snacks.nvim/issues/909)) ([8676c40](https://github.com/folke/snacks.nvim/commit/8676c409e148e28eff93c114aca0c1bf3d42281a)) * **picker.lazy:** don't use `grep`. Parse spec files manually. Closes [#972](https://github.com/folke/snacks.nvim/issues/972) ([0928007](https://github.com/folke/snacks.nvim/commit/09280078e8339f018be5249fe0e1d7b9d32db7f7)) * **picker.lsp:** use existing buffers for preview when opened ([d4e6353](https://github.com/folke/snacks.nvim/commit/d4e63531c9fba63ded6fb470a5d53c98af110478)) * **picker.matcher:** internal `on_match` ([47b3b69](https://github.com/folke/snacks.nvim/commit/47b3b69570271b12bbd72b9dbcfbd445b915beca)) * **picker.preview:** allow confguring `preview = {main = true, enabled = false}` ([1839c65](https://github.com/folke/snacks.nvim/commit/1839c65f6784bedb7ae96a84ee741fa5c0023226)) * **picker.undo:** added ctrl+y to yank added lines from undo ([811a24c](https://github.com/folke/snacks.nvim/commit/811a24cc16a8e9b7ec947c95b73e1fe05e4692d1)) * **picker:** `picker:dir()` to get the dir of the item (when a directory) or it's parent (when a file) ([969608a](https://github.com/folke/snacks.nvim/commit/969608ab795718cc23299247bf4ea0a199fcca3f)) * **picker:** added `git_grep` picker. Closes [#986](https://github.com/folke/snacks.nvim/issues/986) ([2dc9016](https://github.com/folke/snacks.nvim/commit/2dc901634b250059cc9b7129bdeeedd24520b86c)) * **picker:** allow configuring file icon width. Closes [#981](https://github.com/folke/snacks.nvim/issues/981) ([52c1086](https://github.com/folke/snacks.nvim/commit/52c1086ecdf410dfec3317144d46de7c6f86c1ad)) * **picker:** better checkhealth ([b773368](https://github.com/folke/snacks.nvim/commit/b773368f8aa6e84a68e979f0e335d23de71f405a)) * **picker:** get filetype from modeline when needed. Closes [#987](https://github.com/folke/snacks.nvim/issues/987) ([5af04ab](https://github.com/folke/snacks.nvim/commit/5af04ab6672ae38bf7d72427e75f925615f93904)) * **picker:** opts.on_close ([6235f44](https://github.com/folke/snacks.nvim/commit/6235f44b115c45dd009c45b81a52f8d99863efaa)) * **picker:** pin picker as a split to left/bottom/top/right with `ctrl+z+(hjkl)` ([27cba53](https://github.com/folke/snacks.nvim/commit/27cba535a6763cbca3f3162c5c4bb48c6f382005)) * **rename:** add `old` to `on_rename` callback ([455228e](https://github.com/folke/snacks.nvim/commit/455228ed3a07bf3aee34a75910785b9978f53da6)) * **scope:** allow injected languages to be parsed by treesitter ([#823](https://github.com/folke/snacks.nvim/issues/823)) ([aba21dd](https://github.com/folke/snacks.nvim/commit/aba21ddc712b12db8469680dd7f2080063cb6d5c)) * **statuscolumn:** added mouse click handler to open/close folds. Closes [#968](https://github.com/folke/snacks.nvim/issues/968) ([98a7b64](https://github.com/folke/snacks.nvim/commit/98a7b647c9e245ef02d57d566bf8461c2f7beb56)) * **terminal:** added `start_insert` ([64129e4](https://github.com/folke/snacks.nvim/commit/64129e4c3c5b247c61b1f46bc0faaa1e69e7eef8)) * **terminal:** auto_close and auto_insert. Closes [#965](https://github.com/folke/snacks.nvim/issues/965) ([bb76cae](https://github.com/folke/snacks.nvim/commit/bb76cae87e81a871435570b91c8c6f6e27eb9955)) ### Bug Fixes * **bigfile:** check that passed path is the one from the buffer ([8deea64](https://github.com/folke/snacks.nvim/commit/8deea64dba3b9b8f57e52bb6b0133263f6ff171f)) * **explorer.git:** better git status watching ([09349ec](https://github.com/folke/snacks.nvim/commit/09349ecd44040666db9d4835994a378a9ff53e8c)) * **explorer.git:** dont reset cursor when git status is done updating ([bc87992](https://github.com/folke/snacks.nvim/commit/bc87992e712c29ef8e826f3550f9b8e3f1a9308d)) * **explorer.git:** vim.schedule git updates ([3aad761](https://github.com/folke/snacks.nvim/commit/3aad7616209951320d54f83dd7df35d5578ea61f)) * **explorer.tree:** fix linux ([6f5399b](https://github.com/folke/snacks.nvim/commit/6f5399b47c55f916fcc3a82dcc71cce0eb5d7c92)) * **explorer.tree:** symlink directories ([e5f1e91](https://github.com/folke/snacks.nvim/commit/e5f1e91249b468ff3a7d14a8650074c27f1fdb30)) * **explorer.watch:** pcall watcher, since it can give errors on windows ([af96818](https://github.com/folke/snacks.nvim/commit/af968181af6ce6a988765fe51558b2caefdcf863)) * **explorer:** always refresh state when opening the picker since changes might have happened that were not monitored ([c61114f](https://github.com/folke/snacks.nvim/commit/c61114fb32910863a543a4a7a1f63e9915983d26)) * **explorer:** call original `on_close`. Closes [#971](https://github.com/folke/snacks.nvim/issues/971) ([a0bee9f](https://github.com/folke/snacks.nvim/commit/a0bee9f662d4e22c6533e6544b4daedecd2aacc0)) * **explorer:** check that picker is still open ([50fa1be](https://github.com/folke/snacks.nvim/commit/50fa1be38ee8366d79e1fa58b38abf31d3955033)) * **explorer:** clear cache after action. Fixes [#890](https://github.com/folke/snacks.nvim/issues/890) ([34097ff](https://github.com/folke/snacks.nvim/commit/34097ff37e0fb53771bbe3bf927048d06b4576f6)) * **explorer:** clear selection after delete. Closes [#898](https://github.com/folke/snacks.nvim/issues/898) ([44733ea](https://github.com/folke/snacks.nvim/commit/44733eaa78fb899dc3b3d72d7cac6f447356a287)) * **explorer:** disable follow for explorer search by default. No longer needed. Link directories may show as files then though, but that's not an issue. See [#960](https://github.com/folke/snacks.nvim/issues/960) ([b9a17d8](https://github.com/folke/snacks.nvim/commit/b9a17d82a726dc6cfd9a0b4f8566178708073808)) * **explorer:** don't use --absolute-path option, since that resolves paths to realpath. See [#901](https://github.com/folke/snacks.nvim/issues/901). See [#905](https://github.com/folke/snacks.nvim/issues/905). See [#904](https://github.com/folke/snacks.nvim/issues/904) ([97570d2](https://github.com/folke/snacks.nvim/commit/97570d23ac42dac813c5eb49637381fa3b728246)) * **explorer:** dont focus first file when not searching ([3fd437c](https://github.com/folke/snacks.nvim/commit/3fd437ccd38d79b876154097149d130cdb01e653)) * **explorer:** dont process git when picker closed ([c255d9c](https://github.com/folke/snacks.nvim/commit/c255d9c6a02f070f0048c5eaa40921f71e9f2acb)) * **explorer:** last status for indent guides taking hidden / ignored files into account ([94bd2ef](https://github.com/folke/snacks.nvim/commit/94bd2eff74acd7faa78760bf8a55d9c269e99190)) * **explorer:** strip cwd from search text for explorer items ([38f392a](https://github.com/folke/snacks.nvim/commit/38f392a8ad75ced790f89c8ef43a91f98a2bb6e3)) * **explorer:** windows ([b560054](https://github.com/folke/snacks.nvim/commit/b56005466952b759a2f610e8b3c8263444402d76)) * **exporer.tree:** and now hopefully on windows ([ef9b12d](https://github.com/folke/snacks.nvim/commit/ef9b12d68010a931c76533925a8c730123241635)) * **git:** use nul char as separator for git status ([8e0dfd2](https://github.com/folke/snacks.nvim/commit/8e0dfd285665bedf67441efe11c9c1318781826f)) * **health:** better health checks for picker. Closes [#917](https://github.com/folke/snacks.nvim/issues/917) ([427f036](https://github.com/folke/snacks.nvim/commit/427f036c6532097859d9177f32ccb037803abaa4)) * **picker.actions:** close preview before buffer delete ([762821e](https://github.com/folke/snacks.nvim/commit/762821e420cef56b03b6897b008454eefe68fd1d)) * **picker.actions:** don't reuse_win in floating windows (like the picker preview) ([4b9ea98](https://github.com/folke/snacks.nvim/commit/4b9ea98007cddc0af80fa0479a86a1bf2e880b66)) * **picker.actions:** fix qflist position ([#911](https://github.com/folke/snacks.nvim/issues/911)) ([6d3c135](https://github.com/folke/snacks.nvim/commit/6d3c1352358e0e2980f9f323b6ca8a62415963bc)) * **picker.actions:** get win after splitting or tabnew. Fixes [#896](https://github.com/folke/snacks.nvim/issues/896) ([95d3e7f](https://github.com/folke/snacks.nvim/commit/95d3e7f96182e4cc8aa0c05a616a61eba9a77366)) * **picker.colorscheme:** use wildignore. Closes [#969](https://github.com/folke/snacks.nvim/issues/969) ([ba8badf](https://github.com/folke/snacks.nvim/commit/ba8badfe74783e97934c21a69e0c44883092587f)) * **picker.db:** better script to download sqlite3 on windows. See [#918](https://github.com/folke/snacks.nvim/issues/918) ([84d1a92](https://github.com/folke/snacks.nvim/commit/84d1a92faba55a470a6c52a1aca86988b0c57869)) * **picker.finder:** correct check if filter changed ([52bc24c](https://github.com/folke/snacks.nvim/commit/52bc24c23256246e863992dfcc3172c527254f55)) * **picker.input:** fixed startinsert weirdness with prompt buffers (again) ([c030827](https://github.com/folke/snacks.nvim/commit/c030827d7ad3fe7117bf81c0db1613c958015211)) * **picker.input:** set as not modified when setting input through API ([54a041f](https://github.com/folke/snacks.nvim/commit/54a041f7fca05234379d7bceff6b036acc679cdc)) * **picker.list:** better wrap settings for when wrapping is enabled ([a542ea4](https://github.com/folke/snacks.nvim/commit/a542ea4d3487bd1aa449350c320bfdbe0c23083b)) * **picker.list:** let user override wrap ([22da4bd](https://github.com/folke/snacks.nvim/commit/22da4bd5118a63268e6516ac74a8c3dc514218d3)) * **picker.list:** nil check ([c22e46a](https://github.com/folke/snacks.nvim/commit/c22e46ab9a1f1416368759e0979bc5c0c64c0084)) * **picker.lsp:** fix indent guides for sorted document symbols ([17360e4](https://github.com/folke/snacks.nvim/commit/17360e400905f50c5cc513b072c207233f825a73)) * **picker.lsp:** sort document symbols by position ([cc22177](https://github.com/folke/snacks.nvim/commit/cc22177dcf288195022b0f739da3d00fcf56e3d7)) * **picker.matcher:** don't optimize pattern subsets when pattern has a negation ([a6b3d78](https://github.com/folke/snacks.nvim/commit/a6b3d7840baef2cc9207353a7c1a782fc8508af9)) * **picker.preview:** don't clear preview state on close so that colorscheme can be restored. Closes [#932](https://github.com/folke/snacks.nvim/issues/932) ([9688bd9](https://github.com/folke/snacks.nvim/commit/9688bd92cda4fbe57210bbdfbb9c940516382f9a)) * **picker.preview:** update main preview when changing the layout ([604c603](https://github.com/folke/snacks.nvim/commit/604c603dfafdac0c2edc725ff8bcdcc395100028)) * **picker.projects:** add custom project dirs ([c7293bd](https://github.com/folke/snacks.nvim/commit/c7293bdfe7664eca6f49816795ffb7f2af5b8302)) * **picker.projects:** use fd or fdfind ([270250c](https://github.com/folke/snacks.nvim/commit/270250cf4646dbb16c3d1a453257a3f024b8f362)) * **picker.select:** default height shows just the items. See [#902](https://github.com/folke/snacks.nvim/issues/902) ([c667622](https://github.com/folke/snacks.nvim/commit/c667622fb7569a020195e3e35c1405e4a1fa0e7e)) * **picker:** better handling when entering the root layout window. Closes [#894](https://github.com/folke/snacks.nvim/issues/894) ([e294fd8](https://github.com/folke/snacks.nvim/commit/e294fd8a273b1f5622c8a3259802d91a1ed01110)) * **picker:** consider zen windows as main. Closes [#973](https://github.com/folke/snacks.nvim/issues/973) ([b1db65a](https://github.com/folke/snacks.nvim/commit/b1db65ac61127581cbe3bca8e54a8faf8ce16e5f)) * **picker:** disabled preview main ([9fe43bd](https://github.com/folke/snacks.nvim/commit/9fe43bdf9b6c04b129e84bd7c2cb7ebd8e04bfae)) * **picker:** exit insert mode before closing with `<c-c>` to prevent cursor shifting left. Close [#956](https://github.com/folke/snacks.nvim/issues/956) ([71eae96](https://github.com/folke/snacks.nvim/commit/71eae96bfa5ccafad9966a7bc40982ebe05d8f5d)) * **picker:** initial preview state when main ([cd6e336](https://github.com/folke/snacks.nvim/commit/cd6e336ec0dc8b95e7a75c86cba297a16929370e)) * **picker:** only show extmark errors when debug is enabled. Closes [#988](https://github.com/folke/snacks.nvim/issues/988) ([f6d9af7](https://github.com/folke/snacks.nvim/commit/f6d9af7410963780c48772f7bd9ee3f5e7be8599)) * **win:** apply win-local window options for new buffers displayed in a window. Fixes [#925](https://github.com/folke/snacks.nvim/issues/925) ([cb99c46](https://github.com/folke/snacks.nvim/commit/cb99c46fa171134f582f6b13bef32f6d25ebda59)) * **win:** better handling of alien buffers opening in managed windows. See [#886](https://github.com/folke/snacks.nvim/issues/886) ([c8430fd](https://github.com/folke/snacks.nvim/commit/c8430fdd8dec6aa21c73f293cbd363084fb56ab0)) ### Performance Improvements * **explorer:** disable watchdirs fallback watcher ([5d34380](https://github.com/folke/snacks.nvim/commit/5d34380310861cd42e32ce0865bd8cded9027b41)) * **explorer:** early exit for tree calculation ([1a30610](https://github.com/folke/snacks.nvim/commit/1a30610ab78cce8bb184166de2ef35ee2ca1987a)) * **explorer:** no need to get git status with `-uall`. Closes [#983](https://github.com/folke/snacks.nvim/issues/983) ([bc087d3](https://github.com/folke/snacks.nvim/commit/bc087d36d6126ccf25f8bb3ead405ec32547d85d)) * **picker.list:** only re-render when visible items changed ([c72e62e](https://github.com/folke/snacks.nvim/commit/c72e62ef9012161ec6cd86aa749d780f77d1cc87)) * **picker:** cache treesitter line highlights ([af31c31](https://github.com/folke/snacks.nvim/commit/af31c312872cab2a47e17ed2ee67bf5940a522d4)) * **picker:** cache wether ts lang exists and disable smooth scrolling on big files ([719b36f](https://github.com/folke/snacks.nvim/commit/719b36fa70c35a7015537aa0bfd2956f6128c87d)) ## [2.18.0](https://github.com/folke/snacks.nvim/compare/v2.17.0...v2.18.0) (2025-02-03) ### Features * **dashboard:** play nice with file explorer netrw replacement ([5420a64](https://github.com/folke/snacks.nvim/commit/5420a64b66fd7350f5bb9d5dea2372850ea59969)) * **explorer.git:** added git_status_open. When false, then dont show recursive git status in open directories ([8646ba4](https://github.com/folke/snacks.nvim/commit/8646ba469630b73a34c06243664fb5607c0a43fa)) * **explorer:** added `]g` and `[g` to jump between files mentioned in `git status` ([263dfde](https://github.com/folke/snacks.nvim/commit/263dfde1b598e0fbba5f0031b8976e3c979f553c)) * **explorer:** added git status. Closes [#817](https://github.com/folke/snacks.nvim/issues/817) ([5cae48d](https://github.com/folke/snacks.nvim/commit/5cae48d93c875efa302bdffa995e4b057e2c3731)) * **explorer:** hide git status for open directories by default. it's mostly redundant ([b40c0d4](https://github.com/folke/snacks.nvim/commit/b40c0d4ee4e53aadc5fcf0900e58690c49f9763f)) * **explorer:** keep expanded dir state. Closes [#816](https://github.com/folke/snacks.nvim/issues/816) ([31984e8](https://github.com/folke/snacks.nvim/commit/31984e88a51652bda4997456c53113cbdc811cb4)) * **explorer:** more keymaps and tree rework. See [#837](https://github.com/folke/snacks.nvim/issues/837) ([2ff3893](https://github.com/folke/snacks.nvim/commit/2ff389312a78a8615754c2dee32b96211c9f9c54)) * **explorer:** navigate with h/l to close/open directories. Closes [#833](https://github.com/folke/snacks.nvim/issues/833) ([4b29ddc](https://github.com/folke/snacks.nvim/commit/4b29ddc5d9856ff49a07d77a43634e00b06f4d31)) * **explorer:** new `explorer` module with shortcut to start explorer picker and netrw replacement functionlity ([670c673](https://github.com/folke/snacks.nvim/commit/670c67366f0025fc4ebb78ba35a7586b7477989a)) * **explorer:** recursive copy and copying of selected items with `c` ([2528fcb](https://github.com/folke/snacks.nvim/commit/2528fcb02ceab7b19ee72a94b93c620259881e65)) * **explorer:** update on cwd change ([8dea225](https://github.com/folke/snacks.nvim/commit/8dea2252094ca3dc6d2073ab0015b7bcee396e24)) * **explorer:** update status when saving a file that is currently visible ([78d4116](https://github.com/folke/snacks.nvim/commit/78d4116662d38acb8456ffc6869204b487b472f8)) * **picker.commands:** do not autorun commands that require arguments ([#879](https://github.com/folke/snacks.nvim/issues/879)) ([62d99ed](https://github.com/folke/snacks.nvim/commit/62d99ed2a3711769e34fbc33c7072075824d256a)) * **picker.frecency:** added frecency support for directories ([ce67fa9](https://github.com/folke/snacks.nvim/commit/ce67fa9e31467590c750e203e27d3e6df293f2ad)) * **picker.input:** search syntax highlighting ([4242f90](https://github.com/folke/snacks.nvim/commit/4242f90268c93e7e546c195df26d9f0ee6b10645)) * **picker.lines:** jump to first position of match. Closes [#806](https://github.com/folke/snacks.nvim/issues/806) ([ae897f3](https://github.com/folke/snacks.nvim/commit/ae897f329f06695ee3482e2cd768797d5af3e277)) * **picker.list:** use regular CursorLine when picker window is not focused ([8a570bb](https://github.com/folke/snacks.nvim/commit/8a570bb48ba3536dcfe51f08547896b55fcb0e4d)) * **picker.matcher:** added opts.matcher.history_bonus that does fzf's scheme=history. Closes [#882](https://github.com/folke/snacks.nvim/issues/882). Closes [#872](https://github.com/folke/snacks.nvim/issues/872) ([78c2853](https://github.com/folke/snacks.nvim/commit/78c28535ddd4e3973bcdc9bdf342a37979010918)) * **picker.pickwin:** optional win/buf filter. Closes [#877](https://github.com/folke/snacks.nvim/issues/877) ([5c5b40b](https://github.com/folke/snacks.nvim/commit/5c5b40b5d0ed4166c751a11a839b015f4ad6e26a)) * **picker.projects:** added `<c-t>` to open a new tab page with the projects picker ([ced377a](https://github.com/folke/snacks.nvim/commit/ced377a05783073ab3a8506b5a9b0ffaf8293773)) * **picker.projects:** allow disabling projects from recent files ([c2dedb6](https://github.com/folke/snacks.nvim/commit/c2dedb647f6e170ee4defed647c7f89a51ee9fd0)) * **picker.projects:** default to tcd instead of cd ([3d2a075](https://github.com/folke/snacks.nvim/commit/3d2a07503f0724794a7e262a2f570a13843abedf)) * **picker.projects:** enabled frecency for projects picker ([5a20565](https://github.com/folke/snacks.nvim/commit/5a2056575faa50f788293ee787b803c15f42a9e0)) * **picker.projects:** projects now automatically processes dev folders and added a bunch of actions/keymaps. Closes [#871](https://github.com/folke/snacks.nvim/issues/871) ([6f8f0d3](https://github.com/folke/snacks.nvim/commit/6f8f0d3c727fe035dffc0bc4c1843e2a06eee1f2)) * **picker.undo:** better undo tree visualization. Closes [#863](https://github.com/folke/snacks.nvim/issues/863) ([44b8e38](https://github.com/folke/snacks.nvim/commit/44b8e3820493ca774cd220e3daf85c16954e74c7)) * **picker.undo:** make diff opts for undo configurable ([d61fb45](https://github.com/folke/snacks.nvim/commit/d61fb453c6c23976759e16a33fd8d6cb79cc59bc)) * **picker:** added support for double cliking and confirm ([8b26bae](https://github.com/folke/snacks.nvim/commit/8b26bae6bb01db22dbd3c6f868736487265025c0)) * **picker:** automatically download sqlite3.dll on Windows when using frecency / history for the first time. ([65907f7](https://github.com/folke/snacks.nvim/commit/65907f75ba52c09afc16e3d8d3c7ac67a3916237)) * **picker:** beter API to interact with active pickers. Closes [#851](https://github.com/folke/snacks.nvim/issues/851) ([6a83373](https://github.com/folke/snacks.nvim/commit/6a8337396ad843b27bdfe0a03ac2ce26ccf13092)) * **picker:** better health checks. Fixes [#855](https://github.com/folke/snacks.nvim/issues/855) ([d245925](https://github.com/folke/snacks.nvim/commit/d2459258f1a56109a2ad506f4a4dd6c69f2bb9f2)) * **picker:** history per source. Closes [#843](https://github.com/folke/snacks.nvim/issues/843) ([35295e0](https://github.com/folke/snacks.nvim/commit/35295e0eb2ee261e6173545190bc6c181fd08067)) ### Bug Fixes * **dashboard:** open pull requests with P instead of p in the github exmaple ([b2815d7](https://github.com/folke/snacks.nvim/commit/b2815d7f79e82d09cde5c9bb8e6fd13976b4d618)) * **dashboard:** update on VimResized and WinResized ([558b0ee](https://github.com/folke/snacks.nvim/commit/558b0ee04d0c6e1acf842774fbf9e02cce3efb0e)) * **explorer:** after search, cursor always jumped to top. Closes [#827](https://github.com/folke/snacks.nvim/issues/827) ([d17449e](https://github.com/folke/snacks.nvim/commit/d17449ee90b78843a22ee12ae29c3c110b28eac7)) * **explorer:** always use `--follow` to make sure we see dir symlinks as dirs. Fixes [#814](https://github.com/folke/snacks.nvim/issues/814) ([151fd3d](https://github.com/folke/snacks.nvim/commit/151fd3d62d73e0ec122bb243003c3bd59d53f8ef)) * **explorer:** cwd is now changed automatically, so no need to update state. ([5549d4e](https://github.com/folke/snacks.nvim/commit/5549d4e848b865ad4cc5bbb9bdd9487d631c795b)) * **explorer:** don't disable netrw fully. Just the autocmd that loads a directory ([836eb9a](https://github.com/folke/snacks.nvim/commit/836eb9a4e9ca0d7973f733203871d70691447c2b)) * **explorer:** don't try to show when closed. Fixes [#836](https://github.com/folke/snacks.nvim/issues/836) ([6921cd0](https://github.com/folke/snacks.nvim/commit/6921cd06ac7b530d786b2282afdfce67762008f1)) * **explorer:** fix git status sorting ([551d053](https://github.com/folke/snacks.nvim/commit/551d053c7ccc635249c262a5ea38b5d7aa814b3a)) * **explorer:** fixed hierarchical sorting. Closes [#828](https://github.com/folke/snacks.nvim/issues/828) ([fa32e20](https://github.com/folke/snacks.nvim/commit/fa32e20e9910f8071979f16788832027d1e25850)) * **explorer:** keep global git status cache ([a54a21a](https://github.com/folke/snacks.nvim/commit/a54a21adc0e67b97fb787adcbaaf4578c6f44476)) * **explorer:** remove sleep :) ([efbc4a1](https://github.com/folke/snacks.nvim/commit/efbc4a12af6aae39dadeab0badb84d04a94d5f85)) * **git:** use os.getenv to get env var. Fixes [#5504](https://github.com/folke/snacks.nvim/issues/5504) ([16d700e](https://github.com/folke/snacks.nvim/commit/16d700eb65fc320a5ab8e131d8f5d185b241887b)) * **layout:** adjust zindex when needed when another layout is already open. Closes [#826](https://github.com/folke/snacks.nvim/issues/826) ([ab8af1b](https://github.com/folke/snacks.nvim/commit/ab8af1bb32a4d9f82156122056d07a0850c2a828)) * **layout:** destroy in schedule. Fixes [#861](https://github.com/folke/snacks.nvim/issues/861) ([c955a8d](https://github.com/folke/snacks.nvim/commit/c955a8d1ef543fd56907d5291e92e62fd944db9b)) * **picker.actions:** fix split/vsplit/tab. Closes [#818](https://github.com/folke/snacks.nvim/issues/818) ([ff02241](https://github.com/folke/snacks.nvim/commit/ff022416dd6e6dade2ee822469d0087fcf3e0509)) * **picker.actions:** pass edit commands to jump. Closes [#859](https://github.com/folke/snacks.nvim/issues/859) ([df0e3e3](https://github.com/folke/snacks.nvim/commit/df0e3e3d861fd7b8ab85b3e8dbca97817b3d6604)) * **picker.actions:** reworked split/vsplit/drop/tab cmds to better do what's intended. Closes [#854](https://github.com/folke/snacks.nvim/issues/854) ([2946875](https://github.com/folke/snacks.nvim/commit/2946875af09f5f439ce64b78da8da6cf28523b8c)) * **picker.actions:** tab -> tabnew. Closes [#842](https://github.com/folke/snacks.nvim/issues/842) ([d962d5f](https://github.com/folke/snacks.nvim/commit/d962d5f3359dc91da7aa54388515fd0b03a2fe8b)) * **picker.explorer:** do LSP stuff on move ([894ff74](https://github.com/folke/snacks.nvim/commit/894ff749300342593007e6366894b681b3148f19)) * **picker.explorer:** use cached git status ([1ce435c](https://github.com/folke/snacks.nvim/commit/1ce435c6eb161feae63c8ddfe3e1aaf98b2aa41d)) * **picker.format:** extra slash in path ([dad3e00](https://github.com/folke/snacks.nvim/commit/dad3e00e83ec8a8af92e778e29f2fe200ad0d969)) * **picker.format:** use item.file for filename_only ([e784a9e](https://github.com/folke/snacks.nvim/commit/e784a9e6723371f8f453a92edb03d68428da74cc)) * **picker.git_log:** add extra space between the date and the message ([#885](https://github.com/folke/snacks.nvim/issues/885)) ([d897ead](https://github.com/folke/snacks.nvim/commit/d897ead2b78a73a186a3cb1b735a10f2606ddb36)) * **picker.keymaps:** added normalized lhs to search text ([fbd39a4](https://github.com/folke/snacks.nvim/commit/fbd39a48df085a7df979a06b1003faf86625c157)) * **picker.lazy:** don't use live searches. Fixes [#809](https://github.com/folke/snacks.nvim/issues/809) ([1a5fd93](https://github.com/folke/snacks.nvim/commit/1a5fd93b89904b8f8029e6ee74e6d6ada87f28c5)) * **picker.lines:** col is first non-whitespace. Closes [#806](https://github.com/folke/snacks.nvim/issues/806) ([ec8eb60](https://github.com/folke/snacks.nvim/commit/ec8eb6051530261e7d0e5566721e5c396c1ed6cd)) * **picker.list:** better virtual scrolling that works from any window ([4a50291](https://github.com/folke/snacks.nvim/commit/4a502914486346940389a99690578adca9a820bb)) * **picker.matcher:** fix cwd_bonus check ([00af290](https://github.com/folke/snacks.nvim/commit/00af2909064433ee84280dd64233a34b0f8d6027)) * **picker.matcher:** regex offset by one. Fixes [#878](https://github.com/folke/snacks.nvim/issues/878) ([9a82f0a](https://github.com/folke/snacks.nvim/commit/9a82f0a564df3e4f017e9b66da6baa41196962b7)) * **picker.undo:** add newlines ([72826a7](https://github.com/folke/snacks.nvim/commit/72826a72de93f49b2446c691e3bef04df1a44dde)) * **picker.undo:** cleanup tmp buffer ([8368176](https://github.com/folke/snacks.nvim/commit/83681762435a425ab1edb10fe3244b3e8b1280c2)) * **picker.undo:** copy over buffer lines instead of just the file ([c900e2c](https://github.com/folke/snacks.nvim/commit/c900e2cb3ab83c299c95756fc34e4ae52f4e72e9)) * **picker.undo:** disable swap for tmp undo buffer ([033db25](https://github.com/folke/snacks.nvim/commit/033db250cd688872724a84deb623b599662d79c5)) * **picker:** better main window management. Closes [#842](https://github.com/folke/snacks.nvim/issues/842) ([f0f053a](https://github.com/folke/snacks.nvim/commit/f0f053a1d9b16edaa27f05e20ad6fd862db8c6f7)) * **picker:** improve resume. Closes [#853](https://github.com/folke/snacks.nvim/issues/853) ([0f5b30b](https://github.com/folke/snacks.nvim/commit/0f5b30b41196d831cda84e4b792df2ce765fd856)) * **picker:** make pick_win work with split layouts. Closes [#834](https://github.com/folke/snacks.nvim/issues/834) ([6dbc267](https://github.com/folke/snacks.nvim/commit/6dbc26757cb043c8153a4251a1f75bff4dcadf68)) * **picker:** multi layouts that need async task work again. ([cd44efb](https://github.com/folke/snacks.nvim/commit/cd44efb60ce70382de02d069e269bb40e5e7fa22)) * **picker:** no auto-close when entering a floating window ([08e6c12](https://github.com/folke/snacks.nvim/commit/08e6c12358d57dfb497f8ce7de7eb09134868dc7)) * **picker:** no need to track jumping ([b37ea74](https://github.com/folke/snacks.nvim/commit/b37ea748b6ff56cd479600b1c39d19a308ee7eae)) * **picker:** propagate WinEnter when going to the real window after entering the layout split window ([8555789](https://github.com/folke/snacks.nvim/commit/8555789d86f7f6127fdf023723775207972e0c44)) * **picker:** show regex matches in list when needed. Fixes [#878](https://github.com/folke/snacks.nvim/issues/878) ([1d99bac](https://github.com/folke/snacks.nvim/commit/1d99bac9bcf75a11adc6cd78cde4477a95f014bd)) * **picker:** show_empty for files / grep. Closes [#808](https://github.com/folke/snacks.nvim/issues/808) ([a13ff6f](https://github.com/folke/snacks.nvim/commit/a13ff6fe0f68c3242d6be5e352d762b6037a9695)) * **util:** better default icons when no icon plugin is installed ([0e4ddfd](https://github.com/folke/snacks.nvim/commit/0e4ddfd3ee1d81def4028e52e44e45ac3ce98cfc)) * **util:** better keymap normalization ([e1566a4](https://github.com/folke/snacks.nvim/commit/e1566a483df1badc97729f66b1faf358d2bd3362)) * **util:** normkey. Closes [#763](https://github.com/folke/snacks.nvim/issues/763) ([6972960](https://github.com/folke/snacks.nvim/commit/69729608e101923810a13942f0b3bef98f253592)) * **win:** close help when leaving the win buffer ([4aba559](https://github.com/folke/snacks.nvim/commit/4aba559c6e321f90524a2e8164b8fd1f9329552e)) ### Performance Improvements * **explorer:** don't wait till git status finished. Update tree when needed. See [#869](https://github.com/folke/snacks.nvim/issues/869) ([287db30](https://github.com/folke/snacks.nvim/commit/287db30ed21dc2a8be80fabcffcec9b1b878e04e)) * **explorer:** use cache when possible for opening/closing directories. Closes [#869](https://github.com/folke/snacks.nvim/issues/869) ([cef4fc9](https://github.com/folke/snacks.nvim/commit/cef4fc91813ba6e6932db88a1c9e82a30ea51349)) * **git:** also check top-level path to see if it's a git root. Closes [#807](https://github.com/folke/snacks.nvim/issues/807) ([b9e7c51](https://github.com/folke/snacks.nvim/commit/b9e7c51e8f7eea876275e52f1083b58f9d2df92f)) * **picker.files:** no need to check every time for cmd availability ([8f87c2c](https://github.com/folke/snacks.nvim/commit/8f87c2c32bbb75a4fad4f5768d5faa963c4f66d8)) * **picker.undo:** more performance improvements for the undo picker ([3d4b8ee](https://github.com/folke/snacks.nvim/commit/3d4b8eeea9380eb7488217af74f9448eaa7b376e)) * **picker.undo:** use a tmp buffer to get diffs. Way faster than before. Closes [#863](https://github.com/folke/snacks.nvim/issues/863) ([d4a5449](https://github.com/folke/snacks.nvim/commit/d4a54498131a5d9027bccdf2cd0edd2d22594ce7)) ## [2.17.0](https://github.com/folke/snacks.nvim/compare/v2.16.0...v2.17.0) (2025-01-30) ### Features * **picker.actions:** allow selecting the visual selection with `<Tab>` ([96c76c6](https://github.com/folke/snacks.nvim/commit/96c76c6d9d401c2205d73639389b32470c550e6a)) * **picker.explorer:** focus dir on confirm from search ([605f745](https://github.com/folke/snacks.nvim/commit/605f7451984f0011635423571ad83ab74f342ed8)) ### Bug Fixes * **git:** basic support for git work trees ([d76d9aa](https://github.com/folke/snacks.nvim/commit/d76d9aaaf2399c6cf15c5b37a9183680b106a4af)) * **picker.preview:** properly refresh the preview after layout changes. Fixes [#802](https://github.com/folke/snacks.nvim/issues/802) ([47993f9](https://github.com/folke/snacks.nvim/commit/47993f9a809ce13e98c3132d463d5a3002289fd6)) * **picker:** add proper close ([15a9411](https://github.com/folke/snacks.nvim/commit/15a94117e17d78c8c2e579d20988d4cb9e85d098)) * **picker:** make jumping work again... ([f40f338](https://github.com/folke/snacks.nvim/commit/f40f338d669bf2d54b224e4a973c52c8157fe505)) * **picker:** show help for input / list window with `?`. ([87dab7e](https://github.com/folke/snacks.nvim/commit/87dab7eca7949b85c0ee688f86c08b8c437f9433)) * **win:** properly handle closing the last window. Fixes [#793](https://github.com/folke/snacks.nvim/issues/793) ([18de5bb](https://github.com/folke/snacks.nvim/commit/18de5bb23898fa2055afee5035c97a2abe4aae6b)) ## [2.16.0](https://github.com/folke/snacks.nvim/compare/v2.15.0...v2.16.0) (2025-01-30) ### Features * **layout:** added support for split layouts (root box can be a split) ([6da592e](https://github.com/folke/snacks.nvim/commit/6da592e130295388ee64fe282eb0dafa0b99fa2f)) * **layout:** make fullscreen work for split layouts ([115f8c6](https://github.com/folke/snacks.nvim/commit/115f8c6ae9c9a57b36677b728a6f6cc9207c6489)) * **picker.actions:** added separate hl group for pick win current ([9b80e44](https://github.com/folke/snacks.nvim/commit/9b80e444f548aea26214a95ad9e1affc4ef5d91c)) * **picker.actions:** separate edit_split etc in separate split and edit actions. Fixes [#791](https://github.com/folke/snacks.nvim/issues/791) ([3564f4f](https://github.com/folke/snacks.nvim/commit/3564f4fede6feefdfe1dc200295eb3b162996d6d)) * **picker.config:** added `opts.config` which can be a function that can change the resolved options ([b37f368](https://github.com/folke/snacks.nvim/commit/b37f368a81d0a6ce8b7c9f683f9c0c736af6de36)) * **picker.explorer:** added `explorer_move` action mapped to `m` ([08b9083](https://github.com/folke/snacks.nvim/commit/08b9083f4759c87c93f6afb4af0a1f3d2b8ad1fa)) * **picker.explorer:** live search ([db52796](https://github.com/folke/snacks.nvim/commit/db52796e79c63dfa0d5d689d5d13b120f6184642)) * **picker.files:** allow forcing the files finder to use a certain cmd ([3e1dc30](https://github.com/folke/snacks.nvim/commit/3e1dc300cc98815ad74ae11c98f7ebebde966c39)) * **picker.format:** better path formatting for directories ([08f3c32](https://github.com/folke/snacks.nvim/commit/08f3c32c7d64a81ea35d1cb0d22fc140d25c9088)) * **picker.format:** directory formatting ([847509e](https://github.com/folke/snacks.nvim/commit/847509e12c0cd95355cb05c97e1bc8bedde29957)) * **picker.jump:** added `opts.jump.close`, which default to true, but is false for explorer ([a9591ed](https://github.com/folke/snacks.nvim/commit/a9591ed43f4de3b611028eadce7d36c4b3dedca8)) * **picker.list:** added support for setting a cursor/topline target for the next render. Target clears when reached, or when finders finishes. ([da08379](https://github.com/folke/snacks.nvim/commit/da083792053e41c57e8ca5e9fe9e5b175b1e378d)) * **picker:** `opts.focus = "input"|"list"|false` to configure what to focus (if anything) when showing the picker ([5a8d798](https://github.com/folke/snacks.nvim/commit/5a8d79847b1959f9c9515b51a062f6acbe22f1a4)) * **picker:** `picker:iter()` now also returns `idx` ([118d908](https://github.com/folke/snacks.nvim/commit/118d90899d7b2bb0a28a799dbf2a21ed39516e66)) * **picker:** added `edit_win` action bound to `ctrl+enter` to pick a window and edit ([2ba5be8](https://github.com/folke/snacks.nvim/commit/2ba5be84910d14454292423f08ad83ea213de2ba)) * **picker:** added `git_stash` picker. Closes [#762](https://github.com/folke/snacks.nvim/issues/762) ([bb3db11](https://github.com/folke/snacks.nvim/commit/bb3db117a45da1dabe76f08a75144b028314e6b6)) * **picker:** added `notifications` picker. Closes [#738](https://github.com/folke/snacks.nvim/issues/738) ([32cffd2](https://github.com/folke/snacks.nvim/commit/32cffd2e603ccace129b62c777933a42203c5c77)) * **picker:** added support for split layouts to picker (sidebar and ivy_split) ([5496c22](https://github.com/folke/snacks.nvim/commit/5496c22b6e20a26d2252543029faead946cc2ce9)) * **picker:** added support to keep the picker open when focusing another window (auto_close = false) ([ad8f166](https://github.com/folke/snacks.nvim/commit/ad8f16632c63a3082ea0e80a39cdbd774624532a)) * **picker:** new file explorer `Snacks.picker.explorer()` ([00613bd](https://github.com/folke/snacks.nvim/commit/00613bd4163c89e01c1d534d283cfe531773fdc8)) * **picker:** opening a picker with the same source as an active picker, will close it instead (toggle) ([b6cf033](https://github.com/folke/snacks.nvim/commit/b6cf033051aa2f859d9d217bc60e89806fcf5377)) * **picker:** reworked toggles (flags). they're now configurable. Closes [#770](https://github.com/folke/snacks.nvim/issues/770) ([e16a6a4](https://github.com/folke/snacks.nvim/commit/e16a6a441322c944b41e9ae5b30ba816145218cd)) * **rename:** optional `file`, `on_rename` for `Snacks.rename.rename_file()` ([9d8c277](https://github.com/folke/snacks.nvim/commit/9d8c277bebb9483b1d46c7eeeff348966076347f)) ### Bug Fixes * **bigfile:** check if buf still exists when applying syntax. Fixes [#737](https://github.com/folke/snacks.nvim/issues/737) ([08852ac](https://github.com/folke/snacks.nvim/commit/08852ac7f811f51d47a590d62df805d0e84e611a)) * **bufdelete:** ctrl-c throw error in `fn.confirm` ([#750](https://github.com/folke/snacks.nvim/issues/750)) ([3a3e795](https://github.com/folke/snacks.nvim/commit/3a3e79535bb085815d3add9f678d30b98b5f900f)) * **bufdelete:** invalid lua ([b1f4f99](https://github.com/folke/snacks.nvim/commit/b1f4f99a51ef1ca11a0c802b847501b71f09161b)) * **dashboard:** better handling of closed dashboard win ([6cb7fdf](https://github.com/folke/snacks.nvim/commit/6cb7fdfb036239b9c1b6d147633e494489a45191)) * **dashboard:** don't override user configuration ([#774](https://github.com/folke/snacks.nvim/issues/774)) ([5ff2ad3](https://github.com/folke/snacks.nvim/commit/5ff2ad320b0cd1e17d48862c74af0df205894f37)) * **dashboard:** fix dasdhboard when opening in a new win. Closes [#767](https://github.com/folke/snacks.nvim/issues/767) ([d44b978](https://github.com/folke/snacks.nvim/commit/d44b978d7dbe7df8509a172cef0913b5a9ac77e3)) * **dashboard:** prevent starting picker twice when no session manager. Fixes [#783](https://github.com/folke/snacks.nvim/issues/783) ([2f396b3](https://github.com/folke/snacks.nvim/commit/2f396b341dc1a072643eb402bfaa8a73f6be19a1)) * **filter:** insert path from `filter.paths` into `self.paths` ([#761](https://github.com/folke/snacks.nvim/issues/761)) ([ac20c6f](https://github.com/folke/snacks.nvim/commit/ac20c6ff5d0ac8747e164d592e8ae7e8f2581b2e)) * **layout:** better handling of resizing of split layouts ([c8ce9e2](https://github.com/folke/snacks.nvim/commit/c8ce9e2b33623d21901e02213319270936e4545f)) * **layout:** better update check for split layouts ([b50d697](https://github.com/folke/snacks.nvim/commit/b50d697ce45dbee5efe25371428b7f23b037d0ed)) * **layout:** make sure split layouts are still visible when a float layout with backdrop opens ([696d198](https://github.com/folke/snacks.nvim/commit/696d1981b18ad1f0cc0e480aafed78a064730417)) * **matcher.score:** correct prev_class for transition bonusses when in a gap. Fixes [#787](https://github.com/folke/snacks.nvim/issues/787) ([b45d0e0](https://github.com/folke/snacks.nvim/commit/b45d0e03579c80ac901261e0e2705a1be3dfcb20)) * **picker.actions:** detect and report circular action references ([0ffc003](https://github.com/folke/snacks.nvim/commit/0ffc00367a957c1602df745c2038600d48d96305)) * **picker.actions:** proper cr check ([6c9f866](https://github.com/folke/snacks.nvim/commit/6c9f866b3123cbc8cbef91f55593d30d98d4f26a)) * **picker.actions:** stop pick_win when no target and only unhide when picker wasn't stopped ([4a1d189](https://github.com/folke/snacks.nvim/commit/4a1d189f9f7afac4180f7a459597bb094d11435b)) * **picker.actions:** when only 1 win, `pick_win` will select that automatically. Show warning when no windows. See [#623](https://github.com/folke/snacks.nvim/issues/623) ([ba5a70b](https://github.com/folke/snacks.nvim/commit/ba5a70b84d12aa9e497cfea86d5358aa5cf0aad3)) * **picker.config:** fix wrong `opts.cwd = true` config. Closes [#757](https://github.com/folke/snacks.nvim/issues/757) ([ea44a2d](https://github.com/folke/snacks.nvim/commit/ea44a2d4c21aa10fb57fe08b974999f7b8d117d2)) * **picker.config:** normalize `opts.cwd` ([69c013e](https://github.com/folke/snacks.nvim/commit/69c013e1b27e2f70def48576aaffcc1081fa0e47)) * **picker.explorer:** fix cwd for items ([71070b7](https://github.com/folke/snacks.nvim/commit/71070b78f0482a42448da2cee64ed0d84c507314)) * **picker.explorer:** stop file follow when picker was closed ([89fcb3b](https://github.com/folke/snacks.nvim/commit/89fcb3bb2025cb1c986e9af3478715f6e0bdf425)) * **picker.explorer:** when searching, go to first non internal node in the list ([276497b](https://github.com/folke/snacks.nvim/commit/276497b3969cdefd18aa731c5e3d5c1bb8289cca)) * **picker.filter:** proper cwd check. See [#757](https://github.com/folke/snacks.nvim/issues/757) ([e4ae9e3](https://github.com/folke/snacks.nvim/commit/e4ae9e32295d688a1e0d3f59ab1ba4cc78d1ba89)) * **picker.git:** better stash pattern. Closes [#775](https://github.com/folke/snacks.nvim/issues/775) ([e960010](https://github.com/folke/snacks.nvim/commit/e960010496f860d1077f1e54d193e127ad7e26ad)) * **picker.git:** default to git root for `git_files`. Closes [#751](https://github.com/folke/snacks.nvim/issues/751) ([3cdebee](https://github.com/folke/snacks.nvim/commit/3cdebee880742970df1a1f685be4802b90642c7d)) * **picker.git:** ignore autostash ([2b15357](https://github.com/folke/snacks.nvim/commit/2b15357c25db315567f08e7ec8d5c85c94d0753f)) * **picker.git:** show diff for staged files. Fixes [#747](https://github.com/folke/snacks.nvim/issues/747) ([e87f0ff](https://github.com/folke/snacks.nvim/commit/e87f0ffcd100a3a6686549e25f480c1311f08d8f)) * **picker.layout:** fix list cursorline when layout updates ([3f43026](https://github.com/folke/snacks.nvim/commit/3f43026f579f33b679a924dea699df86e8b965b2)) * **picker.layout:** make split layouts work in layout preview ([215ae72](https://github.com/folke/snacks.nvim/commit/215ae72daaed5d7ee18b72e8b14bfd6a727bc939)) * **picker.lsp:** remove symbol detail from search text. too noisy ([92710df](https://github.com/folke/snacks.nvim/commit/92710dfb0bacc72a82e0172bb06f5eb9ad82964a)) * **picker.multi:** apply source filter settings for multi source pickers. See [#761](https://github.com/folke/snacks.nvim/issues/761) ([4e30ff0](https://github.com/folke/snacks.nvim/commit/4e30ff0f1ed58b0bdc8fd3f5f1a9a440959eb998)) * **picker.preview:** don't enable numbers when minimal=true ([04e2995](https://github.com/folke/snacks.nvim/commit/04e2995bbfc505d0fc91263712d0255f102f404e)) * **picker.preview:** don't error on invalid start positions for regex. Fixes [#784](https://github.com/folke/snacks.nvim/issues/784) ([460b58b](https://github.com/folke/snacks.nvim/commit/460b58bdbd57e32a1bed22b3e176fa53befeafaa)) * **picker.preview:** only show binary message when binary and no ft. Closes [#729](https://github.com/folke/snacks.nvim/issues/729) ([ea838e2](https://github.com/folke/snacks.nvim/commit/ea838e28383d74d60cd6d714cac9b007a4a4469a)) * **picker.resume:** fix picker is nil ([#772](https://github.com/folke/snacks.nvim/issues/772)) ([1a5a087](https://github.com/folke/snacks.nvim/commit/1a5a0871c822e5de8e69c10bb1d6cb3dfc2f5c86)) * **picker.score:** scoring closer to fzf. See [#787](https://github.com/folke/snacks.nvim/issues/787) ([390f017](https://github.com/folke/snacks.nvim/commit/390f017c3b227377c09ae6572f88b7c42304b811)) * **picker.select:** allow configuring `vim.ui.select` with the `select` source. Closes [#776](https://github.com/folke/snacks.nvim/issues/776) ([d70af2d](https://github.com/folke/snacks.nvim/commit/d70af2d253f61164a44a8676a5fc316cad10497f)) * **picker.util:** proper cwd check for paths. Fixes [#754](https://github.com/folke/snacks.nvim/issues/754). See [#757](https://github.com/folke/snacks.nvim/issues/757) ([1069d78](https://github.com/folke/snacks.nvim/commit/1069d783347a7a5213240e2e12e485ab57e15bd8)) * **picker:** better handling of win Enter/Leave mostly for split layouts ([046653a](https://github.com/folke/snacks.nvim/commit/046653a4f166633339a276999738bac43c3c1388)) * **picker:** don't destroy active pickers (only an issue when multiple pickers were open) ([b479f10](https://github.com/folke/snacks.nvim/commit/b479f10b24a8cf5325bc575e1bab2fc51ebfde45)) * **picker:** only do preview scrolling when preview is scrolling and removed default preview horizontal scrolling keymaps ([a998c71](https://github.com/folke/snacks.nvim/commit/a998c714c31ab92a06b9edafef71251b63f0eb5b)) * **picker:** show new notifications on top ([0df7c0b](https://github.com/folke/snacks.nvim/commit/0df7c0bef59be861f3e6682aa1381f6422f4a0af)) * **picker:** split edit_win in `{"pick_win", "jump"}` ([dcd3bc0](https://github.com/folke/snacks.nvim/commit/dcd3bc03295a8521773c04671298bd3fdcb14f7b)) * **picker:** stopinsert again ([2250c57](https://github.com/folke/snacks.nvim/commit/2250c57529b1a8da4d96966db1cd9a46b73d8007)) * **win:** don't destroy opts. Fixes [#726](https://github.com/folke/snacks.nvim/issues/726) ([473be03](https://github.com/folke/snacks.nvim/commit/473be039e59730b0554a7dfda2eb800ecf7a948e)) * **win:** error when enabling padding with `listchars=""` ([#786](https://github.com/folke/snacks.nvim/issues/786)) ([6effbcd](https://github.com/folke/snacks.nvim/commit/6effbcdff110c16f49c2cef5d211db86f6db5820)) ### Performance Improvements * **picker.matcher:** optimize matcher priorities and skip items that can't match for pattern subset ([dfaa18d](https://github.com/folke/snacks.nvim/commit/dfaa18d1c72a78cacfe0a682c853b7963641444c)) * **picker.recent:** correct generator for old files ([5f32414](https://github.com/folke/snacks.nvim/commit/5f32414dd645ab7650dc60379f422b00aaecea4f)) * **picker.score:** no need to track `in_gap` status. redundant since we can depend on `gap` instead ([fdf4b0b](https://github.com/folke/snacks.nvim/commit/fdf4b0bf26743eef23e645235915aa4920827daf)) ## [2.15.0](https://github.com/folke/snacks.nvim/compare/v2.14.0...v2.15.0) (2025-01-23) ### Features * **debug:** truncate inspect to 2000 lines max ([570d219](https://github.com/folke/snacks.nvim/commit/570d2191d598d344ddd5b2a85d8e79d207955cc3)) * **input:** input history. Closes [#591](https://github.com/folke/snacks.nvim/issues/591) ([80db91f](https://github.com/folke/snacks.nvim/commit/80db91f03e3493e9b3aa09d1cd90b063ae0ec31c)) * **input:** persistent history. Closes [#591](https://github.com/folke/snacks.nvim/issues/591) ([0ed68bd](https://github.com/folke/snacks.nvim/commit/0ed68bdf7268bf1baef7a403ecc799f2c016b656)) * **picker.debug:** more info about potential leaks ([8d9677f](https://github.com/folke/snacks.nvim/commit/8d9677fc479710ae1f531fc52b0ac368def55b0b)) * **picker.filter:** Filter arg for filter ([5a4b684](https://github.com/folke/snacks.nvim/commit/5a4b684c0dd3eda10ce86f9710e085431a7656f2)) * **picker.finder:** optional transform function ([5e69fb8](https://github.com/folke/snacks.nvim/commit/5e69fb83d50bb79ff62352418733d11562e488d0)) * **picker.format:** `filename_only` option ([0396bdf](https://github.com/folke/snacks.nvim/commit/0396bdfc3eece8438ed6a978f1dbddf3f675ca36)) * **picker.git:** git_log, git_log_file, git_log_line now do git_checkout as confirm. Closes [#722](https://github.com/folke/snacks.nvim/issues/722) ([e6fb538](https://github.com/folke/snacks.nvim/commit/e6fb5381a9bfcbd0f1693ea9c7e3711045380187)) * **picker.help:** add more color to help tags ([5778234](https://github.com/folke/snacks.nvim/commit/5778234e3917999a0be1a5b8145dd83ab41035b3)) * **picker.keymaps:** add global + buffer toggles ([#705](https://github.com/folke/snacks.nvim/issues/705)) ([b7c08df](https://github.com/folke/snacks.nvim/commit/b7c08df2b8ff23e0293cfe06beaf60aa6fd14efc)) * **picker.keymaps:** improvements to keymaps picker ([2762c37](https://github.com/folke/snacks.nvim/commit/2762c37eb09bc434eba647d4ec079d6064d3c563)) * **picker.matcher:** frecency and cwd bonus can now be enabled on any picker ([7b85dfc](https://github.com/folke/snacks.nvim/commit/7b85dfc6f60538b0419ca1b969553891b64cd9b8)) * **picker.multi:** multi now also merges keymaps ([8b2c78a](https://github.com/folke/snacks.nvim/commit/8b2c78a3bf5a3ca52c8c9e46b9d15c288c59c5c1)) * **picker.preview:** better positioning of preview location ([3864955](https://github.com/folke/snacks.nvim/commit/38649556ee9f831e5d456043a796ae0fb115f8eb)) * **picker.preview:** fallback highlight of results when no `end_pos`. Mostly useful for grep. ([d12e454](https://github.com/folke/snacks.nvim/commit/d12e45433960210a16a37adc116e645e253578c1)) * **picker.smart:** add bufdelete actions from buffers picker ([#679](https://github.com/folke/snacks.nvim/issues/679)) ([67fbab1](https://github.com/folke/snacks.nvim/commit/67fbab1bf7f5c436e28af715097eecb2232eea59)) * **picker.smart:** re-implemented smart as multi-source picker ([450d1d4](https://github.com/folke/snacks.nvim/commit/450d1d4d4c218ac1df63924d29717caa61c98f27)) * **picker.util:** smart path truncate. Defaults to 40. Closes [#708](https://github.com/folke/snacks.nvim/issues/708) ([bab8243](https://github.com/folke/snacks.nvim/commit/bab8243395de8d8748a7295906bee7723b7b8190)) * **picker:** added `lazy` source to search for a plugin spec. Closes [#680](https://github.com/folke/snacks.nvim/issues/680) ([d03bd00](https://github.com/folke/snacks.nvim/commit/d03bd00ced5a03f1cb317b9227a6a1812e35a6c2)) * **picker:** added `opts.rtp` (bool) to find/grep over files in the rtp. See [#680](https://github.com/folke/snacks.nvim/issues/680) ([9d5d3bd](https://github.com/folke/snacks.nvim/commit/9d5d3bdb1747669d74587662cce93021fc99f878)) * **picker:** added new `icons` picker for nerd fonts and emoji. Closes [#703](https://github.com/folke/snacks.nvim/issues/703) ([97898e9](https://github.com/folke/snacks.nvim/commit/97898e910d92e50e886ba044ab255360e4271ffc)) * **picker:** getters and setters for cwd ([2c2ff4c](https://github.com/folke/snacks.nvim/commit/2c2ff4caf85ba1cfee3d946ea6ab9fd595ec3667)) * **picker:** multi source picker. Combine multiple pickers (as opposed to just finders) in one picker ([9434986](https://github.com/folke/snacks.nvim/commit/9434986ff15acfca7e010f159460f9ecfee81363)) * **picker:** persistent history. Closes [#528](https://github.com/folke/snacks.nvim/issues/528) ([ea665eb](https://github.com/folke/snacks.nvim/commit/ea665ebad18a8ccd6444df7476237de4164af64a)) * **picker:** preview window horizontal scrolling ([#686](https://github.com/folke/snacks.nvim/issues/686)) ([bc47e0b](https://github.com/folke/snacks.nvim/commit/bc47e0b1dd0102b58a90aba87f22e0cc0a48985c)) * **picker:** syntax highlighting for command and search history ([efb6d1f](https://github.com/folke/snacks.nvim/commit/efb6d1f8b8057e5f8455452c47ab769358902a18)) * **profiler:** added support for `Snacks.profiler` and dropped support for fzf-lua / telescope. Closes [#695](https://github.com/folke/snacks.nvim/issues/695) ([ada83de](https://github.com/folke/snacks.nvim/commit/ada83de9528d0825928726a6d252fb97271fb73a)) ### Bug Fixes * **picker.actions:** `checktime` after `git_checkout` ([b86d90e](https://github.com/folke/snacks.nvim/commit/b86d90e3e9c68f4d24a0208e873d35b0074c12b0)) * **picker.async:** better handling of abort and schedule/defer util function ([dfcf27e](https://github.com/folke/snacks.nvim/commit/dfcf27e2a90d4b262d2bd0e54c1b576dba296c73)) * **picker.async:** fixed aborting a coroutine from the coroutine itself. See [#665](https://github.com/folke/snacks.nvim/issues/665) ([c1e2c61](https://github.com/folke/snacks.nvim/commit/c1e2c619b229a3f2ccdc000a6fadddc7ca9f482d)) * **picker.files:** include symlinks ([dc9c6fb](https://github.com/folke/snacks.nvim/commit/dc9c6fbd028e0488a9292e030c788b72b16cbeca)) * **picker.frecency:** track visit on BufWinEnter instead of BufReadPost and exclude floating windows ([024a448](https://github.com/folke/snacks.nvim/commit/024a448e52563aadf9e5b234ddfb17168aa5ada7)) * **picker.git_branches:** handle detached HEAD ([#671](https://github.com/folke/snacks.nvim/issues/671)) ([390f687](https://github.com/folke/snacks.nvim/commit/390f6874318addcf48b668f900ef62d316c44602)) * **picker.git:** `--follow` only works for `git_log_file`. Closes [#666](https://github.com/folke/snacks.nvim/issues/666) ([23a8668](https://github.com/folke/snacks.nvim/commit/23a8668ef0b0c9d910c7bbcd57651d8889b0fa65)) * **picker.git:** parse all detached states. See [#671](https://github.com/folke/snacks.nvim/issues/671) ([2cac667](https://github.com/folke/snacks.nvim/commit/2cac6678a95f89a7e23ed668c9634eff9e60dbe5)) * **picker.grep:** off-by-one for grep results col ([e3455ef](https://github.com/folke/snacks.nvim/commit/e3455ef4dc96fac3b53f76e12c487007a5fca9e7)) * **picker.icons:** bump build for nerd fonts ([ba108e2](https://github.com/folke/snacks.nvim/commit/ba108e2aa86909168905e522342859ec9ed4e220)) * **picker.icons:** fix typo in Nerd Fonts and display the full category name ([#716](https://github.com/folke/snacks.nvim/issues/716)) ([a4b0a85](https://github.com/folke/snacks.nvim/commit/a4b0a85e3bc68fe1aeca1ee4161053dabaeb856c)) * **picker.icons:** opts.icons -> opts.icon_sources. Fixes [#715](https://github.com/folke/snacks.nvim/issues/715) ([9e7bfc0](https://github.com/folke/snacks.nvim/commit/9e7bfc05d5e4a0f079f695cdd6869c219c762224)) * **picker.input:** better handling of `stopinsert` with prompt buffers. Closes [#723](https://github.com/folke/snacks.nvim/issues/723) ([c2916cb](https://github.com/folke/snacks.nvim/commit/c2916cb526d42fb6726cf9f7252ceb44516fc2f6)) * **picker.input:** correct cursor position in input when cycling / focus. Fixes [#688](https://github.com/folke/snacks.nvim/issues/688) ([93cca7a](https://github.com/folke/snacks.nvim/commit/93cca7a4b3923c291726305b301a51316b275bf2)) * **picker.lsp:** include_current on Windows. Closes [#678](https://github.com/folke/snacks.nvim/issues/678) ([66d2854](https://github.com/folke/snacks.nvim/commit/66d2854ea0c83339042b6340b29dfdc48982e75a)) * **picker.lsp:** make `lsp_symbols` work for unloaded buffers ([9db49b7](https://github.com/folke/snacks.nvim/commit/9db49b7e6c5ded7edeff8bec6327322fb6125695)) * **picker.lsp:** schedule_wrap cancel functions and resume when no clients ([6cbca8a](https://github.com/folke/snacks.nvim/commit/6cbca8adffd4014e9f67ba327f9c164f0412b685)) * **picker.lsp:** use async from ctx ([b878caa](https://github.com/folke/snacks.nvim/commit/b878caaddc7b91386ec95b3b2f034b275dc7f49a)) * **picker.lsp:** use correct buf/win ([8006caa](https://github.com/folke/snacks.nvim/commit/8006caadb3eedf2553a587497c508c01aadf098b)) * **picker.preview:** clear buftype for file previews ([5429dff](https://github.com/folke/snacks.nvim/commit/5429dff1cd51ceaa10134dbff4faf447823de017)) * **picker.undo:** use new API. Closes [#707](https://github.com/folke/snacks.nvim/issues/707) ([79a6eab](https://github.com/folke/snacks.nvim/commit/79a6eabd318d2b65d5786c4e3c2419eaa91c6240)) * **picker.util:** for `--` args require a space before ([ee6f21b](https://github.com/folke/snacks.nvim/commit/ee6f21bbd636e82691a0386f39f0c8310f8cadd8)) * **picker.util:** more relaxed resolve_loc ([964beb1](https://github.com/folke/snacks.nvim/commit/964beb11489afc2a2a1004bbb1b2b3286da9a8ac)) * **picker.util:** prevent empty shortened paths if it's the cwd. Fixes [#721](https://github.com/folke/snacks.nvim/issues/721) ([14f16ce](https://github.com/folke/snacks.nvim/commit/14f16ceb5d4dc53ddbd9b56992335658105d1d5f)) * **picker:** better handling of buffers with custom URIs. Fixes [#677](https://github.com/folke/snacks.nvim/issues/677) ([cd5eddb](https://github.com/folke/snacks.nvim/commit/cd5eddb1dea0ab69a451702395104cf716678b36)) * **picker:** don't jump to invalid positions. Fixes [#712](https://github.com/folke/snacks.nvim/issues/712) ([51adb67](https://github.com/folke/snacks.nvim/commit/51adb6792e1819c9cf0153606f506403f97647fe)) * **picker:** don't try showing the preview when the picker is closed. Fixes [#714](https://github.com/folke/snacks.nvim/issues/714) ([11c0761](https://github.com/folke/snacks.nvim/commit/11c07610557f0a6c6eb40bca60c60982ff6e3b93)) * **picker:** resume. Closes [#709](https://github.com/folke/snacks.nvim/issues/709) ([9b55a90](https://github.com/folke/snacks.nvim/commit/9b55a907bd0468752c3e5d9cd7e607cab206a6d7)) * **picker:** starting a picker from the picker sometimes didnt start in insert mode. Fixes [#718](https://github.com/folke/snacks.nvim/issues/718) ([08d4f14](https://github.com/folke/snacks.nvim/commit/08d4f14cd85466fd37d63b7123437c7d15bc87f6)) * **picker:** update title on find. Fixes [#717](https://github.com/folke/snacks.nvim/issues/717) ([431a24e](https://github.com/folke/snacks.nvim/commit/431a24e24e2a7066e44272f83410d7b44f497e26)) * **scroll:** handle buffer changes while animating ([3da0b0e](https://github.com/folke/snacks.nvim/commit/3da0b0ec11dff6c88e68c91194688c9ff3513e86)) * **win:** better way of finding a main window when fixbuf is `true` ([84ee7dd](https://github.com/folke/snacks.nvim/commit/84ee7ddf543aa1249ca4e29873200073e28f693f)) * **zen:** parent buf. Fixes [#720](https://github.com/folke/snacks.nvim/issues/720) ([f314676](https://github.com/folke/snacks.nvim/commit/f31467637ac91406efba15981d53cd6da09718e0)) ### Performance Improvements * **picker.frecency:** cache all deadlines on load ([5b3625b](https://github.com/folke/snacks.nvim/commit/5b3625bcea5ed78e7cddbeb038159a0041110c71)) * **picker:** gc optims ([3fa2ea3](https://github.com/folke/snacks.nvim/commit/3fa2ea3115c2e8203ec44025ff4be054c5f1e917)) * **picker:** small optims ([ee76e9b](https://github.com/folke/snacks.nvim/commit/ee76e9ba674e6b67a3d687868f27751745e2baad)) * **picker:** small optims for abort ([317a209](https://github.com/folke/snacks.nvim/commit/317a2093ea0cdd62a34f3a414e625f3313e5e2e8)) * **picker:** use picker ref in progress updater. Fixes [#710](https://github.com/folke/snacks.nvim/issues/710) ([37f3888](https://github.com/folke/snacks.nvim/commit/37f3888dccc922e4044ee1713c25dba51b4290d2)) ## [2.14.0](https://github.com/folke/snacks.nvim/compare/v2.13.0...v2.14.0) (2025-01-20) ### Features * **picker.buffer:** add filetype to bufname for buffers without name ([83baea0](https://github.com/folke/snacks.nvim/commit/83baea06d65d616f1f800501d0d82e4ad117abf2)) * **picker.debug:** debug option to detect garbage collection leaks ([b59f4ff](https://github.com/folke/snacks.nvim/commit/b59f4ff477a18cdc3673a240c2e992a2bccd48fe)) * **picker.matcher:** new `opts.matcher.file_pos` which defaults to `true` to support patterns like `file:line:col` or `file:line`. Closes [#517](https://github.com/folke/snacks.nvim/issues/517). Closes [#496](https://github.com/folke/snacks.nvim/issues/496). Closes [#651](https://github.com/folke/snacks.nvim/issues/651) ([5e00b0a](https://github.com/folke/snacks.nvim/commit/5e00b0ab271149f1bd74d5d5afe106b440f45a9d)) * **picker:** added `args` option for `files` and `grep`. Closes [#621](https://github.com/folke/snacks.nvim/issues/621) ([781b6f6](https://github.com/folke/snacks.nvim/commit/781b6f6ab0cd5f65a685bf8bac284f4a12e43589)) * **picker:** added `undo` picker to navigate the undo tree. Closes [#638](https://github.com/folke/snacks.nvim/issues/638) ([5c45f1c](https://github.com/folke/snacks.nvim/commit/5c45f1c193f2ed2fa639146df373f341d7410e8b)) * **picker:** added support for item.resolve that gets called if needed during list rendering / preview ([b0d3266](https://github.com/folke/snacks.nvim/commit/b0d32669856b8ad9c75fa7c6c4b643566001c8bc)) * **terminal:** allow overriding default shell. Closes [#450](https://github.com/folke/snacks.nvim/issues/450) ([3146fd1](https://github.com/folke/snacks.nvim/commit/3146fd139b89760526f32fd9d3ac4c91af010f0c)) * **terminal:** close terminals on `ExitPre`. Fixes [#419](https://github.com/folke/snacks.nvim/issues/419) ([2abf208](https://github.com/folke/snacks.nvim/commit/2abf208f2c43a387ca6c55c33b5ebbc7869c189c)) ### Bug Fixes * **dashboard:** added optional filter for recent files ([32cd343](https://github.com/folke/snacks.nvim/commit/32cd34383c8ac5d0e43408aba559308546555962)) * **debug.run:** schedule only nvim_buf_set_extmark in on_print ([#425](https://github.com/folke/snacks.nvim/issues/425)) ([81572b5](https://github.com/folke/snacks.nvim/commit/81572b5f97c3cb2f2e254972762f4b816e790fde)) * **indent:** use correct hl based on indent. Fixes [#422](https://github.com/folke/snacks.nvim/issues/422) ([627af73](https://github.com/folke/snacks.nvim/commit/627af7342cf5bea06793606c33992d2cc882655b)) * **input:** put the cursor right after the default prompt ([#549](https://github.com/folke/snacks.nvim/issues/549)) ([f904481](https://github.com/folke/snacks.nvim/commit/f904481439706e678e93225372b30f97281cfc2c)) * **notifier:** added `SnacksNotifierMinimal`. Closes [#410](https://github.com/folke/snacks.nvim/issues/410) ([daa575e](https://github.com/folke/snacks.nvim/commit/daa575e3cd42f003e171dbb8a3e992e670d5032c)) * **notifier:** win:close instead of win:hide ([f29f7a4](https://github.com/folke/snacks.nvim/commit/f29f7a433a2d9ea95f43c163d57df2f647700115)) * **picker.buffers:** add buf number to text ([70106a7](https://github.com/folke/snacks.nvim/commit/70106a79306525a281a3156bae1499f70c183d1d)) * **picker.buffer:** unselect on delete. Fixes [#653](https://github.com/folke/snacks.nvim/issues/653) ([0ac5605](https://github.com/folke/snacks.nvim/commit/0ac5605bfbeb31cee4bb91a6ca7a2bfe8c4d468f)) * **picker.grep:** correctly insert args from pattern. See [#601](https://github.com/folke/snacks.nvim/issues/601) ([8601a8c](https://github.com/folke/snacks.nvim/commit/8601a8ced398145d95f118737b29f3bd5f7eb700)) * **picker.grep:** debug ([f0d51ce](https://github.com/folke/snacks.nvim/commit/f0d51ce03835815aba0a6d748b54c3277ff38b70)) * **picker.lsp.symbols:** only include filename for search with workspace symbols ([eb0e5b7](https://github.com/folke/snacks.nvim/commit/eb0e5b7efe603bea7a0823ffaed13c52b395d04b)) * **picker.lsp:** backward compat with Neovim 0.95 ([3df2408](https://github.com/folke/snacks.nvim/commit/3df2408713efdbc72f9a8fcedc8586aed50ab023)) * **picker.lsp:** lazy resolve item lsp locations. Fixes [#650](https://github.com/folke/snacks.nvim/issues/650) ([d0a0046](https://github.com/folke/snacks.nvim/commit/d0a0046e37d274d8acdfcde653f3cadb12be6ba1)) * **picker.preview:** disable relativenumber by default. Closes [#664](https://github.com/folke/snacks.nvim/issues/664) ([384b9a7](https://github.com/folke/snacks.nvim/commit/384b9a7a36b5e30959fd89d3d857855105f65611)) * **picker.preview:** off-by-one for cmd output ([da5556a](https://github.com/folke/snacks.nvim/commit/da5556aa6bceb3428700607ab3005e5b44cb8b2e)) * **picker.preview:** reset before notify ([e50f2e3](https://github.com/folke/snacks.nvim/commit/e50f2e39094d4511506329044713ac69541f4135)) * **picker.undo:** disable number and signcolumn in preview ([40cea79](https://github.com/folke/snacks.nvim/commit/40cea79697acc97c3e4f814ca99a2d261fd6a4ee)) * **picker.util:** item.resolve for nil item ([2ff21b4](https://github.com/folke/snacks.nvim/commit/2ff21b4394d1f34887cb3425e32f18a793b749c7)) * **picker.util:** relax pattern for args ([6b7705c](https://github.com/folke/snacks.nvim/commit/6b7705c7edc9b93f16179d1343f9b2ae062340f9)) * **scope:** parse treesitter injections. Closes [#430](https://github.com/folke/snacks.nvim/issues/430) ([985ada3](https://github.com/folke/snacks.nvim/commit/985ada3c14346cc6df6a6013564a6541c66f6ce9)) * **statusline:** fix status line cache key ([#656](https://github.com/folke/snacks.nvim/issues/656)) ([af55934](https://github.com/folke/snacks.nvim/commit/af559349e591afaaaf75a8b3ecf5ee6f6711dde0)) * **win:** always close created scratch buffers when win closes ([abd7e61](https://github.com/folke/snacks.nvim/commit/abd7e61b7395af10a7862cec5bc746253a3b7917)) * **zen:** properly handle close ([920a9d2](https://github.com/folke/snacks.nvim/commit/920a9d28f1b1bf5ca06755236f9bbb8853adfea8)) * **zen:** sync cursor with parent window ([#547](https://github.com/folke/snacks.nvim/issues/547)) ([ba45c28](https://github.com/folke/snacks.nvim/commit/ba45c280dd9a35a6441a89d830b72f7cc8849b74)), closes [#539](https://github.com/folke/snacks.nvim/issues/539) ### Performance Improvements * **picker:** fixed some issues with closed pickers not always being garbage collected ([eebf44a](https://github.com/folke/snacks.nvim/commit/eebf44a34e9e004f988437116140712834efd745)) ## [2.13.0](https://github.com/folke/snacks.nvim/compare/v2.12.0...v2.13.0) (2025-01-19) ### Features * **picker.actions:** added support for action options. Fixes [#598](https://github.com/folke/snacks.nvim/issues/598) ([8035398](https://github.com/folke/snacks.nvim/commit/8035398e523588df7eac928fd23e6692522f6e1e)) * **picker.buffers:** del buffer with ctrl+x ([2479ff7](https://github.com/folke/snacks.nvim/commit/2479ff7cf41392130bd660fb787e3b1730863657)) * **picker.buffers:** delete buffers with dd ([2ab18a0](https://github.com/folke/snacks.nvim/commit/2ab18a0b9f425ccbc697adc53a01b26ea38abe0d)) * **picker.commands:** added builtin commands. Fixes [#634](https://github.com/folke/snacks.nvim/issues/634) ([ee988fa](https://github.com/folke/snacks.nvim/commit/ee988fa4b018ae617a16e2a4078b4586f08364f2)) * **picker.frecency:** cleanup old entries from sqlite3 database ([320a4a6](https://github.com/folke/snacks.nvim/commit/320a4a62a159f9d3177251e21d81cb96156291b9)) * **picker.git:** added `git_diff` picker for diff hunks ([#519](https://github.com/folke/snacks.nvim/issues/519)) ([cc69043](https://github.com/folke/snacks.nvim/commit/cc690436892d6ab0b8d5ee51ad60ff91c3a5d640)) * **picker.git:** git diff/show can now use native or neovim for preview. defaults to neovim. Closes [#500](https://github.com/folke/snacks.nvim/issues/500). Closes [#494](https://github.com/folke/snacks.nvim/issues/494). Closes [#491](https://github.com/folke/snacks.nvim/issues/491). Closes [#478](https://github.com/folke/snacks.nvim/issues/478) ([e36e6af](https://github.com/folke/snacks.nvim/commit/e36e6af96cb2ac0574199ab9d229735fb6d9f016)) * **picker.git:** stage/unstage files in git status with `<tab>` key ([0892db4](https://github.com/folke/snacks.nvim/commit/0892db4f42fc538df0a0b8fd66600d1e2d41b9e4)) * **picker.grep:** added `ft` (rg's `type`) and `regex` (rg's `--fixed-strings`) options ([0437cfd](https://github.com/folke/snacks.nvim/commit/0437cfd98ea9767836685ef8f100b7a758239624)) * **picker.list:** added debug option to show scores ([821e231](https://github.com/folke/snacks.nvim/commit/821e23101fdfcc28819e27596177eaa64eebf0c2)) * **picker.list:** added select_all action mapped to ctrl+a ([c9e2695](https://github.com/folke/snacks.nvim/commit/c9e2695969687285fbf53c86336b75c4dae3b609)) * **picker.list:** better way of highlighting field patterns ([924a988](https://github.com/folke/snacks.nvim/commit/924a988d9af72bf1abba122fa9f02a4eb917f15a)) * **picker.list:** make `conceallevel` configurable. Fixes [#635](https://github.com/folke/snacks.nvim/issues/635) ([d88eab6](https://github.com/folke/snacks.nvim/commit/d88eab6e3fec20e162f52e618114b869f561e3fd)) * **picker.lsp:** added `lsp_workspace_symbols`. Supports live search. Closes [#473](https://github.com/folke/snacks.nvim/issues/473) ([348307a](https://github.com/folke/snacks.nvim/commit/348307a82e4ae139fcb02b4cd4ae95dbf46f32c7)) * **picker.matcher:** added opts.matcher.sort_empty and opts.matcher.filename_bonus ([ed91078](https://github.com/folke/snacks.nvim/commit/ed91078625996106ddd31dfb4bac634d2895f61f)) * **picker.matcher:** better scoring algorithm based on fzf. Closes [#512](https://github.com/folke/snacks.nvim/issues/512). Fixes [#513](https://github.com/folke/snacks.nvim/issues/513) ([e4e2e88](https://github.com/folke/snacks.nvim/commit/e4e2e88c769d54094194d6e3d68fbfae87b20cbe)) * **picker.matcher:** integrate custom item scores ([7267e24](https://github.com/folke/snacks.nvim/commit/7267e2493b5962a550d874f142aaf64c3873fb7e)) * **picker.matcher:** moved length tiebreak to sorter instead ([d5ccb30](https://github.com/folke/snacks.nvim/commit/d5ccb301c1fe2adb874dd8f4f675797d983a8284)) * **picker.recent:** include open files in recent files. Closes [#487](https://github.com/folke/snacks.nvim/issues/487) ([96ffaba](https://github.com/folke/snacks.nvim/commit/96ffaba71bed87cf8bf75c6d945dedae3fa40af2)) * **picker.score:** prioritize matches in filenames ([5cf5ec1](https://github.com/folke/snacks.nvim/commit/5cf5ec1a314b38d4e361f7f26cb6eb14febd4d69)) * **picker.smart:** better frecency bonus ([74feefc](https://github.com/folke/snacks.nvim/commit/74feefc52284e2ebf93ad815ec5aaeec918d4dc2)) * **picker.sort:** default sorter can now sort by len of a field ([6ae87d9](https://github.com/folke/snacks.nvim/commit/6ae87d9f62a17124db9283c789b1bd968a55a85a)) * **picker.sources:** lines just sorts by score/idx. Smart sorts on empty ([be42182](https://github.com/folke/snacks.nvim/commit/be421822d5498ad962481b134e6272defff9118e)) * **picker:** add qflist_all action to send all even when already sel… ([#600](https://github.com/folke/snacks.nvim/issues/600)) ([c7354d8](https://github.com/folke/snacks.nvim/commit/c7354d83486d60d0a965426fa920d341759b69c6)) * **picker:** add some source aliases like the Telescope / FzfLua names ([5a83a8e](https://github.com/folke/snacks.nvim/commit/5a83a8e32885d6b923319cb8dc5ff1d1d97d0b10)) * **picker:** added `{preview}` and `{flags}` title placeholders. Closes [#557](https://github.com/folke/snacks.nvim/issues/557), Closes [#540](https://github.com/folke/snacks.nvim/issues/540) ([2e70b7f](https://github.com/folke/snacks.nvim/commit/2e70b7f42364e50df25407ddbd897e157a44c526)) * **picker:** added `git_branches` picker. Closes [#614](https://github.com/folke/snacks.nvim/issues/614) ([8563dfc](https://github.com/folke/snacks.nvim/commit/8563dfce682eeb260fa17e554b3e02de47e61f35)) * **picker:** added `inspect` action mapped to `<c-i>`. Useful to see what search fields are available on an item. ([2ba165b](https://github.com/folke/snacks.nvim/commit/2ba165b826d31ab0ebeaaff26632efe7013042b6)) * **picker:** added `smart` picker ([772f3e9](https://github.com/folke/snacks.nvim/commit/772f3e9b8970123db4050e9f7a5bdf2270575c6c)) * **picker:** added exclude option for files and grep. Closes [#581](https://github.com/folke/snacks.nvim/issues/581) ([192fb31](https://github.com/folke/snacks.nvim/commit/192fb31c4beda9aa4ebbc8bad0abe59df8bdde85)) * **picker:** added jump options jumplist(true for all), reuse_win & tagstack (true for lsp). Closes [#589](https://github.com/folke/snacks.nvim/issues/589). Closes [#568](https://github.com/folke/snacks.nvim/issues/568) ([84c3738](https://github.com/folke/snacks.nvim/commit/84c3738fb04fff83aba8e91c3af8ad9e74b089fd)) * **picker:** added preliminary support for combining finder results. More info coming soon ([000db17](https://github.com/folke/snacks.nvim/commit/000db17bf9f8bd243bbe944c0ae7e162d8cad572)) * **picker:** added spelling picker. Closes [#625](https://github.com/folke/snacks.nvim/issues/625) ([b170ced](https://github.com/folke/snacks.nvim/commit/b170ced527c03911a4658fb2df7139fa7040bcef)) * **picker:** added support for live args for `grep` and `files`. Closes [#601](https://github.com/folke/snacks.nvim/issues/601) ([50f3c3e](https://github.com/folke/snacks.nvim/commit/50f3c3e5b1c52c223a2689b1b2828c1ddae9e866)) * **picker:** added toggle/flag/action for `follow`. Closes [#633](https://github.com/folke/snacks.nvim/issues/633) ([aa53f6c](https://github.com/folke/snacks.nvim/commit/aa53f6c0799f1f6b80f6fb46472ec4773763f6b8)) * **picker:** allow disabling file icons ([76fbf9e](https://github.com/folke/snacks.nvim/commit/76fbf9e8a85485abfe1c53d096c74faad3fa2a6b)) * **picker:** allow setting a custom `opts.title`. Fixes [#620](https://github.com/folke/snacks.nvim/issues/620) ([6001fb2](https://github.com/folke/snacks.nvim/commit/6001fb2077306655afefba6593ec8b55e18abc39)) * **picker:** custom icon for unselected entries ([#588](https://github.com/folke/snacks.nvim/issues/588)) ([6402687](https://github.com/folke/snacks.nvim/commit/64026877ad8dac658eb5056e0c56f66e17401bdb)) * **picker:** restore cursor / topline on resume ([ca54948](https://github.com/folke/snacks.nvim/commit/ca54948f79917113dfcdf1c4ccaec573244a02aa)) * **pickers.format:** added `opts.picker.formatters.file.filename_first` ([98562ae](https://github.com/folke/snacks.nvim/commit/98562ae6a112bf1d80a9bec7fb2849605234a9d5)) * **picker:** use an sqlite3 database for frecency data when available ([c43969d](https://github.com/folke/snacks.nvim/commit/c43969dabd42e261c570f533c2f343f99a9d1f01)) * **scroll:** faster animations for scroll repeats after delay. (replaces spamming handling) ([d494a9e](https://github.com/folke/snacks.nvim/commit/d494a9e66447e9ae22e40c374e2e7d9a24b64d93)) * **snacks:** added `snacks.picker` ([#445](https://github.com/folke/snacks.nvim/issues/445)) ([559d6c6](https://github.com/folke/snacks.nvim/commit/559d6c6bf207e4e768a88e7f727ac12a87c768b7)) * **toggle:** allow toggling global options. Fixes [#534](https://github.com/folke/snacks.nvim/issues/534) ([b50effc](https://github.com/folke/snacks.nvim/commit/b50effc96763f0b84473b68c733ef3eff8a14be5)) * **win:** warn on duplicate keymaps that differ in case. See [#554](https://github.com/folke/snacks.nvim/issues/554) ([a71b7c0](https://github.com/folke/snacks.nvim/commit/a71b7c0d26b578ad2b758ad74139b2ddecf8c15f)) ### Bug Fixes * **animate:** never animate stopped animations ([197b0a9](https://github.com/folke/snacks.nvim/commit/197b0a9be93a6fa49b840fe159ce6373c7edcf98)) * **bigfile:** check existence of NoMatchParen before executing ([#561](https://github.com/folke/snacks.nvim/issues/561)) ([9b8f57b](https://github.com/folke/snacks.nvim/commit/9b8f57b96f823b83848572bf3384754f8ab46217)) * **config:** better vim.tbl_deep_extend that prevents issues with list-like tables. Fixes [#554](https://github.com/folke/snacks.nvim/issues/554) ([75eb16f](https://github.com/folke/snacks.nvim/commit/75eb16fd9c746bbd5e21992b6eb986d389dd246e)) * **config:** dont exclude metatables ([2d4a0b5](https://github.com/folke/snacks.nvim/commit/2d4a0b594a69c535704c15fc41c74d18c5f4d08b)) * **grep:** explicitely set `--no-hidden` because of the git filter ([ae2de9a](https://github.com/folke/snacks.nvim/commit/ae2de9aa8101dbff1ee1ab101d53e916f6e217dd)) * **indent:** dont redraw when list/shiftwidth/listchars change. Triggered way too often. Fixes [#613](https://github.com/folke/snacks.nvim/issues/613). Closes [#627](https://github.com/folke/snacks.nvim/issues/627) ([d212e3c](https://github.com/folke/snacks.nvim/commit/d212e3c99090eec3211e84e526b9fbdd000e020c)) * **input:** bring back `<c-w>`. Fixes [#426](https://github.com/folke/snacks.nvim/issues/426). Closes [#429](https://github.com/folke/snacks.nvim/issues/429) ([5affa72](https://github.com/folke/snacks.nvim/commit/5affa7214f621144526b9a7d93b83302fa6ea6f4)) * **layout:** allow root with relative=cursor. Closes [#479](https://github.com/folke/snacks.nvim/issues/479) ([f06f14c](https://github.com/folke/snacks.nvim/commit/f06f14c4ae4d131eb5e15f4c49994f8debddff42)) * **layout:** don't trigger events during re-layout on resize. Fixes [#552](https://github.com/folke/snacks.nvim/issues/552) ([d73a4a6](https://github.com/folke/snacks.nvim/commit/d73a4a64dfd203b9f3a4a9dedd76af398faa6652)) * **layout:** open/update windows in order of the layout to make sure offsets are correct ([034d50d](https://github.com/folke/snacks.nvim/commit/034d50d44e98af433260292001a88ac54d2466b6)) * **layout:** use eventignore when updating windows that are already visible to fix issues with synatx. Fixes [#552](https://github.com/folke/snacks.nvim/issues/552) ([f7d967c](https://github.com/folke/snacks.nvim/commit/f7d967c5157ee621168153502812a266c509bd97)) * **lsp:** use treesitter highlights for LSP locations ([fc06a36](https://github.com/folke/snacks.nvim/commit/fc06a363b95312eba0f3335f1190c745d0e5ea26)) * **notifier:** content width. Fixes [#631](https://github.com/folke/snacks.nvim/issues/631) ([0e27737](https://github.com/folke/snacks.nvim/commit/0e277379ea7d25c97d109d31da33abacf26da841)) * **picker.actions:** added hack to make folds work. Fixes [#514](https://github.com/folke/snacks.nvim/issues/514) ([df1060f](https://github.com/folke/snacks.nvim/commit/df1060fa501d06758d588a341d5cdec650cbfc67)) * **picker.actions:** close existing empty buffer if it's the current buffer ([0745505](https://github.com/folke/snacks.nvim/commit/0745505f2f43d2983867f48805bd4f700ad06c73)) * **picker.actions:** full path for qflist and loclist actions ([3e39250](https://github.com/folke/snacks.nvim/commit/3e392507963f784a4d57708585f8e012f1b95768)) * **picker.actions:** only delete empty buffer if it's not displayed in a window. Fixes [#566](https://github.com/folke/snacks.nvim/issues/566) ([b7ab888](https://github.com/folke/snacks.nvim/commit/b7ab888dd0c5bb0bafe9d01209f6a63320621b11)) * **picker.actions:** return action result. Fixes [#612](https://github.com/folke/snacks.nvim/issues/612). See [#611](https://github.com/folke/snacks.nvim/issues/611) ([4a482be](https://github.com/folke/snacks.nvim/commit/4a482bea3c5cac7af66a7a3d5cee5f97fca6c9d8)) * **picker.colorscheme:** nil check. Fixes [#575](https://github.com/folke/snacks.nvim/issues/575) ([de01907](https://github.com/folke/snacks.nvim/commit/de01907930bb125d1b67b4a1fb372f21d972f70b)) * **picker.config:** allow merging list-like layouts with table layout options ([706b1ab](https://github.com/folke/snacks.nvim/commit/706b1abc1697ca050314dc667e0900d53cad8aa4)) * **picker.config:** better config merging and tests ([9986b47](https://github.com/folke/snacks.nvim/commit/9986b47707bbe76cf3b901c3048e55b2ba2bb4a8)) * **picker.config:** normalize keys before merging so you can override `<c-s>` with `` ([afef949](https://github.com/folke/snacks.nvim/commit/afef949d88b6fa3dde8515b27066b132cfdb0a70)) * **picker.db:** remove tests ([71f69e5](https://github.com/folke/snacks.nvim/commit/71f69e5e57f355f40251e274d45560af7d8dd365)) * **picker.diagnostics:** sort on empty pattern. Fixes [#641](https://github.com/folke/snacks.nvim/issues/641) ([c6c76a6](https://github.com/folke/snacks.nvim/commit/c6c76a6aa338af47e2725cff35cc814fcd2ad1e7)) * **picker.files:** ignore errors since it's not possible to know if the error isbecause of an incomplete pattern. Fixes [#551](https://github.com/folke/snacks.nvim/issues/551) ([4823f2d](https://github.com/folke/snacks.nvim/commit/4823f2da65a5e11c10242af5e0d1c977474288b3)) * **picker.format:** filename ([a194bbc](https://github.com/folke/snacks.nvim/commit/a194bbc3747f73416ec2fd25cb39c233fcc7a656)) * **picker.format:** use forward slashes for paths. Closes [#624](https://github.com/folke/snacks.nvim/issues/624) ([5f82ffd](https://github.com/folke/snacks.nvim/commit/5f82ffde0befbcbfbedb9b5066bf4f3a5d667495)) * **picker.git:** git log file/line for a file not in cwd. Fixes [#616](https://github.com/folke/snacks.nvim/issues/616) ([9034319](https://github.com/folke/snacks.nvim/commit/903431903b0151f97f45087353ebe8fa1cb8ef80)) * **picker.git:** git_file and git_line should only show diffs including the file. Fixes [#522](https://github.com/folke/snacks.nvim/issues/522) ([1481a90](https://github.com/folke/snacks.nvim/commit/1481a90affedb24291997f5ebde0637cafc1d20e)) * **picker.git:** use Snacks.git.get_root instead vim.fs.root for backward compatibility ([a2fb70e](https://github.com/folke/snacks.nvim/commit/a2fb70e8ba2bb2ce5c60ed1ee7505d6f6d7be061)) * **picker.highlight:** properly deal with multiline treesitter captures ([27b72ec](https://github.com/folke/snacks.nvim/commit/27b72ecd005743ecf5855cd3b430fce74bd4f2e3)) * **picker.input:** don't set prompt interrupt, but use a `<c-c>` mapping instead that can be changed ([123f0d9](https://github.com/folke/snacks.nvim/commit/123f0d9e5d7be5b23ef9b28b9ddde403e4b2d061)) * **picker.input:** leave insert mode when closing and before executing confirm. Fixes [#543](https://github.com/folke/snacks.nvim/issues/543) ([05eb22c](https://github.com/folke/snacks.nvim/commit/05eb22c4fbe09f9bed53a752301d5c2a8a060a4e)) * **picker.input:** statuscolumn on resize / re-layout. Fixes [#643](https://github.com/folke/snacks.nvim/issues/643) ([4d8d844](https://github.com/folke/snacks.nvim/commit/4d8d844027a3ea04ac7ecb0ab6cd63e39e96d06f)) * **picker.input:** strip newllines from pattern (mainly due to pasting in the input box) ([c6a9955](https://github.com/folke/snacks.nvim/commit/c6a9955516b686d1b6bd815e1df0c808baa60bd3)) * **picker.input:** use `Snacks.util.wo` instead of `vim.wo`. Fixes [#643](https://github.com/folke/snacks.nvim/issues/643) ([d6284d5](https://github.com/folke/snacks.nvim/commit/d6284d51ff748f43c5431c35ffed7f7c02e51069)) * **picker.list:** disable folds ([5582a84](https://github.com/folke/snacks.nvim/commit/5582a84020a1e11d9001660252cdee6a424ba159)) * **picker.list:** include `search` filter for highlighting items (live search). See [#474](https://github.com/folke/snacks.nvim/issues/474) ([1693fbb](https://github.com/folke/snacks.nvim/commit/1693fbb0dcf1b82f2212a325379ebb0257d582e5)) * **picker.list:** newlines in text. Fixes [#607](https://github.com/folke/snacks.nvim/issues/607). Closes [#580](https://github.com/folke/snacks.nvim/issues/580) ([c45a940](https://github.com/folke/snacks.nvim/commit/c45a94044b8e4c9f5deb420a7caa505281e1eed5)) * **picker.list:** possible issue with window options being set in the wrong window ([f1b6c55](https://github.com/folke/snacks.nvim/commit/f1b6c55027c6e75940fcb40fa8ac5ab717de1647)) * **picker.list:** scores debug ([9499b94](https://github.com/folke/snacks.nvim/commit/9499b944e79ff305769a59819c44a93911023fc7)) * **picker.lsp:** added support for single location result ([79d27f1](https://github.com/folke/snacks.nvim/commit/79d27f19dc62c0978d2688c6fac9348f253ef007)) * **picker.matcher:** initialize matcher with pattern from opts. Fixes [#596](https://github.com/folke/snacks.nvim/issues/596) ([c434eb8](https://github.com/folke/snacks.nvim/commit/c434eb89fe030672f4e518b4de1e506ffaf0e96e)) * **picker.matcher:** inverse scores ([1816931](https://github.com/folke/snacks.nvim/commit/1816931aadb1fdcd3e08606d773d31f3d51fabcc)) * **picker.minheap:** clear sorted on minheap clear. Fixes [#492](https://github.com/folke/snacks.nvim/issues/492) ([79bea58](https://github.com/folke/snacks.nvim/commit/79bea58b1ec92909d763c8b5788baf8da6c19a06)) * **picker.preview:** don't show line numbers for preview commands ([a652214](https://github.com/folke/snacks.nvim/commit/a652214f52694233b5e27d374db0d51d2f7cb43d)) * **picker.preview:** pattern to detect binary files was incorrect ([bbd1a08](https://github.com/folke/snacks.nvim/commit/bbd1a0885b3e89103a8a59f1f07d296f23c7d2ad)) * **picker.preview:** scratch buffer filetype. Fixes [#595](https://github.com/folke/snacks.nvim/issues/595) ([ece76b3](https://github.com/folke/snacks.nvim/commit/ece76b333a9ff372959bf5204ab22f58215383c1)) * **picker.proc:** correct offset for carriage returns. Fixes [#599](https://github.com/folke/snacks.nvim/issues/599) ([a01e0f5](https://github.com/folke/snacks.nvim/commit/a01e0f536815a0cc23443fae5ac10f7fdcb4f313)) * **picker.qf:** better quickfix item list. Fixes [#562](https://github.com/folke/snacks.nvim/issues/562) ([cb84540](https://github.com/folke/snacks.nvim/commit/cb845408dbc795582ea1567fba2bf4ba64c777ac)) * **picker.select:** allow main to be current. Fixes [#497](https://github.com/folke/snacks.nvim/issues/497) ([076259d](https://github.com/folke/snacks.nvim/commit/076259d263c927ed1d1c56c94b6e870230e77a3d)) * **picker.util:** cleanup func for key-value store (frecency) ([bd2da45](https://github.com/folke/snacks.nvim/commit/bd2da45c384ea7ce44bdd15a7b5e32ee3806cf8d)) * **picker:** add alias for `oldfiles` ([46554a6](https://github.com/folke/snacks.nvim/commit/46554a63425c0594eacaa0e8eaddec5dbf79b48e)) * **picker:** add keymaps for preview scratch buffers ([dc3f114](https://github.com/folke/snacks.nvim/commit/dc3f114c1f787218e4314d907866874a62253756)) * **picker:** always stopinsert, even when picker is already closed. Should not be needed, but some plugins misbehave. See [#579](https://github.com/folke/snacks.nvim/issues/579) ([29becb0](https://github.com/folke/snacks.nvim/commit/29becb0ecbeb20683b3ae71d4c082734b2f518af)) * **picker:** better buffer edit. Fixes [#593](https://github.com/folke/snacks.nvim/issues/593) ([716492c](https://github.com/folke/snacks.nvim/commit/716492c57870e11e04a5764bcc1e549859f563be)) * **picker:** better normkey. Fixes [#610](https://github.com/folke/snacks.nvim/issues/610) ([540ecbd](https://github.com/folke/snacks.nvim/commit/540ecbd9a4b4c4d4ed47db83367f9e5d04220c27)) * **picker:** changed inspect mapping to `<a-d>` since not all terminal differentiate between `` and `` ([8386540](https://github.com/folke/snacks.nvim/commit/8386540c422774059a75fe26ce7cfb6ab3811c73)) * **picker:** correctly normalize path after fnamemodify ([f351dcf](https://github.com/folke/snacks.nvim/commit/f351dcfcaca069d5f70bcf6edbde244e7358d063)) * **picker:** deepcopy before config merging. Fixes [#554](https://github.com/folke/snacks.nvim/issues/554) ([7865df0](https://github.com/folke/snacks.nvim/commit/7865df0558fa24cce9ec27c4e002d5e179cab685)) * **picker:** don't throttle preview if it's the first item we're previewing ([b785167](https://github.com/folke/snacks.nvim/commit/b785167814c5481643b53d241487fc9802a1ab13)) * **picker:** dont fast path matcher when finder items have scores ([2ba5602](https://github.com/folke/snacks.nvim/commit/2ba5602834830e1a96e4f1a81e0fbb310481ca74)) * **picker:** format: one too many spaces for default icon in results … ([#594](https://github.com/folke/snacks.nvim/issues/594)) ([d7f727f](https://github.com/folke/snacks.nvim/commit/d7f727f673a67b60882a3d2a96db4d1490566e73)) * **picker:** picker:items() should return filtered items, not finder items. Closes [#481](https://github.com/folke/snacks.nvim/issues/481) ([d67e093](https://github.com/folke/snacks.nvim/commit/d67e093bbc2a2069c5e6118babf99c9029b34675)) * **picker:** potential issue with preview winhl being set on the main window ([34208eb](https://github.com/folke/snacks.nvim/commit/34208ebe00a237a232a6050f81fb89f25d473180)) * **picker:** preview / lsp / diagnostics positions were wrong; Should all be (1-0) indexed. Fixes [#543](https://github.com/folke/snacks.nvim/issues/543) ([40d08bd](https://github.com/folke/snacks.nvim/commit/40d08bd901db740f4b270fb6e9b710312a2846df)) * **picker:** properly handle `opts.layout` being a string. Fixes [#636](https://github.com/folke/snacks.nvim/issues/636) ([b80c9d2](https://github.com/folke/snacks.nvim/commit/b80c9d275d05778c90df32bd361c8630b28d13fd)) * **picker:** select_and_prev should use list_up instead of list_down ([#471](https://github.com/folke/snacks.nvim/issues/471)) ([b993be7](https://github.com/folke/snacks.nvim/commit/b993be762ba9aa9c1efd858ae777eef3de28c609)) * **picker:** set correct cwd for git status picker ([#505](https://github.com/folke/snacks.nvim/issues/505)) ([2cc7cf4](https://github.com/folke/snacks.nvim/commit/2cc7cf42e94041c08f5654935f745424d10a15b1)) * **picker:** show all files in git status ([#586](https://github.com/folke/snacks.nvim/issues/586)) ([43c312d](https://github.com/folke/snacks.nvim/commit/43c312dfc1433b3bdf24c7a14bc0aac648fa8d33)) * **scope:** make sure to parse the ts tree. Fixes [#521](https://github.com/folke/snacks.nvim/issues/521) ([4c55f1c](https://github.com/folke/snacks.nvim/commit/4c55f1c2da103e4c2776274cb0f6fab0318ea811)) * **scratch:** autowrite right buffer. Fixes [#449](https://github.com/folke/snacks.nvim/issues/449). ([#452](https://github.com/folke/snacks.nvim/issues/452)) ([8d2e26c](https://github.com/folke/snacks.nvim/commit/8d2e26cf27330ead517193f7eeb898d189a973c2)) * **scroll:** don't animate for new changedtick. Fixes [#384](https://github.com/folke/snacks.nvim/issues/384) ([ac8b3cd](https://github.com/folke/snacks.nvim/commit/ac8b3cdfa08c2bba065c95b512887d3bbea91807)) * **scroll:** don't animate when recording or executing macros ([7dcdcb0](https://github.com/folke/snacks.nvim/commit/7dcdcb0b6ab6ecb2c6efdbaa4769bc03f5837832)) * **statuscolumn:** return "" when no signs and no numbers are needed. Closes [#570](https://github.com/folke/snacks.nvim/issues/570). ([c4980ef](https://github.com/folke/snacks.nvim/commit/c4980ef9b4e678d4057ece6fd2c13637a395f3a0)) * **util:** normkey ([cd58a14](https://github.com/folke/snacks.nvim/commit/cd58a14e20fdcd810b55e8aee535486a3ad8719f)) * **win:** clear syntax when setting filetype ([c49f38c](https://github.com/folke/snacks.nvim/commit/c49f38c5a919400689ab11669d45331f210ea91c)) * **win:** correctly deal with initial text containing newlines. Fixes [#542](https://github.com/folke/snacks.nvim/issues/542) ([825c106](https://github.com/folke/snacks.nvim/commit/825c106f40352dc2979724643e0d36af3a962eb1)) * **win:** duplicate keymap should take mode into account. Closes [#559](https://github.com/folke/snacks.nvim/issues/559) ([097e68f](https://github.com/folke/snacks.nvim/commit/097e68fc720a8fcdb370e6ccd2a3acad40b98952)) * **win:** exclude cursor from redraw. Fixes [#613](https://github.com/folke/snacks.nvim/issues/613) ([ad9b382](https://github.com/folke/snacks.nvim/commit/ad9b382f7d0e150d2420cb65d44d6fb81a6b62c8)) * **win:** fix relative=cursor again ([5b1cd46](https://github.com/folke/snacks.nvim/commit/5b1cd464e8759156d4d69f0398a0f5b34fa0b743)) * **win:** relative=cursor. Closes [#427](https://github.com/folke/snacks.nvim/issues/427). Closes [#477](https://github.com/folke/snacks.nvim/issues/477) ([743f8b3](https://github.com/folke/snacks.nvim/commit/743f8b3fee780780d8e201960a4a295dc5187e9b)) * **win:** special handling of `<C-J>`. Closes [#565](https://github.com/folke/snacks.nvim/issues/565). Closes [#592](https://github.com/folke/snacks.nvim/issues/592) ([5ac80f0](https://github.com/folke/snacks.nvim/commit/5ac80f0159239e44448fa9b08d8462e93342192d)) * **win:** win position with border offsets. Closes [#413](https://github.com/folke/snacks.nvim/issues/413). Fixes [#423](https://github.com/folke/snacks.nvim/issues/423) ([ee08b1f](https://github.com/folke/snacks.nvim/commit/ee08b1f32e06904318f8fa24714557d3abcdd215)) * **words:** added support for new name of the namespace used for lsp references. Fixes [#555](https://github.com/folke/snacks.nvim/issues/555) ([566f302](https://github.com/folke/snacks.nvim/commit/566f3029035143c525c0df7f5e7bbf9b3e939f54)) ### Performance Improvements * **notifier:** skip processing during search. See [#627](https://github.com/folke/snacks.nvim/issues/627) ([cf5f56a](https://github.com/folke/snacks.nvim/commit/cf5f56a1d82f4ece762843334c744e5830f4b4ef)) * **picker.matcher:** fast path when we already found a perfect match ([6bbf50c](https://github.com/folke/snacks.nvim/commit/6bbf50c5e3a3ab3bfc6a6747f6a2c66cbc9b7548)) * **picker.matcher:** only use filename_bonus for items that have a file field ([fd854ab](https://github.com/folke/snacks.nvim/commit/fd854ab9efdd13cf9cda192838f967551f332e36)) * **picker.matcher:** yield every 1ms to prevent ui locking in large repos ([19979c8](https://github.com/folke/snacks.nvim/commit/19979c88f37930f71bd98b96e1afa50ae26a09ae)) * **picker.util:** cache path calculation ([7117356](https://github.com/folke/snacks.nvim/commit/7117356b49ceedd7185c43a732dfe10c6a60cdbc)) * **picker:** dont use prompt buffer callbacks ([8293add](https://github.com/folke/snacks.nvim/commit/8293add1e524f48aee1aacd68f72d4204096aed2)) * **picker:** matcher optims ([5295741](https://github.com/folke/snacks.nvim/commit/529574128783c0fc4a146b207ea532950f48732f)) ## [2.12.0](https://github.com/folke/snacks.nvim/compare/v2.11.0...v2.12.0) (2025-01-05) ### Features * **debug:** system & memory metrics useful for debugging ([cba16bd](https://github.com/folke/snacks.nvim/commit/cba16bdb35199c941c8d78b8fb9ddecf568c0b1f)) * **input:** disable completion engines in input ([37038df](https://github.com/folke/snacks.nvim/commit/37038df00d6b47a65de24266c25683ff5a781a40)) * **scope:** disable treesitter blocks by default ([8ec6e6a](https://github.com/folke/snacks.nvim/commit/8ec6e6adc5b098674c41005530d1c8af126480ae)) * **statuscolumn:** allow changing the marks hl group. Fixes [#390](https://github.com/folke/snacks.nvim/issues/390) ([8a6e5b9](https://github.com/folke/snacks.nvim/commit/8a6e5b9685bb8c596a179256b048043a51a64e09)) * **util:** `Snacks.util.ref` ([7383eda](https://github.com/folke/snacks.nvim/commit/7383edaec842609deac50b114a3567c2983b54f4)) * **util:** throttle ([737980d](https://github.com/folke/snacks.nvim/commit/737980d987cdb4d3c2b18e0b3b8613fde974a2e9)) * **win:** `Snacks.win:border_size` ([4cd0647](https://github.com/folke/snacks.nvim/commit/4cd0647eb5bda07431e125374c1419059783a741)) * **win:** `Snacks.win:redraw` ([0711a82](https://github.com/folke/snacks.nvim/commit/0711a82b7a77c0ab35251e28cf1a7be0b3bde6d4)) * **win:** `Snacks.win:scroll` ([a1da66e](https://github.com/folke/snacks.nvim/commit/a1da66e3bf2768273f1dfb556b29269fd8ba153d)) * **win:** allow setting `desc` for window actions ([402494b](https://github.com/folke/snacks.nvim/commit/402494bdee8800c8ac3eeceb8c5e78e00f72f265)) * **win:** better dimension calculation for windows (use by upcoming layouts) ([cc0b528](https://github.com/folke/snacks.nvim/commit/cc0b52872b99e3af7d80536e8a9cbc28d47e7f19)) * **win:** top,right,bottom,left borders ([320ecbc](https://github.com/folke/snacks.nvim/commit/320ecbc15c25a240fee2c2970f826259d809ed72)) ### Bug Fixes * **dashboard:** hash dashboard terminal section caching key to support long commands ([#381](https://github.com/folke/snacks.nvim/issues/381)) ([d312053](https://github.com/folke/snacks.nvim/commit/d312053f78b4fb55523def179ac502438dd93193)) * **debug:** make debug.inpect work in fast events ([b70edc2](https://github.com/folke/snacks.nvim/commit/b70edc29dbc8c9718af246a181b05d4d190ad260)) * **debug:** make sure debug can be required in fast events ([6cbdbb9](https://github.com/folke/snacks.nvim/commit/6cbdbb9afa748e84af4c35d17fc4737b18638a35)) * **indent:** allow rendering over blank lines. Fixes [#313](https://github.com/folke/snacks.nvim/issues/313) ([766e671](https://github.com/folke/snacks.nvim/commit/766e67145259e30ae7d63dfd6d51b8d8ef0840ae)) * **indent:** better way to deal with `breakindent`. Fixes [#329](https://github.com/folke/snacks.nvim/issues/329) ([235427a](https://github.com/folke/snacks.nvim/commit/235427abcbf3e2b251a8b75f0e409dfbb6c737d6)) * **indent:** breakdinent ([972c61c](https://github.com/folke/snacks.nvim/commit/972c61cc1cd254ef3b43ec1dfd51eefbdc441a7d)) * **indent:** correct calculation of partial indent when leftcol > 0 ([6f3cbf8](https://github.com/folke/snacks.nvim/commit/6f3cbf8ad328d181a694cdded344477e81cd094d)) * **indent:** do animate check in bufcall ([c62e7a2](https://github.com/folke/snacks.nvim/commit/c62e7a2561351c9fe3a8e7e9fc8602f3b61abf53)) * **indent:** don't render scopes in closed folds. Fixes [#352](https://github.com/folke/snacks.nvim/issues/352) ([94ec568](https://github.com/folke/snacks.nvim/commit/94ec5686a64218c9477de7761af4fd34dd4a665b)) * **indent:** off-by-one for indent guide hl group ([551e644](https://github.com/folke/snacks.nvim/commit/551e644ca311d065b3a6882db900846c1e66e636)) * **indent:** repeat_linbebreak only works on Neovim >= 0.10. Fixes [#353](https://github.com/folke/snacks.nvim/issues/353) ([b93201b](https://github.com/folke/snacks.nvim/commit/b93201bdf36bd62b07daf7d40bc305998f9da52c)) * **indent:** simplify indent guide logic and never overwrite blanks. Fixes [#334](https://github.com/folke/snacks.nvim/issues/334) ([282be8b](https://github.com/folke/snacks.nvim/commit/282be8bfa8e6f46d6994ff46638d1c155b90753f)) * **indent:** typo for underline ([66cce2f](https://github.com/folke/snacks.nvim/commit/66cce2f512e11a961a8f187eac802acbf8725d05)) * **indent:** use space instead of full blank for indent offset. See [#313](https://github.com/folke/snacks.nvim/issues/313) ([58081bc](https://github.com/folke/snacks.nvim/commit/58081bcecb31db8c6f12ad876c70786582a7f6a8)) * **input:** change buftype to prompt. Fixes [#350](https://github.com/folke/snacks.nvim/issues/350) ([2990bf0](https://github.com/folke/snacks.nvim/commit/2990bf0c7a79f5780a0268a47bae69ef004cec99)) * **input:** make sure to show input window with a higher zindex of the parent window (if float) ([3123e6e](https://github.com/folke/snacks.nvim/commit/3123e6e9882f178411ea6e9fbf5e9552134b82b0)) * **input:** refactor for win changes and ensure modified=false. Fixes [#403](https://github.com/folke/snacks.nvim/issues/403). Fixes [#402](https://github.com/folke/snacks.nvim/issues/402) ([8930630](https://github.com/folke/snacks.nvim/commit/89306308f357e12510683758c35d08f368db2b2c)) * **input:** use correct highlight group for input prompt ([#328](https://github.com/folke/snacks.nvim/issues/328)) ([818da33](https://github.com/folke/snacks.nvim/commit/818da334ac8f655235b5861bb50577921e4e6bd8)) * **lazygit:** enable boolean values in config ([#377](https://github.com/folke/snacks.nvim/issues/377)) ([ec34684](https://github.com/folke/snacks.nvim/commit/ec346843e0adb51b45e595dd0ef34bf9e64d4627)) * **notifier:** open history window with correct style ([#307](https://github.com/folke/snacks.nvim/issues/307)) ([d2b5680](https://github.com/folke/snacks.nvim/commit/d2b5680359ee8feb34b095fd574b4f9b3f013629)) * **notifier:** rename style `notification.history` -> `notification_history` ([fd9ef30](https://github.com/folke/snacks.nvim/commit/fd9ef30206185e3dd4d3294c74e2fd0dee9722d1)) * **scope:** allow treesitter scopes when treesitter highlighting is disabled. See [#231](https://github.com/folke/snacks.nvim/issues/231) ([58ae580](https://github.com/folke/snacks.nvim/commit/58ae580c2c12275755bb3e2003aebd06d550f2db)) * **scope:** don't expand to invalid range. Fixes [#339](https://github.com/folke/snacks.nvim/issues/339) ([1244305](https://github.com/folke/snacks.nvim/commit/1244305bedb8e60a946d949c78453263a714a4ad)) * **scope:** properly caluclate start indent when `cursor=true` for indent scopes. See [#5068](https://github.com/folke/snacks.nvim/issues/5068) ([e63fa7b](https://github.com/folke/snacks.nvim/commit/e63fa7bf05d22f4306c5fff594d48bc01e382238)) * **scope:** use virtcol for calculating scopes at the cursor ([6a36f32](https://github.com/folke/snacks.nvim/commit/6a36f32eaa7d5d59e681b7b8112a85a58a2d563d)) * **scroll:** check for invalid window. Fixes [#340](https://github.com/folke/snacks.nvim/issues/340) ([b6032e8](https://github.com/folke/snacks.nvim/commit/b6032e8f1b5cba55b5a2cf138ab4f172c4decfbd)) * **scroll:** don't animate when leaving cmdline search with incsearch enabled. Fixes [#331](https://github.com/folke/snacks.nvim/issues/331) ([fc0a99b](https://github.com/folke/snacks.nvim/commit/fc0a99b8493c34e6a930b3571ee8491e23831bca)) * **util:** throttle now autonatically schedules when in fast event ([9840331](https://github.com/folke/snacks.nvim/commit/98403313c749e26e5ae9a8ff51343c97f76ce170)) * **win:** backdrop having bright cell at top right ([#400](https://github.com/folke/snacks.nvim/issues/400)) ([373d0f9](https://github.com/folke/snacks.nvim/commit/373d0f9b6d6d83cdba641937b6303b0a0a18119f)) * **win:** don't enter when focusable is `false` ([ca233c7](https://github.com/folke/snacks.nvim/commit/ca233c7448c930658e8c7da9745e8d98884c3852)) * **win:** force-close any buffer that is not a file ([dd50e53](https://github.com/folke/snacks.nvim/commit/dd50e53a9efea11329e21c4a61ca35ae5122ceca)) * **win:** unset `winblend` when transparent ([0617e28](https://github.com/folke/snacks.nvim/commit/0617e28f8289002310fed5986acc29fde38e01b5)) * **words:** only check modes for `is_enabled` when needed ([80dcb88](https://github.com/folke/snacks.nvim/commit/80dcb88ede1a96f79edd3b7ede0bc41d51dd8a2d)) * **zen:** set zindex to 40, lower than hover (45). Closes [#345](https://github.com/folke/snacks.nvim/issues/345) ([05f4981](https://github.com/folke/snacks.nvim/commit/05f49814f3a2f3ecb83d9e72b7f8f2af40351aad)) ## [2.11.0](https://github.com/folke/snacks.nvim/compare/v2.10.0...v2.11.0) (2024-12-15) ### Features * **indent:** properly handle continuation indents. Closes [#286](https://github.com/folke/snacks.nvim/issues/286) ([f2bb7fa](https://github.com/folke/snacks.nvim/commit/f2bb7fa94e4b9b1fa7f84066bbedea8b3d9875e3)) * **input:** allow configuring position of prompt and icon ([d0cb707](https://github.com/folke/snacks.nvim/commit/d0cb7070e98d6a2ca31d94dd04d7048c9b258f33)) * **notifier:** notification `history` option ([#297](https://github.com/folke/snacks.nvim/issues/297)) ([8f56e19](https://github.com/folke/snacks.nvim/commit/8f56e19f916f8075e2bfb534d723e3d850e256a4)) * **scope:** `Scope:inner` for indent based and treesitter scopes ([8a8b1c9](https://github.com/folke/snacks.nvim/commit/8a8b1c976fc2736a3b91697750074fd3b23a24c9)) * **scope:** added `__tostring` for debugging ([94e0849](https://github.com/folke/snacks.nvim/commit/94e0849c3aae3b818cad2804c256c57318256c72)) * **scope:** added `opts.cursor` to take cursor column into account for scope detection. (defaults to true). Closes [#282](https://github.com/folke/snacks.nvim/issues/282) ([54bc6ba](https://github.com/folke/snacks.nvim/commit/54bc6bab2dbd07270c8c3fd447e8b72f825c315c)) * **scope:** text objects now use treesitter scopes by default. See [#231](https://github.com/folke/snacks.nvim/issues/231) ([a953697](https://github.com/folke/snacks.nvim/commit/a9536973a9111c3c7b66fb51bc5f62be27850884)) * **statuscolumn:** allow left/right to be a function. Closes [#288](https://github.com/folke/snacks.nvim/issues/288) ([cb42b95](https://github.com/folke/snacks.nvim/commit/cb42b952c5d4047f8e805c02c7aa596eb4e45ef2)) * **util:** on_key handler ([002d5eb](https://github.com/folke/snacks.nvim/commit/002d5eb5c2710a4e7456dd572543369e8424fd64)) * **win:** win:line() ([17494ad](https://github.com/folke/snacks.nvim/commit/17494ad9bf98e82c6a16f032cb3c9c82e072371a)) ### Bug Fixes * **dashboard:** telescope can't be run from a `vim.schedule` for some reason ([dcc5338](https://github.com/folke/snacks.nvim/commit/dcc5338e6f2a825b78791c96829d7e5a29e3ea5d)) * **indent:** `opts.indent.blank` now defaults to `listchars.space`. Closes [#291](https://github.com/folke/snacks.nvim/issues/291) ([31bc409](https://github.com/folke/snacks.nvim/commit/31bc409342b00d406963de3e1f38f3a2f84cfdcb)) * **indent:** fixup ([14d71c3](https://github.com/folke/snacks.nvim/commit/14d71c3fb2856634a8697f7c9f01704980e49bd0)) * **indent:** honor lead listchar ([#303](https://github.com/folke/snacks.nvim/issues/303)) ([7db0cc9](https://github.com/folke/snacks.nvim/commit/7db0cc9281b23c71155422433b6f485675674932)) * **indent:** honor listchars and list when blank is `nil`. Closes [#296](https://github.com/folke/snacks.nvim/issues/296) ([0e150f5](https://github.com/folke/snacks.nvim/commit/0e150f5510e381753ddd18f29facba14716d5669)) * **indent:** lower priorities of indent guides ([7f66818](https://github.com/folke/snacks.nvim/commit/7f668185ea810304cef5cb166a51665d4859124b)) * **input:** check if parent win still exists. Fixes [#287](https://github.com/folke/snacks.nvim/issues/287) ([db768a5](https://github.com/folke/snacks.nvim/commit/db768a5497301aad7fcddae2fe578cb320cc9ca2)) * **input:** go back to insert mode if input was started from insert mode. Fixes [#287](https://github.com/folke/snacks.nvim/issues/287) ([5d00e6d](https://github.com/folke/snacks.nvim/commit/5d00e6dec5686d7d2a6d97288287892d117d579b)) * **input:** missing padding if neither title nor icon positioned left ([#292](https://github.com/folke/snacks.nvim/issues/292)) ([97542a7](https://github.com/folke/snacks.nvim/commit/97542a7d9bfc7f242acbaa9851a62c649222fec8)) * **input:** open input window with `noautocmd=true` set. Fixes [#287](https://github.com/folke/snacks.nvim/issues/287) ([26b7d4c](https://github.com/folke/snacks.nvim/commit/26b7d4cbd9f803d9f759fc00d0dc4caa0141048b)) * **scope:** add `indent` to `__eq` ([be2779e](https://github.com/folke/snacks.nvim/commit/be2779e942bee0932e9c14ef4ed3e4002be861ce)) * **scope:** better treesitter scope edge detection ([b7355c1](https://github.com/folke/snacks.nvim/commit/b7355c16fb441e33be993ade74130464b62304cf)) * **scroll:** check mousescroll before spamming ([3d67bda](https://github.com/folke/snacks.nvim/commit/3d67bda1e29b8e8108dd74d611bf5c8b42883838)) * **util:** on_key compat with Neovim 0.9 ([effa885](https://github.com/folke/snacks.nvim/commit/effa885120670ca8a1775fc16ab2ec9e8040c288)) ## [2.10.0](https://github.com/folke/snacks.nvim/compare/v2.9.0...v2.10.0) (2024-12-13) ### Features * **animate:** add done to animation object ([ec73346](https://github.com/folke/snacks.nvim/commit/ec73346b7d4a25e440538141d5b8c68e42a1047d)) * **lazygit:** respect existing LG_CONFIG_FILE when setting config paths ([#208](https://github.com/folke/snacks.nvim/issues/208)) ([ef114c0](https://github.com/folke/snacks.nvim/commit/ef114c0efede3339221df5bc5b250aa9f8328b8d)) * **scroll:** added spamming detection and disable animations when user is spamming keys :) ([c58605f](https://github.com/folke/snacks.nvim/commit/c58605f8b3abf974e984ca5483cbe6ab9d2afc6e)) * **scroll:** improve smooth scrolling when user is spamming keys ([5532ba0](https://github.com/folke/snacks.nvim/commit/5532ba07be1306eb05c727a27368a8311bae3eeb)) * **zen:** added on_open / on_close callbacks ([5851de1](https://github.com/folke/snacks.nvim/commit/5851de157a08c96a0ca15580ced2ea53063fd65d)) * **zen:** make zen/zoom mode work for floating windows. Closes [#5028](https://github.com/folke/snacks.nvim/issues/5028) ([05bb957](https://github.com/folke/snacks.nvim/commit/05bb95739a362f8bec382f164f07c53137244627)) ### Bug Fixes * **dashboard:** set cursor to non-hidden actionable items. Fixes [#273](https://github.com/folke/snacks.nvim/issues/273) ([7c7b18f](https://github.com/folke/snacks.nvim/commit/7c7b18fdeeb7e228463b41d805cb47327f3d03f1)) * **indent:** fix rendering issues when `only_scope` is set for indent. Fixes [#268](https://github.com/folke/snacks.nvim/issues/268) ([370703d](https://github.com/folke/snacks.nvim/commit/370703da81a19db9d8bf41bb518e7b6959e53cea)) * **indent:** only render adjusted top/bottom. See [#268](https://github.com/folke/snacks.nvim/issues/268) ([54294cb](https://github.com/folke/snacks.nvim/commit/54294cba6a17ec048a63837fa341e6a663f3217d)) * **notifier:** set `modifiable=false` for notifier history ([12e68a3](https://github.com/folke/snacks.nvim/commit/12e68a33b5a1fd3648a7a558ef027fbb245125f7)) * **scope:** change from/to selection to make more sense ([e8dd394](https://github.com/folke/snacks.nvim/commit/e8dd394c01699276e8f7214957625222c30c8e9e)) * **scope:** possible loop? See [#278](https://github.com/folke/snacks.nvim/issues/278) ([ac6a748](https://github.com/folke/snacks.nvim/commit/ac6a74823b29cc1839df82fc839b81400ca80d45)) * **scratch:** normalize filename ([5200a8b](https://github.com/folke/snacks.nvim/commit/5200a8baa59a96e73786c11192282d2d3e10deeb)) * **scroll:** don't animate scroll distance 1 ([a986851](https://github.com/folke/snacks.nvim/commit/a986851a74512683c3331fa72a220751026fd611)) ## [2.9.0](https://github.com/folke/snacks.nvim/compare/v2.8.0...v2.9.0) (2024-12-12) ### Features * **animate:** allow disabling all animations globally or per buffer ([25c290d](https://github.com/folke/snacks.nvim/commit/25c290d7c093f0c57473ffcccf56780b6d58dd37)) * **animate:** allow toggling buffer-local / global animations with or without id ([50912dc](https://github.com/folke/snacks.nvim/commit/50912dc2fd926a49e3574d7029aed11fae3fb45b)) * **dashboard:** add dashboard startuptime icon option ([#214](https://github.com/folke/snacks.nvim/issues/214)) ([63506d5](https://github.com/folke/snacks.nvim/commit/63506d5168d2bb7679026cab80df1adfe3cd98b8)) * **indent:** animation styles `out`, `up_down`, `up`, `down` ([0a9b013](https://github.com/folke/snacks.nvim/commit/0a9b013ff13f6d1a550af2eac366c73d25e55e0a)) * **indent:** don't animate indents when new scope overlaps with the previous one, taking white-space into account. See [#264](https://github.com/folke/snacks.nvim/issues/264) ([9b4a859](https://github.com/folke/snacks.nvim/commit/9b4a85905aaa114e04a9371585953e96b3095f93)) * **indent:** move animate settings top-level, since they impact both scope and chunk ([baf8c18](https://github.com/folke/snacks.nvim/commit/baf8c180d9dda5797b4da538f7af122f4349f554)) * **toggle:** added zoom toggle ([3367705](https://github.com/folke/snacks.nvim/commit/336770581348c137bc2cb3967cc2af90b2ff51a2)) * **toggle:** return toggle after map ([4f22016](https://github.com/folke/snacks.nvim/commit/4f22016b4b765f3335ae7682fb5b3b79b414ecbd)) * **util:** get var either from buffer or global ([4243912](https://github.com/folke/snacks.nvim/commit/42439123c4fbc088fbe0bdd636a6bdc501794491)) ### Bug Fixes * **indent:** make sure cursor line is in scope for the `out` style. Fixes [#264](https://github.com/folke/snacks.nvim/issues/264) ([39c009f](https://github.com/folke/snacks.nvim/commit/39c009fe0b45243ccbbd372e659dcbd9409a68df)) * **indent:** when at edge of two blocks, prefer the one below. See [#231](https://github.com/folke/snacks.nvim/issues/231) ([2457d91](https://github.com/folke/snacks.nvim/commit/2457d913dc92835da12fc071b43bce3a03d31470)) ## [2.8.0](https://github.com/folke/snacks.nvim/compare/v2.7.0...v2.8.0) (2024-12-11) ### Features * **animate:** added animate plugin ([971229e](https://github.com/folke/snacks.nvim/commit/971229e8a93dab7dc73fe379110cdb47a7fd1387)) * **animate:** added animation context to callbacks ([1091280](https://github.com/folke/snacks.nvim/commit/109128087709fe5cba39e6983b8722b60cce8120)) * **dim:** added dim plugin ([4dda551](https://github.com/folke/snacks.nvim/commit/4dda5516e88a64c2b387727662d4ecd645582c55)) * **indent:** added indent plugin ([2c4021c](https://github.com/folke/snacks.nvim/commit/2c4021c4663ff4fe5da5b95c3e06a4f6eb416502)) * **indent:** allow disabling indent guides. See [#230](https://github.com/folke/snacks.nvim/issues/230) ([4a4ad63](https://github.com/folke/snacks.nvim/commit/4a4ad633dc9f864532716af4387b5e035d57768c)) * **indent:** allow disabling scope highlighting ([99207ee](https://github.com/folke/snacks.nvim/commit/99207ee44d3a2b4d14c915b08407bae708749235)) * **indent:** optional rendering of scopes as chunks. Closes [#230](https://github.com/folke/snacks.nvim/issues/230) ([109a0d2](https://github.com/folke/snacks.nvim/commit/109a0d207eeee49e13a6640131b44383e31d0b0f)) * **input:** added `input` snack ([70902ee](https://github.com/folke/snacks.nvim/commit/70902eee9e5aca7791450c6065dd51bed4651f24)) * **profiler:** on_close can now be a function ([48a5879](https://github.com/folke/snacks.nvim/commit/48a58792a0dd2e3c9249cfa4b1df73a8ea86a290)) * **scope:** added scope plugin ([63a279c](https://github.com/folke/snacks.nvim/commit/63a279c4e2e84ed02b5a2a6c2f84d68daf8f906a)) * **scope:** fill the range for treesitter scopes ([38ed01b](https://github.com/folke/snacks.nvim/commit/38ed01b5a229fc0c41b07dde08e9119de9ff1c4e)) * **scope:** text objects and jumping for scopes. Closes [#231](https://github.com/folke/snacks.nvim/issues/231) ([8faafb3](https://github.com/folke/snacks.nvim/commit/8faafb34831c48f3ff777d6bd0ced1c68c8ab82f)) * **scroll:** added smooth scrolling plugin ([38a5ccc](https://github.com/folke/snacks.nvim/commit/38a5ccc3a6436ba67fba71f6a2a9693ee1c2f142)) * **scroll:** allow disabling scroll globally or for some buffers ([04f15c1](https://github.com/folke/snacks.nvim/commit/04f15c1ba29afa6d1b085eb0d85a654c88be8fde)) * **scroll:** use `on_key` to track mouse scrolling ([26c3e49](https://github.com/folke/snacks.nvim/commit/26c3e4960f37320bcd418ec18f859b0e24d1e7d8)) * **scroll:** user virtual columns while scrolling ([fefa6fd](https://github.com/folke/snacks.nvim/commit/fefa6fd6920a2f8a6e717ae856c14e32d5d76ddb)) * **snacks:** zen mode ([c509ea5](https://github.com/folke/snacks.nvim/commit/c509ea52b7b3487e3d904d9f3d55d20ad136facb)) * **toggle:** add which-key mappings when which-key loads ([c9f494b](https://github.com/folke/snacks.nvim/commit/c9f494bd9a4729722186d2631ca91192ffc19b40)) * **toggle:** add zen mode toggle ([#243](https://github.com/folke/snacks.nvim/issues/243)) ([9454ba3](https://github.com/folke/snacks.nvim/commit/9454ba35f8c6ad3baeda4132fe1e5c96a5850960)) * **toggle:** added toggle for smooth scroll ([aeec09c](https://github.com/folke/snacks.nvim/commit/aeec09c5413c87df7ca827bd6b7c3fbf0f4d2909)) * **toggle:** toggles for new plugins ([bddae83](https://github.com/folke/snacks.nvim/commit/bddae83141d9e18b23a5d7a9ccc52c76ad736ca2)) * **util:** added Snacks.util.on_module to execute a callback when a module loads (or immediately when already loaded) ([f540b7b](https://github.com/folke/snacks.nvim/commit/f540b7b6cc10223adcbd6d9747155a076ddfa9a4)) * **util:** set_hl no longer sets default=true when not specified ([d6309c6](https://github.com/folke/snacks.nvim/commit/d6309c62b8e5910407449975b9e333c2699d06d0)) * **win:** added actions to easily combine actions in keymaps ([46362a5](https://github.com/folke/snacks.nvim/commit/46362a5a9c2583094bd0416dd6dea17996eaecf9)) * **win:** allow configuring initial text to display in the buffer ([003ea8d](https://github.com/folke/snacks.nvim/commit/003ea8d6edcf6d813bfdc143ffe4fa6cc55c0ea5)) * **win:** allow customizing backdrop window ([cdb495c](https://github.com/folke/snacks.nvim/commit/cdb495cb8f7b801d9d731cdfa2c6f92fadf1317d)) * **win:** col/row can be negative calculated on height/end of parent ([bd49d2f](https://github.com/folke/snacks.nvim/commit/bd49d2f32e567cbe42adf0bd8582b7829de6c1dc)) * **words:** added toggle for words ([bd7cf03](https://github.com/folke/snacks.nvim/commit/bd7cf038234a84b48d1c1f09dffae9e64910ff7e)) * **zen:** `zz` when entering zen mode ([b5cb90f](https://github.com/folke/snacks.nvim/commit/b5cb90f91dedaa692c4da1dfa216d13e58ad219d)) * **zen:** added zen plugin ([afb89ea](https://github.com/folke/snacks.nvim/commit/afb89ea159a20e1241656af5aa46f638327d2f5a)) * **zen:** added zoom indicator ([8459e2a](https://github.com/folke/snacks.nvim/commit/8459e2adc090aaf59865a60836c360744d82ed0a)) ### Bug Fixes * **compat:** fixes for Neovim < 0.10 ([33fbb30](https://github.com/folke/snacks.nvim/commit/33fbb309f8c21c8ec30b99fe323a5cc55c84c5bc)) * **dashboard:** add filetype to terminal sections ([#215](https://github.com/folke/snacks.nvim/issues/215)) ([9c68a54](https://github.com/folke/snacks.nvim/commit/9c68a54af652ff69348848dad62c4cd901da59a0)) * **dashboard:** don't open with startup option args ([#222](https://github.com/folke/snacks.nvim/issues/222)) ([6b78172](https://github.com/folke/snacks.nvim/commit/6b78172864ef94cd5f2ab184c0f98cf36f5a8e74)) * **dashboard:** override foldmethod ([47ad2a7](https://github.com/folke/snacks.nvim/commit/47ad2a7bfa49c3eb5c20083de82a39f59fb8f17a)) * **debug:** schedule wrap print ([3a107af](https://github.com/folke/snacks.nvim/commit/3a107afbf8dffabf6c2754750c51d740707b76af)) * **dim:** check if win still exist when animating. Closes [#259](https://github.com/folke/snacks.nvim/issues/259) ([69018d0](https://github.com/folke/snacks.nvim/commit/69018d070c9a0db76abc0f34539287e1c181a5d4)) * **health:** health checks ([72eba84](https://github.com/folke/snacks.nvim/commit/72eba841801928b00a1f1f74e1f976a31534a674)) * **indent:** always align indents with shiftwidth ([1de6c15](https://github.com/folke/snacks.nvim/commit/1de6c152883b576524201a465e1b8a09622a6041)) * **indent:** always render underline regardless of leftcol ([4e96e69](https://github.com/folke/snacks.nvim/commit/4e96e692e8a3c6c67f9c9b2971b6fa263461054b)) * **indent:** always use scope hl to render underlines. Fixes [#234](https://github.com/folke/snacks.nvim/issues/234) ([8723945](https://github.com/folke/snacks.nvim/commit/8723945183aac31671d1fa27481c90dc7c665c02)) * **indent:** better way of dealing with indents on blank lines. See [#246](https://github.com/folke/snacks.nvim/issues/246) ([c129683](https://github.com/folke/snacks.nvim/commit/c1296836f5c36e75b508aae9a76aa0931058feec)) * **indent:** expand scopes to inlude end_pos based on the end_pos scope. See [#231](https://github.com/folke/snacks.nvim/issues/231) ([897f801](https://github.com/folke/snacks.nvim/commit/897f8019248009eca191185cf7094e04c9371a85)) * **indent:** gradually increase scope when identical to visual selection for text objects ([bc7f96b](https://github.com/folke/snacks.nvim/commit/bc7f96bdee77368d4ddae2613823f085266529ab)) * **indent:** properly deal with empty lines when highlighting scopes. Fixes [#246](https://github.com/folke/snacks.nvim/issues/246). Fixes [#245](https://github.com/folke/snacks.nvim/issues/245) ([d04cf1d](https://github.com/folke/snacks.nvim/commit/d04cf1dc4f332bade99c300283380bf0893f1996)) * **indent:** set max_size=1 for textobjects and jumps by default. See [#231](https://github.com/folke/snacks.nvim/issues/231) ([5f217bc](https://github.com/folke/snacks.nvim/commit/5f217bca6adc88a5dc6aa55e5bf0580b95025a52)) * **indent:** set shiftwidth to tabstop when 0 ([782b6ee](https://github.com/folke/snacks.nvim/commit/782b6ee3fca35ede62b8dba866a9ad5c50edfdce)) * **indent:** underline. See [#234](https://github.com/folke/snacks.nvim/issues/234) ([51f9569](https://github.com/folke/snacks.nvim/commit/51f95693aedcde10e65c8121c6fd1293a3ac3819)) * **indent:** use correct config options ([5352198](https://github.com/folke/snacks.nvim/commit/5352198b5a59968c871b962fa15f1d7ca4eb7b52)) * **init:** enabled check ([519a45b](https://github.com/folke/snacks.nvim/commit/519a45bfe5df7fdf5aea0323e978e20eb52e15bc)) * **init:** set input disabled by default. Fixes [#227](https://github.com/folke/snacks.nvim/issues/227) ([e9d0993](https://github.com/folke/snacks.nvim/commit/e9d099322fca1bb7b35e781230e4a7478ded86bf)) * **input:** health check. Fixes [#239](https://github.com/folke/snacks.nvim/issues/239) ([acf743f](https://github.com/folke/snacks.nvim/commit/acf743fcfc4e0e42e1c9fe5c06f677849fa38e8b)) * **input:** set current win before executing callback. Fixes [#257](https://github.com/folke/snacks.nvim/issues/257) ([c17c1b2](https://github.com/folke/snacks.nvim/commit/c17c1b2f6c99f0ed4f3ab8f00bf32bb39f9d0186)) * **input:** set current win in `vim.schedule` so that it works properly from `expr` keymaps. Fixes [#257](https://github.com/folke/snacks.nvim/issues/257) ([8c2410c](https://github.com/folke/snacks.nvim/commit/8c2410c2de0a86c095177a831993f8eea78a63b6)) * **input:** update window position in the context of the parent window to make sure position=cursor works as expected. Fixes [#254](https://github.com/folke/snacks.nvim/issues/254) ([6c27ff2](https://github.com/folke/snacks.nvim/commit/6c27ff2a365c961831c7620365dd87fa8d8ad633)) * **input:** various minor visual fixes ([#252](https://github.com/folke/snacks.nvim/issues/252)) ([e01668c](https://github.com/folke/snacks.nvim/commit/e01668c36771c0c1424a2ce3ab26a09cbb43d472)) * **notifier:** toggle show history. Fixes [#197](https://github.com/folke/snacks.nvim/issues/197) ([8b58b55](https://github.com/folke/snacks.nvim/commit/8b58b55e40221ca5124f156f47e46185310fbe1c)) * **scope:** better edge detection for treesitter scopes ([6b02a09](https://github.com/folke/snacks.nvim/commit/6b02a09e5e81e4e38a42e0fcc2d7f0350404c228)) * **scope:** return `nil` when buffer is empty for indent scope ([4aa378a](https://github.com/folke/snacks.nvim/commit/4aa378a35e8f3d3771410344525cc4bc9ac50e8a)) * **scope:** take edges into account for min_size ([e2e6c86](https://github.com/folke/snacks.nvim/commit/e2e6c86d214029bfeae5d50929aee72f7059b7b7)) * **scope:** typo for textobject ([0324125](https://github.com/folke/snacks.nvim/commit/0324125ca1e5a5e6810d28ea81bb2c7c0af1dc16)) * **scroll:** better toggle ([3dcaad8](https://github.com/folke/snacks.nvim/commit/3dcaad8d0aacc1a736f75fee5719adbf80cbbfa2)) * **scroll:** disable scroll by default for terminals ([7b5a78a](https://github.com/folke/snacks.nvim/commit/7b5a78a5c76cdf2c73abbeef47ac14bb8ccbee72)) * **scroll:** don't animate invalid windows ([41ca13d](https://github.com/folke/snacks.nvim/commit/41ca13d119b328872ed1da9c8f458c5c24962d31)) * **scroll:** don't bother setting cursor when scrolloff is larger than half of viewport. Fixes [#240](https://github.com/folke/snacks.nvim/issues/240) ([0ca9ca7](https://github.com/folke/snacks.nvim/commit/0ca9ca79926b3bf473172eee7072fa2838509fba)) * **scroll:** move scrollbind check to M.check ([7211ec0](https://github.com/folke/snacks.nvim/commit/7211ec08ce01da754544f66e965effb13fd22fd3)) * **scroll:** only animate the current window when scrollbind is active ([c9880ce](https://github.com/folke/snacks.nvim/commit/c9880ce872ca000d17ae8d62b10e913045f54735)) * **scroll:** set cursor to correct position when target is reached. Fixes [#236](https://github.com/folke/snacks.nvim/issues/236) ([4209929](https://github.com/folke/snacks.nvim/commit/4209929e6d4f67d3d216d1ab5f52dc387c8e27c2)) * **scroll:** use actual scrolling to perform the scroll to properly deal with folds etc. Fixes [#236](https://github.com/folke/snacks.nvim/issues/236) ([280a09e](https://github.com/folke/snacks.nvim/commit/280a09e4eef08157eface8398295eb7fa3f9a08d)) * **win:** ensure win is set when relative=win ([5d472b8](https://github.com/folke/snacks.nvim/commit/5d472b833b7f925033fea164de0ab9e389e31bef)) * **words:** incorrect enabled check. Fixes [#247](https://github.com/folke/snacks.nvim/issues/247) ([9c8f3d5](https://github.com/folke/snacks.nvim/commit/9c8f3d531874ebd20eebe259f5c30cea575a1bba)) * **zen:** properly close existing zen window on toggle ([14da56e](https://github.com/folke/snacks.nvim/commit/14da56ee9791143ef2503816fb93f8bd2bf0b58d)) * **zen:** return after closing. Fixes [#259](https://github.com/folke/snacks.nvim/issues/259) ([b13eaf6](https://github.com/folke/snacks.nvim/commit/b13eaf6bd9089d8832c79f4088e72affc449c8ee)) * **zen:** when Normal is transparent, show an opaque transparent backdrop. Fixes [#235](https://github.com/folke/snacks.nvim/issues/235) ([d93de7a](https://github.com/folke/snacks.nvim/commit/d93de7af6916d2c734c9420624b8703236f386ff)) ### Performance Improvements * **animate:** check for animation easing updates ouside the main loop and schedule an update when needed ([03c0774](https://github.com/folke/snacks.nvim/commit/03c0774e8555a38267624309d4f00a30e351c4be)) * **input:** lazy-load `vim.ui.input` ([614df63](https://github.com/folke/snacks.nvim/commit/614df63acfb5ce9b1ac174ea4f09e545a086af4d)) * **util:** redraw helpers ([9fb88c6](https://github.com/folke/snacks.nvim/commit/9fb88c67b60cbd9d4a56f9aadcb9285929118518)) ## [2.7.0](https://github.com/folke/snacks.nvim/compare/v2.6.0...v2.7.0) (2024-12-07) ### Features * **bigfile:** disable matchparen, set foldmethod=manual, set conceallevel=0 ([891648a](https://github.com/folke/snacks.nvim/commit/891648a483b6f5410ec9c8b74890d5a00b50fa4c)) * **dashbard:** explude files from stdpath data/cache/state in recent files and projects ([b99bc64](https://github.com/folke/snacks.nvim/commit/b99bc64ef910cd075e4ab9cf0914e99e1a1d61c1)) * **dashboard:** allow items to be hidden, but still create the keymaps etc ([7a47eb7](https://github.com/folke/snacks.nvim/commit/7a47eb76df2fd36bfcf3ed5c4da871542e1386be)) * **dashboard:** allow terminal sections to have actions. Closes [#189](https://github.com/folke/snacks.nvim/issues/189) ([78f44f7](https://github.com/folke/snacks.nvim/commit/78f44f720b1b0609930581965f4f92649efae95b)) * **dashboard:** hide title if section has no items. Fixes [#184](https://github.com/folke/snacks.nvim/issues/184) ([d370be6](https://github.com/folke/snacks.nvim/commit/d370be6d6966a298725901abad4bec90264859af)) * **dashboard:** make buffer not listed ([#191](https://github.com/folke/snacks.nvim/issues/191)) ([42d6277](https://github.com/folke/snacks.nvim/commit/42d62775d82b7af4dbe001b04be6a8a6e461e8ec)) * **dashboard:** when a section has a title, use that for action and key. If not put it in the section. Fixes [#189](https://github.com/folke/snacks.nvim/issues/189) ([045a17d](https://github.com/folke/snacks.nvim/commit/045a17da069aae6221b2b3eae8610c2aa5ca03ea)) * **debug:** added `Snacks.debug.run()` to execute the buffer or selection with inlined print and errors ([e1fe4f5](https://github.com/folke/snacks.nvim/commit/e1fe4f5afed5c679fd2eed3486f17e8c0994b982)) * **gitbrowse:** added `line_count`. See [#186](https://github.com/folke/snacks.nvim/issues/186) ([f03727c](https://github.com/folke/snacks.nvim/commit/f03727c77f739503fd297dd12a826c2aca3490f9)) * **gitbrowse:** opts.notify ([a856952](https://github.com/folke/snacks.nvim/commit/a856952ab24757f4eaf4ae2e1728d097f1866681)) * **gitbrowse:** url pattern can now also be a function ([0a48c2e](https://github.com/folke/snacks.nvim/commit/0a48c2e726e6ca90370260a05618f45345dbb66a)) * **notifier:** reverse notif history by default for `show_history` ([5a50738](https://github.com/folke/snacks.nvim/commit/5a50738b8e952519658570f31ff5f24e06882f18)) * **scratch:** `opts.ft` can now be a function and defaults to the ft of the current buffer or markdown ([652303e](https://github.com/folke/snacks.nvim/commit/652303e6de5fa746709979425f4bed763e3fcbfa)) * **scratch:** change keymap to execute buffer/selection to `<cr>` ([7db0ed4](https://github.com/folke/snacks.nvim/commit/7db0ed4239a2f67c0ca288aaac21bc6aa65212a7)) * **scratch:** use `Snacks.debug.run()` to execute buffer/selection ([32c46b4](https://github.com/folke/snacks.nvim/commit/32c46b4e2f61c026e41b3fa128d01b0e89da106c)) * **snacks:** added `Snacks.profiler` ([8088799](https://github.com/folke/snacks.nvim/commit/808879951f960399844c89efef9aec1724f83402)) * **snacks:** added new `scratch` snack ([1cec695](https://github.com/folke/snacks.nvim/commit/1cec695fefb6e42ee644cfaf282612c213009aed)) * **toggle:** toggles for the profiler ([999ae07](https://github.com/folke/snacks.nvim/commit/999ae07808858df08d30eb099a8dbce401527008)) * **util:** encode/decode a string to be used as a filename ([e6f6397](https://github.com/folke/snacks.nvim/commit/e6f63970de2225ad44ed08af7ffd8a0f37d8fc58)) * **util:** simple function to get an icon ([7c29848](https://github.com/folke/snacks.nvim/commit/7c29848e89861b40e751cc15c557cf1e574acf66)) * **win:** added `opts.fixbuf` to configure fixed window buffers ([1f74d1c](https://github.com/folke/snacks.nvim/commit/1f74d1ce77d2015e2802027c93b9e0bcd548e4d1)) * **win:** backdrop can now be made opaque ([681b9c9](https://github.com/folke/snacks.nvim/commit/681b9c9d650e7b01a5e54567656f646fbd3b8d46)) * **win:** width/height can now be a function ([964d7ae](https://github.com/folke/snacks.nvim/commit/964d7ae99af1f45949175e1494562a796e2ef99b)) ### Bug Fixes * **dashboard:** calculate proper offset when item has no text ([6e3b954](https://github.com/folke/snacks.nvim/commit/6e3b9546de4871a696652cfcee6768c39e7b8ee9)) * **dashboard:** prevent possible duplicate recent files. Fixes [#171](https://github.com/folke/snacks.nvim/issues/171) ([93b254d](https://github.com/folke/snacks.nvim/commit/93b254d65845aa44ad1e01000c26e1faf5efb9a6)) * **dashboard:** take hidden items into account when calculating padding. Fixes [#194](https://github.com/folke/snacks.nvim/issues/194) ([736ce44](https://github.com/folke/snacks.nvim/commit/736ce447e8815eb231271abf7d49d8fa7d96e225)) * **dashboard:** take indent into account when calculating terminal width ([cda695e](https://github.com/folke/snacks.nvim/commit/cda695e53ffb34c7569dc3536134c9e432b2a1c1)) * **dashboard:** truncate file names when too long. Fixes [#183](https://github.com/folke/snacks.nvim/issues/183) ([4bdf7da](https://github.com/folke/snacks.nvim/commit/4bdf7daece384ab8a5472e6effd5fd6167a5ce6a)) * **debug:** better way of getting visual selection. See [#190](https://github.com/folke/snacks.nvim/issues/190) ([af41cb0](https://github.com/folke/snacks.nvim/commit/af41cb088d3ff8da9dede9fbdd5c81860bc37e64)) * **gitbrowse:** opts.notify ([2436557](https://github.com/folke/snacks.nvim/commit/243655796e4adddf58ce581f1b86a283130ecf41)) * **gitbrowse:** removed debug ([f894952](https://github.com/folke/snacks.nvim/commit/f8949523ed3f27f976e5346051a6658957d9492a)) * **gitbrowse:** use line_start and line_end directly in patterns. Closes [#186](https://github.com/folke/snacks.nvim/issues/186) ([adf0433](https://github.com/folke/snacks.nvim/commit/adf0433e8fca3fbc7287ac7b05f01f10ac354283)) * **profiler:** startup opts ([85f5132](https://github.com/folke/snacks.nvim/commit/85f51320b2662830a6435563668a04ab21686178)) * **scratch:** always set filetype on the buffer. Fixes [#179](https://github.com/folke/snacks.nvim/issues/179) ([6db50cf](https://github.com/folke/snacks.nvim/commit/6db50cfe2d1b333512e8175d072a2f82e796433d)) * **scratch:** floating window title/footer hl groups ([6c25ab1](https://github.com/folke/snacks.nvim/commit/6c25ab1108d12ef3642e97e3757710e69782cbd1)) * **scratch:** make sure win.opts.keys exists. See [#190](https://github.com/folke/snacks.nvim/issues/190) ([50bd510](https://github.com/folke/snacks.nvim/commit/50bd5103ba0a15294b1a931fda14900e7fd10161)) * **scratch:** sort keys. Fixes [#193](https://github.com/folke/snacks.nvim/issues/193) ([0df7a08](https://github.com/folke/snacks.nvim/commit/0df7a08b01b037e434efe7cd25e7d4608a282a92)) * **scratch:** weirdness with visual selection and inclusive/exclusive. See [#190](https://github.com/folke/snacks.nvim/issues/190) ([f955f08](https://github.com/folke/snacks.nvim/commit/f955f082e09683d02df3fd0f8b621b938f38e6aa)) * **statuscolumn:** add virtnum and relnum to cache key. See [#198](https://github.com/folke/snacks.nvim/issues/198) ([3647ca7](https://github.com/folke/snacks.nvim/commit/3647ca7d8a47362a01b539f5b6efc2d5339b5e8e)) * **statuscolumn:** don't show signs on virtual ligns. See [#198](https://github.com/folke/snacks.nvim/issues/198) ([f5fb59c](https://github.com/folke/snacks.nvim/commit/f5fb59cc4c62e25745bbefd2ef55665a67196245)) * **util:** better support for nvim-web-devicons ([ddaa2aa](https://github.com/folke/snacks.nvim/commit/ddaa2aaba59bbd05c03992bfb295c98ccd3b3e50)) * **util:** make sure to always return an icon ([ca7188c](https://github.com/folke/snacks.nvim/commit/ca7188c531350fe313c211ad60a59d642749f93e)) * **win:** allow resolving nil window option values. Fixes [#179](https://github.com/folke/snacks.nvim/issues/179) ([0043fa9](https://github.com/folke/snacks.nvim/commit/0043fa9ee142b63ab653507f2e6f45395e8a23d5)) * **win:** don't force close modified buffers ([d517b11](https://github.com/folke/snacks.nvim/commit/d517b11cabf94bf833d020c7a0781122d0f48c06)) * **win:** update opts.wo for padding instead of vim.wo directly ([446f502](https://github.com/folke/snacks.nvim/commit/446f50208fe823787ce60a8b216a622a4b6b63dd)) * **win:** update window local options when the buffer changes ([630d96c](https://github.com/folke/snacks.nvim/commit/630d96cf1f0403352580f2d119fc3b3ba29e33a4)) ### Performance Improvements * **dashboard:** properly cleanup autocmds ([8e6d977](https://github.com/folke/snacks.nvim/commit/8e6d977ec985a1b3f12a53741df82881a7835f9a)) * **statuscolumn:** optimize caching ([d972bc0](https://github.com/folke/snacks.nvim/commit/d972bc0a471fbf3067a115924a4add852d15f5f0)) ## [2.6.0](https://github.com/folke/snacks.nvim/compare/v2.5.0...v2.6.0) (2024-11-29) ### Features * **config:** allow overriding resolved options for a plugin. See [#164](https://github.com/folke/snacks.nvim/issues/164) ([d730a13](https://github.com/folke/snacks.nvim/commit/d730a13b5519fa901114cbdd0b2d484067068cce)) * **config:** make it easier to use examples in your config ([6e3cb7e](https://github.com/folke/snacks.nvim/commit/6e3cb7e53c0a1b314203d392dc1b7df8207a31a6)) * **dashboard:** allow passing win=0, buf=0 to use for the dashboard instead of creating a new window ([417e07c](https://github.com/folke/snacks.nvim/commit/417e07c0d22173a0a50ed8207e913ba25b96088e)) * **dashboard:** always render cache even when expired. Then refresh when needed. ([59f8f0d](https://github.com/folke/snacks.nvim/commit/59f8f0db99e7a2d4f6a181f02f3fe77355c016c8)) * **gitbrowse:** add Bitbucket URL patterns ([#163](https://github.com/folke/snacks.nvim/issues/163)) ([53441c9](https://github.com/folke/snacks.nvim/commit/53441c97030dbc15b4a22d56e33054749a13750f)) * **gitbrowse:** open commit when word is valid hash ([#161](https://github.com/folke/snacks.nvim/issues/161)) ([59c8eb3](https://github.com/folke/snacks.nvim/commit/59c8eb36ae6933dd54d87811fec22decfe4f303c)) * **health:** check that snacks.nvim plugin spec is correctly setup ([2c7b4b7](https://github.com/folke/snacks.nvim/commit/2c7b4b7971c8b488cfc9949f402f6c0307e24fce)) * **notifier:** added history opts.reverse ([bebd7e7](https://github.com/folke/snacks.nvim/commit/bebd7e70cdd336dcc582227c2f3bd6ea0cef60d9)) * **win:** go back to the previous window, when closing a snacks window ([51996df](https://github.com/folke/snacks.nvim/commit/51996dfeac5f0936aa1196e90b28760eb028ac1a)) ### Bug Fixes * **config:** check correct var for single config result. Fixes [#167](https://github.com/folke/snacks.nvim/issues/167) ([45fd0ef](https://github.com/folke/snacks.nvim/commit/45fd0efe41a453f1fa54a0892d352b931d6f88bb)) * **dashboard:** fixed mini.sessions.read. Fixes [#144](https://github.com/folke/snacks.nvim/issues/144) ([4e04b70](https://github.com/folke/snacks.nvim/commit/4e04b70ea3f6f91ae47e0fc7671e53e801171290)) * **dashboard:** terminal commands get 5 seconds to complete to trigger caching ([f83a7b0](https://github.com/folke/snacks.nvim/commit/f83a7b0ffb13adfae55a464f4d99fe3d4b578fe6)) * **git:** make git.get_root work for work-trees and cache git root checks. Closes [#136](https://github.com/folke/snacks.nvim/issues/136). Fixes [#115](https://github.com/folke/snacks.nvim/issues/115) ([9462273](https://github.com/folke/snacks.nvim/commit/9462273bf7c0e627da0f412c02daee907947078d)) * **init:** use rawget when loading modules to prevent possible recursive loading with invalid module fields ([d0794dc](https://github.com/folke/snacks.nvim/commit/d0794dcf8e988cf70c8db705a6e65867ba3b6e30)) * **notifier:** always show notifs directly when blocking ([0c7f7c5](https://github.com/folke/snacks.nvim/commit/0c7f7c5970d204d62488a4e351f1f1514a2a42e5)) * **notifier:** gracefully handle E565 errors ([0bbc9e7](https://github.com/folke/snacks.nvim/commit/0bbc9e7ae65820bc5ee356e1321656a7106d409a)) * **statuscolumn:** bad copy/paste!! Fixes [#152](https://github.com/folke/snacks.nvim/issues/152) ([7564a30](https://github.com/folke/snacks.nvim/commit/7564a30cad803c01f8ecc15683a280d2f0e9bdb7)) * **statuscolumn:** never error (in case of E565 for example). Fixes [#150](https://github.com/folke/snacks.nvim/issues/150) ([cf84008](https://github.com/folke/snacks.nvim/commit/cf840087c5adf1c076b61fdd044ac960b31e4e1e)) * **win:** handle E565 errors on close ([0b02044](https://github.com/folke/snacks.nvim/commit/0b020449ad8496c6bfd34e10bc69f807b52970f8)) ### Performance Improvements * **statuscolumn:** some small optims ([985be4a](https://github.com/folke/snacks.nvim/commit/985be4a759f6fe83e569679da431eeb7d2db5286)) ## [2.5.0](https://github.com/folke/snacks.nvim/compare/v2.4.0...v2.5.0) (2024-11-22) ### Features * **dashboard:** added Snacks.dashboard.update(). Closes [#121](https://github.com/folke/snacks.nvim/issues/121) ([c770ebe](https://github.com/folke/snacks.nvim/commit/c770ebeaf7b19abad8a447ef55b48cec71e7db54)) * **debug:** profile title ([0177079](https://github.com/folke/snacks.nvim/commit/017707955f465335900c4fd483c32df018fd3427)) * **notifier:** show indicator when notif has more lines. Closes [#112](https://github.com/folke/snacks.nvim/issues/112) ([cf72c06](https://github.com/folke/snacks.nvim/commit/cf72c06ee61f3102bf828ee7e8dde20316310374)) * **terminal:** added Snacks.terminal.get(). Closes [#122](https://github.com/folke/snacks.nvim/issues/122) ([7f63d4f](https://github.com/folke/snacks.nvim/commit/7f63d4fefb7ba22f6e98986f7adeb04f9f9369b1)) * **util:** get hl color ([b0da066](https://github.com/folke/snacks.nvim/commit/b0da066536493b6ed977744e4ee91fac01fcc2a8)) * **util:** set_hl managed ([9642695](https://github.com/folke/snacks.nvim/commit/96426953a029b12d02ad45849e086c1ee14e065b)) ### Bug Fixes * **dashboard:** `vim.pesc` for auto keys. Fixes [#134](https://github.com/folke/snacks.nvim/issues/134) ([aebffe5](https://github.com/folke/snacks.nvim/commit/aebffe535b09237b28d2c61bb3febab12bc95ae8)) * **dashboard:** align should always set width even if no alignment is needed. Fixes [#137](https://github.com/folke/snacks.nvim/issues/137) ([54d521c](https://github.com/folke/snacks.nvim/commit/54d521cd0fde5e3ccf36716f23371707d0267768)) * **dashboard:** better git check for advanced example. See [#126](https://github.com/folke/snacks.nvim/issues/126) ([b4a293a](https://github.com/folke/snacks.nvim/commit/b4a293aac747fbde7aafa72242a2d26dc17e325d)) * **dashboard:** open fullscreen on relaunch ([853240b](https://github.com/folke/snacks.nvim/commit/853240bb207ed7a2366c6c63ffc38f3b26fd484f)) * **dashboard:** randomseed needs argument on stable ([c359164](https://github.com/folke/snacks.nvim/commit/c359164872e82646e11c652fb0fbe723e58bfdd8)) * **debug:** include `main` in caller ([33d31af](https://github.com/folke/snacks.nvim/commit/33d31af1501ec154dba6008064d17ab72ec37d00)) * **git:** get_root should work for non file buffers ([723d8ea](https://github.com/folke/snacks.nvim/commit/723d8eac849749e9015d9e9598f99974684ca3bb)) * **notifier:** hide existing nofif if higher prio notif arrives and no free space for lower notif ([7a061de](https://github.com/folke/snacks.nvim/commit/7a061de75f758db23cf2c2ee0822a76356b54035)) * **quickfile:** don't load when bigfile detected. Fixes [#116](https://github.com/folke/snacks.nvim/issues/116) ([978424c](https://github.com/folke/snacks.nvim/commit/978424ce280ec85e78e9660b200aee9aa12e9ef2)) * **sessions:** change persisted.nvim load session command ([#118](https://github.com/folke/snacks.nvim/issues/118)) ([26bec4b](https://github.com/folke/snacks.nvim/commit/26bec4b51d617cd275218e9935fdff5390c18a87)) * **terminal:** hide on `q` instead of close ([30a0721](https://github.com/folke/snacks.nvim/commit/30a0721d56993a7125a247a07116f1a07e0efda4)) ## [2.4.0](https://github.com/folke/snacks.nvim/compare/v2.3.0...v2.4.0) (2024-11-19) ### Features * **dashboard:** hide tabline and statusline when loading during startup ([75dc74c](https://github.com/folke/snacks.nvim/commit/75dc74c5dc933b81cde85e8bc368a384343af69f)) * **dashboard:** when an item is wider than pane width and only one pane, then center it. See [#108](https://github.com/folke/snacks.nvim/issues/108) ([c15953e](https://github.com/folke/snacks.nvim/commit/c15953ee885cf8afb40dcd478569d7de2edae939)) * **gitbrowse:** open also visual selection range ([#89](https://github.com/folke/snacks.nvim/issues/89)) ([c29c0d4](https://github.com/folke/snacks.nvim/commit/c29c0d48500cb976c9210bb2d42909ad203cd4aa)) * **win:** detect alien buffers opening in managed windows and open them somewhere else. Fixes [#110](https://github.com/folke/snacks.nvim/issues/110) ([9c0d2e2](https://github.com/folke/snacks.nvim/commit/9c0d2e2e93615e70627e5c09c7bbb04e93eab2c6)) ### Bug Fixes * **dashboard:** always hide cursor ([68fcc25](https://github.com/folke/snacks.nvim/commit/68fcc258023404a0a0341a7cc93db47cd17f85f4)) * **dashboard:** check session managers in order ([1acea8b](https://github.com/folke/snacks.nvim/commit/1acea8b94005620dad70dfde6a6344c130a57c59)) * **dashboard:** fix race condition when sending data while closing ([4188446](https://github.com/folke/snacks.nvim/commit/4188446f86b5c6abae090eb6abca65d5d9bb8003)) * **dashboard:** minimum one pane even when it doesn't fit the screen. Fixes [#104](https://github.com/folke/snacks.nvim/issues/104) ([be8feef](https://github.com/folke/snacks.nvim/commit/be8feef4ab584f50aaa96b69d50b3f86a35aacff)) * **dashboard:** only check for piped stdin when in TUI. Ignore GUIs ([3311d75](https://github.com/folke/snacks.nvim/commit/3311d75f893191772a1b9525b18b94d8c3a8943a)) * **dashboard:** remove weird preset.keys function override. Just copy defaults if you want to change them ([0b9e09c](https://github.com/folke/snacks.nvim/commit/0b9e09cbd97c178ebe5db78fd373448448c5511b)) ## [2.3.0](https://github.com/folke/snacks.nvim/compare/v2.2.0...v2.3.0) (2024-11-18) ### Features * added dashboard health checks ([deb00d0](https://github.com/folke/snacks.nvim/commit/deb00d0ddc57d77f5f6c3e5510ba7c2f07e593eb)) * **dashboard:** added support for mini.sessions ([c8e209e](https://github.com/folke/snacks.nvim/commit/c8e209e6be9e8d8cdee19842a99ae7b89ac4248d)) * **dashboard:** allow opts.preset.keys to be a function with default keymaps as arg ([b7775ec](https://github.com/folke/snacks.nvim/commit/b7775ec879e14362d8e4082b7ed97a752bbb654a)) * **dashboard:** automatically detect streaming commands and don't cache those. tty-clock, cmatrix galore. Fixes [#100](https://github.com/folke/snacks.nvim/issues/100) ([082beb5](https://github.com/folke/snacks.nvim/commit/082beb508ccf3584aebfee845c1271d9f8b8abb6)) * **notifier:** timeout=0 keeps the notif visible till manually hidden. Closes [#102](https://github.com/folke/snacks.nvim/issues/102) ([0cf22a8](https://github.com/folke/snacks.nvim/commit/0cf22a8d87f28759c083969d67b55498e568a1b7)) ### Bug Fixes * **dashboard:** check uis for headless and stdin_tty. Fixes [#97](https://github.com/folke/snacks.nvim/issues/97). Fixes [#98](https://github.com/folke/snacks.nvim/issues/98) ([4ff08f1](https://github.com/folke/snacks.nvim/commit/4ff08f1c4d7b7b5d3e1da3b2cc9d6c341cd4dc1a)) * **dashboard:** debug output ([c0129da](https://github.com/folke/snacks.nvim/commit/c0129da4f839fd4306627b087cb722ea54c50c18)) * **dashboard:** disable `vim.wo.colorcolumn` ([#101](https://github.com/folke/snacks.nvim/issues/101)) ([43b4abb](https://github.com/folke/snacks.nvim/commit/43b4abb9f11a07d7130b461f9bd96b3e4e3c5b94)) * **dashboard:** notify on errors. Fixes [#99](https://github.com/folke/snacks.nvim/issues/99) ([2ae4108](https://github.com/folke/snacks.nvim/commit/2ae410889cbe6f59fb52f40cb86b25d6f7e874e2)) * **debug:** MYVIMRC is not always set ([735f4d8](https://github.com/folke/snacks.nvim/commit/735f4d8c9de6fcff31bce671495569f345818ea0)) * **notifier:** also handle timeout = false / timeout = true. See [#102](https://github.com/folke/snacks.nvim/issues/102) ([99f1f49](https://github.com/folke/snacks.nvim/commit/99f1f49104d413dabee9ee45bc22366aee97056e)) ## [2.2.0](https://github.com/folke/snacks.nvim/compare/v2.1.0...v2.2.0) (2024-11-18) ### Features * **dashboard:** added new `dashboard` snack ([#77](https://github.com/folke/snacks.nvim/issues/77)) ([d540fa6](https://github.com/folke/snacks.nvim/commit/d540fa607c415b55f5a0d773f561c19cd6287de4)) * **debug:** Snacks.debug.trace and Snacks.debug.stats for hierarchical traces (like lazy profile) ([b593598](https://github.com/folke/snacks.nvim/commit/b593598859b1bb3946671fc78ee1896d32460552)) * **notifier:** global keep when in cmdline ([73b1e20](https://github.com/folke/snacks.nvim/commit/73b1e20d38d4d238316ed391faf3d7ad4c3e71be)) ## [2.1.0](https://github.com/folke/snacks.nvim/compare/v2.0.0...v2.1.0) (2024-11-16) ### Features * **notifier:** allow specifying a minimal level to show notifications. See [#82](https://github.com/folke/snacks.nvim/issues/82) ([ec9cfb3](https://github.com/folke/snacks.nvim/commit/ec9cfb36b1ea2b4bf21b5812791c8bfee3bcf322)) * **terminal:** when terminal terminates too quickly, don't close the window and show an error message. See [#80](https://github.com/folke/snacks.nvim/issues/80) ([313954e](https://github.com/folke/snacks.nvim/commit/313954efdfb064a85df731b29fa9b86bc711044a)) ### Bug Fixes * **docs:** typo in README.md ([#78](https://github.com/folke/snacks.nvim/issues/78)) ([dc0f404](https://github.com/folke/snacks.nvim/commit/dc0f4041dcc8da860bdf84c3bf27d41a6a4debf3)) * **example:** rename file. Closes [#76](https://github.com/folke/snacks.nvim/issues/76) ([00c7a67](https://github.com/folke/snacks.nvim/commit/00c7a674004665999fbea310a322f1e105e1cfb5)) * **notifier:** no gap in border without title/icon ([#85](https://github.com/folke/snacks.nvim/issues/85)) ([bc80bdc](https://github.com/folke/snacks.nvim/commit/bc80bdcc62efd236617dbbd183ce5882aded2145)) * **win:** delay when closing windows ([#81](https://github.com/folke/snacks.nvim/issues/81)) ([d3dc8e7](https://github.com/folke/snacks.nvim/commit/d3dc8e7c27a663e4b30579e4e1ca3313052d0874)) ## [2.0.0](https://github.com/folke/snacks.nvim/compare/v1.2.0...v2.0.0) (2024-11-14) ### ⚠ BREAKING CHANGES * **config:** plugins are no longer enabled by default. Pass any options, or set `enabled = true`. ### Features * **config:** plugins are no longer enabled by default. Pass any options, or set `enabled = true`. ([797708b](https://github.com/folke/snacks.nvim/commit/797708b0384ddfd66118651c48c3b399e376cb77)) * **health:** added health checks to plugins ([1c4c748](https://github.com/folke/snacks.nvim/commit/1c4c74828fcca382f54817f4446649b201d56557)) * **terminal:** added `Snacks.terminal.colorize()` to replace ansi codes by colors ([519b684](https://github.com/folke/snacks.nvim/commit/519b6841c42c575aec2ffc6c79c4e0a1a13e74bd)) ### Bug Fixes * **lazygit:** not needed to use deprecated fallback for set_hl ([14f076e](https://github.com/folke/snacks.nvim/commit/14f076e039aa876ba086449a45053d847bddb3db)) * **notifier:** disable `colorcolumn` by default ([#66](https://github.com/folke/snacks.nvim/issues/66)) ([7627b81](https://github.com/folke/snacks.nvim/commit/7627b81d9f3453bd2e979d48d3eff2787e6029e9)) * **statuscolumn:** ensure Snacks exists when loading before plugin loaded ([97e0e1e](https://github.com/folke/snacks.nvim/commit/97e0e1ec7f1088ee026efdaa789d102461ad49d4)) * **terminal:** properly deal with args in `vim.o.shell`. Fixes [#69](https://github.com/folke/snacks.nvim/issues/69) ([2ccb70f](https://github.com/folke/snacks.nvim/commit/2ccb70fd3a42d188c95db233bfb7469259d56fb6)) * **win:** take border into account for window position ([#64](https://github.com/folke/snacks.nvim/issues/64)) ([f0e47fd](https://github.com/folke/snacks.nvim/commit/f0e47fd5dc3ccc71cdd8866e2ce82749d3797fbb)) ## [1.2.0](https://github.com/folke/snacks.nvim/compare/v1.1.0...v1.2.0) (2024-11-11) ### Features * **bufdelete:** added `wipe` option. Closes [#38](https://github.com/folke/snacks.nvim/issues/38) ([5914cb1](https://github.com/folke/snacks.nvim/commit/5914cb101070956a73462dcb1c81c8462e9e77d7)) * **lazygit:** allow overriding extra lazygit config options ([d2f4f19](https://github.com/folke/snacks.nvim/commit/d2f4f1937e6fa97a48d5839d49f1f3012067bf45)) * **notifier:** added `refresh` option configurable ([df8c9d7](https://github.com/folke/snacks.nvim/commit/df8c9d7724ade9f3c63277f08b237ac3b32b6cfe)) * **notifier:** added backward compatibility for nvim-notify's replace option ([9b9777e](https://github.com/folke/snacks.nvim/commit/9b9777ec3bba97b3ddb37bd824a9ef9a46955582)) * **words:** add `fold_open` and `set_jump_point` config options ([#31](https://github.com/folke/snacks.nvim/issues/31)) ([5dc749b](https://github.com/folke/snacks.nvim/commit/5dc749b045e62e30a156ca8522416a6d1ca9a959)) ### Bug Fixes * added compatibility with Neovim >= 0.9.4 ([4f99818](https://github.com/folke/snacks.nvim/commit/4f99818b0ab98510ab8987a0427afc515fb5f76b)) * **bufdelete:** opts.wipe. See [#38](https://github.com/folke/snacks.nvim/issues/38) ([0efbb93](https://github.com/folke/snacks.nvim/commit/0efbb93e0a4405b955d574746eb57ef6d48ae386)) * **notifier:** take title/footer into account to determine notification width. Fixes [#54](https://github.com/folke/snacks.nvim/issues/54) ([09a6f17](https://github.com/folke/snacks.nvim/commit/09a6f17eccbb551797f522403033f48e63a25f74)) * **notifier:** update layout on vim resize ([7f9f691](https://github.com/folke/snacks.nvim/commit/7f9f691a12d0665146b25a44323f21e18aa46c24)) * **terminal:** `gf` properly opens file ([#45](https://github.com/folke/snacks.nvim/issues/45)) ([340cc27](https://github.com/folke/snacks.nvim/commit/340cc2756e9d7ef0ae9a6f55cdfbfdca7a9defa7)) * **terminal:** pass a list of strings to termopen to prevent splitting. Fixes [#59](https://github.com/folke/snacks.nvim/issues/59) ([458a84b](https://github.com/folke/snacks.nvim/commit/458a84bd1db856c21f234a504ec384191a9899cf)) ### Performance Improvements * **notifier:** only force redraw for new windows and for updated while search is not active. Fixes [#52](https://github.com/folke/snacks.nvim/issues/52) ([da86b1d](https://github.com/folke/snacks.nvim/commit/da86b1deff9a0b1bb66f344241fb07577b6463b8)) * **win:** don't try highlighting snacks internal filetypes ([eb8ab37](https://github.com/folke/snacks.nvim/commit/eb8ab37f6ac421eeda2570257d2279bd12700667)) * **win:** prevent treesitter and syntax attaching to scratch buffers ([cc80f6d](https://github.com/folke/snacks.nvim/commit/cc80f6dc1b7a286cb06c6321bfeb1046f7a59418)) ## [1.1.0](https://github.com/folke/snacks.nvim/compare/v1.0.0...v1.1.0) (2024-11-08) ### Features * **bufdelete:** optional filter and shortcuts to delete `all` and `other` buffers. Closes [#11](https://github.com/folke/snacks.nvim/issues/11) ([71a2346](https://github.com/folke/snacks.nvim/commit/71a234608ffeebfa8a04c652834342fa9ce508c3)) * **debug:** simple log function to quickly log something to a debug.log file ([fc2a8e7](https://github.com/folke/snacks.nvim/commit/fc2a8e74686c7c347ed0aaa5eb607874ecdca288)) * **docs:** docs for highlight groups ([#13](https://github.com/folke/snacks.nvim/issues/13)) ([964cd6a](https://github.com/folke/snacks.nvim/commit/964cd6aa76f3608c7e379b8b1a483ae19f57e279)) * **gitbrowse:** choose to open repo, branch or file. Closes [#10](https://github.com/folke/snacks.nvim/issues/10). Closes [#17](https://github.com/folke/snacks.nvim/issues/17) ([92da87c](https://github.com/folke/snacks.nvim/commit/92da87c910a9b1421e8baae2e67020565526fba8)) * **notifier:** added history to notifier. Closes [#14](https://github.com/folke/snacks.nvim/issues/14) ([65d8c8f](https://github.com/folke/snacks.nvim/commit/65d8c8f00b6589b44410301b790d97c268f86f85)) * **notifier:** added option to show notifs top-down or bottom-up. Closes [#9](https://github.com/folke/snacks.nvim/issues/9) ([080e0d4](https://github.com/folke/snacks.nvim/commit/080e0d403924e1e62d3b88412a41f3ab22594049)) * **notifier:** allow overriding hl groups per notification ([8bcb2bc](https://github.com/folke/snacks.nvim/commit/8bcb2bc805a1785208f96ad7ad96690eee50c925)) * **notifier:** allow setting dynamic options ([36e9f45](https://github.com/folke/snacks.nvim/commit/36e9f45302bc9c200c76349ecd79a319a5944d8c)) * **win:** added default hl groups for windows ([8c0f10b](https://github.com/folke/snacks.nvim/commit/8c0f10b9dade154d355e31aa3f9c8c0ba212205e)) * **win:** allow setting `ft` just for highlighting without actually changing the `filetype` ([cad236f](https://github.com/folke/snacks.nvim/commit/cad236f9bbe46fbb53127014731d8507a3bc80af)) * **win:** disable winblend when colorscheme is transparent. Fixes [#26](https://github.com/folke/snacks.nvim/issues/26) ([12077bc](https://github.com/folke/snacks.nvim/commit/12077bcf65554b585b1f094c69746df402433132)) * **win:** equalize splits ([e982aab](https://github.com/folke/snacks.nvim/commit/e982aabefdf0b1d00ddd850152921e577cd980cc)) * **win:** util methods to handle buffer text ([d3efb92](https://github.com/folke/snacks.nvim/commit/d3efb92aa546eb160782e24e305f74a559eec212)) * **win:** win:focus() ([476fb56](https://github.com/folke/snacks.nvim/commit/476fb56bfd8e32a2805f46fadafbc4eee7878597)) * **words:** `jump` optionally shows notification with reference count ([#23](https://github.com/folke/snacks.nvim/issues/23)) ([6a3f865](https://github.com/folke/snacks.nvim/commit/6a3f865357005c934e2b5ad2cfadaa038775e9e0)) * **words:** configurable mode to show references. Defaults to n, i, c. Closes [#18](https://github.com/folke/snacks.nvim/issues/18) ([d079fbf](https://github.com/folke/snacks.nvim/commit/d079fbfe354ebfec18c4e1bfd7fee695703e3692)) ### Bug Fixes * **config:** deepcopy config where needed ([6c76f91](https://github.com/folke/snacks.nvim/commit/6c76f913981663ec0dba39686018cbc2ff3220b8)) * **config:** fix reading config during setup. Fixes [#2](https://github.com/folke/snacks.nvim/issues/2) ([0d91a4e](https://github.com/folke/snacks.nvim/commit/0d91a4e364866e407901020b59121883cbfb1cf1)) * **notifier:** re-apply winhl since level might have changed with a replace ([b8cc93e](https://github.com/folke/snacks.nvim/commit/b8cc93e273fd481f2b3b7785f64e301d70fd8e45)) * **notifier:** set default conceallevel=2 ([662795c](https://github.com/folke/snacks.nvim/commit/662795c2855b7bfd5e6ec254e469284dacdabb3f)) * **notifier:** try to keep layout when replacing notifs ([9bdb24e](https://github.com/folke/snacks.nvim/commit/9bdb24e735458ea4fd3974939c33ea78cbba0212)) * **terminal:** dont overwrite user opts ([0b08d28](https://github.com/folke/snacks.nvim/commit/0b08d280b605b2e460c1fd92bc87152e66f14430)) * **terminal:** user options ([334895c](https://github.com/folke/snacks.nvim/commit/334895c5bb2ed04f65800abaeb91ccb0487b0f1f)) * **win:** better winfixheight and winfixwidth for splits ([8be14c6](https://github.com/folke/snacks.nvim/commit/8be14c68a7825fff90ca071f0650657ba88da423)) * **win:** disable sidescroloff in minimal style ([107d10b](https://github.com/folke/snacks.nvim/commit/107d10b52e54828606a645517b55802dd807e8ad)) * **win:** dont center float when `relative="cursor"` ([4991e34](https://github.com/folke/snacks.nvim/commit/4991e347dcc6ff6c14443afe9b4d849a67b67944)) * **win:** properly resolve user styles as last ([cc5ee19](https://github.com/folke/snacks.nvim/commit/cc5ee192caf79446d58cbc09487268aa1f86f405)) * **win:** set border to none for backdrop windows ([#19](https://github.com/folke/snacks.nvim/issues/19)) ([f5602e6](https://github.com/folke/snacks.nvim/commit/f5602e60c325f0c60eb6f2869a7222beb88a773c)) * **win:** simpler way to add buffer padding ([f59237f](https://github.com/folke/snacks.nvim/commit/f59237f1dcdceb646bf2552b69b7e2040f80f603)) * **win:** update win/buf opts when needed ([5fd9c42](https://github.com/folke/snacks.nvim/commit/5fd9c426e850c02489943d7177d9e7fddec5e589)) * **words:** disable notify_jump by default ([9576081](https://github.com/folke/snacks.nvim/commit/9576081e871a801f60367e7180543fa41c384755)) ### Performance Improvements * **notifier:** index queue by id ([5df4394](https://github.com/folke/snacks.nvim/commit/5df4394c60958635bf4651d8d7e25f53f48a3965)) * **notifier:** optimize layout code ([8512896](https://github.com/folke/snacks.nvim/commit/8512896228b3e37e3d02c68fa739749c9f0b9838)) * **notifier:** skip processing queue when free space is smaller than min height ([08190a5](https://github.com/folke/snacks.nvim/commit/08190a545857ef09cb6ada4337fe7ec67d3602a9)) * **win:** skip events when setting buf/win options. Trigger FileType on BufEnter only if needed ([61496a3](https://github.com/folke/snacks.nvim/commit/61496a3ef00bd67afb7affcb4933905910a6283c)) ## 1.0.0 (2024-11-06) ### Features * added debug ([6cb43f6](https://github.com/folke/snacks.nvim/commit/6cb43f603360c6fc702b5d7c928dfde22d886e2f)) * added git ([f0a9991](https://github.com/folke/snacks.nvim/commit/f0a999134738c54dccb78ae462774eb228614221)) * added gitbrowse ([a638d8b](https://github.com/folke/snacks.nvim/commit/a638d8bafef85ac6046cfc02e415a8893e0391b9)) * added lazygit ([fc32619](https://github.com/folke/snacks.nvim/commit/fc32619734e4d3c024b8fc2db941c8ac19d2dd6c)) * added notifier ([44011dd](https://github.com/folke/snacks.nvim/commit/44011ddf0da07d0fa89734d21bb770f01a630077)) * added notify ([f4e0130](https://github.com/folke/snacks.nvim/commit/f4e0130ec3cb0299a3a85c589250c114d46f53c2)) * added toggle ([28c3029](https://github.com/folke/snacks.nvim/commit/28c30296991ac5549b49b7ecfb49f108f70d76ba)) * better buffer/window vars for terminal and float ([1abce78](https://github.com/folke/snacks.nvim/commit/1abce78a8b826943d5055464636cd9fad074b4bb)) * bigfile ([8d62b28](https://github.com/folke/snacks.nvim/commit/8d62b285d5026e3d7c064d435c424bab40d1910a)) * **bigfile:** show message when bigfile was detected ([fdc0d3d](https://github.com/folke/snacks.nvim/commit/fdc0d3d1f80a6be64e85a5a25dc34693edadd73f)) * bufdelete ([cc5353f](https://github.com/folke/snacks.nvim/commit/cc5353f6b3f3f3869e2110b2d3d1a95418653213)) * config & setup ([c98c4c0](https://github.com/folke/snacks.nvim/commit/c98c4c030711a59e6791d8e5cab7550e33ac2d2d)) * **config:** get config for snack with defaults and custom opts ([b3d08be](https://github.com/folke/snacks.nvim/commit/b3d08beb8c60fddc6bfbf96ac9f45c4db49e64af)) * **debug:** added simple profile function ([e1f736d](https://github.com/folke/snacks.nvim/commit/e1f736d71fb9020a09019a49d645d4fe6d9f30db)) * **docs:** better handling of overloads ([038b283](https://github.com/folke/snacks.nvim/commit/038b28319c3a4eba7220a679a5759c06e69b8493)) * ensure Snacks global is available when not using setup ([f0458ba](https://github.com/folke/snacks.nvim/commit/f0458bafb059da9885de4fbab1ae5cb6ce2cd0bb)) * float ([d106107](https://github.com/folke/snacks.nvim/commit/d106107cdccc7ecb9931e011a89df6011eed44c4)) * **float:** added support for splits ([977a3d3](https://github.com/folke/snacks.nvim/commit/977a3d345b6da2b819d9bc4870d3d8a7e026728e)) * **float:** better key mappings ([a171a81](https://github.com/folke/snacks.nvim/commit/a171a815b3acd72dd779781df5586a7cd6ddd649)) * initial commit ([63a24f6](https://github.com/folke/snacks.nvim/commit/63a24f6eb047530234297460a9b7ccd6af0b9858)) * **notifier:** add 1 cell left/right padding and make wrapping work properly ([efc9699](https://github.com/folke/snacks.nvim/commit/efc96996e5a98b619e87581e9527c871177dee52)) * **notifier:** added global keep config option ([f32d82d](https://github.com/folke/snacks.nvim/commit/f32d82d1b705512eb56c901d4d7de68eedc827b1)) * **notifier:** added minimal style ([b29a6d5](https://github.com/folke/snacks.nvim/commit/b29a6d5972943cb8fcfdfb94610d850f0ba050b3)) * **notifier:** allow closing notifs with `q` ([97acbbb](https://github.com/folke/snacks.nvim/commit/97acbbb654d13a0d38792fd6383973a2ca01a2bf)) * **notifier:** allow config of default filetype ([8a96888](https://github.com/folke/snacks.nvim/commit/8a968884098be83acb42f31a573e62b63420268e)) * **notifier:** enable wrapping by default ([d02aa2f](https://github.com/folke/snacks.nvim/commit/d02aa2f7cb49273330fd778818124ddb39838372)) * **notifier:** keep notif open when it's the current window ([1e95800](https://github.com/folke/snacks.nvim/commit/1e9580039b706cfc1f526fea2e46b6857473420b)) * quickfile ([d0ce645](https://github.com/folke/snacks.nvim/commit/d0ce6454f95fe056c65324a0f59a250532a658f3)) * rename ([fa33688](https://github.com/folke/snacks.nvim/commit/fa336883019110b8f525081665bf55c19df5f0aa)) * statuscolumn ([99b1700](https://github.com/folke/snacks.nvim/commit/99b170001592fe054368a220599da546de64894e)) * terminal ([e6cc7c9](https://github.com/folke/snacks.nvim/commit/e6cc7c998afa63eaf126d169f4702953f548d39f)) * **terminal:** allow to override the default terminal implementation (like toggleterm) ([11c9ee8](https://github.com/folke/snacks.nvim/commit/11c9ee83aa133f899dad966224df0e2d7de236f2)) * **terminal:** better defaults and winbar ([7ceeb47](https://github.com/folke/snacks.nvim/commit/7ceeb47e545619dff6dd8853f0a368afae7d3ec8)) * **terminal:** better double esc to go to normal mode ([a4af729](https://github.com/folke/snacks.nvim/commit/a4af729b2489714b066ca03f008bf5fe42c93343)) * **win:** better api to deal with sizes ([ac1a50c](https://github.com/folke/snacks.nvim/commit/ac1a50c810c5f67909921592afcebffa566ee3d3)) * **win:** custom views ([12d6f86](https://github.com/folke/snacks.nvim/commit/12d6f863f73cbd3580295adc5bc546c9d10e9e7f)) * words ([73445af](https://github.com/folke/snacks.nvim/commit/73445af400457722508395d18d2c974965c53fe2)) ### Bug Fixes * **config:** don't change defaults in merge ([6e825f5](https://github.com/folke/snacks.nvim/commit/6e825f509ed0e41dbafe5bca0236157772344554)) * **config:** merging of possible nil values ([f5bbb44](https://github.com/folke/snacks.nvim/commit/f5bbb446ed012361bbd54362558d1f32476206c7)) * **debug:** exclude vimrc from callers ([8845a6a](https://github.com/folke/snacks.nvim/commit/8845a6a912a528f63216ffe4b991b025c8955447)) * **float:** don't use backdrop for splits ([5eb64c5](https://github.com/folke/snacks.nvim/commit/5eb64c52aeb7271a3116751f1ec61b00536cdc08)) * **float:** only set default filetype if no ft is set ([66b2525](https://github.com/folke/snacks.nvim/commit/66b252535c7a78f0cd73755fad22b07b814309f1)) * **float:** proper closing of backdrop ([a528e77](https://github.com/folke/snacks.nvim/commit/a528e77397daea422ff84dde64124d4a7e352bc2)) * **notifier:** modifiable ([fd57c24](https://github.com/folke/snacks.nvim/commit/fd57c243015e6f2c863bb6c89e1417e77f3e0ea4)) * **notifier:** modifiable = false ([9ef9e69](https://github.com/folke/snacks.nvim/commit/9ef9e69620fc51d368fcee830a59bc9279594d43)) * **notifier:** show notifier errors with nvim_err_writeln ([e8061bc](https://github.com/folke/snacks.nvim/commit/e8061bcda095e3e9a3110ce697cdf7155e178d6e)) * **notifier:** sorting ([d9a1f23](https://github.com/folke/snacks.nvim/commit/d9a1f23e216230fcb2060e74056778d57d1d7676)) * simplify setup ([787b53e](https://github.com/folke/snacks.nvim/commit/787b53e7635f322bf42a42279f647340daf77770)) * **win:** backdrop ([71dd912](https://github.com/folke/snacks.nvim/commit/71dd912763918fd6b7fd07dd66ccd16afd4fea78)) * **win:** better implementation of window styles (previously views) ([6681097](https://github.com/folke/snacks.nvim/commit/66810971b9bd08e212faae467d884758bf142ffe)) * **win:** dont error when augroup is already deleted ([8c43597](https://github.com/folke/snacks.nvim/commit/8c43597f10dc2916200a8c857026f3f15fd3ae65)) * **win:** dont update win opt noautocmd ([a06e3ed](https://github.com/folke/snacks.nvim/commit/a06e3ed8fcd08aaf43fc2454bb1b0f935053aca7)) * **win:** no need to set EndOfBuffer winhl ([7a7f221](https://github.com/folke/snacks.nvim/commit/7a7f221020e024da831c2a3d72f8a9a0c330d711)) * **win:** use syntax as fallback for treesitter ([f3b69a6](https://github.com/folke/snacks.nvim/commit/f3b69a617a57597571fcf4263463f989fa3b663d)) ### Performance Improvements * **win:** set options with eventignore and handle ft manually ([80d9a89](https://github.com/folke/snacks.nvim/commit/80d9a894f9d6b2087e0dfee6d777e7b78490ba93)) ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # 🍿 `snacks.nvim` A collection of small QoL plugins for Neovim. ## ✨ Features | Snack | Description | Setup | | ----- | ----------- | :---: | | [animate](https://github.com/folke/snacks.nvim/blob/main/docs/animate.md) | Efficient animations including over 45 easing functions _(library)_ | | | [bigfile](https://github.com/folke/snacks.nvim/blob/main/docs/bigfile.md) | Deal with big files | ‼️ | | [bufdelete](https://github.com/folke/snacks.nvim/blob/main/docs/bufdelete.md) | Delete buffers without disrupting window layout | | | [dashboard](https://github.com/folke/snacks.nvim/blob/main/docs/dashboard.md) | Beautiful declarative dashboards | ‼️ | | [debug](https://github.com/folke/snacks.nvim/blob/main/docs/debug.md) | Pretty inspect & backtraces for debugging | | | [dim](https://github.com/folke/snacks.nvim/blob/main/docs/dim.md) | Focus on the active scope by dimming the rest | | | [explorer](https://github.com/folke/snacks.nvim/blob/main/docs/explorer.md) | A file explorer (picker in disguise) | ‼️ | | [gh](https://github.com/folke/snacks.nvim/blob/main/docs/gh.md) | GitHub CLI integration | | | [git](https://github.com/folke/snacks.nvim/blob/main/docs/git.md) | Git utilities | | | [gitbrowse](https://github.com/folke/snacks.nvim/blob/main/docs/gitbrowse.md) | Open the current file, branch, commit, or repo in a browser (e.g. GitHub, GitLab, Bitbucket) | | | [image](https://github.com/folke/snacks.nvim/blob/main/docs/image.md) | Image viewer using Kitty Graphics Protocol, supported by `kitty`, `wezterm` and `ghostty` | ‼️ | | [indent](https://github.com/folke/snacks.nvim/blob/main/docs/indent.md) | Indent guides and scopes | | | [input](https://github.com/folke/snacks.nvim/blob/main/docs/input.md) | Better `vim.ui.input` | ‼️ | | [keymap](https://github.com/folke/snacks.nvim/blob/main/docs/keymap.md) | Better `vim.keymap` with support for filetypes and LSP clients | | | [layout](https://github.com/folke/snacks.nvim/blob/main/docs/layout.md) | Window layouts | | | [lazygit](https://github.com/folke/snacks.nvim/blob/main/docs/lazygit.md) | Open LazyGit in a float, auto-configure colorscheme and integration with Neovim | | | [notifier](https://github.com/folke/snacks.nvim/blob/main/docs/notifier.md) | Pretty `vim.notify` | ‼️ | | [notify](https://github.com/folke/snacks.nvim/blob/main/docs/notify.md) | Utility functions to work with Neovim's `vim.notify` | | | [picker](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md) | Picker for selecting items | ‼️ | | [profiler](https://github.com/folke/snacks.nvim/blob/main/docs/profiler.md) | Neovim lua profiler | | | [quickfile](https://github.com/folke/snacks.nvim/blob/main/docs/quickfile.md) | When doing `nvim somefile.txt`, it will render the file as quickly as possible, before loading your plugins. | ‼️ | | [rename](https://github.com/folke/snacks.nvim/blob/main/docs/rename.md) | LSP-integrated file renaming with support for plugins like [neo-tree.nvim](https://github.com/nvim-neo-tree/neo-tree.nvim) and [mini.files](https://github.com/nvim-mini/mini.files). | | | [scope](https://github.com/folke/snacks.nvim/blob/main/docs/scope.md) | Scope detection, text objects and jumping based on treesitter or indent | ‼️ | | [scratch](https://github.com/folke/snacks.nvim/blob/main/docs/scratch.md) | Scratch buffers with a persistent file | | | [scroll](https://github.com/folke/snacks.nvim/blob/main/docs/scroll.md) | Smooth scrolling | ‼️ | | [statuscolumn](https://github.com/folke/snacks.nvim/blob/main/docs/statuscolumn.md) | Pretty status column | ‼️ | | [terminal](https://github.com/folke/snacks.nvim/blob/main/docs/terminal.md) | Create and toggle floating/split terminals | | | [toggle](https://github.com/folke/snacks.nvim/blob/main/docs/toggle.md) | Toggle keymaps integrated with which-key icons / colors | | | [util](https://github.com/folke/snacks.nvim/blob/main/docs/util.md) | Utility functions for Snacks _(library)_ | | | [win](https://github.com/folke/snacks.nvim/blob/main/docs/win.md) | Create and manage floating windows or splits | | | [words](https://github.com/folke/snacks.nvim/blob/main/docs/words.md) | Auto-show LSP references and quickly navigate between them | ‼️ | | [zen](https://github.com/folke/snacks.nvim/blob/main/docs/zen.md) | Zen mode • distraction-free coding | | ## ⚡️ Requirements - **Neovim** >= 0.9.4 - for proper icons support: - [mini.icons](https://github.com/nvim-mini/mini.icons) _(optional)_ - [nvim-web-devicons](https://github.com/nvim-tree/nvim-web-devicons) _(optional)_ - a [Nerd Font](https://www.nerdfonts.com/) **_(optional)_** ## 📦 Installation Install the plugin with your package manager: ### [lazy.nvim](https://github.com/folke/lazy.nvim) > [!important] > A couple of plugins **require** `snacks.nvim` to be set-up early. > Setup creates some autocmds and does not load any plugins. > Check the [code](https://github.com/folke/snacks.nvim/blob/main/lua/snacks/init.lua) to see what it does. > [!caution] > You need to explicitly pass options for a plugin or set `enabled = true` to enable it. > [!tip] > It's a good idea to run `:checkhealth snacks` to see if everything is set up correctly. ```lua { "folke/snacks.nvim", priority = 1000, lazy = false, ---@type snacks.Config opts = { -- your configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below bigfile = { enabled = true }, dashboard = { enabled = true }, explorer = { enabled = true }, indent = { enabled = true }, input = { enabled = true }, picker = { enabled = true }, notifier = { enabled = true }, quickfile = { enabled = true }, scope = { enabled = true }, scroll = { enabled = true }, statuscolumn = { enabled = true }, words = { enabled = true }, }, } ``` For an in-depth setup of `snacks.nvim` with `lazy.nvim`, check the [example](https://github.com/folke/snacks.nvim?tab=readme-ov-file#-usage) below. ## ⚙️ Configuration Please refer to the readme of each plugin for their specific configuration.
Default Options ```lua ---@class snacks.Config ---@field animate? snacks.animate.Config ---@field bigfile? snacks.bigfile.Config ---@field dashboard? snacks.dashboard.Config ---@field dim? snacks.dim.Config ---@field explorer? snacks.explorer.Config ---@field gh? snacks.gh.Config ---@field gitbrowse? snacks.gitbrowse.Config ---@field image? snacks.image.Config ---@field indent? snacks.indent.Config ---@field input? snacks.input.Config ---@field layout? snacks.layout.Config ---@field lazygit? snacks.lazygit.Config ---@field notifier? snacks.notifier.Config ---@field picker? snacks.picker.Config ---@field profiler? snacks.profiler.Config ---@field quickfile? snacks.quickfile.Config ---@field scope? snacks.scope.Config ---@field scratch? snacks.scratch.Config ---@field scroll? snacks.scroll.Config ---@field statuscolumn? snacks.statuscolumn.Config ---@field terminal? snacks.terminal.Config ---@field toggle? snacks.toggle.Config ---@field win? snacks.win.Config ---@field words? snacks.words.Config ---@field zen? snacks.zen.Config ---@field styles? table ---@field image? snacks.image.Config|{} { image = { -- define these here, so that we don't need to load the image module formats = { "png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "heic", "avif", "mp4", "mov", "avi", "mkv", "webm", "pdf", "icns", }, }, } ```
Some plugins have examples in their documentation. You can include them in your config like this: ```lua { dashboard = { example = "github" } } ``` If you want to customize options for a plugin after they have been resolved, you can use the `config` function: ```lua { gitbrowse = { config = function(opts, defaults) table.insert(opts.remote_patterns, { "my", "custom pattern" }) end }, } ``` ## 🚀 Usage See the example below for how to configure `snacks.nvim`. ```lua { "folke/snacks.nvim", priority = 1000, lazy = false, ---@type snacks.Config opts = { bigfile = { enabled = true }, dashboard = { enabled = true }, explorer = { enabled = true }, indent = { enabled = true }, input = { enabled = true }, notifier = { enabled = true, timeout = 3000, }, picker = { enabled = true }, quickfile = { enabled = true }, scope = { enabled = true }, scroll = { enabled = true }, statuscolumn = { enabled = true }, words = { enabled = true }, styles = { notification = { -- wo = { wrap = true } -- Wrap notifications } } }, keys = { -- Top Pickers & Explorer { "", function() Snacks.picker.smart() end, desc = "Smart Find Files" }, { ",", function() Snacks.picker.buffers() end, desc = "Buffers" }, { "/", function() Snacks.picker.grep() end, desc = "Grep" }, { ":", function() Snacks.picker.command_history() end, desc = "Command History" }, { "n", function() Snacks.picker.notifications() end, desc = "Notification History" }, { "e", function() Snacks.explorer() end, desc = "File Explorer" }, -- find { "fb", function() Snacks.picker.buffers() end, desc = "Buffers" }, { "fc", function() Snacks.picker.files({ cwd = vim.fn.stdpath("config") }) end, desc = "Find Config File" }, { "ff", function() Snacks.picker.files() end, desc = "Find Files" }, { "fg", function() Snacks.picker.git_files() end, desc = "Find Git Files" }, { "fp", function() Snacks.picker.projects() end, desc = "Projects" }, { "fr", function() Snacks.picker.recent() end, desc = "Recent" }, -- git { "gb", function() Snacks.picker.git_branches() end, desc = "Git Branches" }, { "gl", function() Snacks.picker.git_log() end, desc = "Git Log" }, { "gL", function() Snacks.picker.git_log_line() end, desc = "Git Log Line" }, { "gs", function() Snacks.picker.git_status() end, desc = "Git Status" }, { "gS", function() Snacks.picker.git_stash() end, desc = "Git Stash" }, { "gd", function() Snacks.picker.git_diff() end, desc = "Git Diff (Hunks)" }, { "gf", function() Snacks.picker.git_log_file() end, desc = "Git Log File" }, -- gh { "gi", function() Snacks.picker.gh_issue() end, desc = "GitHub Issues (open)" }, { "gI", function() Snacks.picker.gh_issue({ state = "all" }) end, desc = "GitHub Issues (all)" }, { "gp", function() Snacks.picker.gh_pr() end, desc = "GitHub Pull Requests (open)" }, { "gP", function() Snacks.picker.gh_pr({ state = "all" }) end, desc = "GitHub Pull Requests (all)" }, -- Grep { "sb", function() Snacks.picker.lines() end, desc = "Buffer Lines" }, { "sB", function() Snacks.picker.grep_buffers() end, desc = "Grep Open Buffers" }, { "sg", function() Snacks.picker.grep() end, desc = "Grep" }, { "sw", function() Snacks.picker.grep_word() end, desc = "Visual selection or word", mode = { "n", "x" } }, -- search { 's"', function() Snacks.picker.registers() end, desc = "Registers" }, { 's/', function() Snacks.picker.search_history() end, desc = "Search History" }, { "sa", function() Snacks.picker.autocmds() end, desc = "Autocmds" }, { "sb", function() Snacks.picker.lines() end, desc = "Buffer Lines" }, { "sc", function() Snacks.picker.command_history() end, desc = "Command History" }, { "sC", function() Snacks.picker.commands() end, desc = "Commands" }, { "sd", function() Snacks.picker.diagnostics() end, desc = "Diagnostics" }, { "sD", function() Snacks.picker.diagnostics_buffer() end, desc = "Buffer Diagnostics" }, { "sh", function() Snacks.picker.help() end, desc = "Help Pages" }, { "sH", function() Snacks.picker.highlights() end, desc = "Highlights" }, { "si", function() Snacks.picker.icons() end, desc = "Icons" }, { "sj", function() Snacks.picker.jumps() end, desc = "Jumps" }, { "sk", function() Snacks.picker.keymaps() end, desc = "Keymaps" }, { "sl", function() Snacks.picker.loclist() end, desc = "Location List" }, { "sm", function() Snacks.picker.marks() end, desc = "Marks" }, { "sM", function() Snacks.picker.man() end, desc = "Man Pages" }, { "sp", function() Snacks.picker.lazy() end, desc = "Search for Plugin Spec" }, { "sq", function() Snacks.picker.qflist() end, desc = "Quickfix List" }, { "sR", function() Snacks.picker.resume() end, desc = "Resume" }, { "su", function() Snacks.picker.undo() end, desc = "Undo History" }, { "uC", function() Snacks.picker.colorschemes() end, desc = "Colorschemes" }, -- LSP { "gd", function() Snacks.picker.lsp_definitions() end, desc = "Goto Definition" }, { "gD", function() Snacks.picker.lsp_declarations() end, desc = "Goto Declaration" }, { "gr", function() Snacks.picker.lsp_references() end, nowait = true, desc = "References" }, { "gI", function() Snacks.picker.lsp_implementations() end, desc = "Goto Implementation" }, { "gy", function() Snacks.picker.lsp_type_definitions() end, desc = "Goto T[y]pe Definition" }, { "gai", function() Snacks.picker.lsp_incoming_calls() end, desc = "C[a]lls Incoming" }, { "gao", function() Snacks.picker.lsp_outgoing_calls() end, desc = "C[a]lls Outgoing" }, { "ss", function() Snacks.picker.lsp_symbols() end, desc = "LSP Symbols" }, { "sS", function() Snacks.picker.lsp_workspace_symbols() end, desc = "LSP Workspace Symbols" }, -- Other { "z", function() Snacks.zen() end, desc = "Toggle Zen Mode" }, { "Z", function() Snacks.zen.zoom() end, desc = "Toggle Zoom" }, { ".", function() Snacks.scratch() end, desc = "Toggle Scratch Buffer" }, { "S", function() Snacks.scratch.select() end, desc = "Select Scratch Buffer" }, { "n", function() Snacks.notifier.show_history() end, desc = "Notification History" }, { "bd", function() Snacks.bufdelete() end, desc = "Delete Buffer" }, { "cR", function() Snacks.rename.rename_file() end, desc = "Rename File" }, { "gB", function() Snacks.gitbrowse() end, desc = "Git Browse", mode = { "n", "v" } }, { "gg", function() Snacks.lazygit() end, desc = "Lazygit" }, { "un", function() Snacks.notifier.hide() end, desc = "Dismiss All Notifications" }, { "", function() Snacks.terminal() end, desc = "Toggle Terminal" }, { "", function() Snacks.terminal() end, desc = "which_key_ignore" }, { "]]", function() Snacks.words.jump(vim.v.count1) end, desc = "Next Reference", mode = { "n", "t" } }, { "[[", function() Snacks.words.jump(-vim.v.count1) end, desc = "Prev Reference", mode = { "n", "t" } }, { "N", desc = "Neovim News", function() Snacks.win({ file = vim.api.nvim_get_runtime_file("doc/news.txt", false)[1], width = 0.6, height = 0.6, wo = { spell = false, wrap = false, signcolumn = "yes", statuscolumn = " ", conceallevel = 3, }, }) end, } }, init = function() vim.api.nvim_create_autocmd("User", { pattern = "VeryLazy", callback = function() -- Setup some globals for debugging (lazy-loaded) _G.dd = function(...) Snacks.debug.inspect(...) end _G.bt = function() Snacks.debug.backtrace() end -- Override print to use snacks for `:=` command if vim.fn.has("nvim-0.11") == 1 then vim._print = function(_, ...) dd(...) end else vim.print = _G.dd end -- Create some toggle mappings Snacks.toggle.option("spell", { name = "Spelling" }):map("us") Snacks.toggle.option("wrap", { name = "Wrap" }):map("uw") Snacks.toggle.option("relativenumber", { name = "Relative Number" }):map("uL") Snacks.toggle.diagnostics():map("ud") Snacks.toggle.line_number():map("ul") Snacks.toggle.option("conceallevel", { off = 0, on = vim.o.conceallevel > 0 and vim.o.conceallevel or 2 }):map("uc") Snacks.toggle.treesitter():map("uT") Snacks.toggle.option("background", { off = "light", on = "dark", name = "Dark Background" }):map("ub") Snacks.toggle.inlay_hints():map("uh") Snacks.toggle.indent():map("ug") Snacks.toggle.dim():map("uD") end, }) end, } ``` ## 🌈 Highlight Groups Snacks defines **a lot** of highlight groups and it's impossible to document them all. Instead, you can use the picker to see all the highlight groups. ```lua Snacks.picker.highlights({pattern = "hl_group:^Snacks"}) ``` ================================================ FILE: doc/snacks.nvim-animate.txt ================================================ *snacks-animate* snacks animate docs ============================================================================== Table of Contents *snacks.nvim-animate-table-of-contents* 1. Setup |snacks.nvim-animate-setup| 2. Config |snacks.nvim-animate-config| 3. Types |snacks.nvim-animate-types| 4. Module |snacks.nvim-animate-module| - Snacks.animate() |snacks.nvim-animate-module-snacks.animate()| - Snacks.animate.add() |snacks.nvim-animate-module-snacks.animate.add()| - Snacks.animate.del() |snacks.nvim-animate-module-snacks.animate.del()| - Snacks.animate.enabled()|snacks.nvim-animate-module-snacks.animate.enabled()| Efficient animation library including over 45 easing functions: - Emmanuel Oga’s easing functions - Easing functions overview There’s at any given time at most one timer running, that takes care of all active animations, controlled by the `fps` setting. You can at any time disable all animations with: - `vim.g.snacks_animate = false` globally - `vim.b.snacks_animate = false` locally for the buffer Doing this, will disable `scroll`, `indent`, `dim` and all other animations. ============================================================================== 1. Setup *snacks.nvim-animate-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { animate = { -- your animate configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Config *snacks.nvim-animate-config* >lua ---@class snacks.animate.Config ---@field easing? snacks.animate.easing|snacks.animate.easing.Fn { ---@type snacks.animate.Duration|number duration = 20, -- ms per step easing = "linear", fps = 120, -- frames per second. Global setting for all animations } < ============================================================================== 3. Types *snacks.nvim-animate-types* All easing functions take these parameters: - `t` _(time)_: should go from 0 to duration - `b` _(begin)_: starting value of the property - `c` _(change)_: ending value of the property - starting value - `d` _(duration)_: total duration of the animation Some functions allow additional modifiers, like the elastic functions which also can receive an amplitud and a period parameters (defaults are included) >lua ---@alias snacks.animate.easing.Fn fun(t: number, b: number, c: number, d: number): number < Duration can be specified as the total duration or the duration per step. When both are specified, the minimum of both is used. >lua ---@class snacks.animate.Duration ---@field step? number duration per step in ms ---@field total? number total duration in ms < >lua ---@class snacks.animate.Opts: snacks.animate.Config ---@field buf? number optional buffer to check if animations should be enabled ---@field int? boolean interpolate the value to an integer ---@field id? number|string unique identifier for the animation < >lua ---@class snacks.animate.ctx ---@field anim snacks.animate.Animation ---@field prev number ---@field done boolean < >lua ---@alias snacks.animate.cb fun(value:number, ctx: snacks.animate.ctx) < ============================================================================== 4. Module *snacks.nvim-animate-module* `Snacks.animate()` *Snacks.animate()* >lua ---@type fun(from: number, to: number, cb: snacks.animate.cb, opts?: snacks.animate.Opts): snacks.animate.Animation Snacks.animate() < `Snacks.animate.add()` *Snacks.animate.add()* Add an animation >lua ---@param from number ---@param to number ---@param cb snacks.animate.cb ---@param opts? snacks.animate.Opts Snacks.animate.add(from, to, cb, opts) < `Snacks.animate.del()` *Snacks.animate.del()* Delete an animation >lua ---@param id number|string Snacks.animate.del(id) < `Snacks.animate.enabled()` *Snacks.animate.enabled()* Check if animations are enabled. Will return false if `snacks_animate` is set to false or if the buffer local variable `snacks_animate` is set to false. >lua ---@param opts? {buf?: number, name?: string} Snacks.animate.enabled(opts) < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-bigfile.txt ================================================ *snacks-bigfile* snacks bigfile docs ============================================================================== Table of Contents *snacks.nvim-bigfile-table-of-contents* 1. Setup |snacks.nvim-bigfile-setup| 2. Config |snacks.nvim-bigfile-config| `bigfile` adds a new filetype `bigfile` to Neovim that triggers when the file is larger than the configured size. This automatically prevents things like LSP and Treesitter attaching to the buffer. Use the `setup` config function to further make changes to a `bigfile` buffer. The context provides the actual filetype. The default implementation enables `syntax` for the buffer and disables mini.animate (if used) ============================================================================== 1. Setup *snacks.nvim-bigfile-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { bigfile = { -- your bigfile configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Config *snacks.nvim-bigfile-config* >lua ---@class snacks.bigfile.Config ---@field enabled? boolean { notify = true, -- show notification when big file detected size = 1.5 * 1024 * 1024, -- 1.5MB line_length = 1000, -- average line length (useful for minified files) -- Enable or disable features when big file detected ---@param ctx {buf: number, ft:string} setup = function(ctx) if vim.fn.exists(":NoMatchParen") ~= 0 then vim.cmd([[NoMatchParen]]) end Snacks.util.wo(0, { foldmethod = "manual", statuscolumn = "", conceallevel = 0 }) vim.b.completion = false vim.b.minianimate_disable = true vim.b.minihipatterns_disable = true vim.schedule(function() if vim.api.nvim_buf_is_valid(ctx.buf) then vim.bo[ctx.buf].syntax = ctx.ft end end) end, } < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-bufdelete.txt ================================================ *snacks-bufdelete* snacks bufdelete docs ============================================================================== Table of Contents *snacks.nvim-bufdelete-table-of-contents* 1. Types |snacks.nvim-bufdelete-types| 2. Module |snacks.nvim-bufdelete-module| - Snacks.bufdelete() |snacks.nvim-bufdelete-module-snacks.bufdelete()| - Snacks.bufdelete.all()|snacks.nvim-bufdelete-module-snacks.bufdelete.all()| - Snacks.bufdelete.delete()|snacks.nvim-bufdelete-module-snacks.bufdelete.delete()| - Snacks.bufdelete.other()|snacks.nvim-bufdelete-module-snacks.bufdelete.other()| Delete buffers without disrupting window layout. If the buffer you want to close has changes, a prompt will be shown to save or discard. ============================================================================== 1. Types *snacks.nvim-bufdelete-types* >lua ---@class snacks.bufdelete.Opts ---@field buf? number Buffer to delete. Defaults to the current buffer ---@field file? string Delete buffer by file name. If provided, `buf` is ignored ---@field force? boolean Delete the buffer even if it is modified ---@field filter? fun(buf: number): boolean Filter buffers to delete ---@field wipe? boolean Wipe the buffer instead of deleting it (see `:h :bwipeout`) < ============================================================================== 2. Module *snacks.nvim-bufdelete-module* `Snacks.bufdelete()` *Snacks.bufdelete()* >lua ---@type fun(buf?: number|snacks.bufdelete.Opts) Snacks.bufdelete() < `Snacks.bufdelete.all()` *Snacks.bufdelete.all()* Delete all buffers >lua ---@param opts? snacks.bufdelete.Opts Snacks.bufdelete.all(opts) < `Snacks.bufdelete.delete()` *Snacks.bufdelete.delete()* Delete a buffer: - either the current buffer if `buf` is not provided - or the buffer `buf` if it is a number - or every buffer for which `buf` returns true if it is a function >lua ---@param opts? number|snacks.bufdelete.Opts Snacks.bufdelete.delete(opts) < `Snacks.bufdelete.other()` *Snacks.bufdelete.other()* Delete all buffers except the current one >lua ---@param opts? snacks.bufdelete.Opts Snacks.bufdelete.other(opts) < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-dashboard.txt ================================================ *snacks-dashboard* snacks dashboard docs ============================================================================== Table of Contents *snacks.nvim-dashboard-table-of-contents* 1. Features |snacks.nvim-dashboard-features| 2. Usage |snacks.nvim-dashboard-usage| - Section actions |snacks.nvim-dashboard-usage-section-actions| - Item text |snacks.nvim-dashboard-usage-item-text| 3. Setup |snacks.nvim-dashboard-setup| 4. Config |snacks.nvim-dashboard-config| 5. Examples |snacks.nvim-dashboard-examples| - advanced |snacks.nvim-dashboard-examples-advanced| - chafa |snacks.nvim-dashboard-examples-chafa| - compact_files |snacks.nvim-dashboard-examples-compact_files| - doom |snacks.nvim-dashboard-examples-doom| - files |snacks.nvim-dashboard-examples-files| - github |snacks.nvim-dashboard-examples-github| - pokemon |snacks.nvim-dashboard-examples-pokemon| - startify |snacks.nvim-dashboard-examples-startify| 6. Styles |snacks.nvim-dashboard-styles| - dashboard |snacks.nvim-dashboard-styles-dashboard| 7. Types |snacks.nvim-dashboard-types| 8. Module |snacks.nvim-dashboard-module| - Snacks.dashboard() |snacks.nvim-dashboard-module-snacks.dashboard()| - Snacks.dashboard.have_plugin()|snacks.nvim-dashboard-module-snacks.dashboard.have_plugin()| - Snacks.dashboard.health()|snacks.nvim-dashboard-module-snacks.dashboard.health()| - Snacks.dashboard.icon()|snacks.nvim-dashboard-module-snacks.dashboard.icon()| - Snacks.dashboard.oldfiles()|snacks.nvim-dashboard-module-snacks.dashboard.oldfiles()| - Snacks.dashboard.open()|snacks.nvim-dashboard-module-snacks.dashboard.open()| - Snacks.dashboard.pick()|snacks.nvim-dashboard-module-snacks.dashboard.pick()| - Snacks.dashboard.sections.header()|snacks.nvim-dashboard-module-snacks.dashboard.sections.header()| - Snacks.dashboard.sections.keys()|snacks.nvim-dashboard-module-snacks.dashboard.sections.keys()| - Snacks.dashboard.sections.projects()|snacks.nvim-dashboard-module-snacks.dashboard.sections.projects()| - Snacks.dashboard.sections.recent_files()|snacks.nvim-dashboard-module-snacks.dashboard.sections.recent_files()| - Snacks.dashboard.sections.session()|snacks.nvim-dashboard-module-snacks.dashboard.sections.session()| - Snacks.dashboard.sections.startup()|snacks.nvim-dashboard-module-snacks.dashboard.sections.startup()| - Snacks.dashboard.sections.terminal()|snacks.nvim-dashboard-module-snacks.dashboard.sections.terminal()| - Snacks.dashboard.setup()|snacks.nvim-dashboard-module-snacks.dashboard.setup()| - Snacks.dashboard.update()|snacks.nvim-dashboard-module-snacks.dashboard.update()| 9. Links |snacks.nvim-dashboard-links| ============================================================================== 1. Features *snacks.nvim-dashboard-features* - declarative configuration - flexible layouts - multiple vertical panes - built-in sections: - **header**: show a header - **keys**: show keymaps - **projects**: show recent projects - **recent_files**: show recent files - **session**: session support - **startup**: startup time (lazy.nvim) - **terminal**: colored terminal output - super fast `terminal` sections with automatic caching ============================================================================== 2. Usage *snacks.nvim-dashboard-usage* The dashboard comes with a set of default sections, that can be customized with `opts.preset` or fully replaced with `opts.sections`. The default preset comes with support for: - pickers: - fzf-lua - telescope.nvim - mini.pick - session managers: (only works with lazy.nvim ) - persistence.nvim - persisted.nvim - neovim-session-manager - posession.nvim - mini.sessions SECTION ACTIONS *snacks.nvim-dashboard-usage-section-actions* A section can have an `action` property that will be executed as: - a command if it starts with `:` - a keymap if it’s a string not starting with `:` - a function if it’s a function >lua -- command { action = ":Telescope find_files", key = "f", }, < >lua -- keymap { action = "ff", key = "f", }, < >lua -- function { action = function() require("telescope.builtin").find_files() end, key = "h", }, < ITEM TEXT *snacks.nvim-dashboard-usage-item-text* Every item should have a `text` property with an array of `snacks.dashboard.Text` objects. If the `text` property is not provided, the `snacks.dashboard.Config.formats` will be used to generate the text. In the example below, both sections are equivalent. >lua { text = { { " ", hl = "SnacksDashboardIcon" }, { "Find File", hl = "SnacksDashboardDesc", width = 50 }, { "[f]", hl = "SnacksDashboardKey" }, }, action = ":Telescope find_files", key = "f", }, < >lua { action = ":Telescope find_files", key = "f", desc = "Find File", icon = " ", }, < ============================================================================== 3. Setup *snacks.nvim-dashboard-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { dashboard = { -- your dashboard configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 4. Config *snacks.nvim-dashboard-config* >lua ---@class snacks.dashboard.Config ---@field enabled? boolean ---@field sections snacks.dashboard.Section ---@field formats table { width = 60, row = nil, -- dashboard position. nil for center col = nil, -- dashboard position. nil for center pane_gap = 4, -- empty columns between vertical panes autokeys = "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", -- autokey sequence -- These settings are used by some built-in sections preset = { -- Defaults to a picker that supports `fzf-lua`, `telescope.nvim` and `mini.pick` ---@type fun(cmd:string, opts:table)|nil pick = nil, -- Used by the `keys` section to show keymaps. -- Set your custom keymaps here. -- When using a function, the `items` argument are the default keymaps. ---@type snacks.dashboard.Item[] keys = { { icon = " ", key = "f", desc = "Find File", action = ":lua Snacks.dashboard.pick('files')" }, { icon = " ", key = "n", desc = "New File", action = ":ene | startinsert" }, { icon = " ", key = "g", desc = "Find Text", action = ":lua Snacks.dashboard.pick('live_grep')" }, { icon = " ", key = "r", desc = "Recent Files", action = ":lua Snacks.dashboard.pick('oldfiles')" }, { icon = " ", key = "c", desc = "Config", action = ":lua Snacks.dashboard.pick('files', {cwd = vim.fn.stdpath('config')})" }, { icon = " ", key = "s", desc = "Restore Session", section = "session" }, { icon = "󰒲 ", key = "L", desc = "Lazy", action = ":Lazy", enabled = package.loaded.lazy ~= nil }, { icon = " ", key = "q", desc = "Quit", action = ":qa" }, }, -- Used by the `header` section header = [[ ███╗ ██╗███████╗ ██████╗ ██╗ ██╗██╗███╗ ███╗ ████╗ ██║██╔════╝██╔═══██╗██║ ██║██║████╗ ████║ ██╔██╗ ██║█████╗ ██║ ██║██║ ██║██║██╔████╔██║ ██║╚██╗██║██╔══╝ ██║ ██║╚██╗ ██╔╝██║██║╚██╔╝██║ ██║ ╚████║███████╗╚██████╔╝ ╚████╔╝ ██║██║ ╚═╝ ██║ ╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═══╝ ╚═╝╚═╝ ╚═╝]], }, -- item field formatters formats = { icon = function(item) if item.file and item.icon == "file" or item.icon == "directory" then return Snacks.dashboard.icon(item.file, item.icon) end return { item.icon, width = 2, hl = "icon" } end, footer = { "%s", align = "center" }, header = { "%s", align = "center" }, file = function(item, ctx) local fname = vim.fn.fnamemodify(item.file, ":~") fname = ctx.width and #fname > ctx.width and vim.fn.pathshorten(fname) or fname if #fname > ctx.width then local dir = vim.fn.fnamemodify(fname, ":h") local file = vim.fn.fnamemodify(fname, ":t") if dir and file then file = file:sub(-(ctx.width - #dir - 2)) fname = dir .. "/…" .. file end end local dir, file = fname:match("^(.*)/(.+)$") return dir and { { dir .. "/", hl = "dir" }, { file, hl = "file" } } or { { fname, hl = "file" } } end, }, sections = { { section = "header" }, { section = "keys", gap = 1, padding = 1 }, { section = "startup" }, }, } < ============================================================================== 5. Examples *snacks.nvim-dashboard-examples* ADVANCED *snacks.nvim-dashboard-examples-advanced* A more advanced example using multiple panes >lua { sections = { { section = "header" }, { pane = 2, section = "terminal", cmd = "colorscript -e square", height = 5, padding = 1, }, { section = "keys", gap = 1, padding = 1 }, { pane = 2, icon = " ", title = "Recent Files", section = "recent_files", indent = 2, padding = 1 }, { pane = 2, icon = " ", title = "Projects", section = "projects", indent = 2, padding = 1 }, { pane = 2, icon = " ", title = "Git Status", section = "terminal", enabled = function() return Snacks.git.get_root() ~= nil end, cmd = "git status --short --branch --renames", height = 5, padding = 1, ttl = 5 * 60, indent = 3, }, { section = "startup" }, }, } < CHAFA *snacks.nvim-dashboard-examples-chafa* An example using the `chafa` command to display an image >lua { sections = { { section = "terminal", cmd = "chafa ~/.config/wall.png --format symbols --symbols vhalf --size 60x17 --stretch; sleep .1", height = 17, padding = 1, }, { pane = 2, { section = "keys", gap = 1, padding = 1 }, { section = "startup" }, }, }, } < COMPACT_FILES *snacks.nvim-dashboard-examples-compact_files* A more compact version of the `files` example >lua { sections = { { section = "header" }, { icon = " ", title = "Keymaps", section = "keys", indent = 2, padding = 1 }, { icon = " ", title = "Recent Files", section = "recent_files", indent = 2, padding = 1 }, { icon = " ", title = "Projects", section = "projects", indent = 2, padding = 1 }, { section = "startup" }, }, } < DOOM *snacks.nvim-dashboard-examples-doom* Similar to the Emacs Doom dashboard >lua { sections = { { section = "header" }, { section = "keys", gap = 1, padding = 1 }, { section = "startup" }, }, } < FILES *snacks.nvim-dashboard-examples-files* A simple example with a header, keys, recent files, and projects >lua { sections = { { section = "header" }, { section = "keys", gap = 1 }, { icon = " ", title = "Recent Files", section = "recent_files", indent = 2, padding = { 2, 2 } }, { icon = " ", title = "Projects", section = "projects", indent = 2, padding = 2 }, { section = "startup" }, }, } < GITHUB *snacks.nvim-dashboard-examples-github* Advanced example using the GitHub CLI. >lua { sections = { { section = "header" }, { pane = 2, section = "terminal", cmd = "colorscript -e square", height = 5, padding = 1, }, { section = "keys", gap = 1, padding = 1 }, { pane = 2, icon = " ", desc = "Browse Repo", padding = 1, key = "b", action = function() Snacks.gitbrowse() end, }, function() local in_git = Snacks.git.get_root() ~= nil local cmds = { { title = "Notifications", cmd = "gh notify -s -a -n5", action = function() vim.ui.open("https://github.com/notifications") end, key = "n", icon = " ", height = 5, enabled = true, }, { title = "Open Issues", cmd = "gh issue list -L 3", key = "i", action = function() vim.fn.jobstart("gh issue list --web", { detach = true }) end, icon = " ", height = 7, }, { icon = " ", title = "Open PRs", cmd = "gh pr list -L 3", key = "P", action = function() vim.fn.jobstart("gh pr list --web", { detach = true }) end, height = 7, }, { icon = " ", title = "Git Status", cmd = "git --no-pager diff --stat -B -M -C", height = 10, }, } return vim.tbl_map(function(cmd) return vim.tbl_extend("force", { pane = 2, section = "terminal", enabled = in_git, padding = 1, ttl = 5 * 60, indent = 3, }, cmd) end, cmds) end, { section = "startup" }, }, } < POKEMON *snacks.nvim-dashboard-examples-pokemon* Pokemons, because why not? >lua { sections = { { section = "header" }, { section = "keys", gap = 1, padding = 1 }, { section = "startup" }, { section = "terminal", cmd = "pokemon-colorscripts -r --no-title; sleep .1", random = 10, pane = 2, indent = 4, height = 30, }, }, } < STARTIFY *snacks.nvim-dashboard-examples-startify* Similar to the Vim Startify dashboard >lua { formats = { key = function(item) return { { "[", hl = "special" }, { item.key, hl = "key" }, { "]", hl = "special" } } end, }, sections = { { section = "terminal", cmd = "fortune -s | cowsay", hl = "header", padding = 1, indent = 8 }, { title = "MRU", padding = 1 }, { section = "recent_files", limit = 8, padding = 1 }, { title = "MRU ", file = vim.fn.fnamemodify(".", ":~"), padding = 1 }, { section = "recent_files", cwd = true, limit = 8, padding = 1 }, { title = "Sessions", padding = 1 }, { section = "projects", padding = 1 }, { title = "Bookmarks", padding = 1 }, { section = "keys" }, }, } < ============================================================================== 6. Styles *snacks.nvim-dashboard-styles* Check the styles docs for more information on how to customize these styles DASHBOARD *snacks.nvim-dashboard-styles-dashboard* The default style for the dashboard. When opening the dashboard during startup, only the `bo` and `wo` options are used. The other options are used with `:lua Snacks.dashboard()` >lua { zindex = 10, height = 0, width = 0, bo = { bufhidden = "wipe", buftype = "nofile", buflisted = false, filetype = "snacks_dashboard", swapfile = false, undofile = false, }, wo = { colorcolumn = "", cursorcolumn = false, cursorline = false, foldmethod = "manual", list = false, number = false, relativenumber = false, sidescrolloff = 0, signcolumn = "no", spell = false, statuscolumn = "", statusline = "", winbar = "", winhighlight = "Normal:SnacksDashboardNormal,NormalFloat:SnacksDashboardNormal", wrap = false, }, } < ============================================================================== 7. Types *snacks.nvim-dashboard-types* >lua ---@class snacks.dashboard.Item ---@field indent? number ---@field align? "left" | "center" | "right" ---@field gap? number the number of empty lines between child items ---@field padding? number | {[1]:number, [2]:number} bottom or {bottom, top} padding --- The action to run when the section is selected or the key is pressed. --- * if it's a string starting with `:`, it will be run as a command --- * if it's a string, it will be executed as a keymap --- * if it's a function, it will be called ---@field action? snacks.dashboard.Action ---@field enabled? boolean|fun(opts:snacks.dashboard.Opts):boolean if false, the section will be disabled ---@field section? string the name of a section to include. See `Snacks.dashboard.sections` ---@field [string] any section options ---@field key? string shortcut key ---@field hidden? boolean when `true`, the item will not be shown, but the key will still be assigned ---@field autokey? boolean automatically assign a numerical key ---@field label? string ---@field desc? string ---@field file? string ---@field footer? string ---@field header? string ---@field icon? string ---@field title? string ---@field text? string|snacks.dashboard.Text[] < >lua ---@alias snacks.dashboard.Format.ctx {width?:number} ---@alias snacks.dashboard.Action string|fun(self:snacks.dashboard.Class) ---@alias snacks.dashboard.Gen fun(self:snacks.dashboard.Class):snacks.dashboard.Section? ---@alias snacks.dashboard.Section snacks.dashboard.Item|snacks.dashboard.Gen|snacks.dashboard.Section[] < >lua ---@class snacks.dashboard.Text ---@field [1] string the text ---@field hl? string the highlight group ---@field width? number the width used for alignment ---@field align? "left" | "center" | "right" < >lua ---@class snacks.dashboard.Opts: snacks.dashboard.Config ---@field buf? number the buffer to use. If not provided, a new buffer will be created ---@field win? number the window to use. If not provided, a new floating window will be created < ============================================================================== 8. Module *snacks.nvim-dashboard-module* `Snacks.dashboard()` *Snacks.dashboard()* >lua ---@type fun(opts?: snacks.dashboard.Opts): snacks.dashboard.Class Snacks.dashboard() < `Snacks.dashboard.have_plugin()` *Snacks.dashboard.have_plugin()* Checks if the plugin is installed. Only works with lazy.nvim >lua ---@param name string Snacks.dashboard.have_plugin(name) < `Snacks.dashboard.health()` *Snacks.dashboard.health()* >lua Snacks.dashboard.health() < `Snacks.dashboard.icon()` *Snacks.dashboard.icon()* Get an icon >lua ---@param name string ---@param cat? string ---@return snacks.dashboard.Text Snacks.dashboard.icon(name, cat) < `Snacks.dashboard.oldfiles()` *Snacks.dashboard.oldfiles()* >lua ---@param opts? {filter?: table} ---@return fun():string? Snacks.dashboard.oldfiles(opts) < `Snacks.dashboard.open()` *Snacks.dashboard.open()* >lua ---@param opts? snacks.dashboard.Opts ---@return snacks.dashboard.Class Snacks.dashboard.open(opts) < `Snacks.dashboard.pick()` *Snacks.dashboard.pick()* Used by the default preset to pick something >lua ---@param cmd? string Snacks.dashboard.pick(cmd, opts) < `Snacks.dashboard.sections.header()` *Snacks.dashboard.sections.header()* >lua ---@return snacks.dashboard.Gen Snacks.dashboard.sections.header() < `Snacks.dashboard.sections.keys()` *Snacks.dashboard.sections.keys()* >lua ---@return snacks.dashboard.Gen Snacks.dashboard.sections.keys() < `Snacks.dashboard.sections.projects()` *Snacks.dashboard.sections.projects()* Get the most recent projects based on git roots of recent files. The default action will change the directory to the project root, try to restore the session and open the picker if the session is not restored. You can customize the behavior by providing a custom action. Use `opts.dirs` to provide a list of directories to use instead of the git roots. >lua ---@param opts? {limit?:number, dirs?:(string[]|fun():string[]), pick?:boolean, session?:boolean, action?:fun(dir), filter?:fun(dir:string):boolean?} Snacks.dashboard.sections.projects(opts) < `Snacks.dashboard.sections.recent_files()` *Snacks.dashboard.sections.recent_files()* Get the most recent files, optionally filtered by the current working directory or a custom directory. >lua ---@param opts? {limit?:number, cwd?:string|boolean, filter?:fun(file:string):boolean?} ---@return snacks.dashboard.Gen Snacks.dashboard.sections.recent_files(opts) < `Snacks.dashboard.sections.session()` *Snacks.dashboard.sections.session()* Adds a section to restore the session if any of the supported plugins are installed. >lua ---@param item? snacks.dashboard.Item ---@return snacks.dashboard.Item? Snacks.dashboard.sections.session(item) < `Snacks.dashboard.sections.startup()` *Snacks.dashboard.sections.startup()* Add the startup section >lua ---@param opts? {icon?:string} ---@return snacks.dashboard.Section? Snacks.dashboard.sections.startup(opts) < `Snacks.dashboard.sections.terminal()` *Snacks.dashboard.sections.terminal()* >lua ---@param opts {cmd:string|string[], ttl?:number, height?:number, width?:number, random?:number}|snacks.dashboard.Item ---@return snacks.dashboard.Gen Snacks.dashboard.sections.terminal(opts) < `Snacks.dashboard.setup()` *Snacks.dashboard.setup()* Check if the dashboard should be opened >lua Snacks.dashboard.setup() < `Snacks.dashboard.update()` *Snacks.dashboard.update()* Update the dashboard >lua Snacks.dashboard.update() < ============================================================================== 9. Links *snacks.nvim-dashboard-links* 1. *image*: https://github.com/user-attachments/assets/bbf4d2cd-6fc5-4122-a462-0ca59ba89545 2. *image*: https://github.com/user-attachments/assets/e498ef8f-83ce-4917-a720-8cb31d98ecec 3. *image*: https://github.com/user-attachments/assets/772e84fe-b220-4841-bbe9-6e28780dc30a 4. *image*: https://github.com/user-attachments/assets/823f702d-e5d0-449a-afd2-684e1fb97622 5. *image*: https://github.com/user-attachments/assets/e98997b6-07d3-4162-bc06-2768b78fe353 6. *image*: https://github.com/user-attachments/assets/747d7386-ef05-487f-9550-3e5ef94869fc 7. *image*: https://github.com/user-attachments/assets/2fb17ecc-8bc0-48d3-a023-aa8dfc70247e 8. *image*: https://github.com/user-attachments/assets/561eff8c-ddf0-4de9-8485-e6be18a19c0b Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-debug.txt ================================================ *snacks-debug* snacks debug docs ============================================================================== Table of Contents *snacks.nvim-debug-table-of-contents* 1. Types |snacks.nvim-debug-types| 2. Module |snacks.nvim-debug-module| - Snacks.debug() |snacks.nvim-debug-module-snacks.debug()| - Snacks.debug.backtrace()|snacks.nvim-debug-module-snacks.debug.backtrace()| - Snacks.debug.cmd() |snacks.nvim-debug-module-snacks.debug.cmd()| - Snacks.debug.inspect() |snacks.nvim-debug-module-snacks.debug.inspect()| - Snacks.debug.log() |snacks.nvim-debug-module-snacks.debug.log()| - Snacks.debug.metrics() |snacks.nvim-debug-module-snacks.debug.metrics()| - Snacks.debug.profile() |snacks.nvim-debug-module-snacks.debug.profile()| - Snacks.debug.run() |snacks.nvim-debug-module-snacks.debug.run()| - Snacks.debug.size() |snacks.nvim-debug-module-snacks.debug.size()| - Snacks.debug.stats() |snacks.nvim-debug-module-snacks.debug.stats()| - Snacks.debug.trace() |snacks.nvim-debug-module-snacks.debug.trace()| - Snacks.debug.tracemod() |snacks.nvim-debug-module-snacks.debug.tracemod()| 3. Links |snacks.nvim-debug-links| Utility functions you can use in your code. Personally, I have the code below at the top of my `init.lua`: >lua _G.dd = function(...) Snacks.debug.inspect(...) end _G.bt = function() Snacks.debug.backtrace() end if vim.fn.has("nvim-0.11") == 1 then vim._print = function(_, ...) dd(...) end else vim.print = dd end < What this does: - Add a global `dd(...)` you can use anywhere to quickly show a notification with a pretty printed dump of the object(s) with lua treesitter highlighting - Add a global `bt()` to show a notification with a pretty backtrace. - Override Neovim’s `vim.print`, which is also used by `:= {something = 123}` ============================================================================== 1. Types *snacks.nvim-debug-types* >lua ---@class snacks.debug.cmd ---@field cmd string|string[] ---@field level? snacks.notifier.level|vim.log.levels ---@field title? string ---@field args? string[] ---@field cwd? string ---@field group? boolean ---@field notify? boolean ---@field footer? string ---@field header? string ---@field props? table < >lua ---@alias snacks.debug.Trace {name: string, time: number, [number]:snacks.debug.Trace} ---@alias snacks.debug.Stat {name:string, time:number, count?:number, depth?:number} < ============================================================================== 2. Module *snacks.nvim-debug-module* `Snacks.debug()` *Snacks.debug()* >lua ---@type fun(...) Snacks.debug() < `Snacks.debug.backtrace()` *Snacks.debug.backtrace()* Show a notification with a pretty backtrace >lua ---@param msg? string|string[] ---@param opts? snacks.notify.Opts Snacks.debug.backtrace(msg, opts) < `Snacks.debug.cmd()` *Snacks.debug.cmd()* >lua ---@param opts snacks.debug.cmd Snacks.debug.cmd(opts) < `Snacks.debug.inspect()` *Snacks.debug.inspect()* Show a notification with a pretty printed dump of the object(s) with lua treesitter highlighting and the location of the caller >lua Snacks.debug.inspect(...) < `Snacks.debug.log()` *Snacks.debug.log()* Log a message to the file `./debug.log`. - a timestamp will be added to every message. - accepts multiple arguments and pretty prints them. - if the argument is not a string, it will be printed using `vim.inspect`. - if the message is smaller than 120 characters, it will be printed on a single line. >lua Snacks.debug.log("Hello", { foo = "bar" }, 42) -- 2024-11-08 08:56:52 Hello { foo = "bar" } 42 < >lua Snacks.debug.log(...) < `Snacks.debug.metrics()` *Snacks.debug.metrics()* >lua Snacks.debug.metrics() < `Snacks.debug.profile()` *Snacks.debug.profile()* Very simple function to profile a lua function. **flush**: set to `true` to use `jit.flush` in every iteration. **count**: defaults to 100 >lua ---@param fn fun() ---@param opts? {count?: number, flush?: boolean, title?: string} Snacks.debug.profile(fn, opts) < `Snacks.debug.run()` *Snacks.debug.run()* Run the current buffer or a range of lines. Shows the output of `print` inlined with the code. Any error will be shown as a diagnostic. >lua ---@param opts? {name?:string, buf?:number, print?:boolean} Snacks.debug.run(opts) < `Snacks.debug.size()` *Snacks.debug.size()* >lua Snacks.debug.size(bytes) < `Snacks.debug.stats()` *Snacks.debug.stats()* >lua ---@param opts? {min?: number, show?:boolean} ---@return {summary:table, trace:snacks.debug.Stat[], traces:snacks.debug.Trace[]} Snacks.debug.stats(opts) < `Snacks.debug.trace()` *Snacks.debug.trace()* >lua ---@param name string? Snacks.debug.trace(name) < `Snacks.debug.tracemod()` *Snacks.debug.tracemod()* >lua ---@param modname string ---@param mod? table ---@param suffix? string Snacks.debug.tracemod(modname, mod, suffix) < ============================================================================== 3. Links *snacks.nvim-debug-links* 1. *image*: https://github.com/user-attachments/assets/0517aed7-fbd0-42ee-8058-c213410d80a7 Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-dim.txt ================================================ *snacks-dim* snacks dim docs ============================================================================== Table of Contents *snacks.nvim-dim-table-of-contents* 1. Setup |snacks.nvim-dim-setup| 2. Config |snacks.nvim-dim-config| 3. Module |snacks.nvim-dim-module| - Snacks.dim() |snacks.nvim-dim-module-snacks.dim()| - Snacks.dim.disable() |snacks.nvim-dim-module-snacks.dim.disable()| - Snacks.dim.enable() |snacks.nvim-dim-module-snacks.dim.enable()| 4. Links |snacks.nvim-dim-links| Focus on the active scope by dimming the rest. Similar plugins: - twilight.nvim - limelight.vim - goyo.vim ============================================================================== 1. Setup *snacks.nvim-dim-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { dim = { -- your dim configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Config *snacks.nvim-dim-config* >lua ---@class snacks.dim.Config { ---@type snacks.scope.Config scope = { min_size = 5, max_size = 20, siblings = true, }, -- animate scopes. Enabled by default for Neovim >= 0.10 -- Works on older versions but has to trigger redraws during animation. ---@type snacks.animate.Config|{enabled?: boolean} animate = { enabled = vim.fn.has("nvim-0.10") == 1, easing = "outQuad", duration = { step = 20, -- ms per step total = 300, -- maximum duration }, }, -- what buffers to dim filter = function(buf) return vim.g.snacks_dim ~= false and vim.b[buf].snacks_dim ~= false and vim.bo[buf].buftype == "" end, } < ============================================================================== 3. Module *snacks.nvim-dim-module* `Snacks.dim()` *Snacks.dim()* >lua ---@type fun(opts: snacks.dim.Config) Snacks.dim() < `Snacks.dim.disable()` *Snacks.dim.disable()* Disable dimming >lua Snacks.dim.disable() < `Snacks.dim.enable()` *Snacks.dim.enable()* >lua ---@param opts? snacks.dim.Config Snacks.dim.enable(opts) < ============================================================================== 4. Links *snacks.nvim-dim-links* 1. *image*: https://github.com/user-attachments/assets/c0c5ffda-aaeb-4578-8a18-abee2e443a93 Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-explorer.txt ================================================ *snacks-explorer* snacks explorer docs ============================================================================== Table of Contents *snacks.nvim-explorer-table-of-contents* 1. Usage |snacks.nvim-explorer-usage| - File Operations |snacks.nvim-explorer-usage-file-operations| - Navigation |snacks.nvim-explorer-usage-navigation| - Quick Actions |snacks.nvim-explorer-usage-quick-actions| - Git Integration |snacks.nvim-explorer-usage-git-integration| - Diagnostics |snacks.nvim-explorer-usage-diagnostics| - Visual Mode |snacks.nvim-explorer-usage-visual-mode| 2. Setup |snacks.nvim-explorer-setup| 3. Config |snacks.nvim-explorer-config| 4. Module |snacks.nvim-explorer-module| - Snacks.explorer() |snacks.nvim-explorer-module-snacks.explorer()| - Snacks.explorer.health()|snacks.nvim-explorer-module-snacks.explorer.health()| - Snacks.explorer.open()|snacks.nvim-explorer-module-snacks.explorer.open()| - Snacks.explorer.reveal()|snacks.nvim-explorer-module-snacks.explorer.reveal()| 5. Links |snacks.nvim-explorer-links| A file explorer for snacks. This is actually a picker in disguise. This module provide a shortcut to open the explorer picker and a setup function to replace netrw with the explorer. When the explorer and `replace_netrw` is enabled, the explorer will be opened: - when you start `nvim` with a directory - when you open a directory in vim Configuring the explorer picker is done with the picker options . >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { explorer = { -- your explorer configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below }, picker = { sources = { explorer = { -- your explorer picker configuration comes here -- or leave it empty to use the default settings } } } } } < ============================================================================== 1. Usage *snacks.nvim-explorer-usage* FILE OPERATIONS *snacks.nvim-explorer-usage-file-operations* The explorer provides powerful file operations with an intuitive selection-based workflow. MOVING AND COPYING FILES ~ The most efficient way to move or copy multiple files: 1. **Select files** with `` (works on multiple files) 2. **Navigate** to the target directory 3. **Execute** the operation:- Press `m` to **move** selected files to the current directory - Press `c` to **copy** selected files to the current directory > Example workflow: 1. Navigate to source files 2. Press on file1.txt 3. Press on file2.txt (both now selected) 4. Navigate to target directory 5. Press 'm' → files are moved! < **Single file operations:** - `m` on a single file (no selection) → renames the file - `c` on a single file (no selection) → prompts for new name to copy to - `r` → rename current file - `d` → delete current/selected files COPY/PASTE WITH REGISTERS ~ Alternative workflow using yank and paste: 1. **Select files** with `` or visual mode 2. Press `y` to **yank** file paths to register 3. Navigate to target directory 4. Press `p` to **paste** (copies files from register) This works across different explorer instances and even after closing/reopening! OTHER FILE OPERATIONS ~ - `a` → **Add** new file or directory (directories end with `/`) - `d` → **Delete** files (uses system trash if available, see `:checkhealth snacks`) - `o` → **Open** file with system application - `u` → **Update/refresh** the file tree NAVIGATION *snacks.nvim-explorer-usage-navigation* - `` or `l` → Open file or toggle directory - `h` → Close directory - `` → Go up one directory - `.` → Focus on current directory (set as cwd) - `H` → Toggle hidden files - `I` → Toggle ignored files (from gitignore) - `Z` → Close all directories QUICK ACTIONS *snacks.nvim-explorer-usage-quick-actions* - `/` → Grep in current directory - `` → Open terminal in current directory - `` → Change tab directory to current directory - `P` → Toggle preview GIT INTEGRATION *snacks.nvim-explorer-usage-git-integration* When `git_status = true` (default), files show git status indicators: - `]g` / `[g` → Jump to next/previous git change - Directories show aggregate status of contained files DIAGNOSTICS *snacks.nvim-explorer-usage-diagnostics* When `diagnostics = true` (default), files show diagnostic indicators: - `]d` / `[d` → Jump to next/previous diagnostic - `]e` / `[e` → Jump to next/previous error - `]w` / `[w` → Jump to next/previous warning VISUAL MODE *snacks.nvim-explorer-usage-visual-mode* You can use visual mode (`v` or `V`) to select multiple files, then: - `y` → Yank selected file paths - Any other operation works on visual selection ============================================================================== 2. Setup *snacks.nvim-explorer-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { explorer = { -- your explorer configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 3. Config *snacks.nvim-explorer-config* These are just the general explorer settings. To configure the explorer picker, see `snacks.picker.explorer.Config` >lua ---@class snacks.explorer.Config { replace_netrw = true, -- Replace netrw with the snacks explorer trash = true, -- Use the system trash when deleting files } < ============================================================================== 4. Module *snacks.nvim-explorer-module* `Snacks.explorer()` *Snacks.explorer()* >lua ---@type fun(opts?: snacks.picker.explorer.Config): snacks.Picker Snacks.explorer() < `Snacks.explorer.health()` *Snacks.explorer.health()* >lua Snacks.explorer.health() < `Snacks.explorer.open()` *Snacks.explorer.open()* Shortcut to open the explorer picker >lua ---@param opts? snacks.picker.explorer.Config|{} Snacks.explorer.open(opts) < `Snacks.explorer.reveal()` *Snacks.explorer.reveal()* Reveals the given file/buffer or the current buffer in the explorer >lua ---@param opts? {file?:string, buf?:number} Snacks.explorer.reveal(opts) < ============================================================================== 5. Links *snacks.nvim-explorer-links* 1. *image*: https://github.com/user-attachments/assets/e09d25f8-8559-441c-a0f7-576d2aa57097 Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-gh.txt ================================================ *snacks-gh* snacks gh docs ============================================================================== Table of Contents *snacks.nvim-gh-table-of-contents* 1. Features |snacks.nvim-gh-features| 2. Requirements |snacks.nvim-gh-requirements| 3. Recommended Setup |snacks.nvim-gh-recommended-setup| 4. Usage |snacks.nvim-gh-usage| - Available Actions |snacks.nvim-gh-usage-available-actions| - GitHub Buffers |snacks.nvim-gh-usage-github-buffers| 5. Setup |snacks.nvim-gh-setup| 6. Config |snacks.nvim-gh-config| 7. Types |snacks.nvim-gh-types| 8. Module |snacks.nvim-gh-module| - Snacks.gh.issue() |snacks.nvim-gh-module-snacks.gh.issue()| - Snacks.gh.pr() |snacks.nvim-gh-module-snacks.gh.pr()| A modern GitHub CLI integration for Neovim that brings GitHub issues and pull requests directly into your editor. ============================================================================== 1. Features *snacks.nvim-gh-features* - Browse and search **GitHub issues** and **pull requests** with fuzzy finding - View full issue/PR details including **comments**, **reactions**, and **status checks** - Perform GitHub actions directly from Neovim: - Comment on issues and PRs - Close, reopen, edit, and merge PRs - Add reactions and labels - Review PRs (approve, request changes, comment) - Checkout PR branches locally - View PR diffs with syntax highlighting - Customizable **keymaps** for common GitHub operations - Beautiful **syntax highlighting** using Treesitter - Open issues/PRs in your web browser - Yank URLs to clipboard - Built on top of the powerful Snacks picker ============================================================================== 2. Requirements *snacks.nvim-gh-requirements* - GitHub CLI (`gh`) - must be installed and authenticated - Snacks picker enabled ============================================================================== 3. Recommended Setup *snacks.nvim-gh-recommended-setup* >lua { "folke/snacks.nvim", opts = { gh = { -- your gh configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below }, picker = { sources = { gh_issue = { -- your gh_issue picker configuration comes here -- or leave it empty to use the default settings }, gh_pr = { -- your gh_pr picker configuration comes here -- or leave it empty to use the default settings } } }, }, keys = { { "gi", function() Snacks.picker.gh_issue() end, desc = "GitHub Issues (open)" }, { "gI", function() Snacks.picker.gh_issue({ state = "all" }) end, desc = "GitHub Issues (all)" }, { "gp", function() Snacks.picker.gh_pr() end, desc = "GitHub Pull Requests (open)" }, { "gP", function() Snacks.picker.gh_pr({ state = "all" }) end, desc = "GitHub Pull Requests (all)" }, }, } < ============================================================================== 4. Usage *snacks.nvim-gh-usage* >lua -- Browse open issues Snacks.picker.gh_issue() -- Browse all issues (including closed) Snacks.picker.gh_issue({ state = "all" }) -- Browse open pull requests Snacks.picker.gh_pr() -- Browse all pull requests Snacks.picker.gh_pr({ state = "all" }) -- View PR diff Snacks.picker.gh_diff({ pr = 123 }) -- Open issue/PR in buffer Snacks.gh.open({ type = "issue", number = 123, repo = "owner/repo" }) < AVAILABLE ACTIONS *snacks.nvim-gh-usage-available-actions* When viewing an issue or PR in the picker, press `` to show available actions: `Snacks.gh` makes extensive use of `Snacks.scratch` for editing comments and descriptions. **Common Actions:** - **Open in buffer** - View full details with comments - **Open in browser** - Open in GitHub web UI - **Add comment** - Add a new comment - **Add reaction** - React with emoji - **Add/Remove labels** - Manage labels - **Close/Reopen** - Change issue/PR state - **Edit** - Edit title and body - **Yank URL** - Copy URL to clipboard **Pull Request/Issue Specific:** - **View diff** - Show changed files with syntax highlighting - **Checkout** - Checkout PR branch locally - **Merge** - Merge, squash, or rebase and merge - **Review** - Approve, request changes, or comment - **Mark as draft/ready** - Change draft status - and more… GITHUB BUFFERS *snacks.nvim-gh-usage-github-buffers* When you open an issue or PR in a buffer, you get a beautiful rendered view with: - **Metadata** - Status, author, dates, labels, reactions, and assignees - **Description** - Full issue/PR body with markdown rendering - **Comments** - All comments with author info and timestamps - **Status Checks** - PR status checks and CI results (for PRs) - **Syntax Highlighting** - Full Treesitter support for markdown - **Folding** - Foldable sections for comments and metadata **Default Keymaps in GitHub Buffers:** Key Action Description ------ --------------- ------------------------------ Select Action Show available actions menu i Edit Edit issue/PR title and body a Add Comment Add a new comment c Close Close the issue/PR o Reopen Reopen a closed issue/PR See the |snacks.nvim-gh-config-section| to customize these keymaps. ============================================================================== 5. Setup *snacks.nvim-gh-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { gh = { -- your gh configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 6. Config *snacks.nvim-gh-config* >lua ---@class snacks.gh.Config { --- Keymaps for GitHub buffers ---@type table? keys = { select = { "", "gh_actions", desc = "Select Action" }, edit = { "i" , "gh_edit" , desc = "Edit" }, comment = { "a" , "gh_comment", desc = "Add Comment" }, close = { "c" , "gh_close" , desc = "Close" }, reopen = { "o" , "gh_reopen" , desc = "Reopen" }, }, ---@type vim.wo|{} wo = { breakindent = true, wrap = true, showbreak = "", linebreak = true, number = false, relativenumber = false, foldexpr = "v:lua.vim.treesitter.foldexpr()", foldmethod = "expr", concealcursor = "n", conceallevel = 2, list = false, winhighlight = Snacks.util.winhl({ Normal = "SnacksGhNormal", NormalFloat = "SnacksGhNormalFloat", FloatBorder = "SnacksGhBorder", FloatTitle = "SnacksGhTitle", FloatFooter = "SnacksGhFooter", }), }, ---@type vim.bo|{} bo = {}, diff = { min = 4, -- minimum number of lines changed to show diff wrap = 80, -- wrap diff lines at this length }, scratch = { height = 15, -- height of scratch window }, icons = { logo = " ", user= " ", checkmark = " ", crossmark = " ", block = "■", file = " ", checks = { pending = " ", success = " ", failure = "", skipped = " ", }, issue = { open = " ", completed = " ", other = " " }, pr = { open = " ", closed = " ", merged = " ", draft = " ", other = " ", }, review = { approved = " ", changes_requested = " ", commented = " ", dismissed = " ", pending = " ", }, merge_status = { clean = " ", dirty = " ", blocked = " ", unstable = " " }, reactions = { thumbs_up = "👍", thumbs_down = "👎", eyes = "👀", confused = "😕", heart = "❤️", hooray = "🎉", laugh = "😄", rocket = "🚀", }, }, } < ============================================================================== 7. Types *snacks.nvim-gh-types* >lua ---@alias snacks.gh.Keymap.fn fun(item:snacks.picker.gh.Item, buf:snacks.gh.Buf) ---@class snacks.gh.Keymap: vim.keymap.set.Opts ---@field [1] string lhs ---@field [2] string|snacks.gh.Keymap.fn rhs ---@field mode? string|string[] defaults to `n` < ============================================================================== 8. Module *snacks.nvim-gh-module* >lua ---@class snacks.gh ---@field api snacks.gh.api ---@field item snacks.picker.gh.Item Snacks.gh = {} < `Snacks.gh.issue()` *Snacks.gh.issue()* >lua ---@param opts? snacks.picker.gh.issue.Config Snacks.gh.issue(opts) < `Snacks.gh.pr()` *Snacks.gh.pr()* >lua ---@param opts? snacks.picker.gh.pr.Config Snacks.gh.pr(opts) < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-git.txt ================================================ *snacks-git* snacks git docs ============================================================================== Table of Contents *snacks.nvim-git-table-of-contents* 1. Styles |snacks.nvim-git-styles| - blame_line |snacks.nvim-git-styles-blame_line| 2. Module |snacks.nvim-git-module| - Snacks.git.blame_line() |snacks.nvim-git-module-snacks.git.blame_line()| - Snacks.git.get_root() |snacks.nvim-git-module-snacks.git.get_root()| ============================================================================== 1. Styles *snacks.nvim-git-styles* Check the styles docs for more information on how to customize these styles BLAME_LINE *snacks.nvim-git-styles-blame_line* >lua { width = 0.6, height = 0.6, border = true, title = " Git Blame ", title_pos = "center", ft = "git", } < ============================================================================== 2. Module *snacks.nvim-git-module* `Snacks.git.blame_line()` *Snacks.git.blame_line()* Show git log for the current line. >lua ---@param opts? snacks.terminal.Opts | {count?: number} Snacks.git.blame_line(opts) < `Snacks.git.get_root()` *Snacks.git.get_root()* Gets the git root for a buffer or path. Defaults to the current buffer. >lua ---@param path? number|string buffer or path ---@return string? Snacks.git.get_root(path) < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-gitbrowse.txt ================================================ *snacks-gitbrowse* snacks gitbrowse docs ============================================================================== Table of Contents *snacks.nvim-gitbrowse-table-of-contents* 1. Setup |snacks.nvim-gitbrowse-setup| 2. Config |snacks.nvim-gitbrowse-config| 3. Types |snacks.nvim-gitbrowse-types| 4. Module |snacks.nvim-gitbrowse-module| - Snacks.gitbrowse() |snacks.nvim-gitbrowse-module-snacks.gitbrowse()| - Snacks.gitbrowse.get_url()|snacks.nvim-gitbrowse-module-snacks.gitbrowse.get_url()| - Snacks.gitbrowse.open()|snacks.nvim-gitbrowse-module-snacks.gitbrowse.open()| Open the repo of the active file in the browser (e.g., GitHub) ============================================================================== 1. Setup *snacks.nvim-gitbrowse-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { gitbrowse = { -- your gitbrowse configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Config *snacks.nvim-gitbrowse-config* >lua ---@class snacks.gitbrowse.Config ---@field url_patterns? table> { notify = true, -- show notification on open -- Handler to open the url in a browser ---@param url string open = function(url) if vim.fn.has("nvim-0.10") == 0 then require("lazy.util").open(url, { system = true }) return end vim.ui.open(url) end, ---@type "repo" | "branch" | "file" | "commit" | "permalink" what = "commit", -- what to open. not all remotes support all types commit = nil, ---@type string? branch = nil, ---@type string? line_start = nil, ---@type number? line_end = nil, ---@type number? -- patterns to transform remotes to an actual URL remote_patterns = { { "^(https?://.*)%.git$" , "%1" }, { "^git@(.+):(.+)%.git$" , "https://%1/%2" }, { "^git@(.+):(.+)$" , "https://%1/%2" }, { "^git@(.+)/(.+)$" , "https://%1/%2" }, { "^org%-%d+@(.+):(.+)%.git$" , "https://%1/%2" }, { "^ssh://git@(.*)$" , "https://%1" }, { "^ssh://([^:/]+)(:%d+)/(.*)$" , "https://%1/%3" }, { "^ssh://([^/]+)/(.*)$" , "https://%1/%2" }, { "ssh%.dev%.azure%.com/v3/(.*)/(.*)$", "dev.azure.com/%1/_git/%2" }, { "^https://%w*@(.*)" , "https://%1" }, { "^git@(.*)" , "https://%1" }, { ":%d+" , "" }, { "%.git$" , "" }, }, url_patterns = { ["github%.com"] = { branch = "/tree/{branch}", file = "/blob/{branch}/{file}#L{line_start}-L{line_end}", permalink = "/blob/{commit}/{file}#L{line_start}-L{line_end}", commit = "/commit/{commit}", }, ["gitlab%.com"] = { branch = "/-/tree/{branch}", file = "/-/blob/{branch}/{file}#L{line_start}-{line_end}", permalink = "/-/blob/{commit}/{file}#L{line_start}-{line_end}", commit = "/-/commit/{commit}", }, ["bitbucket%.org"] = { branch = "/src/{branch}", file = "/src/{branch}/{file}#lines-{line_start}-L{line_end}", permalink = "/src/{commit}/{file}#lines-{line_start}-L{line_end}", commit = "/commits/{commit}", }, ["git.sr.ht"] = { branch = "/tree/{branch}", file = "/tree/{branch}/item/{file}", permalink = "/tree/{commit}/item/{file}#L{line_start}", commit = "/commit/{commit}", }, }, } < ============================================================================== 3. Types *snacks.nvim-gitbrowse-types* >lua ---@class snacks.gitbrowse.Fields ---@field branch? string ---@field file? string ---@field line_start? number ---@field line_end? number ---@field commit? string ---@field line_count? number < ============================================================================== 4. Module *snacks.nvim-gitbrowse-module* `Snacks.gitbrowse()` *Snacks.gitbrowse()* >lua ---@type fun(opts?: snacks.gitbrowse.Config) Snacks.gitbrowse() < `Snacks.gitbrowse.get_url()` *Snacks.gitbrowse.get_url()* >lua ---@param repo string ---@param fields snacks.gitbrowse.Fields ---@param opts? snacks.gitbrowse.Config Snacks.gitbrowse.get_url(repo, fields, opts) < `Snacks.gitbrowse.open()` *Snacks.gitbrowse.open()* >lua ---@param opts? snacks.gitbrowse.Config Snacks.gitbrowse.open(opts) < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-health.txt ================================================ *snacks-health* snacks health docs ============================================================================== Table of Contents *snacks.nvim-health-table-of-contents* 1. Types |snacks.nvim-health-types| 2. Module |snacks.nvim-health-module| - Snacks.health.check() |snacks.nvim-health-module-snacks.health.check()| - Snacks.health.has_lang()|snacks.nvim-health-module-snacks.health.has_lang()| - Snacks.health.have_tool()|snacks.nvim-health-module-snacks.health.have_tool()| ============================================================================== 1. Types *snacks.nvim-health-types* >lua ---@class snacks.health.Tool ---@field cmd string|string[] ---@field version? string|false ---@field enabled? boolean < >lua ---@alias snacks.health.Tool.spec (string|snacks.health.Tool)[]|snacks.health.Tool|string < ============================================================================== 2. Module *snacks.nvim-health-module* >lua ---@class snacks.health ---@field ok fun(msg: string) ---@field warn fun(msg: string) ---@field error fun(msg: string) ---@field info fun(msg: string) ---@field start fun(msg: string) Snacks.health = {} < `Snacks.health.check()` *Snacks.health.check()* >lua Snacks.health.check() < `Snacks.health.has_lang()` *Snacks.health.has_lang()* Check if the given languages are available in treesitter >lua ---@param langs string[]|string Snacks.health.has_lang(langs) < `Snacks.health.have_tool()` *Snacks.health.have_tool()* Check if any of the tools are available, with an optional version check >lua ---@param tools snacks.health.Tool.spec Snacks.health.have_tool(tools) < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-image.txt ================================================ *snacks-image* snacks image docs ============================================================================== Table of Contents *snacks.nvim-image-table-of-contents* 1. Features |snacks.nvim-image-features| 2. Setup |snacks.nvim-image-setup| 3. Config |snacks.nvim-image-config| 4. Styles |snacks.nvim-image-styles| - snacks_image |snacks.nvim-image-styles-snacks_image| 5. Types |snacks.nvim-image-types| 6. Module |snacks.nvim-image-module| - Snacks.image.hover() |snacks.nvim-image-module-snacks.image.hover()| - Snacks.image.langs() |snacks.nvim-image-module-snacks.image.langs()| - Snacks.image.supports() |snacks.nvim-image-module-snacks.image.supports()| - Snacks.image.supports_file()|snacks.nvim-image-module-snacks.image.supports_file()| - Snacks.image.supports_terminal()|snacks.nvim-image-module-snacks.image.supports_terminal()| 7. Links |snacks.nvim-image-links| ============================================================================== 1. Features *snacks.nvim-image-features* - Image viewer using the Kitty Graphics Protocol . - open images in a wide range of formats: `pdf`, `png`, `jpg`, `jpeg`, `gif`, `bmp`, `webp`, `tiff`, `heic`, `avif`, `mp4`, `mov`, `avi`, `mkv`, `webm` - Supports inline image rendering in: `markdown`, `html`, `norg`, `tsx`, `javascript`, `css`, `vue`, `svelte`, `scss`, `latex`, `typst` - LaTex math expressions in `markdown` and `latex` documents Terminal support: - kitty - ghostty - wezterm Wezterm has only limited support for the kitty graphics protocol. Inline image rendering is not supported. - tmux Snacks automatically tries to enable `allow-passthrough=on` for tmux, but you may need to enable it manually in your tmux configuration. - zellij is **not** supported, since they don’t have any support for passthrough Image will be transferred to the terminal by filename or by sending the image date in case `ssh` is detected. In some cases you may need to force snacks to detect or not detect a certain environment. You can do this by setting `SNACKS_${ENV_NAME}` to `true` or `false`. For example, to force detection of **ghostty** you can set `SNACKS_GHOSTTY=true`. In order to automatically display the image when opening an image file, or to have imaged displayed in supported document formats like `markdown` or `html`, you need to enable the `image` plugin in your `snacks` config. ImageMagick is required to convert images to the supported formats (all except PNG). In case of issues, make sure to run `:checkhealth snacks`. ============================================================================== 2. Setup *snacks.nvim-image-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { image = { -- your image configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 3. Config *snacks.nvim-image-config* >lua ---@class snacks.image.Config ---@field enabled? boolean enable image viewer ---@field wo? vim.wo|{} options for windows showing the image ---@field bo? vim.bo|{} options for the image buffer ---@field formats? string[] --- Resolves a reference to an image with src in a file (currently markdown only). --- Return the absolute path or url to the image. --- When `nil`, the path is resolved relative to the file. ---@field resolve? fun(file: string, src: string): string? ---@field convert? snacks.image.convert.Config { formats = { "png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "heic", "avif", "mp4", "mov", "avi", "mkv", "webm", "pdf", "icns", }, force = false, -- try displaying the image, even if the terminal does not support it doc = { -- enable image viewer for documents -- a treesitter parser must be available for the enabled languages. enabled = true, -- render the image inline in the buffer -- if your env doesn't support unicode placeholders, this will be disabled -- takes precedence over `opts.float` on supported terminals inline = true, -- render the image in a floating window -- only used if `opts.inline` is disabled float = true, max_width = 80, max_height = 40, -- Set to `true`, to conceal the image text when rendering inline. -- (experimental) ---@param lang string tree-sitter language ---@param type snacks.image.Type image type conceal = function(lang, type) -- only conceal math expressions return type == "math" end, }, img_dirs = { "img", "images", "assets", "static", "public", "media", "attachments" }, -- window options applied to windows displaying image buffers -- an image buffer is a buffer with `filetype=image` wo = { wrap = false, number = false, relativenumber = false, cursorcolumn = false, signcolumn = "no", foldcolumn = "0", list = false, spell = false, statuscolumn = "", }, cache = vim.fn.stdpath("cache") .. "/snacks/image", debug = { request = false, convert = false, placement = false, }, env = {}, -- icons used to show where an inline image is located that is -- rendered below the text. icons = { math = "󰪚 ", chart = "󰄧 ", image = " ", }, ---@class snacks.image.convert.Config convert = { notify = false, -- show a notification on error ---@type snacks.image.args mermaid = function() local theme = vim.o.background == "light" and "neutral" or "dark" return { "-i", "{src}", "-o", "{file}", "-b", "transparent", "-t", theme, "-s", "{scale}" } end, ---@type table magick = { default = { "{src}[0]", "-scale", "1920x1080>" }, -- default for raster images vector = { "-density", 192, "{src}[{page}]" }, -- used by vector images like svg math = { "-density", 192, "{src}[{page}]", "-trim" }, pdf = { "-density", 192, "{src}[{page}]", "-background", "white", "-alpha", "remove", "-trim" }, }, }, math = { enabled = true, -- enable math expression rendering -- in the templates below, `${header}` comes from any section in your document, -- between a start/end header comment. Comment syntax is language-specific. -- * start comment: `// snacks: header start` -- * end comment: `// snacks: header end` typst = { tpl = [[ #set page(width: auto, height: auto, margin: (x: 2pt, y: 2pt)) #show math.equation.where(block: false): set text(top-edge: "bounds", bottom-edge: "bounds") #set text(size: 12pt, fill: rgb("${color}")) ${header} ${content}]], }, latex = { font_size = "Large", -- see https://www.sascha-frank.com/latex-font-size.html -- for latex documents, the doc packages are included automatically, -- but you can add more packages here. Useful for markdown documents. packages = { "amsmath", "amssymb", "amsfonts", "amscd", "mathtools" }, tpl = [[ \documentclass[preview,border=0pt,varwidth,12pt]{standalone} \usepackage{${packages}} \begin{document} ${header} { \${font_size} \selectfont \color[HTML]{${color}} ${content}} \end{document}]], }, }, } < ============================================================================== 4. Styles *snacks.nvim-image-styles* Check the styles docs for more information on how to customize these styles SNACKS_IMAGE *snacks.nvim-image-styles-snacks_image* >lua { relative = "cursor", border = true, focusable = false, backdrop = false, row = 1, col = 1, -- width/height are automatically set by the image size unless specified below } < ============================================================================== 5. Types *snacks.nvim-image-types* >lua ---@alias snacks.image.Size {width: number, height: number} ---@alias snacks.image.Pos {[1]: number, [2]: number} ---@alias snacks.image.Loc snacks.image.Pos|snacks.image.Size|{zindex?: number} ---@alias snacks.image.Type "image"|"math"|"chart" < >lua ---@class snacks.image.Env ---@field name string ---@field env? table ---@field terminal? string ---@field supported? boolean default: false ---@field placeholders? boolean default: false ---@field setup? fun(): boolean? ---@field transform? fun(data: string): string ---@field detected? boolean ---@field remote? boolean this is a remote client, so full transfer of the image data is required < >lua ---@class snacks.image.Opts ---@field pos? snacks.image.Pos (row, col) (1,0)-indexed. defaults to the top-left corner ---@field range? Range4 ---@field conceal? boolean ---@field inline? boolean render the image inline in the buffer ---@field width? number ---@field min_width? number ---@field max_width? number ---@field height? number ---@field min_height? number ---@field max_height? number ---@field on_update? fun(placement: snacks.image.Placement) ---@field on_update_pre? fun(placement: snacks.image.Placement) ---@field type? snacks.image.Type ---@field auto_resize? boolean < ============================================================================== 6. Module *snacks.nvim-image-module* >lua ---@class snacks.image ---@field terminal snacks.image.terminal ---@field image snacks.Image ---@field placement snacks.image.Placement ---@field util snacks.image.util ---@field buf snacks.image.buf ---@field doc snacks.image.doc ---@field convert snacks.image.convert ---@field inline snacks.image.inline Snacks.image = {} < `Snacks.image.hover()` *Snacks.image.hover()* Show the image at the cursor in a floating window >lua Snacks.image.hover() < `Snacks.image.langs()` *Snacks.image.langs()* >lua ---@return string[] Snacks.image.langs() < `Snacks.image.supports()` *Snacks.image.supports()* Check if the file format is supported and the terminal supports the kitty graphics protocol >lua ---@param file string Snacks.image.supports(file) < `Snacks.image.supports_file()` *Snacks.image.supports_file()* Check if the file format is supported >lua ---@param file string Snacks.image.supports_file(file) < `Snacks.image.supports_terminal()` *Snacks.image.supports_terminal()* Check if the terminal supports the kitty graphics protocol >lua Snacks.image.supports_terminal() < ============================================================================== 7. Links *snacks.nvim-image-links* 1. *Image*: https://github.com/user-attachments/assets/4e8a686c-bf41-4989-9d74-1641ecf2835f Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-indent.txt ================================================ *snacks-indent* snacks indent docs ============================================================================== Table of Contents *snacks.nvim-indent-table-of-contents* 1. Setup |snacks.nvim-indent-setup| 2. Config |snacks.nvim-indent-config| 3. Types |snacks.nvim-indent-types| 4. Module |snacks.nvim-indent-module| - Snacks.indent.debug_win()|snacks.nvim-indent-module-snacks.indent.debug_win()| - Snacks.indent.disable()|snacks.nvim-indent-module-snacks.indent.disable()| - Snacks.indent.enable() |snacks.nvim-indent-module-snacks.indent.enable()| 5. Links |snacks.nvim-indent-links| Visualize indent guides and scopes based on treesitter or indent. Similar plugins: - indent-blankline.nvim - mini.indentscope ============================================================================== 1. Setup *snacks.nvim-indent-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { indent = { -- your indent configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Config *snacks.nvim-indent-config* >lua ---@class snacks.indent.Config ---@field enabled? boolean { indent = { priority = 1, enabled = true, -- enable indent guides char = "│", only_scope = false, -- only show indent guides of the scope only_current = false, -- only show indent guides in the current window hl = "SnacksIndent", ---@type string|string[] hl groups for indent guides -- can be a list of hl groups to cycle through -- hl = { -- "SnacksIndent1", -- "SnacksIndent2", -- "SnacksIndent3", -- "SnacksIndent4", -- "SnacksIndent5", -- "SnacksIndent6", -- "SnacksIndent7", -- "SnacksIndent8", -- }, }, -- animate scopes. Enabled by default for Neovim >= 0.10 -- Works on older versions but has to trigger redraws during animation. ---@class snacks.indent.animate: snacks.animate.Config ---@field enabled? boolean --- * out: animate outwards from the cursor --- * up: animate upwards from the cursor --- * down: animate downwards from the cursor --- * up_down: animate up or down based on the cursor position ---@field style? "out"|"up_down"|"down"|"up" animate = { enabled = vim.fn.has("nvim-0.10") == 1, style = "out", easing = "linear", duration = { step = 20, -- ms per step total = 500, -- maximum duration }, }, ---@class snacks.indent.Scope.Config: snacks.scope.Config scope = { enabled = true, -- enable highlighting the current scope priority = 200, char = "│", underline = false, -- underline the start of the scope only_current = false, -- only show scope in the current window hl = "SnacksIndentScope", ---@type string|string[] hl group for scopes }, chunk = { -- when enabled, scopes will be rendered as chunks, except for the -- top-level scope which will be rendered as a scope. enabled = false, -- only show chunk scopes in the current window only_current = false, priority = 200, hl = "SnacksIndentChunk", ---@type string|string[] hl group for chunk scopes char = { corner_top = "┌", corner_bottom = "└", -- corner_top = "╭", -- corner_bottom = "╰", horizontal = "─", vertical = "│", arrow = ">", }, }, -- filter for buffers to enable indent guides ---@param buf number ---@param win number filter = function(buf, win) return vim.g.snacks_indent ~= false and vim.b[buf].snacks_indent ~= false and vim.bo[buf].buftype == "" end, } < ============================================================================== 3. Types *snacks.nvim-indent-types* >lua ---@class snacks.indent.Scope: snacks.scope.Scope ---@field win number ---@field step? number ---@field animate? {from: number, to: number} < ============================================================================== 4. Module *snacks.nvim-indent-module* `Snacks.indent.debug_win()` *Snacks.indent.debug_win()* >lua Snacks.indent.debug_win() < `Snacks.indent.disable()` *Snacks.indent.disable()* Disable indent guides >lua Snacks.indent.disable() < `Snacks.indent.enable()` *Snacks.indent.enable()* Enable indent guides >lua Snacks.indent.enable() < ============================================================================== 5. Links *snacks.nvim-indent-links* 1. *image*: https://github.com/user-attachments/assets/56a99495-05ab-488e-9619-574cb7ff2b7d Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-init.txt ================================================ *snacks-init* snacks init docs ============================================================================== Table of Contents *snacks.nvim-init-table-of-contents* 1. Config |snacks.nvim-init-config| 2. Types |snacks.nvim-init-types| 3. Module |snacks.nvim-init-module| - Snacks.init.config.example()|snacks.nvim-init-module-snacks.init.config.example()| - Snacks.init.config.get()|snacks.nvim-init-module-snacks.init.config.get()| - Snacks.init.config.merge()|snacks.nvim-init-module-snacks.init.config.merge()| - Snacks.init.config.style()|snacks.nvim-init-module-snacks.init.config.style()| - Snacks.init.setup() |snacks.nvim-init-module-snacks.init.setup()| ============================================================================== 1. Config *snacks.nvim-init-config* >lua ---@class snacks.Config ---@field animate? snacks.animate.Config ---@field bigfile? snacks.bigfile.Config ---@field dashboard? snacks.dashboard.Config ---@field dim? snacks.dim.Config ---@field explorer? snacks.explorer.Config ---@field gh? snacks.gh.Config ---@field gitbrowse? snacks.gitbrowse.Config ---@field image? snacks.image.Config ---@field indent? snacks.indent.Config ---@field input? snacks.input.Config ---@field layout? snacks.layout.Config ---@field lazygit? snacks.lazygit.Config ---@field notifier? snacks.notifier.Config ---@field picker? snacks.picker.Config ---@field profiler? snacks.profiler.Config ---@field quickfile? snacks.quickfile.Config ---@field scope? snacks.scope.Config ---@field scratch? snacks.scratch.Config ---@field scroll? snacks.scroll.Config ---@field statuscolumn? snacks.statuscolumn.Config ---@field terminal? snacks.terminal.Config ---@field toggle? snacks.toggle.Config ---@field win? snacks.win.Config ---@field words? snacks.words.Config ---@field zen? snacks.zen.Config ---@field styles? table ---@field image? snacks.image.Config|{} { image = { -- define these here, so that we don't need to load the image module formats = { "png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "heic", "avif", "mp4", "mov", "avi", "mkv", "webm", "pdf", "icns", }, }, } < ============================================================================== 2. Types *snacks.nvim-init-types* >lua ---@class snacks.Config.base ---@field example? string ---@field config? fun(opts: table, defaults: table) < ============================================================================== 3. Module *snacks.nvim-init-module* >lua ---@class Snacks ---@field animate snacks.animate ---@field bigfile snacks.bigfile ---@field bufdelete snacks.bufdelete ---@field dashboard snacks.dashboard ---@field debug snacks.debug ---@field dim snacks.dim ---@field explorer snacks.explorer ---@field gh snacks.gh ---@field git snacks.git ---@field gitbrowse snacks.gitbrowse ---@field health snacks.health ---@field image snacks.image ---@field indent snacks.indent ---@field input snacks.input ---@field keymap snacks.keymap ---@field layout snacks.layout ---@field lazygit snacks.lazygit ---@field meta snacks.meta ---@field notifier snacks.notifier ---@field notify snacks.notify ---@field picker snacks.picker ---@field profiler snacks.profiler ---@field quickfile snacks.quickfile ---@field rename snacks.rename ---@field scope snacks.scope ---@field scratch snacks.scratch ---@field scroll snacks.scroll ---@field statuscolumn snacks.statuscolumn ---@field terminal snacks.terminal ---@field toggle snacks.toggle ---@field util snacks.util ---@field win snacks.win ---@field words snacks.words ---@field zen snacks.zen Snacks = {} < `Snacks.init.config.example()` *Snacks.init.config.example()* Get an example config from the docs/examples directory. >lua ---@param snack string ---@param name string ---@param opts? table Snacks.init.config.example(snack, name, opts) < `Snacks.init.config.get()` *Snacks.init.config.get()* >lua ---@generic T: table ---@param snack string ---@param defaults T ---@param ... T[] ---@return T Snacks.init.config.get(snack, defaults, ...) < `Snacks.init.config.merge()` *Snacks.init.config.merge()* Merges the values similar to vim.tbl_deep_extend with the **force** behavior, but the values can be any type >lua ---@generic T ---@param ... T ---@return T Snacks.init.config.merge(...) < `Snacks.init.config.style()` *Snacks.init.config.style()* Register a new window style config. >lua ---@param name string ---@param defaults snacks.win.Config|{} ---@return string Snacks.init.config.style(name, defaults) < `Snacks.init.setup()` *Snacks.init.setup()* >lua ---@param opts snacks.Config? Snacks.init.setup(opts) < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-input.txt ================================================ *snacks-input* snacks input docs ============================================================================== Table of Contents *snacks.nvim-input-table-of-contents* 1. Setup |snacks.nvim-input-setup| 2. Config |snacks.nvim-input-config| 3. Styles |snacks.nvim-input-styles| - input |snacks.nvim-input-styles-input| 4. Types |snacks.nvim-input-types| 5. Module |snacks.nvim-input-module| - Snacks.input() |snacks.nvim-input-module-snacks.input()| - Snacks.input.disable() |snacks.nvim-input-module-snacks.input.disable()| - Snacks.input.enable() |snacks.nvim-input-module-snacks.input.enable()| - Snacks.input.input() |snacks.nvim-input-module-snacks.input.input()| 6. Links |snacks.nvim-input-links| Better `vim.ui.input`. ============================================================================== 1. Setup *snacks.nvim-input-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { input = { -- your input configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Config *snacks.nvim-input-config* >lua ---@class snacks.input.Config ---@field enabled? boolean ---@field win? snacks.win.Config|{} ---@field icon? string ---@field icon_pos? snacks.input.Pos ---@field prompt_pos? snacks.input.Pos { icon = " ", icon_hl = "SnacksInputIcon", icon_pos = "left", prompt_pos = "title", win = { style = "input" }, expand = true, } < ============================================================================== 3. Styles *snacks.nvim-input-styles* Check the styles docs for more information on how to customize these styles INPUT *snacks.nvim-input-styles-input* >lua { backdrop = false, position = "float", border = true, title_pos = "center", height = 1, width = 60, relative = "editor", noautocmd = true, row = 2, -- relative = "cursor", -- row = -3, -- col = 0, wo = { winhighlight = "NormalFloat:SnacksInputNormal,FloatBorder:SnacksInputBorder,FloatTitle:SnacksInputTitle", cursorline = false, }, bo = { filetype = "snacks_input", buftype = "prompt", }, --- buffer local variables b = { completion = false, -- disable blink completions in input }, keys = { n_esc = { "", { "cmp_close", "cancel" }, mode = "n", expr = true }, i_esc = { "", { "cmp_close", "stopinsert" }, mode = "i", expr = true }, i_cr = { "", { "cmp_accept", "confirm" }, mode = { "i", "n" }, expr = true }, i_tab = { "", { "cmp_select_next", "cmp" }, mode = "i", expr = true }, i_ctrl_w = { "", "", mode = "i", expr = true }, i_up = { "", { "hist_up" }, mode = { "i", "n" } }, i_down = { "", { "hist_down" }, mode = { "i", "n" } }, q = "cancel", }, } < ============================================================================== 4. Types *snacks.nvim-input-types* >lua ---@alias snacks.input.Pos "left"|"title"|false < >lua ---@alias snacks.input.Highlight {[1]:number, [2]:number, [3]:string} < >lua ---@class snacks.input.Opts: snacks.input.Config,{} ---@field prompt? string ---@field default? string ---@field completion? string ---@field highlight? fun(text: string): snacks.input.Highlight[] < ============================================================================== 5. Module *snacks.nvim-input-module* `Snacks.input()` *Snacks.input()* >lua ---@type fun(opts: snacks.input.Opts, on_confirm: fun(value?: string)): snacks.win Snacks.input() < `Snacks.input.disable()` *Snacks.input.disable()* >lua Snacks.input.disable() < `Snacks.input.enable()` *Snacks.input.enable()* >lua Snacks.input.enable() < `Snacks.input.input()` *Snacks.input.input()* >lua ---@param opts? snacks.input.Opts ---@param on_confirm fun(value?: string) Snacks.input.input(opts, on_confirm) < ============================================================================== 6. Links *snacks.nvim-input-links* 1. *image*: https://github.com/user-attachments/assets/f7579302-bea1-4f1c-8b3b-723c3f4ca04b Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-keymap.txt ================================================ *snacks-keymap* snacks keymap docs ============================================================================== Table of Contents *snacks.nvim-keymap-table-of-contents* 1. Features |snacks.nvim-keymap-features| 2. Usage |snacks.nvim-keymap-usage| - Filetype-specific Keymaps|snacks.nvim-keymap-usage-filetype-specific-keymaps| - LSP-aware Keymaps |snacks.nvim-keymap-usage-lsp-aware-keymaps| - Standard Keymaps |snacks.nvim-keymap-usage-standard-keymaps| - Deleting Keymaps |snacks.nvim-keymap-usage-deleting-keymaps| 3. Types |snacks.nvim-keymap-types| 4. Module |snacks.nvim-keymap-module| - Snacks.keymap.del() |snacks.nvim-keymap-module-snacks.keymap.del()| - Snacks.keymap.set() |snacks.nvim-keymap-module-snacks.keymap.set()| Better `vim.keymap.set` and `vim.keymap.del` with support for filetype-specific and LSP client-aware keymaps. ============================================================================== 1. Features *snacks.nvim-keymap-features* - **Filetype-specific keymaps**: Set keymaps that only apply to specific filetypes - **LSP-aware keymaps**: Set keymaps based on LSP client capabilities - **Automatic setup**: Keymaps are automatically applied to existing and new buffers - **Drop-in replacement**: Same API as `vim.keymap.set/del` with additional options - **Smart defaults**: Silent by default ============================================================================== 2. Usage *snacks.nvim-keymap-usage* FILETYPE-SPECIFIC KEYMAPS *snacks.nvim-keymap-usage-filetype-specific-keymaps* Set keymaps that only apply to buffers with specific filetypes: >lua -- Single filetype - execute the current lua buffer Snacks.keymap.set("n", "r", function() vim.cmd.source() end, { ft = "lua", desc = "Run Lua File", }) -- Multiple filetypes Snacks.keymap.set("n", "t", ":TestNearest", { ft = { "python", "ruby", "javascript" }, desc = "Run Test", }) < LSP-AWARE KEYMAPS *snacks.nvim-keymap-usage-lsp-aware-keymaps* Set keymaps based on LSP client capabilities: >lua -- Set keymap for buffers with any LSP that supports code actions Snacks.keymap.set("n", "ca", vim.lsp.buf.code_action, { lsp = { method = "textDocument/codeAction" }, desc = "Code Action", }) -- Set keymap for buffers with a specific LSP client Snacks.keymap.set("n", "co", function() vim.lsp.buf.code_action({ apply = true, context = { only = { "source.organizeImports" }, diagnostics = {}, }, }) end, { lsp = { name = "vtsls" }, desc = "Organize Imports", }) -- Set keymap for buffers with LSP that supports definitions Snacks.keymap.set("n", "gd", vim.lsp.buf.definition, { lsp = { method = "textDocument/definition" }, desc = "Go to Definition", }) < STANDARD KEYMAPS *snacks.nvim-keymap-usage-standard-keymaps* Works exactly like `vim.keymap.set` without special options: >lua Snacks.keymap.set("n", "w", ":w", { desc = "Save" }) Snacks.keymap.set({ "n", "v" }, "y", '"+y', { desc = "Copy to clipboard" }) < DELETING KEYMAPS *snacks.nvim-keymap-usage-deleting-keymaps* >lua -- Delete a standard keymap Snacks.keymap.del("n", "w") -- Delete a filetype-specific keymap Snacks.keymap.del("n", "", { ft = "lua" }) -- Delete an LSP-aware keymap Snacks.keymap.del("n", "ca", { lsp = { method = "textDocument/codeAction" } }) < ============================================================================== 3. Types *snacks.nvim-keymap-types* >lua ---@class snacks.keymap.set.Opts: vim.keymap.set.Opts ---@field ft? string|string[] Filetype(s) to set the keymap for. ---@field lsp? vim.lsp.get_clients.Filter Set for buffers with LSP clients matching this filter. ---@field enabled? boolean|fun(buf?:number): boolean condition to enable the keymap. < >lua ---@class snacks.keymap.del.Opts: vim.keymap.del.Opts ---@field buffer? boolean|number If true or 0, use the current buffer. ---@field ft? string|string[] Filetype(s) to set the keymap for. ---@field lsp? vim.lsp.get_clients.Filter Set for buffers with LSP clients matching this filter. < >lua ---@class snacks.Keymap ---@field id number Unique ID for the keymap. ---@field key string Unique key for the keymap, in the format "mode:lhs". ---@field mode string Mode "short-name" (see |nvim_set_keymap()|), or a list thereof. ---@field lhs string Left-hand side |{lhs}| of the mapping. ---@field rhs string|function Right-hand side |{rhs}| of the mapping, can be a Lua function. ---@field lsp? vim.lsp.get_clients.Filter ---@field opts? snacks.keymap.set.Opts ---@field enabled fun(buf:number): boolean < ============================================================================== 4. Module *snacks.nvim-keymap-module* `Snacks.keymap.del()` *Snacks.keymap.del()* >lua ---@param mode string|string[] Mode "short-name" (see |nvim_set_keymap()|), or a list thereof. ---@param lhs string Left-hand side |{lhs}| of the mapping. ---@param opts? snacks.keymap.del.Opts Snacks.keymap.del(mode, lhs, opts) < `Snacks.keymap.set()` *Snacks.keymap.set()* >lua ---@param mode string|string[] Mode "short-name" (see |nvim_set_keymap()|), or a list thereof. ---@param lhs string Left-hand side |{lhs}| of the mapping. ---@param rhs string|function Right-hand side |{rhs}| of the mapping, can be a Lua function. ---@param opts? snacks.keymap.set.Opts Snacks.keymap.set(mode, lhs, rhs, opts) < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-layout.txt ================================================ *snacks-layout* snacks layout docs ============================================================================== Table of Contents *snacks.nvim-layout-table-of-contents* 1. Setup |snacks.nvim-layout-setup| 2. Config |snacks.nvim-layout-config| 3. Types |snacks.nvim-layout-types| 4. Module |snacks.nvim-layout-module| - Snacks.layout.new() |snacks.nvim-layout-module-snacks.layout.new()| - layout:close() |snacks.nvim-layout-module-layout:close()| - layout:each() |snacks.nvim-layout-module-layout:each()| - layout:hide() |snacks.nvim-layout-module-layout:hide()| - layout:is_enabled() |snacks.nvim-layout-module-layout:is_enabled()| - layout:is_hidden() |snacks.nvim-layout-module-layout:is_hidden()| - layout:maximize() |snacks.nvim-layout-module-layout:maximize()| - layout:needs_layout() |snacks.nvim-layout-module-layout:needs_layout()| - layout:show() |snacks.nvim-layout-module-layout:show()| - layout:toggle() |snacks.nvim-layout-module-layout:toggle()| - layout:unhide() |snacks.nvim-layout-module-layout:unhide()| - layout:valid() |snacks.nvim-layout-module-layout:valid()| ============================================================================== 1. Setup *snacks.nvim-layout-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { layout = { -- your layout configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Config *snacks.nvim-layout-config* >lua ---@class snacks.layout.Config ---@field show? boolean show the layout on creation (default: true) ---@field wins table windows to include in the layout ---@field layout snacks.layout.Box layout definition ---@field fullscreen? boolean open in fullscreen ---@field hidden? string[] list of windows that will be excluded from the layout (but can be toggled) ---@field on_update? fun(layout: snacks.layout) ---@field on_update_pre? fun(layout: snacks.layout) ---@field on_close? fun(layout: snacks.layout) { layout = { width = 0.6, height = 0.6, zindex = 50, }, } < ============================================================================== 3. Types *snacks.nvim-layout-types* >lua ---@class snacks.layout.Win: snacks.win.Config,{} ---@field depth? number ---@field win string layout window name < >lua ---@class snacks.layout.Box: snacks.layout.Win,{} ---@field box "horizontal" | "vertical" ---@field id? number ---@field [number] snacks.layout.Win | snacks.layout.Box children < >lua ---@alias snacks.layout.Widget snacks.layout.Win | snacks.layout.Box < ============================================================================== 4. Module *snacks.nvim-layout-module* >lua ---@class snacks.layout ---@field opts snacks.layout.Config ---@field root snacks.win ---@field wins table ---@field box_wins snacks.win[] ---@field win_opts table ---@field closed? boolean ---@field split? boolean ---@field screenpos number[]? Snacks.layout = {} < `Snacks.layout.new()` *Snacks.layout.new()* >lua ---@param opts snacks.layout.Config Snacks.layout.new(opts) < LAYOUT:CLOSE() *snacks.nvim-layout-module-layout:close()* Close the layout >lua ---@param opts? {wins?: boolean} layout:close(opts) < LAYOUT:EACH() *snacks.nvim-layout-module-layout:each()* >lua ---@param cb fun(widget: snacks.layout.Widget, parent?: snacks.layout.Box) ---@param opts? {wins?:boolean, boxes?:boolean, box?:snacks.layout.Box} layout:each(cb, opts) < LAYOUT:HIDE() *snacks.nvim-layout-module-layout:hide()* >lua layout:hide() < LAYOUT:IS_ENABLED() *snacks.nvim-layout-module-layout:is_enabled()* Check if the window has been used in the layout >lua ---@param w string layout:is_enabled(w) < LAYOUT:IS_HIDDEN() *snacks.nvim-layout-module-layout:is_hidden()* Check if a window is hidden >lua ---@param win string layout:is_hidden(win) < LAYOUT:MAXIMIZE() *snacks.nvim-layout-module-layout:maximize()* Toggle fullscreen >lua layout:maximize() < LAYOUT:NEEDS_LAYOUT() *snacks.nvim-layout-module-layout:needs_layout()* >lua ---@param win string layout:needs_layout(win) < LAYOUT:SHOW() *snacks.nvim-layout-module-layout:show()* Show the layout >lua layout:show() < LAYOUT:TOGGLE() *snacks.nvim-layout-module-layout:toggle()* Toggle a window >lua ---@param win string ---@param enable? boolean ---@param on_update? fun(enabled: boolean) called when the layout will be updated layout:toggle(win, enable, on_update) < LAYOUT:UNHIDE() *snacks.nvim-layout-module-layout:unhide()* >lua layout:unhide() < LAYOUT:VALID() *snacks.nvim-layout-module-layout:valid()* Check if layout is valid (visible) >lua layout:valid() < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-lazygit.txt ================================================ *snacks-lazygit* snacks lazygit docs ============================================================================== Table of Contents *snacks.nvim-lazygit-table-of-contents* 1. Setup |snacks.nvim-lazygit-setup| 2. Config |snacks.nvim-lazygit-config| 3. Styles |snacks.nvim-lazygit-styles| - lazygit |snacks.nvim-lazygit-styles-lazygit| 4. Types |snacks.nvim-lazygit-types| 5. Module |snacks.nvim-lazygit-module| - Snacks.lazygit() |snacks.nvim-lazygit-module-snacks.lazygit()| - Snacks.lazygit.log() |snacks.nvim-lazygit-module-snacks.lazygit.log()| - Snacks.lazygit.log_file()|snacks.nvim-lazygit-module-snacks.lazygit.log_file()| - Snacks.lazygit.open() |snacks.nvim-lazygit-module-snacks.lazygit.open()| 6. Links |snacks.nvim-lazygit-links| Automatically configures lazygit with a theme generated based on your Neovim colorscheme and integrate edit with the current neovim instance. ============================================================================== 1. Setup *snacks.nvim-lazygit-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { lazygit = { -- your lazygit configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Config *snacks.nvim-lazygit-config* >lua ---@class snacks.lazygit.Config: snacks.terminal.Opts ---@field args? string[] ---@field theme? snacks.lazygit.Theme { -- automatically configure lazygit to use the current colorscheme -- and integrate edit with the current neovim instance configure = true, -- extra configuration for lazygit that will be merged with the default -- snacks does NOT have a full yaml parser, so if you need `"test"` to appear with the quotes -- you need to double quote it: `"\"test\""` config = { os = { editPreset = "nvim-remote" }, gui = { -- set to an empty string "" to disable icons nerdFontsVersion = "3", }, }, theme_path = svim.fs.normalize(vim.fn.stdpath("cache") .. "/lazygit-theme.yml"), -- Theme for lazygit theme = { [241] = { fg = "Special" }, activeBorderColor = { fg = "MatchParen", bold = true }, cherryPickedCommitBgColor = { fg = "Identifier" }, cherryPickedCommitFgColor = { fg = "Function" }, defaultFgColor = { fg = "Normal" }, inactiveBorderColor = { fg = "FloatBorder" }, optionsTextColor = { fg = "Function" }, searchingActiveBorderColor = { fg = "MatchParen", bold = true }, selectedLineBgColor = { bg = "Visual" }, -- set to `default` to have no background colour unstagedChangesColor = { fg = "DiagnosticError" }, }, win = { style = "lazygit", }, } < ============================================================================== 3. Styles *snacks.nvim-lazygit-styles* Check the styles docs for more information on how to customize these styles LAZYGIT *snacks.nvim-lazygit-styles-lazygit* >lua {} < ============================================================================== 4. Types *snacks.nvim-lazygit-types* >lua ---@alias snacks.lazygit.Color {fg?:string, bg?:string, bold?:boolean} < >lua ---@class snacks.lazygit.Theme: table ---@field activeBorderColor snacks.lazygit.Color ---@field cherryPickedCommitBgColor snacks.lazygit.Color ---@field cherryPickedCommitFgColor snacks.lazygit.Color ---@field defaultFgColor snacks.lazygit.Color ---@field inactiveBorderColor snacks.lazygit.Color ---@field optionsTextColor snacks.lazygit.Color ---@field searchingActiveBorderColor snacks.lazygit.Color ---@field selectedLineBgColor snacks.lazygit.Color ---@field unstagedChangesColor snacks.lazygit.Color < ============================================================================== 5. Module *snacks.nvim-lazygit-module* `Snacks.lazygit()` *Snacks.lazygit()* >lua ---@type fun(opts?: snacks.lazygit.Config): snacks.win Snacks.lazygit() < `Snacks.lazygit.log()` *Snacks.lazygit.log()* Opens lazygit with the log view >lua ---@param opts? snacks.lazygit.Config Snacks.lazygit.log(opts) < `Snacks.lazygit.log_file()` *Snacks.lazygit.log_file()* Opens lazygit with the log of the current file >lua ---@param opts? snacks.lazygit.Config|{} Snacks.lazygit.log_file(opts) < `Snacks.lazygit.open()` *Snacks.lazygit.open()* Opens lazygit, properly configured to use the current colorscheme and integrate with the current neovim instance >lua ---@param opts? snacks.lazygit.Config Snacks.lazygit.open(opts) < ============================================================================== 6. Links *snacks.nvim-lazygit-links* 1. *image*: https://github.com/user-attachments/assets/5e5ca232-af65-4ebc-b0ca-02bc9c33d23d Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-meta.txt ================================================ *snacks-meta* snacks meta docs ============================================================================== Table of Contents *snacks.nvim-meta-table-of-contents* 1. Types |snacks.nvim-meta-types| 2. Module |snacks.nvim-meta-module| - Snacks.meta.file() |snacks.nvim-meta-module-snacks.meta.file()| - Snacks.meta.get() |snacks.nvim-meta-module-snacks.meta.get()| Meta functions for Snacks ============================================================================== 1. Types *snacks.nvim-meta-types* >lua ---@class snacks.meta.Meta ---@field desc string ---@field needs_setup? boolean ---@field hide? boolean ---@field readme? boolean ---@field docs? boolean ---@field health? boolean ---@field types? boolean ---@field config? boolean ---@field merge? { [string|number]: string } < >lua ---@class snacks.meta.Plugin ---@field name string ---@field file string ---@field meta snacks.meta.Meta ---@field health? fun() < ============================================================================== 2. Module *snacks.nvim-meta-module* `Snacks.meta.file()` *Snacks.meta.file()* >lua Snacks.meta.file(name) < `Snacks.meta.get()` *Snacks.meta.get()* Get the metadata for all snacks plugins >lua ---@return snacks.meta.Plugin[] Snacks.meta.get() < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-notifier.txt ================================================ *snacks-notifier* snacks notifier docs ============================================================================== Table of Contents *snacks.nvim-notifier-table-of-contents* 1. Notification History |snacks.nvim-notifier-notification-history| 2. Examples |snacks.nvim-notifier-examples| 3. Setup |snacks.nvim-notifier-setup| 4. Config |snacks.nvim-notifier-config| 5. Styles |snacks.nvim-notifier-styles| - notification |snacks.nvim-notifier-styles-notification| - notification_history |snacks.nvim-notifier-styles-notification_history| 6. Types |snacks.nvim-notifier-types| - Notifications |snacks.nvim-notifier-types-notifications| - Rendering |snacks.nvim-notifier-types-rendering| - History |snacks.nvim-notifier-types-history| 7. Module |snacks.nvim-notifier-module| - Snacks.notifier() |snacks.nvim-notifier-module-snacks.notifier()| - Snacks.notifier.get_history()|snacks.nvim-notifier-module-snacks.notifier.get_history()| - Snacks.notifier.hide()|snacks.nvim-notifier-module-snacks.notifier.hide()| - Snacks.notifier.notify()|snacks.nvim-notifier-module-snacks.notifier.notify()| - Snacks.notifier.show_history()|snacks.nvim-notifier-module-snacks.notifier.show_history()| 8. Links |snacks.nvim-notifier-links| ============================================================================== 1. Notification History *snacks.nvim-notifier-notification-history* ============================================================================== 2. Examples *snacks.nvim-notifier-examples* Replace a notification ~ >lua -- to replace an existing notification just use the same id. -- you can also use the return value of the notify function as id. for i = 1, 10 do vim.defer_fn(function() vim.notify("Hello " .. i, "info", { id = "test" }) end, i * 500) end < Simple LSP Progress ~ >lua vim.api.nvim_create_autocmd("LspProgress", { ---@param ev {data: {client_id: integer, params: lsp.ProgressParams}} callback = function(ev) local spinner = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" } vim.notify(vim.lsp.status(), "info", { id = "lsp_progress", title = "LSP Progress", opts = function(notif) notif.icon = ev.data.params.value.kind == "end" and " " or spinner[math.floor(vim.uv.hrtime() / (1e6 * 80)) % #spinner + 1] end, }) end, }) < Advanced LSP Progress ~ >lua ---@type table local progress = vim.defaulttable() vim.api.nvim_create_autocmd("LspProgress", { ---@param ev {data: {client_id: integer, params: lsp.ProgressParams}} callback = function(ev) local client = vim.lsp.get_client_by_id(ev.data.client_id) local value = ev.data.params.value --[[@as {percentage?: number, title?: string, message?: string, kind: "begin" | "report" | "end"}]] if not client or type(value) ~= "table" then return end local p = progress[client.id] for i = 1, #p + 1 do if i == #p + 1 or p[i].token == ev.data.params.token then p[i] = { token = ev.data.params.token, msg = ("[%3d%%] %s%s"):format( value.kind == "end" and 100 or value.percentage or 100, value.title or "", value.message and (" **%s**"):format(value.message) or "" ), done = value.kind == "end", } break end end local msg = {} ---@type string[] progress[client.id] = vim.tbl_filter(function(v) return table.insert(msg, v.msg) or not v.done end, p) local spinner = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" } vim.notify(table.concat(msg, "\n"), "info", { id = "lsp_progress", title = client.name, opts = function(notif) notif.icon = #progress[client.id] == 0 and " " or spinner[math.floor(vim.uv.hrtime() / (1e6 * 80)) % #spinner + 1] end, }) end, }) < ============================================================================== 3. Setup *snacks.nvim-notifier-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { notifier = { -- your notifier configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 4. Config *snacks.nvim-notifier-config* >lua ---@class snacks.notifier.Config ---@field enabled? boolean ---@field keep? fun(notif: snacks.notifier.Notif): boolean # global keep function ---@field filter? fun(notif: snacks.notifier.Notif): boolean # filter our unwanted notifications (return false to hide) { timeout = 3000, -- default timeout in ms width = { min = 40, max = 0.4 }, height = { min = 1, max = 0.6 }, -- editor margin to keep free. tabline and statusline are taken into account automatically margin = { top = 0, right = 1, bottom = 0 }, padding = true, -- add 1 cell of left/right padding to the notification window gap = 0, -- gap between notifications sort = { "level", "added" }, -- sort by level and time -- minimum log level to display. TRACE is the lowest -- all notifications are stored in history level = vim.log.levels.TRACE, icons = { error = " ", warn = " ", info = " ", debug = " ", trace = " ", }, keep = function(notif) return vim.fn.getcmdpos() > 0 end, ---@type snacks.notifier.style style = "compact", top_down = true, -- place notifications from top to bottom date_format = "%R", -- time format for notifications -- format for footer when more lines are available -- `%d` is replaced with the number of lines. -- only works for styles with a border ---@type string|boolean more_format = " ↓ %d lines ", refresh = 50, -- refresh at most every 50ms } < ============================================================================== 5. Styles *snacks.nvim-notifier-styles* Check the styles docs for more information on how to customize these styles NOTIFICATION *snacks.nvim-notifier-styles-notification* >lua { border = true, zindex = 100, ft = "markdown", wo = { winblend = 5, wrap = false, conceallevel = 2, colorcolumn = "", }, bo = { filetype = "snacks_notif" }, } < NOTIFICATION_HISTORY *snacks.nvim-notifier-styles-notification_history* >lua { border = true, zindex = 100, width = 0.6, height = 0.6, minimal = false, title = " Notification History ", title_pos = "center", ft = "markdown", bo = { filetype = "snacks_notif_history", modifiable = false }, wo = { winhighlight = "Normal:SnacksNotifierHistory" }, keys = { q = "close" }, } < ============================================================================== 6. Types *snacks.nvim-notifier-types* Render styles: compact: use border for icon and title minimal: no border, only icon and message fancy: similar to the default nvim-notify style >lua ---@alias snacks.notifier.style snacks.notifier.render|"compact"|"fancy"|"minimal" < NOTIFICATIONS *snacks.nvim-notifier-types-notifications* Notification options >lua ---@class snacks.notifier.Notif.opts ---@field id? number|string ---@field msg? string ---@field level? number|snacks.notifier.level ---@field title? string ---@field icon? string ---@field timeout? number|boolean timeout in ms. Set to 0|false to keep until manually closed ---@field ft? string ---@field keep? fun(notif: snacks.notifier.Notif): boolean ---@field style? snacks.notifier.style ---@field opts? fun(notif: snacks.notifier.Notif) -- dynamic opts ---@field hl? snacks.notifier.hl -- highlight overrides ---@field history? boolean < Notification object >lua ---@class snacks.notifier.Notif: snacks.notifier.Notif.opts ---@field id number|string ---@field msg string ---@field win? snacks.win ---@field icon string ---@field level snacks.notifier.level ---@field timeout number ---@field dirty? boolean ---@field added number timestamp with nano precision ---@field updated number timestamp with nano precision ---@field shown? number timestamp with nano precision ---@field hidden? number timestamp with nano precision ---@field layout? { top?: number, width: number, height: number } < RENDERING *snacks.nvim-notifier-types-rendering* >lua ---@alias snacks.notifier.render fun(buf: number, notif: snacks.notifier.Notif, ctx: snacks.notifier.ctx) < >lua ---@class snacks.notifier.hl ---@field title string ---@field icon string ---@field border string ---@field footer string ---@field msg string < >lua ---@class snacks.notifier.ctx ---@field opts snacks.win.Config ---@field notifier snacks.notifier.Class ---@field hl snacks.notifier.hl ---@field ns number < HISTORY *snacks.nvim-notifier-types-history* >lua ---@class snacks.notifier.history ---@field filter? vim.log.levels|snacks.notifier.level|fun(notif: snacks.notifier.Notif): boolean ---@field sort? string[] # sort fields, default: {"added"} ---@field reverse? boolean < >lua ---@alias snacks.notifier.level "trace"|"debug"|"info"|"warn"|"error" < ============================================================================== 7. Module *snacks.nvim-notifier-module* `Snacks.notifier()` *Snacks.notifier()* >lua ---@type fun(msg: string, level?: snacks.notifier.level|number, opts?: snacks.notifier.Notif.opts): number|string Snacks.notifier() < `Snacks.notifier.get_history()` *Snacks.notifier.get_history()* >lua ---@param opts? snacks.notifier.history Snacks.notifier.get_history(opts) < `Snacks.notifier.hide()` *Snacks.notifier.hide()* >lua ---@param id? number|string Snacks.notifier.hide(id) < `Snacks.notifier.notify()` *Snacks.notifier.notify()* >lua ---@param msg string ---@param level? snacks.notifier.level|number ---@param opts? snacks.notifier.Notif.opts Snacks.notifier.notify(msg, level, opts) < `Snacks.notifier.show_history()` *Snacks.notifier.show_history()* >lua ---@param opts? snacks.notifier.history Snacks.notifier.show_history(opts) < ============================================================================== 8. Links *snacks.nvim-notifier-links* 1. *image*: https://github.com/user-attachments/assets/b89eb279-08fb-40b2-9330-9a77014b9389 2. *image*: https://github.com/user-attachments/assets/0dc449f4-b275-49e4-a25f-f58efcba3079 3. *image*: https://github.com/user-attachments/assets/a81b411c-150a-43ec-8def-87270c6f8dde Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-notify.txt ================================================ *snacks-notify* snacks notify docs ============================================================================== Table of Contents *snacks.nvim-notify-table-of-contents* 1. Types |snacks.nvim-notify-types| 2. Module |snacks.nvim-notify-module| - Snacks.notify() |snacks.nvim-notify-module-snacks.notify()| - Snacks.notify.error() |snacks.nvim-notify-module-snacks.notify.error()| - Snacks.notify.info() |snacks.nvim-notify-module-snacks.notify.info()| - Snacks.notify.notify() |snacks.nvim-notify-module-snacks.notify.notify()| - Snacks.notify.warn() |snacks.nvim-notify-module-snacks.notify.warn()| ============================================================================== 1. Types *snacks.nvim-notify-types* >lua ---@alias snacks.notify.Opts snacks.notifier.Notif.opts|{once?: boolean} < ============================================================================== 2. Module *snacks.nvim-notify-module* `Snacks.notify()` *Snacks.notify()* >lua ---@type fun(msg: string|string[], opts?: snacks.notify.Opts) Snacks.notify() < `Snacks.notify.error()` *Snacks.notify.error()* >lua ---@param msg string|string[] ---@param opts? snacks.notify.Opts Snacks.notify.error(msg, opts) < `Snacks.notify.info()` *Snacks.notify.info()* >lua ---@param msg string|string[] ---@param opts? snacks.notify.Opts Snacks.notify.info(msg, opts) < `Snacks.notify.notify()` *Snacks.notify.notify()* >lua ---@param msg string|string[] ---@param opts? snacks.notify.Opts Snacks.notify.notify(msg, opts) < `Snacks.notify.warn()` *Snacks.notify.warn()* >lua ---@param msg string|string[] ---@param opts? snacks.notify.Opts Snacks.notify.warn(msg, opts) < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-picker.txt ================================================ *snacks-picker* snacks picker docs ============================================================================== Table of Contents *snacks.nvim-picker-table-of-contents* 1. Features |snacks.nvim-picker-features| 2. Usage |snacks.nvim-picker-usage| 3. Setup |snacks.nvim-picker-setup| 4. Config |snacks.nvim-picker-config| 5. Examples |snacks.nvim-picker-examples| - flash |snacks.nvim-picker-examples-flash| - general |snacks.nvim-picker-examples-general| - todo_comments |snacks.nvim-picker-examples-todo_comments| - trouble |snacks.nvim-picker-examples-trouble| 6. Types |snacks.nvim-picker-types| 7. Module |snacks.nvim-picker-module| - Snacks.picker() |snacks.nvim-picker-module-snacks.picker()| - Snacks.picker.get() |snacks.nvim-picker-module-snacks.picker.get()| - Snacks.picker.pick() |snacks.nvim-picker-module-snacks.picker.pick()| - Snacks.picker.resume() |snacks.nvim-picker-module-snacks.picker.resume()| - Snacks.picker.select() |snacks.nvim-picker-module-snacks.picker.select()| 8. Sources |snacks.nvim-picker-sources| - autocmds |snacks.nvim-picker-sources-autocmds| - buffers |snacks.nvim-picker-sources-buffers| - cliphist |snacks.nvim-picker-sources-cliphist| - colorschemes |snacks.nvim-picker-sources-colorschemes| - command_history |snacks.nvim-picker-sources-command_history| - commands |snacks.nvim-picker-sources-commands| - diagnostics |snacks.nvim-picker-sources-diagnostics| - diagnostics_buffer |snacks.nvim-picker-sources-diagnostics_buffer| - explorer |snacks.nvim-picker-sources-explorer| - files |snacks.nvim-picker-sources-files| - gh_actions |snacks.nvim-picker-sources-gh_actions| - gh_diff |snacks.nvim-picker-sources-gh_diff| - gh_issue |snacks.nvim-picker-sources-gh_issue| - gh_labels |snacks.nvim-picker-sources-gh_labels| - gh_pr |snacks.nvim-picker-sources-gh_pr| - gh_reactions |snacks.nvim-picker-sources-gh_reactions| - git_branches |snacks.nvim-picker-sources-git_branches| - git_diff |snacks.nvim-picker-sources-git_diff| - git_files |snacks.nvim-picker-sources-git_files| - git_grep |snacks.nvim-picker-sources-git_grep| - git_log |snacks.nvim-picker-sources-git_log| - git_log_file |snacks.nvim-picker-sources-git_log_file| - git_log_line |snacks.nvim-picker-sources-git_log_line| - git_stash |snacks.nvim-picker-sources-git_stash| - git_status |snacks.nvim-picker-sources-git_status| - grep |snacks.nvim-picker-sources-grep| - grep_buffers |snacks.nvim-picker-sources-grep_buffers| - grep_word |snacks.nvim-picker-sources-grep_word| - help |snacks.nvim-picker-sources-help| - highlights |snacks.nvim-picker-sources-highlights| - icons |snacks.nvim-picker-sources-icons| - jumps |snacks.nvim-picker-sources-jumps| - keymaps |snacks.nvim-picker-sources-keymaps| - lazy |snacks.nvim-picker-sources-lazy| - lines |snacks.nvim-picker-sources-lines| - loclist |snacks.nvim-picker-sources-loclist| - lsp_config |snacks.nvim-picker-sources-lsp_config| - lsp_declarations |snacks.nvim-picker-sources-lsp_declarations| - lsp_definitions |snacks.nvim-picker-sources-lsp_definitions| - lsp_implementations |snacks.nvim-picker-sources-lsp_implementations| - lsp_incoming_calls |snacks.nvim-picker-sources-lsp_incoming_calls| - lsp_outgoing_calls |snacks.nvim-picker-sources-lsp_outgoing_calls| - lsp_references |snacks.nvim-picker-sources-lsp_references| - lsp_symbols |snacks.nvim-picker-sources-lsp_symbols| - lsp_type_definitions |snacks.nvim-picker-sources-lsp_type_definitions| - lsp_workspace_symbols |snacks.nvim-picker-sources-lsp_workspace_symbols| - man |snacks.nvim-picker-sources-man| - marks |snacks.nvim-picker-sources-marks| - notifications |snacks.nvim-picker-sources-notifications| - picker_actions |snacks.nvim-picker-sources-picker_actions| - picker_format |snacks.nvim-picker-sources-picker_format| - picker_layouts |snacks.nvim-picker-sources-picker_layouts| - picker_preview |snacks.nvim-picker-sources-picker_preview| - pickers |snacks.nvim-picker-sources-pickers| - projects |snacks.nvim-picker-sources-projects| - qflist |snacks.nvim-picker-sources-qflist| - recent |snacks.nvim-picker-sources-recent| - registers |snacks.nvim-picker-sources-registers| - resume |snacks.nvim-picker-sources-resume| - scratch |snacks.nvim-picker-sources-scratch| - search_history |snacks.nvim-picker-sources-search_history| - select |snacks.nvim-picker-sources-select| - smart |snacks.nvim-picker-sources-smart| - spelling |snacks.nvim-picker-sources-spelling| - tags |snacks.nvim-picker-sources-tags| - treesitter |snacks.nvim-picker-sources-treesitter| - undo |snacks.nvim-picker-sources-undo| - zoxide |snacks.nvim-picker-sources-zoxide| 9. Layouts |snacks.nvim-picker-layouts| - bottom |snacks.nvim-picker-layouts-bottom| - default |snacks.nvim-picker-layouts-default| - dropdown |snacks.nvim-picker-layouts-dropdown| - ivy |snacks.nvim-picker-layouts-ivy| - ivy_split |snacks.nvim-picker-layouts-ivy_split| - left |snacks.nvim-picker-layouts-left| - right |snacks.nvim-picker-layouts-right| - select |snacks.nvim-picker-layouts-select| - sidebar |snacks.nvim-picker-layouts-sidebar| - telescope |snacks.nvim-picker-layouts-telescope| - top |snacks.nvim-picker-layouts-top| - vertical |snacks.nvim-picker-layouts-vertical| - vscode |snacks.nvim-picker-layouts-vscode| 10. snacks.picker.actions |snacks.nvim-picker-snacks.picker.actions| - Snacks.picker.actions.bufdelete()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.bufdelete()| - Snacks.picker.actions.cancel()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.cancel()| - Snacks.picker.actions.cd()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.cd()| - Snacks.picker.actions.close()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.close()| - Snacks.picker.actions.cmd()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.cmd()| - Snacks.picker.actions.cycle_win()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.cycle_win()| - Snacks.picker.actions.focus_input()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.focus_input()| - Snacks.picker.actions.focus_list()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.focus_list()| - Snacks.picker.actions.focus_preview()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.focus_preview()| - Snacks.picker.actions.git_branch_add()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.git_branch_add()| - Snacks.picker.actions.git_branch_del()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.git_branch_del()| - Snacks.picker.actions.git_checkout()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.git_checkout()| - Snacks.picker.actions.git_restore()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.git_restore()| - Snacks.picker.actions.git_stage()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.git_stage()| - Snacks.picker.actions.git_stash_apply()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.git_stash_apply()| - Snacks.picker.actions.help()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.help()| - Snacks.picker.actions.history_back()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.history_back()| - Snacks.picker.actions.history_forward()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.history_forward()| - Snacks.picker.actions.insert()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.insert()| - Snacks.picker.actions.inspect()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.inspect()| - Snacks.picker.actions.item_action()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.item_action()| - Snacks.picker.actions.jump()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.jump()| - Snacks.picker.actions.layout()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.layout()| - Snacks.picker.actions.lcd()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.lcd()| - Snacks.picker.actions.list_bottom()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_bottom()| - Snacks.picker.actions.list_down()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_down()| - Snacks.picker.actions.list_scroll_bottom()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_bottom()| - Snacks.picker.actions.list_scroll_center()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_center()| - Snacks.picker.actions.list_scroll_down()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_down()| - Snacks.picker.actions.list_scroll_top()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_top()| - Snacks.picker.actions.list_scroll_up()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_up()| - Snacks.picker.actions.list_top()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_top()| - Snacks.picker.actions.list_up()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_up()| - Snacks.picker.actions.load_session()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.load_session()| - Snacks.picker.actions.loclist()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.loclist()| - Snacks.picker.actions.mark_delete()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.mark_delete()| - Snacks.picker.actions.paste()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.paste()| - Snacks.picker.actions.pick_win()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.pick_win()| - Snacks.picker.actions.picker()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.picker()| - Snacks.picker.actions.picker_grep()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.picker_grep()| - Snacks.picker.actions.preview_scroll_down()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.preview_scroll_down()| - Snacks.picker.actions.preview_scroll_left()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.preview_scroll_left()| - Snacks.picker.actions.preview_scroll_right()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.preview_scroll_right()| - Snacks.picker.actions.preview_scroll_up()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.preview_scroll_up()| - Snacks.picker.actions.print_cwd()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.print_cwd()| - Snacks.picker.actions.print_dir()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.print_dir()| - Snacks.picker.actions.print_path()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.print_path()| - Snacks.picker.actions.qflist()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.qflist()| - Snacks.picker.actions.qflist_all()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.qflist_all()| - Snacks.picker.actions.search()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.search()| - Snacks.picker.actions.select_all()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.select_all()| - Snacks.picker.actions.select_and_next()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.select_and_next()| - Snacks.picker.actions.select_and_prev()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.select_and_prev()| - Snacks.picker.actions.tcd()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.tcd()| - Snacks.picker.actions.terminal()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.terminal()| - Snacks.picker.actions.toggle_focus()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.toggle_focus()| - Snacks.picker.actions.toggle_help_input()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.toggle_help_input()| - Snacks.picker.actions.toggle_help_list()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.toggle_help_list()| - Snacks.picker.actions.toggle_input()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.toggle_input()| - Snacks.picker.actions.toggle_live()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.toggle_live()| - Snacks.picker.actions.toggle_maximize()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.toggle_maximize()| - Snacks.picker.actions.toggle_preview()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.toggle_preview()| - Snacks.picker.actions.yank()|snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.yank()| 11. snacks.picker.core.picker |snacks.nvim-picker-snacks.picker.core.picker| - Snacks.picker.picker.get()|snacks.nvim-picker-snacks.picker.core.picker-snacks.picker.picker.get()| - picker:action()|snacks.nvim-picker-snacks.picker.core.picker-picker:action()| - picker:close()|snacks.nvim-picker-snacks.picker.core.picker-picker:close()| - picker:count()|snacks.nvim-picker-snacks.picker.core.picker-picker:count()| - picker:current()|snacks.nvim-picker-snacks.picker.core.picker-picker:current()| - picker:current_win()|snacks.nvim-picker-snacks.picker.core.picker-picker:current_win()| - picker:cwd() |snacks.nvim-picker-snacks.picker.core.picker-picker:cwd()| - picker:dir() |snacks.nvim-picker-snacks.picker.core.picker-picker:dir()| - picker:empty()|snacks.nvim-picker-snacks.picker.core.picker-picker:empty()| - picker:filter()|snacks.nvim-picker-snacks.picker.core.picker-picker:filter()| - picker:find() |snacks.nvim-picker-snacks.picker.core.picker-picker:find()| - picker:focus()|snacks.nvim-picker-snacks.picker.core.picker-picker:focus()| - picker:hist() |snacks.nvim-picker-snacks.picker.core.picker-picker:hist()| - picker:is_active()|snacks.nvim-picker-snacks.picker.core.picker-picker:is_active()| - picker:is_focused()|snacks.nvim-picker-snacks.picker.core.picker-picker:is_focused()| - picker:items()|snacks.nvim-picker-snacks.picker.core.picker-picker:items()| - picker:iter() |snacks.nvim-picker-snacks.picker.core.picker-picker:iter()| - picker:norm() |snacks.nvim-picker-snacks.picker.core.picker-picker:norm()| - picker:on_current_tab()|snacks.nvim-picker-snacks.picker.core.picker-picker:on_current_tab()| - picker:ref() |snacks.nvim-picker-snacks.picker.core.picker-picker:ref()| - picker:refresh()|snacks.nvim-picker-snacks.picker.core.picker-picker:refresh()| - picker:resolve()|snacks.nvim-picker-snacks.picker.core.picker-picker:resolve()| - picker:selected()|snacks.nvim-picker-snacks.picker.core.picker-picker:selected()| - picker:set_cwd()|snacks.nvim-picker-snacks.picker.core.picker-picker:set_cwd()| - picker:set_layout()|snacks.nvim-picker-snacks.picker.core.picker-picker:set_layout()| - picker:show_preview()|snacks.nvim-picker-snacks.picker.core.picker-picker:show_preview()| - picker:toggle()|snacks.nvim-picker-snacks.picker.core.picker-picker:toggle()| - picker:word() |snacks.nvim-picker-snacks.picker.core.picker-picker:word()| 12. Links |snacks.nvim-picker-links| Snacks now comes with a modern fuzzy-finder to navigate the Neovim universe. ============================================================================== 1. Features *snacks.nvim-picker-features* - over 40 built-in sources - Fast and powerful fuzzy matching engine that supports the fzf search syntax - additionally supports field searches like `file:lua$ 'function` - `files` and `grep` additionally support adding options like `foo -- -e=lua` - uses **treesitter** highlighting where it makes sense - Sane default settings so you can start using it right away - Finders and matchers run asynchronously for maximum performance - Different layouts to suit your needs, or create your own. Uses Snacks.layout under the hood. - Simple API to create your own pickers - Better `vim.ui.select` Some acknowledgements: - fzf-lua - telescope.nvim - mini.pick ============================================================================== 2. Usage *snacks.nvim-picker-usage* The best way to get started is to copy some of the example configs below. >lua -- Show all pickers Snacks.picker() -- run files picker (all three are equivalent) Snacks.picker.files(opts) Snacks.picker.pick("files", opts) Snacks.picker.pick({source = "files", ...}) < ============================================================================== 3. Setup *snacks.nvim-picker-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { picker = { -- your picker configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 4. Config *snacks.nvim-picker-config* >lua ---@class snacks.picker.Config ---@field multi? (string|snacks.picker.Config)[] ---@field source? string source name and config to use ---@field pattern? string|fun(picker:snacks.Picker):string pattern used to filter items by the matcher ---@field search? string|fun(picker:snacks.Picker):string search string used by finders ---@field cwd? string current working directory ---@field live? boolean when true, typing will trigger live searches ---@field limit? number when set, the finder will stop after finding this number of items. useful for live searches ---@field limit_live? number when set, the finder will stop after finding this number of items during live searches. useful for performance ---@field ui_select? boolean set `vim.ui.select` to a snacks picker ---@field filter? snacks.picker.filter.Config generic filter used by some finders --- Source definition ---@field items? snacks.picker.finder.Item[] items to show instead of using a finder ---@field format? string|snacks.picker.format|string format function or preset ---@field finder? string|snacks.picker.finder|snacks.picker.finder.multi finder function or preset ---@field preview? snacks.picker.preview|string preview function or preset ---@field matcher? snacks.picker.matcher.Config|{} matcher config ---@field sort? snacks.picker.sort|snacks.picker.sort.Config sort function or config ---@field transform? string|snacks.picker.transform transform/filter function --- UI ---@field win? snacks.picker.win.Config ---@field layout? snacks.picker.layout.Config|string|{}|fun(source:string):(snacks.picker.layout.Config|string) ---@field icons? snacks.picker.icons ---@field prompt? string prompt text / icon ---@field title? string defaults to a capitalized source name ---@field auto_close? boolean automatically close the picker when focusing another window (defaults to true) ---@field show_empty? boolean show the picker even when there are no items ---@field show_delay? number delay (in ms) to wait before showing the picker while no results yet ---@field focus? "input"|"list" where to focus when the picker is opened (defaults to "input") ---@field enter? boolean enter the picker when opening it ---@field toggles? table --- Preset options ---@field previewers? snacks.picker.previewers.Config|{} ---@field formatters? snacks.picker.formatters.Config|{} ---@field sources? snacks.picker.sources.Config|{}|table ---@field layouts? table --- Actions ---@field actions? table actions used by keymaps ---@field confirm? snacks.picker.Action.spec shortcut for confirm action ---@field auto_confirm? boolean automatically confirm if there is only one item ---@field main? snacks.picker.main.Config main editor window config ---@field on_change? fun(picker:snacks.Picker, item?:snacks.picker.Item) called when the cursor changes ---@field on_show? fun(picker:snacks.Picker) called when the picker is shown ---@field on_close? fun(picker:snacks.Picker) called when the picker is closed ---@field jump? snacks.picker.jump.Config|{} --- Other ---@field config? fun(opts:snacks.picker.Config):snacks.picker.Config? custom config function ---@field db? snacks.picker.db.Config|{} ---@field debug? snacks.picker.debug|{} { prompt = " ", sources = {}, focus = "input", show_delay = 5000, limit_live = 10000, layout = { cycle = true, --- Use the default layout or vertical if the window is too narrow preset = function() return vim.o.columns >= 120 and "default" or "vertical" end, }, ---@class snacks.picker.matcher.Config matcher = { fuzzy = true, -- use fuzzy matching smartcase = true, -- use smartcase ignorecase = true, -- use ignorecase sort_empty = false, -- sort results when the search string is empty filename_bonus = true, -- give bonus for matching file names (last part of the path) file_pos = true, -- support patterns like `file:line:col` and `file:line` -- the bonusses below, possibly require string concatenation and path normalization, -- so this can have a performance impact for large lists and increase memory usage cwd_bonus = false, -- give bonus for matching files in the cwd frecency = false, -- frecency bonus history_bonus = false, -- give more weight to chronological order }, sort = { -- default sort is by score, text length and index fields = { "score:desc", "#text", "idx" }, }, ui_select = true, -- replace `vim.ui.select` with the snacks picker ---@class snacks.picker.formatters.Config formatters = { text = { ft = nil, ---@type string? filetype for highlighting }, file = { filename_first = false, -- display filename before the file path --- * left: truncate the beginning of the path --- * center: truncate the middle of the path --- * right: truncate the end of the path ---@type "left"|"center"|"right" truncate = "center", min_width = 40, -- minimum length of the truncated path filename_only = false, -- only show the filename icon_width = 2, -- width of the icon (in characters) git_status_hl = true, -- use the git status highlight group for the filename }, selected = { show_always = false, -- only show the selected column when there are multiple selections unselected = true, -- use the unselected icon for unselected items }, severity = { icons = true, -- show severity icons level = false, -- show severity level ---@type "left"|"right" pos = "left", -- position of the diagnostics }, }, ---@class snacks.picker.previewers.Config previewers = { diff = { -- fancy: Snacks fancy diff (borders, multi-column line numbers, syntax highlighting) -- syntax: Neovim's built-in diff syntax highlighting -- terminal: external command (git's pager for git commands, `cmd` for other diffs) style = "fancy", ---@type "fancy"|"syntax"|"terminal" cmd = { "delta" }, -- example for using `delta` as the external diff command ---@type vim.wo?|{} window options for the fancy diff preview window wo = { breakindent = true, wrap = true, linebreak = true, showbreak = "", }, }, git = { args = {}, -- additional arguments passed to the git command. Useful to set pager options usin `-c ...` }, file = { max_size = 1024 * 1024, -- 1MB max_line_length = 500, -- max line length ft = nil, ---@type string? filetype for highlighting. Use `nil` for auto detect }, man_pager = nil, ---@type string? MANPAGER env to use for `man` preview }, ---@class snacks.picker.jump.Config jump = { jumplist = true, -- save the current position in the jumplist tagstack = false, -- save the current position in the tagstack reuse_win = false, -- reuse an existing window if the buffer is already open close = true, -- close the picker when jumping/editing to a location (defaults to true) match = false, -- jump to the first match position. (useful for `lines`) }, toggles = { follow = "f", hidden = "h", ignored = "i", modified = "m", regex = { icon = "R", value = false }, }, win = { -- input window input = { keys = { -- to close the picker on ESC instead of going to normal mode, -- add the following keymap to your config -- [""] = { "close", mode = { "n", "i" } }, ["/"] = "toggle_focus", [""] = { "history_forward", mode = { "i", "n" } }, [""] = { "history_back", mode = { "i", "n" } }, [""] = { "cancel", mode = "i" }, [""] = { "", mode = { "i" }, expr = true, desc = "delete word" }, [""] = { "confirm", mode = { "n", "i" } }, [""] = { "list_down", mode = { "i", "n" } }, [""] = "cancel", [""] = { { "pick_win", "jump" }, mode = { "n", "i" } }, [""] = { "select_and_prev", mode = { "i", "n" } }, [""] = { "select_and_next", mode = { "i", "n" } }, [""] = { "list_up", mode = { "i", "n" } }, [""] = { "inspect", mode = { "n", "i" } }, [""] = { "toggle_follow", mode = { "i", "n" } }, [""] = { "toggle_hidden", mode = { "i", "n" } }, [""] = { "toggle_ignored", mode = { "i", "n" } }, [""] = { "toggle_regex", mode = { "i", "n" } }, [""] = { "toggle_maximize", mode = { "i", "n" } }, [""] = { "toggle_preview", mode = { "i", "n" } }, [""] = { "cycle_win", mode = { "i", "n" } }, [""] = { "select_all", mode = { "n", "i" } }, [""] = { "preview_scroll_up", mode = { "i", "n" } }, [""] = { "list_scroll_down", mode = { "i", "n" } }, [""] = { "preview_scroll_down", mode = { "i", "n" } }, [""] = { "toggle_live", mode = { "i", "n" } }, [""] = { "list_down", mode = { "i", "n" } }, [""] = { "list_up", mode = { "i", "n" } }, [""] = { "list_down", mode = { "i", "n" } }, [""] = { "list_up", mode = { "i", "n" } }, [""] = { "qflist", mode = { "i", "n" } }, [""] = { "edit_split", mode = { "i", "n" } }, [""] = { "tab", mode = { "n", "i" } }, [""] = { "list_scroll_up", mode = { "i", "n" } }, [""] = { "edit_vsplit", mode = { "i", "n" } }, ["#"] = { "insert_alt", mode = "i" }, ["%"] = { "insert_filename", mode = "i" }, [""] = { "insert_cWORD", mode = "i" }, [""] = { "insert_file", mode = "i" }, [""] = { "insert_line", mode = "i" }, [""] = { "insert_file_full", mode = "i" }, [""] = { "insert_cword", mode = "i" }, ["H"] = "layout_left", ["J"] = "layout_bottom", ["K"] = "layout_top", ["L"] = "layout_right", ["?"] = "toggle_help_input", ["G"] = "list_bottom", ["gg"] = "list_top", ["j"] = "list_down", ["k"] = "list_up", ["q"] = "cancel", }, b = { minipairs_disable = true, }, }, -- result list window list = { keys = { ["/"] = "toggle_focus", ["<2-LeftMouse>"] = "confirm", [""] = "confirm", [""] = "list_down", [""] = "cancel", [""] = { { "pick_win", "jump" } }, [""] = { "select_and_prev", mode = { "n", "x" } }, [""] = { "select_and_next", mode = { "n", "x" } }, [""] = "list_up", [""] = "inspect", [""] = "toggle_follow", [""] = "toggle_hidden", [""] = "toggle_ignored", [""] = "toggle_maximize", [""] = "toggle_preview", [""] = "cycle_win", [""] = "select_all", [""] = "preview_scroll_up", [""] = "list_scroll_down", [""] = "preview_scroll_down", [""] = "list_down", [""] = "list_up", [""] = "list_down", [""] = "list_up", [""] = "qflist", [""] = "print_path", [""] = "edit_split", [""] = "tab", [""] = "list_scroll_up", [""] = "edit_vsplit", ["H"] = "layout_left", ["J"] = "layout_bottom", ["K"] = "layout_top", ["L"] = "layout_right", ["?"] = "toggle_help_list", ["G"] = "list_bottom", ["gg"] = "list_top", ["i"] = "focus_input", ["j"] = "list_down", ["k"] = "list_up", ["q"] = "cancel", ["zb"] = "list_scroll_bottom", ["zt"] = "list_scroll_top", ["zz"] = "list_scroll_center", }, wo = { conceallevel = 2, concealcursor = "nvc", }, }, -- preview window preview = { keys = { [""] = "cancel", ["q"] = "cancel", ["i"] = "focus_input", [""] = "cycle_win", }, }, }, ---@class snacks.picker.icons icons = { files = { enabled = true, -- show file icons dir = "󰉋 ", dir_open = "󰝰 ", file = "󰈔 " }, keymaps = { nowait = "󰓅 " }, tree = { vertical = "│ ", middle = "├╴", last = "└╴", }, undo = { saved = " ", }, ui = { live = "󰐰 ", hidden = "h", ignored = "i", follow = "f", selected = "● ", unselected = "○ ", -- selected = " ", }, git = { enabled = true, -- show git icons commit = "󰜘 ", -- used by git log staged = "●", -- staged changes. always overrides the type icons added = "", deleted = "", ignored = " ", modified = "○", renamed = "", unmerged = " ", untracked = "?", }, diagnostics = { Error = " ", Warn = " ", Hint = " ", Info = " ", }, lsp = { unavailable = "", enabled = " ", disabled = " ", attached = "󰖩 " }, kinds = { Array = " ", Boolean = "󰨙 ", Class = " ", Color = " ", Control = " ", Collapsed = " ", Constant = "󰏿 ", Constructor = " ", Copilot = " ", Enum = " ", EnumMember = " ", Event = " ", Field = " ", File = " ", Folder = " ", Function = "󰊕 ", Interface = " ", Key = " ", Keyword = " ", Method = "󰊕 ", Module = " ", Namespace = "󰦮 ", Null = " ", Number = "󰎠 ", Object = " ", Operator = " ", Package = " ", Property = " ", Reference = " ", Snippet = "󱄽 ", String = " ", Struct = "󰆼 ", Text = " ", TypeParameter = " ", Unit = " ", Unknown = " ", Value = " ", Variable = "󰀫 ", }, }, ---@class snacks.picker.db.Config db = { -- path to the sqlite3 library -- If not set, it will try to load the library by name. -- On Windows it will download the library from the internet. sqlite3_path = nil, ---@type string? }, ---@class snacks.picker.debug debug = { scores = false, -- show scores in the list leaks = false, -- show when pickers don't get garbage collected explorer = false, -- show explorer debug info files = false, -- show file debug info grep = false, -- show file debug info proc = false, -- show proc debug info extmarks = false, -- show extmarks errors }, } < ============================================================================== 5. Examples *snacks.nvim-picker-examples* FLASH *snacks.nvim-picker-examples-flash* >lua { "folke/flash.nvim", optional = true, specs = { { "folke/snacks.nvim", opts = { picker = { win = { input = { keys = { [""] = { "flash", mode = { "n", "i" } }, ["s"] = { "flash" }, }, }, }, actions = { flash = function(picker) require("flash").jump({ pattern = "^", label = { after = { 0, 0 } }, search = { mode = "search", exclude = { function(win) return vim.bo[vim.api.nvim_win_get_buf(win)].filetype ~= "snacks_picker_list" end, }, }, action = function(match) local idx = picker.list:row2idx(match.pos[1]) picker.list:_move(idx, true, true) end, }) end, }, }, }, }, }, } < GENERAL *snacks.nvim-picker-examples-general* >lua { "folke/snacks.nvim", opts = { picker = {}, explorer = {}, }, keys = { -- Top Pickers & Explorer { "", function() Snacks.picker.smart() end, desc = "Smart Find Files" }, { ",", function() Snacks.picker.buffers() end, desc = "Buffers" }, { "/", function() Snacks.picker.grep() end, desc = "Grep" }, { ":", function() Snacks.picker.command_history() end, desc = "Command History" }, { "n", function() Snacks.picker.notifications() end, desc = "Notification History" }, { "e", function() Snacks.explorer() end, desc = "File Explorer" }, -- find { "fb", function() Snacks.picker.buffers() end, desc = "Buffers" }, { "fc", function() Snacks.picker.files({ cwd = vim.fn.stdpath("config") }) end, desc = "Find Config File" }, { "ff", function() Snacks.picker.files() end, desc = "Find Files" }, { "fg", function() Snacks.picker.git_files() end, desc = "Find Git Files" }, { "fp", function() Snacks.picker.projects() end, desc = "Projects" }, { "fr", function() Snacks.picker.recent() end, desc = "Recent" }, -- git { "gb", function() Snacks.picker.git_branches() end, desc = "Git Branches" }, { "gl", function() Snacks.picker.git_log() end, desc = "Git Log" }, { "gL", function() Snacks.picker.git_log_line() end, desc = "Git Log Line" }, { "gs", function() Snacks.picker.git_status() end, desc = "Git Status" }, { "gS", function() Snacks.picker.git_stash() end, desc = "Git Stash" }, { "gd", function() Snacks.picker.git_diff() end, desc = "Git Diff (Hunks)" }, { "gf", function() Snacks.picker.git_log_file() end, desc = "Git Log File" }, -- gh { "gi", function() Snacks.picker.gh_issue() end, desc = "GitHub Issues (open)" }, { "gI", function() Snacks.picker.gh_issue({ state = "all" }) end, desc = "GitHub Issues (all)" }, { "gp", function() Snacks.picker.gh_pr() end, desc = "GitHub Pull Requests (open)" }, { "gP", function() Snacks.picker.gh_pr({ state = "all" }) end, desc = "GitHub Pull Requests (all)" }, -- Grep { "sb", function() Snacks.picker.lines() end, desc = "Buffer Lines" }, { "sB", function() Snacks.picker.grep_buffers() end, desc = "Grep Open Buffers" }, { "sg", function() Snacks.picker.grep() end, desc = "Grep" }, { "sw", function() Snacks.picker.grep_word() end, desc = "Visual selection or word", mode = { "n", "x" } }, -- search { 's"', function() Snacks.picker.registers() end, desc = "Registers" }, { 's/', function() Snacks.picker.search_history() end, desc = "Search History" }, { "sa", function() Snacks.picker.autocmds() end, desc = "Autocmds" }, { "sb", function() Snacks.picker.lines() end, desc = "Buffer Lines" }, { "sc", function() Snacks.picker.command_history() end, desc = "Command History" }, { "sC", function() Snacks.picker.commands() end, desc = "Commands" }, { "sd", function() Snacks.picker.diagnostics() end, desc = "Diagnostics" }, { "sD", function() Snacks.picker.diagnostics_buffer() end, desc = "Buffer Diagnostics" }, { "sh", function() Snacks.picker.help() end, desc = "Help Pages" }, { "sH", function() Snacks.picker.highlights() end, desc = "Highlights" }, { "si", function() Snacks.picker.icons() end, desc = "Icons" }, { "sj", function() Snacks.picker.jumps() end, desc = "Jumps" }, { "sk", function() Snacks.picker.keymaps() end, desc = "Keymaps" }, { "sl", function() Snacks.picker.loclist() end, desc = "Location List" }, { "sm", function() Snacks.picker.marks() end, desc = "Marks" }, { "sM", function() Snacks.picker.man() end, desc = "Man Pages" }, { "sp", function() Snacks.picker.lazy() end, desc = "Search for Plugin Spec" }, { "sq", function() Snacks.picker.qflist() end, desc = "Quickfix List" }, { "sR", function() Snacks.picker.resume() end, desc = "Resume" }, { "su", function() Snacks.picker.undo() end, desc = "Undo History" }, { "uC", function() Snacks.picker.colorschemes() end, desc = "Colorschemes" }, -- LSP { "gd", function() Snacks.picker.lsp_definitions() end, desc = "Goto Definition" }, { "gD", function() Snacks.picker.lsp_declarations() end, desc = "Goto Declaration" }, { "gr", function() Snacks.picker.lsp_references() end, nowait = true, desc = "References" }, { "gI", function() Snacks.picker.lsp_implementations() end, desc = "Goto Implementation" }, { "gy", function() Snacks.picker.lsp_type_definitions() end, desc = "Goto T[y]pe Definition" }, { "gai", function() Snacks.picker.lsp_incoming_calls() end, desc = "C[a]lls Incoming" }, { "gao", function() Snacks.picker.lsp_outgoing_calls() end, desc = "C[a]lls Outgoing" }, { "ss", function() Snacks.picker.lsp_symbols() end, desc = "LSP Symbols" }, { "sS", function() Snacks.picker.lsp_workspace_symbols() end, desc = "LSP Workspace Symbols" }, }, } < TODO_COMMENTS *snacks.nvim-picker-examples-todo_comments* >lua { "folke/todo-comments.nvim", optional = true, keys = { { "st", function() Snacks.picker.todo_comments() end, desc = "Todo" }, { "sT", function () Snacks.picker.todo_comments({ keywords = { "TODO", "FIX", "FIXME" } }) end, desc = "Todo/Fix/Fixme" }, }, } < TROUBLE *snacks.nvim-picker-examples-trouble* >lua { "folke/trouble.nvim", optional = true, specs = { "folke/snacks.nvim", opts = function(_, opts) return vim.tbl_deep_extend("force", opts or {}, { picker = { actions = require("trouble.sources.snacks").actions, win = { input = { keys = { [""] = { "trouble_open", mode = { "n", "i" }, }, }, }, }, }, }) end, }, } < ============================================================================== 6. Types *snacks.nvim-picker-types* >lua ---@class snacks.picker.resume.Opts ---@field source? string ---@field include? string[] ---@field exclude? string[] < >lua ---@class snacks.picker.jump.Action: snacks.picker.Action ---@field cmd? snacks.picker.EditCmd < >lua ---@class snacks.picker.layout.Action: snacks.picker.Action ---@field layout? snacks.picker.layout.Config|string < >lua ---@class snacks.picker.yank.Action: snacks.picker.Action ---@field reg? string ---@field field? string ---@field notify? boolean < >lua ---@class snacks.picker.insert.Action: snacks.picker.Action ---@field expr string < >lua ---@alias snacks.picker.format.resolve fun(max_width:number):snacks.picker.Highlight[] ---@alias snacks.picker.Extmark vim.api.keyset.set_extmark|{col:number, row?:number, field?:string} ---@alias snacks.picker.Meta {[string]:any} ---@alias snacks.picker.Text {[1]:string, [2]:(string|string[])?, virtual?:boolean, field?:string, resolve?:snacks.picker.format.resolve, inline?:boolean} ---@alias snacks.picker.Highlight snacks.picker.Text|snacks.picker.Extmark|{meta?:snacks.picker.Meta} ---@alias snacks.picker.format fun(item:snacks.picker.Item, picker:snacks.Picker):snacks.picker.Highlight[] ---@alias snacks.picker.preview fun(ctx: snacks.picker.preview.ctx):boolean? ---@alias snacks.picker.sort fun(a:snacks.picker.Item, b:snacks.picker.Item):boolean ---@alias snacks.picker.transform fun(item:snacks.picker.finder.Item, ctx:snacks.picker.finder.ctx):(boolean|snacks.picker.finder.Item|nil) ---@alias snacks.picker.Pos {[1]:number, [2]:number} ---@alias snacks.picker.toggle {icon?:string, enabled?:boolean, value?:boolean} < Generic filter used by some finders to pre-filter items >lua ---@class snacks.picker.filter.Config ---@field cwd? boolean|string only show files for the given cwd ---@field buf? boolean|number only show items for the current or given buffer ---@field paths? table only show items that include or exclude the given paths ---@field filter? fun(item:snacks.picker.finder.Item, filter:snacks.picker.Filter):boolean? custom filter function ---@field transform? fun(picker:snacks.Picker, filter:snacks.picker.Filter):boolean? filter transform. Return `true` to force refresh < This is only used when using `opts.preview = "preview"`. It’s a previewer that shows a preview based on the item data. >lua ---@class snacks.picker.Item.preview ---@field text string text to show in the preview buffer ---@field ft? string optional filetype used tohighlight the preview buffer ---@field extmarks? snacks.picker.Extmark[] additional extmarks ---@field loc? boolean set to false to disable showing the item location in the preview < >lua ---@class snacks.picker.Item ---@field [string] any ---@field idx number ---@field score number ---@field frecency? number ---@field score_add? number ---@field score_mul? number ---@field source_id? number ---@field file? string ---@field text string ---@field pos? snacks.picker.Pos ---@field loc? snacks.picker.lsp.Loc ---@field end_pos? snacks.picker.Pos ---@field highlights? snacks.picker.Highlight[][] ---@field preview? snacks.picker.Item.preview ---@field resolve? fun(item:snacks.picker.Item) ---@field positions? number[] indices of matched characters in `text` < >lua ---@class snacks.picker.finder.Item: snacks.picker.Item ---@field idx? number ---@field score? number < >lua ---@class snacks.picker.layout.Config ---@field layout snacks.layout.Box ---@field reverse? boolean when true, the list will be reversed (bottom-up) ---@field fullscreen? boolean open in fullscreen ---@field cycle? boolean cycle through the list ---@field preview? "main" show preview window in the picker or the main window ---@field preset? string|fun(source:string):string ---@field hidden? ("input"|"preview"|"list")[] don't show the given windows when opening the picker. (only "input" and "preview" make sense) ---@field auto_hide? ("input"|"preview"|"list")[] hide the given windows when not focused (only "input" makes real sense) ---@field config? fun(layout:snacks.picker.layout.Config) customize the resolved layout config < >lua ---@class snacks.picker.win.Config ---@field input? snacks.win.Config|{} input window config ---@field list? snacks.win.Config|{} result list window config ---@field preview? snacks.win.Config|{} preview window config < >lua ---@alias snacks.Picker.ref (fun():snacks.Picker?)|{value?: snacks.Picker} < >lua ---@alias snacks.picker.history.Record {pattern: string, search: string, live?: boolean} < ============================================================================== 7. Module *snacks.nvim-picker-module* >lua ---@class snacks.picker ---@field actions snacks.picker.actions ---@field config snacks.picker.config ---@field format snacks.picker.formatters ---@field preview snacks.picker.previewers ---@field sort snacks.picker.sorters ---@field util snacks.picker.util ---@field current? snacks.Picker ---@field highlight snacks.picker.highlight ---@field resume fun(opts?: snacks.picker.Config):snacks.Picker ---@field sources snacks.picker.sources.Config Snacks.picker = {} < `Snacks.picker()` *Snacks.picker()* >lua ---@type fun(source: string, opts: snacks.picker.Config): snacks.Picker Snacks.picker() < >lua ---@type fun(opts: snacks.picker.Config): snacks.Picker Snacks.picker() < `Snacks.picker.get()` *Snacks.picker.get()* Get active pickers, optionally filtered by source, or the current tab >lua ---@param opts? {source?: string, tab?: boolean} tab defaults to true Snacks.picker.get(opts) < `Snacks.picker.pick()` *Snacks.picker.pick()* Create a new picker >lua ---@param source? string ---@param opts? snacks.picker.Config ---@overload fun(opts: snacks.picker.Config): snacks.Picker Snacks.picker.pick(source, opts) < `Snacks.picker.resume()` *Snacks.picker.resume()* >lua ---@param opts? snacks.picker.resume.Opts ---@overload fun(source:string):snacks.Picker? ---@return snacks.Picker? Snacks.picker.resume(opts) < `Snacks.picker.select()` *Snacks.picker.select()* Implementation for `vim.ui.select` >lua ---@type snacks.picker.ui_select Snacks.picker.select(...) < ============================================================================== 8. Sources *snacks.nvim-picker-sources* AUTOCMDS *snacks.nvim-picker-sources-autocmds* >vim :lua Snacks.picker.autocmds(opts?) < >lua { finder = "vim_autocmds", format = "autocmd", preview = "preview", } < BUFFERS *snacks.nvim-picker-sources-buffers* >vim :lua Snacks.picker.buffers(opts?) < >lua ---@class snacks.picker.buffers.Config: snacks.picker.Config ---@field hidden? boolean show hidden buffers (unlisted) ---@field unloaded? boolean show loaded buffers ---@field current? boolean show current buffer ---@field nofile? boolean show `buftype=nofile` buffers ---@field modified? boolean show only modified buffers ---@field sort_lastused? boolean sort by last used ---@field filter? snacks.picker.filter.Config { finder = "buffers", format = "buffer", hidden = false, unloaded = true, current = true, sort_lastused = true, win = { input = { keys = { [""] = { "bufdelete", mode = { "n", "i" } }, }, }, list = { keys = { ["dd"] = "bufdelete" } }, }, } < CLIPHIST *snacks.nvim-picker-sources-cliphist* >vim :lua Snacks.picker.cliphist(opts?) < >lua { finder = "system_cliphist", format = "text", preview = "preview", confirm = { "copy", "close" }, } < COLORSCHEMES *snacks.nvim-picker-sources-colorschemes* >vim :lua Snacks.picker.colorschemes(opts?) < Neovim colorschemes with live preview >lua { finder = "vim_colorschemes", format = "text", preview = "colorscheme", preset = "vertical", confirm = function(picker, item) picker:close() if item then picker.preview.state.colorscheme = nil vim.schedule(function() vim.cmd("colorscheme " .. item.text) end) end end, } < COMMAND_HISTORY *snacks.nvim-picker-sources-command_history* >vim :lua Snacks.picker.command_history(opts?) < Neovim command history >lua ---@type snacks.picker.history.Config { finder = "vim_history", name = "cmd", format = "text", preview = "none", main = { current = true }, layout = { preset = "vscode", }, confirm = "cmd", formatters = { text = { ft = "vim" } }, } < COMMANDS *snacks.nvim-picker-sources-commands* >vim :lua Snacks.picker.commands(opts?) < Neovim commands >lua { finder = "vim_commands", format = "command", preview = "preview", confirm = "cmd", } < DIAGNOSTICS *snacks.nvim-picker-sources-diagnostics* >vim :lua Snacks.picker.diagnostics(opts?) < >lua ---@class snacks.picker.diagnostics.Config: snacks.picker.Config ---@field filter? snacks.picker.filter.Config ---@field severity? vim.diagnostic.SeverityFilter { finder = "diagnostics", format = "diagnostic", sort = { fields = { "is_current", "is_cwd", "severity", "file", "lnum", }, }, matcher = { sort_empty = true }, -- only show diagnostics from the cwd by default filter = { cwd = true }, } < DIAGNOSTICS_BUFFER *snacks.nvim-picker-sources-diagnostics_buffer* >vim :lua Snacks.picker.diagnostics_buffer(opts?) < >lua ---@type snacks.picker.diagnostics.Config { finder = "diagnostics", format = "diagnostic", sort = { fields = { "severity", "file", "lnum" }, }, matcher = { sort_empty = true }, filter = { buf = true }, } < EXPLORER *snacks.nvim-picker-sources-explorer* >vim :lua Snacks.picker.explorer(opts?) < >lua ---@class snacks.picker.explorer.Config: snacks.picker.files.Config|{} ---@field follow_file? boolean follow the file from the current buffer ---@field tree? boolean show the file tree (default: true) ---@field git_status? boolean show git status (default: true) ---@field git_status_open? boolean show recursive git status for open directories ---@field git_untracked? boolean needed to show untracked git status ---@field diagnostics? boolean show diagnostics ---@field diagnostics_open? boolean show recursive diagnostics for open directories ---@field watch? boolean watch for file changes ---@field exclude? string[] exclude glob patterns ---@field include? string[] include glob patterns. These take precedence over `exclude`, `ignored` and `hidden` { finder = "explorer", sort = { fields = { "sort" } }, supports_live = true, tree = true, watch = true, diagnostics = true, diagnostics_open = false, git_status = true, git_status_open = false, git_untracked = true, follow_file = true, focus = "list", auto_close = false, jump = { close = false }, layout = { preset = "sidebar", preview = false }, -- to show the explorer to the right, add the below to -- your config under `opts.picker.sources.explorer` -- layout = { layout = { position = "right" } }, formatters = { file = { filename_only = true }, severity = { pos = "right" }, }, matcher = { sort_empty = false, fuzzy = false }, config = function(opts) return require("snacks.picker.source.explorer").setup(opts) end, win = { list = { keys = { [""] = "explorer_up", ["l"] = "confirm", ["h"] = "explorer_close", -- close directory ["a"] = "explorer_add", ["d"] = "explorer_del", ["r"] = "explorer_rename", ["c"] = "explorer_copy", ["m"] = "explorer_move", ["o"] = "explorer_open", -- open with system application ["P"] = "toggle_preview", ["y"] = { "explorer_yank", mode = { "n", "x" } }, ["p"] = "explorer_paste", ["u"] = "explorer_update", [""] = "tcd", ["/"] = "picker_grep", [""] = "terminal", ["."] = "explorer_focus", ["I"] = "toggle_ignored", ["H"] = "toggle_hidden", ["Z"] = "explorer_close_all", ["]g"] = "explorer_git_next", ["[g"] = "explorer_git_prev", ["]d"] = "explorer_diagnostic_next", ["[d"] = "explorer_diagnostic_prev", ["]w"] = "explorer_warn_next", ["[w"] = "explorer_warn_prev", ["]e"] = "explorer_error_next", ["[e"] = "explorer_error_prev", }, }, }, } < FILES *snacks.nvim-picker-sources-files* >vim :lua Snacks.picker.files(opts?) < >lua ---@class snacks.picker.files.Config: snacks.picker.proc.Config ---@field cmd? "fd"| "rg"| "find" command to use. Leave empty to auto-detect ---@field hidden? boolean show hidden files ---@field ignored? boolean show ignored files ---@field dirs? string[] directories to search ---@field follow? boolean follow symlinks ---@field exclude? string[] exclude patterns ---@field args? string[] additional arguments ---@field ft? string|string[] file extension(s) ---@field rtp? boolean search in runtimepath { finder = "files", format = "file", show_empty = true, hidden = false, ignored = false, follow = false, supports_live = true, } < GH_ACTIONS *snacks.nvim-picker-sources-gh_actions* >vim :lua Snacks.picker.gh_actions(opts?) < >lua ---@class snacks.picker.gh.actions.Config: snacks.picker.Config ---@field number number issue or PR number ---@field repo string GitHub repository (owner/repo). Defaults to current git repo ---@field type "issue" | "pr" ---@field item? snacks.picker.gh.Item { layout = { preset = "select", layout = { max_width = 50 } }, title = " Actions", main = { current = true }, finder = "gh_get_actions", format = "gh_format_action", confirm = "gh_perform_action", } < GH_DIFF *snacks.nvim-picker-sources-gh_diff* >vim :lua Snacks.picker.gh_diff(opts?) < >lua ---@class snacks.picker.gh.diff.Config: snacks.picker.Config ---@field group? boolean group changes by file (when false, show individual hunks) ---@field pr number number PR number to diff against ---@field repo? string GitHub repository (owner/repo). Defaults to current git repo { title = " Pull Request Diff", group = true, finder = "gh_diff", format = "git_status", preview = "gh_preview_diff", win = { preview = { keys = { ["a"] = { "gh_comment", mode = { "n", "x" } }, [""] = { "gh_actions", mode = { "n", "x" } }, }, }, }, } < GH_ISSUE *snacks.nvim-picker-sources-gh_issue* >vim :lua Snacks.picker.gh_issue(opts?) < >lua ---@class snacks.picker.gh.issue.Config: snacks.picker.gh.Config ---@field state "open" | "closed" | "all" ---@field mention? string filter by mention ---@field milestone? string filter by milestone { title = " Issues", finder = "gh_issue", format = "gh_format", preview = "gh_preview", sort = { fields = { "score:desc", "idx" } }, supports_live = true, live = true, confirm = "gh_actions", win = { input = { keys = { [""] = { "gh_browse", mode = { "n", "i" } }, [""] = { "gh_yank", mode = { "n", "i" } }, }, }, list = { keys = { ["y"] = { "gh_yank", mode = { "n", "x" } }, }, }, }, } < GH_LABELS *snacks.nvim-picker-sources-gh_labels* >vim :lua Snacks.picker.gh_labels(opts?) < >lua ---@class snacks.picker.gh.labels.Config: snacks.picker.Config ---@field number number issue or PR number ---@field repo string GitHub repository (owner/repo). Defaults to current git repo { layout = { preset = "select", layout = { max_width = 50 } }, title = " Labels", main = { current = true }, group = true, finder = "gh_labels", format = "gh_format_label", } < GH_PR *snacks.nvim-picker-sources-gh_pr* >vim :lua Snacks.picker.gh_pr(opts?) < >lua ---@class snacks.picker.gh.pr.Config: snacks.picker.gh.Config ---@field state "open" | "closed" | "merged" | "all" ---@field draft? boolean filter draft PRs ---@field base? string filter by base branch { title = " Pull Requests", finder = "gh_pr", format = "gh_format", preview = "gh_preview", sort = { fields = { "score:desc", "idx" } }, supports_live = true, live = true, confirm = "gh_actions", win = { input = { keys = { [""] = { "gh_browse", mode = { "n", "i" } }, [""] = { "gh_yank", mode = { "n", "i" } }, }, }, list = { keys = { ["y"] = { "gh_yank", mode = { "n", "x" } }, }, }, }, } < GH_REACTIONS *snacks.nvim-picker-sources-gh_reactions* >vim :lua Snacks.picker.gh_reactions(opts?) < >lua ---@class snacks.picker.gh.reactions.Config: snacks.picker.Config ---@field number number issue or PR number ---@field repo string GitHub repository (owner/repo). Defaults to current git repo { layout = { preset = "select", layout = { max_width = 50 } }, title = " Reactions", main = { current = true }, group = true, finder = "gh_reactions", format = "gh_format_reaction", } < GIT_BRANCHES *snacks.nvim-picker-sources-git_branches* >vim :lua Snacks.picker.git_branches(opts?) < >lua ---@class snacks.picker.git.branches.Config: snacks.picker.git.Config ---@field all? boolean show all branches, including remote { all = false, finder = "git_branches", format = "git_branch", preview = "git_log", confirm = "git_checkout", win = { input = { keys = { [""] = { "git_branch_add", mode = { "n", "i" } }, [""] = { "git_branch_del", mode = { "n", "i" } }, }, }, }, ---@param picker snacks.Picker on_show = function(picker) for i, item in ipairs(picker:items()) do if item.current then picker.list:view(i) Snacks.picker.actions.list_scroll_center(picker) break end end end, } < GIT_DIFF *snacks.nvim-picker-sources-git_diff* >vim :lua Snacks.picker.git_diff(opts?) < >lua ---@class snacks.picker.git.diff.Config: snacks.picker.git.Config ---@field group? boolean group changes by file (when false, show individual hunks) ---@field staged? boolean show staged changes ---@field base? string base commit/branch/tag to diff against (default: HEAD) { group = false, finder = "git_diff", format = "git_status", preview = "diff", matcher = { sort_empty = true }, sort = { fields = { "score:desc", "file", "idx" } }, win = { input = { keys = { [""] = { "git_stage", mode = { "n", "i" } }, [""] = { "git_restore", mode = { "n", "i" }, nowait = true }, }, }, }, } < GIT_FILES *snacks.nvim-picker-sources-git_files* >vim :lua Snacks.picker.git_files(opts?) < Find git files >lua ---@class snacks.picker.git.files.Config: snacks.picker.git.Config ---@field untracked? boolean show untracked files ---@field submodules? boolean show submodule files { finder = "git_files", show_empty = true, format = "file", untracked = false, submodules = false, } < GIT_GREP *snacks.nvim-picker-sources-git_grep* >vim :lua Snacks.picker.git_grep(opts?) < Grep in git files >lua ---@class snacks.picker.git.grep.Config: snacks.picker.git.Config ---@field untracked? boolean search in untracked files ---@field submodules? boolean search in submodule files ---@field need_search? boolean require a search pattern ---@field pathspec? string|string[] pathspec pattern(s) ---@field ignorecase? boolean ignore case { finder = "git_grep", format = "file", untracked = false, need_search = true, submodules = false, show_empty = true, supports_live = true, live = true, } < GIT_LOG *snacks.nvim-picker-sources-git_log* >vim :lua Snacks.picker.git_log(opts?) < Git log >lua ---@class snacks.picker.git.log.Config: snacks.picker.git.Config ---@field follow? boolean track file history across renames ---@field current_file? boolean show current file log ---@field current_line? boolean show current line log ---@field author? string filter commits by author { finder = "git_log", format = "git_log", preview = "git_show", confirm = "git_checkout", supports_live = true, sort = { fields = { "score:desc", "idx" } }, } < GIT_LOG_FILE *snacks.nvim-picker-sources-git_log_file* >vim :lua Snacks.picker.git_log_file(opts?) < >lua ---@type snacks.picker.git.log.Config { finder = "git_log", format = "git_log", preview = "git_show", current_file = true, follow = true, confirm = "git_checkout", sort = { fields = { "score:desc", "idx" } }, } < GIT_LOG_LINE *snacks.nvim-picker-sources-git_log_line* >vim :lua Snacks.picker.git_log_line(opts?) < >lua ---@type snacks.picker.git.log.Config { finder = "git_log", format = "git_log", preview = "git_show", current_line = true, follow = true, confirm = "git_checkout", sort = { fields = { "score:desc", "idx" } }, } < GIT_STASH *snacks.nvim-picker-sources-git_stash* >vim :lua Snacks.picker.git_stash(opts?) < >lua { finder = "git_stash", format = "git_stash", preview = "git_stash", confirm = "git_stash_apply", } < GIT_STATUS *snacks.nvim-picker-sources-git_status* >vim :lua Snacks.picker.git_status(opts?) < >lua ---@class snacks.picker.git.status.Config: snacks.picker.git.Config ---@field ignored? boolean show ignored files { finder = "git_status", format = "git_status", preview = "git_status", win = { input = { keys = { [""] = { "git_stage", mode = { "n", "i" } }, [""] = { "git_restore", mode = { "n", "i" }, nowait = true }, }, }, }, } < GREP *snacks.nvim-picker-sources-grep* >vim :lua Snacks.picker.grep(opts?) < >lua ---@class snacks.picker.grep.Config: snacks.picker.proc.Config ---@field cmd? string ---@field hidden? boolean show hidden files ---@field ignored? boolean show ignored files ---@field dirs? string[] directories to search ---@field follow? boolean follow symlinks ---@field glob? string|string[] glob file pattern(s) ---@field ft? string|string[] ripgrep file type(s). See `rg --type-list` ---@field regex? boolean use regex search pattern (defaults to `true`) ---@field buffers? boolean search in open buffers ---@field need_search? boolean require a search pattern ---@field exclude? string[] exclude patterns ---@field args? string[] additional arguments ---@field rtp? boolean search in runtimepath { finder = "grep", regex = true, format = "file", show_empty = true, live = true, -- live grep by default supports_live = true, } < GREP_BUFFERS *snacks.nvim-picker-sources-grep_buffers* >vim :lua Snacks.picker.grep_buffers(opts?) < >lua ---@type snacks.picker.grep.Config|{} { finder = "grep", format = "file", live = true, buffers = true, need_search = false, supports_live = true, } < GREP_WORD *snacks.nvim-picker-sources-grep_word* >vim :lua Snacks.picker.grep_word(opts?) < >lua ---@type snacks.picker.grep.Config|{} { finder = "grep", regex = false, args = { "--word-regexp" }, format = "file", search = function(picker) return picker:word() end, live = false, supports_live = true, } < HELP *snacks.nvim-picker-sources-help* >vim :lua Snacks.picker.help(opts?) < Neovim help tags >lua ---@class snacks.picker.help.Config: snacks.picker.Config ---@field lang? string[] defaults to `vim.opt.helplang` { finder = "help", format = "text", previewers = { file = { ft = "help" }, }, win = { preview = { minimal = true } }, confirm = "help", } < HIGHLIGHTS *snacks.nvim-picker-sources-highlights* >vim :lua Snacks.picker.highlights(opts?) < >lua { finder = "vim_highlights", format = "hl", preview = "preview", confirm = "close", } < ICONS *snacks.nvim-picker-sources-icons* >vim :lua Snacks.picker.icons(opts?) < >lua ---@class snacks.picker.icons.Config: snacks.picker.Config ---@field icon_sources? string[] list of sources to use --- Custom icon sources can be added here. The key is the source name, --- and the value is the file path or URL to load icons from. --- The file should be a JSON array of: --- `{[1]:string, [2]:string}|{icon:string, name:string, category:string}` --- The format is compatible with https://github.com/nvim-telescope/telescope-symbols.nvim ---@field custom_sources? table additional icon sources `table` { main = { current = true }, finder = "icons", format = "icon", layout = { preset = "vscode" }, confirm = "put", } < JUMPS *snacks.nvim-picker-sources-jumps* >vim :lua Snacks.picker.jumps(opts?) < >lua { finder = "vim_jumps", format = "file", main = { current = true }, } < KEYMAPS *snacks.nvim-picker-sources-keymaps* >vim :lua Snacks.picker.keymaps(opts?) < >lua ---@class snacks.picker.keymaps.Config: snacks.picker.Config ---@field global? boolean show global keymaps ---@field local? boolean show buffer keymaps ---@field plugs? boolean show plugin keymaps ---@field modes? string[] { finder = "vim_keymaps", format = "keymap", preview = "preview", global = true, plugs = false, ["local"] = true, modes = { "n", "v", "x", "s", "o", "i", "c", "t" }, ---@param picker snacks.Picker confirm = function(picker, item) picker:norm(function() if item then picker:close() vim.api.nvim_input(item.item.lhs) end end) end, actions = { toggle_global = function(picker) picker.opts.global = not picker.opts.global picker:find() end, toggle_buffer = function(picker) picker.opts["local"] = not picker.opts["local"] picker:find() end, }, win = { input = { keys = { [""] = { "toggle_global", mode = { "n", "i" }, desc = "Toggle Global Keymaps" }, [""] = { "toggle_buffer", mode = { "n", "i" }, desc = "Toggle Buffer Keymaps" }, }, }, }, } < LAZY *snacks.nvim-picker-sources-lazy* >vim :lua Snacks.picker.lazy(opts?) < Search for a lazy.nvim plugin spec >lua { finder = "lazy_spec", pattern = "'", } < LINES *snacks.nvim-picker-sources-lines* >vim :lua Snacks.picker.lines(opts?) < Search lines in the current buffer >lua ---@class snacks.picker.lines.Config: snacks.picker.Config ---@field buf? number { finder = "lines", format = "lines", layout = { preview = "main", preset = "ivy", }, jump = { match = true }, -- allow any window to be used as the main window main = { current = true }, ---@param picker snacks.Picker on_show = function(picker) local cursor = vim.api.nvim_win_get_cursor(picker.main) local info = vim.api.nvim_win_call(picker.main, vim.fn.winsaveview) picker.list:view(cursor[1], info.topline) picker:show_preview() end, sort = { fields = { "score:desc", "idx" } }, } < LOCLIST *snacks.nvim-picker-sources-loclist* >vim :lua Snacks.picker.loclist(opts?) < Loclist >lua ---@type snacks.picker.qf.Config { finder = "qf", format = "file", qf_win = 0, main = { current = true }, } < LSP_CONFIG *snacks.nvim-picker-sources-lsp_config* >vim :lua Snacks.picker.lsp_config(opts?) < >lua ---@class snacks.picker.lsp.config.Config: snacks.picker.Config ---@field installed? boolean only show installed servers ---@field configured? boolean only show configured servers (setup with lspconfig) ---@field attached? boolean|number only show attached servers. When `number`, show only servers attached to that buffer (can be 0) { finder = "lsp.config#find", format = "lsp.config#format", preview = "lsp.config#preview", confirm = "close", sort = { fields = { "score:desc", "attached_buf", "attached", "enabled", "installed", "name" } }, matcher = { sort_empty = true }, } < LSP_DECLARATIONS *snacks.nvim-picker-sources-lsp_declarations* >vim :lua Snacks.picker.lsp_declarations(opts?) < LSP declarations >lua ---@type snacks.picker.lsp.Config { finder = "lsp_declarations", format = "file", include_current = false, auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } < LSP_DEFINITIONS *snacks.nvim-picker-sources-lsp_definitions* >vim :lua Snacks.picker.lsp_definitions(opts?) < LSP definitions >lua ---@type snacks.picker.lsp.Config { finder = "lsp_definitions", format = "file", include_current = false, auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } < LSP_IMPLEMENTATIONS *snacks.nvim-picker-sources-lsp_implementations* >vim :lua Snacks.picker.lsp_implementations(opts?) < LSP implementations >lua ---@type snacks.picker.lsp.Config { finder = "lsp_implementations", format = "file", include_current = false, auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } < LSP_INCOMING_CALLS *snacks.nvim-picker-sources-lsp_incoming_calls* >vim :lua Snacks.picker.lsp_incoming_calls(opts?) < LSP incoming calls >lua ---@type snacks.picker.lsp.Config { finder = "lsp_incoming_calls", format = "lsp_symbol", include_current = false, workspace = true, -- this ensures the file is included in the formatter auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } < LSP_OUTGOING_CALLS *snacks.nvim-picker-sources-lsp_outgoing_calls* >vim :lua Snacks.picker.lsp_outgoing_calls(opts?) < LSP outgoing calls >lua ---@type snacks.picker.lsp.Config { finder = "lsp_outgoing_calls", format = "lsp_symbol", include_current = false, workspace = true, -- this ensures the file is included in the formatter auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } < LSP_REFERENCES *snacks.nvim-picker-sources-lsp_references* >vim :lua Snacks.picker.lsp_references(opts?) < LSP references >lua ---@class snacks.picker.lsp.references.Config: snacks.picker.lsp.Config ---@field include_declaration? boolean default true { finder = "lsp_references", format = "file", include_declaration = true, include_current = false, auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } < LSP_SYMBOLS *snacks.nvim-picker-sources-lsp_symbols* >vim :lua Snacks.picker.lsp_symbols(opts?) < LSP document symbols >lua ---@class snacks.picker.lsp.symbols.Config: snacks.picker.Config ---@field tree? boolean show symbol tree ---@field keep_parents? boolean keep parent symbols when filtering ---@field filter table? symbol kind filter ---@field workspace? boolean show workspace symbols { finder = "lsp_symbols", format = "lsp_symbol", tree = true, filter = { default = { "Class", "Constructor", "Enum", "Field", "Function", "Interface", "Method", "Module", "Namespace", "Package", "Property", "Struct", "Trait", }, -- set to `true` to include all symbols markdown = true, help = true, -- you can specify a different filter for each filetype lua = { "Class", "Constructor", "Enum", "Field", "Function", "Interface", "Method", "Module", "Namespace", -- "Package", -- remove package since luals uses it for control flow structures "Property", "Struct", "Trait", }, }, } < LSP_TYPE_DEFINITIONS *snacks.nvim-picker-sources-lsp_type_definitions* >vim :lua Snacks.picker.lsp_type_definitions(opts?) < LSP type definitions >lua ---@type snacks.picker.lsp.Config { finder = "lsp_type_definitions", format = "file", include_current = false, auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } < LSP_WORKSPACE_SYMBOLS *snacks.nvim-picker-sources-lsp_workspace_symbols* >vim :lua Snacks.picker.lsp_workspace_symbols(opts?) < >lua ---@type snacks.picker.lsp.symbols.Config vim.tbl_extend("force", {}, M.lsp_symbols, { workspace = true, tree = false, supports_live = true, live = true, -- live by default }) < MAN *snacks.nvim-picker-sources-man* >vim :lua Snacks.picker.man(opts?) < >lua { finder = "system_man", format = "man", preview = "man", confirm = function(picker, item, action) ---@cast action snacks.picker.jump.Action picker:close() if item then vim.schedule(function() local cmd = "Man " .. item.ref ---@type string if action.cmd == "vsplit" then cmd = "vert " .. cmd elseif action.cmd == "tab" then cmd = "tab " .. cmd end vim.cmd(cmd) end) end end, } < MARKS *snacks.nvim-picker-sources-marks* >vim :lua Snacks.picker.marks(opts?) < >lua ---@class snacks.picker.marks.Config: snacks.picker.Config ---@field global? boolean show global marks ---@field local? boolean show buffer marks { finder = "vim_marks", format = "file", global = true, ["local"] = true, win = { input = { keys = { [""] = { "mark_delete", mode = { "n", "i" } }, }, }, }, } < NOTIFICATIONS *snacks.nvim-picker-sources-notifications* >vim :lua Snacks.picker.notifications(opts?) < >lua ---@class snacks.picker.notifications.Config: snacks.picker.Config ---@field filter? snacks.notifier.level|fun(notif: snacks.notifier.Notif): boolean { finder = "snacks_notifier", format = "notification", preview = "preview", formatters = { severity = { level = true } }, confirm = "close", } < PICKER_ACTIONS *snacks.nvim-picker-sources-picker_actions* >vim :lua Snacks.picker.picker_actions(opts?) < >lua { finder = "meta_actions", format = "text", } < PICKER_FORMAT *snacks.nvim-picker-sources-picker_format* >vim :lua Snacks.picker.picker_format(opts?) < >lua { finder = "meta_format", format = "text", } < PICKER_LAYOUTS *snacks.nvim-picker-sources-picker_layouts* >vim :lua Snacks.picker.picker_layouts(opts?) < >lua { finder = "meta_layouts", format = "text", on_change = function(picker, item) vim.schedule(function() picker:set_layout(item.text) end) end, } < PICKER_PREVIEW *snacks.nvim-picker-sources-picker_preview* >vim :lua Snacks.picker.picker_preview(opts?) < >lua { finder = "meta_preview", format = "text", } < PICKERS *snacks.nvim-picker-sources-pickers* >vim :lua Snacks.picker.pickers(opts?) < List all available sources >lua { finder = "meta_pickers", format = "text", confirm = function(picker, item) picker:close() if item then vim.schedule(function() Snacks.picker(item.text) end) end end, } < PROJECTS *snacks.nvim-picker-sources-projects* >vim :lua Snacks.picker.projects(opts?) < Open recent projects >lua ---@class snacks.picker.projects.Config: snacks.picker.Config ---@field filter? snacks.picker.filter.Config ---@field dev? string|string[] top-level directories containing multiple projects (sub-folders that contains a root pattern) ---@field projects? string[] list of project directories ---@field patterns? string[] patterns to detect project root directories ---@field recent? boolean include project directories of recent files ---@field max_depth? number maximum depth to search in dev directories (default: 2) { finder = "recent_projects", format = "file", dev = { "~/dev", "~/projects" }, confirm = "load_session", patterns = { ".git", "_darcs", ".hg", ".bzr", ".svn", "package.json", "Makefile" }, recent = true, matcher = { frecency = true, -- use frecency boosting sort_empty = true, -- sort even when the filter is empty cwd_bonus = false, }, sort = { fields = { "score:desc", "idx" } }, win = { preview = { minimal = true }, input = { keys = { -- every action will always first change the cwd of the current tabpage to the project [""] = { { "tcd", "picker_explorer" }, mode = { "n", "i" } }, [""] = { { "tcd", "picker_files" }, mode = { "n", "i" } }, [""] = { { "tcd", "picker_grep" }, mode = { "n", "i" } }, [""] = { { "tcd", "picker_recent" }, mode = { "n", "i" }, nowait = true }, [""] = { { "tcd" }, mode = { "n", "i" } }, [""] = { function(picker) vim.cmd("tabnew") Snacks.notify("New tab opened") picker:close() Snacks.picker.projects() end, mode = { "n", "i" }, }, }, }, }, } < QFLIST *snacks.nvim-picker-sources-qflist* >vim :lua Snacks.picker.qflist(opts?) < Quickfix list >lua ---@type snacks.picker.qf.Config { finder = "qf", format = "file", } < RECENT *snacks.nvim-picker-sources-recent* >vim :lua Snacks.picker.recent(opts?) < Find recent files >lua ---@class snacks.picker.recent.Config: snacks.picker.Config ---@field filter? snacks.picker.filter.Config { finder = "recent_files", format = "file", filter = { paths = { [vim.fn.stdpath("data")] = false, [vim.fn.stdpath("cache")] = false, [vim.fn.stdpath("state")] = false, }, }, } < REGISTERS *snacks.nvim-picker-sources-registers* >vim :lua Snacks.picker.registers(opts?) < Neovim registers >lua { finder = "vim_registers", main = { current = true }, format = "register", preview = "preview", confirm = { "copy", "close" }, } < RESUME *snacks.nvim-picker-sources-resume* >vim :lua Snacks.picker.resume(opts?) < Special picker that resumes the last picker >lua {} < SCRATCH *snacks.nvim-picker-sources-scratch* >vim :lua Snacks.picker.scratch(opts?) < Open or create scratch buffers >lua { finder = "scratch", format = "scratch_format", confirm = "scratch_open", win = { input = { keys = { [""] = { "scratch_delete", mode = { "n", "i" } }, [""] = { "scratch_new", mode = { "n", "i" } }, }, }, }, } < SEARCH_HISTORY *snacks.nvim-picker-sources-search_history* >vim :lua Snacks.picker.search_history(opts?) < Neovim search history >lua ---@type snacks.picker.history.Config { finder = "vim_history", name = "search", format = "text", preview = "none", main = { current = true }, layout = { preset = "vscode" }, confirm = "search", formatters = { text = { ft = "regex" } }, } < SELECT *snacks.nvim-picker-sources-select* >vim :lua Snacks.picker.select(opts?) < Config used by `vim.ui.select`. Not meant to be used directly. >lua ---@class snacks.picker.select.Config: snacks.picker.Config ---@field kinds? table custom snacks picker configs for specific `vim.ui.select` kinds { items = {}, -- these are set dynamically main = { current = true }, layout = { preset = "select" }, } < SMART *snacks.nvim-picker-sources-smart* >vim :lua Snacks.picker.smart(opts?) < >lua ---@class snacks.picker.smart.Config: snacks.picker.Config ---@field finders? string[] list of finders to use ---@field filter? snacks.picker.filter.Config { multi = { "buffers", "recent", "files" }, format = "file", -- use `file` format for all sources matcher = { cwd_bonus = true, -- boost cwd matches frecency = true, -- use frecency boosting sort_empty = true, -- sort even when the filter is empty }, transform = "unique_file", } < SPELLING *snacks.nvim-picker-sources-spelling* >vim :lua Snacks.picker.spelling(opts?) < >lua { finder = "vim_spelling", format = "text", main = { current = true }, layout = { preset = "vscode" }, confirm = "item_action", } < TAGS *snacks.nvim-picker-sources-tags* >vim :lua Snacks.picker.tags(opts?) < Search tags file >lua ---@class snacks.picker.tags.Config: snacks.picker.Config { workspace = true, -- search tags in the workspace finder = "vim_tags", format = "lsp_symbol", } < TREESITTER *snacks.nvim-picker-sources-treesitter* >vim :lua Snacks.picker.treesitter(opts?) < >lua ---@class snacks.picker.treesitter.Config: snacks.picker.Config ---@field filter table? symbol kind filter ---@field tree? boolean show symbol tree { finder = "treesitter_symbols", format = "lsp_symbol", tree = true, filter = { default = { "Class", "Enum", "Field", "Function", "Method", "Module", "Namespace", "Struct", "Trait", }, -- set to `true` to include all symbols markdown = true, help = true, }, } < UNDO *snacks.nvim-picker-sources-undo* >vim :lua Snacks.picker.undo(opts?) < >lua ---@class snacks.picker.undo.Config: snacks.picker.Config ---@field diff? vim.text.diff.Opts { finder = "vim_undo", format = "undo", preview = "diff", confirm = "item_action", win = { preview = { wo = { number = false, relativenumber = false, signcolumn = "no" } }, input = { keys = { [""] = { "yank_add", mode = { "n", "i" } }, [""] = { "yank_del", mode = { "n", "i" } }, }, }, }, actions = { yank_add = { action = "yank", field = "added_lines" }, yank_del = { action = "yank", field = "removed_lines" }, }, icons = { tree = { last = "┌╴" } }, -- the tree is upside down diff = { ctxlen = 4, ignore_cr_at_eol = true, ignore_whitespace_change_at_eol = true, indent_heuristic = true, }, } < ZOXIDE *snacks.nvim-picker-sources-zoxide* >vim :lua Snacks.picker.zoxide(opts?) < Open a project from zoxide >lua { finder = "files_zoxide", format = "file", confirm = "load_session", win = { preview = { minimal = true, }, }, } < ============================================================================== 9. Layouts *snacks.nvim-picker-layouts* BOTTOM *snacks.nvim-picker-layouts-bottom* >lua { preset = "ivy", layout = { position = "bottom" } } < DEFAULT *snacks.nvim-picker-layouts-default* >lua { layout = { box = "horizontal", width = 0.8, min_width = 120, height = 0.8, { box = "vertical", border = true, title = "{title} {live} {flags}", { win = "input", height = 1, border = "bottom" }, { win = "list", border = "none" }, }, { win = "preview", title = "{preview}", border = true, width = 0.5 }, }, } < DROPDOWN *snacks.nvim-picker-layouts-dropdown* >lua { layout = { backdrop = false, row = 1, width = 0.4, min_width = 80, height = 0.8, border = "none", box = "vertical", { win = "preview", title = "{preview}", height = 0.4, border = true }, { box = "vertical", border = true, title = "{title} {live} {flags}", title_pos = "center", { win = "input", height = 1, border = "bottom" }, { win = "list", border = "none" }, }, }, } < IVY *snacks.nvim-picker-layouts-ivy* >lua { layout = { box = "vertical", backdrop = false, row = -1, width = 0, height = 0.4, border = "top", title = " {title} {live} {flags}", title_pos = "left", { win = "input", height = 1, border = "bottom" }, { box = "horizontal", { win = "list", border = "none" }, { win = "preview", title = "{preview}", width = 0.6, border = "left" }, }, }, } < IVY_SPLIT *snacks.nvim-picker-layouts-ivy_split* >lua { preview = "main", layout = { box = "vertical", backdrop = false, width = 0, height = 0.4, position = "bottom", border = "top", title = " {title} {live} {flags}", title_pos = "left", { win = "input", height = 1, border = "bottom" }, { box = "horizontal", { win = "list", border = "none" }, { win = "preview", title = "{preview}", width = 0.6, border = "left" }, }, }, } < LEFT *snacks.nvim-picker-layouts-left* >lua M.sidebar < RIGHT *snacks.nvim-picker-layouts-right* >lua { preset = "sidebar", layout = { position = "right" } } < SELECT *snacks.nvim-picker-layouts-select* >lua { hidden = { "preview" }, layout = { backdrop = false, width = 0.5, min_width = 80, max_width = 100, height = 0.4, min_height = 2, box = "vertical", border = true, title = "{title}", title_pos = "center", { win = "input", height = 1, border = "bottom" }, { win = "list", border = "none" }, { win = "preview", title = "{preview}", height = 0.4, border = "top" }, }, } < SIDEBAR *snacks.nvim-picker-layouts-sidebar* >lua { preview = "main", layout = { backdrop = false, width = 40, min_width = 40, height = 0, position = "left", border = "none", box = "vertical", { win = "input", height = 1, border = true, title = "{title} {live} {flags}", title_pos = "center", }, { win = "list", border = "none" }, { win = "preview", title = "{preview}", height = 0.4, border = "top" }, }, } < TELESCOPE *snacks.nvim-picker-layouts-telescope* >lua { reverse = true, layout = { box = "horizontal", backdrop = false, width = 0.8, height = 0.9, border = "none", { box = "vertical", { win = "list", title = " Results ", title_pos = "center", border = true }, { win = "input", height = 1, border = true, title = "{title} {live} {flags}", title_pos = "center" }, }, { win = "preview", title = "{preview:Preview}", width = 0.45, border = true, title_pos = "center", }, }, } < TOP *snacks.nvim-picker-layouts-top* >lua { preset = "ivy", layout = { position = "top" } } < VERTICAL *snacks.nvim-picker-layouts-vertical* >lua { layout = { backdrop = false, width = 0.5, min_width = 80, height = 0.8, min_height = 30, box = "vertical", border = true, title = "{title} {live} {flags}", title_pos = "center", { win = "input", height = 1, border = "bottom" }, { win = "list", border = "none" }, { win = "preview", title = "{preview}", height = 0.4, border = "top" }, }, } < VSCODE *snacks.nvim-picker-layouts-vscode* >lua { hidden = { "preview" }, layout = { backdrop = false, row = 1, width = 0.4, min_width = 80, height = 0.4, border = "none", box = "vertical", { win = "input", height = 1, border = true, title = "{title} {live} {flags}", title_pos = "center" }, { win = "list", border = "hpad" }, { win = "preview", title = "{preview}", border = true }, }, } < ============================================================================== 10. snacks.picker.actions *snacks.nvim-picker-snacks.picker.actions* >lua ---@class snacks.picker.actions ---@field [string] snacks.picker.Action.spec local M = {} < SNACKS.PICKER.ACTIONS.BUFDELETE()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.bufdelete()* >lua Snacks.picker.actions.bufdelete(picker) < SNACKS.PICKER.ACTIONS.CANCEL()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.cancel()* >lua Snacks.picker.actions.cancel(picker) < SNACKS.PICKER.ACTIONS.CD()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.cd()* >lua Snacks.picker.actions.cd(_, item) < SNACKS.PICKER.ACTIONS.CLOSE()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.close()* >lua Snacks.picker.actions.close(picker) < SNACKS.PICKER.ACTIONS.CMD()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.cmd()* >lua Snacks.picker.actions.cmd(picker, item) < SNACKS.PICKER.ACTIONS.CYCLE_WIN()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.cycle_win()* >lua Snacks.picker.actions.cycle_win(picker) < SNACKS.PICKER.ACTIONS.FOCUS_INPUT()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.focus_input()* >lua Snacks.picker.actions.focus_input(picker) < SNACKS.PICKER.ACTIONS.FOCUS_LIST()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.focus_list()* >lua Snacks.picker.actions.focus_list(picker) < SNACKS.PICKER.ACTIONS.FOCUS_PREVIEW()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.focus_preview()* >lua Snacks.picker.actions.focus_preview(picker) < SNACKS.PICKER.ACTIONS.GIT_BRANCH_ADD()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.git_branch_add()* >lua Snacks.picker.actions.git_branch_add(picker) < SNACKS.PICKER.ACTIONS.GIT_BRANCH_DEL()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.git_branch_del()* >lua Snacks.picker.actions.git_branch_del(picker, item) < SNACKS.PICKER.ACTIONS.GIT_CHECKOUT()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.git_checkout()* >lua Snacks.picker.actions.git_checkout(picker, item) < SNACKS.PICKER.ACTIONS.GIT_RESTORE()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.git_restore()* >lua Snacks.picker.actions.git_restore(picker) < SNACKS.PICKER.ACTIONS.GIT_STAGE()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.git_stage()* >lua Snacks.picker.actions.git_stage(picker) < SNACKS.PICKER.ACTIONS.GIT_STASH_APPLY()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.git_stash_apply()* >lua Snacks.picker.actions.git_stash_apply(_, item) < SNACKS.PICKER.ACTIONS.HELP()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.help()* >lua Snacks.picker.actions.help(picker, item, action) < SNACKS.PICKER.ACTIONS.HISTORY_BACK()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.history_back()* >lua Snacks.picker.actions.history_back(picker) < SNACKS.PICKER.ACTIONS.HISTORY_FORWARD()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.history_forward()* >lua Snacks.picker.actions.history_forward(picker) < SNACKS.PICKER.ACTIONS.INSERT()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.insert()* >lua Snacks.picker.actions.insert(picker, _, action) < SNACKS.PICKER.ACTIONS.INSPECT()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.inspect()* >lua Snacks.picker.actions.inspect(picker, item) < SNACKS.PICKER.ACTIONS.ITEM_ACTION()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.item_action()* >lua Snacks.picker.actions.item_action(picker, item, action) < SNACKS.PICKER.ACTIONS.JUMP()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.jump()* >lua Snacks.picker.actions.jump(picker, _, action) < SNACKS.PICKER.ACTIONS.LAYOUT()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.layout()* >lua Snacks.picker.actions.layout(picker, _, action) < SNACKS.PICKER.ACTIONS.LCD()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.lcd()* >lua Snacks.picker.actions.lcd(_, item) < SNACKS.PICKER.ACTIONS.LIST_BOTTOM()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_bottom()* >lua Snacks.picker.actions.list_bottom(picker) < SNACKS.PICKER.ACTIONS.LIST_DOWN()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_down()* >lua Snacks.picker.actions.list_down(picker) < SNACKS.PICKER.ACTIONS.LIST_SCROLL_BOTTOM()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_bottom()* >lua Snacks.picker.actions.list_scroll_bottom(picker) < SNACKS.PICKER.ACTIONS.LIST_SCROLL_CENTER()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_center()* >lua Snacks.picker.actions.list_scroll_center(picker) < SNACKS.PICKER.ACTIONS.LIST_SCROLL_DOWN()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_down()* >lua Snacks.picker.actions.list_scroll_down(picker) < SNACKS.PICKER.ACTIONS.LIST_SCROLL_TOP()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_top()* >lua Snacks.picker.actions.list_scroll_top(picker) < SNACKS.PICKER.ACTIONS.LIST_SCROLL_UP()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_up()* >lua Snacks.picker.actions.list_scroll_up(picker) < SNACKS.PICKER.ACTIONS.LIST_TOP()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_top()* >lua Snacks.picker.actions.list_top(picker) < SNACKS.PICKER.ACTIONS.LIST_UP()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.list_up()* >lua Snacks.picker.actions.list_up(picker) < SNACKS.PICKER.ACTIONS.LOAD_SESSION()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.load_session()* Tries to load the session, if it fails, it will open the picker. >lua Snacks.picker.actions.load_session(picker, item) < SNACKS.PICKER.ACTIONS.LOCLIST()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.loclist()* Send selected or all items to the location list. >lua Snacks.picker.actions.loclist(picker) < SNACKS.PICKER.ACTIONS.MARK_DELETE()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.mark_delete()* >lua Snacks.picker.actions.mark_delete(picker) < SNACKS.PICKER.ACTIONS.PASTE()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.paste()* >lua Snacks.picker.actions.paste(picker, item, action) < SNACKS.PICKER.ACTIONS.PICK_WIN()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.pick_win()* >lua Snacks.picker.actions.pick_win(picker, item, action) < SNACKS.PICKER.ACTIONS.PICKER()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.picker()* >lua Snacks.picker.actions.picker(picker, item, action) < SNACKS.PICKER.ACTIONS.PICKER_GREP()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.picker_grep()* >lua Snacks.picker.actions.picker_grep(_, item) < SNACKS.PICKER.ACTIONS.PREVIEW_SCROLL_DOWN()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.preview_scroll_down()* >lua Snacks.picker.actions.preview_scroll_down(picker) < SNACKS.PICKER.ACTIONS.PREVIEW_SCROLL_LEFT()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.preview_scroll_left()* >lua Snacks.picker.actions.preview_scroll_left(picker) < SNACKS.PICKER.ACTIONS.PREVIEW_SCROLL_RIGHT()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.preview_scroll_right()* >lua Snacks.picker.actions.preview_scroll_right(picker) < SNACKS.PICKER.ACTIONS.PREVIEW_SCROLL_UP()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.preview_scroll_up()* >lua Snacks.picker.actions.preview_scroll_up(picker) < SNACKS.PICKER.ACTIONS.PRINT_CWD()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.print_cwd()* >lua Snacks.picker.actions.print_cwd(picker) < SNACKS.PICKER.ACTIONS.PRINT_DIR()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.print_dir()* >lua Snacks.picker.actions.print_dir(picker) < SNACKS.PICKER.ACTIONS.PRINT_PATH()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.print_path()* >lua Snacks.picker.actions.print_path(picker, item) < SNACKS.PICKER.ACTIONS.QFLIST()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.qflist()* Send selected or all items to the quickfix list. >lua Snacks.picker.actions.qflist(picker) < SNACKS.PICKER.ACTIONS.QFLIST_ALL()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.qflist_all()* Send all items to the quickfix list. >lua Snacks.picker.actions.qflist_all(picker) < SNACKS.PICKER.ACTIONS.SEARCH()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.search()* >lua Snacks.picker.actions.search(picker, item) < SNACKS.PICKER.ACTIONS.SELECT_ALL()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.select_all()* Selects all items in the list. Or clears the selection if all items are selected. >lua Snacks.picker.actions.select_all(picker) < SNACKS.PICKER.ACTIONS.SELECT_AND_NEXT()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.select_and_next()* Toggles the selection of the current item, and moves the cursor to the next item. >lua Snacks.picker.actions.select_and_next(picker) < SNACKS.PICKER.ACTIONS.SELECT_AND_PREV()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.select_and_prev()* Toggles the selection of the current item, and moves the cursor to the prev item. >lua Snacks.picker.actions.select_and_prev(picker) < SNACKS.PICKER.ACTIONS.TCD()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.tcd()* >lua Snacks.picker.actions.tcd(_, item) < SNACKS.PICKER.ACTIONS.TERMINAL()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.terminal()* >lua Snacks.picker.actions.terminal(_, item) < SNACKS.PICKER.ACTIONS.TOGGLE_FOCUS()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.toggle_focus()* >lua Snacks.picker.actions.toggle_focus(picker) < SNACKS.PICKER.ACTIONS.TOGGLE_HELP_INPUT()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.toggle_help_input()* >lua Snacks.picker.actions.toggle_help_input(picker) < SNACKS.PICKER.ACTIONS.TOGGLE_HELP_LIST()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.toggle_help_list()* >lua Snacks.picker.actions.toggle_help_list(picker) < SNACKS.PICKER.ACTIONS.TOGGLE_INPUT()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.toggle_input()* >lua Snacks.picker.actions.toggle_input(picker) < SNACKS.PICKER.ACTIONS.TOGGLE_LIVE()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.toggle_live()* >lua Snacks.picker.actions.toggle_live(picker) < SNACKS.PICKER.ACTIONS.TOGGLE_MAXIMIZE()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.toggle_maximize()* >lua Snacks.picker.actions.toggle_maximize(picker) < SNACKS.PICKER.ACTIONS.TOGGLE_PREVIEW()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.toggle_preview()* >lua Snacks.picker.actions.toggle_preview(picker) < SNACKS.PICKER.ACTIONS.YANK()*snacks.nvim-picker-snacks.picker.actions-snacks.picker.actions.yank()* >lua Snacks.picker.actions.yank(picker, item, action) < ============================================================================== 11. snacks.picker.core.picker *snacks.nvim-picker-snacks.picker.core.picker* >lua ---@class snacks.Picker ---@field id number ---@field opts snacks.picker.Config ---@field init_opts? snacks.picker.Config ---@field finder snacks.picker.Finder ---@field format snacks.picker.format ---@field input snacks.picker.input ---@field layout snacks.layout ---@field resolved_layout snacks.picker.layout.Config ---@field list snacks.picker.list ---@field matcher snacks.picker.Matcher ---@field main number ---@field _main snacks.picker.Main ---@field preview snacks.picker.Preview ---@field shown? boolean ---@field sort snacks.picker.sort ---@field updater uv.uv_timer_t ---@field start_time number ---@field title string ---@field closed? boolean ---@field history snacks.picker.History ---@field visual? snacks.picker.Visual local M = {} < SNACKS.PICKER.PICKER.GET()*snacks.nvim-picker-snacks.picker.core.picker-snacks.picker.picker.get()* >lua ---@param opts? {source?: string, tab?: boolean} Snacks.picker.picker.get(opts) < PICKER:ACTION() *snacks.nvim-picker-snacks.picker.core.picker-picker:action()* Execute the given action(s) >lua ---@param actions string|string[] picker:action(actions) < PICKER:CLOSE() *snacks.nvim-picker-snacks.picker.core.picker-picker:close()* Close the picker >lua picker:close() < PICKER:COUNT() *snacks.nvim-picker-snacks.picker.core.picker-picker:count()* Total number of items in the picker >lua picker:count() < PICKER:CURRENT()*snacks.nvim-picker-snacks.picker.core.picker-picker:current()* Get the current item at the cursor >lua ---@param opts? {resolve?: boolean} default is `true` picker:current(opts) < PICKER:CURRENT_WIN()*snacks.nvim-picker-snacks.picker.core.picker-picker:current_win()* >lua ---@return string? name, snacks.win? win picker:current_win() < PICKER:CWD() *snacks.nvim-picker-snacks.picker.core.picker-picker:cwd()* >lua picker:cwd() < PICKER:DIR() *snacks.nvim-picker-snacks.picker.core.picker-picker:dir()* Returns the directory of the current item or the cwd. When the item is a directory, return item path, otherwise return the directory of the item. >lua picker:dir() < PICKER:EMPTY() *snacks.nvim-picker-snacks.picker.core.picker-picker:empty()* Check if the picker is empty >lua picker:empty() < PICKER:FILTER() *snacks.nvim-picker-snacks.picker.core.picker-picker:filter()* Get the active filter >lua picker:filter() < PICKER:FIND() *snacks.nvim-picker-snacks.picker.core.picker-picker:find()* Check if the finder and/or matcher need to run, based on the current pattern and search string. >lua ---@param opts? { on_done?: fun(), refresh?: boolean } picker:find(opts) < PICKER:FOCUS() *snacks.nvim-picker-snacks.picker.core.picker-picker:focus()* Focuses the given or configured window. Falls back to the first available window if the window is hidden. >lua ---@param win? "input"|"list"|"preview" ---@param opts? {show?: boolean} when enable is true, the window will be shown if hidden picker:focus(win, opts) < PICKER:HIST() *snacks.nvim-picker-snacks.picker.core.picker-picker:hist()* Move the history cursor >lua ---@param forward? boolean picker:hist(forward) < PICKER:IS_ACTIVE()*snacks.nvim-picker-snacks.picker.core.picker-picker:is_active()* Check if the finder or matcher is running >lua picker:is_active() < PICKER:IS_FOCUSED()*snacks.nvim-picker-snacks.picker.core.picker-picker:is_focused()* >lua picker:is_focused() < PICKER:ITEMS() *snacks.nvim-picker-snacks.picker.core.picker-picker:items()* Get all filtered items in the picker. >lua picker:items() < PICKER:ITER() *snacks.nvim-picker-snacks.picker.core.picker-picker:iter()* Returns an iterator over the filtered items in the picker. Items will be in sorted order. >lua ---@return fun():(snacks.picker.Item?, number?) picker:iter() < PICKER:NORM() *snacks.nvim-picker-snacks.picker.core.picker-picker:norm()* Execute the callback in normal mode. When still in insert mode, stop insert mode first, and then`vim.schedule` the callback. >lua ---@param cb fun() picker:norm(cb) < PICKER:ON_CURRENT_TAB()*snacks.nvim-picker-snacks.picker.core.picker-picker:on_current_tab()* >lua picker:on_current_tab() < PICKER:REF() *snacks.nvim-picker-snacks.picker.core.picker-picker:ref()* >lua ---@return snacks.Picker.ref picker:ref() < PICKER:REFRESH()*snacks.nvim-picker-snacks.picker.core.picker-picker:refresh()* Clears the selection, set the target to the current item, and refresh the finder and matcher. >lua picker:refresh() < PICKER:RESOLVE()*snacks.nvim-picker-snacks.picker.core.picker-picker:resolve()* >lua ---@param item snacks.picker.Item? picker:resolve(item) < PICKER:SELECTED()*snacks.nvim-picker-snacks.picker.core.picker-picker:selected()* Get the selected items. If `fallback=true` and there is no selection, return the current item. >lua ---@param opts? {fallback?: boolean} default is `false` ---@return snacks.picker.Item[] picker:selected(opts) < PICKER:SET_CWD()*snacks.nvim-picker-snacks.picker.core.picker-picker:set_cwd()* >lua picker:set_cwd(cwd) < PICKER:SET_LAYOUT()*snacks.nvim-picker-snacks.picker.core.picker-picker:set_layout()* Set the picker layout. Can be either the name of a preset layout or a custom layout configuration. >lua ---@param layout? string|snacks.picker.layout.Config picker:set_layout(layout) < PICKER:SHOW_PREVIEW()*snacks.nvim-picker-snacks.picker.core.picker-picker:show_preview()* Show the preview. Show instantly when no item is yet in the preview, otherwise throttle the preview. >lua picker:show_preview() < PICKER:TOGGLE() *snacks.nvim-picker-snacks.picker.core.picker-picker:toggle()* Toggle the given window and optionally focus >lua ---@param win "input"|"list"|"preview" ---@param opts? {enable?: boolean, focus?: boolean|string} picker:toggle(win, opts) < PICKER:WORD() *snacks.nvim-picker-snacks.picker.core.picker-picker:word()* Get the word under the cursor or the current visual selection >lua picker:word() < ============================================================================== 12. Links *snacks.nvim-picker-links* 1. *image*: https://github.com/user-attachments/assets/b454fc3c-6613-4aa4-9296-f57a8b02bf6d 2. *image*: https://github.com/user-attachments/assets/3203aec4-7d75-4bca-b3d5-18d931277e4e 3. *image*: https://github.com/user-attachments/assets/e09d25f8-8559-441c-a0f7-576d2aa57097 4. *image*: https://github.com/user-attachments/assets/291dcf63-0c1d-4e9a-97cb-dd5503660e6f 5. *image*: https://github.com/user-attachments/assets/1aba5737-a650-4a00-94f8-033b7d8d21ba 6. *image*: https://github.com/user-attachments/assets/976e0ed8-eb80-43e1-93ac-4683136c0a3c Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-profiler.txt ================================================ *snacks-profiler* snacks profiler docs ============================================================================== Table of Contents *snacks.nvim-profiler-table-of-contents* 1. Features |snacks.nvim-profiler-features| 2. Why? |snacks.nvim-profiler-why?| 3. Usage |snacks.nvim-profiler-usage| - Caveats |snacks.nvim-profiler-usage-caveats| - Recommended Setup |snacks.nvim-profiler-usage-recommended-setup| - Profiling Neovim Startup|snacks.nvim-profiler-usage-profiling-neovim-startup| - Filtering |snacks.nvim-profiler-usage-filtering| 4. Setup |snacks.nvim-profiler-setup| 5. Config |snacks.nvim-profiler-config| 6. Types |snacks.nvim-profiler-types| - Traces |snacks.nvim-profiler-types-traces| - Pick: grouping, filtering and sorting|snacks.nvim-profiler-types-pick:-grouping,-filtering-and-sorting| - UI |snacks.nvim-profiler-types-ui| - Other |snacks.nvim-profiler-types-other| 7. Module |snacks.nvim-profiler-module| - Snacks.profiler.find()|snacks.nvim-profiler-module-snacks.profiler.find()| - Snacks.profiler.highlight()|snacks.nvim-profiler-module-snacks.profiler.highlight()| - Snacks.profiler.pick()|snacks.nvim-profiler-module-snacks.profiler.pick()| - Snacks.profiler.running()|snacks.nvim-profiler-module-snacks.profiler.running()| - Snacks.profiler.scratch()|snacks.nvim-profiler-module-snacks.profiler.scratch()| - Snacks.profiler.start()|snacks.nvim-profiler-module-snacks.profiler.start()| - Snacks.profiler.startup()|snacks.nvim-profiler-module-snacks.profiler.startup()| - Snacks.profiler.status()|snacks.nvim-profiler-module-snacks.profiler.status()| - Snacks.profiler.stop()|snacks.nvim-profiler-module-snacks.profiler.stop()| - Snacks.profiler.toggle()|snacks.nvim-profiler-module-snacks.profiler.toggle()| 8. Links |snacks.nvim-profiler-links| A low overhead Lua profiler for Neovim. ============================================================================== 1. Features *snacks.nvim-profiler-features* - low overhead **instrumentation** - captures a function’s **def**inition and **ref**erence (_caller_) locations - profiling of **autocmds** - profiling of **require**d modules - buffer **highlighting** of functions and calls - lots of different ways to **filter** and **group** traces - show traces with: - fzf-lua - telescope.nvim - trouble.nvim ============================================================================== 2. Why? *snacks.nvim-profiler-why?* Before the snacks profiler, I used to use a combination of my own profiler(s), **lazy.nvim**’s internal profiler, profile.nvim and perfanno.nvim . They all have their strengths and weaknesses: - **lazy.nvim**’s profiler is great for structured traces, but needed a lot of manual work to get the traces I wanted. - **profile.nvim** does proper instrumentation, but was lacking in the UI department. - **perfanno.nvim** has a great UI, but uses `jit.profile` which is not as detailed as instrumentation. The snacks profiler tries to combine the best of all worlds. ============================================================================== 3. Usage *snacks.nvim-profiler-usage* The easiest way to use the profiler is to toggle it with the suggested keybindings. When the profiler stops, it will show a picker using the `on_stop` preset. To quickly change picker options, you can use the `Snacks.profiler.scratch()` scratch buffer. CAVEATS *snacks.nvim-profiler-usage-caveats* - your Neovim session might slow down when profiling - due to the overhead of instrumentation, fast functions that are called often, might skew the results. Best to add those to the `opts.filter_fn` config. - by default, only captures functions defined on lua modules. If you want to profile others, add them to `opts.globals` - the profiler is not perfect and might not capture all calls - the profiler might not work well with some plugins - it can only profile `autocmds` created when the profiler is running. - only `autocmds` with a lua function callback can be profiled - functions that `resume` or `yield` won’t be captured correctly - functions that do blocking calls like `vim.fn.getchar` will work, but the time will include the time spent waiting for the blocking call RECOMMENDED SETUP *snacks.nvim-profiler-usage-recommended-setup* >lua { { "folke/snacks.nvim", opts = function() -- Toggle the profiler Snacks.toggle.profiler():map("pp") -- Toggle the profiler highlights Snacks.toggle.profiler_highlights():map("ph") end, keys = { { "ps", function() Snacks.profiler.scratch() end, desc = "Profiler Scratch Bufer" }, } }, -- optional lualine component to show captured events -- when the profiler is running { "nvim-lualine/lualine.nvim", opts = function(_, opts) table.insert(opts.sections.lualine_x, Snacks.profiler.status()) end, }, } < PROFILING NEOVIM STARTUP *snacks.nvim-profiler-usage-profiling-neovim-startup* In order to profile Neovim’s startup, you need to make sure `snacks.nvim` is installed and loaded **before** doing anything else. So also before loading your plugin manager. You can add something like the below to the top of your `init.lua`. Then you can profile your Neovim session, with `PROF=1 nvim`. >lua if vim.env.PROF then -- example for lazy.nvim -- change this to the correct path for your plugin manager local snacks = vim.fn.stdpath("data") .. "/lazy/snacks.nvim" vim.opt.rtp:append(snacks) require("snacks.profiler").startup({ startup = { event = "VimEnter", -- stop profiler on this event. Defaults to `VimEnter` -- event = "UIEnter", -- event = "VeryLazy", }, }) end < FILTERING *snacks.nvim-profiler-usage-filtering* For the full definition, see the `snacks.profiler.Filter` type. Each field can be a string or a boolean. When a field is a string, it will match the exact value, unless it starts with `^` in which case it will match the pattern. When any of the `def`/`ref` fields are `true`, the filter matches the current location of the cursor. For example, `{ref_file = true}` will match all traces calling something, in the current file. All other fields equal to `true` will match if the trace has a value for that field. ============================================================================== 4. Setup *snacks.nvim-profiler-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { profiler = { -- your profiler configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 5. Config *snacks.nvim-profiler-config* >lua ---@class snacks.profiler.Config { autocmds = true, runtime = vim.env.VIMRUNTIME, ---@type string -- thresholds for buttons to be shown as info, warn or error -- value is a tuple of [warn, error] thresholds = { time = { 2, 10 }, pct = { 10, 20 }, count = { 10, 100 }, }, on_stop = { highlights = true, -- highlight entries after stopping the profiler pick = true, -- show a picker after stopping the profiler (uses the `on_stop` preset) }, ---@type snacks.profiler.Highlights highlights = { min_time = 0, -- only highlight entries with time > min_time (in ms) max_shade = 20, -- time in ms for the darkest shade badges = { "time", "pct", "count", "trace" }, align = 80, }, pick = { picker = "snacks", ---@type snacks.profiler.Picker ---@type snacks.profiler.Badge.type[] badges = { "time", "count", "name" }, ---@type snacks.profiler.Highlights preview = { badges = { "time", "pct", "count" }, align = "right", }, }, startup = { event = "VimEnter", -- stop profiler on this event. Defaults to `VimEnter` after = true, -- stop the profiler **after** the event. When false it stops **at** the event pattern = nil, -- pattern to match for the autocmd pick = true, -- show a picker after starting the profiler (uses the `startup` preset) }, ---@type table presets = { startup = { min_time = 1, sort = false }, on_stop = {}, filter_by_plugin = function() return { filter = { def_plugin = vim.fn.input("Filter by plugin: ") } } end, }, ---@type string[] globals = { -- "vim", -- "vim.api", -- "vim.keymap", -- "Snacks.dashboard.Dashboard", }, -- filter modules by pattern. -- longest patterns are matched first filter_mod = { default = true, -- default value for unmatched patterns ["^vim%."] = false, ["mason-core.functional"] = false, ["mason-core.functional.data"] = false, ["mason-core.optional"] = false, ["which-key.state"] = false, }, filter_fn = { default = true, ["^.*%._[^%.]*$"] = false, ["trouble.filter.is"] = false, ["trouble.item.__index"] = false, ["which-key.node.__index"] = false, ["smear_cursor.draw.wo"] = false, ["^ibl%.utils%."] = false, }, icons = { time = " ", pct = " ", count = " ", require = "󰋺 ", modname = "󰆼 ", plugin = " ", autocmd = "⚡", file = " ", fn = "󰊕 ", status = "󰈸 ", }, } < ============================================================================== 6. Types *snacks.nvim-profiler-types* TRACES *snacks.nvim-profiler-types-traces* >lua ---@class snacks.profiler.Trace ---@field name string fully qualified name of the function ---@field time number time in nanoseconds ---@field depth number stack depth ---@field [number] snacks.profiler.Trace child traces ---@field fname string function name ---@field fn function function reference ---@field modname? string module name ---@field require? string special case for require ---@field autocmd? string special case for autocmd ---@field count? number number of calls ---@field def? snacks.profiler.Loc location of the definition ---@field ref? snacks.profiler.Loc location of the reference (caller) ---@field loc? snacks.profiler.Loc normalized location < >lua ---@class snacks.profiler.Loc ---@field file string path to the file ---@field line number line number ---@field loc? string normalized location ---@field modname? string module name ---@field plugin? string plugin name < PICK: GROUPING, FILTERING AND SORTING*snacks.nvim-profiler-types-pick:-grouping,-filtering-and-sorting* >lua ---@class snacks.profiler.Find ---@field structure? boolean show traces as a tree or flat list ---@field sort? "time"|"count"|false sort by time or count, or keep original order ---@field loc? "def"|"ref" what location to show in the preview ---@field group? boolean|snacks.profiler.Field group traces by field ---@field filter? snacks.profiler.Filter filter traces by field(s) ---@field min_time? number only show grouped traces with `time >= min_time` < >lua ---@class snacks.profiler.Pick: snacks.profiler.Find ---@field picker? snacks.profiler.Picker < >lua ---@alias snacks.profiler.Picker "snacks"|"trouble" ---@alias snacks.profiler.Pick.spec snacks.profiler.Pick|{preset?:string}|fun():snacks.profiler.Pick < >lua ---@alias snacks.profiler.Field ---| "name" fully qualified name of the function ---| "def" definition ---| "ref" reference (caller) ---| "require" require ---| "autocmd" autocmd ---| "modname" module name of the called function ---| "def_file" file of the definition ---| "def_modname" module name of the definition ---| "def_plugin" plugin that defines the function ---| "ref_file" file of the reference ---| "ref_modname" module name of the reference ---| "ref_plugin" plugin that references the function < >lua ---@class snacks.profiler.Filter ---@field name? string|boolean fully qualified name of the function ---@field def? string|boolean location of the definition ---@field ref? string|boolean location of the reference (caller) ---@field require? string|boolean special case for require ---@field autocmd? string|boolean special case for autocmd ---@field modname? string|boolean module name ---@field def_file? string|boolean file of the definition ---@field def_modname? string|boolean module name of the definition ---@field def_plugin? string|boolean plugin that defines the function ---@field ref_file? string|boolean file of the reference ---@field ref_modname? string|boolean module name of the reference ---@field ref_plugin? string|boolean plugin that references the function < UI *snacks.nvim-profiler-types-ui* >lua ---@alias snacks.profiler.Badge {icon:string, text:string, padding?:boolean, level?:string} ---@alias snacks.profiler.Badge.type "time"|"pct"|"count"|"name"|"trace" < >lua ---@class snacks.profiler.Highlights ---@field min_time? number only highlight entries with time >= min_time ---@field max_shade? number -- time in ms for the darkest shade ---@field badges? snacks.profiler.Badge.type[] badges to show ---@field align? "right"|"left"|number align the badges right, left or at a specific column < OTHER *snacks.nvim-profiler-types-other* >lua ---@class snacks.profiler.Startup ---@field event? string ---@field pattern? string|string[] pattern to match for the autocmd < >lua ---@alias snacks.profiler.GroupFn fun(entry:snacks.profiler.Trace):{key:string, name?:string}? < ============================================================================== 7. Module *snacks.nvim-profiler-module* >lua ---@class snacks.profiler ---@field core snacks.profiler.core ---@field loc snacks.profiler.loc ---@field tracer snacks.profiler.tracer ---@field ui snacks.profiler.ui ---@field picker snacks.profiler.picker Snacks.profiler = {} < `Snacks.profiler.find()` *Snacks.profiler.find()* Group and filter traces >lua ---@param opts snacks.profiler.Find Snacks.profiler.find(opts) < `Snacks.profiler.highlight()` *Snacks.profiler.highlight()* Toggle the profiler highlights >lua ---@param enable? boolean Snacks.profiler.highlight(enable) < `Snacks.profiler.pick()` *Snacks.profiler.pick()* Group and filter traces and open a picker >lua ---@param opts? snacks.profiler.Pick.spec Snacks.profiler.pick(opts) < `Snacks.profiler.running()` *Snacks.profiler.running()* Check if the profiler is running >lua Snacks.profiler.running() < `Snacks.profiler.scratch()` *Snacks.profiler.scratch()* Open a scratch buffer with the profiler picker options >lua Snacks.profiler.scratch() < `Snacks.profiler.start()` *Snacks.profiler.start()* Start the profiler >lua ---@param opts? snacks.profiler.Config Snacks.profiler.start(opts) < `Snacks.profiler.startup()` *Snacks.profiler.startup()* Start the profiler on startup, and stop it after the event has been triggered. >lua ---@param opts snacks.profiler.Config Snacks.profiler.startup(opts) < `Snacks.profiler.status()` *Snacks.profiler.status()* Statusline component >lua Snacks.profiler.status() < `Snacks.profiler.stop()` *Snacks.profiler.stop()* Stop the profiler >lua ---@param opts? {highlights?:boolean, pick?:snacks.profiler.Pick.spec} Snacks.profiler.stop(opts) < `Snacks.profiler.toggle()` *Snacks.profiler.toggle()* Toggle the profiler >lua Snacks.profiler.toggle() < ============================================================================== 8. Links *snacks.nvim-profiler-links* 1. *image*: https://github.com/user-attachments/assets/cebb1308-077b-4f20-bee3-28644fb121b8 2. *image*: https://github.com/user-attachments/assets/4ee557c4-a290-4a52-b5c9-64e325bf1094 3. *image*: https://github.com/user-attachments/assets/ec03e440-6719-4463-a649-a8626dcfe2ec Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-quickfile.txt ================================================ *snacks-quickfile* snacks quickfile docs ============================================================================== Table of Contents *snacks.nvim-quickfile-table-of-contents* 1. Setup |snacks.nvim-quickfile-setup| 2. Config |snacks.nvim-quickfile-config| When doing `nvim somefile.txt`, it will render the file as quickly as possible, before loading your plugins. ============================================================================== 1. Setup *snacks.nvim-quickfile-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { quickfile = { -- your quickfile configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Config *snacks.nvim-quickfile-config* >lua ---@class snacks.quickfile.Config { -- any treesitter langs to exclude exclude = { "latex" }, } < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-rename.txt ================================================ *snacks-rename* snacks rename docs ============================================================================== Table of Contents *snacks.nvim-rename-table-of-contents* 1. Usage |snacks.nvim-rename-usage| 2. mini.files |snacks.nvim-rename-mini.files| 3. oil.nvim |snacks.nvim-rename-oil.nvim| 4. fyler.nvim |snacks.nvim-rename-fyler.nvim| 5. neo-tree.nvim |snacks.nvim-rename-neo-tree.nvim| 6. nvim-tree |snacks.nvim-rename-nvim-tree| 7. netrw (builtin file explorer)|snacks.nvim-rename-netrw-(builtin-file-explorer)| 8. Module |snacks.nvim-rename-module| - Snacks.rename.on_rename_file()|snacks.nvim-rename-module-snacks.rename.on_rename_file()| - Snacks.rename.rename_file()|snacks.nvim-rename-module-snacks.rename.rename_file()| LSP-integrated file renaming with support for plugins like neo-tree.nvim and mini.files . ============================================================================== 1. Usage *snacks.nvim-rename-usage* ============================================================================== 2. mini.files *snacks.nvim-rename-mini.files* >lua vim.api.nvim_create_autocmd("User", { pattern = "MiniFilesActionRename", callback = function(event) Snacks.rename.on_rename_file(event.data.from, event.data.to) end, }) < ============================================================================== 3. oil.nvim *snacks.nvim-rename-oil.nvim* >lua vim.api.nvim_create_autocmd("User", { pattern = "OilActionsPost", callback = function(event) if event.data.actions[1].type == "move" then Snacks.rename.on_rename_file(event.data.actions[1].src_url, event.data.actions[1].dest_url) end end, }) < ============================================================================== 4. fyler.nvim *snacks.nvim-rename-fyler.nvim* >lua return { "A7Lavinraj/fyler.nvim", dependencies = { "echasnovski/mini.icons" }, opts = { hooks = { on_rename = function(src_path, destination_path) Snacks.rename.on_rename_file(src_path, destination_path) end, }, }, } < ============================================================================== 5. neo-tree.nvim *snacks.nvim-rename-neo-tree.nvim* >lua { "nvim-neo-tree/neo-tree.nvim", opts = function(_, opts) local function on_move(data) Snacks.rename.on_rename_file(data.source, data.destination) end local events = require("neo-tree.events") opts.event_handlers = opts.event_handlers or {} vim.list_extend(opts.event_handlers, { { event = events.FILE_MOVED, handler = on_move }, { event = events.FILE_RENAMED, handler = on_move }, }) end, } < ============================================================================== 6. nvim-tree *snacks.nvim-rename-nvim-tree* >lua local prev = { new_name = "", old_name = "" } -- Prevents duplicate events vim.api.nvim_create_autocmd("User", { pattern = "NvimTreeSetup", callback = function() local events = require("nvim-tree.api").events events.subscribe(events.Event.NodeRenamed, function(data) if prev.new_name ~= data.new_name or prev.old_name ~= data.old_name then data = data Snacks.rename.on_rename_file(data.old_name, data.new_name) end end) end, }) < ============================================================================== 7. netrw (builtin file explorer)*snacks.nvim-rename-netrw-(builtin-file-explorer)* >lua vim.api.nvim_create_autocmd({ 'FileType' }, { pattern = { 'netrw' }, group = vim.api.nvim_create_augroup('NetrwOnRename', { clear = true }), callback = function() vim.keymap.set("n", "R", function() local original_file_path = vim.b.netrw_curdir .. '/' .. vim.fn["netrw#Call"]("NetrwGetWord") vim.ui.input({ prompt = 'Move/rename to:', default = original_file_path }, function(target_file_path) if target_file_path and target_file_path ~= "" then local file_exists = vim.uv.fs_access(target_file_path, "W") if not file_exists then vim.uv.fs_rename(original_file_path, target_file_path) Snacks.rename.on_rename_file(original_file_path, target_file_path) else vim.notify("File '" .. target_file_path .. "' already exists! Skipping...", vim.log.levels.ERROR) end -- Refresh netrw vim.cmd(':Ex ' .. vim.b.netrw_curdir) end end) end, { remap = true, buffer = true }) end }) < ============================================================================== 8. Module *snacks.nvim-rename-module* `Snacks.rename.on_rename_file()` *Snacks.rename.on_rename_file()* Lets LSP clients know that a file has been renamed >lua ---@param from string ---@param to string ---@param rename? fun() Snacks.rename.on_rename_file(from, to, rename) < `Snacks.rename.rename_file()` *Snacks.rename.rename_file()* Renames the provided file, or the current buffer’s file. Prompt for the new filename if `to` is not provided. do the rename, and trigger LSP handlers >lua ---@param opts? {from?: string, to?:string, on_rename?: fun(to:string, from:string, ok:boolean)} Snacks.rename.rename_file(opts) < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-scope.txt ================================================ *snacks-scope* snacks scope docs ============================================================================== Table of Contents *snacks.nvim-scope-table-of-contents* 1. Setup |snacks.nvim-scope-setup| 2. Config |snacks.nvim-scope-config| 3. Types |snacks.nvim-scope-types| 4. Module |snacks.nvim-scope-module| - Snacks.scope.attach() |snacks.nvim-scope-module-snacks.scope.attach()| - Snacks.scope.get() |snacks.nvim-scope-module-snacks.scope.get()| - Snacks.scope.jump() |snacks.nvim-scope-module-snacks.scope.jump()| - Snacks.scope.textobject()|snacks.nvim-scope-module-snacks.scope.textobject()| Scope detection based on treesitter or indent. The indent-based algorithm is similar to what is used in mini.indentscope . ============================================================================== 1. Setup *snacks.nvim-scope-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { scope = { -- your scope configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Config *snacks.nvim-scope-config* >lua ---@class snacks.scope.Config ---@field max_size? number ---@field enabled? boolean { -- absolute minimum size of the scope. -- can be less if the scope is a top-level single line scope min_size = 2, -- try to expand the scope to this size max_size = nil, cursor = true, -- when true, the column of the cursor is used to determine the scope edge = true, -- include the edge of the scope (typically the line above and below with smaller indent) siblings = false, -- expand single line scopes with single line siblings -- what buffers to attach to filter = function(buf) return vim.bo[buf].buftype == "" and vim.b[buf].snacks_scope ~= false and vim.g.snacks_scope ~= false end, -- debounce scope detection in ms debounce = 30, treesitter = { -- detect scope based on treesitter. -- falls back to indent based detection if not available enabled = true, injections = true, -- include language injections when detecting scope (useful for languages like `vue`) ---@type string[]|{enabled?:boolean} blocks = { enabled = false, -- enable to use the following blocks "function_declaration", "function_definition", "method_declaration", "method_definition", "class_declaration", "class_definition", "do_statement", "while_statement", "repeat_statement", "if_statement", "for_statement", }, -- these treesitter fields will be considered as blocks field_blocks = { "local_declaration", }, }, -- These keymaps will only be set if the `scope` plugin is enabled. -- Alternatively, you can set them manually in your config, -- using the `Snacks.scope.textobject` and `Snacks.scope.jump` functions. keys = { ---@type table textobject = { ii = { min_size = 2, -- minimum size of the scope edge = false, -- inner scope cursor = false, treesitter = { blocks = { enabled = false } }, desc = "inner scope", }, ai = { cursor = false, min_size = 2, -- minimum size of the scope treesitter = { blocks = { enabled = false } }, desc = "full scope", }, }, ---@type table jump = { ["[i"] = { min_size = 1, -- allow single line scopes bottom = false, cursor = false, edge = true, treesitter = { blocks = { enabled = false } }, desc = "jump to top edge of scope", }, ["]i"] = { min_size = 1, -- allow single line scopes bottom = true, cursor = false, edge = true, treesitter = { blocks = { enabled = false } }, desc = "jump to bottom edge of scope", }, }, }, } < ============================================================================== 3. Types *snacks.nvim-scope-types* >lua ---@class snacks.scope.Opts: snacks.scope.Config,{} ---@field buf? number ---@field pos? {[1]:number, [2]:number} -- (1,0) indexed ---@field end_pos? {[1]:number, [2]:number} -- (1,0) indexed ---@field async? boolean run scope detection asynchronously (defaults to true) < >lua ---@class snacks.scope.TextObject: snacks.scope.Opts ---@field linewise? boolean if nil, use visual mode. Defaults to `false` when not in visual mode ---@field notify? boolean show a notification when no scope is found (defaults to true) < >lua ---@class snacks.scope.Jump: snacks.scope.Opts ---@field bottom? boolean if true, jump to the bottom of the scope, otherwise to the top ---@field notify? boolean show a notification when no scope is found (defaults to true) < >lua ---@alias snacks.scope.Attach.cb fun(win: number, buf: number, scope:snacks.scope.Scope?, prev:snacks.scope.Scope?) < >lua ---@alias snacks.scope.scope {buf: number, from: number, to: number, indent?: number} < ============================================================================== 4. Module *snacks.nvim-scope-module* `Snacks.scope.attach()` *Snacks.scope.attach()* Attach a scope listener >lua ---@param cb snacks.scope.Attach.cb ---@param opts? snacks.scope.Config ---@return snacks.scope.Listener Snacks.scope.attach(cb, opts) < `Snacks.scope.get()` *Snacks.scope.get()* >lua ---@param cb fun(scope?: snacks.scope.Scope) ---@param opts? snacks.scope.Opts|{parse?:boolean} Snacks.scope.get(cb, opts) < `Snacks.scope.jump()` *Snacks.scope.jump()* Jump to the top or bottom of the scope If the scope is the same as the current scope, it will jump to the parent scope instead. >lua ---@param opts? snacks.scope.Jump Snacks.scope.jump(opts) < `Snacks.scope.textobject()` *Snacks.scope.textobject()* Text objects for indent scopes. Best to use with Treesitter disabled. When in visual mode, it will select the scope containing the visual selection. When the scope is the same as the visual selection, it will select the parent scope instead. >lua ---@param opts? snacks.scope.TextObject Snacks.scope.textobject(opts) < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-scratch.txt ================================================ *snacks-scratch* snacks scratch docs ============================================================================== Table of Contents *snacks.nvim-scratch-table-of-contents* 1. Usage |snacks.nvim-scratch-usage| 2. Setup |snacks.nvim-scratch-setup| 3. Config |snacks.nvim-scratch-config| 4. Styles |snacks.nvim-scratch-styles| - scratch |snacks.nvim-scratch-styles-scratch| 5. Types |snacks.nvim-scratch-types| 6. Module |snacks.nvim-scratch-module| - Snacks.scratch() |snacks.nvim-scratch-module-snacks.scratch()| - Snacks.scratch.list() |snacks.nvim-scratch-module-snacks.scratch.list()| - Snacks.scratch.open() |snacks.nvim-scratch-module-snacks.scratch.open()| - Snacks.scratch.select()|snacks.nvim-scratch-module-snacks.scratch.select()| 7. Links |snacks.nvim-scratch-links| Quickly open scratch buffers for testing code, creating notes or just messing around. Scratch buffers are organized by using context like your working directory, Git branch and `vim.v.count1`. It supports templates, custom keymaps, and auto-saves when you hide the buffer. In lua buffers, pressing `` will execute the buffer / selection with `Snacks.debug.run()` that will show print output inline and show errors as diagnostics. ============================================================================== 1. Usage *snacks.nvim-scratch-usage* Suggested config: >lua { "folke/snacks.nvim", keys = { { ".", function() Snacks.scratch() end, desc = "Toggle Scratch Buffer" }, { "S", function() Snacks.scratch.select() end, desc = "Select Scratch Buffer" }, } } < ============================================================================== 2. Setup *snacks.nvim-scratch-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { scratch = { -- your scratch configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 3. Config *snacks.nvim-scratch-config* >lua ---@class snacks.scratch.Config ---@field win? snacks.win.Config scratch window ---@field template? string template for new buffers ---@field file? string scratch file path. You probably don't need to set this. ---@field ft? string|fun():string the filetype of the scratch buffer { name = "Scratch", ft = function() if vim.bo.buftype == "" and vim.bo.filetype ~= "" then return vim.bo.filetype end return "markdown" end, ---@type string|string[]? icon = nil, -- `icon|{icon, icon_hl}`. defaults to the filetype icon root = vim.fn.stdpath("data") .. "/scratch", autowrite = true, -- automatically write when the buffer is hidden -- unique key for the scratch file is based on: -- * name -- * ft -- * vim.v.count1 (useful for keymaps) -- * cwd (optional) -- * branch (optional) filekey = { id = nil, ---@type string? unique id used instead of name for the filename hash cwd = true, -- use current working directory branch = true, -- use current branch name count = true, -- use vim.v.count1 }, win = { style = "scratch" }, ---@type table win_by_ft = { lua = { keys = { ["source"] = { "", function(self) local name = "scratch." .. vim.fn.fnamemodify(vim.api.nvim_buf_get_name(self.buf), ":e") Snacks.debug.run({ buf = self.buf, name = name }) end, desc = "Source buffer", mode = { "n", "x" }, }, }, }, }, } < ============================================================================== 4. Styles *snacks.nvim-scratch-styles* Check the styles docs for more information on how to customize these styles SCRATCH *snacks.nvim-scratch-styles-scratch* >lua { width = 100, height = 30, bo = { buftype = "", buflisted = false, bufhidden = "hide", swapfile = false }, minimal = false, noautocmd = false, -- position = "right", zindex = 20, wo = { winhighlight = "NormalFloat:Normal" }, footer_keys = true, border = true, } < ============================================================================== 5. Types *snacks.nvim-scratch-types* >lua ---@class snacks.scratch.File ---@field file string full path to the scratch buffer ---@field name string name of the scratch buffer ---@field ft string file type ---@field icon? string icon for the file type ---@field icon_hl? string highlight group for the icon ---@field cwd? string current working directory ---@field branch? string Git branch ---@field count? number vim.v.count1 used to open the buffer ---@field id? string unique id used instead of name for the filename hash < ============================================================================== 6. Module *snacks.nvim-scratch-module* `Snacks.scratch()` *Snacks.scratch()* >lua ---@type fun(opts?: snacks.scratch.Config): snacks.win Snacks.scratch() < `Snacks.scratch.list()` *Snacks.scratch.list()* Return a list of scratch buffers sorted by mtime. >lua ---@return snacks.scratch.File[] Snacks.scratch.list() < `Snacks.scratch.open()` *Snacks.scratch.open()* Open a scratch buffer with the given options. If a window is already open with the same buffer, it will be closed instead. >lua ---@param opts? snacks.scratch.Config Snacks.scratch.open(opts) < `Snacks.scratch.select()` *Snacks.scratch.select()* Select a scratch buffer from a list of scratch buffers. >lua Snacks.scratch.select() < ============================================================================== 7. Links *snacks.nvim-scratch-links* 1. *image*: https://github.com/user-attachments/assets/52ac7c1a-908f-4d1d-97a2-ad4642f8dc36 2. *image*: https://github.com/user-attachments/assets/d3e766e9-e64a-4c22-85b4-3d965f645b59 Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-scroll.txt ================================================ *snacks-scroll* snacks scroll docs ============================================================================== Table of Contents *snacks.nvim-scroll-table-of-contents* 1. Setup |snacks.nvim-scroll-setup| 2. Config |snacks.nvim-scroll-config| 3. Types |snacks.nvim-scroll-types| 4. Module |snacks.nvim-scroll-module| - Snacks.scroll.disable()|snacks.nvim-scroll-module-snacks.scroll.disable()| - Snacks.scroll.enable() |snacks.nvim-scroll-module-snacks.scroll.enable()| Smooth scrolling for Neovim. Properly handles `scrolloff` and mouse scrolling. Similar plugins: - mini.animate - neoscroll.nvim ============================================================================== 1. Setup *snacks.nvim-scroll-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { scroll = { -- your scroll configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Config *snacks.nvim-scroll-config* >lua ---@class snacks.scroll.Config ---@field animate snacks.animate.Config|{} ---@field animate_repeat snacks.animate.Config|{}|{delay:number} { animate = { duration = { step = 10, total = 200 }, easing = "linear", }, -- faster animation when repeating scroll after delay animate_repeat = { delay = 100, -- delay in ms before using the repeat animation duration = { step = 5, total = 50 }, easing = "linear", }, -- what buffers to animate filter = function(buf) return vim.g.snacks_scroll ~= false and vim.b[buf].snacks_scroll ~= false and vim.bo[buf].buftype ~= "terminal" end, } < ============================================================================== 3. Types *snacks.nvim-scroll-types* >lua ---@alias snacks.scroll.View {topline:number, lnum:number} < ============================================================================== 4. Module *snacks.nvim-scroll-module* `Snacks.scroll.disable()` *Snacks.scroll.disable()* >lua Snacks.scroll.disable() < `Snacks.scroll.enable()` *Snacks.scroll.enable()* >lua Snacks.scroll.enable() < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-statuscolumn.txt ================================================ *snacks-statuscolumn* snacks statuscolumn docs ============================================================================== Table of Contents *snacks.nvim-statuscolumn-table-of-contents* 1. Setup |snacks.nvim-statuscolumn-setup| 2. Config |snacks.nvim-statuscolumn-config| 3. Types |snacks.nvim-statuscolumn-types| 4. Module |snacks.nvim-statuscolumn-module| - Snacks.statuscolumn()|snacks.nvim-statuscolumn-module-snacks.statuscolumn()| - Snacks.statuscolumn.click_fold()|snacks.nvim-statuscolumn-module-snacks.statuscolumn.click_fold()| - Snacks.statuscolumn.get()|snacks.nvim-statuscolumn-module-snacks.statuscolumn.get()| ============================================================================== 1. Setup *snacks.nvim-statuscolumn-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { statuscolumn = { -- your statuscolumn configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Config *snacks.nvim-statuscolumn-config* >lua ---@class snacks.statuscolumn.Config ---@field left snacks.statuscolumn.Components ---@field right snacks.statuscolumn.Components ---@field enabled? boolean { left = { "mark", "sign" }, -- priority of signs on the left (high to low) right = { "fold", "git" }, -- priority of signs on the right (high to low) folds = { open = false, -- show open fold icons git_hl = false, -- use Git Signs hl for fold icons }, git = { -- patterns to match Git signs patterns = { "GitSign", "MiniDiffSign" }, }, refresh = 50, -- refresh at most every 50ms } < ============================================================================== 3. Types *snacks.nvim-statuscolumn-types* >lua ---@class snacks.statuscolumn.FoldInfo ---@field start number Line number where deepest fold starts ---@field level number Fold level, when zero other fields are N/A ---@field llevel number Lowest level that starts in v:lnum ---@field lines number Number of lines from v:lnum to end of closed fold < >lua ---@alias snacks.statuscolumn.Component "mark"|"sign"|"fold"|"git" ---@alias snacks.statuscolumn.Components snacks.statuscolumn.Component[]|fun(win:number,buf:number,lnum:number):snacks.statuscolumn.Component[] ---@alias snacks.statuscolumn.Wanted table < ============================================================================== 4. Module *snacks.nvim-statuscolumn-module* `Snacks.statuscolumn()` *Snacks.statuscolumn()* >lua ---@type fun(): string Snacks.statuscolumn() < `Snacks.statuscolumn.click_fold()` *Snacks.statuscolumn.click_fold()* >lua Snacks.statuscolumn.click_fold() < `Snacks.statuscolumn.get()` *Snacks.statuscolumn.get()* >lua Snacks.statuscolumn.get() < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-styles.txt ================================================ *snacks-styles* snacks styles docs ============================================================================== Table of Contents *snacks.nvim-styles-table-of-contents* 1. Setup |snacks.nvim-styles-setup| 2. Styles |snacks.nvim-styles-styles| - blame_line |snacks.nvim-styles-styles-blame_line| - dashboard |snacks.nvim-styles-styles-dashboard| - float |snacks.nvim-styles-styles-float| - help |snacks.nvim-styles-styles-help| - input |snacks.nvim-styles-styles-input| - lazygit |snacks.nvim-styles-styles-lazygit| - minimal |snacks.nvim-styles-styles-minimal| - notification |snacks.nvim-styles-styles-notification| - notification_history |snacks.nvim-styles-styles-notification_history| - scratch |snacks.nvim-styles-styles-scratch| - snacks_image |snacks.nvim-styles-styles-snacks_image| - split |snacks.nvim-styles-styles-split| - terminal |snacks.nvim-styles-styles-terminal| - zen |snacks.nvim-styles-styles-zen| - zoom_indicator |snacks.nvim-styles-styles-zoom_indicator| Plugins provide window styles that can be customized with the `opts.styles` option of `snacks.nvim`. ============================================================================== 1. Setup *snacks.nvim-styles-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { ---@type table styles = { -- your styles configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Styles *snacks.nvim-styles-styles* These are the default styles that Snacks provides. You can customize them by adding your own styles to `opts.styles`. BLAME_LINE *snacks.nvim-styles-styles-blame_line* >lua { width = 0.6, height = 0.6, border = true, title = " Git Blame ", title_pos = "center", ft = "git", } < DASHBOARD *snacks.nvim-styles-styles-dashboard* The default style for the dashboard. When opening the dashboard during startup, only the `bo` and `wo` options are used. The other options are used with `:lua Snacks.dashboard()` >lua { zindex = 10, height = 0, width = 0, bo = { bufhidden = "wipe", buftype = "nofile", buflisted = false, filetype = "snacks_dashboard", swapfile = false, undofile = false, }, wo = { colorcolumn = "", cursorcolumn = false, cursorline = false, foldmethod = "manual", list = false, number = false, relativenumber = false, sidescrolloff = 0, signcolumn = "no", spell = false, statuscolumn = "", statusline = "", winbar = "", winhighlight = "Normal:SnacksDashboardNormal,NormalFloat:SnacksDashboardNormal", wrap = false, }, } < FLOAT *snacks.nvim-styles-styles-float* >lua { position = "float", backdrop = 60, height = 0.9, width = 0.9, zindex = 50, } < HELP *snacks.nvim-styles-styles-help* >lua { position = "float", backdrop = false, border = "top", row = -1, width = 0, height = 0.3, } < INPUT *snacks.nvim-styles-styles-input* >lua { backdrop = false, position = "float", border = true, title_pos = "center", height = 1, width = 60, relative = "editor", noautocmd = true, row = 2, -- relative = "cursor", -- row = -3, -- col = 0, wo = { winhighlight = "NormalFloat:SnacksInputNormal,FloatBorder:SnacksInputBorder,FloatTitle:SnacksInputTitle", cursorline = false, }, bo = { filetype = "snacks_input", buftype = "prompt", }, --- buffer local variables b = { completion = false, -- disable blink completions in input }, keys = { n_esc = { "", { "cmp_close", "cancel" }, mode = "n", expr = true }, i_esc = { "", { "cmp_close", "stopinsert" }, mode = "i", expr = true }, i_cr = { "", { "cmp_accept", "confirm" }, mode = { "i", "n" }, expr = true }, i_tab = { "", { "cmp_select_next", "cmp" }, mode = "i", expr = true }, i_ctrl_w = { "", "", mode = "i", expr = true }, i_up = { "", { "hist_up" }, mode = { "i", "n" } }, i_down = { "", { "hist_down" }, mode = { "i", "n" } }, q = "cancel", }, } < LAZYGIT *snacks.nvim-styles-styles-lazygit* >lua {} < MINIMAL *snacks.nvim-styles-styles-minimal* >lua { wo = { cursorcolumn = false, cursorline = false, cursorlineopt = "both", colorcolumn = "", fillchars = "eob: ,lastline:…", foldcolumn = "0", list = false, listchars = "extends:…,tab: ", number = false, relativenumber = false, signcolumn = "no", spell = false, winbar = "", statuscolumn = "", wrap = false, sidescrolloff = 0, }, } < NOTIFICATION *snacks.nvim-styles-styles-notification* >lua { border = true, zindex = 100, ft = "markdown", wo = { winblend = 5, wrap = false, conceallevel = 2, colorcolumn = "", }, bo = { filetype = "snacks_notif" }, } < NOTIFICATION_HISTORY *snacks.nvim-styles-styles-notification_history* >lua { border = true, zindex = 100, width = 0.6, height = 0.6, minimal = false, title = " Notification History ", title_pos = "center", ft = "markdown", bo = { filetype = "snacks_notif_history", modifiable = false }, wo = { winhighlight = "Normal:SnacksNotifierHistory" }, keys = { q = "close" }, } < SCRATCH *snacks.nvim-styles-styles-scratch* >lua { width = 100, height = 30, bo = { buftype = "", buflisted = false, bufhidden = "hide", swapfile = false }, minimal = false, noautocmd = false, -- position = "right", zindex = 20, wo = { winhighlight = "NormalFloat:Normal" }, footer_keys = true, border = true, } < SNACKS_IMAGE *snacks.nvim-styles-styles-snacks_image* >lua { relative = "cursor", border = true, focusable = false, backdrop = false, row = 1, col = 1, -- width/height are automatically set by the image size unless specified below } < SPLIT *snacks.nvim-styles-styles-split* >lua { position = "bottom", height = 0.4, width = 0.4, } < TERMINAL *snacks.nvim-styles-styles-terminal* >lua { bo = { filetype = "snacks_terminal", }, wo = {}, stack = true, -- when enabled, multiple split windows with the same position will be stacked together (useful for terminals) keys = { q = "hide", gf = function(self) local f = vim.fn.findfile(vim.fn.expand(""), "**") if f == "" then Snacks.notify.warn("No file under cursor") else self:hide() vim.schedule(function() vim.cmd("e " .. f) end) end end, term_normal = { "", function(self) self.esc_timer = self.esc_timer or (vim.uv or vim.loop).new_timer() if self.esc_timer:is_active() then self.esc_timer:stop() vim.cmd("stopinsert") else self.esc_timer:start(200, 0, function() end) return "" end end, mode = "t", expr = true, desc = "Double escape to normal mode", }, }, } < ZEN *snacks.nvim-styles-styles-zen* >lua { enter = true, fixbuf = false, minimal = false, width = 120, height = 0, backdrop = { transparent = true, blend = 40 }, keys = { q = false }, zindex = 40, wo = { winhighlight = "NormalFloat:Normal", }, w = { snacks_main = true, }, } < ZOOM_INDICATOR *snacks.nvim-styles-styles-zoom_indicator* fullscreen indicator only shown when the window is maximized >lua { text = "▍ zoom 󰊓 ", minimal = true, enter = false, focusable = false, height = 1, row = 0, col = -1, backdrop = false, } < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-terminal.txt ================================================ *snacks-terminal* snacks terminal docs ============================================================================== Table of Contents *snacks.nvim-terminal-table-of-contents* 1. Usage |snacks.nvim-terminal-usage| - Edgy Integration |snacks.nvim-terminal-usage-edgy-integration| 2. Setup |snacks.nvim-terminal-setup| 3. Config |snacks.nvim-terminal-config| 4. Styles |snacks.nvim-terminal-styles| - terminal |snacks.nvim-terminal-styles-terminal| 5. Types |snacks.nvim-terminal-types| 6. Module |snacks.nvim-terminal-module| - Snacks.terminal() |snacks.nvim-terminal-module-snacks.terminal()| - Snacks.terminal.colorize()|snacks.nvim-terminal-module-snacks.terminal.colorize()| - Snacks.terminal.focus()|snacks.nvim-terminal-module-snacks.terminal.focus()| - Snacks.terminal.get() |snacks.nvim-terminal-module-snacks.terminal.get()| - Snacks.terminal.list()|snacks.nvim-terminal-module-snacks.terminal.list()| - Snacks.terminal.open()|snacks.nvim-terminal-module-snacks.terminal.open()| - Snacks.terminal.tid() |snacks.nvim-terminal-module-snacks.terminal.tid()| - Snacks.terminal.toggle()|snacks.nvim-terminal-module-snacks.terminal.toggle()| 7. Links |snacks.nvim-terminal-links| Create and toggle terminal windows. Based on the provided options, some defaults will be set: - if no `cmd` is provided, the window will be opened in a bottom split - if `cmd` is provided, the window will be opened in a floating window - for splits, a `winbar` will be added with the terminal title ============================================================================== 1. Usage *snacks.nvim-terminal-usage* EDGY INTEGRATION *snacks.nvim-terminal-usage-edgy-integration* >lua { "folke/edgy.nvim", ---@module 'edgy' ---@param opts Edgy.Config opts = function(_, opts) for _, pos in ipairs({ "top", "bottom", "left", "right" }) do opts[pos] = opts[pos] or {} table.insert(opts[pos], { ft = "snacks_terminal", size = { height = 0.4 }, title = "%{b:snacks_terminal.id}: %{b:term_title}", filter = function(_buf, win) return vim.w[win].snacks_win and vim.w[win].snacks_win.position == pos and vim.w[win].snacks_win.relative == "editor" and not vim.w[win].trouble_preview end, }) end end, } < ============================================================================== 2. Setup *snacks.nvim-terminal-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { terminal = { -- your terminal configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 3. Config *snacks.nvim-terminal-config* >lua ---@class snacks.terminal.Config ---@field win? snacks.win.Config|{} ---@field shell? string|string[] The shell to use. Defaults to `vim.o.shell` ---@field override? fun(cmd?: string|string[], opts?: snacks.terminal.Opts) Use this to use a different terminal implementation { win = { style = "terminal" }, } < ============================================================================== 4. Styles *snacks.nvim-terminal-styles* Check the styles docs for more information on how to customize these styles TERMINAL *snacks.nvim-terminal-styles-terminal* >lua { bo = { filetype = "snacks_terminal", }, wo = {}, stack = true, -- when enabled, multiple split windows with the same position will be stacked together (useful for terminals) keys = { q = "hide", gf = function(self) local f = vim.fn.findfile(vim.fn.expand(""), "**") if f == "" then Snacks.notify.warn("No file under cursor") else self:hide() vim.schedule(function() vim.cmd("e " .. f) end) end end, term_normal = { "", function(self) self.esc_timer = self.esc_timer or (vim.uv or vim.loop).new_timer() if self.esc_timer:is_active() then self.esc_timer:stop() vim.cmd("stopinsert") else self.esc_timer:start(200, 0, function() end) return "" end end, mode = "t", expr = true, desc = "Double escape to normal mode", }, }, } < ============================================================================== 5. Types *snacks.nvim-terminal-types* >lua ---@class snacks.terminal.Opts: snacks.terminal.Config ---@field cwd? string ---@field count? integer ---@field env? table ---@field start_insert? boolean start insert mode when starting the terminal ---@field auto_insert? boolean start insert mode when entering the terminal buffer ---@field auto_close? boolean close the terminal buffer when the process exits ---@field interactive? boolean shortcut for `start_insert`, `auto_close` and `auto_insert` (default: true) < ============================================================================== 6. Module *snacks.nvim-terminal-module* >lua ---@class snacks.terminal: snacks.win ---@field cmd? string | string[] ---@field opts snacks.terminal.Opts Snacks.terminal = {} < `Snacks.terminal()` *Snacks.terminal()* >lua ---@type fun(cmd?: string|string[], opts?: snacks.terminal.Opts): snacks.terminal Snacks.terminal() < `Snacks.terminal.colorize()` *Snacks.terminal.colorize()* Colorize the current buffer. Replaces ansii color codes with the actual colors. Example: >sh ls -la --color=always | nvim - -c "lua Snacks.terminal.colorize()" < >lua Snacks.terminal.colorize() < `Snacks.terminal.focus()` *Snacks.terminal.focus()* Focus a terminal window. If already focused, hide it. The terminal id is based on the `cmd`, `cwd`, `env` and `vim.v.count1` options. >lua ---@param cmd? string | string[] ---@param opts? snacks.terminal.Opts Snacks.terminal.focus(cmd, opts) < `Snacks.terminal.get()` *Snacks.terminal.get()* Get or create a terminal window. The terminal id is based on the `cmd`, `cwd`, `env` and `vim.v.count1` options. `opts.create` defaults to `true`. >lua ---@param cmd? string | string[] ---@param opts? snacks.terminal.Opts| {create?: boolean} ---@return snacks.win? terminal, boolean? created Snacks.terminal.get(cmd, opts) < `Snacks.terminal.list()` *Snacks.terminal.list()* >lua ---@return snacks.win[] Snacks.terminal.list() < `Snacks.terminal.open()` *Snacks.terminal.open()* Open a new terminal window. >lua ---@param cmd? string | string[] ---@param opts? snacks.terminal.Opts Snacks.terminal.open(cmd, opts) < `Snacks.terminal.tid()` *Snacks.terminal.tid()* Get a terminal id based on the `cmd`, `cwd`, `env` and `vim.v.count1` options. >lua ---@param cmd? string | string[] ---@param opts? snacks.terminal.Opts Snacks.terminal.tid(cmd, opts) < `Snacks.terminal.toggle()` *Snacks.terminal.toggle()* Toggle a terminal window. The terminal id is based on the `cmd`, `cwd`, `env` and `vim.v.count1` options. >lua ---@param cmd? string | string[] ---@param opts? snacks.terminal.Opts Snacks.terminal.toggle(cmd, opts) < ============================================================================== 7. Links *snacks.nvim-terminal-links* 1. *image*: https://github.com/user-attachments/assets/afcc9989-57d7-4518-a390-cc7d6f0cec13 Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-toggle.txt ================================================ *snacks-toggle* snacks toggle docs ============================================================================== Table of Contents *snacks.nvim-toggle-table-of-contents* 1. Setup |snacks.nvim-toggle-setup| 2. Config |snacks.nvim-toggle-config| 3. Types |snacks.nvim-toggle-types| 4. Module |snacks.nvim-toggle-module| - Snacks.toggle() |snacks.nvim-toggle-module-snacks.toggle()| - Snacks.toggle.animate()|snacks.nvim-toggle-module-snacks.toggle.animate()| - Snacks.toggle.diagnostics()|snacks.nvim-toggle-module-snacks.toggle.diagnostics()| - Snacks.toggle.dim() |snacks.nvim-toggle-module-snacks.toggle.dim()| - Snacks.toggle.get() |snacks.nvim-toggle-module-snacks.toggle.get()| - Snacks.toggle.indent() |snacks.nvim-toggle-module-snacks.toggle.indent()| - Snacks.toggle.inlay_hints()|snacks.nvim-toggle-module-snacks.toggle.inlay_hints()| - Snacks.toggle.line_number()|snacks.nvim-toggle-module-snacks.toggle.line_number()| - Snacks.toggle.new() |snacks.nvim-toggle-module-snacks.toggle.new()| - Snacks.toggle.option() |snacks.nvim-toggle-module-snacks.toggle.option()| - Snacks.toggle.profiler()|snacks.nvim-toggle-module-snacks.toggle.profiler()| - Snacks.toggle.profiler_highlights()|snacks.nvim-toggle-module-snacks.toggle.profiler_highlights()| - Snacks.toggle.scroll() |snacks.nvim-toggle-module-snacks.toggle.scroll()| - Snacks.toggle.treesitter()|snacks.nvim-toggle-module-snacks.toggle.treesitter()| - Snacks.toggle.words() |snacks.nvim-toggle-module-snacks.toggle.words()| - Snacks.toggle.zen() |snacks.nvim-toggle-module-snacks.toggle.zen()| - Snacks.toggle.zoom() |snacks.nvim-toggle-module-snacks.toggle.zoom()| 5. Links |snacks.nvim-toggle-links| Toggle keymaps integrated with which-key icons / colors ============================================================================== 1. Setup *snacks.nvim-toggle-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { toggle = { -- your toggle configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Config *snacks.nvim-toggle-config* >lua ---@class snacks.toggle.Config ---@field icon? string|{ enabled: string, disabled: string } ---@field color? string|{ enabled: string, disabled: string } ---@field wk_desc? string|{ enabled: string, disabled: string } ---@field map? fun(mode: string|string[], lhs: string, rhs: string|fun(), opts?: vim.keymap.set.Opts) ---@field which_key? boolean ---@field notify? boolean|fun(state:boolean, opts: snacks.toggle.Opts) { map = vim.keymap.set, -- keymap.set function to use which_key = true, -- integrate with which-key to show enabled/disabled icons and colors notify = true, -- show a notification when toggling -- icons for enabled/disabled states icon = { enabled = " ", disabled = " ", }, -- colors for enabled/disabled states color = { enabled = "green", disabled = "yellow", }, wk_desc = { enabled = "Disable ", disabled = "Enable ", }, } < ============================================================================== 3. Types *snacks.nvim-toggle-types* >lua ---@class snacks.toggle.Opts: snacks.toggle.Config ---@field id? string ---@field name string ---@field get fun():boolean ---@field set fun(state:boolean) < ============================================================================== 4. Module *snacks.nvim-toggle-module* `Snacks.toggle()` *Snacks.toggle()* >lua ---@type fun(... :snacks.toggle.Opts): snacks.toggle.Class Snacks.toggle() < `Snacks.toggle.animate()` *Snacks.toggle.animate()* >lua Snacks.toggle.animate() < `Snacks.toggle.diagnostics()` *Snacks.toggle.diagnostics()* >lua ---@param opts? snacks.toggle.Config Snacks.toggle.diagnostics(opts) < `Snacks.toggle.dim()` *Snacks.toggle.dim()* >lua Snacks.toggle.dim() < `Snacks.toggle.get()` *Snacks.toggle.get()* >lua ---@param id string ---@return snacks.toggle.Class? Snacks.toggle.get(id) < `Snacks.toggle.indent()` *Snacks.toggle.indent()* >lua Snacks.toggle.indent() < `Snacks.toggle.inlay_hints()` *Snacks.toggle.inlay_hints()* >lua ---@param opts? snacks.toggle.Config Snacks.toggle.inlay_hints(opts) < `Snacks.toggle.line_number()` *Snacks.toggle.line_number()* >lua ---@param opts? snacks.toggle.Config Snacks.toggle.line_number(opts) < `Snacks.toggle.new()` *Snacks.toggle.new()* >lua ---@param ... snacks.toggle.Opts Snacks.toggle.new(...) < `Snacks.toggle.option()` *Snacks.toggle.option()* >lua ---@param option string ---@param opts? snacks.toggle.Config | {on?: unknown, off?: unknown, global?: boolean} Snacks.toggle.option(option, opts) < `Snacks.toggle.profiler()` *Snacks.toggle.profiler()* >lua Snacks.toggle.profiler() < `Snacks.toggle.profiler_highlights()` *Snacks.toggle.profiler_highlights()* >lua Snacks.toggle.profiler_highlights() < `Snacks.toggle.scroll()` *Snacks.toggle.scroll()* >lua Snacks.toggle.scroll() < `Snacks.toggle.treesitter()` *Snacks.toggle.treesitter()* >lua ---@param opts? snacks.toggle.Config Snacks.toggle.treesitter(opts) < `Snacks.toggle.words()` *Snacks.toggle.words()* >lua Snacks.toggle.words() < `Snacks.toggle.zen()` *Snacks.toggle.zen()* >lua Snacks.toggle.zen() < `Snacks.toggle.zoom()` *Snacks.toggle.zoom()* >lua Snacks.toggle.zoom() < ============================================================================== 5. Links *snacks.nvim-toggle-links* 1. *image*: https://github.com/user-attachments/assets/6d843acd-1ac1-44fd-b318-58b4c17de2d5 Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-util.txt ================================================ *snacks-util* snacks util docs ============================================================================== Table of Contents *snacks.nvim-util-table-of-contents* 1. Types |snacks.nvim-util-types| 2. Module |snacks.nvim-util-module| - Snacks.util.blend() |snacks.nvim-util-module-snacks.util.blend()| - Snacks.util.bo() |snacks.nvim-util-module-snacks.util.bo()| - Snacks.util.color() |snacks.nvim-util-module-snacks.util.color()| - Snacks.util.debounce() |snacks.nvim-util-module-snacks.util.debounce()| - Snacks.util.file_decode()|snacks.nvim-util-module-snacks.util.file_decode()| - Snacks.util.file_encode()|snacks.nvim-util-module-snacks.util.file_encode()| - Snacks.util.get_lang() |snacks.nvim-util-module-snacks.util.get_lang()| - Snacks.util.icon() |snacks.nvim-util-module-snacks.util.icon()| - Snacks.util.is_float() |snacks.nvim-util-module-snacks.util.is_float()| - Snacks.util.is_transparent()|snacks.nvim-util-module-snacks.util.is_transparent()| - Snacks.util.keycode() |snacks.nvim-util-module-snacks.util.keycode()| - Snacks.util.normkey() |snacks.nvim-util-module-snacks.util.normkey()| - Snacks.util.on_key() |snacks.nvim-util-module-snacks.util.on_key()| - Snacks.util.on_module() |snacks.nvim-util-module-snacks.util.on_module()| - Snacks.util.parse() |snacks.nvim-util-module-snacks.util.parse()| - Snacks.util.path_type() |snacks.nvim-util-module-snacks.util.path_type()| - Snacks.util.redraw() |snacks.nvim-util-module-snacks.util.redraw()| - Snacks.util.redraw_range()|snacks.nvim-util-module-snacks.util.redraw_range()| - Snacks.util.ref() |snacks.nvim-util-module-snacks.util.ref()| - Snacks.util.set_hl() |snacks.nvim-util-module-snacks.util.set_hl()| - Snacks.util.spinner() |snacks.nvim-util-module-snacks.util.spinner()| - Snacks.util.stop() |snacks.nvim-util-module-snacks.util.stop()| - Snacks.util.throttle() |snacks.nvim-util-module-snacks.util.throttle()| - Snacks.util.var() |snacks.nvim-util-module-snacks.util.var()| - Snacks.util.winhl() |snacks.nvim-util-module-snacks.util.winhl()| - Snacks.util.wo() |snacks.nvim-util-module-snacks.util.wo()| ============================================================================== 1. Types *snacks.nvim-util-types* >lua ---@alias snacks.util.hl table < ============================================================================== 2. Module *snacks.nvim-util-module* >lua ---@class snacks.util ---@field spawn snacks.spawn ---@field lsp snacks.lsp Snacks.util = {} < `Snacks.util.blend()` *Snacks.util.blend()* >lua ---@param fg string foreground color ---@param bg string background color ---@param alpha number number between 0 and 1. 0 results in bg, 1 results in fg Snacks.util.blend(fg, bg, alpha) < `Snacks.util.bo()` *Snacks.util.bo()* Set buffer-local options. >lua ---@param buf number ---@param bo vim.bo|{} Snacks.util.bo(buf, bo) < `Snacks.util.color()` *Snacks.util.color()* >lua ---@param group string|string[] hl group to get color from ---@param prop? string property to get. Defaults to "fg" Snacks.util.color(group, prop) < `Snacks.util.debounce()` *Snacks.util.debounce()* >lua ---@generic T ---@param fn T ---@param opts? {ms?:number} ---@return T Snacks.util.debounce(fn, opts) < `Snacks.util.file_decode()` *Snacks.util.file_decode()* Decodes a file name to a string. >lua ---@param str string Snacks.util.file_decode(str) < `Snacks.util.file_encode()` *Snacks.util.file_encode()* Encodes a string to be used as a file name. >lua ---@param str string Snacks.util.file_encode(str) < `Snacks.util.get_lang()` *Snacks.util.get_lang()* >lua ---@param lang string|number|nil ---@overload fun(buf:number):string? ---@overload fun(ft:string):string? ---@return string? Snacks.util.get_lang(lang) < `Snacks.util.icon()` *Snacks.util.icon()* Get an icon from `mini.icons` or `nvim-web-devicons`. >lua ---@param name string ---@param cat? string "file"|"filetype"|"extension"|"directory" ---@param opts? { fallback?: {dir?:string, file?:string} } ---@return string, string? Snacks.util.icon(name, cat, opts) < `Snacks.util.is_float()` *Snacks.util.is_float()* >lua ---@param win? number Snacks.util.is_float(win) < `Snacks.util.is_transparent()` *Snacks.util.is_transparent()* Check if the colorscheme is transparent. >lua Snacks.util.is_transparent() < `Snacks.util.keycode()` *Snacks.util.keycode()* >lua ---@param str string Snacks.util.keycode(str) < `Snacks.util.normkey()` *Snacks.util.normkey()* >lua ---@param key string Snacks.util.normkey(key) < `Snacks.util.on_key()` *Snacks.util.on_key()* >lua ---@param key string ---@param cb fun(key:string) Snacks.util.on_key(key, cb) < `Snacks.util.on_module()` *Snacks.util.on_module()* Call a function when a module is loaded. The callback is called immediately if the module is already loaded. Otherwise, it is called when the module is loaded. >lua ---@param modname string ---@param cb fun(modname:string) Snacks.util.on_module(modname, cb) < `Snacks.util.parse()` *Snacks.util.parse()* Parse async when available. >lua ---@param parser vim.treesitter.LanguageTree ---@param range boolean|Range|nil: Parse this range in the parser's source. ---@param on_parse fun(err?: string, trees?: table) Function invoked when parsing completes. Snacks.util.parse(parser, range, on_parse) < `Snacks.util.path_type()` *Snacks.util.path_type()* Better validation to check if path is a dir or a file >lua ---@param path string ---@return "directory"|"file" Snacks.util.path_type(path) < `Snacks.util.redraw()` *Snacks.util.redraw()* Redraw the window. Optimized for Neovim >= 0.10 >lua ---@param win number Snacks.util.redraw(win) < `Snacks.util.redraw_range()` *Snacks.util.redraw_range()* Redraw the range of lines in the window. Optimized for Neovim >= 0.10 >lua ---@param win number ---@param from number -- 1-indexed, inclusive ---@param to number -- 1-indexed, inclusive Snacks.util.redraw_range(win, from, to) < `Snacks.util.ref()` *Snacks.util.ref()* >lua ---@generic T ---@param t T ---@return { value?:T }|fun():T? Snacks.util.ref(t) < `Snacks.util.set_hl()` *Snacks.util.set_hl()* Ensures the hl groups are always set, even after a colorscheme change. >lua ---@param groups snacks.util.hl ---@param opts? { prefix?:string, default?:boolean, managed?:boolean } Snacks.util.set_hl(groups, opts) < `Snacks.util.spinner()` *Snacks.util.spinner()* >lua Snacks.util.spinner() < `Snacks.util.stop()` *Snacks.util.stop()* >lua ---@param handle? uv.uv_handle_t|uv.uv_timer_t Snacks.util.stop(handle) < `Snacks.util.throttle()` *Snacks.util.throttle()* >lua ---@generic T ---@param fn T ---@param opts? {ms?:number} ---@return T Snacks.util.throttle(fn, opts) < `Snacks.util.var()` *Snacks.util.var()* Get a buffer or global variable. >lua ---@generic T ---@param buf? number ---@param name string ---@param default? T ---@return T Snacks.util.var(buf, name, default) < `Snacks.util.winhl()` *Snacks.util.winhl()* Merges vim.wo.winhighlight options. Option values can be a string or a dictionary. >lua ---@param ... string|table Snacks.util.winhl(...) < `Snacks.util.wo()` *Snacks.util.wo()* Set window-local options. >lua ---@param win number ---@param wo vim.wo|{}|{winhighlight: string|table} Snacks.util.wo(win, wo) < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-win.txt ================================================ *snacks-win* snacks win docs ============================================================================== Table of Contents *snacks.nvim-win-table-of-contents* 1. Usage |snacks.nvim-win-usage| 2. Setup |snacks.nvim-win-setup| 3. Config |snacks.nvim-win-config| 4. Styles |snacks.nvim-win-styles| - float |snacks.nvim-win-styles-float| - help |snacks.nvim-win-styles-help| - minimal |snacks.nvim-win-styles-minimal| - split |snacks.nvim-win-styles-split| 5. Types |snacks.nvim-win-types| 6. Module |snacks.nvim-win-module| - Snacks.win() |snacks.nvim-win-module-snacks.win()| - Snacks.win.is_border() |snacks.nvim-win-module-snacks.win.is_border()| - Snacks.win.new() |snacks.nvim-win-module-snacks.win.new()| - Snacks.win.zindex() |snacks.nvim-win-module-snacks.win.zindex()| - win:action() |snacks.nvim-win-module-win:action()| - win:add_padding() |snacks.nvim-win-module-win:add_padding()| - win:border() |snacks.nvim-win-module-win:border()| - win:border_size() |snacks.nvim-win-module-win:border_size()| - win:border_text_width() |snacks.nvim-win-module-win:border_text_width()| - win:buf_valid() |snacks.nvim-win-module-win:buf_valid()| - win:close() |snacks.nvim-win-module-win:close()| - win:destroy() |snacks.nvim-win-module-win:destroy()| - win:dim() |snacks.nvim-win-module-win:dim()| - win:execute() |snacks.nvim-win-module-win:execute()| - win:fixbuf() |snacks.nvim-win-module-win:fixbuf()| - win:focus() |snacks.nvim-win-module-win:focus()| - win:has_border() |snacks.nvim-win-module-win:has_border()| - win:hide() |snacks.nvim-win-module-win:hide()| - win:hscroll() |snacks.nvim-win-module-win:hscroll()| - win:is_floating() |snacks.nvim-win-module-win:is_floating()| - win:line() |snacks.nvim-win-module-win:line()| - win:lines() |snacks.nvim-win-module-win:lines()| - win:map() |snacks.nvim-win-module-win:map()| - win:on() |snacks.nvim-win-module-win:on()| - win:on_current_tab() |snacks.nvim-win-module-win:on_current_tab()| - win:on_resize() |snacks.nvim-win-module-win:on_resize()| - win:parent_size() |snacks.nvim-win-module-win:parent_size()| - win:redraw() |snacks.nvim-win-module-win:redraw()| - win:scratch() |snacks.nvim-win-module-win:scratch()| - win:scroll() |snacks.nvim-win-module-win:scroll()| - win:set_buf() |snacks.nvim-win-module-win:set_buf()| - win:set_title() |snacks.nvim-win-module-win:set_title()| - win:show() |snacks.nvim-win-module-win:show()| - win:size() |snacks.nvim-win-module-win:size()| - win:text() |snacks.nvim-win-module-win:text()| - win:toggle() |snacks.nvim-win-module-win:toggle()| - win:toggle_help() |snacks.nvim-win-module-win:toggle_help()| - win:update() |snacks.nvim-win-module-win:update()| - win:valid() |snacks.nvim-win-module-win:valid()| - win:win_valid() |snacks.nvim-win-module-win:win_valid()| 7. Links |snacks.nvim-win-links| Easily create and manage floating windows or splits ============================================================================== 1. Usage *snacks.nvim-win-usage* >lua Snacks.win({ file = vim.api.nvim_get_runtime_file("doc/news.txt", false)[1], width = 0.6, height = 0.6, wo = { spell = false, wrap = false, signcolumn = "yes", statuscolumn = " ", conceallevel = 3, }, }) < ============================================================================== 2. Setup *snacks.nvim-win-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { win = { -- your win configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 3. Config *snacks.nvim-win-config* >lua ---@class snacks.win.Config: vim.api.keyset.win_config ---@field style? string merges with config from `Snacks.config.styles[style]` ---@field show? boolean Show the window immediately (default: true) ---@field footer_keys? boolean|string[] Show keys footer. When string[], only show those keys with lhs (default: false) ---@field height? number|fun(self:snacks.win):number Height of the window. Use <1 for relative height. 0 means full height. (default: 0.9) ---@field width? number|fun(self:snacks.win):number Width of the window. Use <1 for relative width. 0 means full width. (default: 0.9) ---@field min_height? number Minimum height of the window ---@field max_height? number Maximum height of the window ---@field min_width? number Minimum width of the window ---@field max_width? number Maximum width of the window ---@field col? number|fun(self:snacks.win):number Column of the window. Use <1 for relative column. (default: center) ---@field row? number|fun(self:snacks.win):number Row of the window. Use <1 for relative row. (default: center) ---@field minimal? boolean Disable a bunch of options to make the window minimal (default: true) ---@field position? "float"|"bottom"|"top"|"left"|"right"|"current" ---@field border? "none"|"top"|"right"|"bottom"|"left"|"top_bottom"|"hpad"|"vpad"|"rounded"|"single"|"double"|"solid"|"shadow"|"bold"|string[]|false|true ---@field buf? number If set, use this buffer instead of creating a new one ---@field file? string If set, use this file instead of creating a new buffer ---@field enter? boolean Enter the window after opening (default: false) ---@field backdrop? number|false|snacks.win.Backdrop Opacity of the backdrop (default: 60) ---@field wo? vim.wo|{} window options ---@field bo? vim.bo|{} buffer options ---@field b? table buffer local variables ---@field w? table window local variables ---@field ft? string filetype to use for treesitter/syntax highlighting. Won't override existing filetype ---@field scratch_ft? string filetype to use for scratch buffers ---@field keys? table Key mappings ---@field on_buf? fun(self: snacks.win) Callback after opening the buffer ---@field on_win? fun(self: snacks.win) Callback after opening the window ---@field on_close? fun(self: snacks.win) Callback after closing the window ---@field fixbuf? boolean don't allow other buffers to be opened in this window ---@field text? string|string[]|fun():(string[]|string) Initial lines to set in the buffer ---@field actions? table Actions that can be used in key mappings ---@field resize? boolean Automatically resize the window when the editor is resized ---@field stack? boolean When enabled, multiple split windows with the same position will be stacked together (useful for terminals) { show = true, fixbuf = true, relative = "editor", position = "float", minimal = true, wo = { winhighlight = "Normal:SnacksNormal,NormalNC:SnacksNormalNC,WinBar:SnacksWinBar,WinBarNC:SnacksWinBarNC,FloatTitle:SnacksTitle,FloatFooter:SnacksFooter,WinSeparator:SnacksWinSeparator", }, bo = {}, title_pos = "center", keys = { q = "close", }, footer_pos = "center", footer_keys = false, } < ============================================================================== 4. Styles *snacks.nvim-win-styles* Check the styles docs for more information on how to customize these styles FLOAT *snacks.nvim-win-styles-float* >lua { position = "float", backdrop = 60, height = 0.9, width = 0.9, zindex = 50, } < HELP *snacks.nvim-win-styles-help* >lua { position = "float", backdrop = false, border = "top", row = -1, width = 0, height = 0.3, } < MINIMAL *snacks.nvim-win-styles-minimal* >lua { wo = { cursorcolumn = false, cursorline = false, cursorlineopt = "both", colorcolumn = "", fillchars = "eob: ,lastline:…", foldcolumn = "0", list = false, listchars = "extends:…,tab: ", number = false, relativenumber = false, signcolumn = "no", spell = false, winbar = "", statuscolumn = "", wrap = false, sidescrolloff = 0, }, } < SPLIT *snacks.nvim-win-styles-split* >lua { position = "bottom", height = 0.4, width = 0.4, } < ============================================================================== 5. Types *snacks.nvim-win-types* >lua ---@class snacks.win.Keys: vim.api.keyset.keymap ---@field [1]? string ---@field [2]? string|string[]|fun(self: snacks.win): string? ---@field mode? string|string[] < >lua ---@class snacks.win.Event: vim.api.keyset.create_autocmd ---@field buf? true ---@field win? true ---@field callback? fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean? < >lua ---@class snacks.win.Backdrop ---@field bg? string ---@field blend? number ---@field transparent? boolean defaults to true ---@field win? snacks.win.Config overrides the backdrop window config < >lua ---@class snacks.win.Dim ---@field width number width of the window, without borders ---@field height number height of the window, without borders ---@field row number row of the window (0-indexed) ---@field col number column of the window (0-indexed) ---@field border? boolean whether the window has a border < >lua ---@alias snacks.win.Action.fn fun(self: snacks.win):(boolean|string?) ---@alias snacks.win.Action.spec snacks.win.Action|snacks.win.Action.fn ---@class snacks.win.Action ---@field action snacks.win.Action.fn ---@field desc? string < ============================================================================== 6. Module *snacks.nvim-win-module* >lua ---@class snacks.win ---@field id number ---@field buf? number ---@field scratch_buf? number ---@field win? number ---@field opts snacks.win.Config ---@field augroup? number ---@field backdrop? snacks.win ---@field keys snacks.win.Keys[] ---@field events (snacks.win.Event|{event:string|string[]})[] ---@field meta table ---@field closed? boolean Snacks.win = {} < `Snacks.win()` *Snacks.win()* >lua ---@type fun(opts? :snacks.win.Config|{}): snacks.win Snacks.win() < `Snacks.win.is_border()` *Snacks.win.is_border()* >lua Snacks.win.is_border(border) < `Snacks.win.new()` *Snacks.win.new()* >lua ---@param opts? snacks.win.Config|{} ---@return snacks.win Snacks.win.new(opts) < `Snacks.win.zindex()` *Snacks.win.zindex()* Calculate the next available zindex for snacks windows. New windows open on top of existing ones. >lua ---@param opts? { zindex?: number, tab?: number|boolean, all?: boolean, max?: number } ---@overload fun(zindex: number): number Snacks.win.zindex(opts) < WIN:ACTION() *snacks.nvim-win-module-win:action()* >lua ---@param actions string|string[] ---@return (fun(): boolean|string?) action, string? desc win:action(actions) < WIN:ADD_PADDING() *snacks.nvim-win-module-win:add_padding()* >lua win:add_padding() < WIN:BORDER() *snacks.nvim-win-module-win:border()* >lua win:border() < WIN:BORDER_SIZE() *snacks.nvim-win-module-win:border_size()* Calculate the size of the border >lua win:border_size() < WIN:BORDER_TEXT_WIDTH() *snacks.nvim-win-module-win:border_text_width()* >lua win:border_text_width() < WIN:BUF_VALID() *snacks.nvim-win-module-win:buf_valid()* >lua win:buf_valid() < WIN:CLOSE() *snacks.nvim-win-module-win:close()* >lua ---@param opts? { buf: boolean } win:close(opts) < WIN:DESTROY() *snacks.nvim-win-module-win:destroy()* >lua win:destroy() < WIN:DIM() *snacks.nvim-win-module-win:dim()* >lua ---@param parent? snacks.win.Dim win:dim(parent) < WIN:EXECUTE() *snacks.nvim-win-module-win:execute()* >lua ---@param actions string|string[] win:execute(actions) < WIN:FIXBUF() *snacks.nvim-win-module-win:fixbuf()* >lua win:fixbuf() < WIN:FOCUS() *snacks.nvim-win-module-win:focus()* >lua win:focus() < WIN:HAS_BORDER() *snacks.nvim-win-module-win:has_border()* >lua win:has_border() < WIN:HIDE() *snacks.nvim-win-module-win:hide()* >lua win:hide() < WIN:HSCROLL() *snacks.nvim-win-module-win:hscroll()* >lua ---@param left? boolean win:hscroll(left) < WIN:IS_FLOATING() *snacks.nvim-win-module-win:is_floating()* >lua win:is_floating() < WIN:LINE() *snacks.nvim-win-module-win:line()* >lua win:line(line) < WIN:LINES() *snacks.nvim-win-module-win:lines()* >lua ---@param from? number 1-indexed, inclusive ---@param to? number 1-indexed, inclusive win:lines(from, to) < WIN:MAP() *snacks.nvim-win-module-win:map()* >lua win:map() < WIN:ON() *snacks.nvim-win-module-win:on()* >lua ---@param event string|string[] ---@param cb fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean? ---@param opts? snacks.win.Event win:on(event, cb, opts) < WIN:ON_CURRENT_TAB() *snacks.nvim-win-module-win:on_current_tab()* >lua win:on_current_tab() < WIN:ON_RESIZE() *snacks.nvim-win-module-win:on_resize()* >lua win:on_resize() < WIN:PARENT_SIZE() *snacks.nvim-win-module-win:parent_size()* >lua ---@return { height: number, width: number } win:parent_size() < WIN:REDRAW() *snacks.nvim-win-module-win:redraw()* >lua win:redraw() < WIN:SCRATCH() *snacks.nvim-win-module-win:scratch()* >lua win:scratch() < WIN:SCROLL() *snacks.nvim-win-module-win:scroll()* >lua ---@param up? boolean win:scroll(up) < WIN:SET_BUF() *snacks.nvim-win-module-win:set_buf()* >lua ---@param buf number win:set_buf(buf) < WIN:SET_TITLE() *snacks.nvim-win-module-win:set_title()* >lua ---@param title string|{[1]:string, [2]:string}[] ---@param pos? "center"|"left"|"right" win:set_title(title, pos) < WIN:SHOW() *snacks.nvim-win-module-win:show()* >lua win:show() < WIN:SIZE() *snacks.nvim-win-module-win:size()* >lua ---@return { height: number, width: number } win:size() < WIN:TEXT() *snacks.nvim-win-module-win:text()* >lua ---@param from? number 1-indexed, inclusive ---@param to? number 1-indexed, inclusive win:text(from, to) < WIN:TOGGLE() *snacks.nvim-win-module-win:toggle()* >lua win:toggle() < WIN:TOGGLE_HELP() *snacks.nvim-win-module-win:toggle_help()* >lua ---@param opts? {col_width?: number, key_width?: number, win?: snacks.win.Config} win:toggle_help(opts) < WIN:UPDATE() *snacks.nvim-win-module-win:update()* >lua win:update() < WIN:VALID() *snacks.nvim-win-module-win:valid()* >lua win:valid() < WIN:WIN_VALID() *snacks.nvim-win-module-win:win_valid()* >lua win:win_valid() < ============================================================================== 7. Links *snacks.nvim-win-links* 1. *image*: https://github.com/user-attachments/assets/250acfbd-a624-4f42-a36b-9aab316ebf64 Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-words.txt ================================================ *snacks-words* snacks words docs ============================================================================== Table of Contents *snacks.nvim-words-table-of-contents* 1. Setup |snacks.nvim-words-setup| 2. Config |snacks.nvim-words-config| 3. Module |snacks.nvim-words-module| - Snacks.words.clear() |snacks.nvim-words-module-snacks.words.clear()| - Snacks.words.disable() |snacks.nvim-words-module-snacks.words.disable()| - Snacks.words.enable() |snacks.nvim-words-module-snacks.words.enable()| - Snacks.words.is_enabled()|snacks.nvim-words-module-snacks.words.is_enabled()| - Snacks.words.jump() |snacks.nvim-words-module-snacks.words.jump()| Auto-show LSP references and quickly navigate between them ============================================================================== 1. Setup *snacks.nvim-words-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { words = { -- your words configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Config *snacks.nvim-words-config* >lua ---@class snacks.words.Config ---@field enabled? boolean { debounce = 200, -- time in ms to wait before updating notify_jump = false, -- show a notification when jumping notify_end = true, -- show a notification when reaching the end foldopen = true, -- open folds after jumping jumplist = true, -- set jump point before jumping modes = { "n", "i", "c" }, -- modes to show references filter = function(buf) -- what buffers to enable `snacks.words` return vim.g.snacks_words ~= false and vim.b[buf].snacks_words ~= false end, } < ============================================================================== 3. Module *snacks.nvim-words-module* `Snacks.words.clear()` *Snacks.words.clear()* >lua Snacks.words.clear() < `Snacks.words.disable()` *Snacks.words.disable()* >lua Snacks.words.disable() < `Snacks.words.enable()` *Snacks.words.enable()* >lua Snacks.words.enable() < `Snacks.words.is_enabled()` *Snacks.words.is_enabled()* >lua ---@param opts? number|{buf?:number, modes:boolean} if modes is true, also check if the current mode is enabled Snacks.words.is_enabled(opts) < `Snacks.words.jump()` *Snacks.words.jump()* >lua ---@param count? number ---@param cycle? boolean Snacks.words.jump(count, cycle) < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim-zen.txt ================================================ *snacks-zen* snacks zen docs ============================================================================== Table of Contents *snacks.nvim-zen-table-of-contents* 1. Setup |snacks.nvim-zen-setup| 2. Config |snacks.nvim-zen-config| 3. Styles |snacks.nvim-zen-styles| - zen |snacks.nvim-zen-styles-zen| - zoom_indicator |snacks.nvim-zen-styles-zoom_indicator| 4. Module |snacks.nvim-zen-module| - Snacks.zen() |snacks.nvim-zen-module-snacks.zen()| - Snacks.zen.zen() |snacks.nvim-zen-module-snacks.zen.zen()| - Snacks.zen.zoom() |snacks.nvim-zen-module-snacks.zen.zoom()| 5. Links |snacks.nvim-zen-links| Zen mode • distraction-free coding. Integrates with `Snacks.toggle` to toggle various UI elements and with `Snacks.dim` to dim code out of scope. Similar plugins: - zen-mode.nvim - true-zen.nvim ============================================================================== 1. Setup *snacks.nvim-zen-setup* >lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { zen = { -- your zen configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } < ============================================================================== 2. Config *snacks.nvim-zen-config* >lua ---@class snacks.zen.Config { -- You can add any `Snacks.toggle` id here. -- Toggle state is restored when the window is closed. -- Toggle config options are NOT merged. ---@type table toggles = { dim = true, git_signs = false, mini_diff_signs = false, -- diagnostics = false, -- inlay_hints = false, }, center = true, -- center the window show = { statusline = false, -- can only be shown when using the global statusline tabline = false, }, ---@type snacks.win.Config win = { style = "zen" }, --- Callback when the window is opened. ---@param win snacks.win on_open = function(win) end, --- Callback when the window is closed. ---@param win snacks.win on_close = function(win) end, --- Options for the `Snacks.zen.zoom()` ---@type snacks.zen.Config zoom = { toggles = {}, center = false, show = { statusline = true, tabline = true }, win = { backdrop = false, width = 0, -- full width }, }, } < ============================================================================== 3. Styles *snacks.nvim-zen-styles* Check the styles docs for more information on how to customize these styles ZEN *snacks.nvim-zen-styles-zen* >lua { enter = true, fixbuf = false, minimal = false, width = 120, height = 0, backdrop = { transparent = true, blend = 40 }, keys = { q = false }, zindex = 40, wo = { winhighlight = "NormalFloat:Normal", }, w = { snacks_main = true, }, } < ZOOM_INDICATOR *snacks.nvim-zen-styles-zoom_indicator* fullscreen indicator only shown when the window is maximized >lua { text = "▍ zoom 󰊓 ", minimal = true, enter = false, focusable = false, height = 1, row = 0, col = -1, backdrop = false, } < ============================================================================== 4. Module *snacks.nvim-zen-module* `Snacks.zen()` *Snacks.zen()* >lua ---@type fun(opts: snacks.zen.Config): snacks.win Snacks.zen() < `Snacks.zen.zen()` *Snacks.zen.zen()* >lua ---@param opts? snacks.zen.Config Snacks.zen.zen(opts) < `Snacks.zen.zoom()` *Snacks.zen.zoom()* >lua ---@param opts? snacks.zen.Config Snacks.zen.zoom(opts) < ============================================================================== 5. Links *snacks.nvim-zen-links* 1. *image*: https://github.com/user-attachments/assets/77c607ec-c354-4e17-bcd1-fdcd4b4c0057 Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: doc/snacks.nvim.txt ================================================ *snacks* snacks docs ============================================================================== Table of Contents *snacks.nvim-table-of-contents* 1. snacks.nvim |snacks.nvim-snacks.nvim| - Features |snacks.nvim-snacks.nvim-features| - Requirements |snacks.nvim-snacks.nvim-requirements| - Installation |snacks.nvim-snacks.nvim-installation| - Configuration |snacks.nvim-snacks.nvim-configuration| - Usage |snacks.nvim-snacks.nvim-usage| - Highlight Groups |snacks.nvim-snacks.nvim-highlight-groups| ============================================================================== 1. snacks.nvim *snacks.nvim-snacks.nvim* A collection of small QoL plugins for Neovim. FEATURES *snacks.nvim-snacks.nvim-features* ----------------------------------------------------------------------- Snack Description Setup ---------------- ------------------------------------- ---------------- animate Efficient animations including over 45 easing functions (library) bigfile Deal with big files bufdelete Deletebuffers without disrupting window layout dashboard Beautiful declarative dashboards debug Prettyinspect & backtraces for debugging dim Focus on the active scope by dimming the rest explorer A file explorer (picker in disguise) gh GitHubCLI integration git Git utilities gitbrowse Open the current file, branch, commit, or repo in a browser (e.g. GitHub, GitLab, Bitbucket) image Image viewer using Kitty Graphics Protocol, supported by kitty, wezterm and ghostty indent Indentguides and scopes input Better vim.ui.input keymap Bettervim.keymap with support for filetypes and LSP clients layout Window layouts lazygit Open LazyGit in a float, auto-configure colorscheme and integration with Neovim notifier Pretty vim.notify notify Utilityfunctions to work with Neovim’s vim.notify picker Picker for selecting items profiler Neovimlua profiler quickfile When doing nvim somefile.txt, it will render the file as quickly as possible, before loading your plugins. rename LSP-integratedfile renaming with support for plugins like neo-tree.nvim and mini.files. scope Scope detection, text objects and jumping based on treesitter or indent scratch Scratchbuffers with a persistent file scroll Smooth scrolling statuscolumn Prettystatus column terminal Createand toggle floating/split terminals toggle Toggle keymaps integrated with which-key icons / colors util Utility functions for Snacks (library) win Create and manage floating windows or splits words Auto-show LSP references and quickly navigate between them zen Zenmode • distraction-free coding ----------------------------------------------------------------------- REQUIREMENTS *snacks.nvim-snacks.nvim-requirements* - **Neovim** >= 0.9.4 - for proper icons support: - mini.icons _(optional)_ - nvim-web-devicons _(optional)_ - a Nerd Font **(optional)** INSTALLATION *snacks.nvim-snacks.nvim-installation* Install the plugin with your package manager: LAZY.NVIM ~ [!important] A couple of plugins **require** `snacks.nvim` to be set-up early. Setup creates some autocmds and does not load any plugins. Check the code to see what it does. [!caution] You need to explicitly pass options for a plugin or set `enabled = true` to enable it. [!tip] It’s a good idea to run `:checkhealth snacks` to see if everything is set up correctly. >lua { "folke/snacks.nvim", priority = 1000, lazy = false, ---@type snacks.Config opts = { -- your configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below bigfile = { enabled = true }, dashboard = { enabled = true }, explorer = { enabled = true }, indent = { enabled = true }, input = { enabled = true }, picker = { enabled = true }, notifier = { enabled = true }, quickfile = { enabled = true }, scope = { enabled = true }, scroll = { enabled = true }, statuscolumn = { enabled = true }, words = { enabled = true }, }, } < For an in-depth setup of `snacks.nvim` with `lazy.nvim`, check the example below. CONFIGURATION *snacks.nvim-snacks.nvim-configuration* Please refer to the readme of each plugin for their specific configuration. Default Options ~ >lua ---@class snacks.Config ---@field animate? snacks.animate.Config ---@field bigfile? snacks.bigfile.Config ---@field dashboard? snacks.dashboard.Config ---@field dim? snacks.dim.Config ---@field explorer? snacks.explorer.Config ---@field gh? snacks.gh.Config ---@field gitbrowse? snacks.gitbrowse.Config ---@field image? snacks.image.Config ---@field indent? snacks.indent.Config ---@field input? snacks.input.Config ---@field layout? snacks.layout.Config ---@field lazygit? snacks.lazygit.Config ---@field notifier? snacks.notifier.Config ---@field picker? snacks.picker.Config ---@field profiler? snacks.profiler.Config ---@field quickfile? snacks.quickfile.Config ---@field scope? snacks.scope.Config ---@field scratch? snacks.scratch.Config ---@field scroll? snacks.scroll.Config ---@field statuscolumn? snacks.statuscolumn.Config ---@field terminal? snacks.terminal.Config ---@field toggle? snacks.toggle.Config ---@field win? snacks.win.Config ---@field words? snacks.words.Config ---@field zen? snacks.zen.Config ---@field styles? table ---@field image? snacks.image.Config|{} { image = { -- define these here, so that we don't need to load the image module formats = { "png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "heic", "avif", "mp4", "mov", "avi", "mkv", "webm", "pdf", "icns", }, }, } < Some plugins have examples in their documentation. You can include them in your config like this: >lua { dashboard = { example = "github" } } < If you want to customize options for a plugin after they have been resolved, you can use the `config` function: >lua { gitbrowse = { config = function(opts, defaults) table.insert(opts.remote_patterns, { "my", "custom pattern" }) end }, } < USAGE *snacks.nvim-snacks.nvim-usage* See the example below for how to configure `snacks.nvim`. >lua { "folke/snacks.nvim", priority = 1000, lazy = false, ---@type snacks.Config opts = { bigfile = { enabled = true }, dashboard = { enabled = true }, explorer = { enabled = true }, indent = { enabled = true }, input = { enabled = true }, notifier = { enabled = true, timeout = 3000, }, picker = { enabled = true }, quickfile = { enabled = true }, scope = { enabled = true }, scroll = { enabled = true }, statuscolumn = { enabled = true }, words = { enabled = true }, styles = { notification = { -- wo = { wrap = true } -- Wrap notifications } } }, keys = { -- Top Pickers & Explorer { "", function() Snacks.picker.smart() end, desc = "Smart Find Files" }, { ",", function() Snacks.picker.buffers() end, desc = "Buffers" }, { "/", function() Snacks.picker.grep() end, desc = "Grep" }, { ":", function() Snacks.picker.command_history() end, desc = "Command History" }, { "n", function() Snacks.picker.notifications() end, desc = "Notification History" }, { "e", function() Snacks.explorer() end, desc = "File Explorer" }, -- find { "fb", function() Snacks.picker.buffers() end, desc = "Buffers" }, { "fc", function() Snacks.picker.files({ cwd = vim.fn.stdpath("config") }) end, desc = "Find Config File" }, { "ff", function() Snacks.picker.files() end, desc = "Find Files" }, { "fg", function() Snacks.picker.git_files() end, desc = "Find Git Files" }, { "fp", function() Snacks.picker.projects() end, desc = "Projects" }, { "fr", function() Snacks.picker.recent() end, desc = "Recent" }, -- git { "gb", function() Snacks.picker.git_branches() end, desc = "Git Branches" }, { "gl", function() Snacks.picker.git_log() end, desc = "Git Log" }, { "gL", function() Snacks.picker.git_log_line() end, desc = "Git Log Line" }, { "gs", function() Snacks.picker.git_status() end, desc = "Git Status" }, { "gS", function() Snacks.picker.git_stash() end, desc = "Git Stash" }, { "gd", function() Snacks.picker.git_diff() end, desc = "Git Diff (Hunks)" }, { "gf", function() Snacks.picker.git_log_file() end, desc = "Git Log File" }, -- gh { "gi", function() Snacks.picker.gh_issue() end, desc = "GitHub Issues (open)" }, { "gI", function() Snacks.picker.gh_issue({ state = "all" }) end, desc = "GitHub Issues (all)" }, { "gp", function() Snacks.picker.gh_pr() end, desc = "GitHub Pull Requests (open)" }, { "gP", function() Snacks.picker.gh_pr({ state = "all" }) end, desc = "GitHub Pull Requests (all)" }, -- Grep { "sb", function() Snacks.picker.lines() end, desc = "Buffer Lines" }, { "sB", function() Snacks.picker.grep_buffers() end, desc = "Grep Open Buffers" }, { "sg", function() Snacks.picker.grep() end, desc = "Grep" }, { "sw", function() Snacks.picker.grep_word() end, desc = "Visual selection or word", mode = { "n", "x" } }, -- search { 's"', function() Snacks.picker.registers() end, desc = "Registers" }, { 's/', function() Snacks.picker.search_history() end, desc = "Search History" }, { "sa", function() Snacks.picker.autocmds() end, desc = "Autocmds" }, { "sb", function() Snacks.picker.lines() end, desc = "Buffer Lines" }, { "sc", function() Snacks.picker.command_history() end, desc = "Command History" }, { "sC", function() Snacks.picker.commands() end, desc = "Commands" }, { "sd", function() Snacks.picker.diagnostics() end, desc = "Diagnostics" }, { "sD", function() Snacks.picker.diagnostics_buffer() end, desc = "Buffer Diagnostics" }, { "sh", function() Snacks.picker.help() end, desc = "Help Pages" }, { "sH", function() Snacks.picker.highlights() end, desc = "Highlights" }, { "si", function() Snacks.picker.icons() end, desc = "Icons" }, { "sj", function() Snacks.picker.jumps() end, desc = "Jumps" }, { "sk", function() Snacks.picker.keymaps() end, desc = "Keymaps" }, { "sl", function() Snacks.picker.loclist() end, desc = "Location List" }, { "sm", function() Snacks.picker.marks() end, desc = "Marks" }, { "sM", function() Snacks.picker.man() end, desc = "Man Pages" }, { "sp", function() Snacks.picker.lazy() end, desc = "Search for Plugin Spec" }, { "sq", function() Snacks.picker.qflist() end, desc = "Quickfix List" }, { "sR", function() Snacks.picker.resume() end, desc = "Resume" }, { "su", function() Snacks.picker.undo() end, desc = "Undo History" }, { "uC", function() Snacks.picker.colorschemes() end, desc = "Colorschemes" }, -- LSP { "gd", function() Snacks.picker.lsp_definitions() end, desc = "Goto Definition" }, { "gD", function() Snacks.picker.lsp_declarations() end, desc = "Goto Declaration" }, { "gr", function() Snacks.picker.lsp_references() end, nowait = true, desc = "References" }, { "gI", function() Snacks.picker.lsp_implementations() end, desc = "Goto Implementation" }, { "gy", function() Snacks.picker.lsp_type_definitions() end, desc = "Goto T[y]pe Definition" }, { "gai", function() Snacks.picker.lsp_incoming_calls() end, desc = "C[a]lls Incoming" }, { "gao", function() Snacks.picker.lsp_outgoing_calls() end, desc = "C[a]lls Outgoing" }, { "ss", function() Snacks.picker.lsp_symbols() end, desc = "LSP Symbols" }, { "sS", function() Snacks.picker.lsp_workspace_symbols() end, desc = "LSP Workspace Symbols" }, -- Other { "z", function() Snacks.zen() end, desc = "Toggle Zen Mode" }, { "Z", function() Snacks.zen.zoom() end, desc = "Toggle Zoom" }, { ".", function() Snacks.scratch() end, desc = "Toggle Scratch Buffer" }, { "S", function() Snacks.scratch.select() end, desc = "Select Scratch Buffer" }, { "n", function() Snacks.notifier.show_history() end, desc = "Notification History" }, { "bd", function() Snacks.bufdelete() end, desc = "Delete Buffer" }, { "cR", function() Snacks.rename.rename_file() end, desc = "Rename File" }, { "gB", function() Snacks.gitbrowse() end, desc = "Git Browse", mode = { "n", "v" } }, { "gg", function() Snacks.lazygit() end, desc = "Lazygit" }, { "un", function() Snacks.notifier.hide() end, desc = "Dismiss All Notifications" }, { "", function() Snacks.terminal() end, desc = "Toggle Terminal" }, { "", function() Snacks.terminal() end, desc = "which_key_ignore" }, { "]]", function() Snacks.words.jump(vim.v.count1) end, desc = "Next Reference", mode = { "n", "t" } }, { "[[", function() Snacks.words.jump(-vim.v.count1) end, desc = "Prev Reference", mode = { "n", "t" } }, { "N", desc = "Neovim News", function() Snacks.win({ file = vim.api.nvim_get_runtime_file("doc/news.txt", false)[1], width = 0.6, height = 0.6, wo = { spell = false, wrap = false, signcolumn = "yes", statuscolumn = " ", conceallevel = 3, }, }) end, } }, init = function() vim.api.nvim_create_autocmd("User", { pattern = "VeryLazy", callback = function() -- Setup some globals for debugging (lazy-loaded) _G.dd = function(...) Snacks.debug.inspect(...) end _G.bt = function() Snacks.debug.backtrace() end -- Override print to use snacks for `:=` command if vim.fn.has("nvim-0.11") == 1 then vim._print = function(_, ...) dd(...) end else vim.print = _G.dd end -- Create some toggle mappings Snacks.toggle.option("spell", { name = "Spelling" }):map("us") Snacks.toggle.option("wrap", { name = "Wrap" }):map("uw") Snacks.toggle.option("relativenumber", { name = "Relative Number" }):map("uL") Snacks.toggle.diagnostics():map("ud") Snacks.toggle.line_number():map("ul") Snacks.toggle.option("conceallevel", { off = 0, on = vim.o.conceallevel > 0 and vim.o.conceallevel or 2 }):map("uc") Snacks.toggle.treesitter():map("uT") Snacks.toggle.option("background", { off = "light", on = "dark", name = "Dark Background" }):map("ub") Snacks.toggle.inlay_hints():map("uh") Snacks.toggle.indent():map("ug") Snacks.toggle.dim():map("uD") end, }) end, } < HIGHLIGHT GROUPS *snacks.nvim-snacks.nvim-highlight-groups* Snacks defines **a lot** of highlight groups and it’s impossible to document them all. Instead, you can use the picker to see all the highlight groups. >lua Snacks.picker.highlights({pattern = "hl_group:^Snacks"}) < Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: docs/animate.md ================================================ # 🍿 animate Efficient animation library including over 45 easing functions: - [Emmanuel Oga's easing functions](https://github.com/EmmanuelOga/easing) - [Easing functions overview](https://github.com/kikito/tween.lua?tab=readme-ov-file#easing-functions) There's at any given time at most one timer running, that takes care of all active animations, controlled by the `fps` setting. You can at any time disable all animations with: - `vim.g.snacks_animate = false` globally - `vim.b.snacks_animate = false` locally for the buffer Doing this, will disable `scroll`, `indent`, `dim` and all other animations. ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { animate = { -- your animate configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.animate.Config ---@field easing? snacks.animate.easing|snacks.animate.easing.Fn { ---@type snacks.animate.Duration|number duration = 20, -- ms per step easing = "linear", fps = 120, -- frames per second. Global setting for all animations } ``` ## 📚 Types All easing functions take these parameters: * `t` _(time)_: should go from 0 to duration * `b` _(begin)_: starting value of the property * `c` _(change)_: ending value of the property - starting value * `d` _(duration)_: total duration of the animation Some functions allow additional modifiers, like the elastic functions which also can receive an amplitud and a period parameters (defaults are included) ```lua ---@alias snacks.animate.easing.Fn fun(t: number, b: number, c: number, d: number): number ``` Duration can be specified as the total duration or the duration per step. When both are specified, the minimum of both is used. ```lua ---@class snacks.animate.Duration ---@field step? number duration per step in ms ---@field total? number total duration in ms ``` ```lua ---@class snacks.animate.Opts: snacks.animate.Config ---@field buf? number optional buffer to check if animations should be enabled ---@field int? boolean interpolate the value to an integer ---@field id? number|string unique identifier for the animation ``` ```lua ---@class snacks.animate.ctx ---@field anim snacks.animate.Animation ---@field prev number ---@field done boolean ``` ```lua ---@alias snacks.animate.cb fun(value:number, ctx: snacks.animate.ctx) ``` ## 📦 Module ### `Snacks.animate()` ```lua ---@type fun(from: number, to: number, cb: snacks.animate.cb, opts?: snacks.animate.Opts): snacks.animate.Animation Snacks.animate() ``` ### `Snacks.animate.add()` Add an animation ```lua ---@param from number ---@param to number ---@param cb snacks.animate.cb ---@param opts? snacks.animate.Opts Snacks.animate.add(from, to, cb, opts) ``` ### `Snacks.animate.del()` Delete an animation ```lua ---@param id number|string Snacks.animate.del(id) ``` ### `Snacks.animate.enabled()` Check if animations are enabled. Will return false if `snacks_animate` is set to false or if the buffer local variable `snacks_animate` is set to false. ```lua ---@param opts? {buf?: number, name?: string} Snacks.animate.enabled(opts) ``` ================================================ FILE: docs/bigfile.md ================================================ # 🍿 bigfile `bigfile` adds a new filetype `bigfile` to Neovim that triggers when the file is larger than the configured size. This automatically prevents things like LSP and Treesitter attaching to the buffer. Use the `setup` config function to further make changes to a `bigfile` buffer. The context provides the actual filetype. The default implementation enables `syntax` for the buffer and disables [mini.animate](https://github.com/nvim-mini/mini.animate) (if used) ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { bigfile = { -- your bigfile configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.bigfile.Config ---@field enabled? boolean { notify = true, -- show notification when big file detected size = 1.5 * 1024 * 1024, -- 1.5MB line_length = 1000, -- average line length (useful for minified files) -- Enable or disable features when big file detected ---@param ctx {buf: number, ft:string} setup = function(ctx) if vim.fn.exists(":NoMatchParen") ~= 0 then vim.cmd([[NoMatchParen]]) end Snacks.util.wo(0, { foldmethod = "manual", statuscolumn = "", conceallevel = 0 }) vim.b.completion = false vim.b.minianimate_disable = true vim.b.minihipatterns_disable = true vim.schedule(function() if vim.api.nvim_buf_is_valid(ctx.buf) then vim.bo[ctx.buf].syntax = ctx.ft end end) end, } ``` ================================================ FILE: docs/bufdelete.md ================================================ # 🍿 bufdelete Delete buffers without disrupting window layout. If the buffer you want to close has changes, a prompt will be shown to save or discard. ## 📚 Types ```lua ---@class snacks.bufdelete.Opts ---@field buf? number Buffer to delete. Defaults to the current buffer ---@field file? string Delete buffer by file name. If provided, `buf` is ignored ---@field force? boolean Delete the buffer even if it is modified ---@field filter? fun(buf: number): boolean Filter buffers to delete ---@field wipe? boolean Wipe the buffer instead of deleting it (see `:h :bwipeout`) ``` ## 📦 Module ### `Snacks.bufdelete()` ```lua ---@type fun(buf?: number|snacks.bufdelete.Opts) Snacks.bufdelete() ``` ### `Snacks.bufdelete.all()` Delete all buffers ```lua ---@param opts? snacks.bufdelete.Opts Snacks.bufdelete.all(opts) ``` ### `Snacks.bufdelete.delete()` Delete a buffer: - either the current buffer if `buf` is not provided - or the buffer `buf` if it is a number - or every buffer for which `buf` returns true if it is a function ```lua ---@param opts? number|snacks.bufdelete.Opts Snacks.bufdelete.delete(opts) ``` ### `Snacks.bufdelete.other()` Delete all buffers except the current one ```lua ---@param opts? snacks.bufdelete.Opts Snacks.bufdelete.other(opts) ``` ================================================ FILE: docs/dashboard.md ================================================ # 🍿 dashboard ## ✨ Features - declarative configuration - flexible layouts - multiple vertical panes - built-in sections: - **header**: show a header - **keys**: show keymaps - **projects**: show recent projects - **recent_files**: show recent files - **session**: session support - **startup**: startup time (lazy.nvim) - **terminal**: colored terminal output - super fast `terminal` sections with automatic caching ## 🚀 Usage The dashboard comes with a set of default sections, that can be customized with `opts.preset` or fully replaced with `opts.sections`. The default preset comes with support for: - pickers: - [fzf-lua](https://github.com/ibhagwan/fzf-lua) - [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) - [mini.pick](https://github.com/nvim-mini/mini.pick) - session managers: (only works with [lazy.nvim](https://github.com/folke/lazy.nvim)) - [persistence.nvim](https://github.com/folke/persistence.nvim) - [persisted.nvim](https://github.com/olimorris/persisted.nvim) - [neovim-session-manager](https://github.com/Shatur/neovim-session-manager) - [posession.nvim](https://github.com/jedrzejboczar/possession.nvim) - [mini.sessions](https://github.com/nvim-mini/mini.sessions) ### Section actions A section can have an `action` property that will be executed as: - a command if it starts with `:` - a keymap if it's a string not starting with `:` - a function if it's a function ```lua -- command { action = ":Telescope find_files", key = "f", }, ``` ```lua -- keymap { action = "ff", key = "f", }, ``` ```lua -- function { action = function() require("telescope.builtin").find_files() end, key = "h", }, ``` ### Item text Every item should have a `text` property with an array of `snacks.dashboard.Text` objects. If the `text` property is not provided, the `snacks.dashboard.Config.formats` will be used to generate the text. In the example below, both sections are equivalent. ```lua { text = { { " ", hl = "SnacksDashboardIcon" }, { "Find File", hl = "SnacksDashboardDesc", width = 50 }, { "[f]", hl = "SnacksDashboardKey" }, }, action = ":Telescope find_files", key = "f", }, ``` ```lua { action = ":Telescope find_files", key = "f", desc = "Find File", icon = " ", }, ``` ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { dashboard = { -- your dashboard configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.dashboard.Config ---@field enabled? boolean ---@field sections snacks.dashboard.Section ---@field formats table { width = 60, row = nil, -- dashboard position. nil for center col = nil, -- dashboard position. nil for center pane_gap = 4, -- empty columns between vertical panes autokeys = "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", -- autokey sequence -- These settings are used by some built-in sections preset = { -- Defaults to a picker that supports `fzf-lua`, `telescope.nvim` and `mini.pick` ---@type fun(cmd:string, opts:table)|nil pick = nil, -- Used by the `keys` section to show keymaps. -- Set your custom keymaps here. -- When using a function, the `items` argument are the default keymaps. ---@type snacks.dashboard.Item[] keys = { { icon = " ", key = "f", desc = "Find File", action = ":lua Snacks.dashboard.pick('files')" }, { icon = " ", key = "n", desc = "New File", action = ":ene | startinsert" }, { icon = " ", key = "g", desc = "Find Text", action = ":lua Snacks.dashboard.pick('live_grep')" }, { icon = " ", key = "r", desc = "Recent Files", action = ":lua Snacks.dashboard.pick('oldfiles')" }, { icon = " ", key = "c", desc = "Config", action = ":lua Snacks.dashboard.pick('files', {cwd = vim.fn.stdpath('config')})" }, { icon = " ", key = "s", desc = "Restore Session", section = "session" }, { icon = "󰒲 ", key = "L", desc = "Lazy", action = ":Lazy", enabled = package.loaded.lazy ~= nil }, { icon = " ", key = "q", desc = "Quit", action = ":qa" }, }, -- Used by the `header` section header = [[ ███╗ ██╗███████╗ ██████╗ ██╗ ██╗██╗███╗ ███╗ ████╗ ██║██╔════╝██╔═══██╗██║ ██║██║████╗ ████║ ██╔██╗ ██║█████╗ ██║ ██║██║ ██║██║██╔████╔██║ ██║╚██╗██║██╔══╝ ██║ ██║╚██╗ ██╔╝██║██║╚██╔╝██║ ██║ ╚████║███████╗╚██████╔╝ ╚████╔╝ ██║██║ ╚═╝ ██║ ╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═══╝ ╚═╝╚═╝ ╚═╝]], }, -- item field formatters formats = { icon = function(item) if item.file and item.icon == "file" or item.icon == "directory" then return Snacks.dashboard.icon(item.file, item.icon) end return { item.icon, width = 2, hl = "icon" } end, footer = { "%s", align = "center" }, header = { "%s", align = "center" }, file = function(item, ctx) local fname = vim.fn.fnamemodify(item.file, ":~") fname = ctx.width and #fname > ctx.width and vim.fn.pathshorten(fname) or fname if #fname > ctx.width then local dir = vim.fn.fnamemodify(fname, ":h") local file = vim.fn.fnamemodify(fname, ":t") if dir and file then file = file:sub(-(ctx.width - #dir - 2)) fname = dir .. "/…" .. file end end local dir, file = fname:match("^(.*)/(.+)$") return dir and { { dir .. "/", hl = "dir" }, { file, hl = "file" } } or { { fname, hl = "file" } } end, }, sections = { { section = "header" }, { section = "keys", gap = 1, padding = 1 }, { section = "startup" }, }, } ``` ## 🚀 Examples ### `advanced` A more advanced example using multiple panes ![image](https://github.com/user-attachments/assets/bbf4d2cd-6fc5-4122-a462-0ca59ba89545) ```lua { sections = { { section = "header" }, { pane = 2, section = "terminal", cmd = "colorscript -e square", height = 5, padding = 1, }, { section = "keys", gap = 1, padding = 1 }, { pane = 2, icon = " ", title = "Recent Files", section = "recent_files", indent = 2, padding = 1 }, { pane = 2, icon = " ", title = "Projects", section = "projects", indent = 2, padding = 1 }, { pane = 2, icon = " ", title = "Git Status", section = "terminal", enabled = function() return Snacks.git.get_root() ~= nil end, cmd = "git status --short --branch --renames", height = 5, padding = 1, ttl = 5 * 60, indent = 3, }, { section = "startup" }, }, } ``` ### `chafa` An example using the `chafa` command to display an image ![image](https://github.com/user-attachments/assets/e498ef8f-83ce-4917-a720-8cb31d98ecec) ```lua { sections = { { section = "terminal", cmd = "chafa ~/.config/wall.png --format symbols --symbols vhalf --size 60x17 --stretch; sleep .1", height = 17, padding = 1, }, { pane = 2, { section = "keys", gap = 1, padding = 1 }, { section = "startup" }, }, }, } ``` ### `compact_files` A more compact version of the `files` example ![image](https://github.com/user-attachments/assets/772e84fe-b220-4841-bbe9-6e28780dc30a) ```lua { sections = { { section = "header" }, { icon = " ", title = "Keymaps", section = "keys", indent = 2, padding = 1 }, { icon = " ", title = "Recent Files", section = "recent_files", indent = 2, padding = 1 }, { icon = " ", title = "Projects", section = "projects", indent = 2, padding = 1 }, { section = "startup" }, }, } ``` ### `doom` Similar to the Emacs Doom dashboard ![image](https://github.com/user-attachments/assets/823f702d-e5d0-449a-afd2-684e1fb97622) ```lua { sections = { { section = "header" }, { section = "keys", gap = 1, padding = 1 }, { section = "startup" }, }, } ``` ### `files` A simple example with a header, keys, recent files, and projects ![image](https://github.com/user-attachments/assets/e98997b6-07d3-4162-bc06-2768b78fe353) ```lua { sections = { { section = "header" }, { section = "keys", gap = 1 }, { icon = " ", title = "Recent Files", section = "recent_files", indent = 2, padding = { 2, 2 } }, { icon = " ", title = "Projects", section = "projects", indent = 2, padding = 2 }, { section = "startup" }, }, } ``` ### `github` Advanced example using the GitHub CLI. ![image](https://github.com/user-attachments/assets/747d7386-ef05-487f-9550-3e5ef94869fc) ```lua { sections = { { section = "header" }, { pane = 2, section = "terminal", cmd = "colorscript -e square", height = 5, padding = 1, }, { section = "keys", gap = 1, padding = 1 }, { pane = 2, icon = " ", desc = "Browse Repo", padding = 1, key = "b", action = function() Snacks.gitbrowse() end, }, function() local in_git = Snacks.git.get_root() ~= nil local cmds = { { title = "Notifications", cmd = "gh notify -s -a -n5", action = function() vim.ui.open("https://github.com/notifications") end, key = "n", icon = " ", height = 5, enabled = true, }, { title = "Open Issues", cmd = "gh issue list -L 3", key = "i", action = function() vim.fn.jobstart("gh issue list --web", { detach = true }) end, icon = " ", height = 7, }, { icon = " ", title = "Open PRs", cmd = "gh pr list -L 3", key = "P", action = function() vim.fn.jobstart("gh pr list --web", { detach = true }) end, height = 7, }, { icon = " ", title = "Git Status", cmd = "git --no-pager diff --stat -B -M -C", height = 10, }, } return vim.tbl_map(function(cmd) return vim.tbl_extend("force", { pane = 2, section = "terminal", enabled = in_git, padding = 1, ttl = 5 * 60, indent = 3, }, cmd) end, cmds) end, { section = "startup" }, }, } ``` ### `pokemon` Pokemons, because why not? ![image](https://github.com/user-attachments/assets/2fb17ecc-8bc0-48d3-a023-aa8dfc70247e) ```lua { sections = { { section = "header" }, { section = "keys", gap = 1, padding = 1 }, { section = "startup" }, { section = "terminal", cmd = "pokemon-colorscripts -r --no-title; sleep .1", random = 10, pane = 2, indent = 4, height = 30, }, }, } ``` ### `startify` Similar to the Vim Startify dashboard ![image](https://github.com/user-attachments/assets/561eff8c-ddf0-4de9-8485-e6be18a19c0b) ```lua { formats = { key = function(item) return { { "[", hl = "special" }, { item.key, hl = "key" }, { "]", hl = "special" } } end, }, sections = { { section = "terminal", cmd = "fortune -s | cowsay", hl = "header", padding = 1, indent = 8 }, { title = "MRU", padding = 1 }, { section = "recent_files", limit = 8, padding = 1 }, { title = "MRU ", file = vim.fn.fnamemodify(".", ":~"), padding = 1 }, { section = "recent_files", cwd = true, limit = 8, padding = 1 }, { title = "Sessions", padding = 1 }, { section = "projects", padding = 1 }, { title = "Bookmarks", padding = 1 }, { section = "keys" }, }, } ``` ## 🎨 Styles Check the [styles](https://github.com/folke/snacks.nvim/blob/main/docs/styles.md) docs for more information on how to customize these styles ### `dashboard` The default style for the dashboard. When opening the dashboard during startup, only the `bo` and `wo` options are used. The other options are used with `:lua Snacks.dashboard()` ```lua { zindex = 10, height = 0, width = 0, bo = { bufhidden = "wipe", buftype = "nofile", buflisted = false, filetype = "snacks_dashboard", swapfile = false, undofile = false, }, wo = { colorcolumn = "", cursorcolumn = false, cursorline = false, foldmethod = "manual", list = false, number = false, relativenumber = false, sidescrolloff = 0, signcolumn = "no", spell = false, statuscolumn = "", statusline = "", winbar = "", winhighlight = "Normal:SnacksDashboardNormal,NormalFloat:SnacksDashboardNormal", wrap = false, }, } ``` ## 📚 Types ```lua ---@class snacks.dashboard.Item ---@field indent? number ---@field align? "left" | "center" | "right" ---@field gap? number the number of empty lines between child items ---@field padding? number | {[1]:number, [2]:number} bottom or {bottom, top} padding --- The action to run when the section is selected or the key is pressed. --- * if it's a string starting with `:`, it will be run as a command --- * if it's a string, it will be executed as a keymap --- * if it's a function, it will be called ---@field action? snacks.dashboard.Action ---@field enabled? boolean|fun(opts:snacks.dashboard.Opts):boolean if false, the section will be disabled ---@field section? string the name of a section to include. See `Snacks.dashboard.sections` ---@field [string] any section options ---@field key? string shortcut key ---@field hidden? boolean when `true`, the item will not be shown, but the key will still be assigned ---@field autokey? boolean automatically assign a numerical key ---@field label? string ---@field desc? string ---@field file? string ---@field footer? string ---@field header? string ---@field icon? string ---@field title? string ---@field text? string|snacks.dashboard.Text[] ``` ```lua ---@alias snacks.dashboard.Format.ctx {width?:number} ---@alias snacks.dashboard.Action string|fun(self:snacks.dashboard.Class) ---@alias snacks.dashboard.Gen fun(self:snacks.dashboard.Class):snacks.dashboard.Section? ---@alias snacks.dashboard.Section snacks.dashboard.Item|snacks.dashboard.Gen|snacks.dashboard.Section[] ``` ```lua ---@class snacks.dashboard.Text ---@field [1] string the text ---@field hl? string the highlight group ---@field width? number the width used for alignment ---@field align? "left" | "center" | "right" ``` ```lua ---@class snacks.dashboard.Opts: snacks.dashboard.Config ---@field buf? number the buffer to use. If not provided, a new buffer will be created ---@field win? number the window to use. If not provided, a new floating window will be created ``` ## 📦 Module ### `Snacks.dashboard()` ```lua ---@type fun(opts?: snacks.dashboard.Opts): snacks.dashboard.Class Snacks.dashboard() ``` ### `Snacks.dashboard.have_plugin()` Checks if the plugin is installed. Only works with [lazy.nvim](https://github.com/folke/lazy.nvim) ```lua ---@param name string Snacks.dashboard.have_plugin(name) ``` ### `Snacks.dashboard.health()` ```lua Snacks.dashboard.health() ``` ### `Snacks.dashboard.icon()` Get an icon ```lua ---@param name string ---@param cat? string ---@return snacks.dashboard.Text Snacks.dashboard.icon(name, cat) ``` ### `Snacks.dashboard.oldfiles()` ```lua ---@param opts? {filter?: table} ---@return fun():string? Snacks.dashboard.oldfiles(opts) ``` ### `Snacks.dashboard.open()` ```lua ---@param opts? snacks.dashboard.Opts ---@return snacks.dashboard.Class Snacks.dashboard.open(opts) ``` ### `Snacks.dashboard.pick()` Used by the default preset to pick something ```lua ---@param cmd? string Snacks.dashboard.pick(cmd, opts) ``` ### `Snacks.dashboard.sections.header()` ```lua ---@return snacks.dashboard.Gen Snacks.dashboard.sections.header() ``` ### `Snacks.dashboard.sections.keys()` ```lua ---@return snacks.dashboard.Gen Snacks.dashboard.sections.keys() ``` ### `Snacks.dashboard.sections.projects()` Get the most recent projects based on git roots of recent files. The default action will change the directory to the project root, try to restore the session and open the picker if the session is not restored. You can customize the behavior by providing a custom action. Use `opts.dirs` to provide a list of directories to use instead of the git roots. ```lua ---@param opts? {limit?:number, dirs?:(string[]|fun():string[]), pick?:boolean, session?:boolean, action?:fun(dir), filter?:fun(dir:string):boolean?} Snacks.dashboard.sections.projects(opts) ``` ### `Snacks.dashboard.sections.recent_files()` Get the most recent files, optionally filtered by the current working directory or a custom directory. ```lua ---@param opts? {limit?:number, cwd?:string|boolean, filter?:fun(file:string):boolean?} ---@return snacks.dashboard.Gen Snacks.dashboard.sections.recent_files(opts) ``` ### `Snacks.dashboard.sections.session()` Adds a section to restore the session if any of the supported plugins are installed. ```lua ---@param item? snacks.dashboard.Item ---@return snacks.dashboard.Item? Snacks.dashboard.sections.session(item) ``` ### `Snacks.dashboard.sections.startup()` Add the startup section ```lua ---@param opts? {icon?:string} ---@return snacks.dashboard.Section? Snacks.dashboard.sections.startup(opts) ``` ### `Snacks.dashboard.sections.terminal()` ```lua ---@param opts {cmd:string|string[], ttl?:number, height?:number, width?:number, random?:number}|snacks.dashboard.Item ---@return snacks.dashboard.Gen Snacks.dashboard.sections.terminal(opts) ``` ### `Snacks.dashboard.setup()` Check if the dashboard should be opened ```lua Snacks.dashboard.setup() ``` ### `Snacks.dashboard.update()` Update the dashboard ```lua Snacks.dashboard.update() ``` ================================================ FILE: docs/debug.md ================================================ # 🍿 debug Utility functions you can use in your code. Personally, I have the code below at the top of my `init.lua`: ```lua _G.dd = function(...) Snacks.debug.inspect(...) end _G.bt = function() Snacks.debug.backtrace() end if vim.fn.has("nvim-0.11") == 1 then vim._print = function(_, ...) dd(...) end else vim.print = dd end ``` What this does: - Add a global `dd(...)` you can use anywhere to quickly show a notification with a pretty printed dump of the object(s) with lua treesitter highlighting - Add a global `bt()` to show a notification with a pretty backtrace. - Override Neovim's `vim.print`, which is also used by `:= {something = 123}` ![image](https://github.com/user-attachments/assets/0517aed7-fbd0-42ee-8058-c213410d80a7) ## 📚 Types ```lua ---@class snacks.debug.cmd ---@field cmd string|string[] ---@field level? snacks.notifier.level|vim.log.levels ---@field title? string ---@field args? string[] ---@field cwd? string ---@field group? boolean ---@field notify? boolean ---@field footer? string ---@field header? string ---@field props? table ``` ```lua ---@alias snacks.debug.Trace {name: string, time: number, [number]:snacks.debug.Trace} ---@alias snacks.debug.Stat {name:string, time:number, count?:number, depth?:number} ``` ## 📦 Module ### `Snacks.debug()` ```lua ---@type fun(...) Snacks.debug() ``` ### `Snacks.debug.backtrace()` Show a notification with a pretty backtrace ```lua ---@param msg? string|string[] ---@param opts? snacks.notify.Opts Snacks.debug.backtrace(msg, opts) ``` ### `Snacks.debug.cmd()` ```lua ---@param opts snacks.debug.cmd Snacks.debug.cmd(opts) ``` ### `Snacks.debug.inspect()` Show a notification with a pretty printed dump of the object(s) with lua treesitter highlighting and the location of the caller ```lua Snacks.debug.inspect(...) ``` ### `Snacks.debug.log()` Log a message to the file `./debug.log`. - a timestamp will be added to every message. - accepts multiple arguments and pretty prints them. - if the argument is not a string, it will be printed using `vim.inspect`. - if the message is smaller than 120 characters, it will be printed on a single line. ```lua Snacks.debug.log("Hello", { foo = "bar" }, 42) -- 2024-11-08 08:56:52 Hello { foo = "bar" } 42 ``` ```lua Snacks.debug.log(...) ``` ### `Snacks.debug.metrics()` ```lua Snacks.debug.metrics() ``` ### `Snacks.debug.profile()` Very simple function to profile a lua function. * **flush**: set to `true` to use `jit.flush` in every iteration. * **count**: defaults to 100 ```lua ---@param fn fun() ---@param opts? {count?: number, flush?: boolean, title?: string} Snacks.debug.profile(fn, opts) ``` ### `Snacks.debug.run()` Run the current buffer or a range of lines. Shows the output of `print` inlined with the code. Any error will be shown as a diagnostic. ```lua ---@param opts? {name?:string, buf?:number, print?:boolean} Snacks.debug.run(opts) ``` ### `Snacks.debug.size()` ```lua Snacks.debug.size(bytes) ``` ### `Snacks.debug.stats()` ```lua ---@param opts? {min?: number, show?:boolean} ---@return {summary:table, trace:snacks.debug.Stat[], traces:snacks.debug.Trace[]} Snacks.debug.stats(opts) ``` ### `Snacks.debug.trace()` ```lua ---@param name string? Snacks.debug.trace(name) ``` ### `Snacks.debug.tracemod()` ```lua ---@param modname string ---@param mod? table ---@param suffix? string Snacks.debug.tracemod(modname, mod, suffix) ``` ================================================ FILE: docs/dim.md ================================================ # 🍿 dim Focus on the active scope by dimming the rest. Similar plugins: - [twilight.nvim](https://github.com/folke/twilight.nvim) - [limelight.vim](https://github.com/junegunn/limelight.vim) - [goyo.vim](https://github.com/junegunn/goyo.vim) ![image](https://github.com/user-attachments/assets/c0c5ffda-aaeb-4578-8a18-abee2e443a93) ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { dim = { -- your dim configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.dim.Config { ---@type snacks.scope.Config scope = { min_size = 5, max_size = 20, siblings = true, }, -- animate scopes. Enabled by default for Neovim >= 0.10 -- Works on older versions but has to trigger redraws during animation. ---@type snacks.animate.Config|{enabled?: boolean} animate = { enabled = vim.fn.has("nvim-0.10") == 1, easing = "outQuad", duration = { step = 20, -- ms per step total = 300, -- maximum duration }, }, -- what buffers to dim filter = function(buf) return vim.g.snacks_dim ~= false and vim.b[buf].snacks_dim ~= false and vim.bo[buf].buftype == "" end, } ``` ## 📦 Module ### `Snacks.dim()` ```lua ---@type fun(opts: snacks.dim.Config) Snacks.dim() ``` ### `Snacks.dim.disable()` Disable dimming ```lua Snacks.dim.disable() ``` ### `Snacks.dim.enable()` ```lua ---@param opts? snacks.dim.Config Snacks.dim.enable(opts) ``` ================================================ FILE: docs/examples/dashboard.lua ================================================ local M = {} ---@type table M.examples = {} -- Similar to the Emacs Doom dashboard -- ![image](https://github.com/user-attachments/assets/823f702d-e5d0-449a-afd2-684e1fb97622) M.examples.doom = { sections = { { section = "header" }, { section = "keys", gap = 1, padding = 1 }, { section = "startup" }, }, } -- Similar to the Vim Startify dashboard -- ![image](https://github.com/user-attachments/assets/561eff8c-ddf0-4de9-8485-e6be18a19c0b) M.examples.startify = { formats = { key = function(item) return { { "[", hl = "special" }, { item.key, hl = "key" }, { "]", hl = "special" } } end, }, sections = { { section = "terminal", cmd = "fortune -s | cowsay", hl = "header", padding = 1, indent = 8 }, { title = "MRU", padding = 1 }, { section = "recent_files", limit = 8, padding = 1 }, { title = "MRU ", file = vim.fn.fnamemodify(".", ":~"), padding = 1 }, { section = "recent_files", cwd = true, limit = 8, padding = 1 }, { title = "Sessions", padding = 1 }, { section = "projects", padding = 1 }, { title = "Bookmarks", padding = 1 }, { section = "keys" }, }, } -- A more advanced example using multiple panes -- ![image](https://github.com/user-attachments/assets/bbf4d2cd-6fc5-4122-a462-0ca59ba89545) M.examples.advanced = { sections = { { section = "header" }, { pane = 2, section = "terminal", cmd = "colorscript -e square", height = 5, padding = 1, }, { section = "keys", gap = 1, padding = 1 }, { pane = 2, icon = " ", title = "Recent Files", section = "recent_files", indent = 2, padding = 1 }, { pane = 2, icon = " ", title = "Projects", section = "projects", indent = 2, padding = 1 }, { pane = 2, icon = " ", title = "Git Status", section = "terminal", enabled = function() return Snacks.git.get_root() ~= nil end, cmd = "git status --short --branch --renames", height = 5, padding = 1, ttl = 5 * 60, indent = 3, }, { section = "startup" }, }, } -- Advanced example using the GitHub CLI. -- ![image](https://github.com/user-attachments/assets/747d7386-ef05-487f-9550-3e5ef94869fc) M.examples.github = { sections = { { section = "header" }, { pane = 2, section = "terminal", cmd = "colorscript -e square", height = 5, padding = 1, }, { section = "keys", gap = 1, padding = 1 }, { pane = 2, icon = " ", desc = "Browse Repo", padding = 1, key = "b", action = function() Snacks.gitbrowse() end, }, function() local in_git = Snacks.git.get_root() ~= nil local cmds = { { title = "Notifications", cmd = "gh notify -s -a -n5", action = function() vim.ui.open("https://github.com/notifications") end, key = "n", icon = " ", height = 5, enabled = true, }, { title = "Open Issues", cmd = "gh issue list -L 3", key = "i", action = function() vim.fn.jobstart("gh issue list --web", { detach = true }) end, icon = " ", height = 7, }, { icon = " ", title = "Open PRs", cmd = "gh pr list -L 3", key = "P", action = function() vim.fn.jobstart("gh pr list --web", { detach = true }) end, height = 7, }, { icon = " ", title = "Git Status", cmd = "git --no-pager diff --stat -B -M -C", height = 10, }, } return vim.tbl_map(function(cmd) return vim.tbl_extend("force", { pane = 2, section = "terminal", enabled = in_git, padding = 1, ttl = 5 * 60, indent = 3, }, cmd) end, cmds) end, { section = "startup" }, }, } -- A simple example with a header, keys, recent files, and projects -- ![image](https://github.com/user-attachments/assets/e98997b6-07d3-4162-bc06-2768b78fe353) M.examples.files = { sections = { { section = "header" }, { section = "keys", gap = 1 }, { icon = " ", title = "Recent Files", section = "recent_files", indent = 2, padding = { 2, 2 } }, { icon = " ", title = "Projects", section = "projects", indent = 2, padding = 2 }, { section = "startup" }, }, } -- A more compact version of the `files` example -- ![image](https://github.com/user-attachments/assets/772e84fe-b220-4841-bbe9-6e28780dc30a) M.examples.compact_files = { sections = { { section = "header" }, { icon = " ", title = "Keymaps", section = "keys", indent = 2, padding = 1 }, { icon = " ", title = "Recent Files", section = "recent_files", indent = 2, padding = 1 }, { icon = " ", title = "Projects", section = "projects", indent = 2, padding = 1 }, { section = "startup" }, }, } -- An example using the `chafa` command to display an image -- ![image](https://github.com/user-attachments/assets/e498ef8f-83ce-4917-a720-8cb31d98ecec) M.examples.chafa = { sections = { { section = "terminal", cmd = "chafa ~/.config/wall.png --format symbols --symbols vhalf --size 60x17 --stretch; sleep .1", height = 17, padding = 1, }, { pane = 2, { section = "keys", gap = 1, padding = 1 }, { section = "startup" }, }, }, } -- Pokemons, because why not? -- ![image](https://github.com/user-attachments/assets/2fb17ecc-8bc0-48d3-a023-aa8dfc70247e) M.examples.pokemon = { sections = { { section = "header" }, { section = "keys", gap = 1, padding = 1 }, { section = "startup" }, { section = "terminal", cmd = "pokemon-colorscripts -r --no-title; sleep .1", random = 10, pane = 2, indent = 4, height = 30, }, }, } return M ================================================ FILE: docs/examples/init.lua ================================================ -- stylua: ignore return { "folke/snacks.nvim", priority = 1000, lazy = false, ---@type snacks.Config opts = { bigfile = { enabled = true }, dashboard = { enabled = true }, explorer = { enabled = true }, indent = { enabled = true }, input = { enabled = true }, notifier = { enabled = true, timeout = 3000, }, picker = { enabled = true }, quickfile = { enabled = true }, scope = { enabled = true }, scroll = { enabled = true }, statuscolumn = { enabled = true }, words = { enabled = true }, styles = { notification = { -- wo = { wrap = true } -- Wrap notifications } } }, keys = { -- EXTRA_KEYS -- Other { "z", function() Snacks.zen() end, desc = "Toggle Zen Mode" }, { "Z", function() Snacks.zen.zoom() end, desc = "Toggle Zoom" }, { ".", function() Snacks.scratch() end, desc = "Toggle Scratch Buffer" }, { "S", function() Snacks.scratch.select() end, desc = "Select Scratch Buffer" }, { "n", function() Snacks.notifier.show_history() end, desc = "Notification History" }, { "bd", function() Snacks.bufdelete() end, desc = "Delete Buffer" }, { "cR", function() Snacks.rename.rename_file() end, desc = "Rename File" }, { "gB", function() Snacks.gitbrowse() end, desc = "Git Browse", mode = { "n", "v" } }, { "gg", function() Snacks.lazygit() end, desc = "Lazygit" }, { "un", function() Snacks.notifier.hide() end, desc = "Dismiss All Notifications" }, { "", function() Snacks.terminal() end, desc = "Toggle Terminal" }, { "", function() Snacks.terminal() end, desc = "which_key_ignore" }, { "]]", function() Snacks.words.jump(vim.v.count1) end, desc = "Next Reference", mode = { "n", "t" } }, { "[[", function() Snacks.words.jump(-vim.v.count1) end, desc = "Prev Reference", mode = { "n", "t" } }, { "N", desc = "Neovim News", function() Snacks.win({ file = vim.api.nvim_get_runtime_file("doc/news.txt", false)[1], width = 0.6, height = 0.6, wo = { spell = false, wrap = false, signcolumn = "yes", statuscolumn = " ", conceallevel = 3, }, }) end, } }, init = function() vim.api.nvim_create_autocmd("User", { pattern = "VeryLazy", callback = function() -- Setup some globals for debugging (lazy-loaded) _G.dd = function(...) Snacks.debug.inspect(...) end _G.bt = function() Snacks.debug.backtrace() end -- Override print to use snacks for `:=` command if vim.fn.has("nvim-0.11") == 1 then vim._print = function(_, ...) dd(...) end else vim.print = _G.dd end -- Create some toggle mappings Snacks.toggle.option("spell", { name = "Spelling" }):map("us") Snacks.toggle.option("wrap", { name = "Wrap" }):map("uw") Snacks.toggle.option("relativenumber", { name = "Relative Number" }):map("uL") Snacks.toggle.diagnostics():map("ud") Snacks.toggle.line_number():map("ul") Snacks.toggle.option("conceallevel", { off = 0, on = vim.o.conceallevel > 0 and vim.o.conceallevel or 2 }):map("uc") Snacks.toggle.treesitter():map("uT") Snacks.toggle.option("background", { off = "light", on = "dark", name = "Dark Background" }):map("ub") Snacks.toggle.inlay_hints():map("uh") Snacks.toggle.indent():map("ug") Snacks.toggle.dim():map("uD") end, }) end, } ================================================ FILE: docs/examples/picker.lua ================================================ local M = {} M.examples = {} M.examples.general = { "folke/snacks.nvim", opts = { picker = {}, explorer = {}, }, -- stylua: ignore keys = { -- Top Pickers & Explorer { "", function() Snacks.picker.smart() end, desc = "Smart Find Files" }, { ",", function() Snacks.picker.buffers() end, desc = "Buffers" }, { "/", function() Snacks.picker.grep() end, desc = "Grep" }, { ":", function() Snacks.picker.command_history() end, desc = "Command History" }, { "n", function() Snacks.picker.notifications() end, desc = "Notification History" }, { "e", function() Snacks.explorer() end, desc = "File Explorer" }, -- find { "fb", function() Snacks.picker.buffers() end, desc = "Buffers" }, { "fc", function() Snacks.picker.files({ cwd = vim.fn.stdpath("config") }) end, desc = "Find Config File" }, { "ff", function() Snacks.picker.files() end, desc = "Find Files" }, { "fg", function() Snacks.picker.git_files() end, desc = "Find Git Files" }, { "fp", function() Snacks.picker.projects() end, desc = "Projects" }, { "fr", function() Snacks.picker.recent() end, desc = "Recent" }, -- git { "gb", function() Snacks.picker.git_branches() end, desc = "Git Branches" }, { "gl", function() Snacks.picker.git_log() end, desc = "Git Log" }, { "gL", function() Snacks.picker.git_log_line() end, desc = "Git Log Line" }, { "gs", function() Snacks.picker.git_status() end, desc = "Git Status" }, { "gS", function() Snacks.picker.git_stash() end, desc = "Git Stash" }, { "gd", function() Snacks.picker.git_diff() end, desc = "Git Diff (Hunks)" }, { "gf", function() Snacks.picker.git_log_file() end, desc = "Git Log File" }, -- gh { "gi", function() Snacks.picker.gh_issue() end, desc = "GitHub Issues (open)" }, { "gI", function() Snacks.picker.gh_issue({ state = "all" }) end, desc = "GitHub Issues (all)" }, { "gp", function() Snacks.picker.gh_pr() end, desc = "GitHub Pull Requests (open)" }, { "gP", function() Snacks.picker.gh_pr({ state = "all" }) end, desc = "GitHub Pull Requests (all)" }, -- Grep { "sb", function() Snacks.picker.lines() end, desc = "Buffer Lines" }, { "sB", function() Snacks.picker.grep_buffers() end, desc = "Grep Open Buffers" }, { "sg", function() Snacks.picker.grep() end, desc = "Grep" }, { "sw", function() Snacks.picker.grep_word() end, desc = "Visual selection or word", mode = { "n", "x" } }, -- search { 's"', function() Snacks.picker.registers() end, desc = "Registers" }, { 's/', function() Snacks.picker.search_history() end, desc = "Search History" }, { "sa", function() Snacks.picker.autocmds() end, desc = "Autocmds" }, { "sb", function() Snacks.picker.lines() end, desc = "Buffer Lines" }, { "sc", function() Snacks.picker.command_history() end, desc = "Command History" }, { "sC", function() Snacks.picker.commands() end, desc = "Commands" }, { "sd", function() Snacks.picker.diagnostics() end, desc = "Diagnostics" }, { "sD", function() Snacks.picker.diagnostics_buffer() end, desc = "Buffer Diagnostics" }, { "sh", function() Snacks.picker.help() end, desc = "Help Pages" }, { "sH", function() Snacks.picker.highlights() end, desc = "Highlights" }, { "si", function() Snacks.picker.icons() end, desc = "Icons" }, { "sj", function() Snacks.picker.jumps() end, desc = "Jumps" }, { "sk", function() Snacks.picker.keymaps() end, desc = "Keymaps" }, { "sl", function() Snacks.picker.loclist() end, desc = "Location List" }, { "sm", function() Snacks.picker.marks() end, desc = "Marks" }, { "sM", function() Snacks.picker.man() end, desc = "Man Pages" }, { "sp", function() Snacks.picker.lazy() end, desc = "Search for Plugin Spec" }, { "sq", function() Snacks.picker.qflist() end, desc = "Quickfix List" }, { "sR", function() Snacks.picker.resume() end, desc = "Resume" }, { "su", function() Snacks.picker.undo() end, desc = "Undo History" }, { "uC", function() Snacks.picker.colorschemes() end, desc = "Colorschemes" }, -- LSP { "gd", function() Snacks.picker.lsp_definitions() end, desc = "Goto Definition" }, { "gD", function() Snacks.picker.lsp_declarations() end, desc = "Goto Declaration" }, { "gr", function() Snacks.picker.lsp_references() end, nowait = true, desc = "References" }, { "gI", function() Snacks.picker.lsp_implementations() end, desc = "Goto Implementation" }, { "gy", function() Snacks.picker.lsp_type_definitions() end, desc = "Goto T[y]pe Definition" }, { "gai", function() Snacks.picker.lsp_incoming_calls() end, desc = "C[a]lls Incoming" }, { "gao", function() Snacks.picker.lsp_outgoing_calls() end, desc = "C[a]lls Outgoing" }, { "ss", function() Snacks.picker.lsp_symbols() end, desc = "LSP Symbols" }, { "sS", function() Snacks.picker.lsp_workspace_symbols() end, desc = "LSP Workspace Symbols" }, }, } M.examples.trouble = { "folke/trouble.nvim", optional = true, specs = { "folke/snacks.nvim", opts = function(_, opts) return vim.tbl_deep_extend("force", opts or {}, { picker = { actions = require("trouble.sources.snacks").actions, win = { input = { keys = { [""] = { "trouble_open", mode = { "n", "i" }, }, }, }, }, }, }) end, }, } M.examples.todo_comments = { "folke/todo-comments.nvim", optional = true, -- stylua: ignore keys = { { "st", function() Snacks.picker.todo_comments() end, desc = "Todo" }, { "sT", function () Snacks.picker.todo_comments({ keywords = { "TODO", "FIX", "FIXME" } }) end, desc = "Todo/Fix/Fixme" }, }, } M.examples.flash = { "folke/flash.nvim", optional = true, specs = { { "folke/snacks.nvim", opts = { picker = { win = { input = { keys = { [""] = { "flash", mode = { "n", "i" } }, ["s"] = { "flash" }, }, }, }, actions = { flash = function(picker) require("flash").jump({ pattern = "^", label = { after = { 0, 0 } }, search = { mode = "search", exclude = { function(win) return vim.bo[vim.api.nvim_win_get_buf(win)].filetype ~= "snacks_picker_list" end, }, }, action = function(match) local idx = picker.list:row2idx(match.pos[1]) picker.list:_move(idx, true, true) end, }) end, }, }, }, }, }, } return M ================================================ FILE: docs/explorer.md ================================================ # 🍿 explorer A file explorer for snacks. This is actually a [picker](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md#explorer) in disguise. This module provide a shortcut to open the explorer picker and a setup function to replace netrw with the explorer. When the explorer and `replace_netrw` is enabled, the explorer will be opened: - when you start `nvim` with a directory - when you open a directory in vim Configuring the explorer picker is done with the [picker options](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md#explorer). ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { explorer = { -- your explorer configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below }, picker = { sources = { explorer = { -- your explorer picker configuration comes here -- or leave it empty to use the default settings } } } } } ``` ![image](https://github.com/user-attachments/assets/e09d25f8-8559-441c-a0f7-576d2aa57097) ## 🚀 Usage ### File Operations The explorer provides powerful file operations with an intuitive selection-based workflow. #### Moving and Copying Files The most efficient way to move or copy multiple files: 1. **Select files** with `` (works on multiple files) 2. **Navigate** to the target directory 3. **Execute** the operation: - Press `m` to **move** selected files to the current directory - Press `c` to **copy** selected files to the current directory ``` Example workflow: 1. Navigate to source files 2. Press on file1.txt 3. Press on file2.txt (both now selected) 4. Navigate to target directory 5. Press 'm' → files are moved! ``` **Single file operations:** - `m` on a single file (no selection) → renames the file - `c` on a single file (no selection) → prompts for new name to copy to - `r` → rename current file - `d` → delete current/selected files #### Copy/Paste with Registers Alternative workflow using yank and paste: 1. **Select files** with `` or visual mode 2. Press `y` to **yank** file paths to register 3. Navigate to target directory 4. Press `p` to **paste** (copies files from register) This works across different explorer instances and even after closing/reopening! #### Other File Operations - `a` → **Add** new file or directory (directories end with `/`) - `d` → **Delete** files (uses system trash if available, see `:checkhealth snacks`) - `o` → **Open** file with system application - `u` → **Update/refresh** the file tree ### Navigation - `` or `l` → Open file or toggle directory - `h` → Close directory - `` → Go up one directory - `.` → Focus on current directory (set as cwd) - `H` → Toggle hidden files - `I` → Toggle ignored files (from gitignore) - `Z` → Close all directories ### Quick Actions - `/` → Grep in current directory - `` → Open terminal in current directory - `` → Change tab directory to current directory - `P` → Toggle preview ### Git Integration When `git_status = true` (default), files show git status indicators: - `]g` / `[g` → Jump to next/previous git change - Directories show aggregate status of contained files ### Diagnostics When `diagnostics = true` (default), files show diagnostic indicators: - `]d` / `[d` → Jump to next/previous diagnostic - `]e` / `[e` → Jump to next/previous error - `]w` / `[w` → Jump to next/previous warning ### Visual Mode You can use visual mode (`v` or `V`) to select multiple files, then: - `y` → Yank selected file paths - Any other operation works on visual selection ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { explorer = { -- your explorer configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config These are just the general explorer settings. To configure the explorer picker, see `snacks.picker.explorer.Config` ```lua ---@class snacks.explorer.Config { replace_netrw = true, -- Replace netrw with the snacks explorer trash = true, -- Use the system trash when deleting files } ``` ## 📦 Module ### `Snacks.explorer()` ```lua ---@type fun(opts?: snacks.picker.explorer.Config): snacks.Picker Snacks.explorer() ``` ### `Snacks.explorer.health()` ```lua Snacks.explorer.health() ``` ### `Snacks.explorer.open()` Shortcut to open the explorer picker ```lua ---@param opts? snacks.picker.explorer.Config|{} Snacks.explorer.open(opts) ``` ### `Snacks.explorer.reveal()` Reveals the given file/buffer or the current buffer in the explorer ```lua ---@param opts? {file?:string, buf?:number} Snacks.explorer.reveal(opts) ``` ================================================ FILE: docs/gh.md ================================================ # 🍿 gh A modern GitHub CLI integration for Neovim that brings GitHub issues and pull requests directly into your editor. Image ## ✨ Features - 📋 Browse and search **GitHub issues** and **pull requests** with fuzzy finding - 🔍 View full issue/PR details including **comments**, **reactions**, and **status checks** - 📝 Perform GitHub actions directly from Neovim: - Comment on issues and PRs - Close, reopen, edit, and merge PRs - Add reactions and labels - Review PRs (approve, request changes, comment) - Checkout PR branches locally - View PR diffs with syntax highlighting - ⌨️ Customizable **keymaps** for common GitHub operations - 🎨 Beautiful **syntax highlighting** using Treesitter - 🔗 Open issues/PRs in your web browser - 📎 Yank URLs to clipboard - 🌲 Built on top of the powerful [Snacks picker](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md) ## ⚡️ Requirements - [GitHub CLI (`gh`)](https://cli.github.com/) - must be installed and authenticated - Snacks [picker](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md) enabled ## 🚀 Recommended Setup ```lua { "folke/snacks.nvim", opts = { gh = { -- your gh configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below }, picker = { sources = { gh_issue = { -- your gh_issue picker configuration comes here -- or leave it empty to use the default settings }, gh_pr = { -- your gh_pr picker configuration comes here -- or leave it empty to use the default settings } } }, }, keys = { { "gi", function() Snacks.picker.gh_issue() end, desc = "GitHub Issues (open)" }, { "gI", function() Snacks.picker.gh_issue({ state = "all" }) end, desc = "GitHub Issues (all)" }, { "gp", function() Snacks.picker.gh_pr() end, desc = "GitHub Pull Requests (open)" }, { "gP", function() Snacks.picker.gh_pr({ state = "all" }) end, desc = "GitHub Pull Requests (all)" }, }, } ``` ## 📚 Usage ```lua -- Browse open issues Snacks.picker.gh_issue() -- Browse all issues (including closed) Snacks.picker.gh_issue({ state = "all" }) -- Browse open pull requests Snacks.picker.gh_pr() -- Browse all pull requests Snacks.picker.gh_pr({ state = "all" }) -- View PR diff Snacks.picker.gh_diff({ pr = 123 }) -- Open issue/PR in buffer Snacks.gh.open({ type = "issue", number = 123, repo = "owner/repo" }) ``` ### Available Actions When viewing an issue or PR in the picker, press `` to show available actions: Image `Snacks.gh` makes extensive use of `Snacks.scratch` for editing comments and descriptions. Image **Common Actions:** - **Open in buffer** - View full details with comments - **Open in browser** - Open in GitHub web UI - **Add comment** - Add a new comment - **Add reaction** - React with emoji - **Add/Remove labels** - Manage labels - **Close/Reopen** - Change issue/PR state - **Edit** - Edit title and body - **Yank URL** - Copy URL to clipboard **Pull Request/Issue Specific:** - **View diff** - Show changed files with syntax highlighting - **Checkout** - Checkout PR branch locally - **Merge** - Merge, squash, or rebase and merge - **Review** - Approve, request changes, or comment - **Mark as draft/ready** - Change draft status - and more... Image ### GitHub Buffers When you open an issue or PR in a buffer, you get a beautiful rendered view with: - **Metadata** - Status, author, dates, labels, reactions, and assignees - **Description** - Full issue/PR body with markdown rendering - **Comments** - All comments with author info and timestamps - **Status Checks** - PR status checks and CI results (for PRs) - **Syntax Highlighting** - Full Treesitter support for markdown - **Folding** - Foldable sections for comments and metadata **Default Keymaps in GitHub Buffers:** | Key | Action | Description | | ------ | ------------- | ---------------------------- | | `` | Select Action | Show available actions menu | | `i` | Edit | Edit issue/PR title and body | | `a` | Add Comment | Add a new comment | | `c` | Close | Close the issue/PR | | `o` | Reopen | Reopen a closed issue/PR | See the [config section](#%EF%B8%8F-config) to customize these keymaps. ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { gh = { -- your gh configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.gh.Config { --- Keymaps for GitHub buffers ---@type table? keys = { select = { "", "gh_actions", desc = "Select Action" }, edit = { "i" , "gh_edit" , desc = "Edit" }, comment = { "a" , "gh_comment", desc = "Add Comment" }, close = { "c" , "gh_close" , desc = "Close" }, reopen = { "o" , "gh_reopen" , desc = "Reopen" }, }, ---@type vim.wo|{} wo = { breakindent = true, wrap = true, showbreak = "", linebreak = true, number = false, relativenumber = false, foldexpr = "v:lua.vim.treesitter.foldexpr()", foldmethod = "expr", concealcursor = "n", conceallevel = 2, list = false, winhighlight = Snacks.util.winhl({ Normal = "SnacksGhNormal", NormalFloat = "SnacksGhNormalFloat", FloatBorder = "SnacksGhBorder", FloatTitle = "SnacksGhTitle", FloatFooter = "SnacksGhFooter", }), }, ---@type vim.bo|{} bo = {}, diff = { min = 4, -- minimum number of lines changed to show diff wrap = 80, -- wrap diff lines at this length }, scratch = { height = 15, -- height of scratch window }, icons = { logo = " ", user= " ", checkmark = " ", crossmark = " ", block = "■", file = " ", checks = { pending = " ", success = " ", failure = "", skipped = " ", }, issue = { open = " ", completed = " ", other = " " }, pr = { open = " ", closed = " ", merged = " ", draft = " ", other = " ", }, review = { approved = " ", changes_requested = " ", commented = " ", dismissed = " ", pending = " ", }, merge_status = { clean = " ", dirty = " ", blocked = " ", unstable = " " }, reactions = { thumbs_up = "👍", thumbs_down = "👎", eyes = "👀", confused = "😕", heart = "❤️", hooray = "🎉", laugh = "😄", rocket = "🚀", }, }, } ``` ## 📚 Types ```lua ---@alias snacks.gh.Keymap.fn fun(item:snacks.picker.gh.Item, buf:snacks.gh.Buf) ---@class snacks.gh.Keymap: vim.keymap.set.Opts ---@field [1] string lhs ---@field [2] string|snacks.gh.Keymap.fn rhs ---@field mode? string|string[] defaults to `n` ``` ## 📦 Module ```lua ---@class snacks.gh ---@field api snacks.gh.api ---@field item snacks.picker.gh.Item Snacks.gh = {} ``` ### `Snacks.gh.issue()` ```lua ---@param opts? snacks.picker.gh.issue.Config Snacks.gh.issue(opts) ``` ### `Snacks.gh.pr()` ```lua ---@param opts? snacks.picker.gh.pr.Config Snacks.gh.pr(opts) ``` ================================================ FILE: docs/git.md ================================================ # 🍿 git ## 🎨 Styles Check the [styles](https://github.com/folke/snacks.nvim/blob/main/docs/styles.md) docs for more information on how to customize these styles ### `blame_line` ```lua { width = 0.6, height = 0.6, border = true, title = " Git Blame ", title_pos = "center", ft = "git", } ``` ## 📦 Module ### `Snacks.git.blame_line()` Show git log for the current line. ```lua ---@param opts? snacks.terminal.Opts | {count?: number} Snacks.git.blame_line(opts) ``` ### `Snacks.git.get_root()` Gets the git root for a buffer or path. Defaults to the current buffer. ```lua ---@param path? number|string buffer or path ---@return string? Snacks.git.get_root(path) ``` ================================================ FILE: docs/gitbrowse.md ================================================ # 🍿 gitbrowse Open the repo of the active file in the browser (e.g., GitHub) ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { gitbrowse = { -- your gitbrowse configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.gitbrowse.Config ---@field url_patterns? table> { notify = true, -- show notification on open -- Handler to open the url in a browser ---@param url string open = function(url) if vim.fn.has("nvim-0.10") == 0 then require("lazy.util").open(url, { system = true }) return end vim.ui.open(url) end, ---@type "repo" | "branch" | "file" | "commit" | "permalink" what = "commit", -- what to open. not all remotes support all types commit = nil, ---@type string? branch = nil, ---@type string? line_start = nil, ---@type number? line_end = nil, ---@type number? -- patterns to transform remotes to an actual URL remote_patterns = { { "^(https?://.*)%.git$" , "%1" }, { "^git@(.+):(.+)%.git$" , "https://%1/%2" }, { "^git@(.+):(.+)$" , "https://%1/%2" }, { "^git@(.+)/(.+)$" , "https://%1/%2" }, { "^org%-%d+@(.+):(.+)%.git$" , "https://%1/%2" }, { "^ssh://git@(.*)$" , "https://%1" }, { "^ssh://([^:/]+)(:%d+)/(.*)$" , "https://%1/%3" }, { "^ssh://([^/]+)/(.*)$" , "https://%1/%2" }, { "ssh%.dev%.azure%.com/v3/(.*)/(.*)$", "dev.azure.com/%1/_git/%2" }, { "^https://%w*@(.*)" , "https://%1" }, { "^git@(.*)" , "https://%1" }, { ":%d+" , "" }, { "%.git$" , "" }, }, url_patterns = { ["github%.com"] = { branch = "/tree/{branch}", file = "/blob/{branch}/{file}#L{line_start}-L{line_end}", permalink = "/blob/{commit}/{file}#L{line_start}-L{line_end}", commit = "/commit/{commit}", }, ["gitlab%.com"] = { branch = "/-/tree/{branch}", file = "/-/blob/{branch}/{file}#L{line_start}-{line_end}", permalink = "/-/blob/{commit}/{file}#L{line_start}-{line_end}", commit = "/-/commit/{commit}", }, ["bitbucket%.org"] = { branch = "/src/{branch}", file = "/src/{branch}/{file}#lines-{line_start}-L{line_end}", permalink = "/src/{commit}/{file}#lines-{line_start}-L{line_end}", commit = "/commits/{commit}", }, ["git.sr.ht"] = { branch = "/tree/{branch}", file = "/tree/{branch}/item/{file}", permalink = "/tree/{commit}/item/{file}#L{line_start}", commit = "/commit/{commit}", }, }, } ``` ## 📚 Types ```lua ---@class snacks.gitbrowse.Fields ---@field branch? string ---@field file? string ---@field line_start? number ---@field line_end? number ---@field commit? string ---@field line_count? number ``` ## 📦 Module ### `Snacks.gitbrowse()` ```lua ---@type fun(opts?: snacks.gitbrowse.Config) Snacks.gitbrowse() ``` ### `Snacks.gitbrowse.get_url()` ```lua ---@param repo string ---@param fields snacks.gitbrowse.Fields ---@param opts? snacks.gitbrowse.Config Snacks.gitbrowse.get_url(repo, fields, opts) ``` ### `Snacks.gitbrowse.open()` ```lua ---@param opts? snacks.gitbrowse.Config Snacks.gitbrowse.open(opts) ``` ================================================ FILE: docs/health.md ================================================ # 🍿 health ## 📚 Types ```lua ---@class snacks.health.Tool ---@field cmd string|string[] ---@field version? string|false ---@field enabled? boolean ``` ```lua ---@alias snacks.health.Tool.spec (string|snacks.health.Tool)[]|snacks.health.Tool|string ``` ## 📦 Module ```lua ---@class snacks.health ---@field ok fun(msg: string) ---@field warn fun(msg: string) ---@field error fun(msg: string) ---@field info fun(msg: string) ---@field start fun(msg: string) Snacks.health = {} ``` ### `Snacks.health.check()` ```lua Snacks.health.check() ``` ### `Snacks.health.has_lang()` Check if the given languages are available in treesitter ```lua ---@param langs string[]|string Snacks.health.has_lang(langs) ``` ### `Snacks.health.have_tool()` Check if any of the tools are available, with an optional version check ```lua ---@param tools snacks.health.Tool.spec Snacks.health.have_tool(tools) ``` ================================================ FILE: docs/image.md ================================================ # 🍿 image ![Image](https://github.com/user-attachments/assets/4e8a686c-bf41-4989-9d74-1641ecf2835f) ## ✨ Features - Image viewer using the [Kitty Graphics Protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/). - open images in a wide range of formats: `pdf`, `png`, `jpg`, `jpeg`, `gif`, `bmp`, `webp`, `tiff`, `heic`, `avif`, `mp4`, `mov`, `avi`, `mkv`, `webm` - Supports inline image rendering in: `markdown`, `html`, `norg`, `tsx`, `javascript`, `css`, `vue`, `svelte`, `scss`, `latex`, `typst` - LaTex math expressions in `markdown` and `latex` documents Terminal support: - [kitty](https://sw.kovidgoyal.net/kitty/) - [ghostty](https://ghostty.org/) - [wezterm](https://wezfurlong.org/wezterm/) Wezterm has only limited support for the kitty graphics protocol. Inline image rendering is not supported. - [tmux](https://github.com/tmux/tmux) Snacks automatically tries to enable `allow-passthrough=on` for tmux, but you may need to enable it manually in your tmux configuration. - [zellij](https://github.com/zellij-org/zellij) is **not** supported, since they don't have any support for passthrough Image will be transferred to the terminal by filename or by sending the image date in case `ssh` is detected. In some cases you may need to force snacks to detect or not detect a certain environment. You can do this by setting `SNACKS_${ENV_NAME}` to `true` or `false`. For example, to force detection of **ghostty** you can set `SNACKS_GHOSTTY=true`. In order to automatically display the image when opening an image file, or to have imaged displayed in supported document formats like `markdown` or `html`, you need to enable the `image` plugin in your `snacks` config. [ImageMagick](https://imagemagick.org/index.php) is required to convert images to the supported formats (all except PNG). In case of issues, make sure to run `:checkhealth snacks`. ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { image = { -- your image configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.image.Config ---@field enabled? boolean enable image viewer ---@field wo? vim.wo|{} options for windows showing the image ---@field bo? vim.bo|{} options for the image buffer ---@field formats? string[] --- Resolves a reference to an image with src in a file (currently markdown only). --- Return the absolute path or url to the image. --- When `nil`, the path is resolved relative to the file. ---@field resolve? fun(file: string, src: string): string? ---@field convert? snacks.image.convert.Config { formats = { "png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "heic", "avif", "mp4", "mov", "avi", "mkv", "webm", "pdf", "icns", }, force = false, -- try displaying the image, even if the terminal does not support it doc = { -- enable image viewer for documents -- a treesitter parser must be available for the enabled languages. enabled = true, -- render the image inline in the buffer -- if your env doesn't support unicode placeholders, this will be disabled -- takes precedence over `opts.float` on supported terminals inline = true, -- render the image in a floating window -- only used if `opts.inline` is disabled float = true, max_width = 80, max_height = 40, -- Set to `true`, to conceal the image text when rendering inline. -- (experimental) ---@param lang string tree-sitter language ---@param type snacks.image.Type image type conceal = function(lang, type) -- only conceal math expressions return type == "math" end, }, img_dirs = { "img", "images", "assets", "static", "public", "media", "attachments" }, -- window options applied to windows displaying image buffers -- an image buffer is a buffer with `filetype=image` wo = { wrap = false, number = false, relativenumber = false, cursorcolumn = false, signcolumn = "no", foldcolumn = "0", list = false, spell = false, statuscolumn = "", }, cache = vim.fn.stdpath("cache") .. "/snacks/image", debug = { request = false, convert = false, placement = false, }, env = {}, -- icons used to show where an inline image is located that is -- rendered below the text. icons = { math = "󰪚 ", chart = "󰄧 ", image = " ", }, ---@class snacks.image.convert.Config convert = { notify = false, -- show a notification on error ---@type snacks.image.args mermaid = function() local theme = vim.o.background == "light" and "neutral" or "dark" return { "-i", "{src}", "-o", "{file}", "-b", "transparent", "-t", theme, "-s", "{scale}" } end, ---@type table magick = { default = { "{src}[0]", "-scale", "1920x1080>" }, -- default for raster images vector = { "-density", 192, "{src}[{page}]" }, -- used by vector images like svg math = { "-density", 192, "{src}[{page}]", "-trim" }, pdf = { "-density", 192, "{src}[{page}]", "-background", "white", "-alpha", "remove", "-trim" }, }, }, math = { enabled = true, -- enable math expression rendering -- in the templates below, `${header}` comes from any section in your document, -- between a start/end header comment. Comment syntax is language-specific. -- * start comment: `// snacks: header start` -- * end comment: `// snacks: header end` typst = { tpl = [[ #set page(width: auto, height: auto, margin: (x: 2pt, y: 2pt)) #show math.equation.where(block: false): set text(top-edge: "bounds", bottom-edge: "bounds") #set text(size: 12pt, fill: rgb("${color}")) ${header} ${content}]], }, latex = { font_size = "Large", -- see https://www.sascha-frank.com/latex-font-size.html -- for latex documents, the doc packages are included automatically, -- but you can add more packages here. Useful for markdown documents. packages = { "amsmath", "amssymb", "amsfonts", "amscd", "mathtools" }, tpl = [[ \documentclass[preview,border=0pt,varwidth,12pt]{standalone} \usepackage{${packages}} \begin{document} ${header} { \${font_size} \selectfont \color[HTML]{${color}} ${content}} \end{document}]], }, }, } ``` ## 🎨 Styles Check the [styles](https://github.com/folke/snacks.nvim/blob/main/docs/styles.md) docs for more information on how to customize these styles ### `snacks_image` ```lua { relative = "cursor", border = true, focusable = false, backdrop = false, row = 1, col = 1, -- width/height are automatically set by the image size unless specified below } ``` ## 📚 Types ```lua ---@alias snacks.image.Size {width: number, height: number} ---@alias snacks.image.Pos {[1]: number, [2]: number} ---@alias snacks.image.Loc snacks.image.Pos|snacks.image.Size|{zindex?: number} ---@alias snacks.image.Type "image"|"math"|"chart" ``` ```lua ---@class snacks.image.Env ---@field name string ---@field env? table ---@field terminal? string ---@field supported? boolean default: false ---@field placeholders? boolean default: false ---@field setup? fun(): boolean? ---@field transform? fun(data: string): string ---@field detected? boolean ---@field remote? boolean this is a remote client, so full transfer of the image data is required ``` ```lua ---@class snacks.image.Opts ---@field pos? snacks.image.Pos (row, col) (1,0)-indexed. defaults to the top-left corner ---@field range? Range4 ---@field conceal? boolean ---@field inline? boolean render the image inline in the buffer ---@field width? number ---@field min_width? number ---@field max_width? number ---@field height? number ---@field min_height? number ---@field max_height? number ---@field on_update? fun(placement: snacks.image.Placement) ---@field on_update_pre? fun(placement: snacks.image.Placement) ---@field type? snacks.image.Type ---@field auto_resize? boolean ``` ## 📦 Module ```lua ---@class snacks.image ---@field terminal snacks.image.terminal ---@field image snacks.Image ---@field placement snacks.image.Placement ---@field util snacks.image.util ---@field buf snacks.image.buf ---@field doc snacks.image.doc ---@field convert snacks.image.convert ---@field inline snacks.image.inline Snacks.image = {} ``` ### `Snacks.image.hover()` Show the image at the cursor in a floating window ```lua Snacks.image.hover() ``` ### `Snacks.image.langs()` ```lua ---@return string[] Snacks.image.langs() ``` ### `Snacks.image.supports()` Check if the file format is supported and the terminal supports the kitty graphics protocol ```lua ---@param file string Snacks.image.supports(file) ``` ### `Snacks.image.supports_file()` Check if the file format is supported ```lua ---@param file string Snacks.image.supports_file(file) ``` ### `Snacks.image.supports_terminal()` Check if the terminal supports the kitty graphics protocol ```lua Snacks.image.supports_terminal() ``` ================================================ FILE: docs/indent.md ================================================ # 🍿 indent Visualize indent guides and scopes based on treesitter or indent. Similar plugins: - [indent-blankline.nvim](https://github.com/lukas-reineke/indent-blankline.nvim) - [mini.indentscope](https://github.com/nvim-mini/mini.indentscope) ![image](https://github.com/user-attachments/assets/56a99495-05ab-488e-9619-574cb7ff2b7d) ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { indent = { -- your indent configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.indent.Config ---@field enabled? boolean { indent = { priority = 1, enabled = true, -- enable indent guides char = "│", only_scope = false, -- only show indent guides of the scope only_current = false, -- only show indent guides in the current window hl = "SnacksIndent", ---@type string|string[] hl groups for indent guides -- can be a list of hl groups to cycle through -- hl = { -- "SnacksIndent1", -- "SnacksIndent2", -- "SnacksIndent3", -- "SnacksIndent4", -- "SnacksIndent5", -- "SnacksIndent6", -- "SnacksIndent7", -- "SnacksIndent8", -- }, }, -- animate scopes. Enabled by default for Neovim >= 0.10 -- Works on older versions but has to trigger redraws during animation. ---@class snacks.indent.animate: snacks.animate.Config ---@field enabled? boolean --- * out: animate outwards from the cursor --- * up: animate upwards from the cursor --- * down: animate downwards from the cursor --- * up_down: animate up or down based on the cursor position ---@field style? "out"|"up_down"|"down"|"up" animate = { enabled = vim.fn.has("nvim-0.10") == 1, style = "out", easing = "linear", duration = { step = 20, -- ms per step total = 500, -- maximum duration }, }, ---@class snacks.indent.Scope.Config: snacks.scope.Config scope = { enabled = true, -- enable highlighting the current scope priority = 200, char = "│", underline = false, -- underline the start of the scope only_current = false, -- only show scope in the current window hl = "SnacksIndentScope", ---@type string|string[] hl group for scopes }, chunk = { -- when enabled, scopes will be rendered as chunks, except for the -- top-level scope which will be rendered as a scope. enabled = false, -- only show chunk scopes in the current window only_current = false, priority = 200, hl = "SnacksIndentChunk", ---@type string|string[] hl group for chunk scopes char = { corner_top = "┌", corner_bottom = "└", -- corner_top = "╭", -- corner_bottom = "╰", horizontal = "─", vertical = "│", arrow = ">", }, }, -- filter for buffers to enable indent guides ---@param buf number ---@param win number filter = function(buf, win) return vim.g.snacks_indent ~= false and vim.b[buf].snacks_indent ~= false and vim.bo[buf].buftype == "" end, } ``` ## 📚 Types ```lua ---@class snacks.indent.Scope: snacks.scope.Scope ---@field win number ---@field step? number ---@field animate? {from: number, to: number} ``` ## 📦 Module ### `Snacks.indent.debug_win()` ```lua Snacks.indent.debug_win() ``` ### `Snacks.indent.disable()` Disable indent guides ```lua Snacks.indent.disable() ``` ### `Snacks.indent.enable()` Enable indent guides ```lua Snacks.indent.enable() ``` ================================================ FILE: docs/init.md ================================================ # 🍿 init ## ⚙️ Config ```lua ---@class snacks.Config ---@field animate? snacks.animate.Config ---@field bigfile? snacks.bigfile.Config ---@field dashboard? snacks.dashboard.Config ---@field dim? snacks.dim.Config ---@field explorer? snacks.explorer.Config ---@field gh? snacks.gh.Config ---@field gitbrowse? snacks.gitbrowse.Config ---@field image? snacks.image.Config ---@field indent? snacks.indent.Config ---@field input? snacks.input.Config ---@field layout? snacks.layout.Config ---@field lazygit? snacks.lazygit.Config ---@field notifier? snacks.notifier.Config ---@field picker? snacks.picker.Config ---@field profiler? snacks.profiler.Config ---@field quickfile? snacks.quickfile.Config ---@field scope? snacks.scope.Config ---@field scratch? snacks.scratch.Config ---@field scroll? snacks.scroll.Config ---@field statuscolumn? snacks.statuscolumn.Config ---@field terminal? snacks.terminal.Config ---@field toggle? snacks.toggle.Config ---@field win? snacks.win.Config ---@field words? snacks.words.Config ---@field zen? snacks.zen.Config ---@field styles? table ---@field image? snacks.image.Config|{} { image = { -- define these here, so that we don't need to load the image module formats = { "png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "heic", "avif", "mp4", "mov", "avi", "mkv", "webm", "pdf", "icns", }, }, } ``` ## 📚 Types ```lua ---@class snacks.Config.base ---@field example? string ---@field config? fun(opts: table, defaults: table) ``` ## 📦 Module ```lua ---@class Snacks ---@field animate snacks.animate ---@field bigfile snacks.bigfile ---@field bufdelete snacks.bufdelete ---@field dashboard snacks.dashboard ---@field debug snacks.debug ---@field dim snacks.dim ---@field explorer snacks.explorer ---@field gh snacks.gh ---@field git snacks.git ---@field gitbrowse snacks.gitbrowse ---@field health snacks.health ---@field image snacks.image ---@field indent snacks.indent ---@field input snacks.input ---@field keymap snacks.keymap ---@field layout snacks.layout ---@field lazygit snacks.lazygit ---@field meta snacks.meta ---@field notifier snacks.notifier ---@field notify snacks.notify ---@field picker snacks.picker ---@field profiler snacks.profiler ---@field quickfile snacks.quickfile ---@field rename snacks.rename ---@field scope snacks.scope ---@field scratch snacks.scratch ---@field scroll snacks.scroll ---@field statuscolumn snacks.statuscolumn ---@field terminal snacks.terminal ---@field toggle snacks.toggle ---@field util snacks.util ---@field win snacks.win ---@field words snacks.words ---@field zen snacks.zen Snacks = {} ``` ### `Snacks.init.config.example()` Get an example config from the docs/examples directory. ```lua ---@param snack string ---@param name string ---@param opts? table Snacks.init.config.example(snack, name, opts) ``` ### `Snacks.init.config.get()` ```lua ---@generic T: table ---@param snack string ---@param defaults T ---@param ... T[] ---@return T Snacks.init.config.get(snack, defaults, ...) ``` ### `Snacks.init.config.merge()` Merges the values similar to vim.tbl_deep_extend with the **force** behavior, but the values can be any type ```lua ---@generic T ---@param ... T ---@return T Snacks.init.config.merge(...) ``` ### `Snacks.init.config.style()` Register a new window style config. ```lua ---@param name string ---@param defaults snacks.win.Config|{} ---@return string Snacks.init.config.style(name, defaults) ``` ### `Snacks.init.setup()` ```lua ---@param opts snacks.Config? Snacks.init.setup(opts) ``` ================================================ FILE: docs/input.md ================================================ # 🍿 input Better `vim.ui.input`. ![image](https://github.com/user-attachments/assets/f7579302-bea1-4f1c-8b3b-723c3f4ca04b) ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { input = { -- your input configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.input.Config ---@field enabled? boolean ---@field win? snacks.win.Config|{} ---@field icon? string ---@field icon_pos? snacks.input.Pos ---@field prompt_pos? snacks.input.Pos { icon = " ", icon_hl = "SnacksInputIcon", icon_pos = "left", prompt_pos = "title", win = { style = "input" }, expand = true, } ``` ## 🎨 Styles Check the [styles](https://github.com/folke/snacks.nvim/blob/main/docs/styles.md) docs for more information on how to customize these styles ### `input` ```lua { backdrop = false, position = "float", border = true, title_pos = "center", height = 1, width = 60, relative = "editor", noautocmd = true, row = 2, -- relative = "cursor", -- row = -3, -- col = 0, wo = { winhighlight = "NormalFloat:SnacksInputNormal,FloatBorder:SnacksInputBorder,FloatTitle:SnacksInputTitle", cursorline = false, }, bo = { filetype = "snacks_input", buftype = "prompt", }, --- buffer local variables b = { completion = false, -- disable blink completions in input }, keys = { n_esc = { "", { "cmp_close", "cancel" }, mode = "n", expr = true }, i_esc = { "", { "cmp_close", "stopinsert" }, mode = "i", expr = true }, i_cr = { "", { "cmp_accept", "confirm" }, mode = { "i", "n" }, expr = true }, i_tab = { "", { "cmp_select_next", "cmp" }, mode = "i", expr = true }, i_ctrl_w = { "", "", mode = "i", expr = true }, i_up = { "", { "hist_up" }, mode = { "i", "n" } }, i_down = { "", { "hist_down" }, mode = { "i", "n" } }, q = "cancel", }, } ``` ## 📚 Types ```lua ---@alias snacks.input.Pos "left"|"title"|false ``` ```lua ---@alias snacks.input.Highlight {[1]:number, [2]:number, [3]:string} ``` ```lua ---@class snacks.input.Opts: snacks.input.Config,{} ---@field prompt? string ---@field default? string ---@field completion? string ---@field highlight? fun(text: string): snacks.input.Highlight[] ``` ## 📦 Module ### `Snacks.input()` ```lua ---@type fun(opts: snacks.input.Opts, on_confirm: fun(value?: string)): snacks.win Snacks.input() ``` ### `Snacks.input.disable()` ```lua Snacks.input.disable() ``` ### `Snacks.input.enable()` ```lua Snacks.input.enable() ``` ### `Snacks.input.input()` ```lua ---@param opts? snacks.input.Opts ---@param on_confirm fun(value?: string) Snacks.input.input(opts, on_confirm) ``` ================================================ FILE: docs/keymap.md ================================================ # 🍿 keymap Better `vim.keymap.set` and `vim.keymap.del` with support for filetype-specific and LSP client-aware keymaps. ## ✨ Features - **Filetype-specific keymaps**: Set keymaps that only apply to specific filetypes - **LSP-aware keymaps**: Set keymaps based on LSP client capabilities - **Automatic setup**: Keymaps are automatically applied to existing and new buffers - **Drop-in replacement**: Same API as `vim.keymap.set/del` with additional options - **Smart defaults**: Silent by default ## 🚀 Usage ### Filetype-specific Keymaps Set keymaps that only apply to buffers with specific filetypes: ```lua -- Single filetype - execute the current lua buffer Snacks.keymap.set("n", "r", function() vim.cmd.source() end, { ft = "lua", desc = "Run Lua File", }) -- Multiple filetypes Snacks.keymap.set("n", "t", ":TestNearest", { ft = { "python", "ruby", "javascript" }, desc = "Run Test", }) ``` ### LSP-aware Keymaps Set keymaps based on LSP client capabilities: ```lua -- Set keymap for buffers with any LSP that supports code actions Snacks.keymap.set("n", "ca", vim.lsp.buf.code_action, { lsp = { method = "textDocument/codeAction" }, desc = "Code Action", }) -- Set keymap for buffers with a specific LSP client Snacks.keymap.set("n", "co", function() vim.lsp.buf.code_action({ apply = true, context = { only = { "source.organizeImports" }, diagnostics = {}, }, }) end, { lsp = { name = "vtsls" }, desc = "Organize Imports", }) -- Set keymap for buffers with LSP that supports definitions Snacks.keymap.set("n", "gd", vim.lsp.buf.definition, { lsp = { method = "textDocument/definition" }, desc = "Go to Definition", }) ``` ### Standard Keymaps Works exactly like `vim.keymap.set` without special options: ```lua Snacks.keymap.set("n", "w", ":w", { desc = "Save" }) Snacks.keymap.set({ "n", "v" }, "y", '"+y', { desc = "Copy to clipboard" }) ``` ### Deleting Keymaps ```lua -- Delete a standard keymap Snacks.keymap.del("n", "w") -- Delete a filetype-specific keymap Snacks.keymap.del("n", "", { ft = "lua" }) -- Delete an LSP-aware keymap Snacks.keymap.del("n", "ca", { lsp = { method = "textDocument/codeAction" } }) ``` ## 📚 Types ```lua ---@class snacks.keymap.set.Opts: vim.keymap.set.Opts ---@field ft? string|string[] Filetype(s) to set the keymap for. ---@field lsp? vim.lsp.get_clients.Filter Set for buffers with LSP clients matching this filter. ---@field enabled? boolean|fun(buf?:number): boolean condition to enable the keymap. ``` ```lua ---@class snacks.keymap.del.Opts: vim.keymap.del.Opts ---@field buffer? boolean|number If true or 0, use the current buffer. ---@field ft? string|string[] Filetype(s) to set the keymap for. ---@field lsp? vim.lsp.get_clients.Filter Set for buffers with LSP clients matching this filter. ``` ```lua ---@class snacks.Keymap ---@field id number Unique ID for the keymap. ---@field key string Unique key for the keymap, in the format "mode:lhs". ---@field mode string Mode "short-name" (see |nvim_set_keymap()|), or a list thereof. ---@field lhs string Left-hand side |{lhs}| of the mapping. ---@field rhs string|function Right-hand side |{rhs}| of the mapping, can be a Lua function. ---@field lsp? vim.lsp.get_clients.Filter ---@field opts? snacks.keymap.set.Opts ---@field enabled fun(buf:number): boolean ``` ## 📦 Module ### `Snacks.keymap.del()` ```lua ---@param mode string|string[] Mode "short-name" (see |nvim_set_keymap()|), or a list thereof. ---@param lhs string Left-hand side |{lhs}| of the mapping. ---@param opts? snacks.keymap.del.Opts Snacks.keymap.del(mode, lhs, opts) ``` ### `Snacks.keymap.set()` ```lua ---@param mode string|string[] Mode "short-name" (see |nvim_set_keymap()|), or a list thereof. ---@param lhs string Left-hand side |{lhs}| of the mapping. ---@param rhs string|function Right-hand side |{rhs}| of the mapping, can be a Lua function. ---@param opts? snacks.keymap.set.Opts Snacks.keymap.set(mode, lhs, rhs, opts) ``` ================================================ FILE: docs/layout.md ================================================ # 🍿 layout ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { layout = { -- your layout configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.layout.Config ---@field show? boolean show the layout on creation (default: true) ---@field wins table windows to include in the layout ---@field layout snacks.layout.Box layout definition ---@field fullscreen? boolean open in fullscreen ---@field hidden? string[] list of windows that will be excluded from the layout (but can be toggled) ---@field on_update? fun(layout: snacks.layout) ---@field on_update_pre? fun(layout: snacks.layout) ---@field on_close? fun(layout: snacks.layout) { layout = { width = 0.6, height = 0.6, zindex = 50, }, } ``` ## 📚 Types ```lua ---@class snacks.layout.Win: snacks.win.Config,{} ---@field depth? number ---@field win string layout window name ``` ```lua ---@class snacks.layout.Box: snacks.layout.Win,{} ---@field box "horizontal" | "vertical" ---@field id? number ---@field [number] snacks.layout.Win | snacks.layout.Box children ``` ```lua ---@alias snacks.layout.Widget snacks.layout.Win | snacks.layout.Box ``` ## 📦 Module ```lua ---@class snacks.layout ---@field opts snacks.layout.Config ---@field root snacks.win ---@field wins table ---@field box_wins snacks.win[] ---@field win_opts table ---@field closed? boolean ---@field split? boolean ---@field screenpos number[]? Snacks.layout = {} ``` ### `Snacks.layout.new()` ```lua ---@param opts snacks.layout.Config Snacks.layout.new(opts) ``` ### `layout:close()` Close the layout ```lua ---@param opts? {wins?: boolean} layout:close(opts) ``` ### `layout:each()` ```lua ---@param cb fun(widget: snacks.layout.Widget, parent?: snacks.layout.Box) ---@param opts? {wins?:boolean, boxes?:boolean, box?:snacks.layout.Box} layout:each(cb, opts) ``` ### `layout:hide()` ```lua layout:hide() ``` ### `layout:is_enabled()` Check if the window has been used in the layout ```lua ---@param w string layout:is_enabled(w) ``` ### `layout:is_hidden()` Check if a window is hidden ```lua ---@param win string layout:is_hidden(win) ``` ### `layout:maximize()` Toggle fullscreen ```lua layout:maximize() ``` ### `layout:needs_layout()` ```lua ---@param win string layout:needs_layout(win) ``` ### `layout:show()` Show the layout ```lua layout:show() ``` ### `layout:toggle()` Toggle a window ```lua ---@param win string ---@param enable? boolean ---@param on_update? fun(enabled: boolean) called when the layout will be updated layout:toggle(win, enable, on_update) ``` ### `layout:unhide()` ```lua layout:unhide() ``` ### `layout:valid()` Check if layout is valid (visible) ```lua layout:valid() ``` ================================================ FILE: docs/lazygit.md ================================================ # 🍿 lazygit Automatically configures lazygit with a theme generated based on your Neovim colorscheme and integrate edit with the current neovim instance. ![image](https://github.com/user-attachments/assets/5e5ca232-af65-4ebc-b0ca-02bc9c33d23d) ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { lazygit = { -- your lazygit configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.lazygit.Config: snacks.terminal.Opts ---@field args? string[] ---@field theme? snacks.lazygit.Theme { -- automatically configure lazygit to use the current colorscheme -- and integrate edit with the current neovim instance configure = true, -- extra configuration for lazygit that will be merged with the default -- snacks does NOT have a full yaml parser, so if you need `"test"` to appear with the quotes -- you need to double quote it: `"\"test\""` config = { os = { editPreset = "nvim-remote" }, gui = { -- set to an empty string "" to disable icons nerdFontsVersion = "3", }, }, theme_path = svim.fs.normalize(vim.fn.stdpath("cache") .. "/lazygit-theme.yml"), -- Theme for lazygit theme = { [241] = { fg = "Special" }, activeBorderColor = { fg = "MatchParen", bold = true }, cherryPickedCommitBgColor = { fg = "Identifier" }, cherryPickedCommitFgColor = { fg = "Function" }, defaultFgColor = { fg = "Normal" }, inactiveBorderColor = { fg = "FloatBorder" }, optionsTextColor = { fg = "Function" }, searchingActiveBorderColor = { fg = "MatchParen", bold = true }, selectedLineBgColor = { bg = "Visual" }, -- set to `default` to have no background colour unstagedChangesColor = { fg = "DiagnosticError" }, }, win = { style = "lazygit", }, } ``` ## 🎨 Styles Check the [styles](https://github.com/folke/snacks.nvim/blob/main/docs/styles.md) docs for more information on how to customize these styles ### `lazygit` ```lua {} ``` ## 📚 Types ```lua ---@alias snacks.lazygit.Color {fg?:string, bg?:string, bold?:boolean} ``` ```lua ---@class snacks.lazygit.Theme: table ---@field activeBorderColor snacks.lazygit.Color ---@field cherryPickedCommitBgColor snacks.lazygit.Color ---@field cherryPickedCommitFgColor snacks.lazygit.Color ---@field defaultFgColor snacks.lazygit.Color ---@field inactiveBorderColor snacks.lazygit.Color ---@field optionsTextColor snacks.lazygit.Color ---@field searchingActiveBorderColor snacks.lazygit.Color ---@field selectedLineBgColor snacks.lazygit.Color ---@field unstagedChangesColor snacks.lazygit.Color ``` ## 📦 Module ### `Snacks.lazygit()` ```lua ---@type fun(opts?: snacks.lazygit.Config): snacks.win Snacks.lazygit() ``` ### `Snacks.lazygit.log()` Opens lazygit with the log view ```lua ---@param opts? snacks.lazygit.Config Snacks.lazygit.log(opts) ``` ### `Snacks.lazygit.log_file()` Opens lazygit with the log of the current file ```lua ---@param opts? snacks.lazygit.Config|{} Snacks.lazygit.log_file(opts) ``` ### `Snacks.lazygit.open()` Opens lazygit, properly configured to use the current colorscheme and integrate with the current neovim instance ```lua ---@param opts? snacks.lazygit.Config Snacks.lazygit.open(opts) ``` ================================================ FILE: docs/meta.md ================================================ # 🍿 meta Meta functions for Snacks ## 📚 Types ```lua ---@class snacks.meta.Meta ---@field desc string ---@field needs_setup? boolean ---@field hide? boolean ---@field readme? boolean ---@field docs? boolean ---@field health? boolean ---@field types? boolean ---@field config? boolean ---@field merge? { [string|number]: string } ``` ```lua ---@class snacks.meta.Plugin ---@field name string ---@field file string ---@field meta snacks.meta.Meta ---@field health? fun() ``` ## 📦 Module ### `Snacks.meta.file()` ```lua Snacks.meta.file(name) ``` ### `Snacks.meta.get()` Get the metadata for all snacks plugins ```lua ---@return snacks.meta.Plugin[] Snacks.meta.get() ``` ================================================ FILE: docs/notifier.md ================================================ # 🍿 notifier ![image](https://github.com/user-attachments/assets/b89eb279-08fb-40b2-9330-9a77014b9389) ## Notification History ![image](https://github.com/user-attachments/assets/0dc449f4-b275-49e4-a25f-f58efcba3079) ## 💡 Examples
Replace a notification ```lua -- to replace an existing notification just use the same id. -- you can also use the return value of the notify function as id. for i = 1, 10 do vim.defer_fn(function() vim.notify("Hello " .. i, "info", { id = "test" }) end, i * 500) end ```
Simple LSP Progress ```lua vim.api.nvim_create_autocmd("LspProgress", { ---@param ev {data: {client_id: integer, params: lsp.ProgressParams}} callback = function(ev) local spinner = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" } vim.notify(vim.lsp.status(), "info", { id = "lsp_progress", title = "LSP Progress", opts = function(notif) notif.icon = ev.data.params.value.kind == "end" and " " or spinner[math.floor(vim.uv.hrtime() / (1e6 * 80)) % #spinner + 1] end, }) end, }) ```
Advanced LSP Progress ![image](https://github.com/user-attachments/assets/a81b411c-150a-43ec-8def-87270c6f8dde) ```lua ---@type table local progress = vim.defaulttable() vim.api.nvim_create_autocmd("LspProgress", { ---@param ev {data: {client_id: integer, params: lsp.ProgressParams}} callback = function(ev) local client = vim.lsp.get_client_by_id(ev.data.client_id) local value = ev.data.params.value --[[@as {percentage?: number, title?: string, message?: string, kind: "begin" | "report" | "end"}]] if not client or type(value) ~= "table" then return end local p = progress[client.id] for i = 1, #p + 1 do if i == #p + 1 or p[i].token == ev.data.params.token then p[i] = { token = ev.data.params.token, msg = ("[%3d%%] %s%s"):format( value.kind == "end" and 100 or value.percentage or 100, value.title or "", value.message and (" **%s**"):format(value.message) or "" ), done = value.kind == "end", } break end end local msg = {} ---@type string[] progress[client.id] = vim.tbl_filter(function(v) return table.insert(msg, v.msg) or not v.done end, p) local spinner = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" } vim.notify(table.concat(msg, "\n"), "info", { id = "lsp_progress", title = client.name, opts = function(notif) notif.icon = #progress[client.id] == 0 and " " or spinner[math.floor(vim.uv.hrtime() / (1e6 * 80)) % #spinner + 1] end, }) end, }) ```
## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { notifier = { -- your notifier configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.notifier.Config ---@field enabled? boolean ---@field keep? fun(notif: snacks.notifier.Notif): boolean # global keep function ---@field filter? fun(notif: snacks.notifier.Notif): boolean # filter our unwanted notifications (return false to hide) { timeout = 3000, -- default timeout in ms width = { min = 40, max = 0.4 }, height = { min = 1, max = 0.6 }, -- editor margin to keep free. tabline and statusline are taken into account automatically margin = { top = 0, right = 1, bottom = 0 }, padding = true, -- add 1 cell of left/right padding to the notification window gap = 0, -- gap between notifications sort = { "level", "added" }, -- sort by level and time -- minimum log level to display. TRACE is the lowest -- all notifications are stored in history level = vim.log.levels.TRACE, icons = { error = " ", warn = " ", info = " ", debug = " ", trace = " ", }, keep = function(notif) return vim.fn.getcmdpos() > 0 end, ---@type snacks.notifier.style style = "compact", top_down = true, -- place notifications from top to bottom date_format = "%R", -- time format for notifications -- format for footer when more lines are available -- `%d` is replaced with the number of lines. -- only works for styles with a border ---@type string|boolean more_format = " ↓ %d lines ", refresh = 50, -- refresh at most every 50ms } ``` ## 🎨 Styles Check the [styles](https://github.com/folke/snacks.nvim/blob/main/docs/styles.md) docs for more information on how to customize these styles ### `notification` ```lua { border = true, zindex = 100, ft = "markdown", wo = { winblend = 5, wrap = false, conceallevel = 2, colorcolumn = "", }, bo = { filetype = "snacks_notif" }, } ``` ### `notification_history` ```lua { border = true, zindex = 100, width = 0.6, height = 0.6, minimal = false, title = " Notification History ", title_pos = "center", ft = "markdown", bo = { filetype = "snacks_notif_history", modifiable = false }, wo = { winhighlight = "Normal:SnacksNotifierHistory" }, keys = { q = "close" }, } ``` ## 📚 Types Render styles: * compact: use border for icon and title * minimal: no border, only icon and message * fancy: similar to the default nvim-notify style ```lua ---@alias snacks.notifier.style snacks.notifier.render|"compact"|"fancy"|"minimal" ``` ### Notifications Notification options ```lua ---@class snacks.notifier.Notif.opts ---@field id? number|string ---@field msg? string ---@field level? number|snacks.notifier.level ---@field title? string ---@field icon? string ---@field timeout? number|boolean timeout in ms. Set to 0|false to keep until manually closed ---@field ft? string ---@field keep? fun(notif: snacks.notifier.Notif): boolean ---@field style? snacks.notifier.style ---@field opts? fun(notif: snacks.notifier.Notif) -- dynamic opts ---@field hl? snacks.notifier.hl -- highlight overrides ---@field history? boolean ``` Notification object ```lua ---@class snacks.notifier.Notif: snacks.notifier.Notif.opts ---@field id number|string ---@field msg string ---@field win? snacks.win ---@field icon string ---@field level snacks.notifier.level ---@field timeout number ---@field dirty? boolean ---@field added number timestamp with nano precision ---@field updated number timestamp with nano precision ---@field shown? number timestamp with nano precision ---@field hidden? number timestamp with nano precision ---@field layout? { top?: number, width: number, height: number } ``` ### Rendering ```lua ---@alias snacks.notifier.render fun(buf: number, notif: snacks.notifier.Notif, ctx: snacks.notifier.ctx) ``` ```lua ---@class snacks.notifier.hl ---@field title string ---@field icon string ---@field border string ---@field footer string ---@field msg string ``` ```lua ---@class snacks.notifier.ctx ---@field opts snacks.win.Config ---@field notifier snacks.notifier.Class ---@field hl snacks.notifier.hl ---@field ns number ``` ### History ```lua ---@class snacks.notifier.history ---@field filter? vim.log.levels|snacks.notifier.level|fun(notif: snacks.notifier.Notif): boolean ---@field sort? string[] # sort fields, default: {"added"} ---@field reverse? boolean ``` ```lua ---@alias snacks.notifier.level "trace"|"debug"|"info"|"warn"|"error" ``` ## 📦 Module ### `Snacks.notifier()` ```lua ---@type fun(msg: string, level?: snacks.notifier.level|number, opts?: snacks.notifier.Notif.opts): number|string Snacks.notifier() ``` ### `Snacks.notifier.get_history()` ```lua ---@param opts? snacks.notifier.history Snacks.notifier.get_history(opts) ``` ### `Snacks.notifier.hide()` ```lua ---@param id? number|string Snacks.notifier.hide(id) ``` ### `Snacks.notifier.notify()` ```lua ---@param msg string ---@param level? snacks.notifier.level|number ---@param opts? snacks.notifier.Notif.opts Snacks.notifier.notify(msg, level, opts) ``` ### `Snacks.notifier.show_history()` ```lua ---@param opts? snacks.notifier.history Snacks.notifier.show_history(opts) ``` ================================================ FILE: docs/notify.md ================================================ # 🍿 notify ## 📚 Types ```lua ---@alias snacks.notify.Opts snacks.notifier.Notif.opts|{once?: boolean} ``` ## 📦 Module ### `Snacks.notify()` ```lua ---@type fun(msg: string|string[], opts?: snacks.notify.Opts) Snacks.notify() ``` ### `Snacks.notify.error()` ```lua ---@param msg string|string[] ---@param opts? snacks.notify.Opts Snacks.notify.error(msg, opts) ``` ### `Snacks.notify.info()` ```lua ---@param msg string|string[] ---@param opts? snacks.notify.Opts Snacks.notify.info(msg, opts) ``` ### `Snacks.notify.notify()` ```lua ---@param msg string|string[] ---@param opts? snacks.notify.Opts Snacks.notify.notify(msg, opts) ``` ### `Snacks.notify.warn()` ```lua ---@param msg string|string[] ---@param opts? snacks.notify.Opts Snacks.notify.warn(msg, opts) ``` ================================================ FILE: docs/picker.md ================================================ # 🍿 picker Snacks now comes with a modern fuzzy-finder to navigate the Neovim universe. ![image](https://github.com/user-attachments/assets/b454fc3c-6613-4aa4-9296-f57a8b02bf6d) ![image](https://github.com/user-attachments/assets/3203aec4-7d75-4bca-b3d5-18d931277e4e) ![image](https://github.com/user-attachments/assets/e09d25f8-8559-441c-a0f7-576d2aa57097) ![image](https://github.com/user-attachments/assets/291dcf63-0c1d-4e9a-97cb-dd5503660e6f) ![image](https://github.com/user-attachments/assets/1aba5737-a650-4a00-94f8-033b7d8d21ba) ![image](https://github.com/user-attachments/assets/976e0ed8-eb80-43e1-93ac-4683136c0a3c) ## ✨ Features - 🔎 over 40 [built-in sources](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md#-sources) - 🚀 Fast and powerful fuzzy matching engine that supports the [fzf](https://junegunn.github.io/fzf/search-syntax/) search syntax - additionally supports field searches like `file:lua$ 'function` - `files` and `grep` additionally support adding options like `foo -- -e=lua` - 🌲 uses **treesitter** highlighting where it makes sense - 🧹 Sane default settings so you can start using it right away - 💪 Finders and matchers run asynchronously for maximum performance - 🪟 Different [layouts](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md#%EF%B8%8F-layouts) to suit your needs, or create your own. Uses [Snacks.layout](https://github.com/folke/snacks.nvim/blob/main/docs/layout.md) under the hood. - 💻 Simple API to create your own pickers - 📋 Better `vim.ui.select` Some acknowledgements: - [fzf-lua](https://github.com/ibhagwan/fzf-lua) - [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) - [mini.pick](https://github.com/nvim-mini/mini.pick) ## 📚 Usage The best way to get started is to copy some of the [example configs](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md#-examples) below. ```lua -- Show all pickers Snacks.picker() -- run files picker (all three are equivalent) Snacks.picker.files(opts) Snacks.picker.pick("files", opts) Snacks.picker.pick({source = "files", ...}) ``` ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { picker = { -- your picker configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.picker.Config ---@field multi? (string|snacks.picker.Config)[] ---@field source? string source name and config to use ---@field pattern? string|fun(picker:snacks.Picker):string pattern used to filter items by the matcher ---@field search? string|fun(picker:snacks.Picker):string search string used by finders ---@field cwd? string current working directory ---@field live? boolean when true, typing will trigger live searches ---@field limit? number when set, the finder will stop after finding this number of items. useful for live searches ---@field limit_live? number when set, the finder will stop after finding this number of items during live searches. useful for performance ---@field ui_select? boolean set `vim.ui.select` to a snacks picker ---@field filter? snacks.picker.filter.Config generic filter used by some finders --- Source definition ---@field items? snacks.picker.finder.Item[] items to show instead of using a finder ---@field format? string|snacks.picker.format|string format function or preset ---@field finder? string|snacks.picker.finder|snacks.picker.finder.multi finder function or preset ---@field preview? snacks.picker.preview|string preview function or preset ---@field matcher? snacks.picker.matcher.Config|{} matcher config ---@field sort? snacks.picker.sort|snacks.picker.sort.Config sort function or config ---@field transform? string|snacks.picker.transform transform/filter function --- UI ---@field win? snacks.picker.win.Config ---@field layout? snacks.picker.layout.Config|string|{}|fun(source:string):(snacks.picker.layout.Config|string) ---@field icons? snacks.picker.icons ---@field prompt? string prompt text / icon ---@field title? string defaults to a capitalized source name ---@field auto_close? boolean automatically close the picker when focusing another window (defaults to true) ---@field show_empty? boolean show the picker even when there are no items ---@field show_delay? number delay (in ms) to wait before showing the picker while no results yet ---@field focus? "input"|"list" where to focus when the picker is opened (defaults to "input") ---@field enter? boolean enter the picker when opening it ---@field toggles? table --- Preset options ---@field previewers? snacks.picker.previewers.Config|{} ---@field formatters? snacks.picker.formatters.Config|{} ---@field sources? snacks.picker.sources.Config|{}|table ---@field layouts? table --- Actions ---@field actions? table actions used by keymaps ---@field confirm? snacks.picker.Action.spec shortcut for confirm action ---@field auto_confirm? boolean automatically confirm if there is only one item ---@field main? snacks.picker.main.Config main editor window config ---@field on_change? fun(picker:snacks.Picker, item?:snacks.picker.Item) called when the cursor changes ---@field on_show? fun(picker:snacks.Picker) called when the picker is shown ---@field on_close? fun(picker:snacks.Picker) called when the picker is closed ---@field jump? snacks.picker.jump.Config|{} --- Other ---@field config? fun(opts:snacks.picker.Config):snacks.picker.Config? custom config function ---@field db? snacks.picker.db.Config|{} ---@field debug? snacks.picker.debug|{} { prompt = " ", sources = {}, focus = "input", show_delay = 5000, limit_live = 10000, layout = { cycle = true, --- Use the default layout or vertical if the window is too narrow preset = function() return vim.o.columns >= 120 and "default" or "vertical" end, }, ---@class snacks.picker.matcher.Config matcher = { fuzzy = true, -- use fuzzy matching smartcase = true, -- use smartcase ignorecase = true, -- use ignorecase sort_empty = false, -- sort results when the search string is empty filename_bonus = true, -- give bonus for matching file names (last part of the path) file_pos = true, -- support patterns like `file:line:col` and `file:line` -- the bonusses below, possibly require string concatenation and path normalization, -- so this can have a performance impact for large lists and increase memory usage cwd_bonus = false, -- give bonus for matching files in the cwd frecency = false, -- frecency bonus history_bonus = false, -- give more weight to chronological order }, sort = { -- default sort is by score, text length and index fields = { "score:desc", "#text", "idx" }, }, ui_select = true, -- replace `vim.ui.select` with the snacks picker ---@class snacks.picker.formatters.Config formatters = { text = { ft = nil, ---@type string? filetype for highlighting }, file = { filename_first = false, -- display filename before the file path --- * left: truncate the beginning of the path --- * center: truncate the middle of the path --- * right: truncate the end of the path ---@type "left"|"center"|"right" truncate = "center", min_width = 40, -- minimum length of the truncated path filename_only = false, -- only show the filename icon_width = 2, -- width of the icon (in characters) git_status_hl = true, -- use the git status highlight group for the filename }, selected = { show_always = false, -- only show the selected column when there are multiple selections unselected = true, -- use the unselected icon for unselected items }, severity = { icons = true, -- show severity icons level = false, -- show severity level ---@type "left"|"right" pos = "left", -- position of the diagnostics }, }, ---@class snacks.picker.previewers.Config previewers = { diff = { -- fancy: Snacks fancy diff (borders, multi-column line numbers, syntax highlighting) -- syntax: Neovim's built-in diff syntax highlighting -- terminal: external command (git's pager for git commands, `cmd` for other diffs) style = "fancy", ---@type "fancy"|"syntax"|"terminal" cmd = { "delta" }, -- example for using `delta` as the external diff command ---@type vim.wo?|{} window options for the fancy diff preview window wo = { breakindent = true, wrap = true, linebreak = true, showbreak = "", }, }, git = { args = {}, -- additional arguments passed to the git command. Useful to set pager options usin `-c ...` }, file = { max_size = 1024 * 1024, -- 1MB max_line_length = 500, -- max line length ft = nil, ---@type string? filetype for highlighting. Use `nil` for auto detect }, man_pager = nil, ---@type string? MANPAGER env to use for `man` preview }, ---@class snacks.picker.jump.Config jump = { jumplist = true, -- save the current position in the jumplist tagstack = false, -- save the current position in the tagstack reuse_win = false, -- reuse an existing window if the buffer is already open close = true, -- close the picker when jumping/editing to a location (defaults to true) match = false, -- jump to the first match position. (useful for `lines`) }, toggles = { follow = "f", hidden = "h", ignored = "i", modified = "m", regex = { icon = "R", value = false }, }, win = { -- input window input = { keys = { -- to close the picker on ESC instead of going to normal mode, -- add the following keymap to your config -- [""] = { "close", mode = { "n", "i" } }, ["/"] = "toggle_focus", [""] = { "history_forward", mode = { "i", "n" } }, [""] = { "history_back", mode = { "i", "n" } }, [""] = { "cancel", mode = "i" }, [""] = { "", mode = { "i" }, expr = true, desc = "delete word" }, [""] = { "confirm", mode = { "n", "i" } }, [""] = { "list_down", mode = { "i", "n" } }, [""] = "cancel", [""] = { { "pick_win", "jump" }, mode = { "n", "i" } }, [""] = { "select_and_prev", mode = { "i", "n" } }, [""] = { "select_and_next", mode = { "i", "n" } }, [""] = { "list_up", mode = { "i", "n" } }, [""] = { "inspect", mode = { "n", "i" } }, [""] = { "toggle_follow", mode = { "i", "n" } }, [""] = { "toggle_hidden", mode = { "i", "n" } }, [""] = { "toggle_ignored", mode = { "i", "n" } }, [""] = { "toggle_regex", mode = { "i", "n" } }, [""] = { "toggle_maximize", mode = { "i", "n" } }, [""] = { "toggle_preview", mode = { "i", "n" } }, [""] = { "cycle_win", mode = { "i", "n" } }, [""] = { "select_all", mode = { "n", "i" } }, [""] = { "preview_scroll_up", mode = { "i", "n" } }, [""] = { "list_scroll_down", mode = { "i", "n" } }, [""] = { "preview_scroll_down", mode = { "i", "n" } }, [""] = { "toggle_live", mode = { "i", "n" } }, [""] = { "list_down", mode = { "i", "n" } }, [""] = { "list_up", mode = { "i", "n" } }, [""] = { "list_down", mode = { "i", "n" } }, [""] = { "list_up", mode = { "i", "n" } }, [""] = { "qflist", mode = { "i", "n" } }, [""] = { "edit_split", mode = { "i", "n" } }, [""] = { "tab", mode = { "n", "i" } }, [""] = { "list_scroll_up", mode = { "i", "n" } }, [""] = { "edit_vsplit", mode = { "i", "n" } }, ["#"] = { "insert_alt", mode = "i" }, ["%"] = { "insert_filename", mode = "i" }, [""] = { "insert_cWORD", mode = "i" }, [""] = { "insert_file", mode = "i" }, [""] = { "insert_line", mode = "i" }, [""] = { "insert_file_full", mode = "i" }, [""] = { "insert_cword", mode = "i" }, ["H"] = "layout_left", ["J"] = "layout_bottom", ["K"] = "layout_top", ["L"] = "layout_right", ["?"] = "toggle_help_input", ["G"] = "list_bottom", ["gg"] = "list_top", ["j"] = "list_down", ["k"] = "list_up", ["q"] = "cancel", }, b = { minipairs_disable = true, }, }, -- result list window list = { keys = { ["/"] = "toggle_focus", ["<2-LeftMouse>"] = "confirm", [""] = "confirm", [""] = "list_down", [""] = "cancel", [""] = { { "pick_win", "jump" } }, [""] = { "select_and_prev", mode = { "n", "x" } }, [""] = { "select_and_next", mode = { "n", "x" } }, [""] = "list_up", [""] = "inspect", [""] = "toggle_follow", [""] = "toggle_hidden", [""] = "toggle_ignored", [""] = "toggle_maximize", [""] = "toggle_preview", [""] = "cycle_win", [""] = "select_all", [""] = "preview_scroll_up", [""] = "list_scroll_down", [""] = "preview_scroll_down", [""] = "list_down", [""] = "list_up", [""] = "list_down", [""] = "list_up", [""] = "qflist", [""] = "print_path", [""] = "edit_split", [""] = "tab", [""] = "list_scroll_up", [""] = "edit_vsplit", ["H"] = "layout_left", ["J"] = "layout_bottom", ["K"] = "layout_top", ["L"] = "layout_right", ["?"] = "toggle_help_list", ["G"] = "list_bottom", ["gg"] = "list_top", ["i"] = "focus_input", ["j"] = "list_down", ["k"] = "list_up", ["q"] = "cancel", ["zb"] = "list_scroll_bottom", ["zt"] = "list_scroll_top", ["zz"] = "list_scroll_center", }, wo = { conceallevel = 2, concealcursor = "nvc", }, }, -- preview window preview = { keys = { [""] = "cancel", ["q"] = "cancel", ["i"] = "focus_input", [""] = "cycle_win", }, }, }, ---@class snacks.picker.icons icons = { files = { enabled = true, -- show file icons dir = "󰉋 ", dir_open = "󰝰 ", file = "󰈔 " }, keymaps = { nowait = "󰓅 " }, tree = { vertical = "│ ", middle = "├╴", last = "└╴", }, undo = { saved = " ", }, ui = { live = "󰐰 ", hidden = "h", ignored = "i", follow = "f", selected = "● ", unselected = "○ ", -- selected = " ", }, git = { enabled = true, -- show git icons commit = "󰜘 ", -- used by git log staged = "●", -- staged changes. always overrides the type icons added = "", deleted = "", ignored = " ", modified = "○", renamed = "", unmerged = " ", untracked = "?", }, diagnostics = { Error = " ", Warn = " ", Hint = " ", Info = " ", }, lsp = { unavailable = "", enabled = " ", disabled = " ", attached = "󰖩 " }, kinds = { Array = " ", Boolean = "󰨙 ", Class = " ", Color = " ", Control = " ", Collapsed = " ", Constant = "󰏿 ", Constructor = " ", Copilot = " ", Enum = " ", EnumMember = " ", Event = " ", Field = " ", File = " ", Folder = " ", Function = "󰊕 ", Interface = " ", Key = " ", Keyword = " ", Method = "󰊕 ", Module = " ", Namespace = "󰦮 ", Null = " ", Number = "󰎠 ", Object = " ", Operator = " ", Package = " ", Property = " ", Reference = " ", Snippet = "󱄽 ", String = " ", Struct = "󰆼 ", Text = " ", TypeParameter = " ", Unit = " ", Unknown = " ", Value = " ", Variable = "󰀫 ", }, }, ---@class snacks.picker.db.Config db = { -- path to the sqlite3 library -- If not set, it will try to load the library by name. -- On Windows it will download the library from the internet. sqlite3_path = nil, ---@type string? }, ---@class snacks.picker.debug debug = { scores = false, -- show scores in the list leaks = false, -- show when pickers don't get garbage collected explorer = false, -- show explorer debug info files = false, -- show file debug info grep = false, -- show file debug info proc = false, -- show proc debug info extmarks = false, -- show extmarks errors }, } ``` ## 🚀 Examples ### `flash` ```lua { "folke/flash.nvim", optional = true, specs = { { "folke/snacks.nvim", opts = { picker = { win = { input = { keys = { [""] = { "flash", mode = { "n", "i" } }, ["s"] = { "flash" }, }, }, }, actions = { flash = function(picker) require("flash").jump({ pattern = "^", label = { after = { 0, 0 } }, search = { mode = "search", exclude = { function(win) return vim.bo[vim.api.nvim_win_get_buf(win)].filetype ~= "snacks_picker_list" end, }, }, action = function(match) local idx = picker.list:row2idx(match.pos[1]) picker.list:_move(idx, true, true) end, }) end, }, }, }, }, }, } ``` ### `general` ```lua { "folke/snacks.nvim", opts = { picker = {}, explorer = {}, }, keys = { -- Top Pickers & Explorer { "", function() Snacks.picker.smart() end, desc = "Smart Find Files" }, { ",", function() Snacks.picker.buffers() end, desc = "Buffers" }, { "/", function() Snacks.picker.grep() end, desc = "Grep" }, { ":", function() Snacks.picker.command_history() end, desc = "Command History" }, { "n", function() Snacks.picker.notifications() end, desc = "Notification History" }, { "e", function() Snacks.explorer() end, desc = "File Explorer" }, -- find { "fb", function() Snacks.picker.buffers() end, desc = "Buffers" }, { "fc", function() Snacks.picker.files({ cwd = vim.fn.stdpath("config") }) end, desc = "Find Config File" }, { "ff", function() Snacks.picker.files() end, desc = "Find Files" }, { "fg", function() Snacks.picker.git_files() end, desc = "Find Git Files" }, { "fp", function() Snacks.picker.projects() end, desc = "Projects" }, { "fr", function() Snacks.picker.recent() end, desc = "Recent" }, -- git { "gb", function() Snacks.picker.git_branches() end, desc = "Git Branches" }, { "gl", function() Snacks.picker.git_log() end, desc = "Git Log" }, { "gL", function() Snacks.picker.git_log_line() end, desc = "Git Log Line" }, { "gs", function() Snacks.picker.git_status() end, desc = "Git Status" }, { "gS", function() Snacks.picker.git_stash() end, desc = "Git Stash" }, { "gd", function() Snacks.picker.git_diff() end, desc = "Git Diff (Hunks)" }, { "gf", function() Snacks.picker.git_log_file() end, desc = "Git Log File" }, -- gh { "gi", function() Snacks.picker.gh_issue() end, desc = "GitHub Issues (open)" }, { "gI", function() Snacks.picker.gh_issue({ state = "all" }) end, desc = "GitHub Issues (all)" }, { "gp", function() Snacks.picker.gh_pr() end, desc = "GitHub Pull Requests (open)" }, { "gP", function() Snacks.picker.gh_pr({ state = "all" }) end, desc = "GitHub Pull Requests (all)" }, -- Grep { "sb", function() Snacks.picker.lines() end, desc = "Buffer Lines" }, { "sB", function() Snacks.picker.grep_buffers() end, desc = "Grep Open Buffers" }, { "sg", function() Snacks.picker.grep() end, desc = "Grep" }, { "sw", function() Snacks.picker.grep_word() end, desc = "Visual selection or word", mode = { "n", "x" } }, -- search { 's"', function() Snacks.picker.registers() end, desc = "Registers" }, { 's/', function() Snacks.picker.search_history() end, desc = "Search History" }, { "sa", function() Snacks.picker.autocmds() end, desc = "Autocmds" }, { "sb", function() Snacks.picker.lines() end, desc = "Buffer Lines" }, { "sc", function() Snacks.picker.command_history() end, desc = "Command History" }, { "sC", function() Snacks.picker.commands() end, desc = "Commands" }, { "sd", function() Snacks.picker.diagnostics() end, desc = "Diagnostics" }, { "sD", function() Snacks.picker.diagnostics_buffer() end, desc = "Buffer Diagnostics" }, { "sh", function() Snacks.picker.help() end, desc = "Help Pages" }, { "sH", function() Snacks.picker.highlights() end, desc = "Highlights" }, { "si", function() Snacks.picker.icons() end, desc = "Icons" }, { "sj", function() Snacks.picker.jumps() end, desc = "Jumps" }, { "sk", function() Snacks.picker.keymaps() end, desc = "Keymaps" }, { "sl", function() Snacks.picker.loclist() end, desc = "Location List" }, { "sm", function() Snacks.picker.marks() end, desc = "Marks" }, { "sM", function() Snacks.picker.man() end, desc = "Man Pages" }, { "sp", function() Snacks.picker.lazy() end, desc = "Search for Plugin Spec" }, { "sq", function() Snacks.picker.qflist() end, desc = "Quickfix List" }, { "sR", function() Snacks.picker.resume() end, desc = "Resume" }, { "su", function() Snacks.picker.undo() end, desc = "Undo History" }, { "uC", function() Snacks.picker.colorschemes() end, desc = "Colorschemes" }, -- LSP { "gd", function() Snacks.picker.lsp_definitions() end, desc = "Goto Definition" }, { "gD", function() Snacks.picker.lsp_declarations() end, desc = "Goto Declaration" }, { "gr", function() Snacks.picker.lsp_references() end, nowait = true, desc = "References" }, { "gI", function() Snacks.picker.lsp_implementations() end, desc = "Goto Implementation" }, { "gy", function() Snacks.picker.lsp_type_definitions() end, desc = "Goto T[y]pe Definition" }, { "gai", function() Snacks.picker.lsp_incoming_calls() end, desc = "C[a]lls Incoming" }, { "gao", function() Snacks.picker.lsp_outgoing_calls() end, desc = "C[a]lls Outgoing" }, { "ss", function() Snacks.picker.lsp_symbols() end, desc = "LSP Symbols" }, { "sS", function() Snacks.picker.lsp_workspace_symbols() end, desc = "LSP Workspace Symbols" }, }, } ``` ### `todo_comments` ```lua { "folke/todo-comments.nvim", optional = true, keys = { { "st", function() Snacks.picker.todo_comments() end, desc = "Todo" }, { "sT", function () Snacks.picker.todo_comments({ keywords = { "TODO", "FIX", "FIXME" } }) end, desc = "Todo/Fix/Fixme" }, }, } ``` ### `trouble` ```lua { "folke/trouble.nvim", optional = true, specs = { "folke/snacks.nvim", opts = function(_, opts) return vim.tbl_deep_extend("force", opts or {}, { picker = { actions = require("trouble.sources.snacks").actions, win = { input = { keys = { [""] = { "trouble_open", mode = { "n", "i" }, }, }, }, }, }, }) end, }, } ``` ## 📚 Types ```lua ---@class snacks.picker.resume.Opts ---@field source? string ---@field include? string[] ---@field exclude? string[] ``` ```lua ---@class snacks.picker.jump.Action: snacks.picker.Action ---@field cmd? snacks.picker.EditCmd ``` ```lua ---@class snacks.picker.layout.Action: snacks.picker.Action ---@field layout? snacks.picker.layout.Config|string ``` ```lua ---@class snacks.picker.yank.Action: snacks.picker.Action ---@field reg? string ---@field field? string ---@field notify? boolean ``` ```lua ---@class snacks.picker.insert.Action: snacks.picker.Action ---@field expr string ``` ```lua ---@alias snacks.picker.format.resolve fun(max_width:number):snacks.picker.Highlight[] ---@alias snacks.picker.Extmark vim.api.keyset.set_extmark|{col:number, row?:number, field?:string} ---@alias snacks.picker.Meta {[string]:any} ---@alias snacks.picker.Text {[1]:string, [2]:(string|string[])?, virtual?:boolean, field?:string, resolve?:snacks.picker.format.resolve, inline?:boolean} ---@alias snacks.picker.Highlight snacks.picker.Text|snacks.picker.Extmark|{meta?:snacks.picker.Meta} ---@alias snacks.picker.format fun(item:snacks.picker.Item, picker:snacks.Picker):snacks.picker.Highlight[] ---@alias snacks.picker.preview fun(ctx: snacks.picker.preview.ctx):boolean? ---@alias snacks.picker.sort fun(a:snacks.picker.Item, b:snacks.picker.Item):boolean ---@alias snacks.picker.transform fun(item:snacks.picker.finder.Item, ctx:snacks.picker.finder.ctx):(boolean|snacks.picker.finder.Item|nil) ---@alias snacks.picker.Pos {[1]:number, [2]:number} ---@alias snacks.picker.toggle {icon?:string, enabled?:boolean, value?:boolean} ``` Generic filter used by some finders to pre-filter items ```lua ---@class snacks.picker.filter.Config ---@field cwd? boolean|string only show files for the given cwd ---@field buf? boolean|number only show items for the current or given buffer ---@field paths? table only show items that include or exclude the given paths ---@field filter? fun(item:snacks.picker.finder.Item, filter:snacks.picker.Filter):boolean? custom filter function ---@field transform? fun(picker:snacks.Picker, filter:snacks.picker.Filter):boolean? filter transform. Return `true` to force refresh ``` This is only used when using `opts.preview = "preview"`. It's a previewer that shows a preview based on the item data. ```lua ---@class snacks.picker.Item.preview ---@field text string text to show in the preview buffer ---@field ft? string optional filetype used tohighlight the preview buffer ---@field extmarks? snacks.picker.Extmark[] additional extmarks ---@field loc? boolean set to false to disable showing the item location in the preview ``` ```lua ---@class snacks.picker.Item ---@field [string] any ---@field idx number ---@field score number ---@field frecency? number ---@field score_add? number ---@field score_mul? number ---@field source_id? number ---@field file? string ---@field text string ---@field pos? snacks.picker.Pos ---@field loc? snacks.picker.lsp.Loc ---@field end_pos? snacks.picker.Pos ---@field highlights? snacks.picker.Highlight[][] ---@field preview? snacks.picker.Item.preview ---@field resolve? fun(item:snacks.picker.Item) ---@field positions? number[] indices of matched characters in `text` ``` ```lua ---@class snacks.picker.finder.Item: snacks.picker.Item ---@field idx? number ---@field score? number ``` ```lua ---@class snacks.picker.layout.Config ---@field layout snacks.layout.Box ---@field reverse? boolean when true, the list will be reversed (bottom-up) ---@field fullscreen? boolean open in fullscreen ---@field cycle? boolean cycle through the list ---@field preview? "main" show preview window in the picker or the main window ---@field preset? string|fun(source:string):string ---@field hidden? ("input"|"preview"|"list")[] don't show the given windows when opening the picker. (only "input" and "preview" make sense) ---@field auto_hide? ("input"|"preview"|"list")[] hide the given windows when not focused (only "input" makes real sense) ---@field config? fun(layout:snacks.picker.layout.Config) customize the resolved layout config ``` ```lua ---@class snacks.picker.win.Config ---@field input? snacks.win.Config|{} input window config ---@field list? snacks.win.Config|{} result list window config ---@field preview? snacks.win.Config|{} preview window config ``` ```lua ---@alias snacks.Picker.ref (fun():snacks.Picker?)|{value?: snacks.Picker} ``` ```lua ---@alias snacks.picker.history.Record {pattern: string, search: string, live?: boolean} ``` ## 📦 Module ```lua ---@class snacks.picker ---@field actions snacks.picker.actions ---@field config snacks.picker.config ---@field format snacks.picker.formatters ---@field preview snacks.picker.previewers ---@field sort snacks.picker.sorters ---@field util snacks.picker.util ---@field current? snacks.Picker ---@field highlight snacks.picker.highlight ---@field resume fun(opts?: snacks.picker.Config):snacks.Picker ---@field sources snacks.picker.sources.Config Snacks.picker = {} ``` ### `Snacks.picker()` ```lua ---@type fun(source: string, opts: snacks.picker.Config): snacks.Picker Snacks.picker() ``` ```lua ---@type fun(opts: snacks.picker.Config): snacks.Picker Snacks.picker() ``` ### `Snacks.picker.get()` Get active pickers, optionally filtered by source, or the current tab ```lua ---@param opts? {source?: string, tab?: boolean} tab defaults to true Snacks.picker.get(opts) ``` ### `Snacks.picker.pick()` Create a new picker ```lua ---@param source? string ---@param opts? snacks.picker.Config ---@overload fun(opts: snacks.picker.Config): snacks.Picker Snacks.picker.pick(source, opts) ``` ### `Snacks.picker.resume()` ```lua ---@param opts? snacks.picker.resume.Opts ---@overload fun(source:string):snacks.Picker? ---@return snacks.Picker? Snacks.picker.resume(opts) ``` ### `Snacks.picker.select()` Implementation for `vim.ui.select` ```lua ---@type snacks.picker.ui_select Snacks.picker.select(...) ``` ## 🔍 Sources ### `autocmds` ```vim :lua Snacks.picker.autocmds(opts?) ``` ```lua { finder = "vim_autocmds", format = "autocmd", preview = "preview", } ``` ### `buffers` ```vim :lua Snacks.picker.buffers(opts?) ``` ```lua ---@class snacks.picker.buffers.Config: snacks.picker.Config ---@field hidden? boolean show hidden buffers (unlisted) ---@field unloaded? boolean show loaded buffers ---@field current? boolean show current buffer ---@field nofile? boolean show `buftype=nofile` buffers ---@field modified? boolean show only modified buffers ---@field sort_lastused? boolean sort by last used ---@field filter? snacks.picker.filter.Config { finder = "buffers", format = "buffer", hidden = false, unloaded = true, current = true, sort_lastused = true, win = { input = { keys = { [""] = { "bufdelete", mode = { "n", "i" } }, }, }, list = { keys = { ["dd"] = "bufdelete" } }, }, } ``` ### `cliphist` ```vim :lua Snacks.picker.cliphist(opts?) ``` ```lua { finder = "system_cliphist", format = "text", preview = "preview", confirm = { "copy", "close" }, } ``` ### `colorschemes` ```vim :lua Snacks.picker.colorschemes(opts?) ``` Neovim colorschemes with live preview ```lua { finder = "vim_colorschemes", format = "text", preview = "colorscheme", preset = "vertical", confirm = function(picker, item) picker:close() if item then picker.preview.state.colorscheme = nil vim.schedule(function() vim.cmd("colorscheme " .. item.text) end) end end, } ``` ### `command_history` ```vim :lua Snacks.picker.command_history(opts?) ``` Neovim command history ```lua ---@type snacks.picker.history.Config { finder = "vim_history", name = "cmd", format = "text", preview = "none", main = { current = true }, layout = { preset = "vscode", }, confirm = "cmd", formatters = { text = { ft = "vim" } }, } ``` ### `commands` ```vim :lua Snacks.picker.commands(opts?) ``` Neovim commands ```lua { finder = "vim_commands", format = "command", preview = "preview", confirm = "cmd", } ``` ### `diagnostics` ```vim :lua Snacks.picker.diagnostics(opts?) ``` ```lua ---@class snacks.picker.diagnostics.Config: snacks.picker.Config ---@field filter? snacks.picker.filter.Config ---@field severity? vim.diagnostic.SeverityFilter { finder = "diagnostics", format = "diagnostic", sort = { fields = { "is_current", "is_cwd", "severity", "file", "lnum", }, }, matcher = { sort_empty = true }, -- only show diagnostics from the cwd by default filter = { cwd = true }, } ``` ### `diagnostics_buffer` ```vim :lua Snacks.picker.diagnostics_buffer(opts?) ``` ```lua ---@type snacks.picker.diagnostics.Config { finder = "diagnostics", format = "diagnostic", sort = { fields = { "severity", "file", "lnum" }, }, matcher = { sort_empty = true }, filter = { buf = true }, } ``` ### `explorer` ```vim :lua Snacks.picker.explorer(opts?) ``` ```lua ---@class snacks.picker.explorer.Config: snacks.picker.files.Config|{} ---@field follow_file? boolean follow the file from the current buffer ---@field tree? boolean show the file tree (default: true) ---@field git_status? boolean show git status (default: true) ---@field git_status_open? boolean show recursive git status for open directories ---@field git_untracked? boolean needed to show untracked git status ---@field diagnostics? boolean show diagnostics ---@field diagnostics_open? boolean show recursive diagnostics for open directories ---@field watch? boolean watch for file changes ---@field exclude? string[] exclude glob patterns ---@field include? string[] include glob patterns. These take precedence over `exclude`, `ignored` and `hidden` { finder = "explorer", sort = { fields = { "sort" } }, supports_live = true, tree = true, watch = true, diagnostics = true, diagnostics_open = false, git_status = true, git_status_open = false, git_untracked = true, follow_file = true, focus = "list", auto_close = false, jump = { close = false }, layout = { preset = "sidebar", preview = false }, -- to show the explorer to the right, add the below to -- your config under `opts.picker.sources.explorer` -- layout = { layout = { position = "right" } }, formatters = { file = { filename_only = true }, severity = { pos = "right" }, }, matcher = { sort_empty = false, fuzzy = false }, config = function(opts) return require("snacks.picker.source.explorer").setup(opts) end, win = { list = { keys = { [""] = "explorer_up", ["l"] = "confirm", ["h"] = "explorer_close", -- close directory ["a"] = "explorer_add", ["d"] = "explorer_del", ["r"] = "explorer_rename", ["c"] = "explorer_copy", ["m"] = "explorer_move", ["o"] = "explorer_open", -- open with system application ["P"] = "toggle_preview", ["y"] = { "explorer_yank", mode = { "n", "x" } }, ["p"] = "explorer_paste", ["u"] = "explorer_update", [""] = "tcd", ["/"] = "picker_grep", [""] = "terminal", ["."] = "explorer_focus", ["I"] = "toggle_ignored", ["H"] = "toggle_hidden", ["Z"] = "explorer_close_all", ["]g"] = "explorer_git_next", ["[g"] = "explorer_git_prev", ["]d"] = "explorer_diagnostic_next", ["[d"] = "explorer_diagnostic_prev", ["]w"] = "explorer_warn_next", ["[w"] = "explorer_warn_prev", ["]e"] = "explorer_error_next", ["[e"] = "explorer_error_prev", }, }, }, } ``` ### `files` ```vim :lua Snacks.picker.files(opts?) ``` ```lua ---@class snacks.picker.files.Config: snacks.picker.proc.Config ---@field cmd? "fd"| "rg"| "find" command to use. Leave empty to auto-detect ---@field hidden? boolean show hidden files ---@field ignored? boolean show ignored files ---@field dirs? string[] directories to search ---@field follow? boolean follow symlinks ---@field exclude? string[] exclude patterns ---@field args? string[] additional arguments ---@field ft? string|string[] file extension(s) ---@field rtp? boolean search in runtimepath { finder = "files", format = "file", show_empty = true, hidden = false, ignored = false, follow = false, supports_live = true, } ``` ### `gh_actions` ```vim :lua Snacks.picker.gh_actions(opts?) ``` ```lua ---@class snacks.picker.gh.actions.Config: snacks.picker.Config ---@field number number issue or PR number ---@field repo string GitHub repository (owner/repo). Defaults to current git repo ---@field type "issue" | "pr" ---@field item? snacks.picker.gh.Item { layout = { preset = "select", layout = { max_width = 50 } }, title = " Actions", main = { current = true }, finder = "gh_get_actions", format = "gh_format_action", confirm = "gh_perform_action", } ``` ### `gh_diff` ```vim :lua Snacks.picker.gh_diff(opts?) ``` ```lua ---@class snacks.picker.gh.diff.Config: snacks.picker.Config ---@field group? boolean group changes by file (when false, show individual hunks) ---@field pr number number PR number to diff against ---@field repo? string GitHub repository (owner/repo). Defaults to current git repo { title = " Pull Request Diff", group = true, finder = "gh_diff", format = "git_status", preview = "gh_preview_diff", win = { preview = { keys = { ["a"] = { "gh_comment", mode = { "n", "x" } }, [""] = { "gh_actions", mode = { "n", "x" } }, }, }, }, } ``` ### `gh_issue` ```vim :lua Snacks.picker.gh_issue(opts?) ``` ```lua ---@class snacks.picker.gh.issue.Config: snacks.picker.gh.Config ---@field state "open" | "closed" | "all" ---@field mention? string filter by mention ---@field milestone? string filter by milestone { title = " Issues", finder = "gh_issue", format = "gh_format", preview = "gh_preview", sort = { fields = { "score:desc", "idx" } }, supports_live = true, live = true, confirm = "gh_actions", win = { input = { keys = { [""] = { "gh_browse", mode = { "n", "i" } }, [""] = { "gh_yank", mode = { "n", "i" } }, }, }, list = { keys = { ["y"] = { "gh_yank", mode = { "n", "x" } }, }, }, }, } ``` ### `gh_labels` ```vim :lua Snacks.picker.gh_labels(opts?) ``` ```lua ---@class snacks.picker.gh.labels.Config: snacks.picker.Config ---@field number number issue or PR number ---@field repo string GitHub repository (owner/repo). Defaults to current git repo { layout = { preset = "select", layout = { max_width = 50 } }, title = " Labels", main = { current = true }, group = true, finder = "gh_labels", format = "gh_format_label", } ``` ### `gh_pr` ```vim :lua Snacks.picker.gh_pr(opts?) ``` ```lua ---@class snacks.picker.gh.pr.Config: snacks.picker.gh.Config ---@field state "open" | "closed" | "merged" | "all" ---@field draft? boolean filter draft PRs ---@field base? string filter by base branch { title = " Pull Requests", finder = "gh_pr", format = "gh_format", preview = "gh_preview", sort = { fields = { "score:desc", "idx" } }, supports_live = true, live = true, confirm = "gh_actions", win = { input = { keys = { [""] = { "gh_browse", mode = { "n", "i" } }, [""] = { "gh_yank", mode = { "n", "i" } }, }, }, list = { keys = { ["y"] = { "gh_yank", mode = { "n", "x" } }, }, }, }, } ``` ### `gh_reactions` ```vim :lua Snacks.picker.gh_reactions(opts?) ``` ```lua ---@class snacks.picker.gh.reactions.Config: snacks.picker.Config ---@field number number issue or PR number ---@field repo string GitHub repository (owner/repo). Defaults to current git repo { layout = { preset = "select", layout = { max_width = 50 } }, title = " Reactions", main = { current = true }, group = true, finder = "gh_reactions", format = "gh_format_reaction", } ``` ### `git_branches` ```vim :lua Snacks.picker.git_branches(opts?) ``` ```lua ---@class snacks.picker.git.branches.Config: snacks.picker.git.Config ---@field all? boolean show all branches, including remote { all = false, finder = "git_branches", format = "git_branch", preview = "git_log", confirm = "git_checkout", win = { input = { keys = { [""] = { "git_branch_add", mode = { "n", "i" } }, [""] = { "git_branch_del", mode = { "n", "i" } }, }, }, }, ---@param picker snacks.Picker on_show = function(picker) for i, item in ipairs(picker:items()) do if item.current then picker.list:view(i) Snacks.picker.actions.list_scroll_center(picker) break end end end, } ``` ### `git_diff` ```vim :lua Snacks.picker.git_diff(opts?) ``` ```lua ---@class snacks.picker.git.diff.Config: snacks.picker.git.Config ---@field group? boolean group changes by file (when false, show individual hunks) ---@field staged? boolean show staged changes ---@field base? string base commit/branch/tag to diff against (default: HEAD) { group = false, finder = "git_diff", format = "git_status", preview = "diff", matcher = { sort_empty = true }, sort = { fields = { "score:desc", "file", "idx" } }, win = { input = { keys = { [""] = { "git_stage", mode = { "n", "i" } }, [""] = { "git_restore", mode = { "n", "i" }, nowait = true }, }, }, }, } ``` ### `git_files` ```vim :lua Snacks.picker.git_files(opts?) ``` Find git files ```lua ---@class snacks.picker.git.files.Config: snacks.picker.git.Config ---@field untracked? boolean show untracked files ---@field submodules? boolean show submodule files { finder = "git_files", show_empty = true, format = "file", untracked = false, submodules = false, } ``` ### `git_grep` ```vim :lua Snacks.picker.git_grep(opts?) ``` Grep in git files ```lua ---@class snacks.picker.git.grep.Config: snacks.picker.git.Config ---@field untracked? boolean search in untracked files ---@field submodules? boolean search in submodule files ---@field need_search? boolean require a search pattern ---@field pathspec? string|string[] pathspec pattern(s) ---@field ignorecase? boolean ignore case { finder = "git_grep", format = "file", untracked = false, need_search = true, submodules = false, show_empty = true, supports_live = true, live = true, } ``` ### `git_log` ```vim :lua Snacks.picker.git_log(opts?) ``` Git log ```lua ---@class snacks.picker.git.log.Config: snacks.picker.git.Config ---@field follow? boolean track file history across renames ---@field current_file? boolean show current file log ---@field current_line? boolean show current line log ---@field author? string filter commits by author { finder = "git_log", format = "git_log", preview = "git_show", confirm = "git_checkout", supports_live = true, sort = { fields = { "score:desc", "idx" } }, } ``` ### `git_log_file` ```vim :lua Snacks.picker.git_log_file(opts?) ``` ```lua ---@type snacks.picker.git.log.Config { finder = "git_log", format = "git_log", preview = "git_show", current_file = true, follow = true, confirm = "git_checkout", sort = { fields = { "score:desc", "idx" } }, } ``` ### `git_log_line` ```vim :lua Snacks.picker.git_log_line(opts?) ``` ```lua ---@type snacks.picker.git.log.Config { finder = "git_log", format = "git_log", preview = "git_show", current_line = true, follow = true, confirm = "git_checkout", sort = { fields = { "score:desc", "idx" } }, } ``` ### `git_stash` ```vim :lua Snacks.picker.git_stash(opts?) ``` ```lua { finder = "git_stash", format = "git_stash", preview = "git_stash", confirm = "git_stash_apply", } ``` ### `git_status` ```vim :lua Snacks.picker.git_status(opts?) ``` ```lua ---@class snacks.picker.git.status.Config: snacks.picker.git.Config ---@field ignored? boolean show ignored files { finder = "git_status", format = "git_status", preview = "git_status", win = { input = { keys = { [""] = { "git_stage", mode = { "n", "i" } }, [""] = { "git_restore", mode = { "n", "i" }, nowait = true }, }, }, }, } ``` ### `grep` ```vim :lua Snacks.picker.grep(opts?) ``` ```lua ---@class snacks.picker.grep.Config: snacks.picker.proc.Config ---@field cmd? string ---@field hidden? boolean show hidden files ---@field ignored? boolean show ignored files ---@field dirs? string[] directories to search ---@field follow? boolean follow symlinks ---@field glob? string|string[] glob file pattern(s) ---@field ft? string|string[] ripgrep file type(s). See `rg --type-list` ---@field regex? boolean use regex search pattern (defaults to `true`) ---@field buffers? boolean search in open buffers ---@field need_search? boolean require a search pattern ---@field exclude? string[] exclude patterns ---@field args? string[] additional arguments ---@field rtp? boolean search in runtimepath { finder = "grep", regex = true, format = "file", show_empty = true, live = true, -- live grep by default supports_live = true, } ``` ### `grep_buffers` ```vim :lua Snacks.picker.grep_buffers(opts?) ``` ```lua ---@type snacks.picker.grep.Config|{} { finder = "grep", format = "file", live = true, buffers = true, need_search = false, supports_live = true, } ``` ### `grep_word` ```vim :lua Snacks.picker.grep_word(opts?) ``` ```lua ---@type snacks.picker.grep.Config|{} { finder = "grep", regex = false, args = { "--word-regexp" }, format = "file", search = function(picker) return picker:word() end, live = false, supports_live = true, } ``` ### `help` ```vim :lua Snacks.picker.help(opts?) ``` Neovim help tags ```lua ---@class snacks.picker.help.Config: snacks.picker.Config ---@field lang? string[] defaults to `vim.opt.helplang` { finder = "help", format = "text", previewers = { file = { ft = "help" }, }, win = { preview = { minimal = true } }, confirm = "help", } ``` ### `highlights` ```vim :lua Snacks.picker.highlights(opts?) ``` ```lua { finder = "vim_highlights", format = "hl", preview = "preview", confirm = "close", } ``` ### `icons` ```vim :lua Snacks.picker.icons(opts?) ``` ```lua ---@class snacks.picker.icons.Config: snacks.picker.Config ---@field icon_sources? string[] list of sources to use --- Custom icon sources can be added here. The key is the source name, --- and the value is the file path or URL to load icons from. --- The file should be a JSON array of: --- `{[1]:string, [2]:string}|{icon:string, name:string, category:string}` --- The format is compatible with https://github.com/nvim-telescope/telescope-symbols.nvim ---@field custom_sources? table additional icon sources `table` { main = { current = true }, finder = "icons", format = "icon", layout = { preset = "vscode" }, confirm = "put", } ``` ### `jumps` ```vim :lua Snacks.picker.jumps(opts?) ``` ```lua { finder = "vim_jumps", format = "file", main = { current = true }, } ``` ### `keymaps` ```vim :lua Snacks.picker.keymaps(opts?) ``` ```lua ---@class snacks.picker.keymaps.Config: snacks.picker.Config ---@field global? boolean show global keymaps ---@field local? boolean show buffer keymaps ---@field plugs? boolean show plugin keymaps ---@field modes? string[] { finder = "vim_keymaps", format = "keymap", preview = "preview", global = true, plugs = false, ["local"] = true, modes = { "n", "v", "x", "s", "o", "i", "c", "t" }, ---@param picker snacks.Picker confirm = function(picker, item) picker:norm(function() if item then picker:close() vim.api.nvim_input(item.item.lhs) end end) end, actions = { toggle_global = function(picker) picker.opts.global = not picker.opts.global picker:find() end, toggle_buffer = function(picker) picker.opts["local"] = not picker.opts["local"] picker:find() end, }, win = { input = { keys = { [""] = { "toggle_global", mode = { "n", "i" }, desc = "Toggle Global Keymaps" }, [""] = { "toggle_buffer", mode = { "n", "i" }, desc = "Toggle Buffer Keymaps" }, }, }, }, } ``` ### `lazy` ```vim :lua Snacks.picker.lazy(opts?) ``` Search for a lazy.nvim plugin spec ```lua { finder = "lazy_spec", pattern = "'", } ``` ### `lines` ```vim :lua Snacks.picker.lines(opts?) ``` Search lines in the current buffer ```lua ---@class snacks.picker.lines.Config: snacks.picker.Config ---@field buf? number { finder = "lines", format = "lines", layout = { preview = "main", preset = "ivy", }, jump = { match = true }, -- allow any window to be used as the main window main = { current = true }, ---@param picker snacks.Picker on_show = function(picker) local cursor = vim.api.nvim_win_get_cursor(picker.main) local info = vim.api.nvim_win_call(picker.main, vim.fn.winsaveview) picker.list:view(cursor[1], info.topline) picker:show_preview() end, sort = { fields = { "score:desc", "idx" } }, } ``` ### `loclist` ```vim :lua Snacks.picker.loclist(opts?) ``` Loclist ```lua ---@type snacks.picker.qf.Config { finder = "qf", format = "file", qf_win = 0, main = { current = true }, } ``` ### `lsp_config` ```vim :lua Snacks.picker.lsp_config(opts?) ``` ```lua ---@class snacks.picker.lsp.config.Config: snacks.picker.Config ---@field installed? boolean only show installed servers ---@field configured? boolean only show configured servers (setup with lspconfig) ---@field attached? boolean|number only show attached servers. When `number`, show only servers attached to that buffer (can be 0) { finder = "lsp.config#find", format = "lsp.config#format", preview = "lsp.config#preview", confirm = "close", sort = { fields = { "score:desc", "attached_buf", "attached", "enabled", "installed", "name" } }, matcher = { sort_empty = true }, } ``` ### `lsp_declarations` ```vim :lua Snacks.picker.lsp_declarations(opts?) ``` LSP declarations ```lua ---@type snacks.picker.lsp.Config { finder = "lsp_declarations", format = "file", include_current = false, auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } ``` ### `lsp_definitions` ```vim :lua Snacks.picker.lsp_definitions(opts?) ``` LSP definitions ```lua ---@type snacks.picker.lsp.Config { finder = "lsp_definitions", format = "file", include_current = false, auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } ``` ### `lsp_implementations` ```vim :lua Snacks.picker.lsp_implementations(opts?) ``` LSP implementations ```lua ---@type snacks.picker.lsp.Config { finder = "lsp_implementations", format = "file", include_current = false, auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } ``` ### `lsp_incoming_calls` ```vim :lua Snacks.picker.lsp_incoming_calls(opts?) ``` LSP incoming calls ```lua ---@type snacks.picker.lsp.Config { finder = "lsp_incoming_calls", format = "lsp_symbol", include_current = false, workspace = true, -- this ensures the file is included in the formatter auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } ``` ### `lsp_outgoing_calls` ```vim :lua Snacks.picker.lsp_outgoing_calls(opts?) ``` LSP outgoing calls ```lua ---@type snacks.picker.lsp.Config { finder = "lsp_outgoing_calls", format = "lsp_symbol", include_current = false, workspace = true, -- this ensures the file is included in the formatter auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } ``` ### `lsp_references` ```vim :lua Snacks.picker.lsp_references(opts?) ``` LSP references ```lua ---@class snacks.picker.lsp.references.Config: snacks.picker.lsp.Config ---@field include_declaration? boolean default true { finder = "lsp_references", format = "file", include_declaration = true, include_current = false, auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } ``` ### `lsp_symbols` ```vim :lua Snacks.picker.lsp_symbols(opts?) ``` LSP document symbols ```lua ---@class snacks.picker.lsp.symbols.Config: snacks.picker.Config ---@field tree? boolean show symbol tree ---@field keep_parents? boolean keep parent symbols when filtering ---@field filter table? symbol kind filter ---@field workspace? boolean show workspace symbols { finder = "lsp_symbols", format = "lsp_symbol", tree = true, filter = { default = { "Class", "Constructor", "Enum", "Field", "Function", "Interface", "Method", "Module", "Namespace", "Package", "Property", "Struct", "Trait", }, -- set to `true` to include all symbols markdown = true, help = true, -- you can specify a different filter for each filetype lua = { "Class", "Constructor", "Enum", "Field", "Function", "Interface", "Method", "Module", "Namespace", -- "Package", -- remove package since luals uses it for control flow structures "Property", "Struct", "Trait", }, }, } ``` ### `lsp_type_definitions` ```vim :lua Snacks.picker.lsp_type_definitions(opts?) ``` LSP type definitions ```lua ---@type snacks.picker.lsp.Config { finder = "lsp_type_definitions", format = "file", include_current = false, auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } ``` ### `lsp_workspace_symbols` ```vim :lua Snacks.picker.lsp_workspace_symbols(opts?) ``` ```lua ---@type snacks.picker.lsp.symbols.Config vim.tbl_extend("force", {}, M.lsp_symbols, { workspace = true, tree = false, supports_live = true, live = true, -- live by default }) ``` ### `man` ```vim :lua Snacks.picker.man(opts?) ``` ```lua { finder = "system_man", format = "man", preview = "man", confirm = function(picker, item, action) ---@cast action snacks.picker.jump.Action picker:close() if item then vim.schedule(function() local cmd = "Man " .. item.ref ---@type string if action.cmd == "vsplit" then cmd = "vert " .. cmd elseif action.cmd == "tab" then cmd = "tab " .. cmd end vim.cmd(cmd) end) end end, } ``` ### `marks` ```vim :lua Snacks.picker.marks(opts?) ``` ```lua ---@class snacks.picker.marks.Config: snacks.picker.Config ---@field global? boolean show global marks ---@field local? boolean show buffer marks { finder = "vim_marks", format = "file", global = true, ["local"] = true, win = { input = { keys = { [""] = { "mark_delete", mode = { "n", "i" } }, }, }, }, } ``` ### `notifications` ```vim :lua Snacks.picker.notifications(opts?) ``` ```lua ---@class snacks.picker.notifications.Config: snacks.picker.Config ---@field filter? snacks.notifier.level|fun(notif: snacks.notifier.Notif): boolean { finder = "snacks_notifier", format = "notification", preview = "preview", formatters = { severity = { level = true } }, confirm = "close", } ``` ### `picker_actions` ```vim :lua Snacks.picker.picker_actions(opts?) ``` ```lua { finder = "meta_actions", format = "text", } ``` ### `picker_format` ```vim :lua Snacks.picker.picker_format(opts?) ``` ```lua { finder = "meta_format", format = "text", } ``` ### `picker_layouts` ```vim :lua Snacks.picker.picker_layouts(opts?) ``` ```lua { finder = "meta_layouts", format = "text", on_change = function(picker, item) vim.schedule(function() picker:set_layout(item.text) end) end, } ``` ### `picker_preview` ```vim :lua Snacks.picker.picker_preview(opts?) ``` ```lua { finder = "meta_preview", format = "text", } ``` ### `pickers` ```vim :lua Snacks.picker.pickers(opts?) ``` List all available sources ```lua { finder = "meta_pickers", format = "text", confirm = function(picker, item) picker:close() if item then vim.schedule(function() Snacks.picker(item.text) end) end end, } ``` ### `projects` ```vim :lua Snacks.picker.projects(opts?) ``` Open recent projects ```lua ---@class snacks.picker.projects.Config: snacks.picker.Config ---@field filter? snacks.picker.filter.Config ---@field dev? string|string[] top-level directories containing multiple projects (sub-folders that contains a root pattern) ---@field projects? string[] list of project directories ---@field patterns? string[] patterns to detect project root directories ---@field recent? boolean include project directories of recent files ---@field max_depth? number maximum depth to search in dev directories (default: 2) { finder = "recent_projects", format = "file", dev = { "~/dev", "~/projects" }, confirm = "load_session", patterns = { ".git", "_darcs", ".hg", ".bzr", ".svn", "package.json", "Makefile" }, recent = true, matcher = { frecency = true, -- use frecency boosting sort_empty = true, -- sort even when the filter is empty cwd_bonus = false, }, sort = { fields = { "score:desc", "idx" } }, win = { preview = { minimal = true }, input = { keys = { -- every action will always first change the cwd of the current tabpage to the project [""] = { { "tcd", "picker_explorer" }, mode = { "n", "i" } }, [""] = { { "tcd", "picker_files" }, mode = { "n", "i" } }, [""] = { { "tcd", "picker_grep" }, mode = { "n", "i" } }, [""] = { { "tcd", "picker_recent" }, mode = { "n", "i" }, nowait = true }, [""] = { { "tcd" }, mode = { "n", "i" } }, [""] = { function(picker) vim.cmd("tabnew") Snacks.notify("New tab opened") picker:close() Snacks.picker.projects() end, mode = { "n", "i" }, }, }, }, }, } ``` ### `qflist` ```vim :lua Snacks.picker.qflist(opts?) ``` Quickfix list ```lua ---@type snacks.picker.qf.Config { finder = "qf", format = "file", } ``` ### `recent` ```vim :lua Snacks.picker.recent(opts?) ``` Find recent files ```lua ---@class snacks.picker.recent.Config: snacks.picker.Config ---@field filter? snacks.picker.filter.Config { finder = "recent_files", format = "file", filter = { paths = { [vim.fn.stdpath("data")] = false, [vim.fn.stdpath("cache")] = false, [vim.fn.stdpath("state")] = false, }, }, } ``` ### `registers` ```vim :lua Snacks.picker.registers(opts?) ``` Neovim registers ```lua { finder = "vim_registers", main = { current = true }, format = "register", preview = "preview", confirm = { "copy", "close" }, } ``` ### `resume` ```vim :lua Snacks.picker.resume(opts?) ``` Special picker that resumes the last picker ```lua {} ``` ### `scratch` ```vim :lua Snacks.picker.scratch(opts?) ``` Open or create scratch buffers ```lua { finder = "scratch", format = "scratch_format", confirm = "scratch_open", win = { input = { keys = { [""] = { "scratch_delete", mode = { "n", "i" } }, [""] = { "scratch_new", mode = { "n", "i" } }, }, }, }, } ``` ### `search_history` ```vim :lua Snacks.picker.search_history(opts?) ``` Neovim search history ```lua ---@type snacks.picker.history.Config { finder = "vim_history", name = "search", format = "text", preview = "none", main = { current = true }, layout = { preset = "vscode" }, confirm = "search", formatters = { text = { ft = "regex" } }, } ``` ### `select` ```vim :lua Snacks.picker.select(opts?) ``` Config used by `vim.ui.select`. Not meant to be used directly. ```lua ---@class snacks.picker.select.Config: snacks.picker.Config ---@field kinds? table custom snacks picker configs for specific `vim.ui.select` kinds { items = {}, -- these are set dynamically main = { current = true }, layout = { preset = "select" }, } ``` ### `smart` ```vim :lua Snacks.picker.smart(opts?) ``` ```lua ---@class snacks.picker.smart.Config: snacks.picker.Config ---@field finders? string[] list of finders to use ---@field filter? snacks.picker.filter.Config { multi = { "buffers", "recent", "files" }, format = "file", -- use `file` format for all sources matcher = { cwd_bonus = true, -- boost cwd matches frecency = true, -- use frecency boosting sort_empty = true, -- sort even when the filter is empty }, transform = "unique_file", } ``` ### `spelling` ```vim :lua Snacks.picker.spelling(opts?) ``` ```lua { finder = "vim_spelling", format = "text", main = { current = true }, layout = { preset = "vscode" }, confirm = "item_action", } ``` ### `tags` ```vim :lua Snacks.picker.tags(opts?) ``` Search tags file ```lua ---@class snacks.picker.tags.Config: snacks.picker.Config { workspace = true, -- search tags in the workspace finder = "vim_tags", format = "lsp_symbol", } ``` ### `treesitter` ```vim :lua Snacks.picker.treesitter(opts?) ``` ```lua ---@class snacks.picker.treesitter.Config: snacks.picker.Config ---@field filter table? symbol kind filter ---@field tree? boolean show symbol tree { finder = "treesitter_symbols", format = "lsp_symbol", tree = true, filter = { default = { "Class", "Enum", "Field", "Function", "Method", "Module", "Namespace", "Struct", "Trait", }, -- set to `true` to include all symbols markdown = true, help = true, }, } ``` ### `undo` ```vim :lua Snacks.picker.undo(opts?) ``` ```lua ---@class snacks.picker.undo.Config: snacks.picker.Config ---@field diff? vim.text.diff.Opts { finder = "vim_undo", format = "undo", preview = "diff", confirm = "item_action", win = { preview = { wo = { number = false, relativenumber = false, signcolumn = "no" } }, input = { keys = { [""] = { "yank_add", mode = { "n", "i" } }, [""] = { "yank_del", mode = { "n", "i" } }, }, }, }, actions = { yank_add = { action = "yank", field = "added_lines" }, yank_del = { action = "yank", field = "removed_lines" }, }, icons = { tree = { last = "┌╴" } }, -- the tree is upside down diff = { ctxlen = 4, ignore_cr_at_eol = true, ignore_whitespace_change_at_eol = true, indent_heuristic = true, }, } ``` ### `zoxide` ```vim :lua Snacks.picker.zoxide(opts?) ``` Open a project from zoxide ```lua { finder = "files_zoxide", format = "file", confirm = "load_session", win = { preview = { minimal = true, }, }, } ``` ## 🖼️ Layouts ### `bottom` ```lua { preset = "ivy", layout = { position = "bottom" } } ``` ### `default` ```lua { layout = { box = "horizontal", width = 0.8, min_width = 120, height = 0.8, { box = "vertical", border = true, title = "{title} {live} {flags}", { win = "input", height = 1, border = "bottom" }, { win = "list", border = "none" }, }, { win = "preview", title = "{preview}", border = true, width = 0.5 }, }, } ``` ### `dropdown` ```lua { layout = { backdrop = false, row = 1, width = 0.4, min_width = 80, height = 0.8, border = "none", box = "vertical", { win = "preview", title = "{preview}", height = 0.4, border = true }, { box = "vertical", border = true, title = "{title} {live} {flags}", title_pos = "center", { win = "input", height = 1, border = "bottom" }, { win = "list", border = "none" }, }, }, } ``` ### `ivy` ```lua { layout = { box = "vertical", backdrop = false, row = -1, width = 0, height = 0.4, border = "top", title = " {title} {live} {flags}", title_pos = "left", { win = "input", height = 1, border = "bottom" }, { box = "horizontal", { win = "list", border = "none" }, { win = "preview", title = "{preview}", width = 0.6, border = "left" }, }, }, } ``` ### `ivy_split` ```lua { preview = "main", layout = { box = "vertical", backdrop = false, width = 0, height = 0.4, position = "bottom", border = "top", title = " {title} {live} {flags}", title_pos = "left", { win = "input", height = 1, border = "bottom" }, { box = "horizontal", { win = "list", border = "none" }, { win = "preview", title = "{preview}", width = 0.6, border = "left" }, }, }, } ``` ### `left` ```lua M.sidebar ``` ### `right` ```lua { preset = "sidebar", layout = { position = "right" } } ``` ### `select` ```lua { hidden = { "preview" }, layout = { backdrop = false, width = 0.5, min_width = 80, max_width = 100, height = 0.4, min_height = 2, box = "vertical", border = true, title = "{title}", title_pos = "center", { win = "input", height = 1, border = "bottom" }, { win = "list", border = "none" }, { win = "preview", title = "{preview}", height = 0.4, border = "top" }, }, } ``` ### `sidebar` ```lua { preview = "main", layout = { backdrop = false, width = 40, min_width = 40, height = 0, position = "left", border = "none", box = "vertical", { win = "input", height = 1, border = true, title = "{title} {live} {flags}", title_pos = "center", }, { win = "list", border = "none" }, { win = "preview", title = "{preview}", height = 0.4, border = "top" }, }, } ``` ### `telescope` ```lua { reverse = true, layout = { box = "horizontal", backdrop = false, width = 0.8, height = 0.9, border = "none", { box = "vertical", { win = "list", title = " Results ", title_pos = "center", border = true }, { win = "input", height = 1, border = true, title = "{title} {live} {flags}", title_pos = "center" }, }, { win = "preview", title = "{preview:Preview}", width = 0.45, border = true, title_pos = "center", }, }, } ``` ### `top` ```lua { preset = "ivy", layout = { position = "top" } } ``` ### `vertical` ```lua { layout = { backdrop = false, width = 0.5, min_width = 80, height = 0.8, min_height = 30, box = "vertical", border = true, title = "{title} {live} {flags}", title_pos = "center", { win = "input", height = 1, border = "bottom" }, { win = "list", border = "none" }, { win = "preview", title = "{preview}", height = 0.4, border = "top" }, }, } ``` ### `vscode` ```lua { hidden = { "preview" }, layout = { backdrop = false, row = 1, width = 0.4, min_width = 80, height = 0.4, border = "none", box = "vertical", { win = "input", height = 1, border = true, title = "{title} {live} {flags}", title_pos = "center" }, { win = "list", border = "hpad" }, { win = "preview", title = "{preview}", border = true }, }, } ``` ## 📦 `snacks.picker.actions` ```lua ---@class snacks.picker.actions ---@field [string] snacks.picker.Action.spec local M = {} ``` ### `Snacks.picker.actions.bufdelete()` ```lua Snacks.picker.actions.bufdelete(picker) ``` ### `Snacks.picker.actions.cancel()` ```lua Snacks.picker.actions.cancel(picker) ``` ### `Snacks.picker.actions.cd()` ```lua Snacks.picker.actions.cd(_, item) ``` ### `Snacks.picker.actions.close()` ```lua Snacks.picker.actions.close(picker) ``` ### `Snacks.picker.actions.cmd()` ```lua Snacks.picker.actions.cmd(picker, item) ``` ### `Snacks.picker.actions.cycle_win()` ```lua Snacks.picker.actions.cycle_win(picker) ``` ### `Snacks.picker.actions.focus_input()` ```lua Snacks.picker.actions.focus_input(picker) ``` ### `Snacks.picker.actions.focus_list()` ```lua Snacks.picker.actions.focus_list(picker) ``` ### `Snacks.picker.actions.focus_preview()` ```lua Snacks.picker.actions.focus_preview(picker) ``` ### `Snacks.picker.actions.git_branch_add()` ```lua Snacks.picker.actions.git_branch_add(picker) ``` ### `Snacks.picker.actions.git_branch_del()` ```lua Snacks.picker.actions.git_branch_del(picker, item) ``` ### `Snacks.picker.actions.git_checkout()` ```lua Snacks.picker.actions.git_checkout(picker, item) ``` ### `Snacks.picker.actions.git_restore()` ```lua Snacks.picker.actions.git_restore(picker) ``` ### `Snacks.picker.actions.git_stage()` ```lua Snacks.picker.actions.git_stage(picker) ``` ### `Snacks.picker.actions.git_stash_apply()` ```lua Snacks.picker.actions.git_stash_apply(_, item) ``` ### `Snacks.picker.actions.help()` ```lua Snacks.picker.actions.help(picker, item, action) ``` ### `Snacks.picker.actions.history_back()` ```lua Snacks.picker.actions.history_back(picker) ``` ### `Snacks.picker.actions.history_forward()` ```lua Snacks.picker.actions.history_forward(picker) ``` ### `Snacks.picker.actions.insert()` ```lua Snacks.picker.actions.insert(picker, _, action) ``` ### `Snacks.picker.actions.inspect()` ```lua Snacks.picker.actions.inspect(picker, item) ``` ### `Snacks.picker.actions.item_action()` ```lua Snacks.picker.actions.item_action(picker, item, action) ``` ### `Snacks.picker.actions.jump()` ```lua Snacks.picker.actions.jump(picker, _, action) ``` ### `Snacks.picker.actions.layout()` ```lua Snacks.picker.actions.layout(picker, _, action) ``` ### `Snacks.picker.actions.lcd()` ```lua Snacks.picker.actions.lcd(_, item) ``` ### `Snacks.picker.actions.list_bottom()` ```lua Snacks.picker.actions.list_bottom(picker) ``` ### `Snacks.picker.actions.list_down()` ```lua Snacks.picker.actions.list_down(picker) ``` ### `Snacks.picker.actions.list_scroll_bottom()` ```lua Snacks.picker.actions.list_scroll_bottom(picker) ``` ### `Snacks.picker.actions.list_scroll_center()` ```lua Snacks.picker.actions.list_scroll_center(picker) ``` ### `Snacks.picker.actions.list_scroll_down()` ```lua Snacks.picker.actions.list_scroll_down(picker) ``` ### `Snacks.picker.actions.list_scroll_top()` ```lua Snacks.picker.actions.list_scroll_top(picker) ``` ### `Snacks.picker.actions.list_scroll_up()` ```lua Snacks.picker.actions.list_scroll_up(picker) ``` ### `Snacks.picker.actions.list_top()` ```lua Snacks.picker.actions.list_top(picker) ``` ### `Snacks.picker.actions.list_up()` ```lua Snacks.picker.actions.list_up(picker) ``` ### `Snacks.picker.actions.load_session()` Tries to load the session, if it fails, it will open the picker. ```lua Snacks.picker.actions.load_session(picker, item) ``` ### `Snacks.picker.actions.loclist()` Send selected or all items to the location list. ```lua Snacks.picker.actions.loclist(picker) ``` ### `Snacks.picker.actions.mark_delete()` ```lua Snacks.picker.actions.mark_delete(picker) ``` ### `Snacks.picker.actions.paste()` ```lua Snacks.picker.actions.paste(picker, item, action) ``` ### `Snacks.picker.actions.pick_win()` ```lua Snacks.picker.actions.pick_win(picker, item, action) ``` ### `Snacks.picker.actions.picker()` ```lua Snacks.picker.actions.picker(picker, item, action) ``` ### `Snacks.picker.actions.picker_grep()` ```lua Snacks.picker.actions.picker_grep(_, item) ``` ### `Snacks.picker.actions.preview_scroll_down()` ```lua Snacks.picker.actions.preview_scroll_down(picker) ``` ### `Snacks.picker.actions.preview_scroll_left()` ```lua Snacks.picker.actions.preview_scroll_left(picker) ``` ### `Snacks.picker.actions.preview_scroll_right()` ```lua Snacks.picker.actions.preview_scroll_right(picker) ``` ### `Snacks.picker.actions.preview_scroll_up()` ```lua Snacks.picker.actions.preview_scroll_up(picker) ``` ### `Snacks.picker.actions.print_cwd()` ```lua Snacks.picker.actions.print_cwd(picker) ``` ### `Snacks.picker.actions.print_dir()` ```lua Snacks.picker.actions.print_dir(picker) ``` ### `Snacks.picker.actions.print_path()` ```lua Snacks.picker.actions.print_path(picker, item) ``` ### `Snacks.picker.actions.qflist()` Send selected or all items to the quickfix list. ```lua Snacks.picker.actions.qflist(picker) ``` ### `Snacks.picker.actions.qflist_all()` Send all items to the quickfix list. ```lua Snacks.picker.actions.qflist_all(picker) ``` ### `Snacks.picker.actions.search()` ```lua Snacks.picker.actions.search(picker, item) ``` ### `Snacks.picker.actions.select_all()` Selects all items in the list. Or clears the selection if all items are selected. ```lua Snacks.picker.actions.select_all(picker) ``` ### `Snacks.picker.actions.select_and_next()` Toggles the selection of the current item, and moves the cursor to the next item. ```lua Snacks.picker.actions.select_and_next(picker) ``` ### `Snacks.picker.actions.select_and_prev()` Toggles the selection of the current item, and moves the cursor to the prev item. ```lua Snacks.picker.actions.select_and_prev(picker) ``` ### `Snacks.picker.actions.tcd()` ```lua Snacks.picker.actions.tcd(_, item) ``` ### `Snacks.picker.actions.terminal()` ```lua Snacks.picker.actions.terminal(_, item) ``` ### `Snacks.picker.actions.toggle_focus()` ```lua Snacks.picker.actions.toggle_focus(picker) ``` ### `Snacks.picker.actions.toggle_help_input()` ```lua Snacks.picker.actions.toggle_help_input(picker) ``` ### `Snacks.picker.actions.toggle_help_list()` ```lua Snacks.picker.actions.toggle_help_list(picker) ``` ### `Snacks.picker.actions.toggle_input()` ```lua Snacks.picker.actions.toggle_input(picker) ``` ### `Snacks.picker.actions.toggle_live()` ```lua Snacks.picker.actions.toggle_live(picker) ``` ### `Snacks.picker.actions.toggle_maximize()` ```lua Snacks.picker.actions.toggle_maximize(picker) ``` ### `Snacks.picker.actions.toggle_preview()` ```lua Snacks.picker.actions.toggle_preview(picker) ``` ### `Snacks.picker.actions.yank()` ```lua Snacks.picker.actions.yank(picker, item, action) ``` ## 📦 `snacks.picker.core.picker` ```lua ---@class snacks.Picker ---@field id number ---@field opts snacks.picker.Config ---@field init_opts? snacks.picker.Config ---@field finder snacks.picker.Finder ---@field format snacks.picker.format ---@field input snacks.picker.input ---@field layout snacks.layout ---@field resolved_layout snacks.picker.layout.Config ---@field list snacks.picker.list ---@field matcher snacks.picker.Matcher ---@field main number ---@field _main snacks.picker.Main ---@field preview snacks.picker.Preview ---@field shown? boolean ---@field sort snacks.picker.sort ---@field updater uv.uv_timer_t ---@field start_time number ---@field title string ---@field closed? boolean ---@field history snacks.picker.History ---@field visual? snacks.picker.Visual local M = {} ``` ### `Snacks.picker.picker.get()` ```lua ---@param opts? {source?: string, tab?: boolean} Snacks.picker.picker.get(opts) ``` ### `picker:action()` Execute the given action(s) ```lua ---@param actions string|string[] picker:action(actions) ``` ### `picker:close()` Close the picker ```lua picker:close() ``` ### `picker:count()` Total number of items in the picker ```lua picker:count() ``` ### `picker:current()` Get the current item at the cursor ```lua ---@param opts? {resolve?: boolean} default is `true` picker:current(opts) ``` ### `picker:current_win()` ```lua ---@return string? name, snacks.win? win picker:current_win() ``` ### `picker:cwd()` ```lua picker:cwd() ``` ### `picker:dir()` Returns the directory of the current item or the cwd. When the item is a directory, return item path, otherwise return the directory of the item. ```lua picker:dir() ``` ### `picker:empty()` Check if the picker is empty ```lua picker:empty() ``` ### `picker:filter()` Get the active filter ```lua picker:filter() ``` ### `picker:find()` Check if the finder and/or matcher need to run, based on the current pattern and search string. ```lua ---@param opts? { on_done?: fun(), refresh?: boolean } picker:find(opts) ``` ### `picker:focus()` Focuses the given or configured window. Falls back to the first available window if the window is hidden. ```lua ---@param win? "input"|"list"|"preview" ---@param opts? {show?: boolean} when enable is true, the window will be shown if hidden picker:focus(win, opts) ``` ### `picker:hist()` Move the history cursor ```lua ---@param forward? boolean picker:hist(forward) ``` ### `picker:is_active()` Check if the finder or matcher is running ```lua picker:is_active() ``` ### `picker:is_focused()` ```lua picker:is_focused() ``` ### `picker:items()` Get all filtered items in the picker. ```lua picker:items() ``` ### `picker:iter()` Returns an iterator over the filtered items in the picker. Items will be in sorted order. ```lua ---@return fun():(snacks.picker.Item?, number?) picker:iter() ``` ### `picker:norm()` Execute the callback in normal mode. When still in insert mode, stop insert mode first, and then`vim.schedule` the callback. ```lua ---@param cb fun() picker:norm(cb) ``` ### `picker:on_current_tab()` ```lua picker:on_current_tab() ``` ### `picker:ref()` ```lua ---@return snacks.Picker.ref picker:ref() ``` ### `picker:refresh()` Clears the selection, set the target to the current item, and refresh the finder and matcher. ```lua picker:refresh() ``` ### `picker:resolve()` ```lua ---@param item snacks.picker.Item? picker:resolve(item) ``` ### `picker:selected()` Get the selected items. If `fallback=true` and there is no selection, return the current item. ```lua ---@param opts? {fallback?: boolean} default is `false` ---@return snacks.picker.Item[] picker:selected(opts) ``` ### `picker:set_cwd()` ```lua picker:set_cwd(cwd) ``` ### `picker:set_layout()` Set the picker layout. Can be either the name of a preset layout or a custom layout configuration. ```lua ---@param layout? string|snacks.picker.layout.Config picker:set_layout(layout) ``` ### `picker:show_preview()` Show the preview. Show instantly when no item is yet in the preview, otherwise throttle the preview. ```lua picker:show_preview() ``` ### `picker:toggle()` Toggle the given window and optionally focus ```lua ---@param win "input"|"list"|"preview" ---@param opts? {enable?: boolean, focus?: boolean|string} picker:toggle(win, opts) ``` ### `picker:word()` Get the word under the cursor or the current visual selection ```lua picker:word() ``` ================================================ FILE: docs/profiler.md ================================================ # 🍿 profiler A low overhead Lua profiler for Neovim. ![image](https://github.com/user-attachments/assets/cebb1308-077b-4f20-bee3-28644fb121b8) ![image](https://github.com/user-attachments/assets/4ee557c4-a290-4a52-b5c9-64e325bf1094) ![image](https://github.com/user-attachments/assets/ec03e440-6719-4463-a649-a8626dcfe2ec) ## ✨ Features - low overhead **instrumentation** - captures a function's **def**inition and **ref**erence (_caller_) locations - profiling of **autocmds** - profiling of **require**d modules - buffer **highlighting** of functions and calls - lots of different ways to **filter** and **group** traces - show traces with: - [fzf-lua](https://github.com/ibhagwan/fzf-lua) - [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) - [trouble.nvim](https://github.com/folke/trouble.nvim) ## ⁉️ Why? Before the snacks profiler, I used to use a combination of my own profiler(s), **lazy.nvim**'s internal profiler, [profile.nvim](https://github.com/stevearc/profile.nvim) and [perfanno.nvim](https://github.com/t-troebst/perfanno.nvim). They all have their strengths and weaknesses: - **lazy.nvim**'s profiler is great for structured traces, but needed a lot of manual work to get the traces I wanted. - **profile.nvim** does proper instrumentation, but was lacking in the UI department. - **perfanno.nvim** has a great UI, but uses `jit.profile` which is not as detailed as instrumentation. The snacks profiler tries to combine the best of all worlds. ## 🚀 Usage The easiest way to use the profiler is to toggle it with the suggested keybindings. When the profiler stops, it will show a picker using the `on_stop` preset. To quickly change picker options, you can use the `Snacks.profiler.scratch()` scratch buffer. ### Caveats - your Neovim session might slow down when profiling - due to the overhead of instrumentation, fast functions that are called often, might skew the results. Best to add those to the `opts.filter_fn` config. - by default, only captures functions defined on lua modules. If you want to profile others, add them to `opts.globals` - the profiler is not perfect and might not capture all calls - the profiler might not work well with some plugins - it can only profile `autocmds` created when the profiler is running. - only `autocmds` with a lua function callback can be profiled - functions that `resume` or `yield` won't be captured correctly - functions that do blocking calls like `vim.fn.getchar` will work, but the time will include the time spent waiting for the blocking call ### Recommended Setup ```lua { { "folke/snacks.nvim", opts = function() -- Toggle the profiler Snacks.toggle.profiler():map("pp") -- Toggle the profiler highlights Snacks.toggle.profiler_highlights():map("ph") end, keys = { { "ps", function() Snacks.profiler.scratch() end, desc = "Profiler Scratch Bufer" }, } }, -- optional lualine component to show captured events -- when the profiler is running { "nvim-lualine/lualine.nvim", opts = function(_, opts) table.insert(opts.sections.lualine_x, Snacks.profiler.status()) end, }, } ``` ### Profiling Neovim Startup In order to profile Neovim's startup, you need to make sure `snacks.nvim` is installed and loaded **before** doing anything else. So also before loading your plugin manager. You can add something like the below to the top of your `init.lua`. Then you can profile your Neovim session, with `PROF=1 nvim`. ```lua if vim.env.PROF then -- example for lazy.nvim -- change this to the correct path for your plugin manager local snacks = vim.fn.stdpath("data") .. "/lazy/snacks.nvim" vim.opt.rtp:append(snacks) require("snacks.profiler").startup({ startup = { event = "VimEnter", -- stop profiler on this event. Defaults to `VimEnter` -- event = "UIEnter", -- event = "VeryLazy", }, }) end ``` ### Filtering For the full definition, see the `snacks.profiler.Filter` type. Each field can be a string or a boolean. When a field is a string, it will match the exact value, unless it starts with `^` in which case it will match the pattern. When any of the `def`/`ref` fields are `true`, the filter matches the current location of the cursor. For example, `{ref_file = true}` will match all traces calling something, in the current file. All other fields equal to `true` will match if the trace has a value for that field. ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { profiler = { -- your profiler configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.profiler.Config { autocmds = true, runtime = vim.env.VIMRUNTIME, ---@type string -- thresholds for buttons to be shown as info, warn or error -- value is a tuple of [warn, error] thresholds = { time = { 2, 10 }, pct = { 10, 20 }, count = { 10, 100 }, }, on_stop = { highlights = true, -- highlight entries after stopping the profiler pick = true, -- show a picker after stopping the profiler (uses the `on_stop` preset) }, ---@type snacks.profiler.Highlights highlights = { min_time = 0, -- only highlight entries with time > min_time (in ms) max_shade = 20, -- time in ms for the darkest shade badges = { "time", "pct", "count", "trace" }, align = 80, }, pick = { picker = "snacks", ---@type snacks.profiler.Picker ---@type snacks.profiler.Badge.type[] badges = { "time", "count", "name" }, ---@type snacks.profiler.Highlights preview = { badges = { "time", "pct", "count" }, align = "right", }, }, startup = { event = "VimEnter", -- stop profiler on this event. Defaults to `VimEnter` after = true, -- stop the profiler **after** the event. When false it stops **at** the event pattern = nil, -- pattern to match for the autocmd pick = true, -- show a picker after starting the profiler (uses the `startup` preset) }, ---@type table presets = { startup = { min_time = 1, sort = false }, on_stop = {}, filter_by_plugin = function() return { filter = { def_plugin = vim.fn.input("Filter by plugin: ") } } end, }, ---@type string[] globals = { -- "vim", -- "vim.api", -- "vim.keymap", -- "Snacks.dashboard.Dashboard", }, -- filter modules by pattern. -- longest patterns are matched first filter_mod = { default = true, -- default value for unmatched patterns ["^vim%."] = false, ["mason-core.functional"] = false, ["mason-core.functional.data"] = false, ["mason-core.optional"] = false, ["which-key.state"] = false, }, filter_fn = { default = true, ["^.*%._[^%.]*$"] = false, ["trouble.filter.is"] = false, ["trouble.item.__index"] = false, ["which-key.node.__index"] = false, ["smear_cursor.draw.wo"] = false, ["^ibl%.utils%."] = false, }, icons = { time = " ", pct = " ", count = " ", require = "󰋺 ", modname = "󰆼 ", plugin = " ", autocmd = "⚡", file = " ", fn = "󰊕 ", status = "󰈸 ", }, } ``` ## 📚 Types ### Traces ```lua ---@class snacks.profiler.Trace ---@field name string fully qualified name of the function ---@field time number time in nanoseconds ---@field depth number stack depth ---@field [number] snacks.profiler.Trace child traces ---@field fname string function name ---@field fn function function reference ---@field modname? string module name ---@field require? string special case for require ---@field autocmd? string special case for autocmd ---@field count? number number of calls ---@field def? snacks.profiler.Loc location of the definition ---@field ref? snacks.profiler.Loc location of the reference (caller) ---@field loc? snacks.profiler.Loc normalized location ``` ```lua ---@class snacks.profiler.Loc ---@field file string path to the file ---@field line number line number ---@field loc? string normalized location ---@field modname? string module name ---@field plugin? string plugin name ``` ### Pick: grouping, filtering and sorting ```lua ---@class snacks.profiler.Find ---@field structure? boolean show traces as a tree or flat list ---@field sort? "time"|"count"|false sort by time or count, or keep original order ---@field loc? "def"|"ref" what location to show in the preview ---@field group? boolean|snacks.profiler.Field group traces by field ---@field filter? snacks.profiler.Filter filter traces by field(s) ---@field min_time? number only show grouped traces with `time >= min_time` ``` ```lua ---@class snacks.profiler.Pick: snacks.profiler.Find ---@field picker? snacks.profiler.Picker ``` ```lua ---@alias snacks.profiler.Picker "snacks"|"trouble" ---@alias snacks.profiler.Pick.spec snacks.profiler.Pick|{preset?:string}|fun():snacks.profiler.Pick ``` ```lua ---@alias snacks.profiler.Field ---| "name" fully qualified name of the function ---| "def" definition ---| "ref" reference (caller) ---| "require" require ---| "autocmd" autocmd ---| "modname" module name of the called function ---| "def_file" file of the definition ---| "def_modname" module name of the definition ---| "def_plugin" plugin that defines the function ---| "ref_file" file of the reference ---| "ref_modname" module name of the reference ---| "ref_plugin" plugin that references the function ``` ```lua ---@class snacks.profiler.Filter ---@field name? string|boolean fully qualified name of the function ---@field def? string|boolean location of the definition ---@field ref? string|boolean location of the reference (caller) ---@field require? string|boolean special case for require ---@field autocmd? string|boolean special case for autocmd ---@field modname? string|boolean module name ---@field def_file? string|boolean file of the definition ---@field def_modname? string|boolean module name of the definition ---@field def_plugin? string|boolean plugin that defines the function ---@field ref_file? string|boolean file of the reference ---@field ref_modname? string|boolean module name of the reference ---@field ref_plugin? string|boolean plugin that references the function ``` ### UI ```lua ---@alias snacks.profiler.Badge {icon:string, text:string, padding?:boolean, level?:string} ---@alias snacks.profiler.Badge.type "time"|"pct"|"count"|"name"|"trace" ``` ```lua ---@class snacks.profiler.Highlights ---@field min_time? number only highlight entries with time >= min_time ---@field max_shade? number -- time in ms for the darkest shade ---@field badges? snacks.profiler.Badge.type[] badges to show ---@field align? "right"|"left"|number align the badges right, left or at a specific column ``` ### Other ```lua ---@class snacks.profiler.Startup ---@field event? string ---@field pattern? string|string[] pattern to match for the autocmd ``` ```lua ---@alias snacks.profiler.GroupFn fun(entry:snacks.profiler.Trace):{key:string, name?:string}? ``` ## 📦 Module ```lua ---@class snacks.profiler ---@field core snacks.profiler.core ---@field loc snacks.profiler.loc ---@field tracer snacks.profiler.tracer ---@field ui snacks.profiler.ui ---@field picker snacks.profiler.picker Snacks.profiler = {} ``` ### `Snacks.profiler.find()` Group and filter traces ```lua ---@param opts snacks.profiler.Find Snacks.profiler.find(opts) ``` ### `Snacks.profiler.highlight()` Toggle the profiler highlights ```lua ---@param enable? boolean Snacks.profiler.highlight(enable) ``` ### `Snacks.profiler.pick()` Group and filter traces and open a picker ```lua ---@param opts? snacks.profiler.Pick.spec Snacks.profiler.pick(opts) ``` ### `Snacks.profiler.running()` Check if the profiler is running ```lua Snacks.profiler.running() ``` ### `Snacks.profiler.scratch()` Open a scratch buffer with the profiler picker options ```lua Snacks.profiler.scratch() ``` ### `Snacks.profiler.start()` Start the profiler ```lua ---@param opts? snacks.profiler.Config Snacks.profiler.start(opts) ``` ### `Snacks.profiler.startup()` Start the profiler on startup, and stop it after the event has been triggered. ```lua ---@param opts snacks.profiler.Config Snacks.profiler.startup(opts) ``` ### `Snacks.profiler.status()` Statusline component ```lua Snacks.profiler.status() ``` ### `Snacks.profiler.stop()` Stop the profiler ```lua ---@param opts? {highlights?:boolean, pick?:snacks.profiler.Pick.spec} Snacks.profiler.stop(opts) ``` ### `Snacks.profiler.toggle()` Toggle the profiler ```lua Snacks.profiler.toggle() ``` ================================================ FILE: docs/quickfile.md ================================================ # 🍿 quickfile When doing `nvim somefile.txt`, it will render the file as quickly as possible, before loading your plugins. ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { quickfile = { -- your quickfile configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.quickfile.Config { -- any treesitter langs to exclude exclude = { "latex" }, } ``` ================================================ FILE: docs/rename.md ================================================ # 🍿 rename LSP-integrated file renaming with support for plugins like [neo-tree.nvim](https://github.com/nvim-neo-tree/neo-tree.nvim) and [mini.files](https://github.com/nvim-mini/mini.files). ## 🚀 Usage ## [mini.files](https://github.com/nvim-mini/mini.files) ```lua vim.api.nvim_create_autocmd("User", { pattern = "MiniFilesActionRename", callback = function(event) Snacks.rename.on_rename_file(event.data.from, event.data.to) end, }) ``` ## [oil.nvim](https://github.com/stevearc/oil.nvim) ```lua vim.api.nvim_create_autocmd("User", { pattern = "OilActionsPost", callback = function(event) if event.data.actions[1].type == "move" then Snacks.rename.on_rename_file(event.data.actions[1].src_url, event.data.actions[1].dest_url) end end, }) ``` ## [fyler.nvim](https://github.com/A7Lavinraj/fyler.nvim) ```lua return { "A7Lavinraj/fyler.nvim", dependencies = { "echasnovski/mini.icons" }, opts = { hooks = { on_rename = function(src_path, destination_path) Snacks.rename.on_rename_file(src_path, destination_path) end, }, }, } ``` ## [neo-tree.nvim](https://github.com/nvim-neo-tree/neo-tree.nvim) ```lua { "nvim-neo-tree/neo-tree.nvim", opts = function(_, opts) local function on_move(data) Snacks.rename.on_rename_file(data.source, data.destination) end local events = require("neo-tree.events") opts.event_handlers = opts.event_handlers or {} vim.list_extend(opts.event_handlers, { { event = events.FILE_MOVED, handler = on_move }, { event = events.FILE_RENAMED, handler = on_move }, }) end, } ``` ## [nvim-tree](https://github.com/nvim-tree/nvim-tree.lua) ```lua local prev = { new_name = "", old_name = "" } -- Prevents duplicate events vim.api.nvim_create_autocmd("User", { pattern = "NvimTreeSetup", callback = function() local events = require("nvim-tree.api").events events.subscribe(events.Event.NodeRenamed, function(data) if prev.new_name ~= data.new_name or prev.old_name ~= data.old_name then data = data Snacks.rename.on_rename_file(data.old_name, data.new_name) end end) end, }) ``` ## netrw (builtin file explorer) ```lua vim.api.nvim_create_autocmd({ 'FileType' }, { pattern = { 'netrw' }, group = vim.api.nvim_create_augroup('NetrwOnRename', { clear = true }), callback = function() vim.keymap.set("n", "R", function() local original_file_path = vim.b.netrw_curdir .. '/' .. vim.fn["netrw#Call"]("NetrwGetWord") vim.ui.input({ prompt = 'Move/rename to:', default = original_file_path }, function(target_file_path) if target_file_path and target_file_path ~= "" then local file_exists = vim.uv.fs_access(target_file_path, "W") if not file_exists then vim.uv.fs_rename(original_file_path, target_file_path) Snacks.rename.on_rename_file(original_file_path, target_file_path) else vim.notify("File '" .. target_file_path .. "' already exists! Skipping...", vim.log.levels.ERROR) end -- Refresh netrw vim.cmd(':Ex ' .. vim.b.netrw_curdir) end end) end, { remap = true, buffer = true }) end }) ``` ## 📦 Module ### `Snacks.rename.on_rename_file()` Lets LSP clients know that a file has been renamed ```lua ---@param from string ---@param to string ---@param rename? fun() Snacks.rename.on_rename_file(from, to, rename) ``` ### `Snacks.rename.rename_file()` Renames the provided file, or the current buffer's file. Prompt for the new filename if `to` is not provided. do the rename, and trigger LSP handlers ```lua ---@param opts? {from?: string, to?:string, on_rename?: fun(to:string, from:string, ok:boolean)} Snacks.rename.rename_file(opts) ``` ================================================ FILE: docs/scope.md ================================================ # 🍿 scope Scope detection based on treesitter or indent. The indent-based algorithm is similar to what is used in [mini.indentscope](https://github.com/nvim-mini/mini.indentscope). ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { scope = { -- your scope configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.scope.Config ---@field max_size? number ---@field enabled? boolean { -- absolute minimum size of the scope. -- can be less if the scope is a top-level single line scope min_size = 2, -- try to expand the scope to this size max_size = nil, cursor = true, -- when true, the column of the cursor is used to determine the scope edge = true, -- include the edge of the scope (typically the line above and below with smaller indent) siblings = false, -- expand single line scopes with single line siblings -- what buffers to attach to filter = function(buf) return vim.bo[buf].buftype == "" and vim.b[buf].snacks_scope ~= false and vim.g.snacks_scope ~= false end, -- debounce scope detection in ms debounce = 30, treesitter = { -- detect scope based on treesitter. -- falls back to indent based detection if not available enabled = true, injections = true, -- include language injections when detecting scope (useful for languages like `vue`) ---@type string[]|{enabled?:boolean} blocks = { enabled = false, -- enable to use the following blocks "function_declaration", "function_definition", "method_declaration", "method_definition", "class_declaration", "class_definition", "do_statement", "while_statement", "repeat_statement", "if_statement", "for_statement", }, -- these treesitter fields will be considered as blocks field_blocks = { "local_declaration", }, }, -- These keymaps will only be set if the `scope` plugin is enabled. -- Alternatively, you can set them manually in your config, -- using the `Snacks.scope.textobject` and `Snacks.scope.jump` functions. keys = { ---@type table textobject = { ii = { min_size = 2, -- minimum size of the scope edge = false, -- inner scope cursor = false, treesitter = { blocks = { enabled = false } }, desc = "inner scope", }, ai = { cursor = false, min_size = 2, -- minimum size of the scope treesitter = { blocks = { enabled = false } }, desc = "full scope", }, }, ---@type table jump = { ["[i"] = { min_size = 1, -- allow single line scopes bottom = false, cursor = false, edge = true, treesitter = { blocks = { enabled = false } }, desc = "jump to top edge of scope", }, ["]i"] = { min_size = 1, -- allow single line scopes bottom = true, cursor = false, edge = true, treesitter = { blocks = { enabled = false } }, desc = "jump to bottom edge of scope", }, }, }, } ``` ## 📚 Types ```lua ---@class snacks.scope.Opts: snacks.scope.Config,{} ---@field buf? number ---@field pos? {[1]:number, [2]:number} -- (1,0) indexed ---@field end_pos? {[1]:number, [2]:number} -- (1,0) indexed ---@field async? boolean run scope detection asynchronously (defaults to true) ``` ```lua ---@class snacks.scope.TextObject: snacks.scope.Opts ---@field linewise? boolean if nil, use visual mode. Defaults to `false` when not in visual mode ---@field notify? boolean show a notification when no scope is found (defaults to true) ``` ```lua ---@class snacks.scope.Jump: snacks.scope.Opts ---@field bottom? boolean if true, jump to the bottom of the scope, otherwise to the top ---@field notify? boolean show a notification when no scope is found (defaults to true) ``` ```lua ---@alias snacks.scope.Attach.cb fun(win: number, buf: number, scope:snacks.scope.Scope?, prev:snacks.scope.Scope?) ``` ```lua ---@alias snacks.scope.scope {buf: number, from: number, to: number, indent?: number} ``` ## 📦 Module ### `Snacks.scope.attach()` Attach a scope listener ```lua ---@param cb snacks.scope.Attach.cb ---@param opts? snacks.scope.Config ---@return snacks.scope.Listener Snacks.scope.attach(cb, opts) ``` ### `Snacks.scope.get()` ```lua ---@param cb fun(scope?: snacks.scope.Scope) ---@param opts? snacks.scope.Opts|{parse?:boolean} Snacks.scope.get(cb, opts) ``` ### `Snacks.scope.jump()` Jump to the top or bottom of the scope If the scope is the same as the current scope, it will jump to the parent scope instead. ```lua ---@param opts? snacks.scope.Jump Snacks.scope.jump(opts) ``` ### `Snacks.scope.textobject()` Text objects for indent scopes. Best to use with Treesitter disabled. When in visual mode, it will select the scope containing the visual selection. When the scope is the same as the visual selection, it will select the parent scope instead. ```lua ---@param opts? snacks.scope.TextObject Snacks.scope.textobject(opts) ``` ================================================ FILE: docs/scratch.md ================================================ # 🍿 scratch Quickly open scratch buffers for testing code, creating notes or just messing around. Scratch buffers are organized by using context like your working directory, Git branch and `vim.v.count1`. It supports templates, custom keymaps, and auto-saves when you hide the buffer. In lua buffers, pressing `` will execute the buffer / selection with `Snacks.debug.run()` that will show print output inline and show errors as diagnostics. ![image](https://github.com/user-attachments/assets/52ac7c1a-908f-4d1d-97a2-ad4642f8dc36) ![image](https://github.com/user-attachments/assets/d3e766e9-e64a-4c22-85b4-3d965f645b59) ## 🚀 Usage Suggested config: ```lua { "folke/snacks.nvim", keys = { { ".", function() Snacks.scratch() end, desc = "Toggle Scratch Buffer" }, { "S", function() Snacks.scratch.select() end, desc = "Select Scratch Buffer" }, } } ``` ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { scratch = { -- your scratch configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.scratch.Config ---@field win? snacks.win.Config scratch window ---@field template? string template for new buffers ---@field file? string scratch file path. You probably don't need to set this. ---@field ft? string|fun():string the filetype of the scratch buffer { name = "Scratch", ft = function() if vim.bo.buftype == "" and vim.bo.filetype ~= "" then return vim.bo.filetype end return "markdown" end, ---@type string|string[]? icon = nil, -- `icon|{icon, icon_hl}`. defaults to the filetype icon root = vim.fn.stdpath("data") .. "/scratch", autowrite = true, -- automatically write when the buffer is hidden -- unique key for the scratch file is based on: -- * name -- * ft -- * vim.v.count1 (useful for keymaps) -- * cwd (optional) -- * branch (optional) filekey = { id = nil, ---@type string? unique id used instead of name for the filename hash cwd = true, -- use current working directory branch = true, -- use current branch name count = true, -- use vim.v.count1 }, win = { style = "scratch" }, ---@type table win_by_ft = { lua = { keys = { ["source"] = { "", function(self) local name = "scratch." .. vim.fn.fnamemodify(vim.api.nvim_buf_get_name(self.buf), ":e") Snacks.debug.run({ buf = self.buf, name = name }) end, desc = "Source buffer", mode = { "n", "x" }, }, }, }, }, } ``` ## 🎨 Styles Check the [styles](https://github.com/folke/snacks.nvim/blob/main/docs/styles.md) docs for more information on how to customize these styles ### `scratch` ```lua { width = 100, height = 30, bo = { buftype = "", buflisted = false, bufhidden = "hide", swapfile = false }, minimal = false, noautocmd = false, -- position = "right", zindex = 20, wo = { winhighlight = "NormalFloat:Normal" }, footer_keys = true, border = true, } ``` ## 📚 Types ```lua ---@class snacks.scratch.File ---@field file string full path to the scratch buffer ---@field name string name of the scratch buffer ---@field ft string file type ---@field icon? string icon for the file type ---@field icon_hl? string highlight group for the icon ---@field cwd? string current working directory ---@field branch? string Git branch ---@field count? number vim.v.count1 used to open the buffer ---@field id? string unique id used instead of name for the filename hash ``` ## 📦 Module ### `Snacks.scratch()` ```lua ---@type fun(opts?: snacks.scratch.Config): snacks.win Snacks.scratch() ``` ### `Snacks.scratch.list()` Return a list of scratch buffers sorted by mtime. ```lua ---@return snacks.scratch.File[] Snacks.scratch.list() ``` ### `Snacks.scratch.open()` Open a scratch buffer with the given options. If a window is already open with the same buffer, it will be closed instead. ```lua ---@param opts? snacks.scratch.Config Snacks.scratch.open(opts) ``` ### `Snacks.scratch.select()` Select a scratch buffer from a list of scratch buffers. ```lua Snacks.scratch.select() ``` ================================================ FILE: docs/scroll.md ================================================ # 🍿 scroll Smooth scrolling for Neovim. Properly handles `scrolloff` and mouse scrolling. Similar plugins: - [mini.animate](https://github.com/nvim-mini/mini.animate) - [neoscroll.nvim](https://github.com/karb94/neoscroll.nvim) ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { scroll = { -- your scroll configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.scroll.Config ---@field animate snacks.animate.Config|{} ---@field animate_repeat snacks.animate.Config|{}|{delay:number} { animate = { duration = { step = 10, total = 200 }, easing = "linear", }, -- faster animation when repeating scroll after delay animate_repeat = { delay = 100, -- delay in ms before using the repeat animation duration = { step = 5, total = 50 }, easing = "linear", }, -- what buffers to animate filter = function(buf) return vim.g.snacks_scroll ~= false and vim.b[buf].snacks_scroll ~= false and vim.bo[buf].buftype ~= "terminal" end, } ``` ## 📚 Types ```lua ---@alias snacks.scroll.View {topline:number, lnum:number} ``` ## 📦 Module ### `Snacks.scroll.disable()` ```lua Snacks.scroll.disable() ``` ### `Snacks.scroll.enable()` ```lua Snacks.scroll.enable() ``` ================================================ FILE: docs/statuscolumn.md ================================================ # 🍿 statuscolumn ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { statuscolumn = { -- your statuscolumn configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.statuscolumn.Config ---@field left snacks.statuscolumn.Components ---@field right snacks.statuscolumn.Components ---@field enabled? boolean { left = { "mark", "sign" }, -- priority of signs on the left (high to low) right = { "fold", "git" }, -- priority of signs on the right (high to low) folds = { open = false, -- show open fold icons git_hl = false, -- use Git Signs hl for fold icons }, git = { -- patterns to match Git signs patterns = { "GitSign", "MiniDiffSign" }, }, refresh = 50, -- refresh at most every 50ms } ``` ## 📚 Types ```lua ---@class snacks.statuscolumn.FoldInfo ---@field start number Line number where deepest fold starts ---@field level number Fold level, when zero other fields are N/A ---@field llevel number Lowest level that starts in v:lnum ---@field lines number Number of lines from v:lnum to end of closed fold ``` ```lua ---@alias snacks.statuscolumn.Component "mark"|"sign"|"fold"|"git" ---@alias snacks.statuscolumn.Components snacks.statuscolumn.Component[]|fun(win:number,buf:number,lnum:number):snacks.statuscolumn.Component[] ---@alias snacks.statuscolumn.Wanted table ``` ## 📦 Module ### `Snacks.statuscolumn()` ```lua ---@type fun(): string Snacks.statuscolumn() ``` ### `Snacks.statuscolumn.click_fold()` ```lua Snacks.statuscolumn.click_fold() ``` ### `Snacks.statuscolumn.get()` ```lua Snacks.statuscolumn.get() ``` ================================================ FILE: docs/styles.md ================================================ # 🍿 styles Plugins provide window styles that can be customized with the `opts.styles` option of `snacks.nvim`. ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { ---@type table styles = { -- your styles configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## 🎨 Styles These are the default styles that Snacks provides. You can customize them by adding your own styles to `opts.styles`. ### `blame_line` ```lua { width = 0.6, height = 0.6, border = true, title = " Git Blame ", title_pos = "center", ft = "git", } ``` ### `dashboard` The default style for the dashboard. When opening the dashboard during startup, only the `bo` and `wo` options are used. The other options are used with `:lua Snacks.dashboard()` ```lua { zindex = 10, height = 0, width = 0, bo = { bufhidden = "wipe", buftype = "nofile", buflisted = false, filetype = "snacks_dashboard", swapfile = false, undofile = false, }, wo = { colorcolumn = "", cursorcolumn = false, cursorline = false, foldmethod = "manual", list = false, number = false, relativenumber = false, sidescrolloff = 0, signcolumn = "no", spell = false, statuscolumn = "", statusline = "", winbar = "", winhighlight = "Normal:SnacksDashboardNormal,NormalFloat:SnacksDashboardNormal", wrap = false, }, } ``` ### `float` ```lua { position = "float", backdrop = 60, height = 0.9, width = 0.9, zindex = 50, } ``` ### `help` ```lua { position = "float", backdrop = false, border = "top", row = -1, width = 0, height = 0.3, } ``` ### `input` ```lua { backdrop = false, position = "float", border = true, title_pos = "center", height = 1, width = 60, relative = "editor", noautocmd = true, row = 2, -- relative = "cursor", -- row = -3, -- col = 0, wo = { winhighlight = "NormalFloat:SnacksInputNormal,FloatBorder:SnacksInputBorder,FloatTitle:SnacksInputTitle", cursorline = false, }, bo = { filetype = "snacks_input", buftype = "prompt", }, --- buffer local variables b = { completion = false, -- disable blink completions in input }, keys = { n_esc = { "", { "cmp_close", "cancel" }, mode = "n", expr = true }, i_esc = { "", { "cmp_close", "stopinsert" }, mode = "i", expr = true }, i_cr = { "", { "cmp_accept", "confirm" }, mode = { "i", "n" }, expr = true }, i_tab = { "", { "cmp_select_next", "cmp" }, mode = "i", expr = true }, i_ctrl_w = { "", "", mode = "i", expr = true }, i_up = { "", { "hist_up" }, mode = { "i", "n" } }, i_down = { "", { "hist_down" }, mode = { "i", "n" } }, q = "cancel", }, } ``` ### `lazygit` ```lua {} ``` ### `minimal` ```lua { wo = { cursorcolumn = false, cursorline = false, cursorlineopt = "both", colorcolumn = "", fillchars = "eob: ,lastline:…", foldcolumn = "0", list = false, listchars = "extends:…,tab: ", number = false, relativenumber = false, signcolumn = "no", spell = false, winbar = "", statuscolumn = "", wrap = false, sidescrolloff = 0, }, } ``` ### `notification` ```lua { border = true, zindex = 100, ft = "markdown", wo = { winblend = 5, wrap = false, conceallevel = 2, colorcolumn = "", }, bo = { filetype = "snacks_notif" }, } ``` ### `notification_history` ```lua { border = true, zindex = 100, width = 0.6, height = 0.6, minimal = false, title = " Notification History ", title_pos = "center", ft = "markdown", bo = { filetype = "snacks_notif_history", modifiable = false }, wo = { winhighlight = "Normal:SnacksNotifierHistory" }, keys = { q = "close" }, } ``` ### `scratch` ```lua { width = 100, height = 30, bo = { buftype = "", buflisted = false, bufhidden = "hide", swapfile = false }, minimal = false, noautocmd = false, -- position = "right", zindex = 20, wo = { winhighlight = "NormalFloat:Normal" }, footer_keys = true, border = true, } ``` ### `snacks_image` ```lua { relative = "cursor", border = true, focusable = false, backdrop = false, row = 1, col = 1, -- width/height are automatically set by the image size unless specified below } ``` ### `split` ```lua { position = "bottom", height = 0.4, width = 0.4, } ``` ### `terminal` ```lua { bo = { filetype = "snacks_terminal", }, wo = {}, stack = true, -- when enabled, multiple split windows with the same position will be stacked together (useful for terminals) keys = { q = "hide", gf = function(self) local f = vim.fn.findfile(vim.fn.expand(""), "**") if f == "" then Snacks.notify.warn("No file under cursor") else self:hide() vim.schedule(function() vim.cmd("e " .. f) end) end end, term_normal = { "", function(self) self.esc_timer = self.esc_timer or (vim.uv or vim.loop).new_timer() if self.esc_timer:is_active() then self.esc_timer:stop() vim.cmd("stopinsert") else self.esc_timer:start(200, 0, function() end) return "" end end, mode = "t", expr = true, desc = "Double escape to normal mode", }, }, } ``` ### `zen` ```lua { enter = true, fixbuf = false, minimal = false, width = 120, height = 0, backdrop = { transparent = true, blend = 40 }, keys = { q = false }, zindex = 40, wo = { winhighlight = "NormalFloat:Normal", }, w = { snacks_main = true, }, } ``` ### `zoom_indicator` fullscreen indicator only shown when the window is maximized ```lua { text = "▍ zoom 󰊓 ", minimal = true, enter = false, focusable = false, height = 1, row = 0, col = -1, backdrop = false, } ``` ================================================ FILE: docs/terminal.md ================================================ # 🍿 terminal Create and toggle terminal windows. Based on the provided options, some defaults will be set: - if no `cmd` is provided, the window will be opened in a bottom split - if `cmd` is provided, the window will be opened in a floating window - for splits, a `winbar` will be added with the terminal title ![image](https://github.com/user-attachments/assets/afcc9989-57d7-4518-a390-cc7d6f0cec13) ## 🚀 Usage ### Edgy Integration ```lua { "folke/edgy.nvim", ---@module 'edgy' ---@param opts Edgy.Config opts = function(_, opts) for _, pos in ipairs({ "top", "bottom", "left", "right" }) do opts[pos] = opts[pos] or {} table.insert(opts[pos], { ft = "snacks_terminal", size = { height = 0.4 }, title = "%{b:snacks_terminal.id}: %{b:term_title}", filter = function(_buf, win) return vim.w[win].snacks_win and vim.w[win].snacks_win.position == pos and vim.w[win].snacks_win.relative == "editor" and not vim.w[win].trouble_preview end, }) end end, } ``` ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { terminal = { -- your terminal configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.terminal.Config ---@field win? snacks.win.Config|{} ---@field shell? string|string[] The shell to use. Defaults to `vim.o.shell` ---@field override? fun(cmd?: string|string[], opts?: snacks.terminal.Opts) Use this to use a different terminal implementation { win = { style = "terminal" }, } ``` ## 🎨 Styles Check the [styles](https://github.com/folke/snacks.nvim/blob/main/docs/styles.md) docs for more information on how to customize these styles ### `terminal` ```lua { bo = { filetype = "snacks_terminal", }, wo = {}, stack = true, -- when enabled, multiple split windows with the same position will be stacked together (useful for terminals) keys = { q = "hide", gf = function(self) local f = vim.fn.findfile(vim.fn.expand(""), "**") if f == "" then Snacks.notify.warn("No file under cursor") else self:hide() vim.schedule(function() vim.cmd("e " .. f) end) end end, term_normal = { "", function(self) self.esc_timer = self.esc_timer or (vim.uv or vim.loop).new_timer() if self.esc_timer:is_active() then self.esc_timer:stop() vim.cmd("stopinsert") else self.esc_timer:start(200, 0, function() end) return "" end end, mode = "t", expr = true, desc = "Double escape to normal mode", }, }, } ``` ## 📚 Types ```lua ---@class snacks.terminal.Opts: snacks.terminal.Config ---@field cwd? string ---@field count? integer ---@field env? table ---@field start_insert? boolean start insert mode when starting the terminal ---@field auto_insert? boolean start insert mode when entering the terminal buffer ---@field auto_close? boolean close the terminal buffer when the process exits ---@field interactive? boolean shortcut for `start_insert`, `auto_close` and `auto_insert` (default: true) ``` ## 📦 Module ```lua ---@class snacks.terminal: snacks.win ---@field cmd? string | string[] ---@field opts snacks.terminal.Opts Snacks.terminal = {} ``` ### `Snacks.terminal()` ```lua ---@type fun(cmd?: string|string[], opts?: snacks.terminal.Opts): snacks.terminal Snacks.terminal() ``` ### `Snacks.terminal.colorize()` Colorize the current buffer. Replaces ansii color codes with the actual colors. Example: ```sh ls -la --color=always | nvim - -c "lua Snacks.terminal.colorize()" ``` ```lua Snacks.terminal.colorize() ``` ### `Snacks.terminal.focus()` Focus a terminal window. If already focused, hide it. The terminal id is based on the `cmd`, `cwd`, `env` and `vim.v.count1` options. ```lua ---@param cmd? string | string[] ---@param opts? snacks.terminal.Opts Snacks.terminal.focus(cmd, opts) ``` ### `Snacks.terminal.get()` Get or create a terminal window. The terminal id is based on the `cmd`, `cwd`, `env` and `vim.v.count1` options. `opts.create` defaults to `true`. ```lua ---@param cmd? string | string[] ---@param opts? snacks.terminal.Opts| {create?: boolean} ---@return snacks.win? terminal, boolean? created Snacks.terminal.get(cmd, opts) ``` ### `Snacks.terminal.list()` ```lua ---@return snacks.win[] Snacks.terminal.list() ``` ### `Snacks.terminal.open()` Open a new terminal window. ```lua ---@param cmd? string | string[] ---@param opts? snacks.terminal.Opts Snacks.terminal.open(cmd, opts) ``` ### `Snacks.terminal.tid()` Get a terminal id based on the `cmd`, `cwd`, `env` and `vim.v.count1` options. ```lua ---@param cmd? string | string[] ---@param opts? snacks.terminal.Opts Snacks.terminal.tid(cmd, opts) ``` ### `Snacks.terminal.toggle()` Toggle a terminal window. The terminal id is based on the `cmd`, `cwd`, `env` and `vim.v.count1` options. ```lua ---@param cmd? string | string[] ---@param opts? snacks.terminal.Opts Snacks.terminal.toggle(cmd, opts) ``` ================================================ FILE: docs/toggle.md ================================================ # 🍿 toggle Toggle keymaps integrated with which-key icons / colors ![image](https://github.com/user-attachments/assets/6d843acd-1ac1-44fd-b318-58b4c17de2d5) ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { toggle = { -- your toggle configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.toggle.Config ---@field icon? string|{ enabled: string, disabled: string } ---@field color? string|{ enabled: string, disabled: string } ---@field wk_desc? string|{ enabled: string, disabled: string } ---@field map? fun(mode: string|string[], lhs: string, rhs: string|fun(), opts?: vim.keymap.set.Opts) ---@field which_key? boolean ---@field notify? boolean|fun(state:boolean, opts: snacks.toggle.Opts) { map = vim.keymap.set, -- keymap.set function to use which_key = true, -- integrate with which-key to show enabled/disabled icons and colors notify = true, -- show a notification when toggling -- icons for enabled/disabled states icon = { enabled = " ", disabled = " ", }, -- colors for enabled/disabled states color = { enabled = "green", disabled = "yellow", }, wk_desc = { enabled = "Disable ", disabled = "Enable ", }, } ``` ## 📚 Types ```lua ---@class snacks.toggle.Opts: snacks.toggle.Config ---@field id? string ---@field name string ---@field get fun():boolean ---@field set fun(state:boolean) ``` ## 📦 Module ### `Snacks.toggle()` ```lua ---@type fun(... :snacks.toggle.Opts): snacks.toggle.Class Snacks.toggle() ``` ### `Snacks.toggle.animate()` ```lua Snacks.toggle.animate() ``` ### `Snacks.toggle.diagnostics()` ```lua ---@param opts? snacks.toggle.Config Snacks.toggle.diagnostics(opts) ``` ### `Snacks.toggle.dim()` ```lua Snacks.toggle.dim() ``` ### `Snacks.toggle.get()` ```lua ---@param id string ---@return snacks.toggle.Class? Snacks.toggle.get(id) ``` ### `Snacks.toggle.indent()` ```lua Snacks.toggle.indent() ``` ### `Snacks.toggle.inlay_hints()` ```lua ---@param opts? snacks.toggle.Config Snacks.toggle.inlay_hints(opts) ``` ### `Snacks.toggle.line_number()` ```lua ---@param opts? snacks.toggle.Config Snacks.toggle.line_number(opts) ``` ### `Snacks.toggle.new()` ```lua ---@param ... snacks.toggle.Opts Snacks.toggle.new(...) ``` ### `Snacks.toggle.option()` ```lua ---@param option string ---@param opts? snacks.toggle.Config | {on?: unknown, off?: unknown, global?: boolean} Snacks.toggle.option(option, opts) ``` ### `Snacks.toggle.profiler()` ```lua Snacks.toggle.profiler() ``` ### `Snacks.toggle.profiler_highlights()` ```lua Snacks.toggle.profiler_highlights() ``` ### `Snacks.toggle.scroll()` ```lua Snacks.toggle.scroll() ``` ### `Snacks.toggle.treesitter()` ```lua ---@param opts? snacks.toggle.Config Snacks.toggle.treesitter(opts) ``` ### `Snacks.toggle.words()` ```lua Snacks.toggle.words() ``` ### `Snacks.toggle.zen()` ```lua Snacks.toggle.zen() ``` ### `Snacks.toggle.zoom()` ```lua Snacks.toggle.zoom() ``` ================================================ FILE: docs/util.md ================================================ # 🍿 util ## 📚 Types ```lua ---@alias snacks.util.hl table ``` ## 📦 Module ```lua ---@class snacks.util ---@field spawn snacks.spawn ---@field lsp snacks.lsp Snacks.util = {} ``` ### `Snacks.util.blend()` ```lua ---@param fg string foreground color ---@param bg string background color ---@param alpha number number between 0 and 1. 0 results in bg, 1 results in fg Snacks.util.blend(fg, bg, alpha) ``` ### `Snacks.util.bo()` Set buffer-local options. ```lua ---@param buf number ---@param bo vim.bo|{} Snacks.util.bo(buf, bo) ``` ### `Snacks.util.color()` ```lua ---@param group string|string[] hl group to get color from ---@param prop? string property to get. Defaults to "fg" Snacks.util.color(group, prop) ``` ### `Snacks.util.debounce()` ```lua ---@generic T ---@param fn T ---@param opts? {ms?:number} ---@return T Snacks.util.debounce(fn, opts) ``` ### `Snacks.util.file_decode()` Decodes a file name to a string. ```lua ---@param str string Snacks.util.file_decode(str) ``` ### `Snacks.util.file_encode()` Encodes a string to be used as a file name. ```lua ---@param str string Snacks.util.file_encode(str) ``` ### `Snacks.util.get_lang()` ```lua ---@param lang string|number|nil ---@overload fun(buf:number):string? ---@overload fun(ft:string):string? ---@return string? Snacks.util.get_lang(lang) ``` ### `Snacks.util.icon()` Get an icon from `mini.icons` or `nvim-web-devicons`. ```lua ---@param name string ---@param cat? string "file"|"filetype"|"extension"|"directory" ---@param opts? { fallback?: {dir?:string, file?:string} } ---@return string, string? Snacks.util.icon(name, cat, opts) ``` ### `Snacks.util.is_float()` ```lua ---@param win? number Snacks.util.is_float(win) ``` ### `Snacks.util.is_transparent()` Check if the colorscheme is transparent. ```lua Snacks.util.is_transparent() ``` ### `Snacks.util.keycode()` ```lua ---@param str string Snacks.util.keycode(str) ``` ### `Snacks.util.normkey()` ```lua ---@param key string Snacks.util.normkey(key) ``` ### `Snacks.util.on_key()` ```lua ---@param key string ---@param cb fun(key:string) Snacks.util.on_key(key, cb) ``` ### `Snacks.util.on_module()` Call a function when a module is loaded. The callback is called immediately if the module is already loaded. Otherwise, it is called when the module is loaded. ```lua ---@param modname string ---@param cb fun(modname:string) Snacks.util.on_module(modname, cb) ``` ### `Snacks.util.parse()` Parse async when available. ```lua ---@param parser vim.treesitter.LanguageTree ---@param range boolean|Range|nil: Parse this range in the parser's source. ---@param on_parse fun(err?: string, trees?: table) Function invoked when parsing completes. Snacks.util.parse(parser, range, on_parse) ``` ### `Snacks.util.path_type()` Better validation to check if path is a dir or a file ```lua ---@param path string ---@return "directory"|"file" Snacks.util.path_type(path) ``` ### `Snacks.util.redraw()` Redraw the window. Optimized for Neovim >= 0.10 ```lua ---@param win number Snacks.util.redraw(win) ``` ### `Snacks.util.redraw_range()` Redraw the range of lines in the window. Optimized for Neovim >= 0.10 ```lua ---@param win number ---@param from number -- 1-indexed, inclusive ---@param to number -- 1-indexed, inclusive Snacks.util.redraw_range(win, from, to) ``` ### `Snacks.util.ref()` ```lua ---@generic T ---@param t T ---@return { value?:T }|fun():T? Snacks.util.ref(t) ``` ### `Snacks.util.set_hl()` Ensures the hl groups are always set, even after a colorscheme change. ```lua ---@param groups snacks.util.hl ---@param opts? { prefix?:string, default?:boolean, managed?:boolean } Snacks.util.set_hl(groups, opts) ``` ### `Snacks.util.spinner()` ```lua Snacks.util.spinner() ``` ### `Snacks.util.stop()` ```lua ---@param handle? uv.uv_handle_t|uv.uv_timer_t Snacks.util.stop(handle) ``` ### `Snacks.util.throttle()` ```lua ---@generic T ---@param fn T ---@param opts? {ms?:number} ---@return T Snacks.util.throttle(fn, opts) ``` ### `Snacks.util.var()` Get a buffer or global variable. ```lua ---@generic T ---@param buf? number ---@param name string ---@param default? T ---@return T Snacks.util.var(buf, name, default) ``` ### `Snacks.util.winhl()` Merges vim.wo.winhighlight options. Option values can be a string or a dictionary. ```lua ---@param ... string|table Snacks.util.winhl(...) ``` ### `Snacks.util.wo()` Set window-local options. ```lua ---@param win number ---@param wo vim.wo|{}|{winhighlight: string|table} Snacks.util.wo(win, wo) ``` ================================================ FILE: docs/win.md ================================================ # 🍿 win Easily create and manage floating windows or splits ## 🚀 Usage ```lua Snacks.win({ file = vim.api.nvim_get_runtime_file("doc/news.txt", false)[1], width = 0.6, height = 0.6, wo = { spell = false, wrap = false, signcolumn = "yes", statuscolumn = " ", conceallevel = 3, }, }) ``` ![image](https://github.com/user-attachments/assets/250acfbd-a624-4f42-a36b-9aab316ebf64) ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { win = { -- your win configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.win.Config: vim.api.keyset.win_config ---@field style? string merges with config from `Snacks.config.styles[style]` ---@field show? boolean Show the window immediately (default: true) ---@field footer_keys? boolean|string[] Show keys footer. When string[], only show those keys with lhs (default: false) ---@field height? number|fun(self:snacks.win):number Height of the window. Use <1 for relative height. 0 means full height. (default: 0.9) ---@field width? number|fun(self:snacks.win):number Width of the window. Use <1 for relative width. 0 means full width. (default: 0.9) ---@field min_height? number Minimum height of the window ---@field max_height? number Maximum height of the window ---@field min_width? number Minimum width of the window ---@field max_width? number Maximum width of the window ---@field col? number|fun(self:snacks.win):number Column of the window. Use <1 for relative column. (default: center) ---@field row? number|fun(self:snacks.win):number Row of the window. Use <1 for relative row. (default: center) ---@field minimal? boolean Disable a bunch of options to make the window minimal (default: true) ---@field position? "float"|"bottom"|"top"|"left"|"right"|"current" ---@field border? "none"|"top"|"right"|"bottom"|"left"|"top_bottom"|"hpad"|"vpad"|"rounded"|"single"|"double"|"solid"|"shadow"|"bold"|string[]|false|true ---@field buf? number If set, use this buffer instead of creating a new one ---@field file? string If set, use this file instead of creating a new buffer ---@field enter? boolean Enter the window after opening (default: false) ---@field backdrop? number|false|snacks.win.Backdrop Opacity of the backdrop (default: 60) ---@field wo? vim.wo|{} window options ---@field bo? vim.bo|{} buffer options ---@field b? table buffer local variables ---@field w? table window local variables ---@field ft? string filetype to use for treesitter/syntax highlighting. Won't override existing filetype ---@field scratch_ft? string filetype to use for scratch buffers ---@field keys? table Key mappings ---@field on_buf? fun(self: snacks.win) Callback after opening the buffer ---@field on_win? fun(self: snacks.win) Callback after opening the window ---@field on_close? fun(self: snacks.win) Callback after closing the window ---@field fixbuf? boolean don't allow other buffers to be opened in this window ---@field text? string|string[]|fun():(string[]|string) Initial lines to set in the buffer ---@field actions? table Actions that can be used in key mappings ---@field resize? boolean Automatically resize the window when the editor is resized ---@field stack? boolean When enabled, multiple split windows with the same position will be stacked together (useful for terminals) { show = true, fixbuf = true, relative = "editor", position = "float", minimal = true, wo = { winhighlight = "Normal:SnacksNormal,NormalNC:SnacksNormalNC,WinBar:SnacksWinBar,WinBarNC:SnacksWinBarNC,FloatTitle:SnacksTitle,FloatFooter:SnacksFooter,WinSeparator:SnacksWinSeparator", }, bo = {}, title_pos = "center", keys = { q = "close", }, footer_pos = "center", footer_keys = false, } ``` ## 🎨 Styles Check the [styles](https://github.com/folke/snacks.nvim/blob/main/docs/styles.md) docs for more information on how to customize these styles ### `float` ```lua { position = "float", backdrop = 60, height = 0.9, width = 0.9, zindex = 50, } ``` ### `help` ```lua { position = "float", backdrop = false, border = "top", row = -1, width = 0, height = 0.3, } ``` ### `minimal` ```lua { wo = { cursorcolumn = false, cursorline = false, cursorlineopt = "both", colorcolumn = "", fillchars = "eob: ,lastline:…", foldcolumn = "0", list = false, listchars = "extends:…,tab: ", number = false, relativenumber = false, signcolumn = "no", spell = false, winbar = "", statuscolumn = "", wrap = false, sidescrolloff = 0, }, } ``` ### `split` ```lua { position = "bottom", height = 0.4, width = 0.4, } ``` ## 📚 Types ```lua ---@class snacks.win.Keys: vim.api.keyset.keymap ---@field [1]? string ---@field [2]? string|string[]|fun(self: snacks.win): string? ---@field mode? string|string[] ``` ```lua ---@class snacks.win.Event: vim.api.keyset.create_autocmd ---@field buf? true ---@field win? true ---@field callback? fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean? ``` ```lua ---@class snacks.win.Backdrop ---@field bg? string ---@field blend? number ---@field transparent? boolean defaults to true ---@field win? snacks.win.Config overrides the backdrop window config ``` ```lua ---@class snacks.win.Dim ---@field width number width of the window, without borders ---@field height number height of the window, without borders ---@field row number row of the window (0-indexed) ---@field col number column of the window (0-indexed) ---@field border? boolean whether the window has a border ``` ```lua ---@alias snacks.win.Action.fn fun(self: snacks.win):(boolean|string?) ---@alias snacks.win.Action.spec snacks.win.Action|snacks.win.Action.fn ---@class snacks.win.Action ---@field action snacks.win.Action.fn ---@field desc? string ``` ## 📦 Module ```lua ---@class snacks.win ---@field id number ---@field buf? number ---@field scratch_buf? number ---@field win? number ---@field opts snacks.win.Config ---@field augroup? number ---@field backdrop? snacks.win ---@field keys snacks.win.Keys[] ---@field events (snacks.win.Event|{event:string|string[]})[] ---@field meta table ---@field closed? boolean Snacks.win = {} ``` ### `Snacks.win()` ```lua ---@type fun(opts? :snacks.win.Config|{}): snacks.win Snacks.win() ``` ### `Snacks.win.is_border()` ```lua Snacks.win.is_border(border) ``` ### `Snacks.win.new()` ```lua ---@param opts? snacks.win.Config|{} ---@return snacks.win Snacks.win.new(opts) ``` ### `Snacks.win.zindex()` Calculate the next available zindex for snacks windows. New windows open on top of existing ones. ```lua ---@param opts? { zindex?: number, tab?: number|boolean, all?: boolean, max?: number } ---@overload fun(zindex: number): number Snacks.win.zindex(opts) ``` ### `win:action()` ```lua ---@param actions string|string[] ---@return (fun(): boolean|string?) action, string? desc win:action(actions) ``` ### `win:add_padding()` ```lua win:add_padding() ``` ### `win:border()` ```lua win:border() ``` ### `win:border_size()` Calculate the size of the border ```lua win:border_size() ``` ### `win:border_text_width()` ```lua win:border_text_width() ``` ### `win:buf_valid()` ```lua win:buf_valid() ``` ### `win:close()` ```lua ---@param opts? { buf: boolean } win:close(opts) ``` ### `win:destroy()` ```lua win:destroy() ``` ### `win:dim()` ```lua ---@param parent? snacks.win.Dim win:dim(parent) ``` ### `win:execute()` ```lua ---@param actions string|string[] win:execute(actions) ``` ### `win:fixbuf()` ```lua win:fixbuf() ``` ### `win:focus()` ```lua win:focus() ``` ### `win:has_border()` ```lua win:has_border() ``` ### `win:hide()` ```lua win:hide() ``` ### `win:hscroll()` ```lua ---@param left? boolean win:hscroll(left) ``` ### `win:is_floating()` ```lua win:is_floating() ``` ### `win:line()` ```lua win:line(line) ``` ### `win:lines()` ```lua ---@param from? number 1-indexed, inclusive ---@param to? number 1-indexed, inclusive win:lines(from, to) ``` ### `win:map()` ```lua win:map() ``` ### `win:on()` ```lua ---@param event string|string[] ---@param cb fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean? ---@param opts? snacks.win.Event win:on(event, cb, opts) ``` ### `win:on_current_tab()` ```lua win:on_current_tab() ``` ### `win:on_resize()` ```lua win:on_resize() ``` ### `win:parent_size()` ```lua ---@return { height: number, width: number } win:parent_size() ``` ### `win:redraw()` ```lua win:redraw() ``` ### `win:scratch()` ```lua win:scratch() ``` ### `win:scroll()` ```lua ---@param up? boolean win:scroll(up) ``` ### `win:set_buf()` ```lua ---@param buf number win:set_buf(buf) ``` ### `win:set_title()` ```lua ---@param title string|{[1]:string, [2]:string}[] ---@param pos? "center"|"left"|"right" win:set_title(title, pos) ``` ### `win:show()` ```lua win:show() ``` ### `win:size()` ```lua ---@return { height: number, width: number } win:size() ``` ### `win:text()` ```lua ---@param from? number 1-indexed, inclusive ---@param to? number 1-indexed, inclusive win:text(from, to) ``` ### `win:toggle()` ```lua win:toggle() ``` ### `win:toggle_help()` ```lua ---@param opts? {col_width?: number, key_width?: number, win?: snacks.win.Config} win:toggle_help(opts) ``` ### `win:update()` ```lua win:update() ``` ### `win:valid()` ```lua win:valid() ``` ### `win:win_valid()` ```lua win:win_valid() ``` ================================================ FILE: docs/words.md ================================================ # 🍿 words Auto-show LSP references and quickly navigate between them ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { words = { -- your words configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.words.Config ---@field enabled? boolean { debounce = 200, -- time in ms to wait before updating notify_jump = false, -- show a notification when jumping notify_end = true, -- show a notification when reaching the end foldopen = true, -- open folds after jumping jumplist = true, -- set jump point before jumping modes = { "n", "i", "c" }, -- modes to show references filter = function(buf) -- what buffers to enable `snacks.words` return vim.g.snacks_words ~= false and vim.b[buf].snacks_words ~= false end, } ``` ## 📦 Module ### `Snacks.words.clear()` ```lua Snacks.words.clear() ``` ### `Snacks.words.disable()` ```lua Snacks.words.disable() ``` ### `Snacks.words.enable()` ```lua Snacks.words.enable() ``` ### `Snacks.words.is_enabled()` ```lua ---@param opts? number|{buf?:number, modes:boolean} if modes is true, also check if the current mode is enabled Snacks.words.is_enabled(opts) ``` ### `Snacks.words.jump()` ```lua ---@param count? number ---@param cycle? boolean Snacks.words.jump(count, cycle) ``` ================================================ FILE: docs/zen.md ================================================ # 🍿 zen Zen mode • distraction-free coding. Integrates with `Snacks.toggle` to toggle various UI elements and with `Snacks.dim` to dim code out of scope. Similar plugins: - [zen-mode.nvim](https://github.com/folke/zen-mode.nvim) - [true-zen.nvim](https://github.com/pocco81/true-zen.nvim) ![image](https://github.com/user-attachments/assets/77c607ec-c354-4e17-bcd1-fdcd4b4c0057) ## 📦 Setup ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { zen = { -- your zen configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ## ⚙️ Config ```lua ---@class snacks.zen.Config { -- You can add any `Snacks.toggle` id here. -- Toggle state is restored when the window is closed. -- Toggle config options are NOT merged. ---@type table toggles = { dim = true, git_signs = false, mini_diff_signs = false, -- diagnostics = false, -- inlay_hints = false, }, center = true, -- center the window show = { statusline = false, -- can only be shown when using the global statusline tabline = false, }, ---@type snacks.win.Config win = { style = "zen" }, --- Callback when the window is opened. ---@param win snacks.win on_open = function(win) end, --- Callback when the window is closed. ---@param win snacks.win on_close = function(win) end, --- Options for the `Snacks.zen.zoom()` ---@type snacks.zen.Config zoom = { toggles = {}, center = false, show = { statusline = true, tabline = true }, win = { backdrop = false, width = 0, -- full width }, }, } ``` ## 🎨 Styles Check the [styles](https://github.com/folke/snacks.nvim/blob/main/docs/styles.md) docs for more information on how to customize these styles ### `zen` ```lua { enter = true, fixbuf = false, minimal = false, width = 120, height = 0, backdrop = { transparent = true, blend = 40 }, keys = { q = false }, zindex = 40, wo = { winhighlight = "NormalFloat:Normal", }, w = { snacks_main = true, }, } ``` ### `zoom_indicator` fullscreen indicator only shown when the window is maximized ```lua { text = "▍ zoom 󰊓 ", minimal = true, enter = false, focusable = false, height = 1, row = 0, col = -1, backdrop = false, } ``` ## 📦 Module ### `Snacks.zen()` ```lua ---@type fun(opts: snacks.zen.Config): snacks.win Snacks.zen() ``` ### `Snacks.zen.zen()` ```lua ---@param opts? snacks.zen.Config Snacks.zen.zen(opts) ``` ### `Snacks.zen.zoom()` ```lua ---@param opts? snacks.zen.Config Snacks.zen.zoom(opts) ``` ================================================ FILE: lua/snacks/animate/easing.lua ================================================ -- https://github.com/EmmanuelOga/easing/blob/master/lib/easing.lua -- -- Adapted from -- Tweener's easing functions (Penner's Easing Equations) -- and http://code.google.com/p/tweener/ (jstweener javascript version) -- --[[ Disclaimer for Robert Penner's Easing Equations license: TERMS OF USE - EASING EQUATIONS Open source under the BSD License. Copyright © 2001 Robert Penner All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the author nor the names of contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ]] -- For all easing functions: -- t = elapsed time -- b = begin -- c = change == ending - beginning -- d = duration (total time) local pow = math.pow local sin = math.sin local cos = math.cos local pi = math.pi local sqrt = math.sqrt local abs = math.abs local asin = math.asin -- Easing functions for animations. local function linear(t, b, c, d) return c * t / d + b end local function inQuad(t, b, c, d) t = t / d return c * pow(t, 2) + b end local function outQuad(t, b, c, d) t = t / d return -c * t * (t - 2) + b end local function inOutQuad(t, b, c, d) t = t / d * 2 if t < 1 then return c / 2 * pow(t, 2) + b else return -c / 2 * ((t - 1) * (t - 3) - 1) + b end end local function outInQuad(t, b, c, d) if t < d / 2 then return outQuad(t * 2, b, c / 2, d) else return inQuad((t * 2) - d, b + c / 2, c / 2, d) end end local function inCubic(t, b, c, d) t = t / d return c * pow(t, 3) + b end local function outCubic(t, b, c, d) t = t / d - 1 return c * (pow(t, 3) + 1) + b end local function inOutCubic(t, b, c, d) t = t / d * 2 if t < 1 then return c / 2 * t * t * t + b else t = t - 2 return c / 2 * (t * t * t + 2) + b end end local function outInCubic(t, b, c, d) if t < d / 2 then return outCubic(t * 2, b, c / 2, d) else return inCubic((t * 2) - d, b + c / 2, c / 2, d) end end local function inQuart(t, b, c, d) t = t / d return c * pow(t, 4) + b end local function outQuart(t, b, c, d) t = t / d - 1 return -c * (pow(t, 4) - 1) + b end local function inOutQuart(t, b, c, d) t = t / d * 2 if t < 1 then return c / 2 * pow(t, 4) + b else t = t - 2 return -c / 2 * (pow(t, 4) - 2) + b end end local function outInQuart(t, b, c, d) if t < d / 2 then return outQuart(t * 2, b, c / 2, d) else return inQuart((t * 2) - d, b + c / 2, c / 2, d) end end local function inQuint(t, b, c, d) t = t / d return c * pow(t, 5) + b end local function outQuint(t, b, c, d) t = t / d - 1 return c * (pow(t, 5) + 1) + b end local function inOutQuint(t, b, c, d) t = t / d * 2 if t < 1 then return c / 2 * pow(t, 5) + b else t = t - 2 return c / 2 * (pow(t, 5) + 2) + b end end local function outInQuint(t, b, c, d) if t < d / 2 then return outQuint(t * 2, b, c / 2, d) else return inQuint((t * 2) - d, b + c / 2, c / 2, d) end end local function inSine(t, b, c, d) return -c * cos(t / d * (pi / 2)) + c + b end local function outSine(t, b, c, d) return c * sin(t / d * (pi / 2)) + b end local function inOutSine(t, b, c, d) return -c / 2 * (cos(pi * t / d) - 1) + b end local function outInSine(t, b, c, d) if t < d / 2 then return outSine(t * 2, b, c / 2, d) else return inSine((t * 2) - d, b + c / 2, c / 2, d) end end local function inExpo(t, b, c, d) if t == 0 then return b else return c * pow(2, 10 * (t / d - 1)) + b - c * 0.001 end end local function outExpo(t, b, c, d) if t == d then return b + c else return c * 1.001 * (-pow(2, -10 * t / d) + 1) + b end end local function inOutExpo(t, b, c, d) if t == 0 then return b end if t == d then return b + c end t = t / d * 2 if t < 1 then return c / 2 * pow(2, 10 * (t - 1)) + b - c * 0.0005 else t = t - 1 return c / 2 * 1.0005 * (-pow(2, -10 * t) + 2) + b end end local function outInExpo(t, b, c, d) if t < d / 2 then return outExpo(t * 2, b, c / 2, d) else return inExpo((t * 2) - d, b + c / 2, c / 2, d) end end local function inCirc(t, b, c, d) t = t / d return (-c * (sqrt(1 - pow(t, 2)) - 1) + b) end local function outCirc(t, b, c, d) t = t / d - 1 return (c * sqrt(1 - pow(t, 2)) + b) end local function inOutCirc(t, b, c, d) t = t / d * 2 if t < 1 then return -c / 2 * (sqrt(1 - t * t) - 1) + b else t = t - 2 return c / 2 * (sqrt(1 - t * t) + 1) + b end end local function outInCirc(t, b, c, d) if t < d / 2 then return outCirc(t * 2, b, c / 2, d) else return inCirc((t * 2) - d, b + c / 2, c / 2, d) end end local function inElastic(t, b, c, d, a, p) if t == 0 then return b end t = t / d if t == 1 then return b + c end if not p then p = d * 0.3 end local s if not a or a < abs(c) then a = c s = p / 4 else s = p / (2 * pi) * asin(c / a) end t = t - 1 return -(a * pow(2, 10 * t) * sin((t * d - s) * (2 * pi) / p)) + b end -- a: amplitud -- p: period local function outElastic(t, b, c, d, a, p) if t == 0 then return b end t = t / d if t == 1 then return b + c end if not p then p = d * 0.3 end local s if not a or a < abs(c) then a = c s = p / 4 else s = p / (2 * pi) * asin(c / a) end return a * pow(2, -10 * t) * sin((t * d - s) * (2 * pi) / p) + c + b end -- p = period -- a = amplitud local function inOutElastic(t, b, c, d, a, p) if t == 0 then return b end t = t / d * 2 if t == 2 then return b + c end if not p then p = d * (0.3 * 1.5) end if not a then a = 0 end local s if not a or a < abs(c) then a = c s = p / 4 else s = p / (2 * pi) * asin(c / a) end if t < 1 then t = t - 1 return -0.5 * (a * pow(2, 10 * t) * sin((t * d - s) * (2 * pi) / p)) + b else t = t - 1 return a * pow(2, -10 * t) * sin((t * d - s) * (2 * pi) / p) * 0.5 + c + b end end -- a: amplitud -- p: period local function outInElastic(t, b, c, d, a, p) if t < d / 2 then return outElastic(t * 2, b, c / 2, d, a, p) else return inElastic((t * 2) - d, b + c / 2, c / 2, d, a, p) end end local function inBack(t, b, c, d, s) if not s then s = 1.70158 end t = t / d return c * t * t * ((s + 1) * t - s) + b end local function outBack(t, b, c, d, s) if not s then s = 1.70158 end t = t / d - 1 return c * (t * t * ((s + 1) * t + s) + 1) + b end local function inOutBack(t, b, c, d, s) if not s then s = 1.70158 end s = s * 1.525 t = t / d * 2 if t < 1 then return c / 2 * (t * t * ((s + 1) * t - s)) + b else t = t - 2 return c / 2 * (t * t * ((s + 1) * t + s) + 2) + b end end local function outInBack(t, b, c, d, s) if t < d / 2 then return outBack(t * 2, b, c / 2, d, s) else return inBack((t * 2) - d, b + c / 2, c / 2, d, s) end end local function outBounce(t, b, c, d) t = t / d if t < 1 / 2.75 then return c * (7.5625 * t * t) + b elseif t < 2 / 2.75 then t = t - (1.5 / 2.75) return c * (7.5625 * t * t + 0.75) + b elseif t < 2.5 / 2.75 then t = t - (2.25 / 2.75) return c * (7.5625 * t * t + 0.9375) + b else t = t - (2.625 / 2.75) return c * (7.5625 * t * t + 0.984375) + b end end local function inBounce(t, b, c, d) return c - outBounce(d - t, 0, c, d) + b end local function inOutBounce(t, b, c, d) if t < d / 2 then return inBounce(t * 2, 0, c, d) * 0.5 + b else return outBounce(t * 2 - d, 0, c, d) * 0.5 + c * 0.5 + b end end local function outInBounce(t, b, c, d) if t < d / 2 then return outBounce(t * 2, b, c / 2, d) else return inBounce((t * 2) - d, b + c / 2, c / 2, d) end end ---@enum (key) snacks.animate.easing local M = { linear = linear, inQuad = inQuad, outQuad = outQuad, inOutQuad = inOutQuad, outInQuad = outInQuad, inCubic = inCubic, outCubic = outCubic, inOutCubic = inOutCubic, outInCubic = outInCubic, inQuart = inQuart, outQuart = outQuart, inOutQuart = inOutQuart, outInQuart = outInQuart, inQuint = inQuint, outQuint = outQuint, inOutQuint = inOutQuint, outInQuint = outInQuint, inSine = inSine, outSine = outSine, inOutSine = inOutSine, outInSine = outInSine, inExpo = inExpo, outExpo = outExpo, inOutExpo = inOutExpo, outInExpo = outInExpo, inCirc = inCirc, outCirc = outCirc, inOutCirc = inOutCirc, outInCirc = outInCirc, inElastic = inElastic, outElastic = outElastic, inOutElastic = inOutElastic, outInElastic = outInElastic, inBack = inBack, outBack = outBack, inOutBack = inOutBack, outInBack = outInBack, inBounce = inBounce, outBounce = outBounce, inOutBounce = inOutBounce, outInBounce = outInBounce, } return M ================================================ FILE: lua/snacks/animate/init.lua ================================================ ---@class snacks.animate ---@overload fun(from: number, to: number, cb: snacks.animate.cb, opts?: snacks.animate.Opts): snacks.animate.Animation local M = setmetatable({}, { __call = function(M, ...) return M.add(...) end, }) M.meta = { desc = "Efficient animations including over 45 easing functions _(library)_", } -- All easing functions take these parameters: -- -- * `t` _(time)_: should go from 0 to duration -- * `b` _(begin)_: starting value of the property -- * `c` _(change)_: ending value of the property - starting value -- * `d` _(duration)_: total duration of the animation -- -- Some functions allow additional modifiers, like the elastic functions -- which also can receive an amplitud and a period parameters (defaults -- are included) ---@alias snacks.animate.easing.Fn fun(t: number, b: number, c: number, d: number): number --- Duration can be specified as the total duration or the duration per step. --- When both are specified, the minimum of both is used. ---@class snacks.animate.Duration ---@field step? number duration per step in ms ---@field total? number total duration in ms ---@class snacks.animate.Config ---@field easing? snacks.animate.easing|snacks.animate.easing.Fn local defaults = { ---@type snacks.animate.Duration|number duration = 20, -- ms per step easing = "linear", fps = 120, -- frames per second. Global setting for all animations } ---@class snacks.animate.Opts: snacks.animate.Config ---@field buf? number optional buffer to check if animations should be enabled ---@field int? boolean interpolate the value to an integer ---@field id? number|string unique identifier for the animation ---@class snacks.animate.ctx ---@field anim snacks.animate.Animation ---@field prev number ---@field done boolean ---@alias snacks.animate.cb fun(value:number, ctx: snacks.animate.ctx) local uv = vim.uv or vim.loop local _id = 0 local function next_id() _id = _id + 1 return _id end ---@type table local active = setmetatable({}, { __mode = "v" }) ---@class snacks.animate.Animation ---@field id number|string unique identifier ---@field opts snacks.animate.Opts ---@field easing snacks.animate.easing.Fn ---@field timer? uv.uv_timer_t ---@field steps? number[] ---@field _step? number local Animation = {} Animation.__index = Animation ---@param opts? snacks.animate.Opts function Animation.new(opts) local id = opts and opts.id or next_id() if active[id] then active[id]:stop() active[id] = nil end local self = setmetatable({}, Animation) self.id = id self.opts = Snacks.config.get("animate", defaults, opts) --[[@as snacks.animate.Opts]] -- resolve easing function local easing = self.opts.easing or "linear" -- easing = easing == "linear" and self.opts.int and "linear_int" or easing easing = type(easing) == "string" and require("snacks.animate.easing")[easing] or easing ---@cast easing snacks.animate.easing.Fn self.easing = easing active[self.id] = self return self end ---@param from number ---@param to number ---@param cb snacks.animate.cb function Animation:start(from, to, cb) self:stop() if from == to then cb(from, { anim = self, prev = from, done = true }) return self end -- calculate duration local d = type(self.opts.duration) == "table" and self.opts.duration or { step = self.opts.duration } ---@cast d snacks.animate.Duration local duration = 0 if d.step then duration = d.step * math.abs(to - from) duration = math.min(duration, d.total or duration) elseif d.total then duration = d.total end duration = duration or 250 local step_duration = math.max(duration / (to - from), 1000 / self.opts.fps) -- local step_duration = math.max(duration / (to - from), 1) local step_count = math.max(math.floor(duration / step_duration + 0.5), 10) local delta = 0 if (self.opts.easing or "linear") == "linear" and self.opts.int then local one_step = math.max(1, math.floor(math.abs(to - from) / step_count + 0.5)) step_count = math.floor(math.abs(to - from) / one_step + 0.5) delta = math.abs(to - from) - one_step * step_count step_duration = duration / step_count end self.steps = {} for i = 1, step_count do local value = 0 if i == step_count then value = to else value = self.easing(i, from, to - from - delta, step_count) end if self.opts.int then value = math.floor(value + 0.5) end table.insert(self.steps, value) end self._step = 0 active[self.id] = self self.timer = assert(uv.new_timer()) self.timer:start(0, step_duration, function() vim.schedule(function() self:step(cb) end) end) return self end ---@param cb snacks.animate.cb function Animation:step(cb) if not self.steps or not self._step or self._step >= #self.steps then return self:stop() end self._step = self._step + 1 local value = self.steps[self._step] local done = self._step >= #self.steps local prev = self.steps[self._step - 1] or value cb(value, { anim = self, prev = prev, done = done }) end function Animation:stop() if self.timer then if self.timer:is_active() then self.timer:stop() self.timer:close() self.timer = nil end end self.steps, self._step = nil, nil end --- Check if animations are enabled. --- Will return false if `snacks_animate` is set to false or if the buffer --- local variable `snacks_animate` is set to false. ---@param opts? {buf?: number, name?: string} function M.enabled(opts) opts = opts or {} if opts.name and not M.enabled({ buf = opts.buf }) then return false end local key = "snacks_animate" .. (opts.name and ("_" .. opts.name) or "") return Snacks.util.var(opts.buf, key, true) end --- Add an animation ---@param from number ---@param to number ---@param cb snacks.animate.cb ---@param opts? snacks.animate.Opts function M.add(from, to, cb, opts) return Animation.new(opts):start(from, to, cb) end --- Delete an animation ---@param id number|string function M.del(id) if active[id] then active[id]:stop() active[id] = nil end end return M ================================================ FILE: lua/snacks/bigfile.lua ================================================ ---@private ---@class snacks.bigfile local M = {} M.meta = { desc = "Deal with big files", needs_setup = true, } ---@class snacks.bigfile.Config ---@field enabled? boolean local defaults = { notify = true, -- show notification when big file detected size = 1.5 * 1024 * 1024, -- 1.5MB line_length = 1000, -- average line length (useful for minified files) -- Enable or disable features when big file detected ---@param ctx {buf: number, ft:string} setup = function(ctx) if vim.fn.exists(":NoMatchParen") ~= 0 then vim.cmd([[NoMatchParen]]) end Snacks.util.wo(0, { foldmethod = "manual", statuscolumn = "", conceallevel = 0 }) vim.b.completion = false vim.b.minianimate_disable = true vim.b.minihipatterns_disable = true vim.schedule(function() if vim.api.nvim_buf_is_valid(ctx.buf) then vim.bo[ctx.buf].syntax = ctx.ft end end) end, } ---@private function M.setup() local opts = Snacks.config.get("bigfile", defaults) vim.filetype.add({ pattern = { [".*"] = { function(path, buf) if not path or not buf or vim.bo[buf].filetype == "bigfile" then return end if path ~= vim.fs.normalize(vim.api.nvim_buf_get_name(buf)) then return end local size = vim.fn.getfsize(path) if size <= 0 then return end if size > opts.size then return "bigfile" end local lines = vim.api.nvim_buf_line_count(buf) return (size - lines) / lines > opts.line_length and "bigfile" or nil end, }, }, }) vim.api.nvim_create_autocmd({ "FileType" }, { group = vim.api.nvim_create_augroup("snacks_bigfile", { clear = true }), pattern = "bigfile", callback = function(ev) if opts.notify then local path = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(ev.buf), ":p:~:.") Snacks.notify.warn({ ("Big file detected `%s`."):format(path), "Some Neovim features have been **disabled**.", }, { title = "Big File" }) end vim.api.nvim_buf_call(ev.buf, function() opts.setup({ buf = ev.buf, ft = vim.filetype.match({ buf = ev.buf }) or "", }) end) end, }) end return M ================================================ FILE: lua/snacks/bufdelete.lua ================================================ ---@class snacks.bufdelete ---@overload fun(buf?: number|snacks.bufdelete.Opts) local M = setmetatable({}, { __call = function(t, ...) return t.delete(...) end, }) M.meta = { desc = "Delete buffers without disrupting window layout", } ---@class snacks.bufdelete.Opts ---@field buf? number Buffer to delete. Defaults to the current buffer ---@field file? string Delete buffer by file name. If provided, `buf` is ignored ---@field force? boolean Delete the buffer even if it is modified ---@field filter? fun(buf: number): boolean Filter buffers to delete ---@field wipe? boolean Wipe the buffer instead of deleting it (see `:h :bwipeout`) --- Delete a buffer: --- - either the current buffer if `buf` is not provided --- - or the buffer `buf` if it is a number --- - or every buffer for which `buf` returns true if it is a function ---@param opts? number|snacks.bufdelete.Opts function M.delete(opts) opts = opts or {} opts = type(opts) == "number" and { buf = opts } or opts opts = type(opts) == "function" and { filter = opts } or opts ---@cast opts snacks.bufdelete.Opts if type(opts.filter) == "function" then for _, b in ipairs(vim.tbl_filter(opts.filter, vim.api.nvim_list_bufs())) do if vim.bo[b].buflisted then M.delete(vim.tbl_extend("force", {}, opts, { buf = b, filter = false })) end end return end local buf = opts.buf or 0 if opts.file then buf = vim.fn.bufnr(opts.file) if buf == -1 then return end end buf = buf == 0 and vim.api.nvim_get_current_buf() or buf if not vim.api.nvim_buf_is_valid(buf) then return end -- Check if the buffer is modified if vim.bo[buf].modified and not opts.force then local ok, choice = pcall(vim.fn.confirm, ("Save changes to %q?"):format(vim.fn.bufname(buf)), "&Yes\n&No\n&Cancel") if not ok or choice == 0 or choice == 3 then -- 0 for / and 3 for Cancel return elseif choice == 1 then -- Yes vim.api.nvim_buf_call(buf, vim.cmd.write) end end -- Get the most recently used listed buffer that is not the one being deleted, local info = vim.fn.getbufinfo({ buflisted = 1 }) ---@param b vim.fn.getbufinfo.ret.item info = vim.tbl_filter(function(b) return b.bufnr ~= buf end, info) table.sort(info, function(a, b) return a.lastused > b.lastused end) local new_buf = info[1] and info[1].bufnr or vim.api.nvim_create_buf(true, false) -- replace the buffer in all windows showing it, -- trying to use the alternate buffer if possible for _, win in ipairs(vim.fn.win_findbuf(buf)) do local win_buf = new_buf vim.api.nvim_win_call(win, function() -- Try using alternate buffer local alt = vim.fn.bufnr("#") win_buf = alt >= 0 and alt ~= buf and vim.bo[alt].buflisted and alt or win_buf end) vim.api.nvim_win_set_buf(win, win_buf) end if vim.api.nvim_buf_is_valid(buf) then pcall(vim.cmd, (opts.wipe and "bwipeout! " or "bdelete! ") .. buf) end end --- Delete all buffers ---@param opts? snacks.bufdelete.Opts function M.all(opts) return M.delete(vim.tbl_extend("force", {}, opts or {}, { filter = function() return true end, })) end --- Delete all buffers except the current one ---@param opts? snacks.bufdelete.Opts function M.other(opts) return M.delete(vim.tbl_extend("force", {}, opts or {}, { filter = function(b) return b ~= vim.api.nvim_get_current_buf() end, })) end return M ================================================ FILE: lua/snacks/compat.lua ================================================ ---@generic T ---@param t T ---@return T local function wrap(t) return setmetatable({}, { __index = t }) end local M = wrap(vim) M.meta = { desc = "Neovim compatibility layer", hide = true, } local is_win = jit.os:find("Windows") M.islist = vim.islist or vim.tbl_islist M.uv = vim.uv or vim.loop if vim.fn.has("nvim-0.11") == 0 then M.fs = wrap(vim.fs) ---@param path (string) Path to normalize ---@param opts? vim.fs.normalize.Opts ---@return (string) : Normalized path function M.fs.normalize(path, opts) local ret = vim.fs.normalize(path, opts) return is_win and ret:gsub("^%a:", string.upper) or ret end end return M ================================================ FILE: lua/snacks/dashboard.lua ================================================ ---@class snacks.dashboard ---@overload fun(opts?: snacks.dashboard.Opts): snacks.dashboard.Class local M = setmetatable({}, { __call = function(M, opts) return M.open(opts) end, }) M.meta = { desc = " Beautiful declarative dashboards", needs_setup = true, } local uv = vim.uv or vim.loop math.randomseed(os.time()) ---@class snacks.dashboard.Item ---@field indent? number ---@field align? "left" | "center" | "right" ---@field gap? number the number of empty lines between child items ---@field padding? number | {[1]:number, [2]:number} bottom or {bottom, top} padding --- The action to run when the section is selected or the key is pressed. --- * if it's a string starting with `:`, it will be run as a command --- * if it's a string, it will be executed as a keymap --- * if it's a function, it will be called ---@field action? snacks.dashboard.Action ---@field enabled? boolean|fun(opts:snacks.dashboard.Opts):boolean if false, the section will be disabled ---@field section? string the name of a section to include. See `Snacks.dashboard.sections` ---@field [string] any section options ---@field key? string shortcut key ---@field hidden? boolean when `true`, the item will not be shown, but the key will still be assigned ---@field autokey? boolean automatically assign a numerical key ---@field label? string ---@field desc? string ---@field file? string ---@field footer? string ---@field header? string ---@field icon? string ---@field title? string ---@field text? string|snacks.dashboard.Text[] ---@alias snacks.dashboard.Format.ctx {width?:number} ---@alias snacks.dashboard.Action string|fun(self:snacks.dashboard.Class) ---@alias snacks.dashboard.Gen fun(self:snacks.dashboard.Class):snacks.dashboard.Section? ---@alias snacks.dashboard.Section snacks.dashboard.Item|snacks.dashboard.Gen|snacks.dashboard.Section[] ---@class snacks.dashboard.Text ---@field [1] string the text ---@field hl? string the highlight group ---@field width? number the width used for alignment ---@field align? "left" | "center" | "right" ---@private ---@class snacks.dashboard.Item ---@field package _? snacks.dashboard.Item._ the position of the item in the dashboard ---@private ---@class snacks.dashboard.Item._ ---@field pane number 1-indexed ---@field row number 1-indexed ---@field col number 0-indexed ---@private ---@class snacks.dashboard.Line ---@field [number] snacks.dashboard.Text ---@field width number ---@private ---@class snacks.dashboard.Block ---@field [number] snacks.dashboard.Line ---@field width number ---@class snacks.dashboard.Config ---@field enabled? boolean ---@field sections snacks.dashboard.Section ---@field formats table local defaults = { width = 60, row = nil, -- dashboard position. nil for center col = nil, -- dashboard position. nil for center pane_gap = 4, -- empty columns between vertical panes autokeys = "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", -- autokey sequence -- These settings are used by some built-in sections preset = { -- Defaults to a picker that supports `fzf-lua`, `telescope.nvim` and `mini.pick` ---@type fun(cmd:string, opts:table)|nil pick = nil, -- Used by the `keys` section to show keymaps. -- Set your custom keymaps here. -- When using a function, the `items` argument are the default keymaps. -- stylua: ignore ---@type snacks.dashboard.Item[] keys = { { icon = " ", key = "f", desc = "Find File", action = ":lua Snacks.dashboard.pick('files')" }, { icon = " ", key = "n", desc = "New File", action = ":ene | startinsert" }, { icon = " ", key = "g", desc = "Find Text", action = ":lua Snacks.dashboard.pick('live_grep')" }, { icon = " ", key = "r", desc = "Recent Files", action = ":lua Snacks.dashboard.pick('oldfiles')" }, { icon = " ", key = "c", desc = "Config", action = ":lua Snacks.dashboard.pick('files', {cwd = vim.fn.stdpath('config')})" }, { icon = " ", key = "s", desc = "Restore Session", section = "session" }, { icon = "󰒲 ", key = "L", desc = "Lazy", action = ":Lazy", enabled = package.loaded.lazy ~= nil }, { icon = " ", key = "q", desc = "Quit", action = ":qa" }, }, -- Used by the `header` section header = [[ ███╗ ██╗███████╗ ██████╗ ██╗ ██╗██╗███╗ ███╗ ████╗ ██║██╔════╝██╔═══██╗██║ ██║██║████╗ ████║ ██╔██╗ ██║█████╗ ██║ ██║██║ ██║██║██╔████╔██║ ██║╚██╗██║██╔══╝ ██║ ██║╚██╗ ██╔╝██║██║╚██╔╝██║ ██║ ╚████║███████╗╚██████╔╝ ╚████╔╝ ██║██║ ╚═╝ ██║ ╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═══╝ ╚═╝╚═╝ ╚═╝]], }, -- item field formatters formats = { icon = function(item) if item.file and item.icon == "file" or item.icon == "directory" then return Snacks.dashboard.icon(item.file, item.icon) end return { item.icon, width = 2, hl = "icon" } end, footer = { "%s", align = "center" }, header = { "%s", align = "center" }, file = function(item, ctx) local fname = vim.fn.fnamemodify(item.file, ":~") fname = ctx.width and #fname > ctx.width and vim.fn.pathshorten(fname) or fname if #fname > ctx.width then local dir = vim.fn.fnamemodify(fname, ":h") local file = vim.fn.fnamemodify(fname, ":t") if dir and file then file = file:sub(-(ctx.width - #dir - 2)) fname = dir .. "/…" .. file end end local dir, file = fname:match("^(.*)/(.+)$") return dir and { { dir .. "/", hl = "dir" }, { file, hl = "file" } } or { { fname, hl = "file" } } end, }, sections = { { section = "header" }, { section = "keys", gap = 1, padding = 1 }, { section = "startup" }, }, debug = false, } -- The default style for the dashboard. -- When opening the dashboard during startup, only the `bo` and `wo` options are used. -- The other options are used with `:lua Snacks.dashboard()` Snacks.config.style("dashboard", { zindex = 10, height = 0, width = 0, bo = { bufhidden = "wipe", buftype = "nofile", buflisted = false, filetype = "snacks_dashboard", swapfile = false, undofile = false, }, wo = { colorcolumn = "", cursorcolumn = false, cursorline = false, foldmethod = "manual", list = false, number = false, relativenumber = false, sidescrolloff = 0, signcolumn = "no", spell = false, statuscolumn = "", statusline = "", winbar = "", winhighlight = "Normal:SnacksDashboardNormal,NormalFloat:SnacksDashboardNormal", wrap = false, }, }) M.ns = vim.api.nvim_create_namespace("snacks_dashboard") local links = { Desc = "Special", File = "Special", Dir = "NonText", Footer = "Title", Header = "Title", Icon = "Special", Key = "Number", Normal = "Normal", Terminal = "SnacksDashboardNormal", Special = "Special", Title = "Title", } local hl_groups = {} ---@type table for group in pairs(links) do hl_groups[group:lower()] = "SnacksDashboard" .. group end Snacks.util.set_hl(links, { prefix = "SnacksDashboard", default = true }) ---@class snacks.dashboard.Opts: snacks.dashboard.Config ---@field buf? number the buffer to use. If not provided, a new buffer will be created ---@field win? number the window to use. If not provided, a new floating window will be created ---@class snacks.dashboard.Class ---@field opts snacks.dashboard.Opts ---@field buf number ---@field win number ---@field _size? {width:number, height:number} ---@field items snacks.dashboard.Item[] ---@field row? number ---@field col? number ---@field panes? snacks.dashboard.Item[][] ---@field lines? string[] ---@field augroup integer local D = {} ---@param opts? snacks.dashboard.Opts ---@return snacks.dashboard.Class function M.open(opts) local self = setmetatable({}, { __index = D }) self.opts = Snacks.config.get("dashboard", defaults, opts) --[[@as snacks.dashboard.Opts]] self.buf = self.opts.buf or vim.api.nvim_create_buf(false, true) self.buf = self.buf == 0 and vim.api.nvim_get_current_buf() or self.buf self.win = self.opts.win or Snacks.win({ style = "dashboard", buf = self.buf, enter = true }).win --[[@as number]] self.win = self.win == 0 and vim.api.nvim_get_current_win() or self.win self.augroup = vim.api.nvim_create_augroup("snacks_dashboard", { clear = true }) self:init() self:update() self.fire("Opened") return self end ---@param name? string function D:trace(name) return self.opts.debug and Snacks.debug.trace(name and ("dashboard:" .. name) or nil) end function D:init() vim.api.nvim_win_set_buf(self.win, self.buf) vim.o.ei = "all" Snacks.util.wo(self.win, Snacks.config.styles.dashboard.wo) Snacks.util.bo(self.buf, Snacks.config.styles.dashboard.bo) vim.b[self.buf].snacks_main = true vim.o.ei = "" if self:is_float() then vim.keymap.set("n", "", "bd", { silent = true, buffer = self.buf }) end vim.keymap.set("n", "q", "bd", { silent = true, buffer = self.buf }) vim.api.nvim_create_autocmd({ "WinResized", "VimResized" }, { group = self.augroup, callback = function() -- only re-render if the size has changed if not vim.deep_equal(self._size, self:size()) then self:update() end end, }) vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, { buffer = self.buf, callback = function() self.fire("Closed") vim.api.nvim_del_augroup_by_id(self.augroup) end, }) vim.api.nvim_create_autocmd("WinEnter", { group = self.augroup, callback = function(ev) if ev.buf == self.buf and not vim.api.nvim_win_is_valid(self.win) then self.win = vim.fn.bufwinid(self.buf) self:update() end end, }) self.on("Update", function() self:update() end, self.augroup) end ---@return {width:number, height:number} function D:size() return { width = vim.api.nvim_win_get_width(self.win), height = vim.api.nvim_win_get_height(self.win) + (vim.o.laststatus >= 2 and 1 or 0), } end function D:is_float() return vim.api.nvim_win_get_config(self.win).relative ~= "" end ---@param action snacks.dashboard.Action function D:action(action) -- close the window before running the action if it's floating if self:is_float() then vim.api.nvim_win_close(self.win, true) self.win = nil end if type(action) == "string" then if action:find("^:") then return vim.cmd(action:sub(2)) else local keys = vim.api.nvim_replace_termcodes(action, true, true, true) return vim.api.nvim_feedkeys(keys, "tm", true) end end action(self) end ---@param item snacks.dashboard.Item ---@param field string ---@param width? number ---@return snacks.dashboard.Text|snacks.dashboard.Text[] function D:format_field(item, field, width) if type(item[field]) == "table" then return item[field] end local format = self.opts.formats[field] if format == nil then return { item[field], hl = field } elseif type(format) == "function" then return format(item, { width = width }) else local text = format and vim.deepcopy(format) or { "%s" } text.hl = text.hl or field text[1] = text[1] == "%s" and item[field] or text[1]:format(item[field]) return text end end ---@param item snacks.dashboard.Text|snacks.dashboard.Line ---@param width? number ---@param align? "left"|"center"|"right" function D:align(item, width, align) local len = 0 if type(item[1]) == "string" then ---@cast item snacks.dashboard.Text width, align, len = width or item.width, align or item.align, vim.api.nvim_strwidth(item[1]) else ---@cast item snacks.dashboard.Line if #item == 1 then -- only one text, so align that instead self:align(item[1], width, align) item.width = item[1].width return end len = item.width end if not width or width <= 0 or width == len then item.width = math.max(width or 0, len) return end align = align or "left" local before = align == "center" and math.floor((width - len) / 2) or align == "right" and width - len or 0 local after = align == "center" and width - len - before or align == "left" and width - len or 0 if type(item[1]) == "string" then ---@cast item snacks.dashboard.Text item[1] = (" "):rep(before) .. item[1] .. (" "):rep(after) item.width = math.max(width, len) else ---@cast item snacks.dashboard.Line if before > 0 then table.insert(item, 1, { (" "):rep(before) }) end if after > 0 then table.insert(item, { (" "):rep(after) }) end item.width = math.max(width, len) end end ---@param texts snacks.dashboard.Text[]|snacks.dashboard.Text|string function D:texts(texts) texts = type(texts) == "string" and { { texts } } or texts texts = type(texts[1]) == "string" and { texts } or texts return texts --[[ @as snacks.dashboard.Text[] ]] end --- Create a block from a list of texts (possibly with newlines) ---@param texts snacks.dashboard.Text[] function D:block(texts) local ret = { { width = 0 }, width = 0 } ---@type snacks.dashboard.Block for _, text in ipairs(texts) do -- PERF: only split lines when needed local lines = text[1]:find("\n", 1, true) and vim.split(text[1], "\n", { plain = true }) or { text[1] } for l, line in ipairs(lines) do if l > 1 then ret[#ret + 1] = { width = 0 } end local child = setmetatable({ line }, { __index = text }) self:align(child) ret[#ret].width = ret[#ret].width + vim.api.nvim_strwidth(child[1]) ret.width = math.max(ret.width, ret[#ret].width) table.insert(ret[#ret], child) end end return ret end ---@param item snacks.dashboard.Item function D:format(item) local width = item.indent or 0 ---@param fields string[] ---@param opts {align?:"left"|"center"|"right", padding?:number, flex?:boolean, multi?:boolean} local function find(fields, opts) local flex = opts.flex and math.max(0, self.opts.width - width) or nil local texts = {} ---@type snacks.dashboard.Text[] for _, k in ipairs(fields) do if item[k] then vim.list_extend(texts, self:texts(self:format_field(item, k, flex))) if not opts.multi then break end end end if #texts == 0 then return { width = 0 } end local block = self:block(texts) block.width = block.width + (opts.padding or 0) width = width + block.width return block end local block = item.text and self:block(self:texts(item.text)) local left = block and { width = 0 } or find({ "icon" }, { align = "left", padding = 1 }) local right = block and { width = 0 } or find({ "label", "key" }, { align = "right", padding = 1 }) local center = block or find({ "header", "footer", "title", "desc", "file" }, { flex = true, multi = true }) local padding = self:padding(item) local ret = { width = self.opts.width } ---@type snacks.dashboard.Block for l = 1, math.max(#left, #center, #right, 1) + padding[1] do ret[l] = { width = 0 } left[l] = left[l] or { width = 0 } right[l] = right[l] or { width = 0 } center[l] = center[l] or { width = 0 } self:align(left[l], left.width, "left") if item.indent then self:align(left[l], left[l].width + item.indent, "right") end self:align(right[l], right.width, "right") self:align(center[l], self.opts.width - left[l].width - right[l].width, item.align) vim.list_extend(ret[l], left[l]) vim.list_extend(ret[l], center[l]) vim.list_extend(ret[l], right[l]) ret[l].width = left[l].width + center[l].width + right[l].width end for _ = 1, padding[2] do table.insert(ret, 1, { width = self.opts.width }) end return ret end ---@param item snacks.dashboard.Item function D:enabled(item) local e = item.enabled if type(e) == "function" then return e(self.opts) end return e == nil or e end ---@param item snacks.dashboard.Section? ---@param results? snacks.dashboard.Item[] ---@param parent? snacks.dashboard.Item function D:resolve(item, results, parent) results = results or {} if not item then return results end if type(item) == "table" and vim.tbl_isempty(item) then return results end if type(item) == "table" and parent then -- inherit parent properties for _, prop in ipairs({ "indent", "align", "pane" }) do item[prop] = item[prop] or parent[prop] end end if type(item) == "function" then return self:resolve(item(self), results, parent) elseif type(item) == "table" and self:enabled(item) then if not item.section and not item[1] then table.insert(results, item) return results end local first_child = #results + 1 if item.section then -- add section items self:trace("resolve." .. item.section) local items = M.sections[item.section](item) ---@type snacks.dashboard.Section? self:resolve(items, results, item) self:trace() end if item[1] then -- add child items for _, child in ipairs(item) do self:resolve(child, results, item) end end -- add the title if there are child items if #results >= first_child and item.title then table.insert(results, first_child, { title = item.title, icon = item.icon, pane = item.pane, action = item.action, key = item.key, label = item.label, }) item.action = nil item.label = nil item.key = nil first_child = first_child + 1 end -- correct first/last taking hidden items into account local first, last = first_child, #results for c = first_child, #results do first = first or not results[c].hidden and c or nil last = not results[c].hidden and c or last end if item.gap then -- add padding between child items for i = first, last - 1 do results[i].padding = item.gap end end if item.padding then -- add padding to the first and last child items local padding = self:padding(item) if padding[2] > 0 and results[first] then results[first].padding = { 0, padding[2] } end if padding[1] > 0 and results[last] then results[last].padding = { padding[1], 0 } end end elseif type(item) ~= "table" then Snacks.notify.error("Invalid item:\n```lua\n" .. vim.inspect(item) .. "\n```", { title = "Dashboard" }) end return results end ---@return {[1]: number, [2]: number} function D:padding(item) return item.padding and (type(item.padding) == "table" and item.padding or { item.padding, 0 }) or { 0, 0 } end function D.fire(event) vim.api.nvim_exec_autocmds("User", { pattern = "SnacksDashboard" .. event, modeline = false }) end ---@param event string|string[] ---@param cb fun() ---@param group? string|integer function D.on(event, cb, group) return vim.api.nvim_create_autocmd("User", { pattern = "SnacksDashboard" .. event, callback = cb, group = group }) end ---@param pos {[1]:number, [2]:number} ---@param from? {[1]:number, [2]:number} function D:find(pos, from) from = from or pos local line = self.lines[pos[1]] local char = vim.fn.charidx(line, pos[2]) -- map col to charachter index local pane = math.floor((char - self.col) / (self.opts.width + self.opts.pane_gap)) + 1 pane = math.max(1, math.min(pane, #self.panes)) if pos[1] == from[1] then if pos[2] == from[2] - 1 then pane = pane - 1 elseif pos[2] == from[2] + 1 then pane = pane + 1 end end pane = math.max(1, math.min(pane, #self.panes)) local ret ---@type snacks.dashboard.Item? for _, item in ipairs(self.items) do if item._ and item._.pane == pane and item.action then if ret and pos[1] < from[1] and item._.row > pos[1] then break end ret = item if pos[1] >= from[1] and item._.row >= pos[1] then break end end end return ret end -- Layout in panes function D:layout() local max_panes = math.max(1, math.floor((self._size.width + self.opts.pane_gap) / (self.opts.width + self.opts.pane_gap))) self.panes = {} ---@type snacks.dashboard.Item[][] for _, item in ipairs(self.items) do if not item.hidden then local pane = item.pane or 1 pane = math.fmod(pane - 1, max_panes) + 1 -- distribute panes evenly self.panes[pane] = self.panes[pane] or {} table.insert(self.panes[pane], item) end end for p = 1, math.max(unpack(vim.tbl_keys(self.panes))) or 1 do self.panes[p] = self.panes[p] or {} end end -- Format and render the dashboard function D:render() -- horizontal position self.col = self.opts.col or math.floor(self._size.width - (self.opts.width * #self.panes + self.opts.pane_gap * (#self.panes - 1))) / 2 self.lines = {} ---@type string[] local extmarks = {} ---@type {row:number, col:number, opts:vim.api.keyset.set_extmark}[] for p, pane in ipairs(self.panes) do local indent = (" "):rep(p == 1 and self.col or self.opts.pane_gap) local row = 0 for _, item in ipairs(pane or {}) do for l, line in ipairs(self:format(item)) do row = row + 1 if p > 1 and not self.lines[row] then -- add lines for empty panes self.lines[row] = (" "):rep(self.col + (self.opts.width + self.opts.pane_gap) * (p - 1)) elseif p == 1 and line.width > self.opts.width then self.lines[row] = (" "):rep(self.col - math.floor((line.width - self.opts.width) / 2)) else self.lines[row] = (self.lines[row] or "") .. indent end if l == 1 then item._ = { pane = p, row = row, col = #self.lines[row] - 1 } end ---@cast line snacks.dashboard.Line for _, text in ipairs(line) do self.lines[row] = self.lines[row] .. text[1] if text.hl then table.insert(extmarks, { row = row - 1, col = #self.lines[row] - #text[1], opts = { hl_group = hl_groups[text.hl] or text.hl, end_col = #self.lines[row] }, }) end end end end end -- vertical position self.row = self.opts.row or math.max(math.floor((self._size.height - #self.lines) / 2), 0) for _ = 1, self.row do table.insert(self.lines, 1, "") end -- fix item positions for _, item in ipairs(self.items) do if item._ then item._.row = item._.row + self.row if item.render then item.render(self, { item._.row, item._.col }) end end end self:render_buf(extmarks) end ---@param extmarks {row:number, col:number, opts:vim.api.keyset.set_extmark}[] function D:render_buf(extmarks) -- set lines vim.bo[self.buf].modifiable = true vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, self.lines) vim.bo[self.buf].modifiable = false -- extmarks vim.api.nvim_buf_clear_namespace(self.buf, M.ns, 0, -1) for _, extmark in ipairs(extmarks) do vim.api.nvim_buf_set_extmark(self.buf, M.ns, extmark.row + self.row, extmark.col, extmark.opts) end end function D:keys() local autokeys = self.opts.autokeys:gsub("[hjklq]", "") for _, item in ipairs(self.items) do if item.key and not item.autokey then autokeys = autokeys:gsub(vim.pesc(item.key), "", 1) end end for _, item in ipairs(self.items) do if item.autokey then item.key, autokeys = autokeys:sub(1, 1), autokeys:sub(2) end if item.key then vim.keymap.set("n", item.key, function() self:action(item.action) end, { buffer = self.buf, nowait = not item.autokey, desc = "Dashboard action" }) end end end function D:update() if not (self.buf and vim.api.nvim_buf_is_valid(self.buf)) then return end self.fire("UpdatePre") self._size = self:size() self.items = self:resolve(self.opts.sections) self:layout() self:keys() self:render() -- actions on enter vim.keymap.set("n", "", function() local item = self:find(vim.api.nvim_win_get_cursor(self.win)) return item and item.action and self:action(item.action) end, { buffer = self.buf, nowait = true, desc = "Dashboard action" }) -- cursor movement local last = { 1, 0 } local function update_cursor() local item = self:find(vim.api.nvim_win_get_cursor(self.win), last) -- can happen for panes without actionable items item = item or vim.tbl_filter(function(it) return it.action and it._ end, self.items)[1] if item then local col = self.lines[item._.row]:find("[%w%d%p]", item._.col + 1) col = col or (item._.col + 1 + (item.indent and (item.indent + 1) or 0)) last = { item._.row, (col or item._.col + 1) - 1 } end vim.api.nvim_win_set_cursor(self.win, last) end vim.api.nvim_create_autocmd("CursorMoved", { group = vim.api.nvim_create_augroup("snacks_dashboard_cursor", { clear = true }), buffer = self.buf, callback = update_cursor, }) update_cursor() self.fire("UpdatePost") end -- Get an icon ---@param name string ---@param cat? string ---@return snacks.dashboard.Text function M.icon(name, cat) local icon, hl = Snacks.util.icon(name, cat) return { icon or " ", hl = hl or "icon", width = 2 } end -- Used by the default preset to pick something ---@param cmd? string function M.pick(cmd, opts) cmd = cmd or "files" local config = Snacks.config.get("dashboard", defaults, opts) local picker = Snacks.picker.config.get() -- stylua: ignore local try = { function() return config.preset.pick(cmd, opts) end, function() return require("fzf-lua")[cmd](opts) end, function() return require("telescope.builtin")[cmd == "files" and "find_files" or cmd](opts) end, function() return require("mini.pick").builtin[cmd](opts) end, function() return Snacks.picker(cmd, opts) end, } if picker.enabled then table.insert(try, 2, table.remove(try, #try)) end for _, fn in ipairs(try) do if pcall(fn) then return end end Snacks.notify.error("No picker found for " .. cmd) end -- Checks if the plugin is installed. -- Only works with [lazy.nvim](https://github.com/folke/lazy.nvim) ---@param name string function M.have_plugin(name) return package.loaded.lazy and require("lazy.core.config").spec.plugins[name] ~= nil end ---@param opts? {filter?: table} ---@return fun():string? function M.oldfiles(opts) opts = vim.tbl_deep_extend("force", { filter = { [vim.fn.stdpath("data")] = false, [vim.fn.stdpath("cache")] = false, [vim.fn.stdpath("state")] = false, }, }, opts or {}) ---@cast opts {filter:table} local filter = {} ---@type {path:string, want:boolean}[] for path, want in pairs(opts.filter or {}) do table.insert(filter, { path = svim.fs.normalize(path), want = want }) end local done = {} ---@type table local i = 1 local oldfiles = vim.v.oldfiles return function() while oldfiles[i] do local file = svim.fs.normalize(oldfiles[i], { _fast = true, expand_env = false }) local want = not done[file] if want then done[file] = true for _, f in ipairs(filter) do local matches = file:sub(1, #f.path) == f.path and (file == f.path or file:sub(#f.path + 1, #f.path + 1):find("[/\\]") ~= nil) if matches ~= f.want then want = false break end end end i = i + 1 if want and uv.fs_stat(file) then return file end end end end M.sections = {} -- Adds a section to restore the session if any of the supported plugins are installed. ---@param item? snacks.dashboard.Item ---@return snacks.dashboard.Item? function M.sections.session(item) local plugins = { { "persistence.nvim", ":lua require('persistence').load()" }, { "persisted.nvim", ":lua require('persisted').load()" }, { "neovim-session-manager", ":SessionManager load_current_dir_session" }, { "possession.nvim", ":PossessionLoadCwd" }, { "mini.sessions", ":lua require('mini.sessions').read()" }, { "mini.nvim", ":lua require('mini.sessions').read()" }, { "auto-session", ":AutoSession restore" }, } for _, plugin in pairs(plugins) do if M.have_plugin(plugin[1]) then return setmetatable({ -- add the action and disable the section action = plugin[2], section = false, }, { __index = item }) end end end --- Get the most recent files, optionally filtered by the --- current working directory or a custom directory. ---@param opts? {limit?:number, cwd?:string|boolean, filter?:fun(file:string):boolean?} ---@return snacks.dashboard.Gen function M.sections.recent_files(opts) return function() opts = opts or {} local limit = opts.limit or 5 local root = opts.cwd and svim.fs.normalize(opts.cwd == true and vim.fn.getcwd() or opts.cwd) or nil -- Only filter by directory when root is specified. If nil, M.oldfiles will use default filters only (excludes stdpath data/cache/state). local oldfiles_opts = root and { filter = { [root] = true } } or nil local ret = {} ---@type snacks.dashboard.Section for file in M.oldfiles(oldfiles_opts) do if not opts.filter or opts.filter(file) then ret[#ret + 1] = { file = file, icon = "file", action = ":e " .. vim.fn.fnameescape(file), autokey = true, } if #ret >= limit then break end end end return ret end end --- Get the most recent projects based on git roots of recent files. --- The default action will change the directory to the project root, --- try to restore the session and open the picker if the session is not restored. --- You can customize the behavior by providing a custom action. --- Use `opts.dirs` to provide a list of directories to use instead of the git roots. ---@param opts? {limit?:number, dirs?:(string[]|fun():string[]), pick?:boolean, session?:boolean, action?:fun(dir), filter?:fun(dir:string):boolean?} function M.sections.projects(opts) opts = vim.tbl_extend("force", { pick = true, session = true }, opts or {}) local limit = opts.limit or 5 local dirs = opts.dirs or {} dirs = type(dirs) == "function" and dirs() or dirs --[[ @as string[] ]] dirs = vim.list_slice(dirs, 1, limit) if not opts.dirs then for file in M.oldfiles() do local dir = Snacks.git.get_root(file) if dir and not vim.tbl_contains(dirs, dir) then if not opts.filter or opts.filter(dir) then table.insert(dirs, dir) if #dirs >= limit then break end end end end end local ret = {} ---@type snacks.dashboard.Item[] for _, dir in ipairs(dirs) do if not opts.filter or opts.filter(dir) then ret[#ret + 1] = { file = dir, icon = "directory", action = function(self) if opts.action then return opts.action(dir) end vim.fn.chdir(dir) local session = M.sections.session() -- stylua: ignore if opts.session and session then local session_loaded = false vim.api.nvim_create_autocmd("SessionLoadPost", { once = true, callback = function() session_loaded = true end }) vim.defer_fn(function() if not session_loaded and opts.pick then M.pick() end end, 100) self:action(session.action) elseif opts.pick then M.pick() end end, autokey = true, } end end return ret end ---@return snacks.dashboard.Gen function M.sections.header() return function(self) return { header = self.opts.preset.header, padding = 2 } end end ---@return snacks.dashboard.Gen function M.sections.keys() return function(self) return vim.deepcopy(self.opts.preset.keys) end end ---@param opts {cmd:string|string[], ttl?:number, height?:number, width?:number, random?:number}|snacks.dashboard.Item ---@return snacks.dashboard.Gen function M.sections.terminal(opts) return function(self) local cmd = opts.cmd or 'echo "No `cmd` provided"' if type(cmd) == "string" and vim.fn.has("linux") == 1 and not vim.o.shell:find("nu") then -- work-around for https://github.com/folke/snacks.nvim/issues/1706 -- jobstart+pty sometimes doesn't flush the full output before exiting cmd = cmd .. "; sleep .1" end local ttl = opts.ttl or 3600 local height, width = opts.height or 10, opts.width or (self.opts.width - (opts.indent or 0)) local hl = opts.hl and hl_groups[opts.hl] or opts.hl or "SnacksDashboardTerminal" local cache_buf, term_buf, win ---@type integer?, integer?, integer? local cache_parts = { table.concat(type(cmd) == "table" and cmd or { cmd }, " "), uv.cwd(), opts.random and math.random(1, opts.random) or "", } local cache_dir = vim.fn.stdpath("cache") .. "/snacks" local cache_file = ("%s/%s.txt"):format(cache_dir, vim.fn.sha256(table.concat(cache_parts, "."))) local stat = uv.fs_stat(cache_file) local has_cache = stat and stat.type == "file" and stat.size > 0 local is_expired = has_cache and stat and os.time() - stat.mtime.sec >= ttl if has_cache and stat then -- show cached output cache_buf = vim.api.nvim_create_buf(false, true) vim.bo[cache_buf].buftype = "nofile" local fin = assert(uv.fs_open(cache_file, "r", 438)) vim.api.nvim_chan_send(vim.api.nvim_open_term(cache_buf, {}), uv.fs_read(fin, stat.size, 0) or "") uv.fs_close(fin) -- -- HACK: without this, some lines may not show up in the terminal buffer vim.bo[cache_buf].scrollback = 9999 vim.bo[cache_buf].scrollback = 9998 end ---@param buf integer local function show(buf) if win and vim.api.nvim_win_is_valid(win) then vim.api.nvim_win_set_buf(win, buf) Snacks.util.wo(win, { winhighlight = "TermCursorNC:" .. hl .. ",NormalFloat:" .. hl }) Snacks.util.bo(buf, { filetype = Snacks.config.styles.dashboard.bo.filetype }) end end local job ---@type snacks.Job? if not has_cache or is_expired then term_buf = vim.api.nvim_create_buf(false, true) local term_ready = false local output = {} ---@type string[] local recording = vim.defer_fn(function() output = {} show(term_buf) end, 5000) --[[@as uv.uv_timer_t]] local Job = require("snacks.util.job") job = Job.new( term_buf, cmd, Snacks.config.merge({}, { start = false, term = true, width = width, height = height, on_stdout = function(_, data) if recording:is_active() then table.insert(output, table.concat(data, "\n")) end if not term_ready and job then local non_empty = #vim.tbl_filter(function(line) return line:match("%S") end, job.lines) if non_empty >= 3 then term_ready = true show(term_buf) end end end, on_exit = function(_, code) if job and job.killed then return end show(term_buf) if recording:is_active() and code == 0 and ttl > 0 then -- save the output vim.fn.mkdir(cache_dir, "p") local fout = assert(uv.fs_open(cache_file, "w", 438)) local data = table.concat(output, "") uv.fs_write(fout, data, 0) uv.fs_close(fout) end end, }) ) end return { action = not opts.title and opts.action or nil, key = not opts.title and opts.key or nil, label = not opts.title and opts.label or nil, render = function(_, pos) self:trace("terminal.render") -- open the window with the terminal buffer if available. -- This is to ensure it starts with the correct window size. win = vim.api.nvim_open_win(assert(term_buf or cache_buf), false, { bufpos = { pos[1] - 1, pos[2] + 1 }, col = opts.indent or 0, focusable = false, height = height, noautocmd = true, relative = "win", row = 0, zindex = Snacks.config.styles.dashboard.zindex + 1, style = "minimal", width = width, win = self.win, border = "none", }) if job then -- start the job if needed job:start() end show(assert(cache_buf or term_buf)) -- set the correct buffer local close = vim.schedule_wrap(function() if job then job:stop() end pcall(vim.api.nvim_win_close, win, true) pcall(vim.api.nvim_buf_delete, cache_buf, { force = true }) pcall(vim.api.nvim_buf_delete, term_buf, { force = true }) return true end) self.on("UpdatePre", close, self.augroup) self.on("Closed", close, self.augroup) self:trace() end, text = ("\n"):rep(height - 1), } end end --- Add the startup section ---@param opts? {icon?:string} ---@return snacks.dashboard.Section? function M.sections.startup(opts) opts = opts or {} M.lazy_stats = M.lazy_stats and M.lazy_stats.startuptime > 0 and M.lazy_stats or require("lazy.stats").stats() local ms = (math.floor(M.lazy_stats.startuptime * 100 + 0.5) / 100) local icon = opts.icon or "⚡ " return { align = "center", text = { { icon .. "Neovim loaded ", hl = "footer" }, { M.lazy_stats.loaded .. "/" .. M.lazy_stats.count, hl = "special" }, { " plugins in ", hl = "footer" }, { ms .. "ms", hl = "special" }, }, } end M.status = { did_setup = false, opened = false, reason = nil, ---@type string? } --- Check if the dashboard should be opened function M.setup() local explorer = Snacks.config.get("explorer", defaults).enabled == true M.status.did_setup = true local buf = 1 local skip = false if explorer and vim.fn.argc(-1) == 1 then local arg = vim.fn.argv(0) --[[@as string]] if arg ~= "" and vim.fn.isdirectory(arg) == 1 then skip = true end end -- don't open the dashboard if there are any arguments if not skip and vim.fn.argc(-1) > 0 then M.status.reason = "argc(-1) > 0" return end -- don't open dashboard if Neovim was invoked for example `nvim +'Octo issue edit 1'` if not skip and vim.api.nvim_buf_get_name(0) ~= "" then M.status.reason = "buffer has a name" return end -- there should be only one non-floating window and it should be the first buffer local wins = vim.tbl_filter(function(win) local b = vim.api.nvim_win_get_buf(win) return vim.api.nvim_win_get_config(win).relative == "" and not vim.bo[b].filetype:find("snacks") end, vim.api.nvim_list_wins()) if #wins ~= 1 then M.status.reason = "more than one non-floating window" return elseif vim.api.nvim_win_get_buf(wins[1]) ~= buf then M.status.reason = "window does not contain the first buffer" return end if vim.bo[buf].modified then M.status.reason = "buffer is modified" return end local uis = vim.api.nvim_list_uis() -- check for headless if #uis == 0 then M.status.reason = "headless" return end -- don't open the dashboard if in TUI and input is piped if uis[1].stdout_tty and not uis[1].stdin_tty then M.status.reason = "stdin is not a tty" return end -- don't open the dashboard if there is any text in the buffer if vim.api.nvim_buf_line_count(buf) > 1 or #(vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or "") > 0 then M.status.reason = "buffer is not empty" return end M.status.opened = true if Snacks.config.dashboard.debug then Snacks.debug.tracemod("dashboard", M) Snacks.debug.tracemod("dashboard", D, ":") end local options = { showtabline = vim.o.showtabline, laststatus = vim.o.laststatus } vim.o.showtabline, vim.o.laststatus = 0, 0 local dashboard = M.open({ buf = buf, win = wins[1] }) local function restore() local view = vim.fn.winsaveview() for k, v in pairs(options) do if vim.o[k] == 0 and v ~= 0 then vim.o[k] = v end end options = {} vim.fn.winrestview(view) end restore = vim.schedule_wrap(restore) D.on("Closed", restore, dashboard.augroup) vim.api.nvim_create_autocmd("WinEnter", { group = dashboard.augroup, callback = function() local win = vim.api.nvim_get_current_win() local is_float = vim.api.nvim_win_get_config(win).relative ~= "" if win ~= dashboard.win and not is_float then restore() end end, }) if Snacks.config.dashboard.debug then Snacks.debug.stats({ min = 0.2 }) end end -- Update the dashboard function M.update() D.fire("Update") end function M.health() if Snacks.config.dashboard.enabled then if M.status.did_setup then Snacks.health.ok("setup ran") if M.status.opened then Snacks.health.ok("dashboard opened") else Snacks.health.warn("dashboard did not open: `" .. M.status.reason .. "`") end else Snacks.health.error("setup did not run") end local modnames = { "alpha", "dashboard", "mini.starter" } for _, modname in ipairs(modnames) do if package.loaded[modname] then Snacks.health.error("`" .. modname .. "` conflicts with `Snacks.dashboard`") end end end end M.Dashboard = D return M ================================================ FILE: lua/snacks/debug.lua ================================================ ---@class snacks.debug ---@overload fun(...) local M = setmetatable({}, { __call = function(t, ...) return t.inspect(...) end, }) M.meta = { desc = "Pretty inspect & backtraces for debugging", } ---@class snacks.debug.cmd ---@field cmd string|string[] ---@field level? snacks.notifier.level|vim.log.levels ---@field title? string ---@field args? string[] ---@field cwd? string ---@field group? boolean ---@field notify? boolean ---@field footer? string ---@field header? string ---@field props? table local uv = vim.uv or vim.loop local MAX_INSPECT_LINES = 2000 vim.schedule(function() Snacks.util.set_hl({ Indent = "LineNr", Print = "NonText", }, { prefix = "SnacksDebug", default = true }) end) -- Show a notification with a pretty printed dump of the object(s) -- with lua treesitter highlighting and the location of the caller function M.inspect(...) local len = select("#", ...) ---@type number local obj = { ... } ---@type unknown[] local caller = debug.getinfo(1, "S") for level = 2, 10 do local info = debug.getinfo(level, "S") if info and info.source ~= caller.source and info.what ~= "C" and info.source ~= "lua" and info.source ~= "@" .. (os.getenv("MYVIMRC") or "") then caller = info break end end vim.schedule(function() local title = "Debug: " .. vim.fn.fnamemodify(caller.source:sub(2), ":~:.") .. ":" .. caller.linedefined local lines = vim.split(vim.inspect(len == 1 and obj[1] or len > 0 and obj or nil), "\n") if #lines > MAX_INSPECT_LINES then local c = #lines lines = vim.list_slice(lines, 1, MAX_INSPECT_LINES) lines[#lines + 1] = "" lines[#lines + 1] = (c - MAX_INSPECT_LINES) .. " more lines have been truncated …" end Snacks.notify.warn(lines, { title = title, ft = "lua" }) end) end --- Run the current buffer or a range of lines. --- Shows the output of `print` inlined with the code. --- Any error will be shown as a diagnostic. ---@param opts? {name?:string, buf?:number, print?:boolean} function M.run(opts) local ns = vim.api.nvim_create_namespace("snacks_debug") opts = vim.tbl_extend("force", { print = true }, opts or {}) local buf = opts.buf or 0 buf = buf == 0 and vim.api.nvim_get_current_buf() or buf local name = opts.name or vim.fn.fnamemodify(vim.api.nvim_buf_get_name(buf), ":t") -- Get the lines to run local lines ---@type string[] local mode = vim.fn.mode() if mode:find("[vV]") then if mode == "v" then vim.cmd("normal! v") elseif mode == "V" then vim.cmd("normal! V") end local from = vim.api.nvim_buf_get_mark(buf, "<") local to = vim.api.nvim_buf_get_mark(buf, ">") -- for some reason, sometimes the column is off by one -- see: https://github.com/folke/snacks.nvim/issues/190 local col_to = math.min(to[2] + 1, #vim.api.nvim_buf_get_lines(buf, to[1] - 1, to[1], false)[1]) lines = vim.api.nvim_buf_get_text(buf, from[1] - 1, from[2], to[1] - 1, col_to, {}) -- Insert empty lines to keep the line numbers for _ = 1, from[1] - 1 do table.insert(lines, 1, "") end vim.fn.feedkeys("gv", "nx") elseif mode == "\22" then -- Yank the visual selection to handle irregularly shaped blocks local tmp = vim.fn.getreginfo("*") vim.cmd('normal! "*y') lines = vim.fn.getreginfo("*").regcontents vim.fn.setreg("*", tmp.regcontents, tmp.regtype) -- Insert empty lines to keep the line numbers local from = vim.api.nvim_buf_get_mark(buf, "<") for _ = 1, from[1] - 1 do table.insert(lines, 1, "") end -- Restore the selection vim.fn.feedkeys("gv", "nx") else lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) end -- Clear diagnostics and extmarks local function reset() vim.diagnostic.reset(ns, buf) vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) end reset() vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { group = vim.api.nvim_create_augroup("snacks_debug_run_" .. buf, { clear = true }), buffer = buf, callback = reset, }) -- Get the line number from the msg or stack local function get_line(msg) local line = msg and msg:match("^" .. vim.pesc(name) .. ":(%d+):") if line then return line end for level = 2, 20 do local info = debug.getinfo(level, "Sln") if info and info.source == "@" .. name then return info.currentline end end end -- Error handler local function on_error(err) local line = get_line(err) if line then vim.diagnostic.set(ns, buf, { { col = 0, lnum = line - 1, message = err, severity = vim.diagnostic.severity.ERROR }, }) end M.backtrace({ err, "" }, { title = "Error in " .. name, level = vim.log.levels.ERROR }) end -- Print handler local function on_print(...) local str = table.concat( vim.tbl_map(function(v) return type(v) == "string" and v or vim.inspect(v) end, { ... }), " " ) ---@type string[][][] local virt_lines = {} for _, line in ipairs(vim.split(str, "\n", { plain = true })) do table.insert(virt_lines, { { " │ ", "SnacksDebugIndent" }, { line, "SnacksDebugPrint" } }) end local line = (get_line() or 1) - 1 vim.schedule(function() vim.api.nvim_buf_set_extmark(buf, ns, line, 0, { virt_lines = virt_lines, }) end) end -- Load the code local chunk, err = load(table.concat(lines, "\n"), "@" .. name) if not chunk then return on_error(err) end -- Setup the env local env = { print = opts.print and on_print or nil } package.seeall(env) setfenv(chunk, env) xpcall(chunk, function(e) on_error(e) end) end -- Show a notification with a pretty backtrace ---@param msg? string|string[] ---@param opts? snacks.notify.Opts function M.backtrace(msg, opts) opts = vim.tbl_deep_extend("force", { level = vim.log.levels.WARN, title = "Backtrace", }, opts or {}) ---@type string[] local trace = type(msg) == "table" and msg or type(msg) == "string" and { msg } or {} for level = 2, 20 do local info = debug.getinfo(level, "Sln") if info and info.what ~= "C" and info.source ~= "lua" and not info.source:find("snacks[/\\]debug") then local line = "- `" .. vim.fn.fnamemodify(info.source:sub(2), ":p:~:.") .. "`:" .. info.currentline if info.name then line = line .. " _in_ **" .. info.name .. "**" end table.insert(trace, line) end end Snacks.notify(#trace > 0 and (table.concat(trace, "\n")) or "", opts) end -- Very simple function to profile a lua function. -- * **flush**: set to `true` to use `jit.flush` in every iteration. -- * **count**: defaults to 100 ---@param fn fun() ---@param opts? {count?: number, flush?: boolean, title?: string} function M.profile(fn, opts) opts = vim.tbl_extend("force", { count = 100, flush = true }, opts or {}) local start = uv.hrtime() for _ = 1, opts.count, 1 do if opts.flush then jit.flush(fn, true) end fn() end Snacks.notify(((uv.hrtime() - start) / 1e6 / opts.count) .. "ms", { title = opts.title or "Profile" }) end -- Log a message to the file `./debug.log`. -- - a timestamp will be added to every message. -- - accepts multiple arguments and pretty prints them. -- - if the argument is not a string, it will be printed using `vim.inspect`. -- - if the message is smaller than 120 characters, it will be printed on a single line. -- -- ```lua -- Snacks.debug.log("Hello", { foo = "bar" }, 42) -- -- 2024-11-08 08:56:52 Hello { foo = "bar" } 42 -- ``` function M.log(...) local file = "./debug.log" local fd = io.open(file, "a+") if not fd then error(("Could not open file %s for writing"):format(file)) end local c = select("#", ...) local parts = {} ---@type string[] for i = 1, c do local v = select(i, ...) parts[i] = type(v) == "string" and v or vim.inspect(v) end local msg = table.concat(parts, " ") msg = #msg < 120 and msg:gsub("%s+", " ") or msg fd:write(os.date("%Y-%m-%d %H:%M:%S ") .. msg) fd:write("\n") fd:close() end ---@alias snacks.debug.Trace {name: string, time: number, [number]:snacks.debug.Trace} ---@alias snacks.debug.Stat {name:string, time:number, count?:number, depth?:number} ---@type snacks.debug.Trace[] M._traces = { { name = "__TOP__", time = 0 } } ---@param name string? function M.trace(name) if name then local entry = { name = name, time = uv.hrtime() } ---@type snacks.debug.Trace table.insert(M._traces[#M._traces], entry) table.insert(M._traces, entry) return entry else local entry = assert(table.remove(M._traces), "trace not ended?") ---@type snacks.debug.Trace entry.time = uv.hrtime() - entry.time return entry end end ---@param modname string ---@param mod? table ---@param suffix? string function M.tracemod(modname, mod, suffix) mod = mod or require(modname) suffix = suffix or "." for k, v in pairs(mod) do if type(v) == "function" and k ~= "trace" then mod[k] = function(...) M.trace(modname .. suffix .. k) local ok, ret = pcall(v, ...) M.trace() return ok == false and error(ret) or ret end end end end ---@param opts? {min?: number, show?:boolean} ---@return {summary:table, trace:snacks.debug.Stat[], traces:snacks.debug.Trace[]} function M.stats(opts) opts = opts or {} local stack, lines, trace = {}, {}, {} ---@type string[], string[], snacks.debug.Stat[] local summary = {} ---@type table ---@param stat snacks.debug.Trace local function collect(stat) if #stack > 0 then local recursive = vim.list_contains(stack, stat.name) summary[stat.name] = summary[stat.name] or { time = 0, count = 0, name = stat.name } summary[stat.name].time = summary[stat.name].time + (recursive and 0 or stat.time) summary[stat.name].count = summary[stat.name].count + 1 table.insert(trace, { name = stat.name, time = stat.time or 0, depth = #stack - 1 }) end table.insert(stack, stat.name) for _, entry in ipairs(stat) do collect(entry) end table.remove(stack) end collect(M._traces[1]) ---@param entries snacks.debug.Stat[] local function add(entries) for _, stat in ipairs(entries) do local ms = math.floor(stat.time / 1e4) / 1e2 if ms >= (opts.min or 0) then local line = ("%s- `%s`: **%.2f**ms"):format((" "):rep(stat.depth or 0), stat.name, ms) table.insert(lines, line .. (stat.count and (" ([%d])"):format(stat.count) or "")) end end end if opts.show ~= false then lines[#lines + 1] = "# Summary" summary = vim.tbl_values(summary) table.sort(summary, function(a, b) return a.time > b.time end) add(summary) lines[#lines + 1] = "\n# Trace" add(trace) Snacks.notify.warn(lines, { title = "Traces" }) end return { summary = summary, trace = trace, tree = M._traces } end function M.size(bytes) local sizes = { "B", "KB", "MB", "GB", "TB" } local s = 1 while bytes > 1024 and s < #sizes do bytes = bytes / 1024 s = s + 1 end return ("%.2f%s"):format(bytes, sizes[s]) end function M.metrics() collectgarbage("collect") local lines = {} ---@type string[] local function add(name, value) lines[#lines + 1] = ("- **%s**: %s"):format(name, value) end add("lua", M.size(collectgarbage("count") * 1024)) for _, stat in ipairs({ "get_total_memory", "get_free_memory", "get_available_memory", "resident_set_memory" }) do add(stat:gsub("get_", ""):gsub("_", " "), M.size(uv[stat]())) end lines[#lines + 1] = ("```lua\n%s\n```"):format(vim.inspect(uv.getrusage())) Snacks.notify.warn(lines, { title = "Metrics" }) end ---@param opts snacks.debug.cmd function M.cmd(opts) local cmd = opts.cmd local args = vim.deepcopy(opts.args or {}) if type(cmd) == "table" then vim.list_extend(args, cmd, 2) cmd = cmd[1] end args = vim.tbl_map(tostring, args) ---@cast cmd string local lines = { cmd } ---@type string[] for _, arg in ipairs(args or {}) do arg = arg:find("[%$%s%?]") and vim.fn.shellescape(arg) or arg if #arg + #lines[#lines] > 40 then lines[#lines] = lines[#lines] .. " \\" table.insert(lines, " " .. arg) else lines[#lines] = lines[#lines] .. " " .. arg end end local props = vim.deepcopy(opts.props or {}) props.cwd = props.cwd or vim.fn.fnamemodify(opts.cwd or uv.cwd() or ".", ":~") local prop_keys = vim.tbl_keys(props) ---@type string[] table.sort(prop_keys) local prop_lines = {} ---@type string[] for _, key in ipairs(prop_keys) do table.insert(prop_lines, ("- **%s**: %s"):format(key, props[key])) end local id = cmd or "cmd" lines = { opts.header or "", table.concat(prop_lines, "\n"), "```sh", table.concat(lines, " \n"), "```", opts.footer or "", } if opts.title and not opts.notify then table.insert(lines, 1, ("# %s\n"):format(opts.title)) end local msg = vim.trim(table.concat(lines, "\n")):gsub("\n\n+", "\n\n") if opts.notify ~= false then Snacks.notify(msg, { id = opts.group and ("snacks.debug.cmd." .. id) or nil, level = opts.level or vim.log.levels.INFO, title = opts.title or "Cmd Debug", }) end return msg end return M ================================================ FILE: lua/snacks/dim.lua ================================================ ---@class snacks.dim ---@overload fun(opts: snacks.dim.Config) local M = setmetatable({}, { __call = function(M, ...) return M.enable(...) end, }) M.meta = { desc = "Focus on the active scope by dimming the rest", } ---@class snacks.dim.Config local defaults = { ---@type snacks.scope.Config scope = { min_size = 5, max_size = 20, siblings = true, }, -- animate scopes. Enabled by default for Neovim >= 0.10 -- Works on older versions but has to trigger redraws during animation. ---@type snacks.animate.Config|{enabled?: boolean} animate = { enabled = vim.fn.has("nvim-0.10") == 1, easing = "outQuad", duration = { step = 20, -- ms per step total = 300, -- maximum duration }, }, -- what buffers to dim filter = function(buf) return vim.g.snacks_dim ~= false and vim.b[buf].snacks_dim ~= false and vim.bo[buf].buftype == "" end, } M.enabled = false local ns = vim.api.nvim_create_namespace("snacks_dim") local scopes ---@type snacks.scope.Listener? local scopes_anim = {} ---@type table Snacks.util.set_hl({ [""] = "DiagnosticUnnecessary", }, { prefix = "SnacksDim", default = true }) --- Called during every redraw cycle, so it should be fast. --- Everything that can be cached should be cached. ---@param win number ---@param buf number ---@param top number -- 1-indexed ---@param bottom number -- 1-indexed ---@private function M.on_win(win, buf, top, bottom) local scope = scopes and scopes:get(win) if not scope then return end local function add(l) vim.api.nvim_buf_set_extmark(buf, ns, l - 1, 0, { end_row = l, end_col = 0, hl_group = "SnacksDim", ephemeral = true, }) end local animating = Snacks.animate.enabled({ buf = buf, name = "dim" }) local from = animating and scopes_anim[win] and scopes_anim[win].from or scope.from local to = animating and scopes_anim[win] and scopes_anim[win].to or scope.to for l = top, math.min(from - 1, bottom) do add(l) end for l = math.max(to + 1, top), bottom do add(l) end end ---@param opts? snacks.dim.Config function M.enable(opts) if M.enabled then return end opts = Snacks.config.get("dim", defaults, opts) M.enabled = true vim.g.snacks_animate_dim = opts.animate.enabled -- setup decoration provider vim.api.nvim_set_decoration_provider(ns, { on_win = function(_, win, buf, top, bottom) if M.enabled and opts.filter(buf) then M.on_win(win, buf, top + 1, bottom + 1) end end, }) scopes = scopes or Snacks.scope.attach(function(win, buf, scope) if not Snacks.animate.enabled({ buf = buf, name = "dim" }) then Snacks.util.redraw(win) else if not (scopes_anim[win] and scopes_anim[win].buf == buf) then local info = vim.fn.getwininfo(win)[1] scopes_anim[win] = { from = info.topline, to = info.botline, buf = buf, } end if scope == nil then return end Snacks.animate(scopes_anim[win].from, scope.from, function(v) if not scopes_anim[win] or not vim.api.nvim_win_is_valid(win) then return end scopes_anim[win].from = v Snacks.util.redraw(win) end, vim.tbl_extend("keep", { int = true, id = "snacks_dim_from_" .. win, buf = buf }, opts.animate)) Snacks.animate(scopes_anim[win].to, scope.to, function(v) if not scopes_anim[win] or not vim.api.nvim_win_is_valid(win) then return end scopes_anim[win].to = v Snacks.util.redraw(win) end, vim.tbl_extend("keep", { int = true, id = "snacks_dim_to_" .. win, buf = buf }, opts.animate)) end end, opts.scope) if not scopes.enabled then scopes:enable() end end -- Disable dimming function M.disable() if not M.enabled then return end M.enabled = false if scopes and scopes.enabled then scopes:disable() end scopes_anim = {} vim.cmd([[redraw!]]) end return M ================================================ FILE: lua/snacks/explorer/actions.lua ================================================ local Git = require("snacks.explorer.git") local Tree = require("snacks.explorer.tree") ---@class snacks.explorer.diagnostic.Action: snacks.picker.Action ---@field severity? number ---@field up? boolean local uv = vim.uv or vim.loop local M = {} ---@param path string function M.get_trash_cmds(path) ---@type string[][] local ret = { { "trash", path }, -- trash-cli (Python or Node.js) { "gio", "trash", path }, -- Most universally available on modern Linux { "kioclient5", "move", path, "trash:/" }, -- KDE Plasma 5 { "kioclient", "move", path, "trash:/" }, -- KDE Plasma 6 } if vim.fn.has("win32") == 1 then ret[#ret + 1] = { "powershell", "-NoProfile", "-Command", ( "Add-Type -AssemblyName Microsoft.VisualBasic; " .. "[Microsoft.VisualBasic.FileIO.FileSystem]::" .. (vim.fn.isdirectory(path) == 0 and "DeleteFile" or "DeleteDirectory") .. "('%s','OnlyErrorDialogs', 'SendToRecycleBin')" ):format(path:gsub("\\", "\\\\"):gsub("'", "''")), } end return ret end ---@param path string function M.trash(path) if Snacks.explorer.config.trash then for _, cmd in ipairs(M.get_trash_cmds(path)) do if vim.fn.executable(cmd[1]) == 1 then local ok, ret = pcall(vim.fn.system, cmd) if not ok or vim.v.shell_error ~= 0 then return false, ("- cmd: `%s`\n- error: %s"):format( table.concat(cmd, " "), type(ret) == "string" and ret or "Unknown error" ) end return true end end end -- Fallback to delete local ok, ret = pcall(vim.fn.delete, path, "rf") if not ok or ret ~= 0 then return false, type(ret) == "string" and ret or "Unknown error" end return true end ---@param picker snacks.Picker ---@param path string function M.reveal(picker, path) if picker.closed then return end for item, idx in picker:iter() do if item.file == path then picker.list:view(idx) return true end end end ---@param picker snacks.Picker ---@param opts? {target?: boolean|string, refresh?: boolean} function M.update(picker, opts) opts = opts or {} local cwd = picker:cwd() local target = type(opts.target) == "string" and opts.target or nil --[[@as string]] local refresh = opts.refresh or Tree:is_dirty(cwd, picker.opts) if target and not Tree:is_visible(cwd, target) then Tree:open(target) refresh = true end -- when searching, restore explorer view first if picker.input.filter.meta.searching then picker.input:set("", "") picker.list.win:focus() refresh = true end if not refresh and target then return M.reveal(picker, target) end if opts.target ~= false then picker.list:set_target() end picker:find({ on_done = function() if target then M.reveal(picker, target) end end, }) end ---@class snacks.explorer.actions ---@field [string] snacks.picker.Action.spec M.actions = {} function M.actions.explorer_focus(picker) picker:set_cwd(picker:dir()) picker:find() end function M.actions.explorer_open(_, item) if item then local _, err = vim.ui.open(item.file) if err then Snacks.notify.error("Failed to open `" .. item.file .. "`:\n- " .. err) end end end function M.actions.explorer_yank(picker) local files = {} ---@type string[] if vim.fn.mode():find("^[vV]") then picker.list:select() end for _, item in ipairs(picker:selected({ fallback = true })) do table.insert(files, Snacks.picker.util.path(item)) end picker.list:set_selected() -- clear selection local value = table.concat(files, "\n") vim.fn.setreg(vim.v.register or "+", value, "l") Snacks.notify.info("Yanked " .. #files .. " files") end function M.actions.explorer_up(picker) picker:set_cwd(vim.fs.dirname(picker:cwd())) picker:find() end function M.actions.explorer_close(picker, item) if not item then return end local dir = picker:dir() if item.dir and not item.open then dir = vim.fs.dirname(dir) end Tree:close(dir) M.update(picker, { target = dir, refresh = true }) end function M.actions.explorer_update(picker) Tree:refresh(picker:cwd()) M.update(picker) end function M.actions.explorer_close_all(picker) Tree:close_all(picker:cwd()) M.update(picker, { refresh = true }) end function M.actions.explorer_git_next(picker, item) local node = Git.next(picker:cwd(), item and item.file) if node then M.update(picker, { target = node.path }) end end function M.actions.explorer_paste(picker) local files = vim.split(vim.fn.getreg(vim.v.register or "+") or "", "\n", { plain = true }) files = vim.tbl_filter(function(file) return file ~= "" and vim.fn.filereadable(file) == 1 end, files) if #files == 0 then return Snacks.notify.warn(("The `%s` register does not contain any files"):format(vim.v.register or "+")) end local dir = picker:dir() Snacks.picker.util.copy(files, dir) Tree:refresh(dir) Tree:open(dir) M.update(picker, { target = dir }) end function M.actions.explorer_git_prev(picker, item) local node = Git.next(picker:cwd(), item and item.file, true) if node then M.update(picker, { target = node.path }) end end function M.actions.explorer_add(picker) Snacks.input({ prompt = 'Add a new file or directory (directories end with a "/")', }, function(value) if not value or value:find("^%s$") then return end local path = svim.fs.normalize(picker:dir() .. "/" .. value) local is_file = value:sub(-1) ~= "/" local dir = is_file and vim.fs.dirname(path) or path if is_file and uv.fs_stat(path) then Snacks.notify.warn("File already exists:\n- `" .. path .. "`") return end vim.fn.mkdir(dir, "p") if is_file then io.open(path, "w"):close() end Tree:open(dir) Tree:refresh(dir) M.update(picker, { target = path }) end) end function M.actions.explorer_rename(picker, item) if not item then return end Snacks.rename.rename_file({ from = item.file, on_rename = function(new, old) Tree:refresh(vim.fs.dirname(old)) Tree:refresh(vim.fs.dirname(new)) M.update(picker, { target = new }) end, }) end function M.actions.explorer_move(picker) ---@type string[] local paths = vim.tbl_map(Snacks.picker.util.path, picker:selected()) if #paths == 0 then Snacks.notify.warn("No files selected to move. Renaming instead.") return M.actions.explorer_rename(picker, picker:current()) end local target = picker:dir() local what = #paths == 1 and vim.fn.fnamemodify(paths[1], ":p:~:.") or #paths .. " files" local t = vim.fn.fnamemodify(target, ":p:~:.") Snacks.picker.util.confirm("Move " .. what .. " to " .. t .. "?", function() for _, from in ipairs(paths) do local to = target .. "/" .. vim.fn.fnamemodify(from, ":t") Snacks.rename.rename_file({ from = from, to = to }) Tree:refresh(vim.fs.dirname(from)) end Tree:refresh(target) picker.list:set_selected() -- clear selection M.update(picker, { target = target }) end) end function M.actions.explorer_copy(picker, item) if not item then return end ---@type string[] local paths = vim.tbl_map(Snacks.picker.util.path, picker:selected()) -- Copy selection if #paths > 0 then local dir = picker:dir() Snacks.picker.util.copy(paths, dir) picker.list:set_selected() -- clear selection Tree:refresh(dir) Tree:open(dir) M.update(picker, { target = dir }) return end Snacks.input({ prompt = "Copy to", }, function(value) if not value or value:find("^%s$") then return end local dir = vim.fs.dirname(item.file) local to = svim.fs.normalize(dir .. "/" .. value) if uv.fs_stat(to) then Snacks.notify.warn("File already exists:\n- `" .. to .. "`") return end Snacks.picker.util.copy_path(item.file, to) Tree:refresh(vim.fs.dirname(to)) M.update(picker, { target = to }) end) end function M.actions.explorer_del(picker) ---@type string[] local paths = vim.tbl_map(Snacks.picker.util.path, picker:selected({ fallback = true })) if #paths == 0 then return end local what = #paths == 1 and vim.fn.fnamemodify(paths[1], ":p:~:.") or #paths .. " files" Snacks.picker.util.confirm("Delete " .. what .. "?", function() for _, path in ipairs(paths) do local ok, err = M.trash(path) if ok then Snacks.bufdelete({ file = path, force = true }) else Snacks.notify.error("Failed to delete `" .. path .. "`:\n" .. err) end Tree:refresh(vim.fs.dirname(path)) end picker.list:set_selected() -- clear selection M.update(picker) end) end function M.actions.confirm(picker, item, action) if not item then return elseif picker.input.filter.meta.searching then M.update(picker, { target = item.file }) elseif item.dir then Tree:toggle(item.file) M.update(picker, { refresh = true }) else Snacks.picker.actions.jump(picker, item, action) end end function M.actions.explorer_diagnostic(picker, item, action) ---@cast action snacks.explorer.diagnostic.Action local node = Tree:next(picker:cwd(), function(node) if not node.severity then return false end return action.severity == nil or node.severity == action.severity end, { up = action.up, path = item and item.file }) if node then M.update(picker, { target = node.path }) end end M.actions.explorer_diagnostic_next = { action = "explorer_diagnostic" } M.actions.explorer_diagnostic_prev = { action = "explorer_diagnostic", up = true } M.actions.explorer_warn_next = { action = "explorer_diagnostic", severity = vim.diagnostic.severity.WARN } M.actions.explorer_warn_prev = { action = "explorer_diagnostic", severity = vim.diagnostic.severity.WARN, up = true } M.actions.explorer_error_next = { action = "explorer_diagnostic", severity = vim.diagnostic.severity.ERROR } M.actions.explorer_error_prev = { action = "explorer_diagnostic", severity = vim.diagnostic.severity.ERROR, up = true } return M ================================================ FILE: lua/snacks/explorer/diagnostics.lua ================================================ ---@diagnostic disable: missing-fields local M = {} ---@param cwd string function M.update(cwd) local Tree = require("snacks.explorer.tree") local node = Tree:find(cwd) local snapshot = Tree:snapshot(node, { "severity" }) Tree:walk(node, function(n) n.severity = nil end, { all = true }) local diags = vim.diagnostic.get() ---@param path string ---@param diag vim.Diagnostic local function add(path, diag) local n = Tree:find(path) local severity = tonumber(diag.severity) or vim.diagnostic.severity.INFO n.severity = math.min(n.severity or severity, severity) end for _, diag in ipairs(diags) do local path = diag.bufnr and vim.api.nvim_buf_get_name(diag.bufnr) path = path and path ~= "" and svim.fs.normalize(path) or nil if path then add(path, diag) add(cwd, diag) for dir in Snacks.picker.util.parents(path, cwd) do add(dir, diag) end end end return Tree:changed(node, snapshot) end return M ================================================ FILE: lua/snacks/explorer/git.lua ================================================ ---@diagnostic disable: missing-fields local M = {} ---@class snacks.explorer.git.Status ---@field status string ---@field file string local uv = vim.uv or vim.loop local CACHE_TTL = 15 * 60 -- 15 minutes M.state = {} ---@type table ---@param path string function M.refresh(path) for root in pairs(M.state) do if path == root or path:find(root .. "/", 1, true) == 1 then M.state[root].last = 0 end end end ---@param cwd string function M.is_dirty(cwd) local root = Snacks.git.get_root(cwd) if not root then return false end return M.state[root] == nil or M.state[root].last == 0 end ---@param cwd string ---@param opts? {on_update?: fun(), ttl?: number, force?: boolean, untracked?: boolean} function M.update(cwd, opts) opts = opts or {} local ttl = opts.ttl or CACHE_TTL if opts.force then ttl = 0 end local root = Snacks.git.get_root(cwd) if not root then return M._update(cwd, {}) end local now = os.time() M.state[root] = M.state[root] or { tick = 0, last = 0 } local state = M.state[root] if now - state.last < ttl then return end state.last = now state.tick = state.tick + 1 local tick = state.tick local output = "" local stdout = assert(uv.new_pipe()) local handle ---@type uv.uv_process_t handle = uv.spawn("git", { stdio = { nil, stdout, nil }, cwd = root, hide = true, args = { "--no-pager", "--no-optional-locks", "status", "--porcelain=v1", "--ignored=matching", "-z", opts.untracked and "-unormal" or "-uno", }, }, function() handle:close() end) if not handle then return M._update(cwd, {}) end local function process() if not M.state[root] or M.state[root].tick ~= tick then return end local ret = {} ---@type snacks.explorer.git.Status[] for _, line in ipairs(vim.split(output, "\0")) do if line ~= "" then local status, file = line:match("^(..) (.+)$") if status then ret[#ret + 1] = { status = status, file = root .. "/" .. file, } end end end if M._update(cwd, ret) and opts and opts.on_update then vim.schedule(opts.on_update) end end stdout:read_start(function(err, data) assert(not err, err) if data then output = output .. data else process() stdout:close() end end) end ---@param cwd string ---@param results snacks.explorer.git.Status[] function M._update(cwd, results) local Tree = require("snacks.explorer.tree") local Git = require("snacks.picker.source.git") local node = Tree:find(cwd) local snapshot = Tree:snapshot(node, { "status", "ignored" }) Tree:walk(node, function(n) n.status = nil n.ignored = nil end, { all = true }) ---@param path string ---@param status string local function add_git_status(path, status) local n = Tree:find(path) n.status = n.status and Git.merge_status(n.status, status) or status if status:sub(1, 1) == "!" then n.ignored = true end end if vim.fn.isdirectory(cwd .. "/.git") == 1 then add_git_status(cwd .. "/.git", "!!") end for _, s in ipairs(results) do local is_dir = s.file:sub(-1) == "/" local path = is_dir and s.file:sub(1, -2) or s.file local deleted = s.status:find("D") and s.status ~= "UD" if not deleted then add_git_status(path, s.status) end if is_dir then local n = Tree:find(path) n.dir_status = s.status end if s.status:sub(1, 1) ~= "!" then -- don't propagate ignored status add_git_status(cwd, s.status) for dir in Snacks.picker.util.parents(path, cwd) do if not s.status:find("^.D$") or vim.fn.isdirectory(dir) == 1 then -- only propagate if not deleted or still exists add_git_status(dir, s.status) end end end end return Tree:changed(node, snapshot) end ---@param cwd string ---@param path? string ---@param up? boolean function M.next(cwd, path, up) local Tree = require("snacks.explorer.tree") return Tree:next(cwd, function(node) return node.status ~= nil end, { up = up, path = path }) end return M ================================================ FILE: lua/snacks/explorer/init.lua ================================================ ---@class snacks.explorer ---@overload fun(opts?: snacks.picker.explorer.Config): snacks.Picker local M = setmetatable({}, { __call = function(M, ...) return M.open(...) end, }) M.meta = { desc = "A file explorer (picker in disguise)", needs_setup = true, } --- These are just the general explorer settings. --- To configure the explorer picker, see `snacks.picker.explorer.Config` ---@class snacks.explorer.Config local defaults = { replace_netrw = true, -- Replace netrw with the snacks explorer trash = true, -- Use the system trash when deleting files } M.config = Snacks.config.get("explorer", defaults) ---@private ---@param event? vim.api.keyset.create_autocmd.callback_args function M.setup(event) if M.config.replace_netrw then -- Disable netrw pcall(vim.api.nvim_del_augroup_by_name, "FileExplorer") local group = vim.api.nvim_create_augroup("snacks.explorer", { clear = true }) local function handle(ev) if ev.file ~= "" and vim.fn.isdirectory(ev.file) == 1 then local picker = M.open({ cwd = ev.file }) if picker and vim.v.vim_did_enter == 0 then -- clear bufname so we don't try loading this one again vim.api.nvim_buf_set_name(ev.buf, "") picker:show() local ref = picker:ref() -- focus on UIEnter, since focusing before doesn't work vim.api.nvim_create_autocmd("UIEnter", { once = true, group = group, callback = function() local p = ref() if p then p:focus() end end, }) else -- after vim has entered, we also need to delete the directory buffer -- use bufdelete to keep the window layout Snacks.bufdelete.delete(ev.buf) end end end -- event from snacks loader if event then handle(event) end -- Open the explorer when opening a directory vim.api.nvim_create_autocmd("BufEnter", { group = group, callback = handle, }) end end --- Shortcut to open the explorer picker ---@param opts? snacks.picker.explorer.Config|{} function M.open(opts) return Snacks.picker.explorer(opts) end --- Reveals the given file/buffer or the current buffer in the explorer ---@param opts? {file?:string, buf?:number} function M.reveal(opts) local Actions = require("snacks.explorer.actions") local Tree = require("snacks.explorer.tree") opts = opts or {} local file = svim.fs.normalize(opts.file or vim.api.nvim_buf_get_name(opts.buf or 0)) local explorer = Snacks.picker.get({ source = "explorer" })[1] local function reveal() local cwd = explorer:cwd() if not Tree:in_cwd(cwd, file) then for parent in vim.fs.parents(file) do if Tree:in_cwd(parent, cwd) then explorer:set_cwd(parent) break end end end Tree:open(file) Actions.update(explorer, { target = file, refresh = true }) end if explorer then reveal() else explorer = M.open({ on_show = reveal }) end return explorer end function M.health() local cmds = require("snacks.explorer.actions").get_trash_cmds("test") if M.config.trash == false then Snacks.health.ok("System trash disabled in config") else local tools = vim.tbl_map(function(cmd) return cmd[1] end, cmds) if Snacks.health.have_tool(tools) then Snacks.health.ok("System trash command found") else Snacks.health.warn("No system trash command found; deleting files will be permanent") end end end return M ================================================ FILE: lua/snacks/explorer/tree.lua ================================================ ---@class snacks.picker.explorer.Node ---@field path string ---@field name string ---@field hidden? boolean ---@field status? string merged git status ---@field dir_status? string git status of the directory ---@field ignored? boolean ---@field type "file"|"directory"|"link"|"fifo"|"socket"|"char"|"block"|"unknown" ---@field dir? boolean ---@field open? boolean wether the node should be expanded (only for directories) ---@field expanded? boolean wether the node is expanded (only for directories) ---@field parent? snacks.picker.explorer.Node ---@field last? boolean child of the parent ---@field utime? number ---@field children table ---@field severity? number ---@class snacks.picker.explorer.Filter ---@field hidden? boolean show hidden files ---@field ignored? boolean show ignored files ---@field exclude? string[] globs to exclude ---@field include? string[] globs to exclude ---@alias snacks.picker.explorer.Snapshot {fields: string[], state:table} local uv = vim.uv or vim.loop local function norm(path) return svim.fs.normalize(path):gsub("/$", ""):gsub("^$", "/") end local function assert_dir(path) assert(vim.fn.isdirectory(path) == 1, "Not a directory: " .. path) end -- local function assert_file(path) -- assert(vim.fn.filereadable(path) == 1, "Not a file: " .. path) -- end ---@class snacks.picker.explorer.Tree ---@field root snacks.picker.explorer.Node ---@field nodes table local Tree = {} Tree.__index = Tree function Tree.new() local self = setmetatable({}, Tree) self.root = { name = "", children = {}, dir = true, type = "directory", path = "" } self.nodes = {} return self end ---@param path string ---@return snacks.picker.explorer.Node? function Tree:node(path) path = norm(path) return self.nodes[norm(path)] end ---@param path string function Tree:find(path) path = norm(path) if self.nodes[path] then return self.nodes[path] end local node = self.root local parts = vim.split(path, "/", { plain = true }) local is_dir = vim.fn.isdirectory(path) == 1 for p, part in ipairs(parts) do node = self:child(node, part, (is_dir or p < #parts) and "directory" or "file") end return node end ---@param node snacks.picker.explorer.Node ---@param name string ---@param type string function Tree:child(node, name, type) if not node.children[name] then local path = node.path .. "/" .. name path = node == self.root and name or path node.children[name] = { name = name, path = path, parent = node, children = {}, type = type, dir = type == "directory" or (type == "link" and vim.fn.isdirectory(path) == 1), hidden = name:sub(1, 1) == ".", } self.nodes[path] = node.children[name] end return node.children[name] end ---@param path string function Tree:open(path) local dir = self:dir(path) local node = self:find(dir) while node do node.open = true node = node.parent end end ---@param path string function Tree:toggle(path) local dir = self:dir(path) local node = self:find(dir) if node.open then self:close(dir) else self:open(dir) end end ---@param path string function Tree:show(path) self:open(vim.fs.dirname(path)) end ---@param path string function Tree:close(path) local dir = self:dir(path) local node = self:find(dir) node.open = false node.expanded = false -- clear expanded state end ---@param node snacks.picker.explorer.Node function Tree:expand(node) if node.expanded then return end local found = {} ---@type table assert(node.dir, "Can only expand directories") local fs = uv.fs_scandir(node.path) while fs do local name, t = uv.fs_scandir_next(fs) if not name then break end t = t or Snacks.util.path_type(node.path .. "/" .. name) found[name] = true local child = self:child(node, name, t) child.type = t child.dir = t == "directory" or (t == "link" and vim.fn.isdirectory(child.path) == 1) end for name in pairs(node.children) do if not found[name] then node.children[name] = nil end end node.expanded = true node.utime = uv.hrtime() end ---@param path string function Tree:dir(path) return vim.fn.isdirectory(path) == 1 and path or vim.fs.dirname(path) end ---@param path string function Tree:refresh(path) local dir = self:dir(path) require("snacks.explorer.git").refresh(dir) local root = self:node(dir) if not root then return end self:walk(root, function(node) node.expanded = nil end, { all = true }) end ---@param node snacks.picker.explorer.Node ---@param fn fun(node: snacks.picker.explorer.Node):boolean? return `false` to not process children, `true` to abort ---@param opts? {all?: boolean} function Tree:walk(node, fn, opts) local abort = false ---@type boolean? abort = fn(node) if abort ~= nil then return abort end local children = vim.tbl_values(node.children) ---@type snacks.picker.explorer.Node[] table.sort(children, function(a, b) if a.dir ~= b.dir then return a.dir end return a.name < b.name end) for c, child in ipairs(children) do child.last = c == #children abort = false if child.dir and (child.open or (opts and opts.all)) then abort = self:walk(child, fn, opts) else abort = fn(child) end if abort then return true end end return false end ---@param filter snacks.picker.explorer.Filter function Tree:filter(filter) local exclude = filter.exclude and #filter.exclude > 0 and Snacks.picker.util.globber(filter.exclude) local include = filter.include and #filter.include > 0 and Snacks.picker.util.globber(filter.include) return function(node) -- takes precedence over all other filters if include and include(node.path) then return true end if node.hidden and not filter.hidden then return false end if node.ignored and not filter.ignored then return false end if exclude and exclude(node.path) then return false end return true end end ---@param cwd string ---@param cb fun(node: snacks.picker.explorer.Node) ---@param opts? {expand?: boolean}|snacks.picker.explorer.Filter function Tree:get(cwd, cb, opts) opts = opts or {} assert_dir(cwd) local node = self:find(cwd) node.open = true local filter = self:filter(opts) self:walk(node, function(n) if n ~= node then if not filter(n) then return false end end if n.dir and n.open and not n.expanded and opts.expand ~= false then self:expand(n) end cb(n) end) end ---@param cwd string ---@param opts? snacks.picker.explorer.Filter function Tree:is_dirty(cwd, opts) opts = opts or {} if require("snacks.explorer.git").is_dirty(cwd) then return true end local dirty = false self:get(cwd, function(n) if n.dir and n.open and not n.expanded then dirty = true end end, { hidden = opts.hidden, ignored = opts.ignored, exclude = opts.exclude, include = opts.include, expand = false }) return dirty end ---@param cwd string ---@param path string function Tree:in_cwd(cwd, path) local dir = vim.fs.dirname(path) return dir == cwd or dir:find(cwd .. "/", 1, true) == 1 end ---@param cwd string ---@param path string function Tree:is_visible(cwd, path) assert_dir(cwd) if cwd == path then return true end local dir = vim.fs.dirname(path) if not self:in_cwd(cwd, path) then return false end local node = self:node(dir) while node do if node.path == cwd then return true elseif not node.open then return false end node = node.parent end return false end ---@param cwd string function Tree:close_all(cwd) self:walk(self:find(cwd), function(node) node.open = false end, { all = true }) end ---@param cwd string ---@param filter fun(node: snacks.picker.explorer.Node):boolean? ---@param opts? {up?: boolean, path?: string} function Tree:next(cwd, filter, opts) opts = opts or {} local path = opts.path or cwd local root = self:node(cwd) or nil if not root then return end local first ---@type snacks.picker.explorer.Node? local last ---@type snacks.picker.explorer.Node? local prev ---@type snacks.picker.explorer.Node? local next ---@type snacks.picker.explorer.Node? local found = false self:walk(root, function(node) local want = not node.dir and filter(node) and not node.ignored if node.path == path then found = true end if want then first, last = first or node, node next = next or (found and node.path ~= path and node) or nil prev = not found and node or prev end end, { all = true }) if opts.up then return prev or last end return next or first end ---@param node snacks.picker.explorer.Node ---@param snapshot snacks.picker.explorer.Snapshot function Tree:changed(node, snapshot) local old = snapshot.state local current = self:snapshot(node, snapshot.fields).state if vim.tbl_count(current) ~= vim.tbl_count(old) then return true end for n, data in pairs(current) do local prev = old[n] if not prev then return true end if not vim.deep_equal(prev, data) then return true end end return false end ---@param node snacks.picker.explorer.Node ---@param fields string[] function Tree:snapshot(node, fields) ---@type snacks.picker.explorer.Snapshot local ret = { state = {}, fields = fields, } Tree:walk(node, function(n) local data = {} ---@type any[] for f, field in ipairs(fields) do data[f] = n[field] end ret.state[n] = data end, { all = true }) return ret end return Tree.new() ================================================ FILE: lua/snacks/explorer/watch.lua ================================================ local M = {} local Git = require("snacks.explorer.git") local Tree = require("snacks.explorer.tree") M._watches = {} ---@type table local uv = vim.uv or vim.loop local timer = assert(uv.new_timer()) ---@param path string ---@param cb? fun(file:string, events: uv.fs_event_start.callback.events) function M.start(path, cb) if M._watches[path] ~= nil then return end local handle = assert(vim.uv.new_fs_event()) local ok, err = handle:start(path, {}, function(_, file, events) if cb then -- Handle nil filename (FreeBSD kqueue bug where filename may be unavailable) -- In that case, we just pass the path being watched cb(file and (path .. "/" .. file) or path, events) else Tree:refresh(path) M.refresh() end end) M._watches[path] = handle if not ok then Snacks.notify.error("Failed to watch " .. path .. ": " .. err) if not handle:is_closing() then handle:close() end return end end ---@param path string function M.stop(path) local handle = M._watches[path] if handle then if not handle:is_closing() then handle:close() end M._watches[path] = nil end end -- Stop all watches function M.abort() for path in pairs(M._watches) do M.stop(path) end end -- batch updates and give explorer the time to update before the watcher function M.refresh() timer:start( 100, 0, vim.schedule_wrap(function() local pickers = Snacks.picker.get({ source = "explorer", tab = false }) for _, picker in ipairs(pickers) do if picker and not picker.closed and Tree:is_dirty(picker:cwd(), picker.opts) then picker.list:set_target() vim.schedule(function() if not picker or picker.closed then return end picker:find() end) end end end) ) end function M.watch() -- Track used watches local used = {} ---@type table local pickers = Snacks.picker.get({ source = "explorer", tab = false }) local cwds = {} ---@type table for _, picker in ipairs(pickers) do cwds[picker:cwd()] = true end for cwd in pairs(cwds) do -- Watch git index local root = Snacks.git.get_root(cwd) if root then used[root .. "/.git"] = true M.start(root .. "/.git", function(file) if vim.fs.basename(file) == "index" then Git.refresh(root) M.refresh() end end) end -- Watch open directories Tree:walk(Tree:find(cwd), function(node) if node.dir and node.open then used[node.path] = true M.start(node.path) end end) end -- Stop unused watches for path in pairs(M._watches) do if not used[path] then M.stop(path) end end end return M ================================================ FILE: lua/snacks/gh/actions.lua ================================================ local Api = require("snacks.gh.api") local config = require("snacks.gh").config() local M = {} ---@class snacks.gh.action.ctx ---@field items snacks.picker.gh.Item[] ---@field picker? snacks.Picker ---@field main? number ---@field action? snacks.picker.Action ---@class snacks.gh.cli.Action.ctx ---@field item snacks.picker.gh.Item ---@field args string[] ---@field opts snacks.gh.cli.Action ---@field picker? snacks.Picker ---@field scratch? snacks.win ---@field main? number ---@field input? string ---@alias snacks.gh.action.fn fun(item?: snacks.picker.gh.Item, ctx: snacks.gh.action.ctx) ---@class snacks.gh.Action ---@field action snacks.gh.action.fn ---@field desc? string ---@field name? string ---@field priority? number ---@field title? string -- for items ---@field type? "pr" | "issue" ---@field enabled? fun(item: snacks.picker.gh.Item, ctx: snacks.gh.action.ctx): boolean ---@param item snacks.picker.gh.Item ---@param ctx snacks.gh.action.ctx local function update_main(item, ctx) local gh = { repo = item.repo, number = item.number, type = item.type } if ctx.main and vim.api.nvim_win_is_valid(ctx.main) then local buf = vim.api.nvim_win_get_buf(ctx.main) if vim.deep_equal(vim.b[buf].snacks_gh or {}, gh) then return ctx.main, buf end end local win = vim.api.nvim_get_current_win() local buf = vim.api.nvim_win_get_buf(win) if vim.deep_equal(vim.b[buf].snacks_gh or {}, gh) then ctx.main = win return ctx.main, buf end end ---@param item snacks.picker.gh.Item ---@param ctx snacks.gh.action.ctx local function get_meta(item, ctx) local win, buf = update_main(item, ctx) if not win or not buf then return end local meta = Snacks.picker.highlight.meta(buf) ---@type {comment_id?: number, diff?: snacks.diff.Meta}? local m = meta and meta[vim.api.nvim_win_get_cursor(win)[1]] or nil return m, meta, buf, win end ---@class snacks.gh.actions: {[string]:snacks.gh.Action} M.actions = setmetatable({}, { __index = function(_, key) if type(key) ~= "string" then return nil end local action = M.cli_actions[key] if action then local ret = M.cli_action(action) rawset(M.actions, key, ret) return ret end end, }) M.actions.gh_diff = { desc = "View PR diff", icon = " ", priority = 100, type = "pr", title = "View diff for PR #{number}", action = function(item, ctx) if not item then return end Snacks.picker.gh_diff({ show_delay = 0, repo = item.repo, pr = item.number, }) end, } M.actions.gh_open = { desc = "Open in buffer", icon = " ", priority = 100, title = "Open {type} #{number} in buffer", action = function(item, ctx) if ctx.picker then return Snacks.picker.actions.jump(ctx.picker, item, ctx.action) end end, } M.actions.gh_actions = { desc = "Show available actions", action = function(item, ctx) -- NOTE: this forwards split/vsplit/tab/drop actions to jump if ctx.action and ctx.action.cmd then return Snacks.picker.actions.jump(ctx.picker, item, ctx.action) end update_main(item, ctx) local actions = M.get_actions(item, ctx) actions.gh_actions = nil -- remove this action actions.gh_perform_action = nil -- remove this action Snacks.picker.gh_actions({ item = item, layout = { config = function(layout) -- Fit list height to number of items, up to 10 for _, box in ipairs(layout.layout) do if box.win == "list" and not box.height then box.height = math.max(math.min(vim.tbl_count(actions), vim.o.lines * 0.8 - 10), 3) end end end, }, ---@param it snacks.picker.gh.Action confirm = function(picker, it, action) if not it then return end ctx.action = action if ctx.picker then ctx.picker.visual = ctx.picker.visual or picker.visual or nil ctx.picker:focus() end update_main(item, ctx) it.action.action(item, ctx) picker:close() end, }) end, } M.actions.gh_perform_action = { action = function(item, ctx) if not item then return end -- pass a new context, since we're doing the action on a single item item.action.action(item.item, { items = { item.item } }) ctx.picker:close() end, } M.actions.gh_browse = { desc = "Open in web browser", title = "Open {type} #{number} in web browser", icon = " ", action = function(_, ctx) for _, item in ipairs(ctx.items) do Api.cmd(function() Snacks.notify.info(("Opened #%s in web browser"):format(item.number)) end, { args = { item.type, "view", tostring(item.number), "--web" }, repo = item.repo, }) end if ctx.picker then ctx.picker.list:set_selected() -- clear selection end end, } M.actions.gh_react = { desc = "Add reaction", icon = " ", action = function(item, ctx) local reactions = { "+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes" } Snacks.picker.pick("gh_reactions", { number = item.number, repo = item.repo, layout = { config = function(layout) -- Fit list height to number of items, up to 10 for _, box in ipairs(layout.layout) do if box.win == "list" and not box.height then box.height = math.max(math.min(#reactions, vim.o.lines * 0.8 - 10), 3) end end end, }, confirm = function(picker) local items = picker:selected({ fallback = true }) for i, it in ipairs(items) do if it.added then M.run(item, { api = { endpoint = "/repos/{repo}/issues/{number}/reactions/" .. it.id, method = "DELETE", }, refresh = i == #items, }, ctx) else M.run(item, { api = { endpoint = "/repos/{repo}/issues/{number}/reactions", fields = { content = it.reaction }, }, refresh = i == #items, }, ctx) end end picker:close() end, }) end, } M.actions.gh_label = { desc = "Add/Remove labels", icon = "󰌕 ", action = function(item, ctx) Snacks.picker.pick("gh_labels", { number = item.number, repo = item.repo, type = item.type, confirm = function(picker) local labels = {} ---@type table for _, label in ipairs(item.item.labels or {}) do labels[label.name] = true end for _, it in ipairs(picker:selected({ fallback = true })) do labels[it.label] = not it.added or nil end M.run(item, { api = { endpoint = "/repos/{repo}/issues/{number}/labels", method = "PUT", input = { labels = vim.tbl_keys(labels) }, }, }, ctx) picker:close() end, }) end, } M.actions.gh_yank = { desc = "Yank URL(s) to clipboard", icon = " ", action = function(_, ctx) if vim.fn.mode():find("^[vV]") and ctx.picker then ctx.picker.list:select() end ---@param it snacks.picker.gh.Item local urls = vim.tbl_map(function(it) return it.url end, ctx.items) if ctx.picker then ctx.picker.list:set_selected() -- clear selection end local value = table.concat(urls, "\n") vim.fn.setreg(vim.v.register or "+", value, "l") Snacks.notify.info("Yanked " .. #urls .. " URL(s)") end, } M.actions.gh_reply_to_comment = { desc = "Reply to comment", title = "Reply to comment on {type} #{number}", priority = 150, icon = " ", enabled = function(item, ctx) local m = get_meta(item, ctx) return m and m.comment_id ~= nil or false end, action = function(item, ctx) local action = vim.deepcopy(M.cli_actions.gh_comment) local m = get_meta(item, ctx) if not (m and m.comment_id) then Snacks.notify.error("No comment found to reply to") return end action.title = "Reply to comment on {type} #{number}" action.api = { endpoint = "/repos/{repo}/pulls/{number}/comments", input = { in_reply_to = m.comment_id }, } M.run(item, action, ctx) end, } M.actions.gh_diff_comment = { desc = "Add diff comment", title = "Comment on diff in {type} #{number}", priority = 150, icon = " ", enabled = function(item, ctx) local m = get_meta(item, ctx) return m and m.diff ~= nil or false end, action = function(item, ctx) local m, meta, buf = get_meta(item, ctx) if not (meta and buf and m and m.diff) then Snacks.notify.error("No diff hunk found to comment on") return end local action = vim.deepcopy(M.cli_actions.gh_comment) local visual = ctx.picker and ctx.picker.visual or Snacks.picker.util.visual() visual = visual and visual.buf == buf and visual or nil local line = m.diff.line ---@type number local start_line ---@type number? if visual then local from, to = math.min(visual.pos[1], visual.end_pos[1]), math.max(visual.pos[1], visual.end_pos[1]) local line_diff = vim.tbl_get(meta, to, "diff") or m.diff --[[@as snacks.diff.Meta]] local start_diff = vim.tbl_get(meta, from, "diff") or m.diff --[[@as snacks.diff.Meta]] if line_diff.file ~= start_diff.file then Snacks.notify.error("Cannot add comment: visual selection spans multiple files") return end local code = {} ---@type string[] for i = from, to do code[#code + 1] = vim.tbl_get(meta, i, "diff", "code") or "" end line, start_line = line_diff.line, start_diff.line local ft = vim.filetype.match({ filename = m.diff.file }) or "" local code_header = "```" .. (ft == "" and "" or (ft .. " ")) .. "suggestion\n" action.template = ("\n%s%s\n```\n"):format(code_header, table.concat(code, "\n")) action.on_submit = function(body) local s, e = body:find(action.template, 1, true) if s and e then -- suggestion not edited, so remove it body = body:sub(1, s - 1) .. body:sub(e + 1) end body = body:gsub(code_header, "```suggestion\n") -- remove ft from suggestion return body end end start_line = start_line ~= line and start_line or nil if start_line then action.title = ("Comment on lines %s%d to %s%d"):format( m.diff.side:sub(1, 1):upper(), start_line or line, m.diff.side:sub(1, 1):upper(), line ) else action.title = ("Comment on line %s%d"):format(m.diff.side:sub(1, 1):upper(), line) end action.api = { endpoint = "/repos/{repo}/pulls/{number}/comments", input = { commit_id = item.headRefOid, path = m.diff.file, side = m.diff.side:upper(), -- "RIGHT" or "LEFT" (uppercase) line = line, start_line = start_line, }, } if item.pendingReview then action.api = { endpoint = "graphql", input = { -- inject: graphql query = [[ mutation($reviewId: ID!, $body: String!, $path: String!, $line: Int!, $side: DiffSide!, $startLine: Int, $startSide: DiffSide) { addPullRequestReviewThread(input: { pullRequestReviewId: $reviewId body: $body path: $path line: $line side: $side startLine: $startLine startSide: $startSide }) { thread { id } } } ]], variables = { reviewId = item.pendingReview.id, path = m.diff.file, side = m.diff.side:upper(), -- "RIGHT" or "LEFT" line = line, startLine = start_line, startSide = start_line and m.diff.side:upper() or nil, }, }, } end M.run(item, action, ctx) end, } M.actions.gh_comment = { desc = "Add comment", title = "Comment on {type} #{number}", icon = " ", action = function(item, ctx) local m = get_meta(item, ctx) if m and m.comment_id then return M.actions.gh_reply_to_comment.action(item, ctx) elseif m and m.diff then return M.actions.gh_diff_comment.action(item, ctx) end local action = vim.deepcopy(M.cli_actions.gh_comment) M.run(item, action, ctx) end, } M.actions.gh_update_branch = { icon = "󰚰 ", title = "Update branch of PR #{number}", type = "pr", enabled = function(item) return item.state == "open" end, action = function(item, ctx) Snacks.picker.select( { "1. Yes using the rebase method", "2. Yes using the merge method", "3. Cancel" }, { title = "Are you sure you want to update the brnch of PR #" .. item.id .. "?" }, function(choice, idx) if idx == 3 then return end local action = vim.deepcopy(M.cli_actions.gh_update_branch) if idx == 1 then action.args = { "--rebase" } end M.run(item, action, ctx) end ) end, } -- Start a new review M.actions.gh_start_review = { desc = "Start a review", type = "pr", icon = " ", priority = 100, enabled = function(item) return item.pendingReview == nil end, action = function(item, ctx) M.run(item, { api = { endpoint = "/repos/{repo}/pulls/{number}/reviews", input = { commit_id = item.headRefOid }, }, success = "Started pending review for PR #{number}", }, ctx) end, } -- Submit pending review M.actions.gh_submit_review = { desc = "Submit pending review", type = "pr", icon = " ", priority = 200, enabled = function(item) return item.pendingReview ~= nil end, action = function(item, ctx) local review_id = item.pendingReview.databaseId -- Ask user: APPROVE, REQUEST_CHANGES, or COMMENT Snacks.picker.select( { "Approve", "Request Changes", "Comment" }, { title = "Submit review for PR #" .. item.number }, function(choice, idx) if not choice then return end local events = { "APPROVE", "REQUEST_CHANGES", "COMMENT" } M.run(item, { title = "Submit review for PR #{number}", api = { endpoint = "/repos/{repo}/pulls/{number}/reviews/" .. review_id .. "/events", input = { event = events[idx] }, }, edit = "body-file", -- Optional summary success = "Submitted review for PR #{number}", }, ctx) end ) end, } ---@type table M.cli_actions = { gh_comment = { cmd = "comment", icon = " ", title = "Comment on {type} #{number}", success = "Commented on {type} #{number}", edit = "body-file", }, gh_update_branch = { cmd = "update-branch", title = "Update branch of PR #{number}", success = "Branch of PR #{number} updated", type = "pr", }, gh_checkout = { cmd = "checkout", icon = " ", type = "pr", confirm = "Are you sure you want to checkout PR #{number}?", title = "Checkout PR #{number}", success = "Checked out PR #{number}", }, gh_close = { edit = "comment", icon = config.icons.crossmark, cmd = "close", title = "Close {type} #{number}", success = "Closed {type} #{number}", enabled = function(item) return item.state == "open" end, }, gh_edit = { cmd = "edit", icon = " ", fields = { { arg = "title", prop = "title", name = "Title" }, }, success = "Edited {type} #{number}", edit = "body-file", template = "{body}", title = "Edit {type} #{number}", }, gh_squash = { cmd = "merge", icon = config.icons.pr.merged, type = "pr", success = "Squashed and merged PR #{number}", args = { "--squash" }, fields = { { arg = "subject", prop = "title", name = "Title" }, }, edit = "body-file", confirm = "Are you sure you want to squash and merge PR #{number}?", template = "{body}", title = "Squash and merge PR #{number}", enabled = function(item) return item.state == "open" end, }, gh_merge_rebase = { cmd = "merge", icon = config.icons.pr.merged, type = "pr", success = "Rebased and merged PR #{number}", args = { "--rebase" }, confirm = "Are you sure you want to rebase and merge PR #{number}?", title = "Rebase and merge PR #{number}", enabled = function(item) return item.state == "open" end, }, gh_merge = { cmd = "merge", icon = config.icons.pr.merged, type = "pr", success = "Merged PR #{number}", args = { "--merge" }, title = "Merge PR #{number}", confirm = "Are you sure you want to merge PR #{number}?", enabled = function(item) return item.state == "open" end, }, gh_close_not_planned = { cmd = "close", icon = config.icons.crossmark, type = "issue", success = "Closed issue #{number} as not planned", args = { "--reason", "not planned" }, edit = "comment", title = "Close issue #{number} as not planned", enabled = function(item) return item.state == "open" end, }, gh_reopen = { cmd = "reopen", icon = " ", edit = "comment", title = "Reopen {type} #{number}", success = "Reopened {type} #{number}", enabled = function(item) return item.state == "closed" end, }, gh_ready = { cmd = "ready", icon = config.icons.pr.open, type = "pr", title = "Mark PR #{number} as ready for review", success = "Marked PR #{number} as ready for review", enabled = function(item) return item.state == "open" and item.isDraft end, }, gh_draft = { cmd = "ready", args = { "--undo" }, icon = config.icons.pr.draft, type = "pr", title = "Mark PR #{number} as draft", success = "Marked PR #{number} as draft", enabled = function(item) return item.state == "open" and not item.isDraft end, }, gh_approve = { cmd = "review", icon = config.icons.checkmark, type = "pr", args = { "--approve" }, edit = "body-file", -- optional review summary title = "Review: approve PR #{number}", success = "Approved PR #{number}", enabled = function(item) return item.state == "open" and not item.pendingReview end, }, gh_request_changes = { cmd = "review", type = "pr", icon = " ", args = { "--request-changes" }, edit = "body-file", -- explain what needs fixing title = "Review: request changes on PR #{number}", success = "Requested changes on PR #{number}", enabled = function(item) return item.state == "open" and not item.pendingReview end, }, gh_review = { cmd = "review", type = "pr", icon = " ", args = { "--comment" }, edit = "body-file", -- general feedback title = "Review: comment on PR #{number}", success = "Commented on PR #{number}", enabled = function(item) return item.state == "open" and not item.pendingReview end, }, } ---@param opts snacks.gh.cli.Action function M.cli_action(opts) ---@type snacks.gh.Action return setmetatable({ desc = opts.desc or opts.title, ---@type snacks.gh.action.fn action = function(item, ctx) M.run(item, opts, ctx) end, }, { __index = opts }) end ---@param str string ---@param ... table function M.tpl(str, ...) local data = { ... } return Snacks.picker.util.tpl( str, setmetatable({}, { __index = function(_, key) for _, d in ipairs(data) do if d[key] ~= nil then local ret = d[key] return ret == "pr" and "PR" or ret end end end, }) ) end ---@param item snacks.picker.gh.Item ---@param ctx snacks.gh.action.ctx function M.get_actions(item, ctx) local ret = {} ---@type table local keys = vim.tbl_keys(M.actions) ---@type string[] vim.list_extend(keys, vim.tbl_keys(M.cli_actions)) for _, name in ipairs(keys) do local action = M.actions[name] local enabled = action.type == nil or action.type == item.type enabled = enabled and (action.enabled == nil or action.enabled(item, ctx)) if enabled then local a = setmetatable({}, { __index = action }) local ca = M.cli_actions[name] or {} a.desc = a.title and M.tpl(a.title or name, item, ca) or a.desc a.name = name ret[name] = a end end return ret end --- Executes a gh cli action ---@param item snacks.picker.gh.Item ---@param action snacks.gh.cli.Action ---@param ctx snacks.gh.action.ctx function M.run(item, action, ctx) local args = action.cmd and { item.type, action.cmd, tostring(item.number) } or {} vim.list_extend(args, action.args or {}) if action.api then action.api.endpoint = M.tpl(action.api.endpoint, item, action) end ---@type snacks.gh.cli.Action.ctx local cli_ctx = { item = item, args = args, opts = action, picker = ctx.picker, main = ctx.main, } if action.edit then return M.edit(cli_ctx) else return M._run(cli_ctx) end end --- Parses frontmatter fields from body and appends them to ctx.args ---@param body string ---@param ctx snacks.gh.cli.Action.ctx function M.parse(body, ctx) if not ctx.opts.fields then return body end local fields = {} ---@type table for _, f in ipairs(ctx.opts.fields) do fields[f.name] = f end local values = {} ---@type table --- parse markdown frontmatter for fields body = body:gsub("^(%-%-%-\n.-\n%-%-%-\n%s*)", function(fm) fm = fm:gsub("^%-%-%-\n", ""):gsub("\n%-%-%-\n%s*$", "") --[[@as string]] local lines = vim.split(fm, "\n") for _, line in ipairs(lines) do local field, value = line:match("^(%w+):%s*(.-)%s*$") if field and fields[field] then values[field] = value else Snacks.notify.warn(("Unknown field `%s` in frontmatter"):format(field or line)) end end return "" end) --[[@as string]] for _, field in ipairs(ctx.opts.fields) do local value = values[field.name] if value then if ctx.opts.api then ctx.opts.api.fields = ctx.opts.api.fields or {} ctx.opts.api.fields[field.arg] = value else vim.list_extend(ctx.args, { "--" .. field.arg, value }) end else Snacks.notify.error(("Missing required field `%s` in frontmatter"):format(field.name)) return end end return body end --- Executes the action CLI command ---@param ctx snacks.gh.cli.Action.ctx function M._run(ctx, force) if not force and ctx.opts.confirm then Snacks.picker.util.confirm(M.tpl(ctx.opts.confirm, ctx.item, ctx.opts), function() M._run(ctx, true) end) return end local spinner = require("snacks.picker.util.spinner").loading() local cb = function() vim.schedule(function() spinner:stop() -- success message if ctx.opts.success then Snacks.notify.info(M.tpl(ctx.opts.success, ctx.item, ctx.opts)) end -- refresh item and picker if ctx.opts.refresh ~= false then vim.schedule(function() Api.refresh(ctx.item) if ctx.picker and not ctx.picker.closed then ctx.picker:refresh() vim.cmd.startinsert() end end) if ctx.picker and not ctx.picker.closed then ctx.picker:focus() end end -- clean up scratch buffer if ctx.scratch then local buf = assert(ctx.scratch.buf) local fname = vim.api.nvim_buf_get_name(buf) ctx.scratch:on("WinClosed", function() vim.schedule(function() pcall(vim.api.nvim_buf_delete, buf, { force = true }) os.remove(fname) os.remove(fname .. ".meta") end) end, { buf = true }) ctx.scratch:close() end end) end if ctx.opts.api then Api.request( cb, Snacks.config.merge(ctx.opts.api or {}, { args = ctx.args, on_error = function() spinner:stop() end, }) ) else Api.cmd(cb, { input = ctx.input, args = ctx.args, repo = ctx.item.repo or ctx.opts.repo, on_error = function() spinner:stop() end, }) end end --- Edit action body in scratch buffer ---@param ctx snacks.gh.cli.Action.ctx function M.edit(ctx) ---@param s? string local function tpl(s) return s and M.tpl(s, ctx.item, ctx.opts) or nil end local template = ctx.opts.template or "" if not vim.tbl_isempty(ctx.opts.fields or {}) then local fm = { "---" } for _, f in ipairs(ctx.opts.fields) do fm[#fm + 1] = ("%s: {%s}"):format(f.name, f.prop) end fm[#fm + 1] = "---\n\n" template = table.concat(fm, "\n") .. template end local preview = ctx.picker and ctx.picker.preview and ctx.picker.preview.win:valid() and ctx.picker.preview.win or nil local actions = preview and preview.opts.actions or {} local parent = ctx.main or preview and preview.win or vim.api.nvim_get_current_win() local height = config.scratch.height or 15 local opts = Snacks.win.resolve({ relative = "win", width = 0, backdrop = false, height = height, actions = { cycle_win = actions.cycle_win, preview_scroll_up = actions.preview_scroll_up, preview_scroll_down = actions.preview_scroll_down, }, win = parent, wo = { winhighlight = "NormalFloat:Normal,FloatTitle:SnacksGhScratchTitle,FloatBorder:SnacksGhScratchBorder" }, border = "top_bottom", row = function(win) local border = win:border_size() return win:parent_size().height - height - border.top - border.bottom end, on_win = function(win) if vim.api.nvim_win_is_valid(parent) then local parent_row = vim.api.nvim_win_call(parent, vim.fn.winline) ---@type number parent_row = parent_row + vim.wo[parent].scrolloff -- adjust for scrolloff local row = vim.api.nvim_win_get_height(parent) - win:size().height if parent_row > row then vim.api.nvim_win_call(parent, function() vim.cmd(("normal! %d%s"):format(parent_row - row, Snacks.util.keycode(""))) end) end end vim.g.snacks_picker_cycle_win = win.win vim.schedule(function() vim.cmd.startinsert() end) end, footer_keys = { "", "R" }, keys = { submit = { "", function(win) ctx.scratch = win M.submit(ctx) end, desc = "Submit", mode = { "n", "i" }, }, }, }, preview and { keys = { [""] = { "cycle_win", mode = { "i", "n" } }, [""] = { "preview_scroll_up", mode = { "i", "n" } }, [""] = { "preview_scroll_down", mode = { "i", "n" } }, }, } or nil) Snacks.scratch({ ft = "markdown", icon = config.icons.logo, name = tpl(ctx.opts.title or "{cmd} {type} #{number}"), template = tpl(template), filekey = { cwd = false, branch = false, count = false, id = tpl("{repo}/{type}/{cmd}"), }, win = opts, }) end --- Submit edited body ---@param ctx snacks.gh.cli.Action.ctx function M.submit(ctx) local edit = assert(ctx.opts.edit, "Submit called for action that doesn't need edit?") local win = assert(ctx.scratch, "Submit not called from scratch window?") ctx = setmetatable({ args = vim.deepcopy(ctx.args), }, { __index = ctx }) -- shallow copy to avoid mutation local body = M.parse(win:text(), ctx) if not body then return -- error already shown in M.parse end if ctx.opts.on_submit then body = ctx.opts.on_submit(body, ctx) or body end if body:find("%S") then if edit == "body-file" then if ctx.opts.api then ctx.opts.api.input = ctx.opts.api.input or {} if ctx.opts.api.input.variables then ctx.opts.api.input.variables.body = body else ctx.opts.api.input.body = body end else ctx.input = body vim.list_extend(ctx.args, { "--body-file", "-" }) end else if ctx.opts.api then ctx.opts.api.fields = ctx.opts.api.fields or {} ctx.opts.api.fields[edit] = body else vim.list_extend(ctx.args, { "--" .. edit, body }) end end end vim.cmd.stopinsert() vim.schedule(function() M._run(ctx) end) end return M ================================================ FILE: lua/snacks/gh/api.lua ================================================ local Async = require("snacks.picker.util.async") local Item = require("snacks.gh.item") local Proc = require("snacks.util.spawn") ---@class snacks.gh.api local M = {} ---@type table local cache = setmetatable({}, { __mode = "v" }) local pr_cache = {} ---@type table ---@type table local config = { base = { list = { "author", "closedAt", "createdAt", "id", "body", "labels", "number", "reactionGroups", "state", "title", "updatedAt", "url", }, view = { "comments" }, text = { "author", "hash", "label", "title" }, options = { "app", "assignee", "author", "jq", "label", "repo", "search", "state" }, }, api = { options = { "cache", "jq", "method", "paginate", "silent", "slurp" }, }, issue = { list = { "stateReason" }, options = { "mention", "milestone" }, ---@param item snacks.picker.gh.Item transform = function(item) item.status = item.state == "closed" and item.state_reason or item.state return item end, }, pr = { options = { "base", "draft" }, list = { "mergedAt", "changedFiles", "mergeable", "mergeStateStatus", "isDraft", }, view = { "additions", "baseRefName", "deletions", "headRefName", "headRefOid", "mergedAt", "statusCheckRollup", "reviews", }, ---@param item snacks.picker.gh.Item transform = function(item) item.status = item.draft and "draft" or item.state return item end, }, } ---@param item snacks.gh.api.View local function cache_get(item) return cache[Item.to_uri(item)] end ---@param item snacks.picker.gh.Item local function cache_set(item) cache[item.uri] = item return item end ---@generic T ---@param fn fun(cb:fun(proc:snacks.spawn.Proc, data?:any), opts:T): snacks.spawn.Proc ---@return fun(opts:T): any? local function wrap_sync(fn) ---@async return function(opts) local ret ---@type any fn(function(_, data) ret = data end, opts):wait() return ret end end --- Cleanup GraphQL internal nodes and reaction groups ---@param ret table local function clean_graphql(ret) for k, v in pairs(ret) do if type(v) == "table" then clean_graphql(v) end if k == "reactionGroups" and type(v) == "table" then ---@param r snacks.gh.Reaction ret[k] = vim.tbl_filter(function(r) return r.users and r.users.totalCount and r.users.totalCount > 0 end, v) ret[k] = #ret[k] > 0 and ret[k] or nil elseif type(v) == "table" and type(v.nodes) == "table" and vim.tbl_count(v) == 1 then ret[k] = v.nodes elseif v == vim.NIL then ret[k] = nil end end return ret end ---@param what "issue" | "pr" ---@param key "list" | "view" local function get_opts(what, key) local base = vim.deepcopy(config.base) local specific = vim.deepcopy(config[what] or {}) base.type = what base.fields = vim.list_extend(base.list or {}, specific.list or {}) if key ~= "list" then base.fields = vim.list_extend(base.fields, base[key] or {}) base.fields = vim.list_extend(base.fields, specific[key] or {}) end base.text = vim.list_extend(base.text, specific.text or {}) base.options = vim.list_extend(base.options, specific.options or {}) base.transform = specific.transform return base end ---@param args string[] ---@param options string[] ---@param opts table local function set_options(args, options, opts) for _, option in ipairs(options or {}) do local value = opts[option] ---@type string|boolean|nil if type(value) == "boolean" and value then args[#args + 1] = "--" .. option elseif value and value ~= "" then vim.list_extend(args, { "--" .. option, tostring(value) }) end end end ---@param cb fun(proc: snacks.spawn.Proc, data?: string) ---@param opts snacks.gh.api.Cmd function M.cmd(cb, opts) opts = opts or {} local args = vim.deepcopy(opts.args) if opts.repo then vim.list_extend(args, { "--repo", opts.repo }) end local Spawn = require("snacks.util.spawn") local async = Async.running() local ret ---@type snacks.spawn.Proc if async then async:on("abort", function() if ret and ret:running() then ret:kill() end end) end ret = Spawn.new({ cmd = "gh", args = args, input = opts.input, timeout = 10000, -- debug = true, on_exit = function(proc, err) if err then vim.schedule(function() if not proc.aborted then if opts.notify ~= false then Snacks.debug.cmd({ header = "GH Error", cmd = { "gh", unpack(args) }, footer = proc:err(), level = vim.log.levels.ERROR, props = { input = opts.input }, }) end if opts.on_error then opts.on_error(proc, proc:err()) end end end) return end return cb(proc, not err and proc:out() or nil) end, }) return ret end M.cmd_sync = wrap_sync(M.cmd) ---@param cb fun(proc: snacks.spawn.Proc, data?: unknown) ---@param opts snacks.gh.api.Fetch function M.fetch(cb, opts) local args = vim.deepcopy(opts.args) vim.list_extend(args, { "--json", table.concat(opts.fields, ",") }) return M.cmd(function(proc, data) cb(proc, data and proc:json() or nil) end, { args = args, repo = opts.repo, notify = opts.notify, }) end M.fetch_sync = wrap_sync(M.fetch) ---@param cb fun(proc: snacks.spawn.Proc, data?: table) ---@param opts snacks.gh.api.Api function M.request(cb, opts) local args = { "api", opts.endpoint } set_options(args, config.api.options or {}, opts) if opts.input then vim.list_extend(args, { "--input", "-" }) end for k, v in pairs(opts.fields or {}) do vim.list_extend(args, { "--raw-field", ("%s=%s"):format(k, tostring(v)) }) end for k, v in pairs(opts.params or {}) do vim.list_extend(args, { "--field", ("%s=%s"):format(k, tostring(v)) }) end for k, v in pairs(opts.header or {}) do vim.list_extend(args, { "--header", ("%s:%s"):format(k, tostring(v)) }) end return M.cmd(function(proc, data) cb(proc, data and data:find("%S") and proc:json() or nil) end, { args = args, input = opts.input and vim.json.encode(opts.input) or nil, on_error = opts.on_error, }) end M.request_sync = wrap_sync(M.request) ---@param cb fun(proc: snacks.spawn.Proc, data?: table) ---@param opts snacks.gh.api.GraphQL function M.graphql(cb, opts) opts = Snacks.config.merge(vim.deepcopy(opts), { endpoint = "graphql", fields = { query = opts.query, }, }) return M.request(function(proc, data) if not data then return end if data.errors then local msgs = {} ---@type string[] for _, err in ipairs(data.errors) do msgs[#msgs + 1] = err.message end vim.schedule(function() Snacks.debug.cmd({ header = "GH GraphQL Error", cmd = { "gh", "api", "graphql" }, footer = table.concat(msgs, "\n"), level = vim.log.levels.ERROR, }) if opts.on_error then opts.on_error(proc, table.concat(msgs, "\n")) end end) return end cb(proc, clean_graphql(data.data)) end, opts) end M.graphql_sync = wrap_sync(M.graphql) ---@async function M.user() ---@type snacks.gh.User return M.request_sync({ endpoint = "/user", }) end ---@param what "issue" | "pr" ---@param cb fun(items?: snacks.picker.gh.Item[]) ---@param opts? snacks.picker.gh.Config function M.list(what, cb, opts) opts = opts or {} local api_opts = get_opts(what, "list") local args = { what, "list" } vim.list_extend(args, { "--limit", tostring(opts.limit or 50) }) set_options(args, api_opts.options, opts) ---@param data? snacks.gh.Item[] return M.fetch(function(_, data) if not data then return cb() end ---@param item snacks.gh.Item return cb(vim.tbl_map(function(item) return cache_set(Item.new(item, api_opts)) end, data)) end, { args = args, fields = api_opts.fields, repo = opts.repo, }) end ---@param cb fun(item?: snacks.picker.gh.Item, updated?: boolean) ---@param item snacks.gh.api.View|{number?: number} ---@param opts? { fields?: string[], force?: boolean } function M.view(cb, item, opts) opts = opts or {} local api_opts = get_opts(item.type, "view") if opts.fields then api_opts.fields = vim.list_extend(api_opts.fields, opts.fields) end item = M.get_cached(item) local todo = Item.is(item) and item:need(api_opts.fields) or api_opts.fields if opts.force or item.dirty then todo = api_opts.fields end if #todo == 0 then cb(item, false) return end local args = { item.type, "view", tostring(item.number) } local need_reviews = item.type == "pr" and vim.tbl_contains(todo, "comments") local it ---@type snacks.gh.Item? local completed = 0 local fetch_comments = false local procs = {} ---@type snacks.spawn.Proc[] ---@param data? snacks.gh.Item|{} local function handler(data) it = data and vim.tbl_extend("force", it or {}, data or {}) or it if fetch_comments then fetch_comments = false item.repo = it and Item.get_repo(it.url) or nil procs[#procs + 1] = M.comments(item, handler) end completed = completed + 1 if completed < #procs then return end if not it then return cb() end item = Item.new(item, api_opts) item:update(it, todo) item.dirty = false cb(cache_set(item), true) end if need_reviews then todo = vim.tbl_filter(function(f) return f ~= "comments" and f ~= "reviews" end, todo) if item.repo then procs[#procs + 1] = M.comments(item, handler) else -- fetch comments once we fetched the item fetch_comments = true end end if #todo > 0 then ---@param data? snacks.gh.Item procs[#procs + 1] = M.fetch(function(_, data) handler(data) end, { args = args, fields = todo, repo = item.repo or api_opts.repo, }) end ---@type snacks.picker.Waitable return { ---@async wait = function() for _, proc in ipairs(procs) do proc:wait() end end, } end ---@param item snacks.gh.api.View ---@param opts? { fields?: string[], force?: boolean } ---@async function M.get(item, opts) local ret ---@type snacks.picker.gh.Item? local procs = M.view(function(it) ret = it end, item, opts) if procs then procs:wait() end return ret end ---@param item snacks.gh.api.View function M.get_cached(item) return not Item.is(item) and cache_get(item) or item end ---@param item snacks.picker.gh.Item function M.refresh(item) item.dirty = true cache_set(item) for _, buf in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_loaded(buf) then if vim.api.nvim_buf_get_name(buf) == item.uri then require("snacks.gh.buf").attach(buf, item) end end end end ---@param cb fun(data?: {comments: snacks.gh.Comment[], reviews: snacks.gh.Review[]}) ---@param item snacks.gh.api.View function M.comments(item, cb) local owner, name = item.repo:match("^(.-)/(.-)$") return M.graphql(function(_, data) if not data then return cb() end cb(data.repository.pullRequest) end, { -- comment params = { owner = owner, name = name, number = item.number, }, -- inject: graphql query = [[ query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviewThreads(first: 100) { nodes { id diffSide comments(first: 50) { nodes { id } } } } reviews(first: 100) { nodes { id databaseId author { login } authorAssociation body state commit { oid } submittedAt createdAt viewerDidAuthor reactionGroups { content users { totalCount } } comments(first: 50) { nodes { id databaseId body path diffHunk line startLine originalLine originalStartLine createdAt subjectType author { login } replyTo { id databaseId } reactionGroups { content users { totalCount } } } } } } comments(first: 100) { nodes { id databaseId body author { login } authorAssociation createdAt reactionGroups { content users { totalCount } } } } } } } ]], }) end ---@async function M.current_pr() local root = Snacks.git.get_root(vim.uv.cwd() or ".") if not root then return end ---@type snacks.picker.gh.Item? local pr local branch = Proc.exec({ "git", "branch", "--show-current" }) local key = root .. "::" .. branch if pr_cache[key] then return pr_cache[key] end -- try with `pr view` first local api_opts = get_opts("pr", "list") pr = M.fetch_sync({ args = { "pr", "view" }, fields = api_opts.fields, notify = false, }) pr = pr and cache_set(Item.new(pr, api_opts)) or nil if pr then pr_cache[key] = pr return pr end -- assume this is the main branch of a fork local author, main = branch:match("^(.-)/(.+)$") if not author or not main then return end local repo = M.cmd_sync({ args = { "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner" }, notify = false, }) if not repo then return end repo = vim.trim(repo) M.list("pr", function(items) pr_cache[key] = items and items[1] or nil pr = pr_cache[key] end, { author = author, head = main, base = main, repo = repo, limit = 1, notify = false, }):wait() return pr end return M ================================================ FILE: lua/snacks/gh/buf.lua ================================================ local Actions = require("snacks.gh.actions") local Api = require("snacks.gh.api") local Item = require("snacks.gh.item") local Render = require("snacks.gh.render") ---@class snacks.gh.Buf ---@field buf number ---@field opts snacks.gh.Config ---@field item snacks.gh.api.View local M = {} M.__index = M ---@class vim.var_accessor ---@field snacks_gh? { repo: string, type: string, number: number } ---@type table M.attached = {} local did_setup = false ---@param buf number ---@param item snacks.gh.api.View function M.new(buf, item) local self = setmetatable({}, M) self.buf = buf self.item = item self.opts = vim.deepcopy(Snacks.gh.config()) self.opts.bo = Snacks.config.merge({}, self.opts.bo, { buftype = "acwrite", swapfile = false, filetype = "markdown.gh", }) vim.b[buf].snacks_gh = { repo = item.repo, type = item.type, number = tonumber(item.number) or item.number, } self:bo() self:wo() self:keys() M.attached[buf] = self vim.schedule(function() self:render() end) return self end function M:update() if not self:valid() then return end self:render({ force = true }) end function M:keys() local actions = Actions.get_actions(self.item, { items = { self.item } }) ---@param name string local function wrap(name) local action = actions[name] if not action then return end ---@type snacks.gh.Keymap.fn return function(item) action.action(item, { items = { item } }) end end for name, km in pairs(self.opts.keys or {}) do if km ~= false then local rhs = km[2] local desc = km.desc local action = type(rhs) == "function" and rhs or type(rhs) == "string" and wrap(rhs) or nil if action then Snacks.keymap.set(km.mode or "n", km[1], function() action(self.item, self) end, { buffer = self.buf, desc = desc }) elseif type(rhs) == "string" and not Actions.actions[rhs] then Snacks.notify.error(("Invalid gh buffer keymap action `%s:%s`"):format(name, rhs)) end end end end function M:valid() return self.buf and M.attached[self.buf] == self and vim.api.nvim_buf_is_valid(self.buf) end ---@param opts? {force?:boolean} function M:render(opts) if not self:valid() then return end opts = opts or {} self.item = Api.get_cached(self.item) self:bo() self:wo() local spinner ---@type snacks.util.Spinner? local proc = Api.view(function(it, updated) vim.schedule(function() if not self:valid() then return end if spinner then spinner:stop() end self.item = it if updated then Render.render(self.buf, it, self.opts) self:keys() end end) end, self.item, { force = opts.force }) -- initial render (is partial if proc is running) if Item.is(self.item) then Render.render(self.buf, self.item, Snacks.config.merge({}, vim.deepcopy(self.opts), { partial = proc ~= nil })) end if proc then spinner = Snacks.picker.util.spinner(self.buf) end end function M:bo() vim.b[self.buf].snacks_statuscolumn_left = false Snacks.util.bo(self.buf, self.opts.bo) end function M:wo() for _, win in ipairs(vim.fn.win_findbuf(self.buf)) do Snacks.util.wo(win, self.opts.wo) end end ---@param buf number ---@param item? snacks.gh.api.View function M.attach(buf, item) M.setup() local ret = M.attached[buf] if ret then ret:update() return ret end if not item then local name = vim.api.nvim_buf_get_name(buf) local repo, type, number = name:match("^gh://([^/]+/[^/]+)/([^/]+)/(%d+)$") if not repo then Snacks.notify.error("Invalid gh:// buffer: " .. name) return end item = { repo = repo, type = type, number = number, } end return M.new(buf, item) end --@param buf number function M.detach(buf) if not M.attached[buf] then return end M.attached[buf] = nil end function M.setup() if did_setup then return end did_setup = true local group = vim.api.nvim_create_augroup("snacks.gh.buf", { clear = true }) vim.api.nvim_create_autocmd("BufReadCmd", { pattern = "gh://*", group = group, callback = function(e) vim.schedule(function() -- schedule since Neovim otherwise runs this in the autocmd window M.attach(e.buf) end) end, }) -- prevent altering the original image file vim.api.nvim_create_autocmd("BufWriteCmd", { pattern = "gh://*", group = group, callback = function(e) vim.bo[e.buf].modified = false end, }) vim.api.nvim_create_autocmd("BufWinEnter", { pattern = "gh://*", group = group, callback = function(e) local buf = M.attached[e.buf] if buf then buf:bo() buf:wo() end end, }) vim.api.nvim_create_autocmd("ColorScheme", { group = group, callback = function(e) for _, buf in pairs(M.attached) do buf:render() end end, }) -- detach on buffer delete vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, { pattern = "gh://*", group = group, callback = function(ev) M.detach(ev.buf) end, }) -- Keep some empty windows in sessions vim.api.nvim_create_autocmd("ExitPre", { group = group, callback = function() local keep = { "markdown.gh" } for _, win in ipairs(vim.api.nvim_list_wins()) do local buf = vim.api.nvim_win_get_buf(win) if vim.tbl_contains(keep, vim.bo[buf].filetype) then vim.bo[buf].buftype = "" -- set buftype to empty to keep the window end end end, }) end return M ================================================ FILE: lua/snacks/gh/init.lua ================================================ ---@class snacks.gh ---@field api snacks.gh.api ---@field item snacks.picker.gh.Item local M = setmetatable({}, { ---@param M snacks.gh __index = function(M, k) if vim.tbl_contains({ "api" }, k) then M[k] = require("snacks.gh." .. k) end return rawget(M, k) end, }) M.meta = { desc = "GitHub CLI integration", needs_setup = false, } ---@alias snacks.gh.Keymap.fn fun(item:snacks.picker.gh.Item, buf:snacks.gh.Buf) ---@class snacks.gh.Keymap: vim.keymap.set.Opts ---@field [1] string lhs ---@field [2] string|snacks.gh.Keymap.fn rhs ---@field mode? string|string[] defaults to `n` ---@class snacks.gh.Config local defaults = { --- Keymaps for GitHub buffers ---@type table? -- stylua: ignore keys = { select = { "", "gh_actions", desc = "Select Action" }, edit = { "i" , "gh_edit" , desc = "Edit" }, comment = { "a" , "gh_comment", desc = "Add Comment" }, close = { "c" , "gh_close" , desc = "Close" }, reopen = { "o" , "gh_reopen" , desc = "Reopen" }, }, ---@type vim.wo|{} wo = { breakindent = true, wrap = true, showbreak = "", linebreak = true, number = false, relativenumber = false, foldexpr = "v:lua.vim.treesitter.foldexpr()", foldmethod = "expr", concealcursor = "n", conceallevel = 2, list = false, winhighlight = Snacks.util.winhl({ Normal = "SnacksGhNormal", NormalFloat = "SnacksGhNormalFloat", FloatBorder = "SnacksGhBorder", FloatTitle = "SnacksGhTitle", FloatFooter = "SnacksGhFooter", }), }, ---@type vim.bo|{} bo = {}, diff = { min = 4, -- minimum number of lines changed to show diff wrap = 80, -- wrap diff lines at this length }, scratch = { height = 15, -- height of scratch window }, -- stylua: ignore icons = { logo = " ", user= " ", checkmark = " ", crossmark = " ", block = "■", file = " ", checks = { pending = " ", success = " ", failure = "", skipped = " ", }, issue = { open = " ", completed = " ", other = " " }, pr = { open = " ", closed = " ", merged = " ", draft = " ", other = " ", }, review = { approved = " ", changes_requested = " ", commented = " ", dismissed = " ", pending = " ", }, merge_status = { clean = " ", dirty = " ", blocked = " ", unstable = " " }, reactions = { thumbs_up = "👍", thumbs_down = "👎", eyes = "👀", confused = "😕", heart = "❤️", hooray = "🎉", laugh = "😄", rocket = "🚀", }, }, } local function diff_linenr(hl) local fg = Snacks.util.color({ hl, "SnacksGhNormalFloat", "Normal" }) local bg = Snacks.util.color({ hl, "SnacksGhNormalFloat", "Normal" }, "bg") bg = bg or vim.o.background == "dark" and "#1e1e1e" or "#f5f5f5" return { fg = fg, bg = Snacks.util.blend(fg, bg, 0.1), } end Snacks.util.set_hl({ Normal = "NormalFloat", NormalFloat = "NormalFloat", Border = "FloatBorder", Title = "FloatTitle", ScratchTitle = "Number", ScratchBorder = "Number", Footer = "FloatFooter", Number = "Number", Green = { fg = "#28a745" }, Purple = { fg = "#6f42c1" }, Gray = { fg = "#6a737d" }, Red = { fg = "#d73a49" }, Branch = "@markup.link", IssueOpen = "SnacksGhGreen", IssueCompleted = "SnacksGhPurple", IssueOther = "SnacksGhGray", PrOpen = "SnacksGhGreen", PrClosed = "SnacksGhRed", PrMerged = "SnacksGhPurple", PrDraft = "SnacksGhGray", Label = "@property", Delim = "@punctuation.delimiter", UserBadge = "DiagnosticInfo", AuthorBadge = "DiagnosticWarn", OwnerBadge = "DiagnosticError", BotBadge = { fg = Snacks.util.color({ "NonText", "SignColumn", "FoldColumn" }) }, ReactionBadge = "Special", AssocBadge = {}, -- will be set to inverse of Normal StatBadge = "Special", PrClean = "DiagnosticInfo", PrUnstable = "DiagnosticWarn", PrDirty = "DiagnosticError", PrBlocked = "DiagnosticError", Additions = "SnacksGhGreen", Deletions = "SnacksGhRed", CheckPending = "DiagnosticWarn", CheckSuccess = "SnacksGhGreen", CheckFailure = "SnacksGhRed", CheckSkipped = "SnacksGhStat", ReviewApproved = "SnacksGhGreen", ReviewChangesRequested = "DiagnosticError", ReviewCommented = {}, ReviewPending = "DiagnosticWarn", CommentAction = "@property", DiffHeader = "DiagnosticVirtualTextInfo", DiffAdd = "DiffAdd", DiffDelete = "DiffDelete", DiffContext = "DiffChange", DiffAddLineNr = diff_linenr("DiffAdd"), DiffDeleteLineNr = diff_linenr("DiffDelete"), DiffContextLineNr = diff_linenr("DiffChange"), Stat = { fg = Snacks.util.color("SignColumn") }, }, { default = true, prefix = "SnacksGh" }) M._config = nil ---@type snacks.gh.Config? local did_setup = false ---@param opts? snacks.picker.gh.issue.Config function M.issue(opts) return Snacks.picker.gh_issue(opts) end ---@param opts? snacks.picker.gh.pr.Config function M.pr(opts) return Snacks.picker.gh_pr(opts) end ---@private function M.config() M._config = M._config or Snacks.config.get("gh", defaults) return M._config end ---@private ---@param ev? vim.api.keyset.create_autocmd.callback_args function M.setup(ev) if did_setup then return end did_setup = true -- vim.treesitter.language.register("markdown", "gh") require("snacks.gh.buf").setup() if ev then vim.schedule(function() require("snacks.gh.buf").attach(ev.buf) end) end end return M ================================================ FILE: lua/snacks/gh/item.lua ================================================ ---@class snacks.picker.gh.Item ---@field opts snacks.gh.api.Config local M = {} local time_fields = { created = "createdAt", updated = "updatedAt", closed = "closedAt", merged = "mergedAt", submitted = "submittedAt", } ---@param s? string ---@return number? local function ts(s) if not s then return nil end local year, month, day, hour, min, sec = s:match("^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$") if not year then return end local t = os.time({ year = assert(tonumber(year), "invalid year in timestamp: " .. s), month = assert(tonumber(month), "invalid month in timestamp: " .. s), day = assert(tonumber(day), "invalid day in timestamp: " .. s), hour = assert(tonumber(hour), "invalid hour in timestamp: " .. s), min = assert(tonumber(min), "invalid minute in timestamp: " .. s), sec = assert(tonumber(sec), "invalid second in timestamp: " .. s), isdst = false, }) -- Calculate UTC offset local now = os.time() local utc_date = os.date("!*t", now) --[[@as osdate]] utc_date.isdst = false return t + os.difftime(now, os.time(utc_date)) end ---@param obj {body?:string} local function fix(obj) obj.body = obj.body and obj.body:gsub("\r\n", "\n") or nil for key, field in pairs(time_fields) do ---@diagnostic disable-next-line: no-unknown, assign-type-mismatch obj[key] = obj[key] or ts(obj[field] or obj[field:gsub("At", "_at")]) end end ---@param item snacks.gh.Item ---@param opts snacks.gh.api.Config function M.new(item, opts) if getmetatable(item) == M then return item --[[@as snacks.picker.gh.Item]] end local self = setmetatable({}, M) --[[@as snacks.picker.gh.Item]] for k, v in pairs(item) do if v == vim.NIL then item[k] = nil end end self.item = item self.opts = opts self.type = opts.type self.repo = opts.repo self.fields = {} for _, field in ipairs(opts.fields or {}) do self.fields[field] = true end self:update() return self --[[@as snacks.picker.gh.Item]] end ---@param item any function M.is(item) return getmetatable(item) == M end function M:__index(key) if time_fields[key] then return ts(self.item[time_fields[key]]) end return rawget(M, key) or rawget(self.item, key) end ---@param fields string[] function M:need(fields) ---@param field string return vim.tbl_filter(function(field) return not self.fields[field] end, fields) end ---@param data? table ---@param fields? string[] function M:update(data, fields) for k, v in pairs(data or {}) do ---@diagnostic disable-next-line: no-unknown self.item[k] = v ~= vim.NIL and v or nil end local item = self.item for _, field in ipairs(fields or {}) do if data and data[field] == nil then self.item[field] = nil end self.fields[field] = true end if not self.repo and item.url then local repo = M.get_repo(item.url) if repo then self.repo = repo end end if self.repo then self.uri = ("gh://%s/%s/%s"):format(self.repo, self.type, tostring(item.number or "")) self.file = self.uri end self.author = item.author and item.author.login or nil self.hash = item.number and ("#" .. tostring(item.number)) or nil self.state = item.state and item.state:lower() or nil self.status = self.state self.state_reason = item.stateReason and item.stateReason:lower() or nil self.draft = item.isDraft self.label = item.labels and table.concat( ---@param label snacks.gh.Label vim.tbl_map(function(label) return label.name end, item.labels), "," ) or nil self.body = item.body and item.body:gsub("\r\n", "\n") or nil vim.tbl_map(fix, item.comments or {}) self.pendingReview = nil for _, review in ipairs(item.reviews or {}) do fix(review) if review.state == "PENDING" and review.viewerDidAuthor then self.pendingReview = review end vim.tbl_map(fix, review.comments or {}) end if item.reactionGroups then self.reactions = {} for _, reaction in ipairs(item.reactionGroups) do table.insert( self.reactions, { content = reaction.content:lower(), count = reaction.users and reaction.users.totalCount or 0 } ) end end if self.opts.transform then self.opts.transform(self) end self.text = Snacks.picker.util.text(self.item, self.opts.text or self.opts.fields or {}) end ---@param item snacks.gh.api.View function M.to_uri(item) if item.uri then return item.uri end return ("gh://%s/%s/%s"):format(item.repo or "", assert(item.type), tostring(assert(item.number))) end ---@param url string function M.get_repo(url) local path = url:find("^http") and url:gsub("^https?://[^/]+/", "") or url:gsub("^[^/]+/", "") return path:match("([^/]+/[^/]+)") --[[@as string?]] end return M ================================================ FILE: lua/snacks/gh/render/init.lua ================================================ local Markdown = require("snacks.picker.util.markdown") local M = {} local H = Snacks.picker.highlight local U = Snacks.picker.util -- tracking comment_skip is needed because review comments can appear both: -- 1. As top-level review.comments -- 2. As replies in the thread tree ---@class snacks.gh.render.ctx ---@field item snacks.picker.gh.Item ---@field opts snacks.gh.Config ---@field comment_skip table ---@field is_review? boolean ---@field diff? boolean render diffs (defaults to true) ---@field markdown? boolean render in a markdown buffer (defaults to true) ---@field annotations? snacks.diff.Annotation[] ---@param field string local function time_prop(field) return { name = U.title(field), hl = function(item) if not item[field] then return end return { { U.reltime(item[field]), "SnacksPickerGitDate" } } end, } end ---@type {name: string, hl:fun(item:snacks.picker.gh.Item, opts:snacks.gh.Config):snacks.picker.Highlight[]? }[] M.props = { { name = "Status", hl = function(item, opts) -- Status Icon local icons = opts.icons[item.type] local status = icons[item.status] and item.status or "other" local ret = {} ---@type snacks.picker.Highlight[] if status then local icon = icons[status] local hl = "SnacksGh" .. U.title(item.type) .. U.title(status) local text = icon .. U.title(item.status or "other") H.extend(ret, H.badge(text, { bg = Snacks.util.color(hl), fg = "#ffffff" })) end if item.baseRefName and item.headRefName then ret[#ret + 1] = { " " } vim.list_extend(ret, { { item.baseRefName, "SnacksGhBranch" }, { " ← ", "SnacksGhDelim" }, { item.headRefName, "SnacksGhBranch" }, }) end return ret end, }, { name = "Repo", hl = function(item, opts) return { { opts.icons.logo, "Special" }, { item.repo, "@markup.link" } } end, }, { name = "Author", hl = function(item, opts) return H.badge(opts.icons.user .. " " .. item.author, "SnacksGhUserBadge") end, }, time_prop("created"), time_prop("updated"), time_prop("closed"), time_prop("merged"), { name = "Reactions", hl = function(item, opts) if item.reactions then local ret = {} ---@type snacks.picker.Highlight[] table.sort(item.reactions, function(a, b) return a.count > b.count end) for _, r in pairs(item.reactions) do local badge = H.badge(opts.icons.reactions[r.content] .. " " .. tostring(r.count), "SnacksGhReactionBadge") vim.list_extend(ret, badge) ret[#ret + 1] = { " " } end return ret end end, }, { name = "Labels", hl = function(item) local ret = {} ---@type snacks.picker.Highlight[] for _, label in ipairs(item.item.labels or {}) do local color = label.color or "888888" local badge = H.badge(label.name, "#" .. color) H.extend(ret, badge) ret[#ret + 1] = { " " } end return ret end, }, { name = "Assignees", hl = function(item) local ret = {} ---@type snacks.picker.Highlight[] for _, u in ipairs(item.item.assignees or {}) do local badge = H.badge(u.login, "Identifier") vim.list_extend(ret, badge) ret[#ret + 1] = { " " } end return ret end, }, { name = "Milestone", hl = function(item) if item.item.milestone then return H.badge(item.item.milestone.title, "Title") end end, }, { name = "Merge Status", hl = function(item, opts) if not item.mergeStateStatus or item.state ~= "open" then return end local status = item.mergeStateStatus:lower() status = opts.icons.merge_status[status] and status or "dirty" local icon = opts.icons.merge_status[status] status = U.title(status) local hl = "SnacksGhPr" .. status return { { icon .. " " .. status, hl } } end, }, { name = "Checks", hl = function(item, opts) if item.type ~= "pr" then return end if #(item.statusCheckRollup or {}) == 0 then return { { " " } } end local workflows = {} ---@type table for _, check in ipairs(item.statusCheckRollup or {}) do local status, name = nil, nil ---@type string, string if check.__typename == "CheckRun" then name = check.workflowName .. ":" .. check.name status = check.status == "COMPLETED" and (check.conclusion or "pending") or check.status elseif check.__typename == "StatusContext" then name = check.context status = check.state end if name and status then status = U.title(status:lower()) workflows[name] = status end end local stats = {} ---@type table for _, status in pairs(workflows) do stats[status] = (stats[status] or 0) + 1 end local ret = {} ---@type snacks.picker.Highlight[] local order = { "Success", "Failure", "Pending", "Skipped" } for _, status in ipairs(order) do local count = stats[status] if count then local icon = opts.icons.checks[status:lower()] or opts.icons.checks["pending"] local badge = H.badge(icon .. " " .. tostring(count), "SnacksGhCheck" .. status) vim.list_extend(ret, badge) ret[#ret + 1] = { " " } end end ret[#ret + 1] = { " " } for _, status in ipairs(order) do local count = stats[status] if count then ret[#ret + 1] = { string.rep(opts.icons.block, count), "SnacksGhCheck" .. status } end end return ret end, }, { name = "Mergeable", hl = function(item, opts) if not item.mergeable then return end return { { (item.mergeable and opts.icons.checkmark or opts.icons.crossmark), item.mergeable and "SnacksGhPrClean" or "SnacksGhPrDirty", }, } or nil end, }, { name = "Changes", hl = function(item, opts) if item.type ~= "pr" then return end local ret = {} ---@type snacks.picker.Highlight[] if item.changedFiles then ret = H.badge(opts.icons.file .. item.changedFiles, "SnacksGhStatBadge") ret[#ret + 1] = { " " } end if (item.additions or 0) > 0 then ret[#ret + 1] = { "+" .. tostring(item.additions), "SnacksGhAdditions" } ret[#ret + 1] = { " " } end if (item.deletions or 0) > 0 then ret[#ret + 1] = { "-" .. tostring(item.deletions), "SnacksGhDeletions" } ret[#ret + 1] = { " " } end if #ret == 0 then return end if item.additions and item.deletions then local unit = math.ceil((item.additions + item.deletions) / 5) local additions = math.floor((0.5 + item.additions) / unit) local deletions = math.floor((0.5 + item.deletions) / unit) local neutral = 5 - additions - deletions ret[#ret + 1] = { string.rep(opts.icons.block, additions), "SnacksGhAdditions" } ret[#ret + 1] = { string.rep(opts.icons.block, deletions), "SnacksGhDeletions" } ret[#ret + 1] = { string.rep(opts.icons.block, neutral), "SnacksGhStat" } end return ret end, }, } local ns = vim.api.nvim_create_namespace("snacks.gh.render") ---@param buf number ---@param item snacks.picker.gh.Item ---@param opts snacks.gh.Config|{partial?:boolean} function M.render(buf, item, opts) if not vim.api.nvim_buf_is_valid(buf) then return end ---@type snacks.gh.render.ctx local ctx = { item = item, opts = opts, comment_skip = {}, } local lines = {} ---@type snacks.picker.Highlight[][] item.msg = item.title ---@diagnostic disable-next-line: missing-fields lines[#lines + 1] = Snacks.picker.format.commit_message(item, {}) vim.list_extend(lines[#lines], { { " " }, { item.hash, "SnacksPickerDimmed" } }) -- space after title lines[#lines + 1] = {} -- empty line for _, prop in ipairs(M.props) do local value = prop.hl(item, opts) if value and #value > 0 then local line = {} ---@type snacks.picker.Highlight[] line[#line + 1] = { prop.name, "SnacksGhLabel" } line[#line + 1] = { ":", "SnacksGhDelim" } line[#line + 1] = { " " } H.extend(line, value) lines[#lines + 1] = line end end lines[#lines + 1] = {} -- empty line lines[#lines + 1] = { { "---", "@punctuation.special.markdown" } } lines[#lines + 1] = {} -- empty line do local text = item.body or "" text = text:gsub("<%!%-%-.-%-%->%s*", "") -- remove html comments local body = vim.split(text or "", "\n") while #body > 0 and body[1]:match("^%s*$") do table.remove(body, 1) end for _, l in ipairs(body) do lines[#lines + 1] = { { l } } end end local threads = M.get_threads(item) if #threads > 0 then lines[#lines + 1] = { { "" } } -- empty line lines[#lines + 1] = { { "---", "@punctuation.special.markdown" } } lines[#lines + 1] = {} -- empty line for _, thread in ipairs(threads) do local c = #lines ctx.is_review = thread.state ~= nil if ctx.is_review then ---@cast thread snacks.gh.Review vim.list_extend(lines, M.review(thread, ctx)) else ---@cast thread snacks.gh.Comment vim.list_extend(lines, M.comment(thread, ctx)) end if #lines > c then -- only add separator if there were comments added lines[#lines + 1] = {} -- empty line end end end local changed = H.render(buf, ns, lines) if changed then Markdown.render(buf, { bullets = false }) end vim.schedule(function() for _, win in ipairs(vim.fn.win_findbuf(buf)) do vim.api.nvim_win_call(win, function() if vim.wo.foldmethod == "expr" then vim.wo.foldmethod = "expr" end end) end end) end ---@param item snacks.picker.gh.Item function M.get_threads(item) local ret = {} ---@type snacks.gh.Thread[] vim.list_extend(ret, item.comments or {}) vim.list_extend(ret, item.reviews or {}) table.sort(ret, function(a, b) return a.created < b.created end) return ret end ---@param comment snacks.gh.Comment|snacks.gh.Review ---@param opts? {text?:string} ---@param ctx snacks.gh.render.ctx function M.comment_header(comment, opts, ctx) opts = opts or {} local ret = {} ---@type snacks.picker.Highlight[] local is_bot = comment.author.login == "github-actions" or comment.author.login:find("copilot") H.extend( ret, H.badge( ("%s %s"):format(is_bot and ctx.opts.icons.logo or ctx.opts.icons.user, comment.author.login), is_bot and "SnacksGhBotBadge" or "SnacksGhUserBadge" ) ) if opts.text then ret[#ret + 1] = { opts.text, "SnacksGhCommentAction" } ret[#ret + 1] = { " " } end ret[#ret + 1] = { U.reltime(comment.created), "SnacksPickerGitDate" } local assoc = comment.authorAssociation assoc = assoc and assoc ~= "NONE" and U.title(assoc:lower()) or nil assoc = comment.author.login == ctx.item.author and "Author" or assoc if assoc then ret[#ret + 1] = { " " } H.extend( ret, H.badge( assoc, assoc == "Author" and "SnacksGhAuthorBadge" or assoc == "Owner" and "SnacksGhOwnerBadge" or "SnacksGhAssocBadge" ) ) end for _, r in ipairs(comment.reactionGroups or {}) do ret[#ret + 1] = { " " } local badge = H.badge( ctx.opts.icons.reactions[r.content:lower()] .. " " .. tostring(r.users.totalCount), "SnacksGhReactionBadge" ) H.extend(ret, badge) end return ret end ---@param item snacks.gh.Comment|snacks.gh.Review ---@param ctx snacks.gh.render.ctx function M.comment_body(item, ctx) local body = item.body or "" if body:match("^%s*$") then return {} end local ret = {} ---@type snacks.picker.Highlight[][] local md = {} ---@type string[] for _, line in ipairs(vim.split(body, "\n", { plain = true })) do if line:find("^```suggestion$") then local ft = item.path and vim.filetype.match({ filename = item.path }) or "" line = "```" .. ft ret[#ret + 1] = H.badge("Suggested change", "SnacksGhSuggestionBadge") md[#md + 1] = "" end md[#md + 1] = line ret[#ret + 1] = { { line } } end if ctx.markdown == false then -- if the filetype of the buffer is not markdown, -- we need to add proper highlights for the markdown content local extmarks = H.get_highlights({ code = table.concat(md, "\n"), ft = "markdown" }) for l, line in pairs(extmarks) do vim.list_extend(ret[l] or {}, line) end end return ret end ---@param lines snacks.picker.Highlight[][] ---@param ctx snacks.gh.render.ctx function M.indent(lines, ctx) -- indent guides for lines after the first local indent = {} ---@type snacks.picker.Highlight[] indent[#indent + 1] = { " ", "Normal" } indent[#indent + 1] = { col = 0, virt_text = { { " ", "Normal" }, { "┃", { "Normal", "@punctuation.definition.blockquote.markdown" } }, { " ", "Normal" }, }, virt_text_pos = "overlay", hl_mode = "combine", virt_text_repeat_linebreak = true, } --- first indent. In a markdown buffer, we need proper structure, --- so we conceal the list marker ---@type snacks.picker.Highlight[] local first = ctx.markdown == false and {} or { { col = 0, end_col = 3, conceal = "", priority = 1000, }, { " * ", "Normal" }, } local ret = {} ---@type snacks.picker.Highlight[][] for l, line in ipairs(lines) do local new = vim.deepcopy(l == 1 and first or indent) H.extend(new, line) ret[l] = new end return ret end ---@param comment snacks.gh.Comment ---@param ctx snacks.gh.render.ctx function M.comment_diff(comment, ctx) if not comment.path or not comment.diffHunk then return {} end local count = 1 local originalLine = comment.originalLine or comment.line or 1 if comment.originalStartLine then count = originalLine - comment.originalStartLine + 1 end count = math.max(ctx.opts.diff.min, math.abs(count)) local Diff = require("snacks.picker.util.diff") local diff = ("diff --git a/%s b/%s\n%s"):format(comment.path, comment.path, comment.diffHunk) local ret = Diff.format(diff, { max_hunk_lines = count, hunk_header = false, }) table.insert(ret, 1, { { "```" } }) table.insert(ret, { { "```" } }) return ret end ---@param comment snacks.gh.Comment ---@param ctx snacks.gh.render.ctx function M.annotate(comment, ctx) if not comment.path or not comment.diffHunk then return end local side = "right" for _, thread in ipairs(ctx.item.reviewThreads or {}) do for _, c in ipairs(thread.comments or {}) do if c.id == comment.id then side = (thread.diffSide or "RIGHT"):lower() break end end end ---@type snacks.diff.Annotation local ret = { side = side, file = comment.path, line = comment.line or comment.originalLine or 1, text = {}, } ctx.annotations = ctx.annotations or {} table.insert(ctx.annotations, ret) return ret end ---@param comment snacks.gh.Comment ---@param ctx snacks.gh.render.ctx function M.comment(comment, ctx) local ret = {} ---@type snacks.picker.Highlight[][] local header = {} ---@type snacks.picker.Highlight[] H.extend(header, M.comment_header(comment, {}, ctx)) ret[#ret + 1] = header local annotation ---@type snacks.diff.Annotation? if not comment.replyTo then annotation = M.annotate(comment, ctx) if ctx.diff ~= false then -- add diff hunk for top-level comments local diff = M.comment_diff(comment, ctx) if #diff > 0 then vim.list_extend(ret, diff) ret[#ret + 1] = {} -- empty line between diff and body end end end vim.list_extend(ret, M.comment_body(comment, ctx)) local replies = M.find_reply(comment.id, ctx) for _, reply in ipairs(replies) do ret[#ret + 1] = {} -- empty line between comment and reply vim.list_extend(ret, M.comment(reply, ctx)) ctx.comment_skip[reply.id] = true end if ctx.is_review then for _, line in ipairs(ret) do local reply_id = comment.replyTo and comment.replyTo.databaseId or comment.databaseId if reply_id then line[#line + 1] = { "", meta = { comment_id = reply_id } } end end end ret = M.indent(ret, ctx) if annotation then annotation.text = vim.deepcopy(ret) end return ret end ---@param id string ---@param ctx snacks.gh.render.ctx function M.find_reply(id, ctx) local ret = {} ---@type snacks.gh.Comment[] for _, review in ipairs(ctx.item.reviews or {}) do for _, comment in ipairs(review.comments or {}) do if comment.replyTo and comment.replyTo.id == id then ret[#ret + 1] = comment end end end return ret end ---@param review snacks.gh.Review ---@param ctx snacks.gh.render.ctx function M.review(review, ctx) local ret = {} ---@type snacks.picker.Highlight[][] ---@type snacks.gh.Comment[] local comments = vim.tbl_filter(function(c) return not ctx.comment_skip[c.id] end, review.comments or {}) if #comments == 0 and review.state == "COMMENTED" and ((review.body or ""):match("^%s*$")) then return ret end local header = {} ---@type snacks.picker.Highlight[] local state_icon = ctx.opts.icons.review[review.state:lower()] or ctx.opts.icons.pr.open H.extend(header, H.badge(state_icon, "SnacksGhReview" .. U.title(review.state:lower()):gsub(" ", ""))) header[#header + 1] = { " " } local texts = { ["CHANGES_REQUESTED"] = "requested changes", ["COMMENTED"] = "reviewed", } local text = texts[review.state] or review.state:lower():gsub("_", " ") H.extend(header, M.comment_header(review, { text = text }, ctx)) ret[#ret + 1] = header vim.list_extend(ret, M.comment_body(review, ctx)) for _, comment in ipairs(comments) do ret[#ret + 1] = {} -- empty line between review and comments vim.list_extend(ret, M.comment(comment, ctx)) end return M.indent(ret, ctx) end ---@param pr snacks.picker.gh.Item function M.annotations(pr) ---@type snacks.gh.render.ctx local ctx = { item = pr, opts = Snacks.gh.config(), comment_skip = {}, is_review = true, diff = false, markdown = false, } for _, review in ipairs(pr.reviews or {}) do M.review(review, ctx) end return ctx.annotations end return M ================================================ FILE: lua/snacks/gh/types.lua ================================================ ---@class snacks.gh.api.Config ---@field type "issue" | "pr" ---@field repo? string ---@field fields string[] ---@field view string[] -- fields to fetch for gh view ---@field list string[] -- fields to fetch for gh list ---@field text string[] ---@field options string[] ---@field transform? fun(item: snacks.picker.gh.Item): snacks.picker.gh.Item? ---@class snacks.picker.gh.list.Config: snacks.picker.gh.Config ---@field type "issue" | "pr" ---@class snacks.picker.gh.api.Config: snacks.picker.gh.Config ---@field api snacks.gh.api.Api ---@field transform? fun(item: snacks.picker.finder.Item): snacks.picker.finder.Item? ---@alias snacks.gh.api.View snacks.picker.gh.Item|{number: number, type: string, repo: string} ---@class snacks.gh.api.Cmd ---@field args string[] ---@field repo? string ---@field input? string ---@field notify? boolean ---@field on_error? fun(proc: snacks.spawn.Proc, err: string) ---@class snacks.gh.api.Api ---@field endpoint string ---@field cache? string cache the response, e.g. "3600s", "1h" ---@field fields? table raw fields (--raw-field) ---@field params? table typed fields (--field) ---@field header? table ---@field jq? string ---@field input? any ---@field method? "GET" | "POST" | "PATCH" | "PUT" | "DELETE" ---@field paginate? boolean ---@field silent? boolean ---@field slurp? boolean ---@field on_error? fun(proc: snacks.spawn.Proc, err: string) ---@class snacks.gh.api.GraphQL: snacks.gh.api.Api ---@field endpoint? nil -- should be "/graphql" ---@field query string ---@alias snacks.gh.Field {arg:string, prop:string, name:string} ---@class snacks.gh.cli.Action: snacks.gh.api.Cmd ---@field args? string[] ---@field stdin? boolean -- whether to write to stdin ---@field edit? string field to edit ---@field api? snacks.gh.api.Api -- api options ---@field cmd? string -- subcommand to run (e.g., "issue edit" or "pr comment") ---@field fields? snacks.gh.Field[] -- field args to parse from the body ---@field title? string -- title of the scratch buffer ---@field template? string -- template to use for the scratch buffer ---@field desc? string -- description to show in the scratch buffer ---@field icon? string -- icon to show in the scratch buffer ---@field type? "issue" | "pr" -- action for items of this type (nil means both) ---@field enabled? fun(item: snacks.picker.gh.Item, ctx: snacks.gh.action.ctx): boolean -- whether the action is enabled for the item ---@field success? string -- success message to show after the action ---@field confirm? string -- confirmation message to show before performing the action ---@field refresh? boolean -- whether to refresh the item after performing the action (default: true) ---@field on_submit? fun(body: string, ctx: snacks.gh.cli.Action.ctx): string? ---@class snacks.gh.api.Fetch: snacks.gh.api.Cmd ---@field fields string[] ---@alias snacks.gh.Reaction { content: string, users: { totalCount: number } } ---@class snacks.gh.Label ---@field id string ---@field name string ---@field color string ---@field description? string ---@class snacks.gh.User ---@field id string ---@field login string ---@field name string ---@field is_bot? boolean ---@class snacks.gh.Check ---@field __typename "CheckRun" | "StatusContext" ---@field completedAt? string ---@field conclusion? "SUCCESS" | "FAILURE" | "SKIPPED" ---@field detailsUrl? string ---@field name string ---@field startedAt? string ---@field status "PENDING" | "COMPLETED" ---@field workflowName string ---@field context? string ---@field state? "SUCCESS" | "FAILURE" | "PENDING" ---@class snacks.gh.review.Thread ---@field id string ---@field diffSide "LEFT" | "RIGHT" ---@field comments {id: string}[] ---@class snacks.gh.Review ---@field id string ---@field databaseId number ---@field author snacks.gh.User ---@field authorAssociation string ---@field body string ---@field createdAt string ---@field submittedAt string ---@field submitted number ---@field created number ---@field reactionGroups? snacks.gh.Reaction[] ---@field state "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED" | "PENDING" ---@field commit? {oid: string} ---@field comments? snacks.gh.Comment[] ---@field viewerDidAuthor? boolean -- whether the viewer authored the review ---@alias snacks.gh.Thread snacks.gh.Comment|snacks.gh.Review ---@class snacks.gh.Item ---@field number number ---@field id string ---@field title string ---@field labels? snacks.gh.Label[] ---@field author? snacks.gh.User ---@field state string ---@field stateReason? string ---@field updatedAt string ---@field url string ---@field reactionGroups? snacks.gh.Reaction[] ---@field body? string ---@field comments? snacks.gh.Comment[] ---@field changedFiles? number ---@field additions? number ---@field deletions? number ---@field mergeStateStatus? string ---@field mergeable? boolean ---@field commits? snacks.gh.Commit[] ---@field statusCheckRollup? snacks.gh.Check[] ---@field baseRefName? string ---@field headRefName? string ---@field headRefOid? string ---@field isDraft? boolean ---@field reviews? snacks.gh.Review[] ---@field reviewThreads? snacks.gh.review.Thread[] ---@field pendingReview? snacks.gh.Review ---@class snacks.gh.Commit ---@field oid string ---@field messageHeadline string ---@field messageBody? string ---@field committedDate string ---@field authors? snacks.gh.User[] ---@field authoredDate string ---@class snacks.gh.Comment ---@field id string ---@field databaseId number ---@field url string ---@field author { login: string } ---@field authorAssociation? string ---@field includesCreatedEdit? boolean ---@field viewerDidAuthor? boolean ---@field isMinimized? boolean ---@field minimizedReason? string ---@field body string ---@field createdAt string ---@field reactionGroups? snacks.gh.Reaction[] ---@field created? number ---@field replyTo? {id: string, databaseId: number} ---@field path? string ---@field diffHunk? string ---@field line? number ---@field originalLine? number ---@field originalStartLine? number ---@class snacks.picker.gh.Item: snacks.picker.Item,snacks.gh.Item,snacks.picker.finder.Item ---@field type "issue" | "pr" ---@field dirty? boolean ---@field uri string ---@field repo? string ---@field hash string ---@field status string ---@field author? string ---@field label? string ---@field status_reason? string ---@field item snacks.gh.Item ---@field body? string ---@field reactions? {content: string, count: number}[] ---@field fields table ---@field created number ---@field updated number ---@field closed? number ---@field merged? number ---@field draft? boolean ---@class snacks.gh.api.Branch ---@field url string URL of the remote branch ---@field author? string owner of the remote branch ---@field repo? string owner/name format ---@field branch string local branch name ---@field base string branch we want to merge into ---@field head string branch we want to merge from ================================================ FILE: lua/snacks/git.lua ================================================ ---@class snacks.git local M = {} M.meta = { desc = "Git utilities", } Snacks.config.style("blame_line", { width = 0.6, height = 0.6, border = true, title = " Git Blame ", title_pos = "center", ft = "git", }) local git_cache = {} ---@type table local function is_git_root(dir) if git_cache[dir] == nil then git_cache[dir] = (vim.uv or vim.loop).fs_stat(dir .. "/.git") ~= nil end return git_cache[dir] end --- Gets the git root for a buffer or path. --- Defaults to the current buffer. ---@param path? number|string buffer or path ---@return string? function M.get_root(path) path = path or 0 path = type(path) == "number" and vim.api.nvim_buf_get_name(path) or path --[[@as string]] path = path == "" and (vim.uv or vim.loop).cwd() or path path = svim.fs.normalize(path) if is_git_root(path) then return path end for dir in vim.fs.parents(path) do if is_git_root(dir) then return svim.fs.normalize(dir) end end return os.getenv("GIT_WORK_TREE") end --- Show git log for the current line. ---@param opts? snacks.terminal.Opts | {count?: number} function M.blame_line(opts) opts = vim.tbl_deep_extend("force", { count = 5, interactive = false, win = { style = "blame_line" }, }, opts or {}) local cursor = vim.api.nvim_win_get_cursor(0) local line = cursor[1] local file = vim.api.nvim_buf_get_name(0) local root = M.get_root() local cmd = { "git", "-C", root, "log", "-n", opts.count, "-u", "-L", line .. ",+1:" .. file } return Snacks.terminal(cmd, opts) end return M ================================================ FILE: lua/snacks/gitbrowse.lua ================================================ ---@class snacks.gitbrowse ---@overload fun(opts?: snacks.gitbrowse.Config) local M = setmetatable({}, { __call = function(t, ...) return t.open(...) end, }) M.meta = { desc = "Open the current file, branch, commit, or repo in a browser (e.g. GitHub, GitLab, Bitbucket)", } local uv = vim.uv or vim.loop ---@class snacks.gitbrowse.Config ---@field url_patterns? table> local defaults = { notify = true, -- show notification on open -- Handler to open the url in a browser ---@param url string open = function(url) if vim.fn.has("nvim-0.10") == 0 then require("lazy.util").open(url, { system = true }) return end vim.ui.open(url) end, ---@type "repo" | "branch" | "file" | "commit" | "permalink" what = "commit", -- what to open. not all remotes support all types commit = nil, ---@type string? branch = nil, ---@type string? line_start = nil, ---@type number? line_end = nil, ---@type number? -- patterns to transform remotes to an actual URL -- stylua: ignore remote_patterns = { { "^(https?://.*)%.git$" , "%1" }, { "^git@(.+):(.+)%.git$" , "https://%1/%2" }, { "^git@(.+):(.+)$" , "https://%1/%2" }, { "^git@(.+)/(.+)$" , "https://%1/%2" }, { "^org%-%d+@(.+):(.+)%.git$" , "https://%1/%2" }, { "^ssh://git@(.*)$" , "https://%1" }, { "^ssh://([^:/]+)(:%d+)/(.*)$" , "https://%1/%3" }, { "^ssh://([^/]+)/(.*)$" , "https://%1/%2" }, { "ssh%.dev%.azure%.com/v3/(.*)/(.*)$", "dev.azure.com/%1/_git/%2" }, { "^https://%w*@(.*)" , "https://%1" }, { "^git@(.*)" , "https://%1" }, { ":%d+" , "" }, { "%.git$" , "" }, }, url_patterns = { ["github%.com"] = { branch = "/tree/{branch}", file = "/blob/{branch}/{file}#L{line_start}-L{line_end}", permalink = "/blob/{commit}/{file}#L{line_start}-L{line_end}", commit = "/commit/{commit}", }, ["gitlab%.com"] = { branch = "/-/tree/{branch}", file = "/-/blob/{branch}/{file}#L{line_start}-{line_end}", permalink = "/-/blob/{commit}/{file}#L{line_start}-{line_end}", commit = "/-/commit/{commit}", }, ["bitbucket%.org"] = { branch = "/src/{branch}", file = "/src/{branch}/{file}#lines-{line_start}-L{line_end}", permalink = "/src/{commit}/{file}#lines-{line_start}-L{line_end}", commit = "/commits/{commit}", }, ["git.sr.ht"] = { branch = "/tree/{branch}", file = "/tree/{branch}/item/{file}", permalink = "/tree/{commit}/item/{file}#L{line_start}", commit = "/commit/{commit}", }, }, } ---@class snacks.gitbrowse.Fields ---@field branch? string ---@field file? string ---@field line_start? number ---@field line_end? number ---@field commit? string ---@field line_count? number ---@private ---@param remote string ---@param opts? snacks.gitbrowse.Config function M.get_repo(remote, opts) opts = Snacks.config.get("gitbrowse", defaults, opts) local ret = remote for _, pattern in ipairs(opts.remote_patterns) do ret = ret:gsub(pattern[1], pattern[2]) --[[@as string]] end return ret:find("https://") == 1 and ret or ("https://%s"):format(ret) end ---@param repo string ---@param fields snacks.gitbrowse.Fields ---@param opts? snacks.gitbrowse.Config function M.get_url(repo, fields, opts) opts = Snacks.config.get("gitbrowse", defaults, opts) for remote, patterns in pairs(opts.url_patterns) do if repo:find(remote) then local pattern = patterns[opts.what] if type(pattern) == "string" then return repo .. pattern:gsub("(%b{})", function(key) return fields[key:sub(2, -2)] or key end) elseif type(pattern) == "function" then return repo .. pattern(fields) end end end return repo end ---@param cmd string[] ---@param err string local function system(cmd, err) local proc = vim.fn.system(cmd) if vim.v.shell_error ~= 0 then Snacks.notify.error({ err, proc }, { title = "Git Browse" }) error("__ignore__") end return vim.split(vim.trim(proc), "\n") end ---@param hash string ---@param cwd string ---@return boolean local function is_valid_commit_hash(hash, cwd) if not (hash:match("^[a-fA-F0-9]+$") and #hash >= 7) then return false end system({ "git", "-C", cwd, "rev-parse", "--verify", hash }, "Invalid commit hash") return true end ---@param opts? snacks.gitbrowse.Config function M.open(opts) local ok, err = pcall(M._open, opts) -- errors are handled with notifications if not ok and err ~= "__ignore__" then error(err) end end ---@param opts? snacks.gitbrowse.Config function M._open(opts) opts = Snacks.config.get("gitbrowse", defaults, opts) local file = vim.api.nvim_buf_get_name(0) ---@type string? file = file and (uv.fs_stat(file) or {}).type == "file" and svim.fs.normalize(file) or nil local cwd = file and vim.fn.fnamemodify(file, ":h") or vim.fn.getcwd() ---@type snacks.gitbrowse.Fields local fields = { branch = opts.branch or system({ "git", "-C", cwd, "rev-parse", "--abbrev-ref", "HEAD" }, "Failed to get current branch")[1], file = file and system({ "git", "-C", cwd, "ls-files", "--full-name", file }, "Failed to get git file path")[1], line_start = opts.line_start, line_end = opts.line_end, commit = opts.commit, } if not fields.commit then if opts.what == "permalink" then fields.commit = system( { "git", "-C", cwd, "log", "-n", "1", "--pretty=format:%H", "--", file }, "Failed to get latest commit of file" )[1] else local word = vim.fn.expand("") fields.commit = is_valid_commit_hash(word, cwd) and word or nil end end -- Get visual selection range if in visual mode if vim.fn.mode():find("[vV]") then vim.fn.feedkeys(":", "nx") local line_start = vim.api.nvim_buf_get_mark(0, "<")[1] local line_end = vim.api.nvim_buf_get_mark(0, ">")[1] vim.fn.feedkeys("gv", "nx") -- Ensure line_start is always the smaller number if line_start > line_end then line_start, line_end = line_end, line_start end fields.line_start = line_start fields.line_end = line_end else fields.line_start = fields.line_start or vim.fn.line(".") fields.line_end = fields.line_end or fields.line_start end fields.line_count = fields.line_end - fields.line_start + 1 if not fields.commit and (opts.what == "commit" or opts.what == "permalink") then opts.what = "file" end if not fields.commit and not fields.file then opts.what = "branch" end if not fields.commit and not fields.branch then opts.what = "repo" end local remotes = {} ---@type {name:string, url:string}[] for _, line in ipairs(system({ "git", "-C", cwd, "remote", "-v" }, "Failed to get git remotes")) do local name, remote = line:match("(%S+)%s+(%S+)%s+%(fetch%)") if name and remote then local repo = M.get_repo(remote, opts) if repo then table.insert(remotes, { name = name, url = M.get_url(repo, fields, opts), }) end end end local function open(remote) if remote then if opts.notify ~= false then Snacks.notify(("Opening [%s](%s)"):format(remote.name, remote.url), { title = "Git Browse" }) end opts.open(remote.url) end end if #remotes == 0 then return Snacks.notify.error("No git remotes found", { title = "Git Browse" }) elseif #remotes == 1 then return open(remotes[1]) end vim.ui.select(remotes, { prompt = "Select remote to browse", format_item = function(item) return item.name .. (" "):rep(8 - #item.name) .. " 🔗 " .. item.url end, }, open) end return M ================================================ FILE: lua/snacks/health.lua ================================================ ---@class snacks.health ---@field ok fun(msg: string) ---@field warn fun(msg: string) ---@field error fun(msg: string) ---@field info fun(msg: string) ---@field start fun(msg: string) local M = setmetatable({}, { __index = function(M, k) return function(msg) return require("vim.health")[k](M.prefix .. msg) end end, }) ---@class snacks.health.Tool ---@field cmd string|string[] ---@field version? string|false ---@field enabled? boolean ---@alias snacks.health.Tool.spec (string|snacks.health.Tool)[]|snacks.health.Tool|string M.prefix = "" M.meta = { desc = "Snacks health checks", readme = false, health = false, } function M.check() M.prefix = "" M.start("Snacks") if Snacks.did_setup then M.ok("setup called") if Snacks.did_setup_after_vim_enter then M.warn("setup called *after* `VimEnter`") end else M.error("setup not called") end if package.loaded.lazy then local plugin = require("lazy.core.config").spec.plugins["snacks.nvim"] if plugin then if plugin.lazy ~= false then M.warn("`snacks.nvim` should not be lazy-loaded. Add `lazy=false` to the plugin spec") end if (plugin.priority or 0) < 1000 then M.warn("`snacks.nvim` should have a priority of 1000 or higher. Add `priority=1000` to the plugin spec") end else M.error("`snacks.nvim` not found in lazy") end end for _, plugin in ipairs(Snacks.meta.get()) do local opts = Snacks.config[plugin.name] or {} --[[@as {enabled?: boolean}]] if plugin.meta.health ~= false and (plugin.meta.needs_setup or plugin.health) then M.start(("Snacks.%s"):format(plugin.name)) -- M.prefix = ("`Snacks.%s` "):format(name) if plugin.meta.needs_setup then if opts.enabled then M.ok("setup {enabled}") else M.warn("setup {disabled}") end end if plugin.health then plugin.health() end end end end --- Check if any of the tools are available, with an optional version check ---@param tools snacks.health.Tool.spec function M.have_tool(tools) tools = type(tools) == "string" and { tools } or tools tools = tools[1] and tools or { tools } ---@cast tools (string|snacks.health.Tool)[] tools = vim.tbl_map(function(tool) return type(tool) == "string" and { cmd = tool } or tool end, tools) ---@cast tools snacks.health.Tool[] local all = {} ---@type string[] local found = false local version_ok = false for _, tool in ipairs(tools) do if tool.enabled ~= false then local tool_version = tool.version and vim.version.parse(tool.version) local cmds = type(tool.cmd) == "string" and { tool.cmd } or tool.cmd --[[@as string[] ]] vim.list_extend(all, cmds) for _, cmd in ipairs(cmds) do if vim.fn.executable(cmd) == 1 then local version = tool.version == false and "" or vim.fn.system(cmd .. " --version") or "" version = vim.trim(vim.split(version, "\n")[1]) if tool_version and tool_version > vim.version.parse(version) then M.error("'" .. cmd .. "' `" .. version .. "` is too old, expected `" .. tool.version .. "`") elseif tool.version == false then M.ok("'" .. cmd .. "'") version_ok = true else M.ok("'" .. cmd .. "' `" .. version .. "`") version_ok = true end found = true end end end end if found then return true, version_ok end all = vim.tbl_map(function(t) return "'" .. tostring(t) .. "'" end, all) if #all == 1 then M.error("Tool not found: " .. all[1]) else M.error("None of the tools found: " .. table.concat(all, ", ")) end return false end --- Check if the given languages are available in treesitter ---@param langs string[]|string function M.has_lang(langs) langs = type(langs) == "string" and { langs } or langs --[[@as string[] ]] local ret = {} ---@type table local available, missing = {}, {} ---@type string[], string[] for _, lang in ipairs(langs) do local has_lang = Snacks.util.get_lang(lang) ~= nil ret[lang] = has_lang lang = ("`%s`"):format(lang) if has_lang then available[#available + 1] = lang else missing[#missing + 1] = lang end end table.sort(available) table.sort(missing) if #available > 0 then M.ok("Available Treesitter languages:\n " .. table.concat(available, ", ")) end if #missing > 0 then M.warn("Missing Treesitter languages:\n " .. table.concat(missing, ", ")) end return ret, #available, #missing end return M ================================================ FILE: lua/snacks/image/buf.lua ================================================ ---@class snacks.image.buf local M = {} ---@param buf number ---@param opts? snacks.image.Opts|{src?: string} function M._attach(buf, opts) Snacks.image.placement.clean(buf) if not vim.api.nvim_buf_is_valid(buf) then return end opts = opts or {} local file = opts.src or vim.api.nvim_buf_get_name(buf) if not Snacks.image.supports(file) then local lines = {} ---@type string[] lines[#lines + 1] = "# Image viewer" lines[#lines + 1] = "- **file**: `" .. file .. "`" if not Snacks.image.supports_file(file) then lines[#lines + 1] = "- unsupported image format" end if not Snacks.image.supports_terminal() then lines[#lines + 1] = "- terminal does not support the kitty graphics protocol." lines[#lines + 1] = " See `:checkhealth snacks` for more info." end vim.bo[buf].modifiable = true vim.bo[buf].filetype = "markdown" vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(table.concat(lines, "\n"), "\n")) vim.bo[buf].modifiable = false vim.bo[buf].modified = false else Snacks.util.bo(buf, { filetype = "image", modifiable = false, modified = false, swapfile = false, }) opts.conceal = true opts.auto_resize = true return Snacks.image.placement.new(buf, file, opts) end end ---@param buf number ---@param opts? snacks.image.Opts|{src?: string} function M.attach(buf, opts) if Snacks.image.config.enabled == false then return end local Terminal = require("snacks.image.terminal") Terminal.detect(function() M._attach(buf, opts) end) end return M ================================================ FILE: lua/snacks/image/convert.lua ================================================ local Spawn = require("snacks.util.spawn") ---@class snacks.image.convert local M = {} local uv = vim.uv or vim.loop ---@class snacks.image.Info ---@field format string ---@field size snacks.image.Size ---@field dpi snacks.image.Size ---@class snacks.image.convert.Opts ---@field src string ---@field on_done? fun(convert: snacks.image.Convert) ---@class snacks.image.meta ---@field src string ---@field info? snacks.image.Info ---@field [string] string|number|boolean ---@alias snacks.image.args (number|string)[] | fun(): ((number|string)[]) ---@class snacks.image.Proc ---@field cmd string ---@field cwd? string ---@field args snacks.image.args ---@class snacks.image.step ---@field name string ---@field file string ---@field ft string ---@field cmd snacks.image.cmd ---@field meta snacks.image.meta ---@field done? boolean ---@field err? string ---@field proc? snacks.spawn.Proc ---@class snacks.image.cmd ---@field cmd (fun(step: snacks.image.step):(snacks.image.Proc|snacks.image.Proc[]))|snacks.image.Proc|snacks.image.Proc[] ---@field ft? string ---@field file? fun(convert: snacks.image.Convert, meta: snacks.image.meta): string ---@field depends? string[] ---@field on_done? fun(step: snacks.image.step) ---@field on_error? fun(step: snacks.image.step):boolean? when return true, continue to next step ---@field pipe? boolean ---@type table local commands = { icns = { ft = "png", cmd = { { cmd = "sips", args = { "-s", "format", "png", "{src}", "--out", "{file}" }, }, }, }, url = { cmd = { { cmd = "curl", args = { "-L", "-o", "{file}", "{src}" }, }, { cmd = "wget", args = { "-O", "{file}", "{src}" }, }, }, file = function(convert, ctx) local src = M.norm(ctx.src) return M.is_uri(src) and convert:tmpfile("data") or src end, on_error = function(step) if uv.fs_stat(step.file) then vim.fs.rm(step.file) end end, }, typ = { ft = "pdf", cmd = { { cmd = "typst", args = { "compile", "--format", "pdf", "--pages", 1, "{src}", "{file}" }, }, }, }, tex = { ft = "pdf", file = function(convert, ctx) ctx.pdf = Snacks.image.config.cache .. "/" .. vim.fs.basename(ctx.src):gsub("%.tex$", ".pdf") return convert:tmpfile("pdf") end, cmd = { { cwd = "{dirname}", cmd = "tectonic", args = { "-Z", "continue-on-errors", "--outdir", "{cache}", "{src}" }, }, { cmd = "pdflatex", cwd = "{dirname}", args = { "-output-directory={cache}", "-interaction=nonstopmode", "{src}" }, }, }, on_done = function(step) local pdf = assert(step.meta.pdf, "No pdf file") --[[@as string]] if uv.fs_stat(pdf) then uv.fs_rename(pdf, step.file) end end, on_error = function(step) local pdf = assert(step.meta.pdf, "No pdf file") --[[@as string]] if step.meta.pdf and vim.fn.getfsize(pdf) > 0 then return true end end, }, mmd = { cmd = { cmd = "mmdc", args = Snacks.image.config.convert.mermaid, }, file = function(convert, ctx) return convert:tmpfile(vim.o.background .. ".png") end, }, identify = { pipe = false, file = function(convert, ctx) return convert:tmpfile(convert:ft() .. ".info") end, cmd = { { cmd = "magick", args = { "identify", "-format", "%m %[fx:w]x%[fx:h] %xx%y", "{src}[{page}]" }, }, { cmd = "identify", args = { "-format", "%m %[fx:w]x%[fx:h] %xx%y", "{src}[{page}]" }, }, }, on_done = function(step) local file = step.file if step.proc then local fd = assert(io.open(file, "w"), "Failed to open file: " .. file) fd:write(step.proc:out()) fd:close() end local fd = assert(io.open(file, "r"), "Failed to open file: " .. file) local info = vim.trim(fd:read("*a")) fd:close() local format, w, h, x, y = info:match("^(%w+)%s+(%d+)x(%d+)%s+(%d+%.?%d*)x(%d+%.?%d*)$") if not format then return end step.meta.info = { format = format:lower(), size = { width = tonumber(w) or 0, height = tonumber(h) or 0 }, dpi = { width = tonumber(x) or 0, height = tonumber(y) or 0 }, } end, }, convert = { ft = "png", cmd = function(step) local formats = vim.deepcopy(Snacks.image.config.convert.magick or {}) local args = formats.default or { "{src}[{page}]" } local info = step.meta.info local format = info and info.format or vim.fn.fnamemodify(step.meta.src, ":e") local vector = vim.tbl_contains({ "pdf", "svg", "eps", "ai", "mvg" }, format) if vector then args = formats.vector or args end local fts = { vim.fs.basename(step.file):match("%.([^%.]+)%.png") } ---@type string[] fts[#fts + 1] = format for _, ft in ipairs(fts) do local fmt = formats[ft] if fmt then args = type(fmt) == "function" and fmt() or fmt break end end args = type(args) == "function" and args() or args ---@cast args (string|number)[] vim.list_extend(args, { "-write", "{file}", "-identify", "-format", "%m %[fx:w]x%[fx:h] %xx%y", "{file}.info" }) return { { cmd = "magick", args = args }, not Snacks.util.is_win and { cmd = "convert", args = args } or nil, } end, }, } local have = {} ---@type table local proc_queue = {} ---@type snacks.spawn.Proc[] local proc_running = 0 ---@type number local MAX_PROCS = 3 ---@param proc? snacks.spawn.Proc local function schedule(proc) if proc then table.insert(proc_queue, proc) else proc_running = proc_running - 1 end -- Snacks.notify("proc_running: " .. proc_running .. "\nproc_queue: " .. #proc_queue, { id = "proc_running" }) if proc_running < MAX_PROCS and #proc_queue > 0 then proc_running = proc_running + 1 proc = table.remove(proc_queue, 1) proc:run() end end ---@param step snacks.image.step local function get_cmd(step) local cmd = step.cmd.cmd cmd = type(cmd) == "function" and cmd(step) or cmd local cmds = cmd.cmd and { cmd } or cmd ---@cast cmds snacks.image.Proc[] for _, c in ipairs(cmds) do if have[c.cmd] == nil then have[c.cmd] = vim.fn.executable(c.cmd) == 1 end if have[c.cmd] then return c end end end ---@class snacks.image.Convert ---@field opts snacks.image.convert.Opts ---@field src string ---@field page number ---@field file string ---@field prefix string ---@field meta snacks.image.meta ---@field steps snacks.image.step[] ---@field _done? boolean ---@field _err? string ---@field _step number ---@field tpl_data table local Convert = {} Convert.__index = Convert ---@param opts snacks.image.convert.Opts function Convert.new(opts) vim.fn.mkdir(Snacks.image.config.cache, "p") local self = setmetatable({}, Convert) opts.src, self.page = M.get_page(opts.src) opts.src = M.norm(opts.src) self.opts = opts self.src = opts.src self._step = 0 local base = vim.fn.fnamemodify(opts.src, ":t:r") if M.is_uri(self.opts.src) then base = self.opts.src:gsub("%?.*", ""):match("^%w%w+://(.*)$") or base end self.prefix = vim.fn.sha256(self.opts.src .. self.page):sub(1, 8) .. "-" .. base:gsub("[^%w%.]+", "-") self.meta = { src = opts.src } self.steps = {} self.tpl_data = { cache = Snacks.image.config.cache, bg = vim.o.background, scale = tostring(Snacks.image.terminal.size().scale or 1), } self:resolve() return self end ---@return snacks.image.step? function Convert:current() return self.steps[self._step] end function Convert:ready() return self:done() and not self:error() end function Convert:done() return self._done or false end function Convert:error() return self._err end ---@param ft string function Convert:tmpfile(ft) return Snacks.image.config.cache .. "/" .. self.prefix .. "." .. ft end ---@param target string function Convert:_resolve(target) local cmd = assert(commands[target], "No command for target: " .. target) assert(cmd.file or cmd.ft, "No file or ft for target: " .. target) for _, dep in ipairs(cmd.depends or {}) do self:_resolve(dep) end local file = cmd.file and cmd.file(self, self.meta) or self:tmpfile(cmd.ft) ---@type snacks.image.step local step = { name = target, file = file, ft = self:ft(file), meta = self.meta, done = uv.fs_stat(file) ~= nil, cmd = cmd, } if cmd.pipe ~= false then self.meta = setmetatable({ src = file }, { __index = self.meta }) end table.insert(self.steps, step) end ---@param src? string ---@return string function Convert:ft(src) return vim.fn.fnamemodify(src or self.meta.src, ":e"):lower() end function Convert:resolve() if M.is_uri(self.src) then self:_resolve("url") self:_resolve("identify") end while self:ft() ~= "png" do local ft = self:ft() local target = commands[ft] and ft or "convert" if self:_resolve(target) then break end end self:_resolve("identify") self.file = self.meta.src end ---@param err? string function Convert:on_step(err) local step = assert(self:current(), "No current step") step.done = true step.err = err if self.aborted then return self:on_done() end if step and err and step.cmd.on_error and step.cmd.on_error(step) then -- keep going elseif err then self._err = err return self:on_done() end if step and step.cmd.on_done then step.cmd.on_done(step) end if self._step < #self.steps then self:step() else self:on_done() end end -- Called when all steps are done or when an error occurs function Convert:on_done() local step = self:current() self._done = true if self._err and Snacks.image.config.convert.notify then local title = step and ("Conversion failed at step `%s`"):format(step.name) or "Conversion failed" if step and step.proc then step.proc:debug({ title = title }) else Snacks.notify.error("# " .. title .. "\n" .. self._err, { title = "Snacks Image" }) end end if self.opts.on_done then self.opts.on_done(self) end end function Convert:abort() if self.aborted then return end if self:done() then return end self.aborted = true self._err = "Aborted" for _, step in ipairs(self.steps) do if step.proc then step.proc:kill() end end end function Convert:step() self._step = self._step + 1 assert(self._step <= #self.steps, "No more steps") local step = self.steps[self._step] step.done = step.done or (uv.fs_stat(step.file) ~= nil) if step.done then return self:on_step() end local cmd = get_cmd(step) if not cmd then return self:on_step("No command available") end local args = type(cmd.args) == "function" and cmd.args() or cmd.args ---@cast args (number|string)[] args = vim.deepcopy(args) local data = vim.tbl_extend("keep", { file = step.file, basename = vim.fs.basename(step.file), name = vim.fn.fnamemodify(step.file, ":t:r"), dirname = vim.fs.dirname(step.meta.src), src = step.meta.src, page = self.page, }, self.tpl_data) for a, arg in ipairs(args) do if type(arg) == "string" then args[a] = Snacks.picker.util.tpl(arg, data) end end step.proc = Spawn.new({ run = false, debug = Snacks.image.config.debug.convert, cwd = cmd.cwd and Snacks.picker.util.tpl(cmd.cwd, data) or nil, cmd = cmd.cmd, args = args, on_exit = function(proc, err) schedule() local out = vim.trim(proc:out() .. "\n" .. proc:err()) vim.schedule(function() self:on_step(err and out or nil) end) end, }) schedule(step.proc) end function Convert:run() if #self.steps == 0 then return self:on_done() end if not M.is_uri(self.src) and vim.fn.filereadable(self.src) == 0 then local f = M.is_uri(self.src) and self.src or vim.fn.fnamemodify(self.src, ":p:~") self._err = ("File not found\n- `%s`"):format(f) return self:on_done() end self:step() end ---@param src string function M.is_url(src) return src:find("^https?://") == 1 end ---@param src string function M.is_uri(src) return src:find("^%w%w+://") == 1 end ---@param src string function M.norm(src) if src:find("^file://") then src = vim.uri_to_fname(src) end if not M.is_uri(src) then src = svim.fs.normalize(vim.fn.fnamemodify(src, ":p")) end return src end ---@param src string ---@return string, number function M.get_page(src) local parts = vim.split(src, "#page=", { plain = true }) local page_number = tonumber(parts[2]) or 1 return parts[1], page_number - 1 end ---@param opts snacks.image.convert.Opts function M.convert(opts) return Convert.new(opts) end return M ================================================ FILE: lua/snacks/image/doc.lua ================================================ ---@class snacks.image.doc local M = {} ---@alias TSMatch {node:TSNode, meta:vim.treesitter.query.TSMetadata} ---@alias snacks.image.transform fun(match: snacks.image.match, ctx: snacks.image.ctx) ---@alias snacks.image.find fun(matches: snacks.image.match[]) ---@class snacks.image.Hover ---@field img snacks.image.Placement ---@field win snacks.win ---@field buf number ---@class snacks.image.ctx ---@field buf number ---@field lang string ---@field meta vim.treesitter.query.TSMetadata ---@field pos? TSMatch ---@field src? TSMatch ---@field content? TSMatch ---@class snacks.image.match ---@field id string ---@field pos snacks.image.Pos ---@field src? string ---@field content? string ---@field content_id? string ---@field ext? string ---@field range? Range4 ---@field lang string ---@field type snacks.image.Type local META_EXT = "image.ext" local META_SRC = "image.src" local META_TYPE = "image.type" local META_IGNORE = "image.ignore" local META_LANG = "image.lang" ---@type table M.transforms = { norg = function(img, ctx) local row, col = ctx.src.node:start() local line = vim.api.nvim_buf_get_lines(ctx.buf, row, row + 1, false)[1] img.src = line:sub(col + 1) end, typst = function(img, ctx) if not img.content then return end img.content = Snacks.picker.util.tpl(Snacks.image.config.math.typst.tpl, { color = Snacks.util.color("SnacksImageMath") or "#000000", header = M.get_header(ctx.buf), content = img.content, }, { indent = true, prefix = "$" }) end, data_img = function(img, ctx) if not vim.base64 then return end if not img.src then return end local ft, data = img.src:match("^data:(.-);base64,(.+)$") if not (ft and data) then return end img.content = vim.base64.decode(data) img.content_id = data:sub(1, 20) img.src = nil img.ext = ft:match("^image/(%w+)$") or "png" end, latex = function(img, ctx) if not (img.content and img.ext == "math.tex") then return end local fg = Snacks.util.color("SnacksImageMath") or "#000000" local content = vim.trim(img.content or "") content = content:gsub("^%$+`?", ""):gsub("`?%$+$", "") content = content:gsub("^\\[%[%(]", ""):gsub("\\[%]%)]$", "") if not content:find("^\\begin") then content = ("\\[%s\\]"):format(content) end local packages = { "xcolor" } vim.list_extend(packages, Snacks.image.config.math.latex.packages) vim.list_extend(packages, M.get_packages(ctx.buf)) table.sort(packages) local seen = {} ---@type table packages = vim.tbl_filter(function(p) if seen[p] then return false end seen[p] = true return true end, packages) img.content = Snacks.picker.util.tpl(Snacks.image.config.math.latex.tpl, { font_size = Snacks.image.config.math.latex.font_size or "large", packages = table.concat(packages, ", "), header = M.get_header(ctx.buf), color = fg:upper():sub(2), content = content, }, { indent = true, prefix = "$" }) end, } local hover ---@type snacks.image.Hover? local uv = vim.uv or vim.loop local dir_cache = {} ---@type table local buf_cache = {} ---@type table ---@param buf number ---@param key string ---@param fn fun():any function M._cache(buf, key, fn) if buf_cache[buf] and buf_cache[buf].tick ~= vim.api.nvim_buf_get_changedtick(buf) then buf_cache[buf] = nil end buf_cache[buf] = buf_cache[buf] or { tick = vim.api.nvim_buf_get_changedtick(buf) } if buf_cache[buf][key] == nil then buf_cache[buf][key] = fn() end return buf_cache[buf][key] end ---@param buf number function M.get_packages(buf) if vim.bo[buf].filetype ~= "tex" then return {} end return M._cache(buf, "packages", function() local ret = {} ---@type string[] for _, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do line = line:match("(.-)%%") or line if line:find("\\usepackage", 1, true) then for _, p in ipairs(vim.split(line:match("\\usepackage.-{(.-)}") or "", ",%s*")) do if not vim.tbl_contains(ret, p) then ret[#ret + 1] = p end end elseif line:find("\\begin{document}", 1, true) then break end end return ret end) end ---@param buf number function M.get_header(buf) return M._cache(buf, "header", function() local header = {} ---@type string[] local in_header = false for _, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do if line:find("snacks:%s*header%s*start") then in_header = true elseif line:find("snacks:%s*header%s*end") then in_header = false elseif in_header then header[#header + 1] = line end end return table.concat(header, "\n") end) end ---@param str string function M.url_decode(str) return str:gsub("+", " "):gsub("%%(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end) end ---@param dir string function M.is_dir(dir) if dir_cache[dir] == nil then dir_cache[dir] = vim.fn.isdirectory(dir) == 1 end return dir_cache[dir] end ---@param buf number ---@param src string function M.resolve(buf, src) src = M.url_decode(src) local file = svim.fs.normalize(vim.api.nvim_buf_get_name(buf)) local s = Snacks.image.config.resolve and Snacks.image.config.resolve(file, src) or nil if s then return s end if not src:find("^%w%w+://") then local cwd = uv.cwd() or "." local checks = { [src] = true } for _, root in ipairs({ cwd, vim.fs.dirname(file) }) do checks[root .. "/" .. src] = true for _, dir in ipairs(Snacks.image.config.img_dirs) do dir = root .. "/" .. dir if M.is_dir(dir) then checks[dir .. "/" .. src] = true end end end for f in pairs(checks) do if vim.fn.filereadable(f) == 1 then src = uv.fs_realpath(f) or f break end end src = svim.fs.normalize(src) end return src end ---@param buf number ---@param cb snacks.image.find function M.find_visible(buf, cb) local ret = {} ---@type table local wins = vim.fn.win_findbuf(buf) local count = #wins for _, win in ipairs(wins) do local info = vim.fn.getwininfo(win)[1] M.find(buf, function(mathes) for _, i in ipairs(mathes) do ret[i.id] = i end count = count - 1 if count == 0 and cb then cb(vim.tbl_values(ret)) end end, { from = math.max(info.topline - 1, 1), to = info.botline }) end end ---@param buf number ---@param cb snacks.image.find ---@param opts? {from?: number, to?: number} function M.find(buf, cb, opts) local ok, parser = pcall(vim.treesitter.get_parser, buf) if not ok or not parser then return cb({}) end opts = opts or {} local from, to = opts.from, opts.to Snacks.util.parse(parser, from and to and { from, to } or true, function() local ret = {} ---@type snacks.image.match[] parser:for_each_tree(function(tstree, tree) if not tstree then return end local query = vim.treesitter.query.get(tree:lang(), "images") if not query then return end for _, match, meta in query:iter_matches(tstree:root(), buf, from and from - 1 or nil, to) do if not meta[META_IGNORE] then ---@type snacks.image.ctx local ctx = { buf = buf, lang = tostring(meta[META_LANG] or meta["injection.language"] or tree:lang()), meta = meta, } for id, nodes in pairs(match) do nodes = type(nodes) == "userdata" and { nodes } or nodes local name = query.captures[id] local field = name == "image" and "pos" or name:match("^image%.(.*)$") if field then ---@diagnostic disable-next-line: assign-type-mismatch ctx[field] = { node = nodes[1], meta = meta[id] or {} } end end ret[#ret + 1] = M._img(ctx) end end end) cb(ret) end) end ---@param ctx snacks.image.ctx function M._img(ctx) ctx.pos = ctx.pos or ctx.src or ctx.content assert(ctx.pos, "no image node") local range6 = vim.treesitter.get_range(ctx.pos.node, ctx.buf, ctx.pos.meta) local range = { range6[1], range6[2], range6[4], range6[5] } ---@type Range4 if range[3] > 0 and range[4] == 0 then range[3] = range[3] - 1 local line = vim.api.nvim_buf_get_lines(ctx.buf, range[3], range[3] + 1, false)[1] range[4] = #line end ---@type snacks.image.match local img = { ext = ctx.meta[META_EXT], src = ctx.meta[META_SRC], lang = ctx.lang, id = ctx.pos.node:id(), range = { range[1] + 1, range[2], range[3] + 1, range[4] }, pos = { range[1] + 1, range[2] }, type = "image", } if ctx.meta[META_TYPE] then img.type = ctx.meta[META_TYPE] elseif img.ext then img.type = img.ext:match("^(%w+)%.") or img.type end if not Snacks.image.config.math.enabled and img.type == "math" then return end if ctx.src then img.src = vim.treesitter.get_node_text(ctx.src.node, ctx.buf, { metadata = ctx.src.meta }) end if ctx.content then img.content = vim.treesitter.get_node_text(ctx.content.node, ctx.buf, { metadata = ctx.content.meta }) end assert(img.src or img.content, "no image src or content") local transform = M.transforms[ctx.lang] if img.src and img.src:find("^data:%w+/%w+;base64,") then transform = M.transforms["data_img"] end if transform then transform(img, ctx) end if img.src then img.src = M.resolve(ctx.buf, img.src) end if img.content and not img.src then local root = Snacks.image.config.cache vim.fn.mkdir(root, "p") img.src = root .. "/" .. (img.content_id or vim.fn.sha256(img.content):sub(1, 8)) .. "-content." .. (img.ext or "png") if vim.fn.filereadable(img.src) == 0 then local fd = assert(io.open(img.src, "w"), "failed to open " .. img.src) fd:write(img.content) fd:close() end end return img end function M.hover_close() if hover then hover.win:close() hover.img:close() hover = nil end end --- Get the image at the cursor (if any) ---@param cb fun(image_src?:string, image_pos?: snacks.image.Pos) function M.at_cursor(cb) local cursor = vim.api.nvim_win_get_cursor(0) M.find(vim.api.nvim_get_current_buf(), function(imgs) for _, img in ipairs(imgs) do local range = img.range if range then if (range[1] == range[3] and cursor[2] >= range[2] and cursor[2] <= range[4]) or (range[1] ~= range[3] and cursor[1] >= range[1] and cursor[1] <= range[3]) then return cb(img.src, img.pos) end end end cb() end, { from = cursor[1], to = cursor[1] + 1 }) end function M.hover() local current_win = vim.api.nvim_get_current_win() local current_buf = vim.api.nvim_get_current_buf() if hover and hover.win.win == current_win and hover.win:valid() then return end if hover and (hover.buf ~= current_buf or vim.fn.mode() ~= "n") then return M.hover_close() end if hover and not hover.win:valid() then M.hover_close() end M.at_cursor(function(src) if not src then return M.hover_close() end if hover and hover.img.img.src ~= src then M.hover_close() elseif hover then hover.img:update() return end local win = Snacks.win(Snacks.win.resolve(Snacks.image.config.doc, "snacks_image", { show = false, enter = false, wo = { winblend = Snacks.image.terminal.env().placeholders and 0 or nil }, })) win:open_buf() local updated = false local o = Snacks.config.merge({}, Snacks.image.config.doc, { on_update_pre = function() if hover and not updated then updated = true local loc = hover.img:state().loc win.opts.width = loc.width win.opts.height = loc.height win:show() end end, inline = false, }) hover = { win = win, buf = current_buf, img = Snacks.image.placement.new(win.buf, src, o), } vim.api.nvim_create_autocmd({ "BufWritePost", "CursorMoved", "ModeChanged", "BufLeave" }, { group = vim.api.nvim_create_augroup("snacks.image.hover", { clear = true }), callback = function() if not hover then return true end M.hover() if not hover then return true end end, }) end) end ---@param buf number function M._attach(buf) if not vim.api.nvim_buf_is_valid(buf) then return end if vim.b[buf].snacks_image_attached then return end vim.b[buf].snacks_image_attached = true local inline = Snacks.image.config.doc.inline and Snacks.image.terminal.env().placeholders local float = Snacks.image.config.doc.float and not inline if not inline and not float then return end if inline then Snacks.image.inline.new(buf) else local group = vim.api.nvim_create_augroup("snacks.image.doc." .. buf, { clear = true }) vim.api.nvim_create_autocmd({ "CursorMoved" }, { group = group, buffer = buf, callback = vim.schedule_wrap(M.hover), }) vim.schedule(M.hover) end end ---@param buf number function M.attach(buf) if Snacks.image.config.enabled == false then return end local Terminal = require("snacks.image.terminal") Terminal.detect(function() M._attach(buf) end) end return M ================================================ FILE: lua/snacks/image/image.lua ================================================ ---@class snacks.Image ---@field src string ---@field file string ---@field id number image id. unique per nvim instance and file ---@field sent? boolean image data is sent ---@field placements table image placements ---@field info? snacks.image.Info ---@field _convert? snacks.image.Convert ---@field fsize? number local M = {} M.__index = M local NVIM_ID_BITS = 10 local CHUNK_SIZE = 4096 local MAX_FSIZE = 200 * 1024 * 1024 -- 200MB local _id = 30 local _pid = 10 local nvim_id = 0 local uv = vim.uv or vim.loop local images = {} ---@type table local terminal = Snacks.image.terminal local lru = {} ---@type {img:snacks.Image, used:number}[] local lru_fsize = 0 ---@param img snacks.Image local function use(img) if img.fsize == 0 then return end local now = os.time() for _, v in ipairs(lru) do if v.img == img then v.used = now return end end table.sort(lru, function(a, b) return a.used > b.used end) while lru_fsize >= MAX_FSIZE and #lru > 0 do local i = table.remove(lru).img i.sent = false lru_fsize = lru_fsize - (i.fsize or 0) end lru_fsize = lru_fsize + (img.fsize or 0) table.insert(lru, { img = img, used = now }) end ---@param src string function M.new(src) local self = setmetatable({}, M) self.src = src self.file = self:convert() if images[self.file] then return images[self.file] end images[self.file] = self _id = _id + 1 local bit = require("bit") -- generate a unique id for this nvim instance (10 bits) if nvim_id == 0 then local pid = vim.fn.getpid() nvim_id = bit.band(bit.bxor(pid, bit.rshift(pid, 5), bit.rshift(pid, NVIM_ID_BITS)), 0x3FF) end -- interleave the nvim id and the image id self.id = bit.bor(bit.lshift(nvim_id, 24 - NVIM_ID_BITS), _id) self.placements = {} self:run() if self:ready() then self:on_ready() end return self end function M:on_ready() if not self.sent then self.fsize = vim.fn.getfsize(self.file) self.info = self._convert and self._convert.meta.info or nil if self.info and self.info.size then -- ghostty uses the decoded rgba size to calculate the fsize self.fsize = (self.info.size.width * 4 + 1) * self.info.size.height end self:send() end end function M:on_send() use(self) for _, placement in pairs(self.placements) do placement:update() end end function M:failed() if self._convert and not self._convert:done() then return false end if self._convert and self._convert:error() then return true end return self.file and vim.fn.filereadable(self.file) == 0 end function M:ready() if self._convert and not self._convert:done() then return false end return self.file and vim.fn.filereadable(self.file) == 1 end function M:run() if not self._convert then return end self._convert:run() end function M:convert() self._convert = Snacks.image.convert.convert({ src = self.src, on_done = function(convert) if convert:error() then vim.schedule(function() for _, p in pairs(self.placements) do p:error() end end) else vim.schedule(function() self:on_ready() end) end end, }) return self._convert.file end -- create the image function M:send() assert(not self.sent, "Image already sent") self.sent = true -- local image if not terminal.env().remote then terminal.request({ t = "f", i = self.id, f = 100, data = Snacks.util.base64(self.file), }) else -- remote image local fd = assert(io.open(self.file, "rb"), "Failed to open file: " .. self.file) local data = fd:read("*a") fd:close() data = Snacks.util.base64(data) -- encode the data local offset = 1 while offset <= #data do local chunk = data:sub(offset, offset + CHUNK_SIZE - 1) local first = offset == 1 offset = offset + CHUNK_SIZE local last = offset > #data if first then terminal.request({ t = "d", i = self.id, f = 100, m = last and 0 or 1, data = chunk, }) else terminal.request({ m = last and 0 or 1, data = chunk, }) end uv.sleep(1) end end self:on_send() end ---@param placement snacks.image.Placement function M:place(placement) if not placement.id then _pid = _pid + 1 placement.id = _pid end self.placements[placement.id] = placement if self.sent then use(self) elseif self:ready() then self:send() end end ---@param pid? number function M:del(pid) for id, p in ipairs(pid and { pid } or vim.tbl_keys(self.placements)) do if self.placements[p] then terminal.request({ a = "d", d = "i", i = self.id, p = id }) self.placements[p] = nil end end if not next(self.placements) then terminal.request({ a = "d", d = "i", i = self.id }) end end function M.clear() images = {} end return M ================================================ FILE: lua/snacks/image/init.lua ================================================ ---@class snacks.image ---@field terminal snacks.image.terminal ---@field image snacks.Image ---@field placement snacks.image.Placement ---@field util snacks.image.util ---@field buf snacks.image.buf ---@field doc snacks.image.doc ---@field convert snacks.image.convert ---@field inline snacks.image.inline local M = setmetatable({}, { ---@param M snacks.image __index = function(M, k) if vim.tbl_contains({ "terminal", "image", "placement", "util", "doc", "buf", "convert", "inline" }, k) then M[k] = require("snacks.image." .. k) end return rawget(M, k) end, }) M.meta = { desc = "Image viewer using Kitty Graphics Protocol, supported by `kitty`, `wezterm` and `ghostty`", needs_setup = true, } ---@alias snacks.image.Size {width: number, height: number} ---@alias snacks.image.Pos {[1]: number, [2]: number} ---@alias snacks.image.Loc snacks.image.Pos|snacks.image.Size|{zindex?: number} ---@alias snacks.image.Type "image"|"math"|"chart" ---@class snacks.image.Env ---@field name string ---@field env? table ---@field terminal? string ---@field supported? boolean default: false ---@field placeholders? boolean default: false ---@field setup? fun(): boolean? ---@field transform? fun(data: string): string ---@field detected? boolean ---@field remote? boolean this is a remote client, so full transfer of the image data is required ---@class snacks.image.Config ---@field enabled? boolean enable image viewer ---@field wo? vim.wo|{} options for windows showing the image ---@field bo? vim.bo|{} options for the image buffer ---@field formats? string[] --- Resolves a reference to an image with src in a file (currently markdown only). --- Return the absolute path or url to the image. --- When `nil`, the path is resolved relative to the file. ---@field resolve? fun(file: string, src: string): string? ---@field convert? snacks.image.convert.Config local defaults = { formats = { "png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "heic", "avif", "mp4", "mov", "avi", "mkv", "webm", "pdf", "icns", }, force = false, -- try displaying the image, even if the terminal does not support it doc = { -- enable image viewer for documents -- a treesitter parser must be available for the enabled languages. enabled = true, -- render the image inline in the buffer -- if your env doesn't support unicode placeholders, this will be disabled -- takes precedence over `opts.float` on supported terminals inline = true, -- render the image in a floating window -- only used if `opts.inline` is disabled float = true, max_width = 80, max_height = 40, -- Set to `true`, to conceal the image text when rendering inline. -- (experimental) ---@param lang string tree-sitter language ---@param type snacks.image.Type image type conceal = function(lang, type) -- only conceal math expressions return type == "math" end, }, img_dirs = { "img", "images", "assets", "static", "public", "media", "attachments" }, -- window options applied to windows displaying image buffers -- an image buffer is a buffer with `filetype=image` wo = { wrap = false, number = false, relativenumber = false, cursorcolumn = false, signcolumn = "no", foldcolumn = "0", list = false, spell = false, statuscolumn = "", }, cache = vim.fn.stdpath("cache") .. "/snacks/image", debug = { request = false, convert = false, placement = false, }, env = {}, -- icons used to show where an inline image is located that is -- rendered below the text. icons = { math = "󰪚 ", chart = "󰄧 ", image = " ", }, ---@class snacks.image.convert.Config convert = { notify = false, -- show a notification on error ---@type snacks.image.args mermaid = function() local theme = vim.o.background == "light" and "neutral" or "dark" return { "-i", "{src}", "-o", "{file}", "-b", "transparent", "-t", theme, "-s", "{scale}" } end, ---@type table magick = { default = { "{src}[0]", "-scale", "1920x1080>" }, -- default for raster images vector = { "-density", 192, "{src}[{page}]" }, -- used by vector images like svg math = { "-density", 192, "{src}[{page}]", "-trim" }, pdf = { "-density", 192, "{src}[{page}]", "-background", "white", "-alpha", "remove", "-trim" }, }, }, math = { enabled = true, -- enable math expression rendering -- in the templates below, `${header}` comes from any section in your document, -- between a start/end header comment. Comment syntax is language-specific. -- * start comment: `// snacks: header start` -- * end comment: `// snacks: header end` typst = { tpl = [[ #set page(width: auto, height: auto, margin: (x: 2pt, y: 2pt)) #show math.equation.where(block: false): set text(top-edge: "bounds", bottom-edge: "bounds") #set text(size: 12pt, fill: rgb("${color}")) ${header} ${content}]], }, latex = { font_size = "Large", -- see https://www.sascha-frank.com/latex-font-size.html -- for latex documents, the doc packages are included automatically, -- but you can add more packages here. Useful for markdown documents. packages = { "amsmath", "amssymb", "amsfonts", "amscd", "mathtools" }, tpl = [[ \documentclass[preview,border=0pt,varwidth,12pt]{standalone} \usepackage{${packages}} \begin{document} ${header} { \${font_size} \selectfont \color[HTML]{${color}} ${content}} \end{document}]], }, }, } M.config = Snacks.config.get("image", defaults) Snacks.config.style("snacks_image", { relative = "cursor", border = true, focusable = false, backdrop = false, row = 1, col = 1, -- width/height are automatically set by the image size unless specified below }) Snacks.util.set_hl({ Spinner = "Special", Anchor = "Special", Loading = "NonText", Math = { fg = Snacks.util.color({ "@markup.math.latex", "Special", "Normal" }) }, }, { prefix = "SnacksImage", default = true }) ---@class snacks.image.Opts ---@field pos? snacks.image.Pos (row, col) (1,0)-indexed. defaults to the top-left corner ---@field range? Range4 ---@field conceal? boolean ---@field inline? boolean render the image inline in the buffer ---@field width? number ---@field min_width? number ---@field max_width? number ---@field height? number ---@field min_height? number ---@field max_height? number ---@field on_update? fun(placement: snacks.image.Placement) ---@field on_update_pre? fun(placement: snacks.image.Placement) ---@field type? snacks.image.Type ---@field auto_resize? boolean local did_setup = false --- Check if the file format is supported ---@param file string function M.supports_file(file) return vim.tbl_contains(M.config.formats or {}, vim.fn.fnamemodify(file, ":e"):lower()) end --- Check if the file format is supported and the terminal supports the kitty graphics protocol ---@param file string function M.supports(file) return M.supports_file(file) and M.supports_terminal() end -- Check if the terminal supports the kitty graphics protocol function M.supports_terminal() return M.terminal.env().supported or M.config.force or false end --- Show the image at the cursor in a floating window function M.hover() M.doc.hover() end ---@return string[] function M.langs() local queries = vim.api.nvim_get_runtime_file("queries/*/images.scm", true) return vim.tbl_map(function(q) return q:match("queries/(.-)/images%.scm") end, queries) end ---@private ---@param ev? vim.api.keyset.create_autocmd.callback_args function M.setup(ev) if did_setup then return end did_setup = true local group = vim.api.nvim_create_augroup("snacks.image", { clear = true }) vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, { group = group, callback = function(e) vim.schedule(function() Snacks.image.placement.clean(e.buf) end) end, }) vim.api.nvim_create_autocmd({ "ExitPre" }, { group = group, once = true, callback = function() Snacks.image.placement.clean() end, }) if M.config.formats and #M.config.formats > 0 then vim.api.nvim_create_autocmd("BufReadCmd", { pattern = "*." .. table.concat(M.config.formats, ",*."), group = group, callback = function(e) M.buf.attach(e.buf) end, }) -- prevent altering the original image file vim.api.nvim_create_autocmd("BufWriteCmd", { pattern = "*." .. table.concat(M.config.formats, ",*."), group = group, callback = function(e) -- vim.api.nvim_exec_autocmds("BufWritePre", { buffer = e.buf }) vim.bo[e.buf].modified = false -- vim.api.nvim_exec_autocmds("BufWritePost", { buffer = e.buf }) end, }) end if M.config.enabled and M.config.doc.enabled then local langs = M.langs() vim.api.nvim_create_autocmd("FileType", { group = group, callback = function(e) local ft = vim.bo[e.buf].filetype local lang = vim.treesitter.language.get_lang(ft) if vim.tbl_contains(langs, lang) then vim.schedule(function() if vim.api.nvim_buf_is_valid(e.buf) then M.doc.attach(e.buf) end end) end end, }) end if ev and ev.event == "BufReadCmd" then M.buf.attach(ev.buf) end end ---@private function M.health() local detected = false require("snacks.image.terminal").detect(function() detected = true end) vim.wait(1500, function() return detected end, 10) Snacks.health.have_tool({ "kitty", "wezterm", "ghostty" }) local is_win = jit.os:find("Windows") if not Snacks.health.have_tool({ "magick", not is_win and "convert" or nil }) then Snacks.health.error("`magick` is required to convert images. Only PNG files will be displayed.") end local env = M.terminal.env() for _, e in ipairs(M.terminal.envs()) do if e.detected then if e.supported == false then Snacks.health.error("`" .. e.name .. "` is not supported") else Snacks.health.ok("`" .. e.name .. "` detected and supported") if e.placeholders == false then Snacks.health.warn("`" .. e.name .. "` does not support placeholders. Fallback rendering will be used") Snacks.health.warn("Inline images are disabled") elseif e.placeholders == true then Snacks.health.ok("`" .. e.name .. "` supports unicode placeholders") Snacks.health.ok("Inline images are available") end end end end local size = M.terminal.size() Snacks.health.ok( ("Terminal Dimensions:\n- {size}: `%d` x `%d` pixels\n- {scale}: `%.2f`\n- {cell}: `%d` x `%d` pixels"):format( size.width, size.height, size.scale, size.cell_width, size.cell_height ) ) local langs, _, missing = Snacks.health.has_lang(M.langs()) if missing > 0 then Snacks.health.warn("Image rendering in docs with missing treesitter parsers won't work") end if Snacks.health.have_tool("gs") then Snacks.health.ok("PDF files are supported") else Snacks.health.warn("`gs` is required to render PDF files") end if Snacks.health.have_tool({ "tectonic", "pdflatex" }) then if langs.latex then Snacks.health.ok("LaTeX math equations are supported") else Snacks.health.warn("The `latex` treesitter parser is required to render LaTeX math expressions") end else Snacks.health.warn("`tectonic` or `pdflatex` is required to render LaTeX math expressions") end if Snacks.health.have_tool("mmdc") then Snacks.health.ok("Mermaid diagrams are supported") else Snacks.health.warn("`mmdc` is required to render Mermaid diagrams") end if env.supported then Snacks.health.ok("your terminal supports the kitty graphics protocol") elseif M.config.force then Snacks.health.warn("image viewer is enabled with `opts.force = true`. Use at your own risk") else Snacks.health.error("your terminal does not support the kitty graphics protocol") Snacks.health.info("supported terminals: `kitty`, `wezterm`, `ghostty`") end end return M ================================================ FILE: lua/snacks/image/inline.lua ================================================ ---@class snacks.image.inline ---@field buf number ---@field imgs table ---@field idx table local M = {} M.__index = M function M.new(buf) local self = setmetatable({}, M) self.buf = buf self.imgs = {} self.idx = {} local group = vim.api.nvim_create_augroup("snacks.image.inline." .. buf, { clear = true }) local update = Snacks.util.debounce(function() self:update() end, { ms = 100 }) vim.api.nvim_create_autocmd({ "BufWritePost", "WinScrolled", "BufWinEnter" }, { group = group, buffer = buf, callback = vim.schedule_wrap(update), }) vim.api.nvim_create_autocmd({ "ModeChanged", "CursorMoved" }, { group = group, buffer = buf, callback = function(ev) if ev.buf == self.buf and ev.buf == vim.api.nvim_get_current_buf() then self:conceal() end end, }) vim.api.nvim_buf_attach(buf, false, { on_lines = update, }) vim.schedule(update) return self end function M:conceal() local mode = vim.fn.mode():sub(1, 1):lower() ---@type string for _, img in pairs(self.imgs) do img:show() end if vim.wo.concealcursor:find(mode) then return end local from, to = vim.fn.line("v"), vim.fn.line(".") from, to = math.min(from, to), math.max(from, to) local hide = self:get(from, to) for _, img in pairs(hide) do if img.opts.conceal then img:hide() end end end function M:visible() local ret = {} ---@type table for _, win in ipairs(vim.fn.win_findbuf(self.buf)) do local info = vim.fn.getwininfo(win)[1] for k, v in pairs(self:get(math.max(info.topline - 1, 1), info.botline)) do ret[k] = v end end return ret end ---@param from number 1-indexed inclusive ---@param to number 1-indexed inclusive function M:get(from, to) local ret = {} ---@type table local marks = vim.api.nvim_buf_get_extmarks(self.buf, Snacks.image.placement.ns, { from - 1, 0 }, { to, -1 }, { overlap = true, hl_name = false, }) for _, m in ipairs(marks) do local p = self.idx[m[1]] ---@type snacks.image.Placement? if p and not self.imgs[p.id] then self.idx[m[1]] = nil p = nil end if p then ret[p.id] = p end end return ret end function M:update() local conceal = Snacks.image.config.doc.conceal conceal = type(conceal) ~= "function" and function() return conceal end or conceal Snacks.image.doc.find_visible(self.buf, function(imgs) local visible = self:visible() local stats = { new = 0, del = 0, update = 0 } for _, i in ipairs(imgs) do local img ---@type snacks.image.Placement? for v, o in pairs(visible) do if o.img.src == i.src then img = o visible[v] = nil break end end if not img then stats.new = stats.new + 1 img = Snacks.image.placement.new( self.buf, i.src, Snacks.config.merge({}, Snacks.image.config.doc, { pos = i.pos, range = i.range, inline = true, conceal = vim.b[self.buf].snacks_image_conceal or conceal(i.lang, i.type), type = i.type, ---@param p snacks.image.Placement on_update = function(p) for _, eid in ipairs(p.eids) do self.idx[eid] = p end end, }) ) for _, eid in ipairs(img.eids) do self.idx[eid] = img end self.imgs[img.id] = img else stats.update = stats.update + 1 img.opts.pos = i.pos img.opts.range = i.range img:update() end end for _, img in pairs(visible) do stats.del = stats.del + 1 img:close() self.imgs[img.id] = nil end for k, v in pairs(stats) do stats[k] = v > 0 and v or nil end -- Snacks.notify( -- vim.inspect({ all = vim.tbl_count(self.imgs), stats = stats }), -- { ft = "lua", id = "snacks.image.inline" } -- ) end) end return M ================================================ FILE: lua/snacks/image/placement.lua ================================================ ---@class snacks.image.Placement ---@field img snacks.Image ---@field id number image placement id ---@field ns number ---@field buf number ---@field opts snacks.image.Opts ---@field augroup number ---@field hidden? boolean ---@field closed? boolean ---@field type? snacks.image.Type ---@field _loc? snacks.image.Loc ---@field _state? snacks.image.State ---@field eids number[] ---@field _extmarks? snacks.image.Extmark[] local M = {} M.__index = M ---@alias snacks.image.Extmark vim.api.keyset.set_extmark|{row:number, col:number} local terminal = Snacks.image.terminal local uv = vim.uv or vim.loop local ns = vim.api.nvim_create_namespace("snacks.image") M.ns = ns local PLACEHOLDER = vim.fn.nr2char(0x10EEEE) local placements = {} ---@type table> -- stylua: ignore local diacritics = vim.split( "0305,030D,030E,0310,0312,033D,033E,033F,0346,034A,034B,034C,0350,0351,0352,0357,035B,0363,0364,0365,0366,0367,0368,0369,036A,036B,036C,036D,036E,036F,0483,0484,0485,0486,0487,0592,0593,0594,0595,0597,0598,0599,059C,059D,059E,059F,05A0,05A1,05A8,05A9,05AB,05AC,05AF,05C4,0610,0611,0612,0613,0614,0615,0616,0617,0657,0658,0659,065A,065B,065D,065E,06D6,06D7,06D8,06D9,06DA,06DB,06DC,06DF,06E0,06E1,06E2,06E4,06E7,06E8,06EB,06EC,0730,0732,0733,0735,0736,073A,073D,073F,0740,0741,0743,0745,0747,0749,074A,07EB,07EC,07ED,07EE,07EF,07F0,07F1,07F3,0816,0817,0818,0819,081B,081C,081D,081E,081F,0820,0821,0822,0823,0825,0826,0827,0829,082A,082B,082C,082D,0951,0953,0954,0F82,0F83,0F86,0F87,135D,135E,135F,17DD,193A,1A17,1A75,1A76,1A77,1A78,1A79,1A7A,1A7B,1A7C,1B6B,1B6D,1B6E,1B6F,1B70,1B71,1B72,1B73,1CD0,1CD1,1CD2,1CDA,1CDB,1CE0,1DC0,1DC1,1DC3,1DC4,1DC5,1DC6,1DC7,1DC8,1DC9,1DCB,1DCC,1DD1,1DD2,1DD3,1DD4,1DD5,1DD6,1DD7,1DD8,1DD9,1DDA,1DDB,1DDC,1DDD,1DDE,1DDF,1DE0,1DE1,1DE2,1DE3,1DE4,1DE5,1DE6,1DFE,20D0,20D1,20D4,20D5,20D6,20D7,20DB,20DC,20E1,20E7,20E9,20F0,2CEF,2CF0,2CF1,2DE0,2DE1,2DE2,2DE3,2DE4,2DE5,2DE6,2DE7,2DE8,2DE9,2DEA,2DEB,2DEC,2DED,2DEE,2DEF,2DF0,2DF1,2DF2,2DF3,2DF4,2DF5,2DF6,2DF7,2DF8,2DF9,2DFA,2DFB,2DFC,2DFD,2DFE,2DFF,A66F,A67C,A67D,A6F0,A6F1,A8E0,A8E1,A8E2,A8E3,A8E4,A8E5,A8E6,A8E7,A8E8,A8E9,A8EA,A8EB,A8EC,A8ED,A8EE,A8EF,A8F0,A8F1,AAB0,AAB2,AAB3,AAB7,AAB8,AABE,AABF,AAC1,FE20,FE21,FE22,FE23,FE24,FE25,FE26,10A0F,10A38,1D185,1D186,1D187,1D188,1D189,1D1AA,1D1AB,1D1AC,1D1AD,1D242,1D243,1D244", ",") ---@type table local positions = {} setmetatable(positions, { __index = function(_, k) positions[k] = vim.fn.nr2char(tonumber(diacritics[k], 16)) return positions[k] end, }) ---@param buf? number ---@param id? number function M.clean(buf, id) for _, b in ipairs(buf and { buf } or vim.tbl_keys(placements)) do for _, p in ipairs(id and { placements[b][id] } or vim.tbl_values(placements[b] or {})) do if p then p:close() end end end end ---@param buf number ---@param opts? snacks.image.Opts function M.new(buf, src, opts) assert(type(buf) == "number", "`Image.new`: buf should be a number") assert(type(src) == "string", "`Image.new`: src should be a string") Snacks.image.setup() -- always setup so that images/videos can be opened local self = setmetatable({}, M) self.img = Snacks.image.image.new(src) self.img:place(self) self.opts = opts or {} self.opts.pos = self.opts.pos or { 1, 0 } self.buf = buf self.augroup = vim.api.nvim_create_augroup("snacks.image." .. self.id, { clear = true }) self.eids = {} if self.opts.auto_resize then vim.api.nvim_create_autocmd({ "BufWinEnter", "WinEnter", "BufWinLeave", "BufEnter" }, { group = self.augroup, buffer = self.buf, callback = function() vim.schedule(function() self:update() end) end, }) vim.api.nvim_create_autocmd({ "WinClosed", "WinNew", "WinEnter", "WinResized" }, { group = self.augroup, callback = function() vim.schedule(function() self:update() end) end, }) end placements[self.buf] = placements[self.buf] or {} placements[self.buf][self.id] = self if self:ready() then vim.schedule(function() self:update() end) elseif self.img:failed() then self:error() elseif self.opts.inline then -- temporary extmark so that we can keep track of unloaded images in the buffer self:_render({ { row = self.opts.pos[1] - 1, col = self.opts.pos[2], }, }) else self:progress() end local update = self.update self.update = Snacks.util.debounce(function() update(self) end, { ms = 10 }) return self end function M:error() if self.opts.inline then return end local msg = "# Image Conversion Failed:\n\n" local convert = self.img._convert if convert then for _, step in ipairs(convert.steps) do if step.err then msg = msg .. "## " .. step.name .. "\n\n" .. step.err .. "\n\n" if step.proc then msg = msg .. Snacks.debug.cmd({ cmd = step.proc.opts.cmd, args = step.proc.opts.args, cwd = step.proc.opts.cwd, notify = false, }) msg = msg .. "\n\n# Output\n" .. vim.trim(step.proc:out() .. "\n" .. step.proc:err()) .. "\n" end end end end local lines = vim.split(msg, "\n") vim.bo[self.buf].modifiable = true vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, lines) vim.bo[self.buf].modifiable = false if not vim.treesitter.start(self.buf, "markdown") then vim.bo[self.buf].syntax = "markdown" end end function M:progress() if self.opts.inline or self:ready() then return end vim.bo[self.buf].modifiable = true vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, {}) vim.bo[self.buf].modifiable = false local timer = assert(uv.new_timer()) timer:start( 0, 80, vim.schedule_wrap(function() if self:ready() or self.img:failed() or not vim.api.nvim_buf_is_valid(self.buf) then timer:stop() if not timer:is_closing() then timer:close() end return end vim.api.nvim_buf_clear_namespace(self.buf, ns, 0, -1) vim.api.nvim_buf_set_extmark(self.buf, ns, 0, 0, { virt_text = { { Snacks.util.spinner(), "SnacksImageSpinner" }, { " " }, { self.img._convert:current().name .. " loading …", "SnacksImageLoading" }, }, }) end) ) end ---@return number[] function M:wins() ---@param win number return vim.tbl_filter(function(win) return vim.api.nvim_win_get_buf(win) == self.buf end, vim.api.nvim_tabpage_list_wins(0)) end function M:close() if self.closed then return end placements[self.buf][self.id] = nil self.closed = true self:del() self:debug("close") pcall(vim.api.nvim_del_augroup_by_id, self.augroup) end function M:del() self.img:del(self.id) if vim.api.nvim_buf_is_valid(self.buf) then for _, eid in ipairs(self.eids) do vim.api.nvim_buf_del_extmark(self.buf, ns, eid) end end end ---@param row number ---@param col number function M:is_concealed(row, col) local captures = vim.treesitter.get_captures_at_pos(self.buf, row, col) for _, cap in ipairs(captures) do if vim.tbl_get(cap, "metadata", "conceal_lines") ~= nil then return true end end return false end ---@param row number function M:find_line(row) local line_count = vim.api.nvim_buf_line_count(self.buf) while row < line_count and self:is_concealed(row, 0) do row = row + 1 end return row end --- Renders the unicode placeholder grid in the buffer ---@param loc snacks.image.Loc function M:render_grid(loc) local hl = "SnacksImage" .. self.id -- image id is encoded in the foreground color Snacks.util.set_hl({ [hl] = { fg = self.img.id, sp = self.id, bg = Snacks.image.config.debug.placement and "#FF007C" or "none", nocombine = true, }, }) local img = {} ---@type string[] local height = math.min(#diacritics, loc.height) local width = math.min(#diacritics, loc.width) for r = 1, height do local line = {} ---@type string[] for c = 1, width do -- cell positions are encoded as diacritics for the placeholder unicode character line[#line + 1] = PLACEHOLDER line[#line + 1] = positions[r] line[#line + 1] = positions[c] end img[#img + 1] = table.concat(line) end local range = self.opts.range or { loc[1], loc[2], loc[1], loc[2] } local lines = vim.api.nvim_buf_get_lines(self.buf, range[1] - 1, range[3], false) local text_width = 0 for _, line in ipairs(lines) do text_width = math.max(text_width, vim.api.nvim_strwidth(line)) end local offset = range[2] local has_after = lines[#lines]:sub(range[4] + 1):find("%S") ~= nil local has_before = lines[1]:sub(1, range[2]):find("%S") ~= nil local conceal = self.opts.conceal and "" or nil local extmarks = {} ---@type snacks.image.Extmark[] -- we can overlay the image if the text is multiline, -- or the text has nothing after the image -- and the text is not wrapped or the text fits the window width local can_overlay = (#lines > 1 or not has_after) for _, win in ipairs(can_overlay and self:wins() or {}) do if vim.wo[win].wrap then local info = vim.fn.getwininfo(win)[1] if info.width - info.textoff < text_width then can_overlay = false break end end end if height == 1 and #lines == 1 then -- render inline self:_render({ { row = range[1] - 1, col = range[2], end_row = range[3] - 1, end_col = range[4], conceal = conceal, invalidate = vim.fn.has("nvim-0.10") == 1 and true or nil, virt_text_pos = "inline", virt_text = { { img[1], hl } }, virt_text_hide = true, }, }) elseif can_overlay then if conceal then -- conceal and overlay on the first line if not self:is_concealed(range[1] - 1, range[2]) then extmarks[#extmarks + 1] = { row = range[1] - 1, col = range[2], end_row = range[3] - 1, end_col = range[4], conceal = conceal, virt_text_pos = "overlay", virt_text = { { table.remove(img, 1), hl } }, virt_text_hide = false, virt_text_win_col = offset, } end -- overlay over the other lines for i = 1, math.min(#img, #lines - 1) do if self:is_concealed(range[1] - 1 + i, 0) then break end extmarks[#extmarks + 1] = { row = range[1] - 1 + i, col = 0, virt_text_pos = "overlay", virt_text = { { table.remove(img, 1), hl } }, virt_text_hide = false, virt_text_win_col = offset, } end -- conceal remaining lines if any local last = extmarks[#extmarks] if last and #img == 0 and (last.row < range[3] - 1) and vim.fn.has("nvim-0.11.4") == 1 then extmarks[#extmarks + 1] = { row = last.row + 1, end_row = range[3] - 1, col = 0, conceal_lines = "", virt_text_hide = false, } end end if #img > 0 then -- add additional virtual lines if there are more lines to render local row = self:find_line(range[3] - 1) local padding = string.rep(" ", offset) extmarks[#extmarks + 1] = { row = row, col = 0, virt_lines_above = row ~= range[3] - 1, ---@param l string virt_lines = vim.tbl_map(function(l) return { { padding }, { l, hl } } end, img), virt_text_hide = false, } end self:_render(extmarks) else local is_inline = has_before or has_after local icon = Snacks.image.config.icons[self.opts.type or "image"] or Snacks.image.config.icons.image -- render below in virtual lines extmarks[#extmarks + 1] = { row = range[1] - 1, col = range[2], end_row = range[3] - 1, end_col = range[4], conceal = conceal, virt_text = is_inline and { { icon, "SnacksImageAnchor" } } or nil, virt_text_pos = "inline", virt_text_hide = false, ---@param l string virt_lines = vim.tbl_map(function(l) return { { l, hl } } end, img), } self:_render(extmarks) end end ---@param extmarks snacks.image.Extmark[] function M:_render(extmarks) for _, e in ipairs(extmarks) do e.undo_restore = false e.strict = false if self.hidden then e.virt_text = nil e.conceal = nil if e.virt_lines then e.virt_lines = vim.tbl_map(function(l) return { { "" } } end, e.virt_lines) end end end local eids = {} ---@type number[] for _, extmark in ipairs(extmarks) do local row, col = extmark.row, extmark.col extmark.row, extmark.col, extmark.id = nil, nil, table.remove(self.eids, 1) table.insert(eids, vim.api.nvim_buf_set_extmark(self.buf, ns, row, col, extmark)) end for _, eid in ipairs(self.eids) do vim.api.nvim_buf_del_extmark(self.buf, ns, eid) end self.eids = eids end function M:hide() if self.hidden or not self:ready() then return end self.hidden = true self:update() end function M:show() if not self.hidden or not self:ready() then return end self.hidden = false self:update() end ---@param state snacks.image.State function M:render_fallback(state) if not self.opts.inline then vim.api.nvim_buf_clear_namespace(self.buf, ns, 0, -1) end for _, win in ipairs(state.wins) do self:debug("render_fallback", win) local border = setmetatable({ opts = vim.api.nvim_win_get_config(win) }, { __index = Snacks.win }):border_size() local pos = vim.api.nvim_win_get_position(win) if (Snacks.config.styles.snacks_image.relative ~= "editor") and ((vim.o.showtabline == 2) or (vim.o.showtabline == 1 and vim.fn.tabpagenr("$") > 1)) then terminal.set_cursor({ pos[1] + border.top, pos[2] + border.left }) else terminal.set_cursor({ pos[1] + 1 + border.top, pos[2] + border.left }) end terminal.request({ a = "p", i = self.img.id, p = self.id, C = 1, c = state.loc.width, r = state.loc.height, }) end end function M:debug(...) if true or not Snacks.image.config.debug then return end Snacks.debug.inspect({ ... }, self.img.src, self.img.id, self.id) end function M:state() local width, height = vim.o.columns, vim.o.lines local wins = {} ---@type number[] local is_fallback = not terminal.env().placeholders local zindex = vim.api.nvim_win_get_config(0).zindex or 0 for _, win in ipairs(self:wins()) do width = math.min(width, vim.api.nvim_win_get_width(win)) height = math.min(height, vim.api.nvim_win_get_height(win)) if is_fallback then local z = vim.api.nvim_win_get_config(win).zindex or 0 if z >= zindex or (zindex > 0 and z > 0) then wins[#wins + 1] = win -- use if higher z-index or both are floating end else wins[#wins + 1] = win end end local function minmax(value, min, max) return math.max(min or 1, math.min(value, max or value)) end width = minmax(self.opts.width or width, self.opts.min_width, self.opts.max_width) height = minmax(self.opts.height or height, self.opts.min_height, self.opts.max_height) local size = Snacks.image.util.fit(self.img.file, { width = width, height = height }, { info = self.img.info }) local pos = self.opts.pos or { 1, 0 } local function is_inline() local range = self.opts.range or { pos[1], pos[2], pos[1], pos[2] } if range[1] == range[3] then local line = vim.api.nvim_buf_get_lines(self.buf, range[1] - 1, range[1], false)[1] or "" local has_before = line:sub(1, range[2]):find("%S") ~= nil local has_after = line:sub(range[4] + 1):find("%S") ~= nil return has_before or has_after end end -- scale down to fit inline if size.height <= 2 and is_inline() then size.width = math.ceil(size.width / size.height) + 2 size.height = 1 end ---@class snacks.image.State ---@field hidden boolean ---@field loc snacks.image.Loc ---@field wins number[] return { hidden = self.hidden or false, loc = { pos[1], pos[2], width = size.width, height = size.height, }, wins = wins, } end function M:valid() return self.buf and vim.api.nvim_buf_is_valid(self.buf) and self:ready() and self.opts.pos[1] <= vim.api.nvim_buf_line_count(self.buf) end function M:update() if not self:ready() then return end if not self:valid() then self:del() return end if self.opts.on_update_pre then self.opts.on_update_pre(self) end local state = self:state() if vim.deep_equal(state, self._state) then return end self._state = state if #state.wins == 0 then self:hide() return end self.img:place(self) self:debug("update") if not self.opts.inline then for _, win in ipairs(state.wins) do Snacks.util.wo(win, Snacks.image.config.wo or {}) end end if terminal.env().placeholders then terminal.request({ a = "p", U = 1, i = self.img.id, p = self.id, C = 1, c = state.loc.width, r = state.loc.height, }) self:render_grid(state.loc) else self:render_fallback(state) end if not self.opts.inline then for _, win in ipairs(state.wins) do vim.api.nvim_win_call(win, function() vim.fn.winrestview({ topline = 1, lnum = 1, col = 0, leftcol = 0 }) end) end end if self.opts.on_update then self.opts.on_update(self) end end function M:ready() return not self.closed and self.buf and vim.api.nvim_buf_is_valid(self.buf) and self.img:ready() end return M ================================================ FILE: lua/snacks/image/terminal.lua ================================================ ---@class snacks.image.terminal ---@field transform? fun(data: string): string local M = {} local size ---@type snacks.image.terminal.Dim? ---@type snacks.image.Env[] local environments = { { name = "kitty", terminal = "kitty", supported = true, placeholders = true, }, { name = "ghostty", terminal = "ghostty", supported = true, placeholders = true, }, { name = "wezterm", terminal = "wezterm", supported = true, placeholders = false, }, { name = "tmux", env = { TERM = "tmux", TMUX = true }, setup = function() pcall(vim.fn.system, { "tmux", "set", "-p", "allow-passthrough", "all" }) end, transform = function(data) return ("\027Ptmux;" .. data:gsub("\027", "\027\027")) .. "\027\\" end, }, { name = "zellij", env = { TERM = "zellij", ZELLIJ = true }, supported = false, placeholders = false }, { name = "ssh", env = { SSH_CLIENT = true, SSH_CONNECTION = true }, remote = true }, } M._env = nil ---@type snacks.image.Env? M._terminal = nil ---@type snacks.image.Terminal? vim.api.nvim_create_autocmd("VimResized", { group = vim.api.nvim_create_augroup("snacks.image.terminal", { clear = true }), callback = function() size = nil end, }) function M.size() if size then return size end local ffi = require("ffi") ffi.cdef([[ typedef struct { unsigned short row; unsigned short col; unsigned short xpixel; unsigned short ypixel; } winsize; int ioctl(int, int, ...); ]]) local TIOCGWINSZ = nil if vim.fn.has("linux") == 1 then TIOCGWINSZ = 0x5413 elseif vim.fn.has("mac") == 1 or vim.fn.has("bsd") == 1 then TIOCGWINSZ = 0x40087468 end local dw, dh = 9, 18 ---@class snacks.image.terminal.Dim size = { width = vim.o.columns * dw, height = vim.o.lines * dh, columns = vim.o.columns, rows = vim.o.lines, cell_width = dw, cell_height = dh, scale = dw / 8, } pcall(function() ---@type { row: number, col: number, xpixel: number, ypixel: number } local sz = ffi.new("winsize") if ffi.C.ioctl(1, TIOCGWINSZ, sz) ~= 0 or sz.col == 0 or sz.row == 0 then return end size = { width = sz.xpixel, height = sz.ypixel, columns = sz.col, rows = sz.row, cell_width = sz.xpixel / sz.col, cell_height = sz.ypixel / sz.row, -- try to guess dpi scale scale = math.max(1, sz.xpixel / sz.col / 8), } end) return size end function M.envs() return environments end function M.env() if M._env then return M._env end if not M._terminal then M.detect() end M._env = { name = "", env = {}, } for _, e in ipairs(environments) do local override = os.getenv("SNACKS_" .. e.name:upper()) if override then e.detected = override ~= "0" and override ~= "false" else if e.terminal and M._terminal and M._terminal.terminal then e.detected = M._terminal.terminal:lower():find(e.terminal:lower()) ~= nil end if not e.detected then for k, v in pairs(e.env or {}) do local val = os.getenv(k) if val and (v == true or val:find(v)) then e.detected = true break end end end end if e.detected then M._env.name = M._env.name .. "/" .. e.name if e.supported ~= nil then M._env.supported = e.supported end if e.placeholders ~= nil then M._env.placeholders = e.placeholders end M._env.transform = e.transform or M._env.transform M._env.remote = e.remote or M._env.remote if e.setup then e.setup() end end end M._env.name = M._env.name:gsub("^/", "") return M._env end ---@param opts table|{data?: string} function M.request(opts) opts.q = opts.q ~= false and (opts.q or 2) or nil -- silence all local msg = {} ---@type string[] for k, v in pairs(opts) do if k ~= "data" then table.insert(msg, string.format("%s=%s", k, v)) end end msg = { table.concat(msg, ",") } if opts.data then msg[#msg + 1] = ";" msg[#msg + 1] = tostring(opts.data) end local data = "\27_G" .. table.concat(msg) .. "\27\\" if Snacks.image.config.debug.request and opts.m ~= 1 then Snacks.debug.inspect(opts) end M.write(data) end ---@param pos {[1]: number, [2]: number} function M.set_cursor(pos) M.write("\27[" .. pos[1] .. ";" .. (pos[2] + 1) .. "H") end function M.write(data) data = M.transform and M.transform(data) or data if vim.api.nvim_ui_send then vim.api.nvim_ui_send(data) else io.stdout:write(data) end end --- Detect terminal capabilities --- Will call the callback when detection is complete, --- or block until detection is complete if no callback is provided. ---@param cb? fun(term: snacks.image.Terminal) function M.detect(cb) if cb then -- async return M._detect(cb) end -- sync local detected = false M.detect(function() detected = true end) vim.wait(1500, function() return detected end, 10) end ---@param cb fun(term: snacks.image.Terminal) function M._detect(cb) if M._terminal then if M._terminal.pending then table.insert(M._terminal.pending, cb) return end return cb(M._terminal) end ---@class snacks.image.Terminal ---@field terminal? string ---@field version? string ---@field supported? boolean ---@field placeholders? boolean local ret = { terminal = "unknown", version = "unknown", pending = { cb }, ---@type fun(term: snacks.image.Terminal)[] } M._terminal = ret local timer = assert(vim.uv.new_timer()) local function on_done() if timer and not timer:is_closing() then timer:stop() timer:close() end vim.schedule(function() local todo = ret.pending or {} ret.pending = nil for _, c in ipairs(todo) do c(ret) end end) end if vim.env.TMUX then pcall(vim.fn.system, { "tmux", "set", "-p", "allow-passthrough", "all" }) M.transform = function(data) return ("\027Ptmux;" .. data:gsub("\027", "\027\027")) .. "\027\\" end -- NOTE: When tmux has extended-keys enabled, Neovim's TermResponse autocmd doesn't fire. -- Terminal response sequences leak as literal text instead of being captured. -- Workaround: Query tmux directly for the terminal name instead of sending escape sequences. -- See: https://github.com/folke/snacks.nvim/issues/2332 local ok, out = pcall(vim.fn.system, { "tmux", "show", "-g", "extended-keys" }) if ok and vim.trim(out):find(" on$") then ok, out = pcall(vim.fn.system, { "tmux", "display-message", "-p", "#{client_termname}" }) if ok then ret.terminal = vim.trim(out):gsub("^xterm%-", "") return vim.schedule(on_done) end end end local id = vim.api.nvim_create_autocmd("TermResponse", { group = vim.api.nvim_create_augroup("image.terminal.detect", { clear = true }), callback = function(ev) local data = ev.data.sequence ---@type string local term, version = data:match("P>|(%S+)%s*(.*)") if not (term and version) then return end ret.terminal = term ret.version = version vim.schedule(on_done) return true -- delete autocmd end, }) timer:start(1000, 0, function() vim.schedule(function() pcall(vim.api.nvim_del_autocmd, id) end) on_done() end) M.write("\27[>q") end return M ================================================ FILE: lua/snacks/image/util.lua ================================================ ---@class snacks.image.util local M = {} local dims = {} ---@type table --- Get the dimensions of a PNG file ---@param file string ---@return snacks.image.Size function M.dim(file) file = svim.fs.normalize(file) if dims[file] then return dims[file] end -- extract header with IHDR chunk local fd = assert(io.open(file, "rb"), "Failed to open file: " .. file) local header = fd:read(24) ---@type string fd:close() -- Check PNG signature assert(header:sub(1, 8) == "\137PNG\r\n\26\n", "Not a valid PNG file: " .. file) -- Extract width and height from the IHDR chunk local width = header:byte(17) * 16777216 + header:byte(18) * 65536 + header:byte(19) * 256 + header:byte(20) local height = header:byte(21) * 16777216 + header:byte(22) * 65536 + header:byte(23) * 256 + header:byte(24) dims[file] = { width = width, height = height } return dims[file] end ---@param size snacks.image.Size function M.pixels_to_cells(size) local terminal = Snacks.image.terminal.size() return M.norm({ width = size.width / terminal.cell_width, height = size.height / terminal.cell_height, }) end ---@param size snacks.image.Size ---@return snacks.image.Size function M.norm(size) return { width = math.max(1, math.ceil(size.width)), height = math.max(1, math.ceil(size.height)), } end ---@param file string ---@param cells snacks.image.Size size in rows x columns ---@param opts? { full?: boolean, info?: snacks.image.Info } function M.fit(file, cells, opts) opts = opts or {} local img_pixels ---@type snacks.image.Size if opts.info then local terminal = Snacks.image.terminal.size() img_pixels = {} img_pixels.height = opts.info.size.height / opts.info.dpi.height * 96 * terminal.scale img_pixels.width = opts.info.size.width / opts.info.dpi.width * 96 * terminal.scale else img_pixels = M.dim(file) end local img_cells = M.pixels_to_cells(img_pixels) local ret = vim.deepcopy(cells) -- if not opts.full then if img_cells.width <= cells.width and img_cells.height <= cells.height then return img_cells end ret.width = math.min(cells.width, img_cells.width) ret.height = math.min(cells.height, img_cells.height) -- end local scale = ret.width / ret.height local img_scale = img_cells.width / img_cells.height local fit_height = math.floor(ret.width / img_scale + 0.5) local fit_width = math.floor(ret.height * img_scale + 0.5) if ret.height == fit_height or ret.width == fit_width then -- Image fits exactly elseif img_scale > scale then -- Image is wider relative to height - fit to width ret.height = fit_height else -- Image is taller relative to width - fit to height ret.width = fit_width end return M.norm(ret) end return M ================================================ FILE: lua/snacks/indent.lua ================================================ ---@class snacks.indent local M = {} M.meta = { desc = "Indent guides and scopes", } M.enabled = false ---@class snacks.indent.Config ---@field enabled? boolean local defaults = { indent = { priority = 1, enabled = true, -- enable indent guides char = "│", only_scope = false, -- only show indent guides of the scope only_current = false, -- only show indent guides in the current window hl = "SnacksIndent", ---@type string|string[] hl groups for indent guides -- can be a list of hl groups to cycle through -- hl = { -- "SnacksIndent1", -- "SnacksIndent2", -- "SnacksIndent3", -- "SnacksIndent4", -- "SnacksIndent5", -- "SnacksIndent6", -- "SnacksIndent7", -- "SnacksIndent8", -- }, }, -- animate scopes. Enabled by default for Neovim >= 0.10 -- Works on older versions but has to trigger redraws during animation. ---@class snacks.indent.animate: snacks.animate.Config ---@field enabled? boolean --- * out: animate outwards from the cursor --- * up: animate upwards from the cursor --- * down: animate downwards from the cursor --- * up_down: animate up or down based on the cursor position ---@field style? "out"|"up_down"|"down"|"up" animate = { enabled = vim.fn.has("nvim-0.10") == 1, style = "out", easing = "linear", duration = { step = 20, -- ms per step total = 500, -- maximum duration }, }, ---@class snacks.indent.Scope.Config: snacks.scope.Config scope = { enabled = true, -- enable highlighting the current scope priority = 200, char = "│", underline = false, -- underline the start of the scope only_current = false, -- only show scope in the current window hl = "SnacksIndentScope", ---@type string|string[] hl group for scopes }, chunk = { -- when enabled, scopes will be rendered as chunks, except for the -- top-level scope which will be rendered as a scope. enabled = false, -- only show chunk scopes in the current window only_current = false, priority = 200, hl = "SnacksIndentChunk", ---@type string|string[] hl group for chunk scopes char = { corner_top = "┌", corner_bottom = "└", -- corner_top = "╭", -- corner_bottom = "╰", horizontal = "─", vertical = "│", arrow = ">", }, }, -- filter for buffers to enable indent guides ---@param buf number ---@param win number filter = function(buf, win) return vim.g.snacks_indent ~= false and vim.b[buf].snacks_indent ~= false and vim.bo[buf].buftype == "" end, debug = false, } ---@class snacks.indent.Scope: snacks.scope.Scope ---@field win number ---@field step? number ---@field animate? {from: number, to: number} local config = Snacks.config.get("scope", defaults) local ns = vim.api.nvim_create_namespace("snacks_indent") local cache_extmarks = {} ---@type table local debug_timer = assert((vim.uv or vim.loop).new_timer()) local cache_underline = {} ---@type table local has_repeat_lb = vim.fn.has("nvim-0.10.0") == 1 local states = {} ---@type table local scopes ---@type snacks.scope.Listener? local stats = { indents = 0, extmarks = 0, scope = 0, } Snacks.util.set_hl({ [""] = "NonText", Blank = "SnacksIndent", Scope = "Special", Chunk = "SnacksIndentScope", ["1"] = "DiagnosticInfo", ["2"] = "DiagnosticHint", ["3"] = "DiagnosticWarn", ["4"] = "DiagnosticError", ["5"] = "DiagnosticInfo", ["6"] = "DiagnosticHint", ["7"] = "DiagnosticWarn", ["8"] = "DiagnosticError", }, { prefix = "SnacksIndent", default = true }) ---@param level number ---@param hl string|string[] local function get_hl(level, hl) return type(hl) == "string" and hl or hl[(level - 1) % #hl + 1] end ---@param hl string local function get_underline_hl(hl) local ret = "SnacksIndentUnderline_" .. hl if not cache_underline[hl] then local fg = Snacks.util.color(hl, "fg") vim.api.nvim_set_hl(0, ret, { sp = fg, underline = true }) cache_underline[hl] = true end return ret end --- Get the virtual text for the indent guide with --- the given indent level, left column and shiftwidth ---@param indent number ---@param state snacks.indent.State local function get_extmarks(indent, state) local key = indent .. ":" .. state.leftcol .. ":" .. state.shiftwidth .. ":" .. state.indent_offset .. ":" .. (state.breakindent and "bi" or "") if cache_extmarks[key] then return cache_extmarks[key] end stats.extmarks = stats.extmarks + 1 local sw = state.shiftwidth indent = math.floor(indent / sw) -- full visible indents local offset = math.max(math.floor(state.indent_offset / sw), 0) -- offset for the scope cache_extmarks[key] = {} for i = 1 + offset, indent do local col = (i - 1) * sw - state.leftcol if col >= 0 then table.insert(cache_extmarks[key], { virt_text = { { config.indent.char, get_hl(i, config.indent.hl) } }, virt_text_pos = "overlay", virt_text_win_col = col, hl_mode = "combine", priority = config.indent.priority, ephemeral = true, virt_text_repeat_linebreak = has_repeat_lb and state.breakindent or nil, }) end end return cache_extmarks[key] end ---@param win number ---@param buf number ---@param top number ---@param bottom number local function get_state(win, buf, top, bottom) local prev, changedtick = states[win], vim.b[buf].changedtick ---@type snacks.indent.State?, number if not (prev and prev.buf == buf and prev.changedtick == changedtick) then prev = nil end ---@class snacks.indent.State ---@field indents table ---@field blanks table local state = { win = win, buf = buf, changedtick = changedtick, is_current = win == vim.api.nvim_get_current_win(), top = top, bottom = bottom, leftcol = vim.api.nvim_buf_call(buf, vim.fn.winsaveview).leftcol --[[@as number]], shiftwidth = vim.bo[buf].shiftwidth, indents = prev and prev.indents or { [0] = 0 }, blanks = prev and prev.blanks or {}, indent_offset = 0, -- the start column of the indent guides breakindent = vim.wo[win].breakindent and vim.wo[win].wrap, } state.shiftwidth = state.shiftwidth == 0 and vim.bo[buf].tabstop or state.shiftwidth states[win] = state return state end function M.debug_win() Snacks.debug.inspect(states[vim.api.nvim_get_current_win()]) end --- Called during every redraw cycle, so it should be fast. --- Everything that can be cached should be cached. ---@param win number ---@param buf number ---@param top number -- 1-indexed ---@param bottom number -- 1-indexed ---@private function M.on_win(win, buf, top, bottom) local state = get_state(win, buf, top, bottom) local scope = scopes and scopes:get(win) --[[@as snacks.indent.Scope?]] vim.api.nvim_buf_call(buf, function() if scope and vim.fn.foldclosed(scope.from) ~= -1 then scope = nil end end) -- adjust top and bottom if only_scope is enabled if config.indent.only_scope then if not scope then return end state.indent_offset = scope.indent or 0 state.top = math.max(state.top, scope.from) state.bottom = math.min(state.bottom, scope.to) end local show_indent = config.indent.enabled and (not config.indent.only_current or state.is_current) local show_scope = config.scope.enabled and (not config.scope.only_current or state.is_current) local show_chunk = config.chunk.enabled and (not config.chunk.only_current or state.is_current) -- Calculate and render indents local indents = state.indents vim.api.nvim_buf_call(buf, function() local parent_indent, current_indent ---@type number, number for l = state.top, state.bottom do local indent = indents[l] if not indent then stats.indents = stats.indents + 1 local next = vim.fn.nextnonblank(l) -- Indent for a blank line is the minimum of the previous and next non-blank line. -- If the previous and next non-blank lines have different indents, add shiftwidth. if next ~= l then state.blanks[l] = true local prev = vim.fn.prevnonblank(l) indents[prev] = indents[prev] or vim.fn.indent(prev) indents[next] = indents[next] or vim.fn.indent(next) indent = math.min(indents[prev], indents[next]) if indents[prev] ~= indents[next] and indent > 0 then indent = indent + state.shiftwidth end else indent = vim.fn.indent(l) end indents[l] = indent end if indent ~= current_indent then parent_indent = current_indent or indent current_indent = indent end indent = math.min(indent, parent_indent + state.shiftwidth) local extmarks = show_indent and indent > 0 and get_extmarks(indent, state) for _, opts in ipairs(extmarks or {}) do vim.api.nvim_buf_set_extmark(buf, ns, l - 1, 0, opts) end end end) -- Render scope if scope and (scope:size() > 1 or vim.g.snacks_indent_overlap) then show_chunk = show_chunk and (scope.indent or 0) >= state.shiftwidth if show_chunk then M.render_chunk(scope, state) elseif show_scope then M.render_scope(scope, state) end end end ---@param scope snacks.indent.Scope ---@param state snacks.indent.State ---@return number from, number to local function bounds(scope, state) local from, to = scope.from, scope.to if scope.animate then from = math.max(scope.animate.from, scope.from) to = math.min(scope.animate.to, scope.to) end from = math.max(from, state.top) to = math.min(to, state.bottom) return from, to end --- Render the scope overlapping the given range ---@param scope snacks.indent.Scope ---@param state snacks.indent.State ---@private function M.render_scope(scope, state) local indent = (scope.indent or 2) local hl = get_hl(math.floor(scope.indent / state.shiftwidth) + 1, config.scope.hl) local from, to = bounds(scope, state) local col = indent - state.leftcol if config.scope.underline and scope.from == from then local scope_first_line = vim.api.nvim_buf_get_lines(scope.buf, scope.from - 1, scope.from, false)[1] if scope_first_line ~= nil then vim.api.nvim_buf_set_extmark(scope.buf, ns, scope.from - 1, math.max(col, 0), { end_col = #scope_first_line, hl_group = get_underline_hl(hl), hl_mode = "combine", priority = config.scope.priority + 1, strict = false, ephemeral = true, }) end end if col < 0 then -- scope is hidden return end for l = from, to do local i = state.indents[l] if (i and i > indent) or vim.g.snacks_indent_overlap or state.blanks[l] then vim.api.nvim_buf_set_extmark(scope.buf, ns, l - 1, 0, { virt_text = { { config.scope.char, hl } }, virt_text_pos = "overlay", virt_text_win_col = col, hl_mode = "combine", priority = config.scope.priority, strict = false, ephemeral = true, virt_text_repeat_linebreak = has_repeat_lb and state.breakindent or nil, }) end end end --- Render the scope overlappping the given range ---@param scope snacks.indent.Scope ---@param state snacks.indent.State ---@private function M.render_chunk(scope, state) local indent = (scope.indent or 2) local col = indent - state.leftcol - state.shiftwidth if col < 0 then -- scope is hidden return end local from, to = bounds(scope, state) local hl = get_hl(math.floor(scope.indent / state.shiftwidth) + 1, config.chunk.hl) local char = config.chunk.char ---@param l number ---@param line string ---@param repeat_indent? boolean local function add(l, line, repeat_indent) vim.api.nvim_buf_set_extmark(scope.buf, ns, l - 1, 0, { virt_text = { { line, hl } }, virt_text_pos = "overlay", virt_text_win_col = col, hl_mode = "combine", priority = config.chunk.priority, strict = false, virt_text_repeat_linebreak = has_repeat_lb and repeat_indent or nil, ephemeral = true, }) end for l = from, to do local i = state.indents[l] - state.leftcol if l == scope.from then -- top line if state.breakindent then add(l, char.vertical, true) end add(l, char.corner_top .. (char.horizontal):rep(i - col - 1)) elseif l == scope.to then -- bottom line add(l, char.corner_bottom .. (char.horizontal):rep(i - col - 2) .. char.arrow) elseif i and i > col then -- middle line add(l, char.vertical, state.breakindent) end end end ---@param scope snacks.indent.Scope ---@param value number ---@param prev? number local function step(scope, value, prev) if not vim.api.nvim_win_is_valid(scope.win) then return end prev = prev or 0 local cursor = vim.api.nvim_win_get_cursor(scope.win) local dt = math.abs(scope.from - cursor[1]) local db = math.abs(scope.to - cursor[1]) local style = config.animate.style == "up_down" and (dt < db and "down" or "up") or config.animate.style if style == "down" then scope.animate = { from = scope.from, to = scope.from + value } elseif style == "up" then scope.animate = { from = scope.to - value, to = scope.to } elseif style == "out" then local line = math.min(math.max(scope.from, cursor[1]), scope.to) scope.animate = { from = math.max(scope.from, line - value), to = math.min(scope.to, line + value), } else Snacks.notify.error("Invalid animate style: " .. style, { title = "Snacks Indent", once = true }) end Snacks.util.redraw_range(scope.win, scope.animate.from, scope.animate.to) end -- Called when the scope changes ---@param win number ---@param buf number ---@param scope snacks.indent.Scope? ---@param prev snacks.indent.Scope? ---@private function M.on_scope(win, buf, scope, prev) stats.scope = stats.scope + 1 if scope then scope.win = win local animate = Snacks.animate.enabled({ buf = buf, name = "indent" }) vim.api.nvim_buf_call(buf, function() -- skip animation if new lines have been added before or inside the scope if prev and (vim.fn.nextnonblank(prev.from) == scope.from) then animate = false end end) if animate then step(scope, 0) Snacks.animate( 0, scope.to - scope.from, function(value, ctx) if scopes and scopes:get(win) ~= scope then return end step(scope, value, ctx.prev) end, vim.tbl_extend("keep", { int = true, id = "indent_scope_" .. win, buf = buf, }, config.animate) ) else Snacks.util.redraw_range(win, scope.from, scope.to) end end if prev then -- clear previous scope Snacks.util.redraw_range(win, prev.from, prev.to) end end ---@private function M.debug() if debug_timer:is_active() then debug_timer:stop() return end local last = {} debug_timer:start(50, 50, function() if not vim.deep_equal(stats, last) then last = vim.deepcopy(stats) Snacks.notify(vim.inspect(stats), { ft = "lua", id = "snacks_indent_debug", title = "Snacks Indent Debug" }) end end) end --- Enable indent guides function M.enable() if M.enabled then return end config = Snacks.config.get("indent", defaults) if config.debug then M.debug() end vim.g.snacks_animate_indent = config.animate.enabled M.enabled = true -- setup decoration provider vim.api.nvim_set_decoration_provider(ns, { on_win = function(_, win, buf, top, bottom) if M.enabled and config.filter(buf, win) then M.on_win(win, buf, top + 1, bottom + 1) end end, }) -- Listen for scope changes scopes = scopes or Snacks.scope.attach(M.on_scope, config.scope) if not scopes.enabled then scopes:enable() end local group = vim.api.nvim_create_augroup("snacks_indent", { clear = true }) vim.api.nvim_create_autocmd("ColorScheme", { group = group, callback = function() cache_underline = {} end, }) -- cleanup cache vim.api.nvim_create_autocmd({ "WinClosed", "BufDelete", "BufWipeout" }, { group = group, callback = function() for win in pairs(states) do if not vim.api.nvim_win_is_valid(win) then states[win] = nil end end end, }) -- redraw when shiftwidth changes -- vim.api.nvim_create_autocmd("OptionSet", { -- group = group, -- pattern = { "shiftwidth", "listchars", "list" }, -- callback = vim.schedule_wrap(function() -- vim.cmd([[redraw!]]) -- end), -- }) end -- Disable indent guides function M.disable() if not M.enabled then return end M.enabled = false if scopes then scopes:disable() end vim.api.nvim_del_augroup_by_name("snacks_indent") debug_timer:stop() states = {} stats = { indents = 0, extmarks = 0, scope = 0 } vim.cmd([[redraw!]]) end return M ================================================ FILE: lua/snacks/init.lua ================================================ ---@class Snacks: snacks.plugins local M = {} setmetatable(M, { __index = function(t, k) ---@diagnostic disable-next-line: no-unknown t[k] = require("snacks." .. k) return rawget(t, k) end, }) _G.Snacks = M _G.svim = vim.fn.has("nvim-0.11") == 1 and vim or require("snacks.compat") M.version = "2.31.0" -- x-release-please-version ---@class snacks.Config.base ---@field example? string ---@field config? fun(opts: table, defaults: table) ---@class snacks.Config: snacks.plugins.Config ---@field styles? table ---@field image? snacks.image.Config|{} local config = { image = { -- define these here, so that we don't need to load the image module formats = { "png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "heic", "avif", "mp4", "mov", "avi", "mkv", "webm", "pdf", "icns", }, }, } config.styles = {} ---@class snacks.config: snacks.Config M.config = setmetatable({}, { __index = function(_, k) config[k] = config[k] or {} return config[k] end, __newindex = function(_, k, v) config[k] = v end, }) local is_dict_like = function(v) -- has string and number keys return type(v) == "table" and (vim.tbl_isempty(v) or not svim.islist(v)) end local is_dict = function(v) -- has only string keys return type(v) == "table" and (vim.tbl_isempty(v) or not v[1]) end --- Merges the values similar to vim.tbl_deep_extend with the **force** behavior, --- but the values can be any type ---@generic T ---@param ... T ---@return T function M.config.merge(...) local ret = select(1, ...) for i = 2, select("#", ...) do local value = select(i, ...) if is_dict_like(ret) and is_dict(value) then for k, v in pairs(value) do ret[k] = M.config.merge(ret[k], v) end elseif value ~= nil then ret = value end end return ret end --- Get an example config from the docs/examples directory. ---@param snack string ---@param name string ---@param opts? table function M.config.example(snack, name, opts) local path = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h:h") .. "/docs/examples/" .. snack .. ".lua" local ok, ret = pcall(function() return loadfile(path)().examples[name] or error(("`%s` not found"):format(name)) end) if not ok then M.notify.error(("Failed to load `%s.%s`:\n%s"):format(snack, name, ret)) end return ok and vim.tbl_deep_extend("force", {}, vim.deepcopy(ret), opts or {}) or {} end ---@generic T: table ---@param snack string ---@param defaults T ---@param ... T[] ---@return T function M.config.get(snack, defaults, ...) local merge, todo = {}, { defaults, config[snack] or {}, ... } for i = 1, select("#", ...) + 2 do local v = todo[i] --[[@as snacks.Config.base]] if type(v) == "table" then if v.example then table.insert(merge, vim.deepcopy(M.config.example(snack, v.example))) v.example = nil end table.insert(merge, vim.deepcopy(v)) end end local ret = M.config.merge(unpack(merge)) if type(ret.config) == "function" then ret.config(ret, defaults) end return ret end --- Register a new window style config. ---@param name string ---@param defaults snacks.win.Config|{} ---@return string function M.config.style(name, defaults) config.styles[name] = vim.tbl_deep_extend("force", vim.deepcopy(defaults), config.styles[name] or {}) return name end M.did_setup = false M.did_setup_after_vim_enter = false ---@param opts snacks.Config? function M.setup(opts) if M.did_setup then return vim.notify("snacks.nvim is already setup", vim.log.levels.ERROR, { title = "snacks.nvim" }) end M.did_setup = true if vim.fn.has("nvim-0.9.4") ~= 1 then return vim.notify("snacks.nvim requires Neovim >= 0.9.4", vim.log.levels.ERROR, { title = "snacks.nvim" }) end -- enable all by default when config is passed opts = opts or {} for k in pairs(opts) do opts[k].enabled = opts[k].enabled == nil or opts[k].enabled end config = vim.tbl_deep_extend("force", config, opts or {}) local events = { BufReadPre = { "bigfile", "image" }, BufReadPost = { "quickfile", "indent" }, BufEnter = { "explorer" }, LspAttach = { "words" }, UIEnter = { "dashboard", "scroll", "input", "scope", "picker" }, } ---@param event string ---@param ev? vim.api.keyset.create_autocmd.callback_args local function load(event, ev) local todo = events[event] or {} events[event] = nil for _, snack in ipairs(todo) do if M.config[snack] and M.config[snack].enabled then if M[snack].setup then M[snack].setup(ev) elseif M[snack].enable then M[snack].enable() end end end end if vim.v.vim_did_enter == 1 then M.did_setup_after_vim_enter = true load("UIEnter") end local group = vim.api.nvim_create_augroup("snacks", { clear = true }) vim.api.nvim_create_autocmd(vim.tbl_keys(events), { group = group, once = true, nested = true, callback = function(ev) load(ev.event, ev) end, }) if M.config.image.enabled and #M.config.image.formats > 0 then vim.api.nvim_create_autocmd("BufReadCmd", { once = true, pattern = "*." .. table.concat(M.config.image.formats, ",*."), group = group, callback = function(e) require("snacks.image").setup(e) end, }) end vim.api.nvim_create_autocmd("BufReadCmd", { once = true, pattern = "gh://*", group = group, callback = function(e) require("snacks.gh").setup(e) end, }) if M.config.statuscolumn.enabled then vim.o.statuscolumn = [[%!v:lua.require'snacks.statuscolumn'.get()]] end if M.config.notifier.enabled then vim.notify = function(msg, level, o) vim.notify = Snacks.notifier.notify return Snacks.notifier.notify(msg, level, o) end end end return M ================================================ FILE: lua/snacks/input.lua ================================================ ---@class snacks.input ---@overload fun(opts: snacks.input.Opts, on_confirm: fun(value?: string)): snacks.win local M = setmetatable({}, { __call = function(M, ...) return M.input(...) end, }) M.meta = { desc = "Better `vim.ui.input`", needs_setup = true, } ---@alias snacks.input.Pos "left"|"title"|false ---@class snacks.input.Config ---@field enabled? boolean ---@field win? snacks.win.Config|{} ---@field icon? string ---@field icon_pos? snacks.input.Pos ---@field prompt_pos? snacks.input.Pos local defaults = { icon = " ", icon_hl = "SnacksInputIcon", icon_pos = "left", prompt_pos = "title", win = { style = "input" }, expand = true, } Snacks.util.set_hl({ Icon = "DiagnosticHint", Normal = "Normal", Border = "DiagnosticInfo", Title = "DiagnosticInfo", Prompt = "SnacksInputTitle", }, { prefix = "SnacksInput", default = true }) Snacks.config.style("input", { backdrop = false, position = "float", border = true, title_pos = "center", height = 1, width = 60, relative = "editor", noautocmd = true, row = 2, -- relative = "cursor", -- row = -3, -- col = 0, wo = { winhighlight = "NormalFloat:SnacksInputNormal,FloatBorder:SnacksInputBorder,FloatTitle:SnacksInputTitle", cursorline = false, }, bo = { filetype = "snacks_input", buftype = "prompt", }, --- buffer local variables b = { completion = false, -- disable blink completions in input }, keys = { n_esc = { "", { "cmp_close", "cancel" }, mode = "n", expr = true }, i_esc = { "", { "cmp_close", "stopinsert" }, mode = "i", expr = true }, i_cr = { "", { "cmp_accept", "confirm" }, mode = { "i", "n" }, expr = true }, i_tab = { "", { "cmp_select_next", "cmp" }, mode = "i", expr = true }, i_ctrl_w = { "", "", mode = "i", expr = true }, i_up = { "", { "hist_up" }, mode = { "i", "n" } }, i_down = { "", { "hist_down" }, mode = { "i", "n" } }, q = "cancel", }, }) local ui_input = vim.ui.input ---@alias snacks.input.Highlight {[1]:number, [2]:number, [3]:string} ---@class snacks.input.Opts: snacks.input.Config,{} ---@field prompt? string ---@field default? string ---@field completion? string ---@field highlight? fun(text: string): snacks.input.Highlight[] ---@class snacks.input.ctx ---@field opts? snacks.input.Opts ---@field win? snacks.win local ctx = {} local ns = vim.api.nvim_create_namespace("snacks.input") ---@param opts? snacks.input.Opts ---@param on_confirm fun(value?: string) function M.input(opts, on_confirm) assert(type(on_confirm) == "function", "`on_confirm` must be a function") local history = require("snacks.picker.util.history").new("input", { filter = function(value) return value ~= "" end, }) local parent_win = vim.api.nvim_get_current_win() local mode = vim.fn.mode() ---@param force? boolean local function record(force) if not ctx.win then return end if not force and not history:is_current() then return end local text = vim.trim(ctx.win:text()) if text == "" then return end history:record(text) end local function confirm(value) record() ctx.win = nil ctx.opts = nil vim.cmd.stopinsert() vim.schedule(function() if vim.api.nvim_win_is_valid(parent_win) then vim.api.nvim_set_current_win(parent_win) if mode == "i" then vim.cmd("startinsert") end end on_confirm(value) end) end opts = Snacks.config.get("input", defaults, opts) --[[@as snacks.input.Opts]] opts.prompt = opts.prompt or "Input" opts.prompt = vim.trim(opts.prompt) opts.prompt = opts.prompt_pos == "title" and opts.prompt:gsub(":$", "") or opts.prompt local title, statuscolumn = {}, {} ---@type string[], string[] local function add(text, hl, pos) if pos == "title" then table.insert(title, { " " .. text, hl }) else table.insert(statuscolumn, "%#" .. hl .. "#" .. text) end end if opts.icon_pos and (opts.icon or "") ~= "" then add(opts.icon, "SnacksInputIcon", opts.icon_pos) end add(opts.prompt, "SnacksInputTitle", opts.prompt_pos) if next(title) then table.insert(title, { " ", "SnacksInputTitle" }) end ---@param text? string local function set(text) text = text or "" vim.api.nvim_buf_set_lines(ctx.win.buf, 0, -1, false, { text }) vim.api.nvim_win_set_cursor(ctx.win.win, { 1, #text }) end opts.win = Snacks.win.resolve("input", opts.win, { enter = true, title = next(title) and title or nil, bo = { modifiable = true, completefunc = "v:lua.Snacks.input.complete", omnifunc = "v:lua.Snacks.input.complete", }, wo = { statuscolumn = next(statuscolumn) and " " .. table.concat(statuscolumn, " ") .. " " or " ", }, actions = { cancel = function(self) confirm() self:close() end, stopinsert = function() vim.schedule(function() vim.cmd("stopinsert") end) end, confirm = function(self) confirm(self:text()) self:close() end, hist_up = function(self) record() set(history:prev()) end, hist_down = function(self) record() set(history:next()) end, cmp = function() return vim.fn.pumvisible() == 0 and "" end, cmp_close = function() return vim.fn.pumvisible() == 1 and "" end, cmp_accept = function() return vim.fn.pumvisible() == 1 and "" end, cmp_select_next = function() return vim.fn.pumvisible() == 1 and "" end, cmp_select_prev = function() return vim.fn.pumvisible() == 1 and "" end, }, }) local parent_zindex = vim.api.nvim_win_get_config(parent_win).zindex opts.win.zindex = math.max((parent_zindex or 50) + 1, opts.win.zindex or 50) local min_width = opts.win.width or 60 if opts.expand then ---@param self snacks.win opts.win.width = function(self) local w = type(min_width) == "function" and min_width(self) or min_width --[[@as number]] return math.max(w, vim.api.nvim_strwidth(self:text()) + 5) end end local win = Snacks.win(opts.win) ctx = { opts = opts, win = win } vim.fn.prompt_setprompt(win.buf, "") if opts.default then vim.api.nvim_buf_set_lines(win.buf, 0, -1, false, { opts.default }) end local function highlight() if type(opts.highlight) ~= "function" then return end local text = win:text() vim.api.nvim_buf_clear_namespace(win.buf, ns, 0, -1) for _, hl in ipairs(opts.highlight(text)) do vim.api.nvim_buf_set_extmark(win.buf, ns, 0, hl[1], { end_col = hl[2], hl_group = hl[3], strict = false, }) end end highlight() vim.api.nvim_win_call(win.win, function() vim.cmd("startinsert!") end) vim.fn.prompt_setcallback(win.buf, function(text) confirm(text) win:close() end) vim.fn.prompt_setinterrupt(win.buf, function() confirm() win:close() end) win:on({ "TextChangedI", "TextChanged" }, function() if not win:valid() then return end highlight() vim.bo[win.buf].modified = false if opts.expand then if vim.api.nvim_win_is_valid(parent_win) then vim.api.nvim_win_call(parent_win, function() win:update() end) end vim.api.nvim_win_call(win.win, function() vim.fn.winrestview({ leftcol = 0 }) end) end end, { buf = true }) return win end ---@param findstart number ---@param base string ---@private function M.complete(findstart, base) local completion = ctx.opts.completion if findstart == 1 then return #ctx.win:text():gsub("%S+$", "") end if not completion then return {} end local ok, results = pcall(vim.fn.getcompletion, base, completion) return ok and results or {} end function M.enable() vim.ui.input = M.input end function M.disable() vim.ui.input = ui_input end ---@private function M.health() if Snacks.config.get("input", defaults).enabled then if vim.ui.input == M.input then Snacks.health.ok("`vim.ui.input` is set to `Snacks.input`") else Snacks.health.error("`vim.ui.input` is not set to `Snacks.input`") end end end return M ================================================ FILE: lua/snacks/keymap.lua ================================================ ---@class snacks.keymap local M = {} M.meta = { desc = "Better `vim.keymap` with support for filetypes and LSP clients", needs_setup = false, } ---@class snacks.keymap.set.Opts: vim.keymap.set.Opts ---@field ft? string|string[] Filetype(s) to set the keymap for. ---@field lsp? vim.lsp.get_clients.Filter Set for buffers with LSP clients matching this filter. ---@field enabled? boolean|fun(buf?:number): boolean condition to enable the keymap. ---@class snacks.keymap.del.Opts: vim.keymap.del.Opts ---@field buffer? boolean|number If true or 0, use the current buffer. ---@field ft? string|string[] Filetype(s) to set the keymap for. ---@field lsp? vim.lsp.get_clients.Filter Set for buffers with LSP clients matching this filter. ---@class snacks.Keymap ---@field id number Unique ID for the keymap. ---@field key string Unique key for the keymap, in the format "mode:lhs". ---@field mode string Mode "short-name" (see |nvim_set_keymap()|), or a list thereof. ---@field lhs string Left-hand side |{lhs}| of the mapping. ---@field rhs string|function Right-hand side |{rhs}| of the mapping, can be a Lua function. ---@field lsp? vim.lsp.get_clients.Filter ---@field opts? snacks.keymap.set.Opts ---@field enabled fun(buf:number): boolean local by_ft = {} ---@type table> local by_lsp = {} ---@type table -- all LSP keymaps, indexed by lsp filter string + keymap key local lsp_on = {} ---@type table -- tracks which LSP filters we're listening to local lsp_dirty = {} ---@type table -- tracks which buffers need their LSP keymaps re-evaluated local kid = 0 local valid = { buffer = true, desc = true, callback = true, remap = true, silent = true, expr = true, nowait = true, unique = true, script = true, replace_keycodes = true, noremap = true, } local did_setup = false ---@param filter vim.lsp.get_clients.Filter local function lsp_key(filter) local ret = {} for k, v in pairs(filter) do table.insert(ret, ("%s=%s"):format(k, v)) end table.sort(ret) return table.concat(ret, ",") end ---@param buf number local function on_ft(buf) local ft = vim.bo[buf].filetype for _, map in pairs(by_ft[ft] or {}) do if map.enabled(buf) then vim.keymap.set(map.mode, map.lhs, map.rhs, Snacks.config.merge(map.opts or {}, { buffer = buf })) end end end ---@param buf number local function on_lsp_buf(buf) if not vim.api.nvim_buf_is_valid(buf) then return -- buffer was closed before we could update it, ignore end local keys = vim.tbl_values(by_lsp) ---@type snacks.Keymap[] table.sort(keys, function(a, b) return a.id > b.id -- newer keymaps first, so they take precedence end) local done = {} ---@type table local matches = {} ---@type table for _, map in ipairs(keys) do if not done[map.key] and map.enabled(buf) then local filter = Snacks.config.merge(vim.deepcopy(map.lsp or {}), { bufnr = buf }) local lkey = lsp_key(filter) if matches[lkey] == nil then matches[lkey] = #(vim.lsp.get_clients(filter)) > 0 end if matches[lkey] then done[map.key] = true vim.keymap.set(map.mode, map.lhs, map.rhs, Snacks.config.merge(map.opts or {}, { buffer = buf })) end end end end local function on_lsp() for buf in pairs(lsp_dirty) do lsp_dirty[buf] = nil on_lsp_buf(buf) end end local function setup() if did_setup then return end did_setup = true on_lsp = Snacks.util.debounce(on_lsp, { ms = 100 }) vim.api.nvim_create_autocmd("FileType", { group = vim.api.nvim_create_augroup("snacks.keymap.ft", { clear = true }), callback = function(ev) on_ft(ev.buf) end, }) end ---@generic T: snacks.keymap.set.Opts|snacks.keymap.del.Opts ---@param ... T ---@return T opts, string[]? fts, vim.lsp.get_clients.Filter? lsp, fun(buf?:number) enabled local function get_opts(...) ---@type snacks.keymap.set.Opts|snacks.keymap.del.Opts local opts = Snacks.config.merge({}, ...) opts.silent = opts.silent ~= false opts.buffer = (opts.buffer == 0 or opts.buffer == true) and vim.api.nvim_get_current_buf() or opts.buffer local fts = opts.ft and (type(opts.ft) == "table" and opts.ft or { opts.ft }) or nil --[[@as string[] ]] local lsp = opts.lsp local ret = vim.deepcopy(opts) ---@type table for k in pairs(ret) do if not valid[k] then ret[k] = nil end end local enabled = function(buf) if type(opts.enabled) == "function" then return opts.enabled(buf) end return opts.enabled ~= false end return ret, fts, lsp, enabled end ---@param mode string|string[] Mode "short-name" (see |nvim_set_keymap()|), or a list thereof. ---@param lhs string Left-hand side |{lhs}| of the mapping. ---@param rhs string|function Right-hand side |{rhs}| of the mapping, can be a Lua function. ---@param opts? snacks.keymap.set.Opts function M.set(mode, lhs, rhs, opts) setup() if type(mode) == "table" then for _, m in ipairs(mode) do M.set(m, lhs, rhs, opts) end return end local _opts, fts, lsp, enabled = get_opts(opts) kid = kid + 1 local key = ("%s:%s"):format(mode, lhs) ---@type snacks.Keymap local km = { id = kid, key = key, mode = mode, lhs = lhs, rhs = rhs, lsp = lsp, opts = _opts, enabled = enabled } if lsp then local lkey = lsp_key(lsp) by_lsp[lkey .. ":" .. key] = km if not lsp_on[lkey] then lsp_on[lkey] = true Snacks.util.lsp.on(lsp, function(buf) -- always re-evaluate all LSP keymaps for the buffer, -- to respect the order of keymaps with the same mode:lhs lsp_dirty[buf] = true on_lsp() end) end elseif fts then for _, ft in ipairs(fts) do by_ft[ft] = by_ft[ft] or {} by_ft[ft][key] = km end for _, buf in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_loaded(buf) and vim.tbl_contains(fts, vim.bo[buf].filetype) then on_ft(buf) end end else if enabled(_opts and _opts.buffer or nil --[[@as integer?]]) then vim.keymap.set(mode, lhs, rhs, _opts) end end end ---@param mode string|string[] Mode "short-name" (see |nvim_set_keymap()|), or a list thereof. ---@param lhs string Left-hand side |{lhs}| of the mapping. ---@param opts? snacks.keymap.del.Opts function M.del(mode, lhs, opts) if type(mode) == "table" then for _, m in ipairs(mode) do M.del(m, lhs, opts) end return end local _opts, fts, lsp = get_opts(opts) local key = ("%s:%s"):format(mode, lhs) if lsp then local lkey = lsp_key(lsp) by_lsp[lkey .. ":" .. key] = nil -- re-evaluate all LSP keymaps for all buffers with clients matching this filter, -- since lower-priority keymaps may now take precedence for _, client in ipairs(vim.lsp.get_clients(lsp)) do for buf in pairs(client.attached_buffers) do lsp_dirty[buf] = true end end on_lsp() elseif fts then for _, ft in ipairs(fts) do if by_ft[ft] then by_ft[ft][key] = nil end end else vim.keymap.del(mode, lhs, _opts) end end return M ================================================ FILE: lua/snacks/layout.lua ================================================ ---@class snacks.layout ---@field opts snacks.layout.Config ---@field root snacks.win ---@field wins table ---@field box_wins snacks.win[] ---@field win_opts table ---@field closed? boolean ---@field split? boolean ---@field screenpos number[]? local M = {} M.__index = M M.meta = { desc = "Window layouts", } ---@class snacks.layout.Win: snacks.win.Config,{} ---@field depth? number ---@field win string layout window name ---@class snacks.layout.Box: snacks.layout.Win,{} ---@field box "horizontal" | "vertical" ---@field id? number ---@field [number] snacks.layout.Win | snacks.layout.Box children ---@alias snacks.layout.Widget snacks.layout.Win | snacks.layout.Box ---@class snacks.layout.Config ---@field show? boolean show the layout on creation (default: true) ---@field wins table windows to include in the layout ---@field layout snacks.layout.Box layout definition ---@field fullscreen? boolean open in fullscreen ---@field hidden? string[] list of windows that will be excluded from the layout (but can be toggled) ---@field on_update? fun(layout: snacks.layout) ---@field on_update_pre? fun(layout: snacks.layout) ---@field on_close? fun(layout: snacks.layout) local defaults = { layout = { width = 0.6, height = 0.6, zindex = 50, }, } ---@param opts snacks.layout.Config function M.new(opts) local self = setmetatable({}, M) self.opts = vim.tbl_extend("force", defaults, opts) self.win_opts = {} self.wins = self.opts.wins or {} self.box_wins = {} self.opts.layout.zindex = Snacks.win.zindex(self.opts.layout.zindex) + 2 -- wrap the split layout in a vertical box -- this is needed since a simple split window can't have borders/titles if self.opts.layout.position and self.opts.layout.position ~= "float" then self.split = true local inner = self.opts.layout self.opts.layout = { zindex = 30, box = "vertical", position = inner.position, width = inner.width, height = inner.height, backdrop = inner.backdrop, inner, } inner.width, inner.height, inner.col, inner.row, inner.position = 0, 0, 0, 0, nil end -- assign ids to boxes and create box wins if needed local id = 1 self:each(function(box, parent) box.depth = (parent and parent.depth + 1) or 0 if box.box then ---@cast box snacks.layout.Box box.id, id = id, id + 1 local has_border = box.border and box.border ~= "" and box.border ~= "none" local is_root = box.id == 1 if is_root or has_border then local backdrop = false if is_root then backdrop = nil end self.box_wins[box.id] = Snacks.win(Snacks.win.resolve(box, { relative = is_root and (box.relative or "editor") or "win", focusable = false, enter = false, show = false, resize = false, noautocmd = true, backdrop = backdrop, zindex = (self.opts.layout.zindex or 50) + box.depth, bo = { filetype = "snacks_layout_box", buftype = "nofile" }, w = { snacks_layout = true }, border = box.border, })) end end end) self.root = self.box_wins[1] assert(self.root, "no root box found") for w, win in pairs(self.wins) do self.win_opts[w] = vim.deepcopy(win.opts) if win.opts.relative == "win" then win.layout = false end end -- close layout when any win is closed self.root:on("WinClosed", function(_, ev) if self.closed then return true end local wid = tonumber(ev.match) for _, win in pairs(self:get_wins()) do if win.win == wid then self:close() return true end end end) self.root:on("WinResized", function(_, ev) if self.closed then return true end if not self.root:on_current_tab() then return end local sp = vim.fn.screenpos(self.root.win, 1, 1) if not vim.deep_equal(sp, self.screenpos) then self.screenpos = sp return self:update() else if vim.tbl_contains(vim.v.event.windows, self.root.win) then return self:update() end for _, win in pairs(self.wins) do if win:win_valid() and vim.tbl_contains(vim.v.event.windows, win.win) then local width_diff = vim.api.nvim_win_get_width(win.win) - win.opts.width local height_diff = vim.api.nvim_win_get_height(win.win) - win.opts.height if width_diff ~= 0 then vim.api.nvim_win_set_width(self.root.win, vim.api.nvim_win_get_width(self.root.win) + width_diff) end if height_diff ~= 0 then vim.api.nvim_win_set_height(self.root.win, vim.api.nvim_win_get_height(self.root.win) + height_diff) end if width_diff ~= 0 or height_diff ~= 0 then return self:update() end end end end end) -- update layout on VimResized self.root:on("VimResized", function() if not self.root:on_current_tab() then return end self:update() end) if self.opts.show ~= false then vim.schedule(function() self:show() end) end return self end ---@param cb fun(widget: snacks.layout.Widget, parent?: snacks.layout.Box) ---@param opts? {wins?:boolean, boxes?:boolean, box?:snacks.layout.Box} function M:each(cb, opts) opts = opts or {} ---@param widget snacks.layout.Widget ---@param parent? snacks.layout.Box local function _each(widget, parent) if widget.box then if opts.boxes ~= false then cb(widget, parent) end ---@cast widget snacks.layout.Box for _, child in ipairs(widget) do _each(child, widget) end elseif opts.wins ~= false then cb(widget, parent) end end _each(opts.box or self.opts.layout) end ---@param win string function M:needs_layout(win) local w = self.wins[win] return w and w.layout ~= false and not self:is_hidden(win) end --- Check if a window is hidden ---@param win string function M:is_hidden(win) return self.opts.hidden and vim.tbl_contains(self.opts.hidden, win) end --- Toggle a window ---@param win string ---@param enable? boolean ---@param on_update? fun(enabled: boolean) called when the layout will be updated function M:toggle(win, enable, on_update) self.opts.hidden = self.opts.hidden or {} local enabled = not self:is_hidden(win) if enable == nil then enable = not enabled end if enable == enabled then return end if enable then self.opts.hidden = vim.tbl_filter(function(w) return w ~= win end, self.opts.hidden) else table.insert(self.opts.hidden, win) end if on_update then on_update(enable) end self:update() end ---@package function M:update() if self.closed then return end vim.o.lazyredraw = true for _, win in pairs(self.wins) do win.enabled = false end local layout = vim.deepcopy(self.opts.layout) if self.opts.fullscreen then layout.width = 0 layout.height = 0 layout.col = 0 layout.row = 0 end if not self.root:valid() then self.root:show() self.screenpos = vim.fn.screenpos(self.root.win, 1, 1) end -- Calculate offsets for vertical splits local top, bottom = 0, 0 local pos = self.opts.layout.position if pos and (pos == "left" or pos == "right") or self.opts.fullscreen then bottom = (vim.o.cmdheight + (vim.o.laststatus == 3 and 1 or 0)) or 0 top = (vim.o.showtabline == 2 or (vim.o.showtabline == 1 and #vim.api.nvim_list_tabpages() > 1)) and 1 or 0 end local parent_width = layout.relative == "win" and vim.api.nvim_win_get_width(self.root.opts.win or 0) or vim.o.columns local parent_height = layout.relative == "win" and vim.api.nvim_win_get_height(self.root.opts.win or 0) or vim.o.lines - top - bottom self:update_box(layout, { col = 0, row = self.opts.fullscreen and self.split and top or 0, -- only needed for fullscreen splits width = parent_width, height = parent_height, }) -- fix fullscreen float layouts if self.opts.fullscreen and not self.split then self.root.opts.row = self.root.opts.row + top end if self.opts.on_update_pre then self.opts.on_update_pre(self) end for _, win in pairs(self:get_wins()) do if win:valid() then -- update windows with eventignore=all -- to fix issues with syntax being reset local ei = vim.o.eventignore vim.o.eventignore = "all" win:update() vim.o.eventignore = ei else win:show() end end for w, win in pairs(self.wins) do if not self:is_enabled(w) and win:win_valid() then win:close() end end vim.o.lazyredraw = false if self.opts.on_update then self.opts.on_update(self) end end ---@param box snacks.layout.Box ---@param parent snacks.win.Dim ---@private function M:update_box(box, parent) local size_main = box.box == "horizontal" and "width" or "height" local pos_main = box.box == "horizontal" and "col" or "row" local is_root = box.id == 1 if not is_root then box.col = box.col or 0 box.row = box.row or 0 end local children = {} ---@type snacks.layout.Widget[] for c, child in ipairs(box) do if not child.win or self:needs_layout(child.win) then children[#children + 1] = child end box[c] = nil end for c, child in ipairs(children) do box[c] = child end local dim, border = self:dim_box(box, parent) local orig_dim = vim.deepcopy(dim) if is_root then dim.col = parent.col dim.row = parent.row else dim.col = dim.col + border.left + parent.col dim.row = dim.row + border.top + parent.row end local free = vim.deepcopy(dim) local box_win = self.box_wins[box.id] local function size(child) local ret = child[size_main] or 0 if type(ret) == "function" then ret = ret(box_win) end return ret end local dims = {} ---@type table local flex = 0 -- fixed for c, child in ipairs(box) do if size(child) > 0 then dims[c] = self:resolve(child, dim) free[size_main] = free[size_main] - dims[c][size_main] else flex = flex + 1 end end -- flex local free_main = free[size_main] for c, child in ipairs(box) do if not dims[c] then -- alocate at least 1 cell free[size_main] = math.max(math.floor(free_main / flex), 1) flex = flex - 1 free_main = free_main - free[size_main] dims[c] = self:resolve(child, free) end end -- fix positions local offset = 0 for c, child in ipairs(box) do dims[c][pos_main] = offset local wins = self:get_wins(child, { layout = true }) for _, win in ipairs(wins) do win.opts[pos_main] = win.opts[pos_main] + offset end offset = offset + dims[c][size_main] end -- if we still have free space, shrink the root box -- if we have negative space, enlarge the root box if free_main ~= 0 and is_root then orig_dim[size_main] = orig_dim[size_main] - free_main end -- update box win if box_win then if not is_root then box_win.opts.win = self.root.win end box_win.opts.col = parent.col + orig_dim.col box_win.opts.row = parent.row + orig_dim.row box_win.opts.width = orig_dim.width box_win.opts.height = orig_dim.height end -- return outer dimensions orig_dim.width = orig_dim.width + border.left + border.right orig_dim.height = orig_dim.height + border.top + border.bottom return orig_dim end ---@param widget? snacks.layout.Widget ---@param opts? {layout: boolean} ---@package function M:get_wins(widget, opts) opts = opts or {} local ret = {} ---@type snacks.win[] self:each(function(w) if w.box and self.box_wins[w.id] then table.insert(ret, self.box_wins[w.id]) elseif w.win and self:is_enabled(w.win) then local win = self.wins[w.win] if not (opts.layout and win.layout == false) then table.insert(ret, self.wins[w.win]) end end end, { box = widget }) return ret end ---@param widget snacks.layout.Widget ---@param parent snacks.win.Dim ---@private function M:resolve(widget, parent) if widget.box then ---@cast widget snacks.layout.Box return self:update_box(widget, parent) else assert(widget.win, "widget must have win or box") ---@cast widget snacks.layout.Win return self:update_win(widget, parent) end end ---@param widget snacks.layout.Box ---@param parent snacks.win.Dim ---@private function M:dim_box(widget, parent) -- honor the actual window size for split layouts if not self.opts.fullscreen and widget.id == 1 and self.split and self.root:valid() then return { height = vim.api.nvim_win_get_height(self.root.win) - (vim.wo[self.root.win].winbar == "" and 0 or 1), width = vim.api.nvim_win_get_width(self.root.win), col = 0, row = 0, }, { left = 0, right = 0, top = 0, bottom = 0 } end local opts = vim.deepcopy(widget) --[[@as snacks.win.Config]] -- adjust max width / height opts.max_width = math.min(parent.width, opts.max_width or parent.width) opts.max_height = math.min(parent.height, opts.max_height or parent.height) local fake_win = setmetatable({ opts = opts }, Snacks.win) local ret = fake_win:dim(parent) return ret, fake_win:border_size() end ---@param win snacks.layout.Win ---@param parent snacks.win.Dim ---@private function M:update_win(win, parent) local w = self.wins[win.win] w.enabled = true assert(w, ("win %s not part of layout"):format(win.win)) -- add win opts from layout w.opts = Snacks.config.merge( vim.deepcopy(self.win_opts[win.win] or {}), { width = 0, height = 0, enter = false, }, win, { relative = "win", win = self.root.win, backdrop = false, resize = false, zindex = (self.opts.layout.zindex or 50) + win.depth + 1, w = { snacks_layout = true }, } ) -- fix fullscreen for splits if self.opts.fullscreen and self.split then w.opts.relative = "editor" w.opts.win = nil end -- adjust max width / height w.opts.max_width = math.max(math.min(parent.width, w.opts.max_width or parent.width), 1) w.opts.max_height = math.max(math.min(parent.height, w.opts.max_height or parent.height), 1) -- resolve width / height relative to parent box local dim = w:dim(parent) w.opts.width, w.opts.height = dim.width, dim.height local border = w:border_size() w.opts.col, w.opts.row = parent.col, parent.row dim.width = dim.width + border.left + border.right dim.height = dim.height + border.top + border.bottom -- dim.col = dim.col + border.left -- dim.row = dim.row + border.top return dim end --- Toggle fullscreen function M:maximize() self.opts.fullscreen = not self.opts.fullscreen self:update() end --- Close the layout ---@param opts? {wins?: boolean} function M:close(opts) if self.closed then return end opts = opts or {} self.closed = true for w, win in pairs(self.wins) do if opts.wins == false then win.opts = self.win_opts[w] else win:destroy() end end for _, win in pairs(self.box_wins) do win:destroy() end vim.schedule(function() if self.opts.on_close then self.opts.on_close(self) end self.opts = nil self.root = nil self.wins = nil self.box_wins = nil self.win_opts = nil end) end --- Check if layout is valid (visible) function M:valid() return not self.closed and self.root:valid() end --- Check if the window has been used in the layout ---@param w string function M:is_enabled(w) return not self:is_hidden(w) and (self.wins[w].enabled or self.wins[w].layout == false) end function M:hide() for _, win in ipairs(self:get_wins()) do if win:valid() then vim.api.nvim_win_set_config(win.win, { hide = true }) if win.backdrop and win.backdrop:valid() then vim.api.nvim_win_set_config(win.backdrop.win, { hide = true }) end end end end function M:unhide() for _, win in ipairs(self:get_wins()) do if win:valid() then vim.api.nvim_win_set_config(win.win, { hide = false }) if win.backdrop and win.backdrop:valid() then vim.api.nvim_win_set_config(win.backdrop.win, { hide = false }) end end end end --- Show the layout function M:show() if self:valid() then return end self:update() end return M ================================================ FILE: lua/snacks/lazygit.lua ================================================ ---@class snacks.lazygit ---@overload fun(opts?: snacks.lazygit.Config): snacks.win local M = setmetatable({}, { __call = function(t, ...) return t.open(...) end, }) M.meta = { desc = "Open LazyGit in a float, auto-configure colorscheme and integration with Neovim", } ---@alias snacks.lazygit.Color {fg?:string, bg?:string, bold?:boolean} ---@class snacks.lazygit.Theme: table ---@field activeBorderColor snacks.lazygit.Color ---@field cherryPickedCommitBgColor snacks.lazygit.Color ---@field cherryPickedCommitFgColor snacks.lazygit.Color ---@field defaultFgColor snacks.lazygit.Color ---@field inactiveBorderColor snacks.lazygit.Color ---@field optionsTextColor snacks.lazygit.Color ---@field searchingActiveBorderColor snacks.lazygit.Color ---@field selectedLineBgColor snacks.lazygit.Color ---@field unstagedChangesColor snacks.lazygit.Color ---@class snacks.lazygit.Config: snacks.terminal.Opts ---@field args? string[] ---@field theme? snacks.lazygit.Theme local defaults = { -- automatically configure lazygit to use the current colorscheme -- and integrate edit with the current neovim instance configure = true, -- extra configuration for lazygit that will be merged with the default -- snacks does NOT have a full yaml parser, so if you need `"test"` to appear with the quotes -- you need to double quote it: `"\"test\""` config = { os = { editPreset = "nvim-remote" }, gui = { -- set to an empty string "" to disable icons nerdFontsVersion = "3", }, }, theme_path = svim.fs.normalize(vim.fn.stdpath("cache") .. "/lazygit-theme.yml"), -- Theme for lazygit -- stylua: ignore theme = { [241] = { fg = "Special" }, activeBorderColor = { fg = "MatchParen", bold = true }, cherryPickedCommitBgColor = { fg = "Identifier" }, cherryPickedCommitFgColor = { fg = "Function" }, defaultFgColor = { fg = "Normal" }, inactiveBorderColor = { fg = "FloatBorder" }, optionsTextColor = { fg = "Function" }, searchingActiveBorderColor = { fg = "MatchParen", bold = true }, selectedLineBgColor = { bg = "Visual" }, -- set to `default` to have no background colour unstagedChangesColor = { fg = "DiagnosticError" }, }, win = { style = "lazygit", }, } Snacks.config.style("lazygit", {}) -- re-create config file on startup local dirty = true local config_dir ---@type string? -- re-create theme file on ColorScheme change vim.api.nvim_create_autocmd("ColorScheme", { callback = function() dirty = true end, }) ---@param opts snacks.lazygit.Config local function env(opts) if not config_dir then local out = vim.fn.system({ "lazygit", "-cd" }) local lines = vim.split(out, "\n", { plain = true }) if vim.v.shell_error == 0 and #lines > 1 then config_dir = vim.split(lines[1], "\n", { plain = true })[1] ---@type string[] local config_files = vim.tbl_filter(function(v) return v:match("%S") end, vim.split(vim.env.LG_CONFIG_FILE or "", ",", { plain = true })) -- add the default config file if it exists and is not already there if #config_files == 0 then local default_config = svim.fs.normalize(config_dir .. "/config.yml") if vim.loop.fs_stat(default_config) then config_files[1] = default_config end end -- add the theme file if it's not already there if not vim.tbl_contains(config_files, opts.theme_path) then table.insert(config_files, opts.theme_path) end vim.env.LG_CONFIG_FILE = table.concat(config_files, ",") else local msg = { "Failed to get **lazygit** config directory.", "Will not apply **lazygit** config.", "", "# Error:", vim.trim(out), } Snacks.notify.error(msg, { title = "lazygit" }) end end end ---@param v snacks.lazygit.Color ---@return string[] local function get_color(v) ---@type string[] local color = {} for _, c in ipairs({ "fg", "bg" }) do if v[c] then local name = v[c] local hl = vim.api.nvim_get_hl(0, { name = name, link = false }) local hl_color ---@type number? if c == "fg" then hl_color = hl and hl.fg or hl.foreground else hl_color = hl and hl.bg or hl.background end if hl_color then table.insert(color, string.format("#%06x", hl_color)) end end end if v.bold then table.insert(color, "bold") end return color end ---@param opts snacks.lazygit.Config local function update_config(opts) ---@type table local theme = {} for k, v in pairs(opts.theme) do if type(k) == "number" then local color = get_color(v) -- LazyGit uses color 241 a lot, so also set it to a nice color -- pcall, since some terminals don't like this pcall(io.write, ("\27]4;%d;%s\7"):format(k, color[1])) else theme[k] = get_color(v) end end local config = vim.tbl_deep_extend("force", { gui = { theme = theme } }, opts.config or {}) local function yaml_val(val) if type(val) == "boolean" then return tostring(val) end return type(val) == "string" and not val:find("^\"'`") and ("%q"):format(val) or val end local function to_yaml(tbl, indent) indent = indent or 0 local lines = {} for k, v in pairs(tbl) do table.insert(lines, string.rep(" ", indent) .. k .. (type(v) == "table" and ":" or ": " .. yaml_val(v))) if type(v) == "table" then if (vim.islist or vim.tbl_islist)(v) then for _, item in ipairs(v) do table.insert(lines, string.rep(" ", indent + 2) .. "- " .. yaml_val(item)) end else vim.list_extend(lines, to_yaml(v, indent + 2)) end end end return lines end vim.fn.writefile(to_yaml(config), opts.theme_path) dirty = false end -- Opens lazygit, properly configured to use the current colorscheme -- and integrate with the current neovim instance ---@param opts? snacks.lazygit.Config function M.open(opts) ---@type snacks.lazygit.Config opts = Snacks.config.get("lazygit", defaults, opts) local cmd = { "lazygit" } vim.list_extend(cmd, opts.args or {}) if opts.configure then if dirty then update_config(opts) end env(opts) end return Snacks.terminal(cmd, opts) end -- Opens lazygit with the log view ---@param opts? snacks.lazygit.Config function M.log(opts) opts = opts or {} opts.args = opts.args or { "log" } return M.open(opts) end -- Opens lazygit with the log of the current file ---@param opts? snacks.lazygit.Config|{} function M.log_file(opts) local file = vim.trim(vim.api.nvim_buf_get_name(0)) opts = opts or {} opts.args = vim.list_extend(opts.args or {}, { "-f", file }) opts.cwd = vim.fn.fnamemodify(file, ":h") return M.open(opts) end ---@private function M.health() local ok = vim.fn.executable("lazygit") == 1 Snacks.health[ok and "ok" or "error"](("{lazygit} %sinstalled"):format(ok and "" or "not ")) end return M ================================================ FILE: lua/snacks/meta/docs.lua ================================================ local M = {} M.meta = { desc = "Doc-gen for Snacks", hide = true, } local query = vim.treesitter.query.parse( "lua", [[ ;; top-level locals ((variable_declaration ( assignment_statement (variable_list name: (identifier) @local_name) (expression_list value: (_) @local_value) (#match? @local_value "(setmetatable|\\{)") )) @local (#any-of? @local_name "M" "defaults" "config") (#has-parent? @local chunk)) ;; top-level functions/methods (function_declaration name: (_) @fun_name (#match? @fun_name "^M") parameters: (_) @fun_params ) @fun ;; styles (function_call name: (dot_index_expression) @_sf (#eq? @_sf "Snacks.config.style") arguments: (arguments (string content: (string_content) @style_name) (table_constructor) @style_config) ) @style ;; examples (assignment_statement (variable_list name: (dot_index_expression field: (identifier) @example_name) @_en (#lua-match? @_en "^M%.examples%.%w+")) (expression_list value: (table_constructor) @example_config) ) @example ;; props (assignment_statement (variable_list name: (dot_index_expression field: (identifier) @prop_name) @_pn (#lua-match? @_pn "^M%.")) (expression_list value: (_) @prop_value) ) @prop ]] ) ---@class snacks.docs.Capture ---@field name string ---@field line number ---@field node TSNode ---@field text string ---@field comment string ---@field fields table ---@class snacks.docs.Parse ---@field captures snacks.docs.Capture[] ---@field comments string[] ---@class snacks.docs.Method ---@field mod string ---@field name string ---@field args string ---@field comment? string ---@field types? string ---@field type "method"|"function"}[] ---@class snacks.docs.Info ---@field config? string ---@field mod? string ---@field modname? string ---@field methods snacks.docs.Method[] ---@field types string[] ---@field setup? string ---@field examples table ---@field styles {name:string, opts:string, comment?:string}[] ---@field props table ---@param lines string[] function M.parse(lines) local source = table.concat(lines, "\n") local parser = vim.treesitter.get_string_parser(source, "lua") parser:parse() local comments = {} ---@type string[] for l, line in ipairs(lines) do if line:find("^%-%-") then comments[l] = line if comments[l - 1] then comments[l] = comments[l - 1] .. "\n" .. comments[l] comments[l - 1] = nil end end end ---@type snacks.docs.Parse local ret = { captures = {}, comments = {} } local used_comments = {} ---@type table for id, node in query:iter_captures(parser:trees()[1]:root(), source) do local name = query.captures[id] if not name:find("_") then -- add fields local fields = {} for id2, node2 in query:iter_captures(node, source) do local c = query.captures[id2] if c:find(name .. "_") then fields[c:gsub("^.*_", "")] = vim.treesitter.get_node_text(node2, source) end end -- add comments local comment = "" ---@type string if comments[node:start()] then comment = comments[node:start()] used_comments[node:start()] = true end if not comment:find("@deprecated") then table.insert(ret.captures, { text = vim.treesitter.get_node_text(node, source), name = name, comment = comment, line = node:start() + 1, node = node, fields = fields, }) end end end for l in pairs(used_comments) do comments[l] = nil end -- remove comments that are followed by code for l in pairs(comments) do if lines[l + 1] and lines[l + 1]:find("^.+$") then comments[l] = nil end end for l in ipairs(lines) do if comments[l] then table.insert(ret.comments, comments[l]) end end return ret end ---@param lines string[] ---@param opts {prefix: string, name:string} function M.extract(lines, opts) local fqn = opts.prefix .. "." .. opts.name local parse = M.parse(lines) ---@type snacks.docs.Info local ret = { methods = {}, types = vim.tbl_filter(function(c) return not c:find("@private") end, parse.comments), styles = {}, examples = {}, props = {}, } for _, c in ipairs(parse.captures) do if c.comment:find("@private") or c.comment:find("@protected") or c.comment:find("@package") or c.comment:find("@hide") then -- skip private elseif c.name == "local" then if vim.tbl_contains({ "defaults", "config" }, c.fields.name) then ret.config = vim.trim(c.comment .. "\n" .. c.fields.value) elseif c.fields.name == "M" then ret.mod = c.comment end elseif c.name == "prop" then local name = c.fields.name:sub(1) local value = c.fields.value ret.props[name] = c.comment == "" and value or c.comment .. "\n" .. value elseif c.name == "fun" then local name = c.fields.name:sub(2) local args = (c.fields.params or ""):sub(2, -2) local type = name:sub(1, 1) name = name:sub(2) if not name:find("^_") then table.insert(ret.methods, { mod = type == ":" and opts.name or fqn, name = name, args = args, comment = c.comment, type = type, }) end elseif c.name == "style" then table.insert(ret.styles, { name = c.fields.name, opts = c.fields.config, comment = c.comment }) elseif c.name == "example" then ret.examples[c.fields.name] = c.comment .. "\n" .. c.fields.config end end if ret.mod then local mod_lines = vim.split(ret.mod, "\n") mod_lines = vim.tbl_filter(function(line) local overload = line:match("^%-%-%-%s*@overload (.*)(%s*)$") --[[@as string?]] if overload then table.insert(ret.methods, { mod = fqn, name = "", args = "", type = "", comment = "---@type " .. overload, }) return false elseif line:find("^%s*$") then return false end return true end, mod_lines) ret.mod = table.concat(mod_lines, "\n") end return ret end ---@param tag string ---@param readme string ---@param content string function M.replace(tag, readme, content) content = vim.trim(content) local pattern = "(<%!%-%- " .. tag .. ":start %-%->).*(<%!%-%- " .. tag .. ":end %-%->)" if not readme:find(pattern) then error("tag " .. tag .. " not found") end return readme:gsub(pattern, "%1\n\n" .. content .. "\n\n%2") end ---@param str string ---@param opts? {extract_comment: boolean} -- default true function M.md(str, opts) str = str or "" str = str:gsub("\r", "") opts = opts or {} if opts.extract_comment == nil then opts.extract_comment = true end str = str:gsub("\n%s*%-%-%s*stylua: ignore\n", "\n") str = str:gsub("\n%s*debug = false,\n", "\n") str = str:gsub("\n%s*debug = true,\n", "\n") local comments = {} ---@type string[] local lines = vim.split(str, "\n", { plain = true }) if opts.extract_comment then while lines[1] and lines[1]:find("^%-%-") and not lines[1]:find("^%-%-%-%s*@") do local line = table.remove(lines, 1):gsub("^[%-]*%s*", "") table.insert(comments, line) end end local ret = {} ---@type string[] if #comments > 0 then table.insert(ret, vim.trim(table.concat(comments, "\n"))) table.insert(ret, "") end if #lines > 0 then table.insert(ret, "```lua") table.insert(ret, vim.trim(table.concat(lines, "\n"))) table.insert(ret, "```") end return vim.trim(table.concat(ret, "\n")) .. "\n" end function M.examples(name) local fname = ("docs/examples/%s.lua"):format(name) if not vim.uv.fs_stat(fname) then return {} end local lines = vim.fn.readfile(fname) local info = M.extract(lines, { prefix = "Snacks.examples", name = name }) return info.examples end ---@param name string ---@param info snacks.docs.Info ---@param opts? {setup?:boolean, config?:boolean, styles?:boolean, types?:boolean, prefix?:string, examples?:boolean} function M.render(name, info, opts) opts = opts or {} local lines = {} ---@type string[] local function add(line) table.insert(lines, line) end local prefix = ("Snacks.%s"):format(name) if name == "init" then prefix = "Snacks" end if info.modname then prefix = "local M" end if name ~= "init" and (info.config or info.setup) and opts.setup ~= false then add("## 📦 Setup\n") add(([[ ```lua -- lazy.nvim { "folke/snacks.nvim", ---@type snacks.Config opts = { %s = { -- your %s configuration comes here -- or leave it empty to use the default settings -- refer to the configuration section below } } } ``` ]]):format(info.setup or name, name)) end if info.config and opts.config ~= false then add("## ⚙️ Config\n") add(M.md(info.config)) end if opts.examples ~= false then local examples = M.examples(name) local names = vim.tbl_keys(examples) table.sort(names) if not vim.tbl_isempty(examples) then add("## 🚀 Examples\n") for _, n in ipairs(names) do local example = examples[n] add(("### `%s`\n"):format(n)) add(M.md(example)) end end end if #info.styles > 0 and opts.styles ~= false then table.sort(info.styles, function(a, b) return a.name < b.name end) add("## 🎨 Styles\n") if name == "styles" then add([[These are the default styles that Snacks provides. You can customize them by adding your own styles to `opts.styles`. ]]) else add([[Check the [styles](https://github.com/folke/snacks.nvim/blob/main/docs/styles.md) docs for more information on how to customize these styles ]]) end for _, style in pairs(info.styles) do add(("### `%s`\n"):format(style.name)) if style.comment and style.comment ~= "" then add(M.md(style.comment)) end add(M.md(style.opts)) end end if #info.types > 0 and opts.types ~= false then add("## 📚 Types\n") for _, t in ipairs(info.types) do add(M.md(t)) end end local mod_lines = info.mod and not info.mod:find("^%s*$") and vim.split(info.mod, "\n") or {} local hide = #mod_lines == 0 or (#mod_lines == 1 and mod_lines[1]:find("@class")) if not hide or #info.methods > 0 then local title = info.modname and ("`%s`"):format(info.modname) or "Module" add(("## 📦 %s\n"):format(title)) end if info.mod and not hide then table.insert(mod_lines, prefix .. " = {}") add(M.md(table.concat(mod_lines, "\n"))) end table.sort(info.methods, function(a, b) if a.mod ~= b.mod then return a.mod < b.mod end if a.type == b.type then return a.name < b.name end return a.type < b.type end) local last ---@type string? for _, method in ipairs(info.methods) do local title = ("### `%s%s%s()`\n"):format(method.mod, method.type, method.name) if title ~= last then last = title add(title) end local code = ("%s\n%s%s%s(%s)"):format(method.comment or "", method.mod, method.type, method.name, method.args) add(M.md(code)) end lines = vim.split(vim.trim(table.concat(lines, "\n")), "\n") return lines end function M.write(name, lines) local path = ("docs/%s.md"):format(name) local ok, text = pcall(vim.fn.readfile, path) local docgen = "" local top = {} ---@type string[] if not ok then table.insert(top, "# 🍿 " .. name) table.insert(top, "") else for _, line in ipairs(text) do if line == docgen then break end table.insert(top, line) end end table.insert(top, docgen) table.insert(top, "") vim.list_extend(top, lines) vim.fn.writefile(vim.split(table.concat(top, "\n"), "\n"), path) end ---@param ret string[] function M.picker(ret) local lines = vim.fn.readfile("lua/snacks/picker/config/sources.lua") local info = M.extract(lines, { prefix = "Snacks.picker", name = "sources" }) local sources = vim.tbl_keys(info.props) table.sort(sources) local source_types = {} ---@type table table.insert(ret, "## 🔍 Sources\n") for _, source in ipairs(sources) do local opts = info.props[source] local opts_lines = vim.split(opts, "\n") for _, l in ipairs(opts_lines) do local t = l:match("^---@type (.*)$") t = t or l:match("^---@class (.*)$") if t then t = vim.trim(t:gsub(":.*", "")) source_types[source] = t break end end table.insert(ret, ("### `%s`"):format(source)) table.insert(ret, "") table.insert(ret, ("```vim\n:lua Snacks.picker.%s(opts?)\n```\n"):format(source)) table.insert(ret, M.md(opts)) end M.picker_types(source_types) lines = vim.fn.readfile("lua/snacks/picker/config/layouts.lua") info = M.extract(lines, { prefix = "Snacks.picker", name = "layouts" }) sources = vim.tbl_keys(info.props) table.sort(sources) table.insert(ret, "## 🖼️ Layouts\n") for _, source in ipairs(sources) do local opts = info.props[source] table.insert(ret, ("### `%s`"):format(source)) table.insert(ret, "") table.insert(ret, M.md(opts)) end end function M._build() local plugins = Snacks.meta.get() ---@class snacks.docs.Types local types = { fields = {}, ---@type string[] config = {}, ---@type string[] } ---@type snacks.docs.Info local styles = { methods = {}, types = {}, examples = {}, styles = {}, setup = "---@type table\n styles", props = {}, } for _, plugin in pairs(plugins) do if plugin.meta.docs then local name = plugin.name print("[gen] " .. name .. ".md") local lines = vim.fn.readfile(plugin.file) local info = M.extract(lines, { prefix = "Snacks", name = name }) local children = {} ---@type snacks.docs.Info[] local to_merge = {} ---@type {child:string, name:string}[] for c, child in pairs(plugin.meta.merge or {}) do local child_name = type(c) == "number" and child or c --[[@as string]] table.insert(to_merge, { child = child, name = child_name }) end table.sort(to_merge, function(a, b) return a.child < b.child end) for _, item in ipairs(to_merge) do local child = item.child local child_name = item.name local child_file = ("%s/%s/%s"):format(Snacks.meta.root, name, child:gsub("%.", "/")) for _, f in ipairs({ ".lua", "/init.lua" }) do if vim.uv.fs_stat(child_file .. f) then child_file = child_file .. f break end end assert(vim.uv.fs_stat(child_file), ("file not found: %s"):format(child_file)) local child_lines = vim.fn.readfile(child_file) local child_info = M.extract(child_lines, { prefix = "Snacks." .. name, name = child_name }) child_info.modname = "snacks." .. name .. "." .. child if child_info.config then assert(not info.config, "config already exists") info.config = child_info.config end vim.list_extend(info.types, child_info.types) table.insert(children, child_info) end vim.list_extend(styles.styles, info.styles) info.config = name ~= "init" and info.config or nil plugin.meta.config = info.config ~= nil local rendered = {} ---@type string[] vim.list_extend(rendered, M.render(name, info)) if name == "picker" then M.picker(rendered) end for _, child in ipairs(children) do table.insert(rendered, "") vim.list_extend( rendered, M.render(name, child, { setup = false, config = false, styles = false, types = false, examples = false, }) ) end M.write(name, rendered) if plugin.meta.types then table.insert(types.fields, ("---@field %s snacks.%s"):format(plugin.name, plugin.name)) end if plugin.meta.config then table.insert(types.config, ("---@field %s? snacks.%s.Config"):format(plugin.name, plugin.name)) end end end M.write("styles", M.render("styles", styles)) M.readme(plugins, types) M.types(types) vim.cmd.checktime() end ---@param types snacks.docs.Types function M.types(types) local lines = {} ---@type string[] lines[#lines + 1] = "---@meta _" lines[#lines + 1] = "" lines[#lines + 1] = "---@class snacks.plugins" vim.list_extend(lines, types.fields) lines[#lines + 1] = "" lines[#lines + 1] = "---@class snacks.plugins.Config" vim.list_extend( lines, vim.tbl_map(function(field) -- make all fields optional return field .. "|{}" end, types.config) ) vim.fn.writefile(lines, "lua/snacks/meta/types.lua") end ---@param types table function M.picker_types(types) local opts = Snacks.picker.config.get() --[[@as table]] local sources = vim.tbl_keys(opts.sources) ---@type string[] table.sort(sources) local lines = {} ---@type string[] lines[#lines + 1] = "---@meta _" lines[#lines + 1] = "" lines[#lines + 1] = "---@class snacks.picker" for _, source in ipairs(sources) do if source ~= "select" then local t = types[source] or "snacks.picker.Config" t = t:gsub("|.*", "") .. "|{}" if source == "resume" then lines[#lines + 1] = ("---@field %s fun(): snacks.Picker"):format(source) else lines[#lines + 1] = ("---@field %s fun(opts?: %s): snacks.Picker"):format(source, t) end end end vim.fn.writefile(lines, "lua/snacks/picker/types.lua") end ---@param plugins snacks.meta.Plugin[] ---@param types snacks.docs.Types function M.readme(plugins, types) local path = "lua/snacks/init.lua" local lines = vim.fn.readfile(path) --[[ @as string[] ]] local info = M.extract(lines, { prefix = "Snacks", name = "init" }) local readme = table.concat(vim.fn.readfile("README.md"), "\n") local example = table.concat(vim.fn.readfile("docs/examples/init.lua"), "\n") local e = M.examples("picker").general or "" local l = vim.split(e, "\n") table.remove(l) table.remove(l) local start = false l = vim.tbl_filter(function(line) if line:find("^%s*keys =") then start = true return false end return start end, l) l[1] = vim.trim(l[1]) e = table.concat(l, "\n") example = example:gsub("%-%- EXTRA_KEYS", e) -- config type lines = {} lines[1] = "---@class snacks.Config" vim.list_extend(lines, types.config) local config_lines = vim.split(info.config or "", "\n") table.remove(config_lines, 1) vim.list_extend(lines, config_lines) info.config = table.concat(lines, "\n") -- snacks type lines = {} lines[#lines + 1] = "---@class Snacks" vim.list_extend(lines, types.fields) info.mod = table.concat(lines, "\n") -- toc lines = {} lines[#lines + 1] = "| Snack | Description | Setup |" lines[#lines + 1] = "| ----- | ----------- | :---: |" for _, plugin in ipairs(plugins) do if plugin.meta.readme then lines[#lines + 1] = ("| %s | %s | %s |"):format( ("[%s](https://github.com/folke/snacks.nvim/blob/main/docs/%s.md)"):format(plugin.name, plugin.name), plugin.meta.desc, plugin.meta.needs_setup and "‼️" or "" ) end end M.write("init", M.render("init", info)) example = example:gsub(".*\nreturn {", "{", 1) readme = M.replace("config", readme, M.md(info.config)) readme = M.replace("example", readme, M.md(example)) readme = M.replace("toc", readme, table.concat(lines, "\n")) vim.fn.writefile(vim.split(readme, "\n"), "README.md") end function M.fix_titles() for file, t in vim.fs.dir("doc", { depth = 1 }) do if t == "file" and file:find("%.txt$") then local lines = vim.fn.readfile("doc/" .. file) --[[@as string[] ]] lines[1] = lines[1]:gsub("%.txt", ""):gsub("%.nvim", "") for i, line in ipairs(lines) do -- Example: SNACKS.GIT.BLAME_LINE() *snacks-git-module-snacks.git.blame_line()* local func = line:gsub("^SNACKS.*module%-snacks(.+%(%))%*$", "Snacks%1") if func ~= line then local left = ("`%s`"):format(func) local right = ("*%s*"):format(func) line = left .. string.rep(" ", #line - #left - #right) .. right lines[i] = line end end vim.fn.writefile(lines, "doc/" .. file) end end vim.cmd.helptags("doc") end function M.build() local ok, err = pcall(M._build) if not ok then vim.api.nvim_err_writeln(err) os.exit(1) end end return M ================================================ FILE: lua/snacks/meta/init.lua ================================================ ---@class snacks.meta local M = {} M.meta = { desc = "Meta functions for Snacks", readme = false, } ---@class snacks.meta.Meta ---@field desc string ---@field needs_setup? boolean ---@field hide? boolean ---@field readme? boolean ---@field docs? boolean ---@field health? boolean ---@field types? boolean ---@field config? boolean ---@field merge? { [string|number]: string } ---@class snacks.meta.Plugin ---@field name string ---@field file string ---@field meta snacks.meta.Meta ---@field health? fun() M.root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h") function M.file(name) return svim.fs.normalize(("%s/%s"):format(M.root, name)) end --- Get the metadata for all snacks plugins ---@return snacks.meta.Plugin[] function M.get() local ret = {} ---@type snacks.meta.Plugin[] for file, t in vim.fs.dir(M.root, { depth = 1 }) do if file:sub(1, 1) ~= "." then local name = vim.fn.fnamemodify(file, ":t:r") file = t == "directory" and ("%s/init.lua"):format(file) or file file = M.root .. "/" .. file local mod = name == "init" and setmetatable({ meta = { desc = "Snacks", hide = true } }, { __index = Snacks }) or Snacks[name] --[[@as snacks.meta.Plugin]] assert(type(mod) == "table", ("`Snacks.%s` not found"):format(name)) assert(type(mod.meta) == "table", ("`Snacks.%s.meta` not found"):format(name)) assert(type(mod.meta.desc) == "string", ("`Snacks.%s.meta.desc` not found"):format(name)) for _, prop in ipairs({ "readme", "docs", "health", "types" }) do if mod.meta[prop] == nil then mod.meta[prop] = not mod.meta.hide end end ret[#ret + 1] = setmetatable({ name = name, file = file, }, { __index = mod, __tostring = function(self) return "snacks." .. self.name end, }) end end table.sort(ret, function(a, b) return a.name < b.name end) return ret end return M ================================================ FILE: lua/snacks/meta/types.lua ================================================ ---@meta _ ---@class snacks.plugins ---@field animate snacks.animate ---@field bigfile snacks.bigfile ---@field bufdelete snacks.bufdelete ---@field dashboard snacks.dashboard ---@field debug snacks.debug ---@field dim snacks.dim ---@field explorer snacks.explorer ---@field gh snacks.gh ---@field git snacks.git ---@field gitbrowse snacks.gitbrowse ---@field health snacks.health ---@field image snacks.image ---@field indent snacks.indent ---@field input snacks.input ---@field keymap snacks.keymap ---@field layout snacks.layout ---@field lazygit snacks.lazygit ---@field meta snacks.meta ---@field notifier snacks.notifier ---@field notify snacks.notify ---@field picker snacks.picker ---@field profiler snacks.profiler ---@field quickfile snacks.quickfile ---@field rename snacks.rename ---@field scope snacks.scope ---@field scratch snacks.scratch ---@field scroll snacks.scroll ---@field statuscolumn snacks.statuscolumn ---@field terminal snacks.terminal ---@field toggle snacks.toggle ---@field util snacks.util ---@field win snacks.win ---@field words snacks.words ---@field zen snacks.zen ---@class snacks.plugins.Config ---@field animate? snacks.animate.Config|{} ---@field bigfile? snacks.bigfile.Config|{} ---@field dashboard? snacks.dashboard.Config|{} ---@field dim? snacks.dim.Config|{} ---@field explorer? snacks.explorer.Config|{} ---@field gh? snacks.gh.Config|{} ---@field gitbrowse? snacks.gitbrowse.Config|{} ---@field image? snacks.image.Config|{} ---@field indent? snacks.indent.Config|{} ---@field input? snacks.input.Config|{} ---@field layout? snacks.layout.Config|{} ---@field lazygit? snacks.lazygit.Config|{} ---@field notifier? snacks.notifier.Config|{} ---@field picker? snacks.picker.Config|{} ---@field profiler? snacks.profiler.Config|{} ---@field quickfile? snacks.quickfile.Config|{} ---@field scope? snacks.scope.Config|{} ---@field scratch? snacks.scratch.Config|{} ---@field scroll? snacks.scroll.Config|{} ---@field statuscolumn? snacks.statuscolumn.Config|{} ---@field terminal? snacks.terminal.Config|{} ---@field toggle? snacks.toggle.Config|{} ---@field win? snacks.win.Config|{} ---@field words? snacks.words.Config|{} ---@field zen? snacks.zen.Config|{} ================================================ FILE: lua/snacks/notifier.lua ================================================ ---@class snacks.notifier ---@overload fun(msg: string, level?: snacks.notifier.level|number, opts?: snacks.notifier.Notif.opts): number|string local M = setmetatable({}, { __call = function(t, ...) return t.notify(...) end, }) M.meta = { desc = "Pretty `vim.notify`", needs_setup = true, } local uv = vim.uv or vim.loop --- Render styles: --- * compact: use border for icon and title --- * minimal: no border, only icon and message --- * fancy: similar to the default nvim-notify style ---@alias snacks.notifier.style snacks.notifier.render|"compact"|"fancy"|"minimal" --- ### Notifications --- --- Notification options ---@class snacks.notifier.Notif.opts ---@field id? number|string ---@field msg? string ---@field level? number|snacks.notifier.level ---@field title? string ---@field icon? string ---@field timeout? number|boolean timeout in ms. Set to 0|false to keep until manually closed ---@field ft? string ---@field keep? fun(notif: snacks.notifier.Notif): boolean ---@field style? snacks.notifier.style ---@field opts? fun(notif: snacks.notifier.Notif) -- dynamic opts ---@field hl? snacks.notifier.hl -- highlight overrides ---@field history? boolean --- Notification object ---@class snacks.notifier.Notif: snacks.notifier.Notif.opts ---@field id number|string ---@field msg string ---@field win? snacks.win ---@field icon string ---@field level snacks.notifier.level ---@field timeout number ---@field dirty? boolean ---@field added number timestamp with nano precision ---@field updated number timestamp with nano precision ---@field shown? number timestamp with nano precision ---@field hidden? number timestamp with nano precision ---@field layout? { top?: number, width: number, height: number } --- ### Rendering ---@alias snacks.notifier.render fun(buf: number, notif: snacks.notifier.Notif, ctx: snacks.notifier.ctx) ---@class snacks.notifier.hl ---@field title string ---@field icon string ---@field border string ---@field footer string ---@field msg string ---@class snacks.notifier.ctx ---@field opts snacks.win.Config ---@field notifier snacks.notifier.Class ---@field hl snacks.notifier.hl ---@field ns number --- ### History ---@class snacks.notifier.history ---@field filter? vim.log.levels|snacks.notifier.level|fun(notif: snacks.notifier.Notif): boolean ---@field sort? string[] # sort fields, default: {"added"} ---@field reverse? boolean ---@type snacks.notifier.history local history_opts = { sort = { "added" }, } Snacks.config.style("notification", { border = true, zindex = 100, ft = "markdown", wo = { winblend = 5, wrap = false, conceallevel = 2, colorcolumn = "", }, bo = { filetype = "snacks_notif" }, }) Snacks.config.style("notification_history", { border = true, zindex = 100, width = 0.6, height = 0.6, minimal = false, title = " Notification History ", title_pos = "center", ft = "markdown", bo = { filetype = "snacks_notif_history", modifiable = false }, wo = { winhighlight = "Normal:SnacksNotifierHistory" }, keys = { q = "close" }, }) ---@class snacks.notifier.Config ---@field enabled? boolean ---@field keep? fun(notif: snacks.notifier.Notif): boolean # global keep function ---@field filter? fun(notif: snacks.notifier.Notif): boolean # filter our unwanted notifications (return false to hide) local defaults = { timeout = 3000, -- default timeout in ms width = { min = 40, max = 0.4 }, height = { min = 1, max = 0.6 }, -- editor margin to keep free. tabline and statusline are taken into account automatically margin = { top = 0, right = 1, bottom = 0 }, padding = true, -- add 1 cell of left/right padding to the notification window gap = 0, -- gap between notifications sort = { "level", "added" }, -- sort by level and time -- minimum log level to display. TRACE is the lowest -- all notifications are stored in history level = vim.log.levels.TRACE, icons = { error = " ", warn = " ", info = " ", debug = " ", trace = " ", }, keep = function(notif) return vim.fn.getcmdpos() > 0 end, ---@type snacks.notifier.style style = "compact", top_down = true, -- place notifications from top to bottom date_format = "%R", -- time format for notifications -- format for footer when more lines are available -- `%d` is replaced with the number of lines. -- only works for styles with a border ---@type string|boolean more_format = " ↓ %d lines ", refresh = 50, -- refresh at most every 50ms } ---@class snacks.notifier.Class ---@field queue table ---@field history table ---@field sorted? snacks.notifier.Notif[] ---@field opts snacks.notifier.Config local N = {} N.ns = vim.api.nvim_create_namespace("snacks.notifier") ---@param str string local function cap(str) return str:sub(1, 1):upper() .. str:sub(2):lower() end ---@param name string ---@param level? snacks.notifier.level local function hl(name, level) return "SnacksNotifier" .. name .. (level and cap(level) or "") end ---@type table N.styles = { -- style using border title compact = function(buf, notif, ctx) local title = vim.trim(notif.icon .. " " .. (notif.title or "")) if title ~= "" then ctx.opts.title = { { " " .. title .. " ", ctx.hl.title } } ctx.opts.title_pos = "center" end vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(notif.msg, "\n")) end, minimal = function(buf, notif, ctx) ctx.opts.border = "none" local whl = ctx.opts.wo.winhighlight ctx.opts.wo.winhighlight = whl:gsub(ctx.hl.msg, "SnacksNotifierMinimal") vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(notif.msg, "\n")) vim.api.nvim_buf_set_extmark(buf, ctx.ns, 0, 0, { virt_text = { { notif.icon, ctx.hl.icon } }, virt_text_pos = "right_align", }) end, history = function(buf, notif, ctx) local lines = vim.split(notif.msg, "\n", { plain = true }) local prefix = { { os.date(ctx.notifier.opts.date_format, notif.added), hl("HistoryDateTime") }, { notif.icon, ctx.hl.icon }, { notif.level:upper(), ctx.hl.title }, { notif.title, hl("HistoryTitle") }, } prefix = vim.tbl_filter(function(v) return (v[1] or "") ~= "" end, prefix) local prefix_width = 0 for i = 1, #prefix do prefix_width = prefix_width + vim.fn.strdisplaywidth(prefix[i * 2 - 1][1]) + 1 table.insert(prefix, i * 2, { " " }) end local top = vim.api.nvim_buf_line_count(buf) local empty = top == 1 and #vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] == 0 top = empty and 0 or top lines[1] = string.rep(" ", prefix_width) .. (lines[1] or "") vim.api.nvim_buf_set_lines(buf, top, -1, false, lines) vim.api.nvim_buf_set_extmark(buf, ctx.ns, top, 0, { virt_text = prefix, virt_text_pos = "overlay", priority = 10, }) end, -- similar to the default nvim-notify style fancy = function(buf, notif, ctx) vim.api.nvim_buf_set_lines(buf, 0, 1, false, { "", "" }) vim.api.nvim_buf_set_lines(buf, 2, -1, false, vim.split(notif.msg, "\n")) vim.api.nvim_buf_set_extmark(buf, ctx.ns, 0, 0, { virt_text = { { " " }, { notif.icon, ctx.hl.icon }, { " " }, { notif.title or "", ctx.hl.title } }, virt_text_win_col = 0, priority = 10, }) vim.api.nvim_buf_set_extmark(buf, ctx.ns, 0, 0, { virt_text = { { " " }, { os.date(ctx.notifier.opts.date_format, notif.added), ctx.hl.title }, { " " } }, virt_text_pos = "right_align", priority = 10, }) vim.api.nvim_buf_set_extmark(buf, ctx.ns, 1, 0, { virt_text = { { string.rep("━", vim.o.columns - 2), ctx.hl.border } }, virt_text_win_col = 0, priority = 10, }) end, } ---@alias snacks.notifier.level "trace"|"debug"|"info"|"warn"|"error" ---@type table N.levels = { [vim.log.levels.TRACE] = "trace", [vim.log.levels.DEBUG] = "debug", [vim.log.levels.INFO] = "info", [vim.log.levels.WARN] = "warn", [vim.log.levels.ERROR] = "error", } N.level_names = vim.tbl_values(N.levels) ---@type snacks.notifier.level[] local MAX_SKIPPED = 10 ---@param level number|string ---@return snacks.notifier.level local function normlevel(level) return type(level) == "string" and (vim.tbl_contains(N.level_names, level:lower()) and level:lower() or "info") or N.levels[level] or "info" end ---@param level number|string ---@return integer local function numlevel(level) return type(level) == "number" and level or vim.log.levels[normlevel(level):upper()] or 0 end local function ts() if uv.clock_gettime then local ret = assert(uv.clock_gettime("realtime")) return ret.sec + ret.nsec / 1e9 end local sec, usec = uv.gettimeofday() return sec + usec / 1e6 end local _id = 0 local function next_id() _id = _id + 1 return _id end ---@param opts? snacks.notifier.Config ---@return snacks.notifier.Class function N.new(opts) local self = setmetatable({}, { __index = N }) self.opts = Snacks.config.get("notifier", defaults, opts) self.queue = {} self.history = {} self:init() self:start() return self end function N:init() local links = { [hl("History")] = "Normal", [hl("HistoryTitle")] = "Title", [hl("HistoryDateTime")] = "Special", SnacksNotifierMinimal = "NormalFloat", } for _, level in ipairs(N.level_names) do local Level = cap(level) local link = vim.tbl_contains({ "Trace", "Debug" }, Level) and "NonText" or nil links[hl("", level)] = "Normal" links[hl("Icon", level)] = link or ("DiagnosticSign" .. Level) links[hl("Border", level)] = link or ("Diagnostic" .. Level) links[hl("Title", level)] = link or ("Diagnostic" .. Level) links[hl("Footer", level)] = link or ("Diagnostic" .. Level) end Snacks.util.set_hl(links, { default = true }) -- resize handler vim.api.nvim_create_autocmd("VimResized", { group = vim.api.nvim_create_augroup("snacks_notifier", {}), callback = function() for _, notif in pairs(self.queue) do notif.dirty = true end self.sorted = nil end, }) end function N:start() local running = false uv.new_timer():start(self.opts.refresh, self.opts.refresh, function() if running or not next(self.queue) then return end running = true vim.schedule(function() if self.in_search() then running = false return end xpcall(function() self:process() end, function(err) if err:find("E565") then return end local trace = debug.traceback(2) vim.schedule(function() vim.api.nvim_err_writeln( ("Snacks notifier failed. Dropping queue. Error:\n%s\n\nTrace:\n%s"):format(err, trace) ) end) self.queue = {} end) running = false end) end) end function N:process() self:update() self:layout() end function N:is_blocking() local mode = vim.api.nvim_get_mode() for _, m in ipairs({ "ic", "ix", "c", "no", "r%?", "rm" }) do if mode.mode:find(m) == 1 then return true end end return mode.blocking end local health_msg = false ---@param opts snacks.notifier.Notif.opts function N:add(opts) if opts.checkhealth then health_msg = true return end local now = ts() local notif = vim.deepcopy(opts) --[[@as snacks.notifier.Notif]] notif.msg = notif.msg or "" -- NOTE: support nvim-notify style replace ---@diagnostic disable-next-line: undefined-field if not notif.id and notif.replace then ---@diagnostic disable-next-line: undefined-field notif.id = type(notif.replace) == "table" and notif.replace.id or notif.replace end notif.title = (notif.title or ""):gsub("\n", " ") notif.id = notif.id or next_id() notif.level = normlevel(notif.level) notif.icon = notif.icon or self.opts.icons[notif.level] notif.timeout = notif.timeout == false and 0 or notif.timeout notif.timeout = notif.timeout == true and self.opts.timeout or notif.timeout notif.timeout = notif.timeout or self.opts.timeout notif.added = now if opts.id and self.queue[opts.id] then local n = self.queue[opts.id] --[[@as snacks.notifier.Notif]] notif.added = n.added notif.updated = now notif.shown = n.shown and now or nil -- reset shown time notif.win = n.win notif.layout = n.layout notif.dirty = true end if opts.history ~= false then self.history[notif.id] = notif end self.sorted = nil local want = numlevel(notif.level) >= numlevel(self.opts.level) want = want and (not self.opts.filter or self.opts.filter(notif)) if not want then return notif.id end self.queue[notif.id] = notif if self:is_blocking() then pcall(function() self:process() end) end return notif.id end function N:update() local now = ts() --- Cleanup queue for id, notif in pairs(self.queue) do local timeout = notif.timeout or self.opts.timeout local keep = not notif.shown -- not shown yet or timeout == 0 -- no timeout or (notif.win and notif.win:win_valid() and vim.api.nvim_get_current_win() == notif.win.win) -- current window or (notif.win and notif.win:buf_valid() and vim.api.nvim_get_current_buf() == notif.win.buf) -- current buffer or (notif.keep and notif.keep(notif)) -- custom keep or (self.opts.keep and self.opts.keep(notif)) -- global keep or (notif.shown + timeout / 1e3 > now) -- not timed out if not keep then self:hide(id) end end self.sorted = self.sorted or self:sort() end ---@param opts? snacks.notifier.history ---@return snacks.notifier.Notif[] function N:get_history(opts) ---@type snacks.notifier.history opts = vim.tbl_deep_extend("force", {}, history_opts, opts or {}) local notifs = vim.tbl_values(self.history) local filter = opts.filter if type(filter) == "string" or type(filter) == "number" then local level = numlevel(filter) filter = function(n) return numlevel(n.level) >= level end end notifs = filter and vim.tbl_filter(filter, notifs) or notifs local ret = self:sort(notifs, opts.sort) if opts.reverse then local rev = {} for i = #ret, 1, -1 do table.insert(rev, ret[i]) end ret = rev end return ret end ---@param opts? snacks.notifier.history function N:show_history(opts) if vim.bo.filetype == "snacks_notif_history" then vim.cmd("close") return end local win = Snacks.win({ style = "notification_history", enter = true, show = false }) local buf = win:open_buf() opts = opts or {} if opts.reverse == nil then opts.reverse = true end for _, notif in ipairs(self:get_history(opts)) do N.styles.history(buf, notif, { opts = win.opts, notifier = self, ns = N.ns, hl = self:hl(notif), }) end return win:show() end ---@param id? number|string function N:hide(id) if not id then for i in pairs(self.queue) do self:hide(i) end return end local notif = self.queue[id] if not notif then return end self.queue[id], self.sorted = nil, nil notif.hidden = ts() if notif.win then notif.win:close() notif.win = nil end end ---@param value number ---@param min number ---@param max number ---@param parent number local function dim(value, min, max, parent) min = math.floor(min < 1 and (parent * min) or min) max = math.floor(max < 1 and (parent * max) or max) return math.min(max, math.max(min, value)) end ---@param style? snacks.notifier.style ---@return snacks.notifier.render function N:get_render(style) style = style or self.opts.style return type(style) == "function" and style or N.styles[style] or N.styles.compact end ---@param notif snacks.notifier.Notif function N:hl(notif) ---@type snacks.notifier.hl return vim.tbl_extend("force", { title = hl("Title", notif.level), icon = hl("Icon", notif.level), border = hl("Border", notif.level), footer = hl("Footer", notif.level), msg = hl("", notif.level), }, notif.hl or {}) end ---@param notif snacks.notifier.Notif function N:render(notif) if type(notif.opts) == "function" then notif.opts(notif) end ---@type snacks.notifier.hl local notif_hl = self:hl(notif) local win = notif.win or Snacks.win({ show = false, style = "notification", enter = false, backdrop = false, ft = notif.ft, noautocmd = true, keys = { q = function() self:hide(notif.id) end, }, }) win.opts.wo.winhighlight = table.concat({ "Normal:" .. notif_hl.msg, "NormalNC:" .. notif_hl.msg, "FloatBorder:" .. notif_hl.border, "FloatTitle:" .. notif_hl.title, "FloatFooter:" .. notif_hl.footer, }, ",") notif.win = win ---@diagnostic disable-next-line: invisible local buf = win:open_buf() vim.api.nvim_buf_clear_namespace(buf, N.ns, 0, -1) local render = self:get_render(notif.style) vim.bo[buf].modifiable = true vim.api.nvim_buf_set_lines(buf, 0, -1, false, {}) render(buf, notif, { opts = win.opts, notifier = self, ns = N.ns, hl = notif_hl, }) vim.bo[buf].modifiable = false local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) -- for the minimal style, we also have to factor in the icon width local icon_width = self.opts.style == "minimal" and vim.api.nvim_strwidth(notif.icon) or 0 local pad = (self.opts.padding and (win:add_padding() or 2) or 0) + icon_width local width = win:border_text_width() for _, line in ipairs(lines) do width = math.max(width, vim.fn.strdisplaywidth(line) + pad) end width = dim(width, self.opts.width.min, self.opts.width.max, vim.o.columns) local height = #lines -- calculate wrapped height if win.opts.wo.wrap then height = 0 for _, line in ipairs(lines) do height = height + math.ceil((vim.fn.strdisplaywidth(line) + pad) / width) end end local wanted_height = height height = dim(height, self.opts.height.min, self.opts.height.max, vim.o.lines) if wanted_height > height and win:has_border() and self.opts.more_format and not win.opts.footer then win.opts.footer = self.opts.more_format:format(wanted_height - height) win.opts.footer_pos = "right" end win.opts.width = width win.opts.height = height end ---@param notifs? snacks.notifier.Notif[] ---@param fields? string[] function N:sort(notifs, fields) fields = fields or self.opts.sort notifs = notifs or vim.tbl_values(self.queue) table.sort(notifs, function(a, b) for _, key in ipairs(fields) do local function v(n) if key == "level" then return 10 - numlevel(n[key]) end return n[key] end local av, bv = v(a), v(b) if av ~= bv then return av < bv end end return false end) return notifs end function N:new_layout() ---@class snacks.notifier.layout local layout = {} layout.free = 0 layout.rows = {} ---@type boolean[] ---@param row number ---@param height number ---@param free boolean function layout.mark(row, height, free) for i = row, math.min(row + height - 1, vim.o.lines) do layout.free = layout.free + (free and 1 or -1) layout.rows[i] = free end end ---@param height number ---@param row? number wanted row function layout.find(height, row) local from, to, down = row or 1, vim.o.lines - height, self.opts.top_down for i = down and from or to, down and to or from, down and 1 or -1 do local ret = true for j = i, i + height - 1 do if not layout.rows[j] then ret = false break end end if ret then return i end end end layout.mark(1, vim.o.lines, true) layout.mark(1, self.opts.margin.top + (vim.o.tabline == "" and 0 or 1), false) layout.mark(vim.o.lines - (self.opts.margin.bottom + (vim.o.laststatus == 0 and 0 or 1)) + 1, vim.o.lines, false) return layout end function N:layout() local layout = self:new_layout() local wins_updated = 0 local wins_created = 0 local wins_skipped = 0 local update = {} ---@type snacks.win[] for _, notif in ipairs(assert(self.sorted)) do if layout.free < (self.opts.height.min + 2) or wins_skipped > MAX_SKIPPED then -- not enough space if notif.win then notif.shown = nil notif.win:hide() end else local prev_layout = notif.layout and { top = notif.layout.top, height = notif.layout.height, width = notif.layout.width } if not notif.win or notif.dirty or not notif.win:buf_valid() or type(notif.opts) == "function" then notif.dirty = true self:render(notif) notif.dirty = false notif.layout = notif.win:size() notif.layout.top = prev_layout and prev_layout.top prev_layout = nil -- always re-render since opts might've changed end notif.layout.top = layout.find(notif.layout.height, notif.layout.top) if notif.layout.top then layout.mark(notif.layout.top, notif.layout.height + (self.opts.gap or 0), false) if not vim.deep_equal(prev_layout, notif.layout) then if notif.win:win_valid() then wins_updated = wins_updated + 1 else wins_created = wins_created + 1 end update[#update + 1] = notif.win notif.win.opts.row = notif.layout.top - 1 notif.win.opts.col = vim.o.columns - notif.layout.width - self.opts.margin.right notif.shown = notif.shown or ts() notif.win:show() end elseif notif.win then wins_skipped = wins_skipped + 1 notif.shown = nil notif.win:hide() end end end if #update > 0 and not self.in_search() then if vim.api.nvim__redraw then for _, win in ipairs(update) do win:redraw() end else vim.cmd.redraw() end end end function N.in_search() return vim.tbl_contains({ "/", "?" }, vim.fn.getcmdtype()) end ---@param msg string ---@param level? snacks.notifier.level|number ---@param opts? snacks.notifier.Notif.opts function N:notify(msg, level, opts) opts = opts or {} opts.msg = msg opts.level = level return self:add(opts) end -- Global instance local notifier = N.new() ---@param msg string ---@param level? snacks.notifier.level|number ---@param opts? snacks.notifier.Notif.opts function M.notify(msg, level, opts) return notifier:notify(msg, level, opts) end ---@param id? number|string function M.hide(id) return notifier:hide(id) end ---@param opts? snacks.notifier.history function M.get_history(opts) return notifier:get_history(opts) end ---@param opts? snacks.notifier.history function M.show_history(opts) return notifier:show_history(opts) end ---@private function M.health() health_msg = false vim.notify("", nil, { checkhealth = true }) vim.wait(500, function() return health_msg end, 10) if health_msg then Snacks.health.ok("is ready") else Snacks.health.error("is not ready") end end return M ================================================ FILE: lua/snacks/notify.lua ================================================ ---@class snacks.notify ---@overload fun(msg: string|string[], opts?: snacks.notify.Opts) local M = setmetatable({}, { __call = function(t, ...) return t.notify(...) end, }) M.meta = { desc = "Utility functions to work with Neovim's `vim.notify`", } ---@alias snacks.notify.Opts snacks.notifier.Notif.opts|{once?: boolean} ---@param msg string|string[] ---@param opts? snacks.notify.Opts function M.notify(msg, opts) opts = opts or {} local notify = vim[opts.once and "notify_once" or "notify"] --[[@as fun(...)]] notify = vim.in_fast_event() and vim.schedule_wrap(notify) or notify msg = type(msg) == "table" and table.concat(msg, "\n") or msg --[[@as string]] msg = vim.trim(msg) opts.title = opts.title or "Snacks" return notify(msg, opts.level, opts) end ---@param msg string|string[] ---@param opts? snacks.notify.Opts function M.warn(msg, opts) return M.notify(msg, vim.tbl_extend("keep", { level = vim.log.levels.WARN }, opts or {})) end ---@param msg string|string[] ---@param opts? snacks.notify.Opts function M.info(msg, opts) return M.notify(msg, vim.tbl_extend("keep", { level = vim.log.levels.INFO }, opts or {})) end ---@param msg string|string[] ---@param opts? snacks.notify.Opts function M.error(msg, opts) return M.notify(msg, vim.tbl_extend("keep", { level = vim.log.levels.ERROR }, opts or {})) end return M ================================================ FILE: lua/snacks/picker/actions.lua ================================================ ---@class snacks.picker.actions ---@field [string] snacks.picker.Action.spec local M = {} ---@class snacks.picker.jump.Action: snacks.picker.Action ---@field cmd? snacks.picker.EditCmd ---@class snacks.picker.layout.Action: snacks.picker.Action ---@field layout? snacks.picker.layout.Config|string ---@class snacks.picker.yank.Action: snacks.picker.Action ---@field reg? string ---@field field? string ---@field notify? boolean ---@class snacks.picker.insert.Action: snacks.picker.Action ---@field expr string ---@enum (key) snacks.picker.EditCmd local edit_cmd = { edit = "buffer", split = "sbuffer", vsplit = "vert sbuffer", tab = "tab sbuffer", drop = "drop", tabdrop = "tab drop", } --- Get `vim.v.count1`, but return 1 if in insert mode. --- In insert mode, you can't really pass a count, so we default to 1 local function count1() return vim.fn.mode():sub(1, 1) == "i" and 1 or vim.v.count1 end function M.jump(picker, _, action) ---@cast action snacks.picker.jump.Action -- if we're still in insert mode, stop it and schedule -- it to prevent issues with cursor position if vim.fn.mode():sub(1, 1) == "i" then vim.cmd.stopinsert() vim.schedule(function() M.jump(picker, _, action) end) return end local items = picker:selected({ fallback = true }) if picker.opts.jump.close then picker:close() else vim.api.nvim_set_current_win(picker.main) end if #items == 0 then return end local win = vim.api.nvim_get_current_win() local current_buf = vim.api.nvim_get_current_buf() local current_tab = vim.api.nvim_get_current_tabpage() local current_empty = vim.bo[current_buf].buftype == "" and vim.bo[current_buf].filetype == "" and vim.api.nvim_buf_line_count(current_buf) == 1 and vim.api.nvim_buf_get_lines(current_buf, 0, -1, false)[1] == "" and vim.api.nvim_buf_get_name(current_buf) == "" local current_tab_windows = #vim.tbl_filter(function(w) return not Snacks.util.is_float(w) end, vim.api.nvim_tabpage_list_wins(current_tab)) if not current_empty then -- save position in jump list if picker.opts.jump.jumplist then vim.api.nvim_win_call(win, function() vim.cmd("normal! m'") end) end -- save position in tag stack if picker.opts.jump.tagstack then local from = vim.fn.getpos(".") from[1] = current_buf local tagstack = { { tagname = vim.fn.expand(""), from = from } } vim.fn.settagstack(vim.fn.win_getid(win), { items = tagstack }, "t") end end local cmd = edit_cmd[action.cmd] or edit_cmd.edit local is_drop = cmd:find("drop") ~= nil -- load the buffers local first_buf ---@type number for _, item in ipairs(items) do local buf = item.buf ---@type number if not buf then local path = assert(Snacks.picker.util.path(item), "Either item.buf or item.file is required") buf = vim.fn.bufadd(path) end vim.bo[buf].buflisted = true first_buf = first_buf or buf end -- find an existing window showing the first buffer in the current tab ---@param in_tab? boolean local function find_win(in_tab) if first_buf == current_buf then return true end for _, w in ipairs(vim.fn.win_findbuf(first_buf)) do if vim.api.nvim_win_get_config(w).relative == "" and (in_tab ~= true or vim.api.nvim_win_get_tabpage(w) == current_tab) then win = w vim.api.nvim_set_current_win(win) return true end end end -- use an existing window if reuse_win or drop if is_drop then if find_win() or cmd == "drop" then cmd = "buffer" else cmd = "tab sbuffer" end elseif cmd == "buffer" and #items == 1 and picker.opts.jump.reuse_win then find_win(true) end -- Don't open a new tab if current buffer is empty if cmd == "tab sbuffer" and current_empty and current_tab_windows == 1 then cmd = "buffer" end -- open the first buffer vim.cmd(("%s %d"):format(cmd, first_buf)) win = vim.api.nvim_get_current_win() -- set the cursor local item = items[1] local pos = item.pos if picker.opts.jump.match then pos = picker.matcher:bufpos(vim.api.nvim_get_current_buf(), item) or pos end if pos and pos[1] > 0 then vim.api.nvim_win_set_cursor(win, { pos[1], pos[2] }) vim.cmd("norm! zzzv") elseif item.search then vim.cmd(item.search) vim.cmd("noh") end -- HACK: this should fix folds if vim.wo.foldmethod == "expr" then vim.schedule(function() vim.opt.foldmethod = "expr" end) end if current_empty and vim.api.nvim_buf_is_valid(current_buf) then local w = vim.fn.win_findbuf(current_buf) if #w == 0 then vim.api.nvim_buf_delete(current_buf, { force = true }) end end end function M.close(picker) picker:norm(function() picker:close() end) end function M.print_cwd(picker) print(vim.fn.fnamemodify(picker:cwd(), ":p:~")) end function M.print_dir(picker) print(vim.fn.fnamemodify(picker:dir(), ":p:~")) end function M.print_path(picker, item) local path = item and Snacks.picker.util.path(item) or picker:dir() print(vim.fn.fnamemodify(path, ":p:~")) end function M.cancel(picker) picker:norm(function() picker.main = picker:filter().current_win picker:close() end) end M.confirm = M.jump -- default confirm action M.split = { action = "confirm", cmd = "split" } M.vsplit = { action = "confirm", cmd = "vsplit" } M.tab = { action = "confirm", cmd = "tab" } M.drop = { action = "confirm", cmd = "drop" } M.tabdrop = { action = "confirm", cmd = "tabdrop" } -- aliases M.edit = M.jump M.edit_split = M.split M.edit_vsplit = M.vsplit M.edit_tab = M.tab function M.layout(picker, _, action) ---@cast action snacks.picker.layout.Action assert(action.layout, "Layout action requires a layout") local opts = type(action.layout) == "table" and { layout = action.layout } or action.layout ---@cast opts snacks.picker.Config local layout = Snacks.picker.config.layout(opts) picker:set_layout(layout) -- Adjust some options for split layouts if (layout.layout.position or "float") ~= "float" then picker.opts.auto_close = false picker.opts.jump.close = false picker:toggle("preview", { enable = false }) picker.list.win:focus() end end M.layout_top = { action = "layout", layout = "top" } M.layout_bottom = { action = "layout", layout = "bottom" } M.layout_left = { action = "layout", layout = "left" } M.layout_right = { action = "layout", layout = "right" } function M.toggle_maximize(picker) picker.layout:maximize() end function M.insert(picker, _, action) ---@cast action snacks.picker.insert.Action if action.expr then local value = "" vim.api.nvim_buf_call(picker.input.filter.current_buf, function() value = action.expr == "line" and vim.api.nvim_get_current_line() or vim.fn.expand(action.expr) end) vim.api.nvim_win_call(picker.input.win.win, function() vim.api.nvim_put({ value }, "c", true, true) end) end end M.insert_cword = { action = "insert", expr = "" } M.insert_cWORD = { action = "insert", expr = "" } M.insert_filename = { action = "insert", expr = "%" } M.insert_file = { action = "insert", expr = "" } M.insert_line = { action = "insert", expr = "line" } M.insert_file_full = { action = "insert", expr = ":p" } M.insert_alt = { action = "insert", expr = "#" } function M.toggle_preview(picker) picker:toggle("preview") end function M.toggle_input(picker) picker:toggle("input", { focus = true }) end function M.picker_grep(_, item) if item then Snacks.picker.grep({ cwd = Snacks.picker.util.dir(item) }) end end function M.terminal(_, item) if item then Snacks.terminal(nil, { cwd = Snacks.picker.util.dir(item) }) end end function M.cd(_, item) if item then vim.fn.chdir(Snacks.picker.util.dir(item)) end end function M.tcd(_, item) if item then vim.cmd.tcd(Snacks.picker.util.dir(item)) end end function M.lcd(_, item) if item then vim.cmd.lcd(Snacks.picker.util.dir(item)) end end function M.picker(picker, item, action) if not item then return end local source = action.source or "files" for _, p in ipairs(Snacks.picker.get({ source = source })) do p:close() end Snacks.picker(source, { cwd = Snacks.picker.util.dir(item), filter = { cwd = source == "recent" and Snacks.picker.util.dir(item) or nil, }, on_show = function() picker:close() end, }) end M.picker_files = { action = "picker", source = "files" } M.picker_explorer = { action = "picker", source = "explorer" } M.picker_recent = { action = "picker", source = "recent" } function M.pick_win(picker, item, action) if not picker.layout.split then picker.layout:hide() end local win = Snacks.picker.util.pick_win({ main = picker.main }) if not win then if not picker.layout.split then picker.layout:unhide() end return true end picker.main = win if not picker.layout.split then vim.defer_fn(function() if not picker.closed then picker.layout:unhide() end end, 100) end end function M.bufdelete(picker) picker.preview:reset() local non_buf_delete_requested = false for _, item in ipairs(picker:selected({ fallback = true })) do if item.buf then Snacks.bufdelete.delete(item.buf) else non_buf_delete_requested = true end end if non_buf_delete_requested then Snacks.notify.warn("Only open buffers can be deleted", { title = "Snacks Picker" }) end picker:refresh() end function M.mark_delete(picker) local selected = picker:selected({ fallback = true }) for _, item in ipairs(selected) do if item.label then if item.buf then vim.api.nvim_buf_del_mark(item.buf, item.label) else vim.api.nvim_del_mark(item.label) end end end picker:refresh() end function M.git_stage(picker) local items = picker:selected({ fallback = true }) local first = items[1] if not first or not (first.status or (first.diff and first.staged ~= nil)) then Snacks.notify.error("Can't stage/unstage this change", { title = "Snacks Picker" }) return end local done = 0 for _, item in ipairs(items) do local opts = { cwd = item.cwd } ---@type snacks.picker.util.cmd.Opts local cmd ---@type string[] if item.diff and item.staged ~= nil then opts.input = item.diff cmd = { "git", "apply", "--cached", item.staged and "--reverse" or nil } elseif item.status then cmd = item.status:sub(2) == " " and { "git", "restore", "--staged", item.file } or { "git", "add", item.file } else Snacks.notify.error("Can't stage/unstage this change", { title = "Snacks Picker" }) return end Snacks.picker.util.cmd(cmd, function() done = done + 1 if done == #items then picker:refresh() end end, opts) end end function M.git_restore(picker) local items = picker:selected({ fallback = true }) if #items == 0 then return end local first = items[1] if not first or not (first.status or (first.diff and first.staged ~= nil)) then Snacks.notify.warn("Can't restore this change", { title = "Snacks Picker" }) return end -- Confirm before discarding changes ---@param item snacks.picker.Item local files = vim.tbl_map(function(item) return Snacks.picker.util.path(item) end, items) local msg = #items == 1 and ("Discard changes to `%s`?"):format(files[1]) or ("Discard changes to %d files?"):format(#items) Snacks.picker.util.confirm(msg, function() local done = 0 for _, item in ipairs(items) do local cmd ---@type string[] local opts = { cwd = item.cwd } if item.diff and item.staged ~= nil then opts.input = item.diff if item.staged then cmd = { "git", "apply", "--reverse", "--cached" } else cmd = { "git", "apply", "--reverse" } end elseif item.status then cmd = { "git", "restore", item.file } else Snacks.notify.error("Can't restore this change", { title = "Snacks Picker" }) return end Snacks.picker.util.cmd(cmd, function() done = done + 1 if done == #items then vim.schedule(function() picker:refresh() vim.cmd.startinsert() vim.cmd.checktime() end) end end, opts) end end) end function M.git_stash_apply(_, item) if not item then return end local cmd = { "git", "stash", "apply", item.stash } Snacks.picker.util.cmd(cmd, function() Snacks.notify("Stash applied: `" .. item.stash .. "`", { title = "Snacks Picker" }) end, { cwd = item.cwd }) end function M.git_checkout(picker, item) picker:close() if item then local what = item.branch or item.commit --[[@as string?]] if not what then Snacks.notify.warn("No branch or commit found", { title = "Snacks Picker" }) return end local cmd = { "git", "checkout", what } local remote_branch = what:match("^remotes/[^/]+/(.+)$") if remote_branch then cmd = { "git", "checkout", "-b", remote_branch, what } end if item.file then vim.list_extend(cmd, { "--", item.file }) end Snacks.picker.util.cmd(cmd, function() Snacks.notify("Checkout " .. what, { title = "Snacks Picker" }) vim.cmd.checktime() end, { cwd = item.cwd }) end end function M.git_branch_add(picker) Snacks.input.input({ prompt = "New Branch Name", default = picker.input:get(), }, function(name) if (name or ""):match("^%s*$") then return end Snacks.picker.util.cmd({ "git", "branch", "--list", name }, function(data) if data[1] ~= "" then return Snacks.notify.error("Branch '" .. name .. "' already exists.", { title = "Snacks Picker" }) end Snacks.picker.util.cmd({ "git", "checkout", "-b", name }, function() Snacks.notify("Created Branch `" .. name .. "`", { title = "Snacks Picker" }) vim.cmd.checktime() picker.list:set_target() picker.input:set("", "") picker:find() end, { cwd = picker:cwd() }) end, { cwd = picker:cwd() }) end) end function M.git_branch_del(picker, item) if not (item and item.branch) then Snacks.notify.warn("No branch or commit found", { title = "Snacks Picker" }) end local branch = item.branch Snacks.picker.util.cmd({ "git", "rev-parse", "--abbrev-ref", "HEAD" }, function(data) -- Check if we are on the same branch if data[1]:match(branch) ~= nil then Snacks.notify.error("Cannot delete the current branch.", { title = "Snacks Picker" }) return end Snacks.picker.util.confirm(("Delete branch %q?"):format(branch), function() -- Proceed with deletion Snacks.picker.util.cmd({ "git", "branch", "-D", branch }, function(_, code) Snacks.notify("Deleted Branch `" .. branch .. "`", { title = "Snacks Picker" }) vim.cmd.checktime() picker:refresh() end, { cwd = picker:cwd() }) end) end, { cwd = picker:cwd() }) end ---@param items snacks.picker.Item[] ---@param opts? {win?:number} local function setqflist(items, opts) local qf = {} ---@type vim.quickfix.entry[] for _, item in ipairs(items) do qf[#qf + 1] = { filename = Snacks.picker.util.path(item), bufnr = item.buf, lnum = item.pos and item.pos[1] or 1, col = item.pos and item.pos[2] + 1 or 1, end_lnum = item.end_pos and item.end_pos[1] or nil, end_col = item.end_pos and item.end_pos[2] + 1 or nil, text = item.line or item.comment or item.label or item.name or item.detail or item.text, pattern = item.search, type = ({ "E", "W", "I", "N" })[item.severity], valid = true, } end if opts and opts.win then vim.fn.setloclist(opts.win, qf) vim.cmd("botright lopen") else vim.fn.setqflist(qf) vim.cmd("botright copen") end end --- Send selected or all items to the quickfix list. function M.qflist(picker) picker:close() local sel = picker:selected() local items = #sel > 0 and sel or picker:items() setqflist(items) end --- Send all items to the quickfix list. function M.qflist_all(picker) picker:close() setqflist(picker:items()) end --- Send selected or all items to the location list. function M.loclist(picker) picker:close() local sel = picker:selected() local items = #sel > 0 and sel or picker:items() setqflist(items, { win = picker.main }) end function M.yank(picker, item, action) ---@cast action snacks.picker.yank.Action if item then local reg = action.reg or vim.v.register local value = item[action.field] or item.data or item.text vim.fn.setreg(reg, value) if action.notify ~= false then local buf = item.buf or vim.api.nvim_win_get_buf(picker.main) local ft = vim.bo[buf].filetype Snacks.notify(("Yanked to register `%s`:\n```%s\n%s\n```"):format(reg, ft, value), { title = "Snacks Picker" }) end end end M.copy = M.yank function M.paste(picker, item, action) ---@cast action snacks.picker.yank.Action picker:close() if item then local value = item[action.field] or item.data or item.text vim.api.nvim_paste(value, true, -1) if picker.input.mode == "i" then vim.schedule(function() vim.cmd.startinsert({ bang = true }) end) end end end M.put = M.paste function M.history_back(picker) picker:hist() end function M.history_forward(picker) picker:hist(true) end --- Toggles the selection of the current item, --- and moves the cursor to the next item. function M.select_and_next(picker) picker.list:select() picker.list:_move(count1()) end --- Toggles the selection of the current item, --- and moves the cursor to the prev item. function M.select_and_prev(picker) picker.list:select() picker.list:_move(-count1()) end --- Selects all items in the list. --- Or clears the selection if all items are selected. function M.select_all(picker) picker.list:select_all() end function M.cmd(picker, item) picker:close() if item and item.cmd then vim.schedule(function() vim.api.nvim_input(":") vim.schedule(function() vim.fn.setcmdline(item.cmd) end) end) end end function M.search(picker, item) picker:close() if item then vim.api.nvim_input("/") vim.schedule(function() vim.fn.setcmdline(item.text) end) end end --- Tries to load the session, if it fails, it will open the picker. function M.load_session(picker, item) picker:close() if not item then return end local dir = item.file local session_loaded = false vim.api.nvim_create_autocmd("SessionLoadPost", { once = true, callback = function() session_loaded = true end, }) vim.defer_fn(function() if not session_loaded then Snacks.picker.files() end end, 100) vim.fn.chdir(dir) local session = Snacks.dashboard.sections.session() if session then vim.cmd(session.action:sub(2)) end end function M.help(picker, item, action) ---@cast action snacks.picker.jump.Action if item then picker:close() local file = Snacks.picker.util.path(item) or "" if package.loaded.lazy then local plugin = file:match("/([^/]+)/doc/") if plugin and require("lazy.core.config").plugins[plugin] then require("lazy").load({ plugins = { plugin } }) end end local cmd = "help " .. item.text if action.cmd == "vsplit" then cmd = "vert " .. cmd elseif action.cmd == "tab" then cmd = "tab " .. cmd end vim.cmd(cmd) end end function M.toggle_help_input(picker) picker.input.win:toggle_help() end function M.toggle_help_list(picker) picker.list.win:toggle_help() end function M.preview_scroll_down(picker) if picker.preview.win:valid() then picker.preview.win:scroll() end end function M.preview_scroll_up(picker) if picker.preview.win:valid() then picker.preview.win:scroll(true) end end function M.preview_scroll_left(picker) if picker.preview.win:valid() then picker.preview.win:hscroll(true) end end function M.preview_scroll_right(picker) if picker.preview.win:valid() then picker.preview.win:hscroll() end end function M.inspect(picker, item) Snacks.debug.inspect(item) end function M.toggle_live(picker) if not picker.opts.supports_live then Snacks.notify.warn("Live search is not supported for `" .. picker.title .. "`", { title = "Snacks Picker" }) return end picker.opts.live = not picker.opts.live picker.input:set() picker.input:update() end function M.toggle_focus(picker) if vim.api.nvim_get_current_win() == picker.input.win.win then picker:focus("list", { show = true }) else picker:focus("input", { show = true }) end end function M.cycle_win(picker) local wins = { picker.input.win.win, picker.preview.win.win, picker.list.win.win } -- HACK: allow specifying an additional window to cycle through if type(vim.g.snacks_picker_cycle_win) == "number" then table.insert(wins, 3, vim.g.snacks_picker_cycle_win) end wins = vim.tbl_filter(function(w) return vim.api.nvim_win_is_valid(w) end, wins) local win = vim.api.nvim_get_current_win() local idx = 1 for i, w in ipairs(wins) do if w == win then idx = i break end end win = wins[idx % #wins + 1] or 1 -- cycle vim.api.nvim_set_current_win(win) end function M.focus_input(picker) picker:focus("input", { show = true }) end function M.focus_list(picker) picker:focus("list", { show = true }) end function M.focus_preview(picker) picker:focus("preview", { show = true }) end function M.item_action(picker, item, action) if item.action then picker:norm(function() picker:close() item.action(picker, item, action) end) end end function M.list_top(picker) picker.list:move(1, true) end function M.list_bottom(picker) picker.list:move(picker.list:count(), true) end function M.list_down(picker) picker.list:move(count1()) end function M.list_up(picker) picker.list:move(-count1()) end function M.list_scroll_top(picker) local cursor = picker.list.cursor picker.list:view(cursor, cursor) end function M.list_scroll_bottom(picker) local cursor = picker.list.cursor picker.list:view(cursor, picker.list.cursor - picker.list:height() + 1) end function M.list_scroll_center(picker) local cursor = picker.list.cursor picker.list:view(cursor, picker.list.cursor - math.ceil(picker.list:height() / 2) + 1) end function M.list_scroll_down(picker) picker.list:scroll(picker.list.state.scroll) end function M.list_scroll_up(picker) picker.list:scroll(-picker.list.state.scroll) end return M ================================================ FILE: lua/snacks/picker/config/defaults.lua ================================================ local M = {} ---@alias snacks.picker.format.resolve fun(max_width:number):snacks.picker.Highlight[] ---@alias snacks.picker.Extmark vim.api.keyset.set_extmark|{col:number, row?:number, field?:string} ---@alias snacks.picker.Meta {[string]:any} ---@alias snacks.picker.Text {[1]:string, [2]:(string|string[])?, virtual?:boolean, field?:string, resolve?:snacks.picker.format.resolve, inline?:boolean} ---@alias snacks.picker.Highlight snacks.picker.Text|snacks.picker.Extmark|{meta?:snacks.picker.Meta} ---@alias snacks.picker.format fun(item:snacks.picker.Item, picker:snacks.Picker):snacks.picker.Highlight[] ---@alias snacks.picker.preview fun(ctx: snacks.picker.preview.ctx):boolean? ---@alias snacks.picker.sort fun(a:snacks.picker.Item, b:snacks.picker.Item):boolean ---@alias snacks.picker.transform fun(item:snacks.picker.finder.Item, ctx:snacks.picker.finder.ctx):(boolean|snacks.picker.finder.Item|nil) ---@alias snacks.picker.Pos {[1]:number, [2]:number} ---@alias snacks.picker.toggle {icon?:string, enabled?:boolean, value?:boolean} --- Generic filter used by some finders to pre-filter items ---@class snacks.picker.filter.Config ---@field cwd? boolean|string only show files for the given cwd ---@field buf? boolean|number only show items for the current or given buffer ---@field paths? table only show items that include or exclude the given paths ---@field filter? fun(item:snacks.picker.finder.Item, filter:snacks.picker.Filter):boolean? custom filter function ---@field transform? fun(picker:snacks.Picker, filter:snacks.picker.Filter):boolean? filter transform. Return `true` to force refresh --- This is only used when using `opts.preview = "preview"`. --- It's a previewer that shows a preview based on the item data. ---@class snacks.picker.Item.preview ---@field text string text to show in the preview buffer ---@field ft? string optional filetype used tohighlight the preview buffer ---@field extmarks? snacks.picker.Extmark[] additional extmarks ---@field loc? boolean set to false to disable showing the item location in the preview ---@class snacks.picker.Item ---@field [string] any ---@field idx number ---@field score number ---@field frecency? number ---@field score_add? number ---@field score_mul? number ---@field source_id? number ---@field file? string ---@field text string ---@field pos? snacks.picker.Pos ---@field loc? snacks.picker.lsp.Loc ---@field end_pos? snacks.picker.Pos ---@field highlights? snacks.picker.Highlight[][] ---@field preview? snacks.picker.Item.preview ---@field resolve? fun(item:snacks.picker.Item) ---@field positions? number[] indices of matched characters in `text` ---@class snacks.picker.finder.Item: snacks.picker.Item ---@field idx? number ---@field score? number ---@class snacks.picker.layout.Config ---@field layout snacks.layout.Box ---@field reverse? boolean when true, the list will be reversed (bottom-up) ---@field fullscreen? boolean open in fullscreen ---@field cycle? boolean cycle through the list ---@field preview? "main" show preview window in the picker or the main window ---@field preset? string|fun(source:string):string ---@field hidden? ("input"|"preview"|"list")[] don't show the given windows when opening the picker. (only "input" and "preview" make sense) ---@field auto_hide? ("input"|"preview"|"list")[] hide the given windows when not focused (only "input" makes real sense) ---@field config? fun(layout:snacks.picker.layout.Config) customize the resolved layout config ---@class snacks.picker.win.Config ---@field input? snacks.win.Config|{} input window config ---@field list? snacks.win.Config|{} result list window config ---@field preview? snacks.win.Config|{} preview window config ---@class snacks.picker.Config ---@field multi? (string|snacks.picker.Config)[] ---@field source? string source name and config to use ---@field pattern? string|fun(picker:snacks.Picker):string pattern used to filter items by the matcher ---@field search? string|fun(picker:snacks.Picker):string search string used by finders ---@field cwd? string current working directory ---@field live? boolean when true, typing will trigger live searches ---@field limit? number when set, the finder will stop after finding this number of items. useful for live searches ---@field limit_live? number when set, the finder will stop after finding this number of items during live searches. useful for performance ---@field ui_select? boolean set `vim.ui.select` to a snacks picker ---@field filter? snacks.picker.filter.Config generic filter used by some finders --- Source definition ---@field items? snacks.picker.finder.Item[] items to show instead of using a finder ---@field format? string|snacks.picker.format|string format function or preset ---@field finder? string|snacks.picker.finder|snacks.picker.finder.multi finder function or preset ---@field preview? snacks.picker.preview|string preview function or preset ---@field matcher? snacks.picker.matcher.Config|{} matcher config ---@field sort? snacks.picker.sort|snacks.picker.sort.Config sort function or config ---@field transform? string|snacks.picker.transform transform/filter function --- UI ---@field win? snacks.picker.win.Config ---@field layout? snacks.picker.layout.Config|string|{}|fun(source:string):(snacks.picker.layout.Config|string) ---@field icons? snacks.picker.icons ---@field prompt? string prompt text / icon ---@field title? string defaults to a capitalized source name ---@field auto_close? boolean automatically close the picker when focusing another window (defaults to true) ---@field show_empty? boolean show the picker even when there are no items ---@field show_delay? number delay (in ms) to wait before showing the picker while no results yet ---@field focus? "input"|"list" where to focus when the picker is opened (defaults to "input") ---@field enter? boolean enter the picker when opening it ---@field toggles? table --- Preset options ---@field previewers? snacks.picker.previewers.Config|{} ---@field formatters? snacks.picker.formatters.Config|{} ---@field sources? snacks.picker.sources.Config|{}|table ---@field layouts? table --- Actions ---@field actions? table actions used by keymaps ---@field confirm? snacks.picker.Action.spec shortcut for confirm action ---@field auto_confirm? boolean automatically confirm if there is only one item ---@field main? snacks.picker.main.Config main editor window config ---@field on_change? fun(picker:snacks.Picker, item?:snacks.picker.Item) called when the cursor changes ---@field on_show? fun(picker:snacks.Picker) called when the picker is shown ---@field on_close? fun(picker:snacks.Picker) called when the picker is closed ---@field jump? snacks.picker.jump.Config|{} --- Other ---@field config? fun(opts:snacks.picker.Config):snacks.picker.Config? custom config function ---@field db? snacks.picker.db.Config|{} ---@field debug? snacks.picker.debug|{} local defaults = { prompt = " ", sources = {}, focus = "input", show_delay = 5000, limit_live = 10000, layout = { cycle = true, --- Use the default layout or vertical if the window is too narrow preset = function() return vim.o.columns >= 120 and "default" or "vertical" end, }, ---@class snacks.picker.matcher.Config matcher = { fuzzy = true, -- use fuzzy matching smartcase = true, -- use smartcase ignorecase = true, -- use ignorecase sort_empty = false, -- sort results when the search string is empty filename_bonus = true, -- give bonus for matching file names (last part of the path) file_pos = true, -- support patterns like `file:line:col` and `file:line` -- the bonusses below, possibly require string concatenation and path normalization, -- so this can have a performance impact for large lists and increase memory usage cwd_bonus = false, -- give bonus for matching files in the cwd frecency = false, -- frecency bonus history_bonus = false, -- give more weight to chronological order }, sort = { -- default sort is by score, text length and index fields = { "score:desc", "#text", "idx" }, }, ui_select = true, -- replace `vim.ui.select` with the snacks picker ---@class snacks.picker.formatters.Config formatters = { text = { ft = nil, ---@type string? filetype for highlighting }, file = { filename_first = false, -- display filename before the file path --- * left: truncate the beginning of the path --- * center: truncate the middle of the path --- * right: truncate the end of the path ---@type "left"|"center"|"right" truncate = "center", min_width = 40, -- minimum length of the truncated path filename_only = false, -- only show the filename icon_width = 2, -- width of the icon (in characters) git_status_hl = true, -- use the git status highlight group for the filename }, selected = { show_always = false, -- only show the selected column when there are multiple selections unselected = true, -- use the unselected icon for unselected items }, severity = { icons = true, -- show severity icons level = false, -- show severity level ---@type "left"|"right" pos = "left", -- position of the diagnostics }, }, ---@class snacks.picker.previewers.Config previewers = { diff = { -- fancy: Snacks fancy diff (borders, multi-column line numbers, syntax highlighting) -- syntax: Neovim's built-in diff syntax highlighting -- terminal: external command (git's pager for git commands, `cmd` for other diffs) style = "fancy", ---@type "fancy"|"syntax"|"terminal" cmd = { "delta" }, -- example for using `delta` as the external diff command ---@type vim.wo?|{} window options for the fancy diff preview window wo = { breakindent = true, wrap = true, linebreak = true, showbreak = "", }, }, git = { args = {}, -- additional arguments passed to the git command. Useful to set pager options usin `-c ...` }, file = { max_size = 1024 * 1024, -- 1MB max_line_length = 500, -- max line length ft = nil, ---@type string? filetype for highlighting. Use `nil` for auto detect }, man_pager = nil, ---@type string? MANPAGER env to use for `man` preview }, ---@class snacks.picker.jump.Config jump = { jumplist = true, -- save the current position in the jumplist tagstack = false, -- save the current position in the tagstack reuse_win = false, -- reuse an existing window if the buffer is already open close = true, -- close the picker when jumping/editing to a location (defaults to true) match = false, -- jump to the first match position. (useful for `lines`) }, toggles = { follow = "f", hidden = "h", ignored = "i", modified = "m", regex = { icon = "R", value = false }, }, win = { -- input window input = { keys = { -- to close the picker on ESC instead of going to normal mode, -- add the following keymap to your config -- [""] = { "close", mode = { "n", "i" } }, ["/"] = "toggle_focus", [""] = { "history_forward", mode = { "i", "n" } }, [""] = { "history_back", mode = { "i", "n" } }, [""] = { "cancel", mode = "i" }, [""] = { "", mode = { "i" }, expr = true, desc = "delete word" }, [""] = { "confirm", mode = { "n", "i" } }, [""] = { "list_down", mode = { "i", "n" } }, [""] = "cancel", [""] = { { "pick_win", "jump" }, mode = { "n", "i" } }, [""] = { "select_and_prev", mode = { "i", "n" } }, [""] = { "select_and_next", mode = { "i", "n" } }, [""] = { "list_up", mode = { "i", "n" } }, [""] = { "inspect", mode = { "n", "i" } }, [""] = { "toggle_follow", mode = { "i", "n" } }, [""] = { "toggle_hidden", mode = { "i", "n" } }, [""] = { "toggle_ignored", mode = { "i", "n" } }, [""] = { "toggle_regex", mode = { "i", "n" } }, [""] = { "toggle_maximize", mode = { "i", "n" } }, [""] = { "toggle_preview", mode = { "i", "n" } }, [""] = { "cycle_win", mode = { "i", "n" } }, [""] = { "select_all", mode = { "n", "i" } }, [""] = { "preview_scroll_up", mode = { "i", "n" } }, [""] = { "list_scroll_down", mode = { "i", "n" } }, [""] = { "preview_scroll_down", mode = { "i", "n" } }, [""] = { "toggle_live", mode = { "i", "n" } }, [""] = { "list_down", mode = { "i", "n" } }, [""] = { "list_up", mode = { "i", "n" } }, [""] = { "list_down", mode = { "i", "n" } }, [""] = { "list_up", mode = { "i", "n" } }, [""] = { "qflist", mode = { "i", "n" } }, [""] = { "edit_split", mode = { "i", "n" } }, [""] = { "tab", mode = { "n", "i" } }, [""] = { "list_scroll_up", mode = { "i", "n" } }, [""] = { "edit_vsplit", mode = { "i", "n" } }, ["#"] = { "insert_alt", mode = "i" }, ["%"] = { "insert_filename", mode = "i" }, [""] = { "insert_cWORD", mode = "i" }, [""] = { "insert_file", mode = "i" }, [""] = { "insert_line", mode = "i" }, [""] = { "insert_file_full", mode = "i" }, [""] = { "insert_cword", mode = "i" }, ["H"] = "layout_left", ["J"] = "layout_bottom", ["K"] = "layout_top", ["L"] = "layout_right", ["?"] = "toggle_help_input", ["G"] = "list_bottom", ["gg"] = "list_top", ["j"] = "list_down", ["k"] = "list_up", ["q"] = "cancel", }, b = { minipairs_disable = true, }, }, -- result list window list = { keys = { ["/"] = "toggle_focus", ["<2-LeftMouse>"] = "confirm", [""] = "confirm", [""] = "list_down", [""] = "cancel", [""] = { { "pick_win", "jump" } }, [""] = { "select_and_prev", mode = { "n", "x" } }, [""] = { "select_and_next", mode = { "n", "x" } }, [""] = "list_up", [""] = "inspect", [""] = "toggle_follow", [""] = "toggle_hidden", [""] = "toggle_ignored", [""] = "toggle_maximize", [""] = "toggle_preview", [""] = "cycle_win", [""] = "select_all", [""] = "preview_scroll_up", [""] = "list_scroll_down", [""] = "preview_scroll_down", [""] = "list_down", [""] = "list_up", [""] = "list_down", [""] = "list_up", [""] = "qflist", [""] = "print_path", [""] = "edit_split", [""] = "tab", [""] = "list_scroll_up", [""] = "edit_vsplit", ["H"] = "layout_left", ["J"] = "layout_bottom", ["K"] = "layout_top", ["L"] = "layout_right", ["?"] = "toggle_help_list", ["G"] = "list_bottom", ["gg"] = "list_top", ["i"] = "focus_input", ["j"] = "list_down", ["k"] = "list_up", ["q"] = "cancel", ["zb"] = "list_scroll_bottom", ["zt"] = "list_scroll_top", ["zz"] = "list_scroll_center", }, wo = { conceallevel = 2, concealcursor = "nvc", }, }, -- preview window preview = { keys = { [""] = "cancel", ["q"] = "cancel", ["i"] = "focus_input", [""] = "cycle_win", }, }, }, ---@class snacks.picker.icons -- stylua: ignore icons = { files = { enabled = true, -- show file icons dir = "󰉋 ", dir_open = "󰝰 ", file = "󰈔 " }, keymaps = { nowait = "󰓅 " }, tree = { vertical = "│ ", middle = "├╴", last = "└╴", }, undo = { saved = " ", }, ui = { live = "󰐰 ", hidden = "h", ignored = "i", follow = "f", selected = "● ", unselected = "○ ", -- selected = " ", }, git = { enabled = true, -- show git icons commit = "󰜘 ", -- used by git log staged = "●", -- staged changes. always overrides the type icons added = "", deleted = "", ignored = " ", modified = "○", renamed = "", unmerged = " ", untracked = "?", }, diagnostics = { Error = " ", Warn = " ", Hint = " ", Info = " ", }, lsp = { unavailable = "", enabled = " ", disabled = " ", attached = "󰖩 " }, kinds = { Array = " ", Boolean = "󰨙 ", Class = " ", Color = " ", Control = " ", Collapsed = " ", Constant = "󰏿 ", Constructor = " ", Copilot = " ", Enum = " ", EnumMember = " ", Event = " ", Field = " ", File = " ", Folder = " ", Function = "󰊕 ", Interface = " ", Key = " ", Keyword = " ", Method = "󰊕 ", Module = " ", Namespace = "󰦮 ", Null = " ", Number = "󰎠 ", Object = " ", Operator = " ", Package = " ", Property = " ", Reference = " ", Snippet = "󱄽 ", String = " ", Struct = "󰆼 ", Text = " ", TypeParameter = " ", Unit = " ", Unknown = " ", Value = " ", Variable = "󰀫 ", }, }, ---@class snacks.picker.db.Config db = { -- path to the sqlite3 library -- If not set, it will try to load the library by name. -- On Windows it will download the library from the internet. sqlite3_path = nil, ---@type string? }, ---@class snacks.picker.debug debug = { scores = false, -- show scores in the list leaks = false, -- show when pickers don't get garbage collected explorer = false, -- show explorer debug info files = false, -- show file debug info grep = false, -- show file debug info proc = false, -- show proc debug info extmarks = false, -- show extmarks errors }, } M.defaults = defaults return M ================================================ FILE: lua/snacks/picker/config/highlights.lua ================================================ ---@class snacks.picker.config.highlights local M = {} Snacks.util.set_hl({ Match = "Special", Search = "Search", Prompt = "Special", InputSearch = "@keyword", Special = "Special", Label = "SnacksPickerSpecial", Totals = "NonText", File = "", -- basename of a file path Link = "Comment", LinkBroken = "DiagnosticError", Directory = "Directory", -- basename of a directory path PathIgnored = "NonText", -- any ignored file or directory PathHidden = "NonText", -- any hidden file or directory Dir = "NonText", -- dirname of a path Toggle = "DiagnosticVirtualTextInfo", Dimmed = "Conceal", Row = "String", Col = "LineNr", Comment = "Comment", Desc = "Comment", Delim = "Delimiter", Spinner = "Special", Selected = "Number", Cmd = "Function", CmdBuiltin = "@constructor", Unselected = "NonText", Idx = "Number", Bold = "Bold", Tree = "LineNr", Italic = "Italic", Code = "@markup.raw.markdown_inline", AuPattern = "String", AuEvent = "Constant", AuGroup = "Type", DiagnosticCode = "Special", DiagnosticSource = "Comment", Register = "Number", KeymapMode = "Number", KeymapLhs = "Special", KeymapNowait = "@variable.builtin", BufNr = "Number", BufFlags = "NonText", BufType = "Function", FileType = "DiagnosticHint", KeymapRhs = "NonText", Time = "Special", UndoAdded = "Added", UndoRemoved = "Removed", UndoCurrent = "@variable.builtin", UndoSaved = "Special", GitCommit = "@variable.builtin", GitBreaking = "Error", GitDetached = "DiagnosticWarn", GitBranch = "Title", GitBranchCurrent = "Number", GitDate = "Special", GitIssue = "Number", GitAuthor = "Constant", GitType = "Title", -- conventional commit type GitScope = "Italic", -- conventional commit scope GitStatus = "Special", GitStatusAdded = "Added", GitStatusModified = "DiagnosticWarn", GitStatusDeleted = "Removed", GitStatusRenamed = "SnacksPickerGitStatus", GitStatusCopied = "SnacksPickerGitStatus", GitStatusUntracked = "NonText", GitStatusIgnored = "NonText", GitStatusUnmerged = "DiagnosticError", GitStatusStaged = "DiagnosticHint", ManSection = "Number", PickWin = "Search", PickWinCurrent = "CurSearch", LspDisabled = "DiagnosticWarn", LspEnabled = "Special", LspAttached = "DiagnosticWarn", LspAttachedBuf = "DiagnosticInfo", LspUnavailable = "DiagnosticError", ManPage = "Special", -- Icons Icon = "Special", IconSource = "@constant", IconName = "@keyword", IconCategory = "@module", -- LSP Symbol Kinds IconArray = "@punctuation.bracket", IconBoolean = "@boolean", IconClass = "@type", IconConstant = "@constant", IconConstructor = "@constructor", IconEnum = "@lsp.type.enum", IconEnumMember = "@lsp.type.enumMember", IconEvent = "Special", IconField = "@variable.member", IconFile = "Normal", IconFunction = "@function", IconInterface = "@lsp.type.interface", IconKey = "@lsp.type.keyword", IconMethod = "@function.method", IconModule = "@module", IconNamespace = "@module", IconNull = "@constant.builtin", IconNumber = "@number", IconObject = "@constant", IconOperator = "@operator", IconPackage = "@module", IconProperty = "@property", IconString = "@string", IconStruct = "@lsp.type.struct", IconTypeParameter = "@lsp.type.typeParameter", IconVariable = "@variable", Rule = "@punctuation.special.markdown", }, { prefix = "SnacksPicker", default = true }) return M ================================================ FILE: lua/snacks/picker/config/init.lua ================================================ ---@class snacks.picker.config local M = {} --- Source aliases M.alias = { live_grep = "grep", find_files = "files", git_commits = "git_log", git_bcommits = "git_log_file", oldfiles = "recent", } local defaults ---@type snacks.picker.Config? --- Fixes keys before merging configs for correctly resolving keymaps. --- For example: -> ---@param opts? snacks.picker.Config function M.fix_keys(opts) opts = opts or {} -- fix keys in sources for _, source in pairs(opts.sources or {}) do M.fix_keys(source) end if not opts.win then return opts end -- fix keys in wins for _, win in pairs(opts.win) do ---@cast win snacks.win.Config if win.keys then local keys = vim.tbl_keys(win.keys) ---@type string[] for _, key in ipairs(keys) do local norm = Snacks.util.normkey(key) if key ~= norm then win.keys[norm], win.keys[key] = win.keys[key], nil end end end end return opts end ---@generic T:snacks.picker.Config ---@param opts? T ---@return T function M.get(opts) M.setup() opts = M.fix_keys(opts) -- Setup defaults if not defaults then defaults = require("snacks.picker.config.defaults").defaults defaults.sources = require("snacks.picker.config.sources") defaults.layouts = require("snacks.picker.config.layouts") M.fix_keys(defaults) end local user = M.fix_keys(Snacks.config.picker or {}) opts.source = M.alias[opts.source] or opts.source -- Prepare config local global = Snacks.config.get("picker", defaults, opts) -- defaults + global user config local source = opts.source and global.sources[opts.source] or {} ---@type snacks.picker.Config[] local todo = { vim.deepcopy(defaults), vim.deepcopy(user), vim.deepcopy(source), opts, } -- Merge the confirm action into the actions table for _, t in ipairs(todo) do if t.confirm then t.actions = t.actions or {} t.actions.confirm = t.confirm end end -- Merge the configs opts = Snacks.config.merge(unpack(todo)) if opts.cwd == true or opts.cwd == "" then opts.cwd = nil elseif opts.cwd then opts.cwd = svim.fs.normalize(vim.fn.fnamemodify(opts.cwd:gsub("[\\/]?$", "/"), ":p")) end for _, t in ipairs(todo) do if t.config then opts = t.config(opts) or opts end end -- add hl groups and actions for toggles opts.actions = opts.actions or {} for name in pairs(opts.toggles) do local hl = table.concat(vim.tbl_map(function(a) return a:sub(1, 1):upper() .. a:sub(2) end, vim.split(name, "_"))) Snacks.util.set_hl({ [hl] = "SnacksPickerToggle" }, { default = true, prefix = "SnacksPickerToggle" }) opts.actions["toggle_" .. name] = function(picker) picker.opts[name] = not picker.opts[name] picker.list:set_target() picker:find() end end M.fix_old(opts) M.multi(opts) return opts end --- Fixes old config options ---@param opts snacks.picker.Config function M.fix_old(opts) end ---@param opts snacks.picker.Config function M.multi(opts) if not opts.multi then return opts end local Finder = require("snacks.picker.core.finder") local finders = {} ---@type snacks.picker.finder[] local formats = {} ---@type snacks.picker.format[] local previews = {} ---@type snacks.picker.preview[] local confirms = {} ---@type snacks.picker.Action.spec[] local sources = {} ---@type snacks.picker.Config[] for _, source in ipairs(opts.multi) do if type(source) == "string" then source = { source = source } end ---@cast source snacks.picker.Config source = Snacks.config.merge({}, opts.sources[source.source], source) --[[@as snacks.picker.Config]] source.actions = source.actions or {} if source.confirm then source.actions.confirm = source.confirm end local finder = M.finder(source.finder) finders[#finders + 1] = function(fopts, ctx) fopts = Snacks.config.merge(vim.deepcopy(source), fopts) ctx = ctx:clone(fopts) -- Update source filter when needed if not vim.tbl_isempty(fopts.filter or {}) then ctx.filter = ctx.filter:clone():init(fopts) end return finder(fopts, ctx) end confirms[#confirms + 1] = source.actions.confirm or "jump" previews[#previews + 1] = M.preview(source) formats[#formats + 1] = M.format(source) sources[#sources + 1] = source -- merge keys for w, win in pairs(source.win or {}) do if win.keys then opts.win = opts.win or {} opts.win[w] = opts.win[w] or {} opts.win[w].keys = Snacks.config.merge(opts.win[w].keys or {}, win.keys) end end end opts.finder = opts.finder or Finder.multi(finders) opts.format = opts.format or function(item, picker) return formats[item.source_id](item, picker) end opts.preview = opts.preview or function(ctx) return previews[ctx.item.source_id](ctx) end opts.confirm = opts.confirm or function(picker, item, action) return confirms[item.source_id](picker, item, action) end end ---@param opts snacks.picker.Config function M.format(opts) local ret = type(opts.format) == "string" and (Snacks.picker.format[opts.format] or M.field(opts.format)) or opts.format or Snacks.picker.format.file ---@cast ret snacks.picker.format return ret end ---@param opts snacks.picker.Config function M.transform(opts) local ret = type(opts.transform) == "string" and require("snacks.picker.transform")[opts.transform] or opts.transform or nil ---@cast ret snacks.picker.transform? return ret end ---@param opts snacks.picker.Config function M.preview(opts) local preview = opts.preview or Snacks.picker.preview.file preview = type(preview) == "string" and (Snacks.picker.preview[preview] or M.field(preview)) or preview ---@cast preview snacks.picker.preview return preview end ---@param opts snacks.picker.Config function M.sort(opts) local sort = opts.sort or require("snacks.picker.sort").default() sort = type(sort) == "table" and require("snacks.picker.sort").default(sort) or sort ---@cast sort snacks.picker.sort return sort end --- Resolve the layout configuration ---@param opts snacks.picker.Config|string function M.layout(opts) if type(opts) == "string" then opts = M.get({ layout = { preset = opts } }) end -- Resolve the layout configuration local layout = M.resolve(opts.layout or {}, opts.source) layout = type(layout) == "string" and { preset = layout } or layout ---@cast layout snacks.picker.layout.Config -- only resolve presets when the layout has no layout if not (layout.layout and layout.layout[1]) then -- Resolve the preset local layouts = opts.layouts or M.get().layouts or {} local done = {} ---@type table local todo = { layout } ---@type snacks.picker.layout.Config[] while true do local preset = M.resolve(todo[1].preset or "custom", opts.source) if not preset or done[preset] or not layouts[preset] then break end done[preset] = true table.insert(todo, 1, vim.deepcopy(layouts[preset])) end -- Merge and return the layout layout = Snacks.config.merge(unpack(todo)) --[[@as snacks.picker.layout.Config]] end -- Fix deprecated layout options layout.hidden = layout.hidden or {} if layout.preview == false then table.insert(layout.hidden, "preview") layout.preview = nil elseif type(layout.preview) == "table" then ---@cast layout snacks.picker.layout.Config|{preview: {enabled: boolean, main: boolean}} if layout.preview.enabled == false then table.insert(layout.hidden, "preview") end if layout.preview.main then layout.preview = "main" else layout.preview = nil end end if layout.config then layout = layout.config(layout) or layout end return layout end ---@generic T ---@generic A ---@param v (fun(...:A):T)|unknown ---@param ... A ---@return T function M.resolve(v, ...) return type(v) == "function" and v(...) or v end --- Get the finder ---@param finder string|snacks.picker.finder|snacks.picker.finder.multi ---@return snacks.picker.finder function M.finder(finder) local nop = function() Snacks.notify.error("Finder not found:\n```lua\n" .. vim.inspect(finder) .. "\n```", { title = "Snacks Picker" }) end if not finder or type(finder) == "function" then return finder end if type(finder) == "table" then ---@cast finder snacks.picker.finder.multi ---@type snacks.picker.finder[] local finders = vim.tbl_map(function(f) return M.finder(f) end, finder) return require("snacks.picker.core.finder").multi(finders) end ---@cast finder string return M.field(finder) or nop end ---@param picker snacks.Picker ---@param action string function M.action(picker, action) local ret = (picker.opts.actions or {})[action] or require("snacks.picker.actions")[action] if ret then return ret end local source = action:match("^(.-)_") if source then -- source specific action return (M.field(("%s_actions"):format(source)) or {})[action] end end --- Resolves a module field ---@param spec string function M.field(spec) local parts = vim.split(spec, ".", { plain = true }) local name, field = parts[#parts]:match("^(.-)[_#](.+)$") if name and field then parts[#parts] = name else field = parts[#parts] end local ok, ret = pcall(function() return require("snacks.picker.source." .. table.concat(parts, "."))[field] end) return ok and ret or nil end local did_setup = false function M.setup() if did_setup then return end did_setup = true require("snacks.picker.config.highlights") for source in pairs(Snacks.picker.config.get().sources) do M.wrap(source) end --- Automatically wrap new sources added after setup setmetatable(require("snacks.picker.config.sources"), { __newindex = function(t, k, v) rawset(t, k, v) M.wrap(k) end, }) end ---@param source string ---@param opts? {check?: boolean} function M.wrap(source, opts) if opts and opts.check then local config = M.get() if not config.sources[source] then return end end if rawget(Snacks.picker, source) then return Snacks.picker[source] end ---@type fun(opts: snacks.picker.Config): snacks.Picker local ret = function(_opts) return Snacks.picker.pick(source, _opts) end ---@diagnostic disable-next-line: no-unknown Snacks.picker[source] = ret return ret end return M ================================================ FILE: lua/snacks/picker/config/layouts.lua ================================================ ---@class snacks.picker.layouts ---@field [string] snacks.picker.layout.Config local M = {} M.default = { layout = { box = "horizontal", width = 0.8, min_width = 120, height = 0.8, { box = "vertical", border = true, title = "{title} {live} {flags}", { win = "input", height = 1, border = "bottom" }, { win = "list", border = "none" }, }, { win = "preview", title = "{preview}", border = true, width = 0.5 }, }, } M.sidebar = { preview = "main", layout = { backdrop = false, width = 40, min_width = 40, height = 0, position = "left", border = "none", box = "vertical", { win = "input", height = 1, border = true, title = "{title} {live} {flags}", title_pos = "center", }, { win = "list", border = "none" }, { win = "preview", title = "{preview}", height = 0.4, border = "top" }, }, } M.telescope = { reverse = true, layout = { box = "horizontal", backdrop = false, width = 0.8, height = 0.9, border = "none", { box = "vertical", { win = "list", title = " Results ", title_pos = "center", border = true }, { win = "input", height = 1, border = true, title = "{title} {live} {flags}", title_pos = "center" }, }, { win = "preview", title = "{preview:Preview}", width = 0.45, border = true, title_pos = "center", }, }, } M.ivy = { layout = { box = "vertical", backdrop = false, row = -1, width = 0, height = 0.4, border = "top", title = " {title} {live} {flags}", title_pos = "left", { win = "input", height = 1, border = "bottom" }, { box = "horizontal", { win = "list", border = "none" }, { win = "preview", title = "{preview}", width = 0.6, border = "left" }, }, }, } M.ivy_split = { preview = "main", layout = { box = "vertical", backdrop = false, width = 0, height = 0.4, position = "bottom", border = "top", title = " {title} {live} {flags}", title_pos = "left", { win = "input", height = 1, border = "bottom" }, { box = "horizontal", { win = "list", border = "none" }, { win = "preview", title = "{preview}", width = 0.6, border = "left" }, }, }, } M.dropdown = { layout = { backdrop = false, row = 1, width = 0.4, min_width = 80, height = 0.8, border = "none", box = "vertical", { win = "preview", title = "{preview}", height = 0.4, border = true }, { box = "vertical", border = true, title = "{title} {live} {flags}", title_pos = "center", { win = "input", height = 1, border = "bottom" }, { win = "list", border = "none" }, }, }, } M.vertical = { layout = { backdrop = false, width = 0.5, min_width = 80, height = 0.8, min_height = 30, box = "vertical", border = true, title = "{title} {live} {flags}", title_pos = "center", { win = "input", height = 1, border = "bottom" }, { win = "list", border = "none" }, { win = "preview", title = "{preview}", height = 0.4, border = "top" }, }, } M.select = { hidden = { "preview" }, layout = { backdrop = false, width = 0.5, min_width = 80, max_width = 100, height = 0.4, min_height = 2, box = "vertical", border = true, title = "{title}", title_pos = "center", { win = "input", height = 1, border = "bottom" }, { win = "list", border = "none" }, { win = "preview", title = "{preview}", height = 0.4, border = "top" }, }, } M.vscode = { hidden = { "preview" }, layout = { backdrop = false, row = 1, width = 0.4, min_width = 80, height = 0.4, border = "none", box = "vertical", { win = "input", height = 1, border = true, title = "{title} {live} {flags}", title_pos = "center" }, { win = "list", border = "hpad" }, { win = "preview", title = "{preview}", border = true }, }, } M.left = M.sidebar M.right = { preset = "sidebar", layout = { position = "right" } } M.top = { preset = "ivy", layout = { position = "top" } } M.bottom = { preset = "ivy", layout = { position = "bottom" } } return M ================================================ FILE: lua/snacks/picker/config/sources.lua ================================================ ---@class snacks.picker.Config ---@field supports_live? boolean ---@class snacks.picker.sources.Config ---@field [string] snacks.picker.Config|{} local M = {} M.autocmds = { finder = "vim_autocmds", format = "autocmd", preview = "preview", } ---@class snacks.picker.buffers.Config: snacks.picker.Config ---@field hidden? boolean show hidden buffers (unlisted) ---@field unloaded? boolean show loaded buffers ---@field current? boolean show current buffer ---@field nofile? boolean show `buftype=nofile` buffers ---@field modified? boolean show only modified buffers ---@field sort_lastused? boolean sort by last used ---@field filter? snacks.picker.filter.Config M.buffers = { finder = "buffers", format = "buffer", hidden = false, unloaded = true, current = true, sort_lastused = true, win = { input = { keys = { [""] = { "bufdelete", mode = { "n", "i" } }, }, }, list = { keys = { ["dd"] = "bufdelete" } }, }, } ---@class snacks.picker.explorer.Config: snacks.picker.files.Config|{} ---@field follow_file? boolean follow the file from the current buffer ---@field tree? boolean show the file tree (default: true) ---@field git_status? boolean show git status (default: true) ---@field git_status_open? boolean show recursive git status for open directories ---@field git_untracked? boolean needed to show untracked git status ---@field diagnostics? boolean show diagnostics ---@field diagnostics_open? boolean show recursive diagnostics for open directories ---@field watch? boolean watch for file changes ---@field exclude? string[] exclude glob patterns ---@field include? string[] include glob patterns. These take precedence over `exclude`, `ignored` and `hidden` M.explorer = { finder = "explorer", sort = { fields = { "sort" } }, supports_live = true, tree = true, watch = true, diagnostics = true, diagnostics_open = false, git_status = true, git_status_open = false, git_untracked = true, follow_file = true, focus = "list", auto_close = false, jump = { close = false }, layout = { preset = "sidebar", preview = false }, -- to show the explorer to the right, add the below to -- your config under `opts.picker.sources.explorer` -- layout = { layout = { position = "right" } }, formatters = { file = { filename_only = true }, severity = { pos = "right" }, }, matcher = { sort_empty = false, fuzzy = false }, config = function(opts) return require("snacks.picker.source.explorer").setup(opts) end, win = { list = { keys = { [""] = "explorer_up", ["l"] = "confirm", ["h"] = "explorer_close", -- close directory ["a"] = "explorer_add", ["d"] = "explorer_del", ["r"] = "explorer_rename", ["c"] = "explorer_copy", ["m"] = "explorer_move", ["o"] = "explorer_open", -- open with system application ["P"] = "toggle_preview", ["y"] = { "explorer_yank", mode = { "n", "x" } }, ["p"] = "explorer_paste", ["u"] = "explorer_update", [""] = "tcd", ["/"] = "picker_grep", [""] = "terminal", ["."] = "explorer_focus", ["I"] = "toggle_ignored", ["H"] = "toggle_hidden", ["Z"] = "explorer_close_all", ["]g"] = "explorer_git_next", ["[g"] = "explorer_git_prev", ["]d"] = "explorer_diagnostic_next", ["[d"] = "explorer_diagnostic_prev", ["]w"] = "explorer_warn_next", ["[w"] = "explorer_warn_prev", ["]e"] = "explorer_error_next", ["[e"] = "explorer_error_prev", }, }, }, } M.cliphist = { finder = "system_cliphist", format = "text", preview = "preview", confirm = { "copy", "close" }, } -- Neovim colorschemes with live preview M.colorschemes = { finder = "vim_colorschemes", format = "text", preview = "colorscheme", preset = "vertical", confirm = function(picker, item) picker:close() if item then picker.preview.state.colorscheme = nil vim.schedule(function() vim.cmd("colorscheme " .. item.text) end) end end, } -- Neovim command history ---@type snacks.picker.history.Config M.command_history = { finder = "vim_history", name = "cmd", format = "text", preview = "none", main = { current = true }, layout = { preset = "vscode", }, confirm = "cmd", formatters = { text = { ft = "vim" } }, } -- Neovim commands M.commands = { finder = "vim_commands", format = "command", preview = "preview", confirm = "cmd", } ---@class snacks.picker.diagnostics.Config: snacks.picker.Config ---@field filter? snacks.picker.filter.Config ---@field severity? vim.diagnostic.SeverityFilter M.diagnostics = { finder = "diagnostics", format = "diagnostic", sort = { fields = { "is_current", "is_cwd", "severity", "file", "lnum", }, }, matcher = { sort_empty = true }, -- only show diagnostics from the cwd by default filter = { cwd = true }, } ---@type snacks.picker.diagnostics.Config M.diagnostics_buffer = { finder = "diagnostics", format = "diagnostic", sort = { fields = { "severity", "file", "lnum" }, }, matcher = { sort_empty = true }, filter = { buf = true }, } ---@class snacks.picker.files.Config: snacks.picker.proc.Config ---@field cmd? "fd"| "rg"| "find" command to use. Leave empty to auto-detect ---@field hidden? boolean show hidden files ---@field ignored? boolean show ignored files ---@field dirs? string[] directories to search ---@field follow? boolean follow symlinks ---@field exclude? string[] exclude patterns ---@field args? string[] additional arguments ---@field ft? string|string[] file extension(s) ---@field rtp? boolean search in runtimepath M.files = { finder = "files", format = "file", show_empty = true, hidden = false, ignored = false, follow = false, supports_live = true, } ---@class snacks.picker.gh.Config: snacks.picker.Config ---@field app? string GitHub App author ---@field assignee? string filter by assignee ---@field author? string filter by author ---@field jq? string custom jq filter ---@field label? string filter by label(s) ---@field limit? number number of items to fetch (default: 50) ---@field repo? string GitHub repository (owner/repo). Defaults to current git repo ---@class snacks.picker.gh.issue.Config: snacks.picker.gh.Config ---@field state "open" | "closed" | "all" ---@field mention? string filter by mention ---@field milestone? string filter by milestone M.gh_issue = { title = " Issues", finder = "gh_issue", format = "gh_format", preview = "gh_preview", sort = { fields = { "score:desc", "idx" } }, supports_live = true, live = true, confirm = "gh_actions", win = { input = { keys = { [""] = { "gh_browse", mode = { "n", "i" } }, [""] = { "gh_yank", mode = { "n", "i" } }, }, }, list = { keys = { ["y"] = { "gh_yank", mode = { "n", "x" } }, }, }, }, } ---@class snacks.picker.gh.pr.Config: snacks.picker.gh.Config ---@field state "open" | "closed" | "merged" | "all" ---@field draft? boolean filter draft PRs ---@field base? string filter by base branch M.gh_pr = { title = " Pull Requests", finder = "gh_pr", format = "gh_format", preview = "gh_preview", sort = { fields = { "score:desc", "idx" } }, supports_live = true, live = true, confirm = "gh_actions", win = { input = { keys = { [""] = { "gh_browse", mode = { "n", "i" } }, [""] = { "gh_yank", mode = { "n", "i" } }, }, }, list = { keys = { ["y"] = { "gh_yank", mode = { "n", "x" } }, }, }, }, } ---@class snacks.picker.gh.diff.Config: snacks.picker.Config ---@field group? boolean group changes by file (when false, show individual hunks) ---@field pr number number PR number to diff against ---@field repo? string GitHub repository (owner/repo). Defaults to current git repo M.gh_diff = { title = " Pull Request Diff", group = true, finder = "gh_diff", format = "git_status", preview = "gh_preview_diff", win = { preview = { keys = { ["a"] = { "gh_comment", mode = { "n", "x" } }, [""] = { "gh_actions", mode = { "n", "x" } }, }, }, }, } ---@class snacks.picker.gh.reactions.Config: snacks.picker.Config ---@field number number issue or PR number ---@field repo string GitHub repository (owner/repo). Defaults to current git repo M.gh_reactions = { layout = { preset = "select", layout = { max_width = 50 } }, title = " Reactions", main = { current = true }, group = true, finder = "gh_reactions", format = "gh_format_reaction", } ---@class snacks.picker.gh.labels.Config: snacks.picker.Config ---@field number number issue or PR number ---@field repo string GitHub repository (owner/repo). Defaults to current git repo M.gh_labels = { layout = { preset = "select", layout = { max_width = 50 } }, title = " Labels", main = { current = true }, group = true, finder = "gh_labels", format = "gh_format_label", } ---@class snacks.picker.gh.actions.Config: snacks.picker.Config ---@field number number issue or PR number ---@field repo string GitHub repository (owner/repo). Defaults to current git repo ---@field type "issue" | "pr" ---@field item? snacks.picker.gh.Item M.gh_actions = { layout = { preset = "select", layout = { max_width = 50 } }, title = " Actions", main = { current = true }, finder = "gh_get_actions", format = "gh_format_action", confirm = "gh_perform_action", } --- Git arguments are use like this: --- * git [] [] --- * cmd may be `status`, `log`, `diff`, etc. ---@class snacks.picker.git.Config: snacks.picker.Config,snacks.picker.git.Args ---@field args? string[] additional arguments to pass to `git` ---@field cmd_args? string[] additional arguments to pass to the `git `` ---@class snacks.picker.git.branches.Config: snacks.picker.git.Config ---@field all? boolean show all branches, including remote M.git_branches = { all = false, finder = "git_branches", format = "git_branch", preview = "git_log", confirm = "git_checkout", win = { input = { keys = { [""] = { "git_branch_add", mode = { "n", "i" } }, [""] = { "git_branch_del", mode = { "n", "i" } }, }, }, }, ---@param picker snacks.Picker on_show = function(picker) for i, item in ipairs(picker:items()) do if item.current then picker.list:view(i) Snacks.picker.actions.list_scroll_center(picker) break end end end, } -- Find git files ---@class snacks.picker.git.files.Config: snacks.picker.git.Config ---@field untracked? boolean show untracked files ---@field submodules? boolean show submodule files M.git_files = { finder = "git_files", show_empty = true, format = "file", untracked = false, submodules = false, } -- Grep in git files ---@class snacks.picker.git.grep.Config: snacks.picker.git.Config ---@field untracked? boolean search in untracked files ---@field submodules? boolean search in submodule files ---@field need_search? boolean require a search pattern ---@field pathspec? string|string[] pathspec pattern(s) ---@field ignorecase? boolean ignore case M.git_grep = { finder = "git_grep", format = "file", untracked = false, need_search = true, submodules = false, show_empty = true, supports_live = true, live = true, } -- Git log ---@class snacks.picker.git.log.Config: snacks.picker.git.Config ---@field follow? boolean track file history across renames ---@field current_file? boolean show current file log ---@field current_line? boolean show current line log ---@field author? string filter commits by author M.git_log = { finder = "git_log", format = "git_log", preview = "git_show", confirm = "git_checkout", supports_live = true, sort = { fields = { "score:desc", "idx" } }, } ---@type snacks.picker.git.log.Config M.git_log_file = { finder = "git_log", format = "git_log", preview = "git_show", current_file = true, follow = true, confirm = "git_checkout", sort = { fields = { "score:desc", "idx" } }, } ---@type snacks.picker.git.log.Config M.git_log_line = { finder = "git_log", format = "git_log", preview = "git_show", current_line = true, follow = true, confirm = "git_checkout", sort = { fields = { "score:desc", "idx" } }, } M.git_stash = { finder = "git_stash", format = "git_stash", preview = "git_stash", confirm = "git_stash_apply", } ---@class snacks.picker.git.status.Config: snacks.picker.git.Config ---@field ignored? boolean show ignored files M.git_status = { finder = "git_status", format = "git_status", preview = "git_status", win = { input = { keys = { [""] = { "git_stage", mode = { "n", "i" } }, [""] = { "git_restore", mode = { "n", "i" }, nowait = true }, }, }, }, } ---@class snacks.picker.git.diff.Config: snacks.picker.git.Config ---@field group? boolean group changes by file (when false, show individual hunks) ---@field staged? boolean show staged changes ---@field base? string base commit/branch/tag to diff against (default: HEAD) M.git_diff = { group = false, finder = "git_diff", format = "git_status", preview = "diff", matcher = { sort_empty = true }, sort = { fields = { "score:desc", "file", "idx" } }, win = { input = { keys = { [""] = { "git_stage", mode = { "n", "i" } }, [""] = { "git_restore", mode = { "n", "i" }, nowait = true }, }, }, }, } ---@class snacks.picker.grep.Config: snacks.picker.proc.Config ---@field cmd? string ---@field hidden? boolean show hidden files ---@field ignored? boolean show ignored files ---@field dirs? string[] directories to search ---@field follow? boolean follow symlinks ---@field glob? string|string[] glob file pattern(s) ---@field ft? string|string[] ripgrep file type(s). See `rg --type-list` ---@field regex? boolean use regex search pattern (defaults to `true`) ---@field buffers? boolean search in open buffers ---@field need_search? boolean require a search pattern ---@field exclude? string[] exclude patterns ---@field args? string[] additional arguments ---@field rtp? boolean search in runtimepath M.grep = { finder = "grep", regex = true, format = "file", show_empty = true, live = true, -- live grep by default supports_live = true, } ---@type snacks.picker.grep.Config|{} M.grep_buffers = { finder = "grep", format = "file", live = true, buffers = true, need_search = false, supports_live = true, } ---@type snacks.picker.grep.Config|{} M.grep_word = { finder = "grep", regex = false, args = { "--word-regexp" }, format = "file", search = function(picker) return picker:word() end, live = false, supports_live = true, } -- Neovim help tags ---@class snacks.picker.help.Config: snacks.picker.Config ---@field lang? string[] defaults to `vim.opt.helplang` M.help = { finder = "help", format = "text", previewers = { file = { ft = "help" }, }, win = { preview = { minimal = true } }, confirm = "help", } M.highlights = { finder = "vim_highlights", format = "hl", preview = "preview", confirm = "close", } ---@class snacks.picker.icons.Config: snacks.picker.Config ---@field icon_sources? string[] list of sources to use --- Custom icon sources can be added here. The key is the source name, --- and the value is the file path or URL to load icons from. --- The file should be a JSON array of: --- `{[1]:string, [2]:string}|{icon:string, name:string, category:string}` --- The format is compatible with https://github.com/nvim-telescope/telescope-symbols.nvim ---@field custom_sources? table additional icon sources `table` M.icons = { main = { current = true }, finder = "icons", format = "icon", layout = { preset = "vscode" }, confirm = "put", } M.jumps = { finder = "vim_jumps", format = "file", main = { current = true }, } ---@class snacks.picker.keymaps.Config: snacks.picker.Config ---@field global? boolean show global keymaps ---@field local? boolean show buffer keymaps ---@field plugs? boolean show plugin keymaps ---@field modes? string[] M.keymaps = { finder = "vim_keymaps", format = "keymap", preview = "preview", global = true, plugs = false, ["local"] = true, modes = { "n", "v", "x", "s", "o", "i", "c", "t" }, ---@param picker snacks.Picker confirm = function(picker, item) picker:norm(function() if item then picker:close() vim.api.nvim_input(item.item.lhs) end end) end, actions = { toggle_global = function(picker) picker.opts.global = not picker.opts.global picker:find() end, toggle_buffer = function(picker) picker.opts["local"] = not picker.opts["local"] picker:find() end, }, win = { input = { keys = { [""] = { "toggle_global", mode = { "n", "i" }, desc = "Toggle Global Keymaps" }, [""] = { "toggle_buffer", mode = { "n", "i" }, desc = "Toggle Buffer Keymaps" }, }, }, }, } --- Search for a lazy.nvim plugin spec M.lazy = { finder = "lazy_spec", pattern = "'", } -- Search lines in the current buffer ---@class snacks.picker.lines.Config: snacks.picker.Config ---@field buf? number M.lines = { finder = "lines", format = "lines", layout = { preview = "main", preset = "ivy", }, jump = { match = true }, -- allow any window to be used as the main window main = { current = true }, ---@param picker snacks.Picker on_show = function(picker) local cursor = vim.api.nvim_win_get_cursor(picker.main) local info = vim.api.nvim_win_call(picker.main, vim.fn.winsaveview) picker.list:view(cursor[1], info.topline) picker:show_preview() end, sort = { fields = { "score:desc", "idx" } }, } -- Loclist ---@type snacks.picker.qf.Config M.loclist = { finder = "qf", format = "file", qf_win = 0, main = { current = true }, } ---@class snacks.picker.lsp.Config: snacks.picker.Config ---@field include_current? boolean default false ---@field unique_lines? boolean include only locations with unique lines ---@field filter? snacks.picker.filter.Config ---@class snacks.picker.lsp.config.Config: snacks.picker.Config ---@field installed? boolean only show installed servers ---@field configured? boolean only show configured servers (setup with lspconfig) ---@field attached? boolean|number only show attached servers. When `number`, show only servers attached to that buffer (can be 0) M.lsp_config = { finder = "lsp.config#find", format = "lsp.config#format", preview = "lsp.config#preview", confirm = "close", sort = { fields = { "score:desc", "attached_buf", "attached", "enabled", "installed", "name" } }, matcher = { sort_empty = true }, } -- LSP declarations ---@type snacks.picker.lsp.Config M.lsp_declarations = { finder = "lsp_declarations", format = "file", include_current = false, auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } -- LSP definitions ---@type snacks.picker.lsp.Config M.lsp_definitions = { finder = "lsp_definitions", format = "file", include_current = false, auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } -- LSP implementations ---@type snacks.picker.lsp.Config M.lsp_implementations = { finder = "lsp_implementations", format = "file", include_current = false, auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } -- LSP incoming calls ---@type snacks.picker.lsp.Config M.lsp_incoming_calls = { finder = "lsp_incoming_calls", format = "lsp_symbol", include_current = false, workspace = true, -- this ensures the file is included in the formatter auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } -- LSP outgoing calls ---@type snacks.picker.lsp.Config M.lsp_outgoing_calls = { finder = "lsp_outgoing_calls", format = "lsp_symbol", include_current = false, workspace = true, -- this ensures the file is included in the formatter auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } -- LSP references ---@class snacks.picker.lsp.references.Config: snacks.picker.lsp.Config ---@field include_declaration? boolean default true M.lsp_references = { finder = "lsp_references", format = "file", include_declaration = true, include_current = false, auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } -- LSP document symbols ---@class snacks.picker.lsp.symbols.Config: snacks.picker.Config ---@field tree? boolean show symbol tree ---@field keep_parents? boolean keep parent symbols when filtering ---@field filter table? symbol kind filter ---@field workspace? boolean show workspace symbols M.lsp_symbols = { finder = "lsp_symbols", format = "lsp_symbol", tree = true, filter = { default = { "Class", "Constructor", "Enum", "Field", "Function", "Interface", "Method", "Module", "Namespace", "Package", "Property", "Struct", "Trait", }, -- set to `true` to include all symbols markdown = true, help = true, -- you can specify a different filter for each filetype lua = { "Class", "Constructor", "Enum", "Field", "Function", "Interface", "Method", "Module", "Namespace", -- "Package", -- remove package since luals uses it for control flow structures "Property", "Struct", "Trait", }, }, } ---@type snacks.picker.lsp.symbols.Config M.lsp_workspace_symbols = vim.tbl_extend("force", {}, M.lsp_symbols, { workspace = true, tree = false, supports_live = true, live = true, -- live by default }) -- LSP type definitions ---@type snacks.picker.lsp.Config M.lsp_type_definitions = { finder = "lsp_type_definitions", format = "file", include_current = false, auto_confirm = true, jump = { tagstack = true, reuse_win = true }, } M.man = { finder = "system_man", format = "man", preview = "man", confirm = function(picker, item, action) ---@cast action snacks.picker.jump.Action picker:close() if item then vim.schedule(function() local cmd = "Man " .. item.ref ---@type string if action.cmd == "vsplit" then cmd = "vert " .. cmd elseif action.cmd == "tab" then cmd = "tab " .. cmd end vim.cmd(cmd) end) end end, } ---@class snacks.picker.marks.Config: snacks.picker.Config ---@field global? boolean show global marks ---@field local? boolean show buffer marks M.marks = { finder = "vim_marks", format = "file", global = true, ["local"] = true, win = { input = { keys = { [""] = { "mark_delete", mode = { "n", "i" } }, }, }, }, } ---@class snacks.picker.notifications.Config: snacks.picker.Config ---@field filter? snacks.notifier.level|fun(notif: snacks.notifier.Notif): boolean M.notifications = { finder = "snacks_notifier", format = "notification", preview = "preview", formatters = { severity = { level = true } }, confirm = "close", } -- List all available sources M.pickers = { finder = "meta_pickers", format = "text", confirm = function(picker, item) picker:close() if item then vim.schedule(function() Snacks.picker(item.text) end) end end, } M.picker_actions = { finder = "meta_actions", format = "text", } M.picker_format = { finder = "meta_format", format = "text", } M.picker_layouts = { finder = "meta_layouts", format = "text", on_change = function(picker, item) vim.schedule(function() picker:set_layout(item.text) end) end, } M.picker_preview = { finder = "meta_preview", format = "text", } -- Open recent projects ---@class snacks.picker.projects.Config: snacks.picker.Config ---@field filter? snacks.picker.filter.Config ---@field dev? string|string[] top-level directories containing multiple projects (sub-folders that contains a root pattern) ---@field projects? string[] list of project directories ---@field patterns? string[] patterns to detect project root directories ---@field recent? boolean include project directories of recent files ---@field max_depth? number maximum depth to search in dev directories (default: 2) M.projects = { finder = "recent_projects", format = "file", dev = { "~/dev", "~/projects" }, confirm = "load_session", patterns = { ".git", "_darcs", ".hg", ".bzr", ".svn", "package.json", "Makefile" }, recent = true, matcher = { frecency = true, -- use frecency boosting sort_empty = true, -- sort even when the filter is empty cwd_bonus = false, }, sort = { fields = { "score:desc", "idx" } }, win = { preview = { minimal = true }, input = { keys = { -- every action will always first change the cwd of the current tabpage to the project [""] = { { "tcd", "picker_explorer" }, mode = { "n", "i" } }, [""] = { { "tcd", "picker_files" }, mode = { "n", "i" } }, [""] = { { "tcd", "picker_grep" }, mode = { "n", "i" } }, [""] = { { "tcd", "picker_recent" }, mode = { "n", "i" }, nowait = true }, [""] = { { "tcd" }, mode = { "n", "i" } }, [""] = { function(picker) vim.cmd("tabnew") Snacks.notify("New tab opened") picker:close() Snacks.picker.projects() end, mode = { "n", "i" }, }, }, }, }, } -- Quickfix list ---@type snacks.picker.qf.Config M.qflist = { finder = "qf", format = "file", } -- Find recent files ---@class snacks.picker.recent.Config: snacks.picker.Config ---@field filter? snacks.picker.filter.Config M.recent = { finder = "recent_files", format = "file", filter = { paths = { [vim.fn.stdpath("data")] = false, [vim.fn.stdpath("cache")] = false, [vim.fn.stdpath("state")] = false, }, }, } -- Neovim registers M.registers = { finder = "vim_registers", main = { current = true }, format = "register", preview = "preview", confirm = { "copy", "close" }, } -- Special picker that resumes the last picker M.resume = {} -- Open or create scratch buffers M.scratch = { finder = "scratch", format = "scratch_format", confirm = "scratch_open", win = { input = { keys = { [""] = { "scratch_delete", mode = { "n", "i" } }, [""] = { "scratch_new", mode = { "n", "i" } }, }, }, }, } -- Neovim search history ---@type snacks.picker.history.Config M.search_history = { finder = "vim_history", name = "search", format = "text", preview = "none", main = { current = true }, layout = { preset = "vscode" }, confirm = "search", formatters = { text = { ft = "regex" } }, } --- Config used by `vim.ui.select`. --- Not meant to be used directly. ---@class snacks.picker.select.Config: snacks.picker.Config ---@field kinds? table custom snacks picker configs for specific `vim.ui.select` kinds M.select = { items = {}, -- these are set dynamically main = { current = true }, layout = { preset = "select" }, } ---@class snacks.picker.smart.Config: snacks.picker.Config ---@field finders? string[] list of finders to use ---@field filter? snacks.picker.filter.Config M.smart = { multi = { "buffers", "recent", "files" }, format = "file", -- use `file` format for all sources matcher = { cwd_bonus = true, -- boost cwd matches frecency = true, -- use frecency boosting sort_empty = true, -- sort even when the filter is empty }, transform = "unique_file", } M.spelling = { finder = "vim_spelling", format = "text", main = { current = true }, layout = { preset = "vscode" }, confirm = "item_action", } -- Search tags file ---@class snacks.picker.tags.Config: snacks.picker.Config M.tags = { workspace = true, -- search tags in the workspace finder = "vim_tags", format = "lsp_symbol", } ---@class snacks.picker.treesitter.Config: snacks.picker.Config ---@field filter table? symbol kind filter ---@field tree? boolean show symbol tree M.treesitter = { finder = "treesitter_symbols", format = "lsp_symbol", tree = true, filter = { default = { "Class", "Enum", "Field", "Function", "Method", "Module", "Namespace", "Struct", "Trait", }, -- set to `true` to include all symbols markdown = true, help = true, }, } ---@class snacks.picker.undo.Config: snacks.picker.Config ---@field diff? vim.text.diff.Opts M.undo = { finder = "vim_undo", format = "undo", preview = "diff", confirm = "item_action", win = { preview = { wo = { number = false, relativenumber = false, signcolumn = "no" } }, input = { keys = { [""] = { "yank_add", mode = { "n", "i" } }, [""] = { "yank_del", mode = { "n", "i" } }, }, }, }, actions = { yank_add = { action = "yank", field = "added_lines" }, yank_del = { action = "yank", field = "removed_lines" }, }, icons = { tree = { last = "┌╴" } }, -- the tree is upside down diff = { ctxlen = 4, ignore_cr_at_eol = true, ignore_whitespace_change_at_eol = true, indent_heuristic = true, }, } -- Open a project from zoxide M.zoxide = { finder = "files_zoxide", format = "file", confirm = "load_session", win = { preview = { minimal = true, }, }, } return M ================================================ FILE: lua/snacks/picker/core/_health.lua ================================================ local M = {} ---@private function M.health() local config = Snacks.picker.config.get() if Snacks.config.get("picker", {}).enabled and config.ui_select then if vim.ui.select == Snacks.picker.select then Snacks.health.ok("`vim.ui.select` is set to `Snacks.picker.select`") else Snacks.health.error("`vim.ui.select` is not set to `Snacks.picker.select`") end else Snacks.health.warn("`vim.ui.select` for `Snacks.picker` is not enabled") end Snacks.health.has_lang("regex") Snacks.health.have_tool("git") local have_rg = Snacks.health.have_tool("rg") if not have_rg then Snacks.health.error("'rg' is required for `Snacks.picker.grep()`") else Snacks.health.ok("`Snacks.picker.grep()` is available") end local have_fd, version_fd = Snacks.health.have_tool({ { cmd = { "fd", "fdfind" }, version = "v8.4" }, }) local have_find = have_fd or (jit.os:find("Windows") == nil and Snacks.health.have_tool({ { cmd = "find", version = false }, })) if have_rg or have_fd or have_find then Snacks.health.ok("`Snacks.picker.files()` is available") else Snacks.health.error("'rg', 'fd' or 'find' is required for `Snacks.picker.files()`") end if not have_fd or not version_fd then Snacks.health.error("'fd' `v8.4` is required for searching with `Snacks.picker.explorer()`") else Snacks.health.ok("`Snacks.picker.explorer()` is available") end local ok = pcall(require, "snacks.picker.util.db") if ok then Snacks.health.ok("`SQLite3` is available") else Snacks.health.warn("`SQLite3` is not available. Frecency and history will be stored in a file instead.") end end return M ================================================ FILE: lua/snacks/picker/core/actions.lua ================================================ local M = {} ---@alias snacks.picker.Action.fn fun(self: snacks.Picker, item?:snacks.picker.Item, action?:snacks.picker.Action):(boolean|string?) ---@alias snacks.picker.Action.spec.one string|snacks.picker.Action|snacks.picker.Action.fn|{action?:snacks.picker.Action.spec.one} ---@alias snacks.picker.Action.spec snacks.picker.Action.spec.one|snacks.picker.Action.spec.one[] ---@class snacks.picker.Action ---@field action snacks.picker.Action.fn ---@field desc? string ---@field name? string ---@field [string] any additional fields ---@param picker snacks.Picker function M.get(picker) local ref = picker:ref() ---@type table local ret = {} setmetatable(ret, { ---@param t table ---@param k string __index = function(t, k) if type(k) ~= "string" then return end t[k] = M.wrap(k, ref, k) or false return rawget(t, k) end, }) return ret end ---@param action snacks.picker.Action.spec ---@param ref snacks.Picker.ref ---@param name? string function M.wrap(action, ref, name) local picker = ref() if not picker then return end action = M.resolve(action, picker, name) action.name = name ---@type snacks.win.Action return { name = name, action = function() local p = ref() if not p then return end return action.action(p, p:current(), action) end, desc = action.desc, } end ---@param action snacks.picker.Action.spec ---@param picker snacks.Picker ---@param name? string ---@param stack? string[] ---@return snacks.picker.Action function M.resolve(action, picker, name, stack) stack = stack or {} if not action then assert(name, "Missing action without name") local fn, desc = picker.input.win[name], name return { action = function(p) if not fn then return name end fn(p.input.win) end, desc = desc, } elseif type(action) == "string" then if vim.tbl_contains(stack, action) then if action == "confirm" or name == "confirm" then action = "jump" else Snacks.notify.error("Circular action reference for `" .. action .. "`:\n- " .. table.concat(stack, "\n- ")) return {} end end stack[#stack + 1] = action return M.resolve(Snacks.picker.config.action(picker, action), picker, action, stack) elseif type(action) == "table" and svim.islist(action) then local actions = vim.tbl_map(function(a) return M.resolve(a, picker, nil, stack) end, action) ---@type snacks.picker.Action return { action = function(p, i) for _, a in ipairs(actions) do a.action(p, i, a) end end, desc = table.concat( vim.tbl_map(function(a) return a.desc or a.name or "unknown" end, actions), ", " ), } elseif type(action) == "table" then if type(action.action) ~= "function" then action = vim.deepcopy(action) action.action = M.resolve(action.action, picker, nil, stack).action end ---@cast action snacks.picker.Action action.desc = action.desc or name or nil return action end assert(type(action) == "function", "Invalid action") return { action = action, desc = name or nil, } end return M ================================================ FILE: lua/snacks/picker/core/filter.lua ================================================ ---@class snacks.picker.Filter ---@field pattern string Pattern used to filter items by the matcher ---@field search string Initial search string used by finders ---@field buf? number ---@field file? string ---@field cwd string ---@field all boolean ---@field paths {path:string, want:boolean}[] ---@field opts snacks.picker.filter.Config ---@field current_buf number ---@field current_win number ---@field source_id? number ---@field meta table local M = {} M.__index = M ---@param picker snacks.Picker function M.new(picker) local opts = picker.opts ---@type snacks.picker.Config|{filter?:snacks.picker.filter.Config} local self = setmetatable({}, M) self.current_buf = vim.api.nvim_get_current_buf() self.current_win = vim.api.nvim_get_current_win() self.meta = {} local function gets(v) return type(v) == "function" and v(picker) or v or "" --[[@as string]] end self.pattern = gets(opts.pattern) self.search = gets(opts.search) self:init(opts) return self end ---@param opts snacks.picker.Config|{filter?:snacks.picker.filter.Config} function M:init(opts) self.opts = opts.filter or {} self.all = not self.opts or not (self.opts.cwd or self.opts.buf or self.opts.paths or self.opts.filter) self.paths = {} local cwd = self.opts and self.opts.cwd self.cwd = type(cwd) == "string" and cwd or opts.cwd or vim.fn.getcwd(0) self.cwd = svim.fs.normalize(self.cwd --[[@as string]], { _fast = true }) if not self.all and self.opts then self.buf = self.opts.buf == true and 0 or self.opts.buf --[[@as number?]] self.buf = self.buf == 0 and M.current_buf or self.buf self.file = self.buf and svim.fs.normalize(vim.api.nvim_buf_get_name(self.buf), { _fast = true }) or nil for path, want in pairs(self.opts.paths or {}) do table.insert(self.paths, { path = svim.fs.normalize(path), want = want }) end end return self end function M:is_empty() return vim.trim(self.pattern) == "" and vim.trim(self.search) == "" end ---@param cwd string function M:set_cwd(cwd) self.cwd = cwd self.cwd = svim.fs.normalize(self.cwd --[[@as string]], { _fast = true }) end ---@param opts? {trim?:boolean} ---@return snacks.picker.Filter function M:clone(opts) local ret = setmetatable({}, { __index = self, __call = M.filter, }) if opts and opts.trim then ret.pattern = vim.trim(self.pattern) ret.search = vim.trim(self.search) else ret.pattern = self.pattern ret.search = self.search end return ret end ---@param item snacks.picker.finder.Item):boolean function M:match(item) if self.all then return true end if self.opts.filter and not self.opts.filter(item, self) then return false end if self.buf and (item.buf ~= self.buf) and (item.file ~= self.file) then return false end if not (self.opts.cwd or self.opts.paths) then return true end local path = Snacks.picker.util.path(item) if not path then return false end if self.opts.cwd and path ~= self.cwd and not path:find(self.cwd .. "/", 1, true) then return false end if self.opts.paths then for _, p in ipairs(self.paths) do if (path:sub(1, #p.path) == p.path) ~= p.want then return false end end end return true end ---@param items snacks.picker.finder.Item[] function M:filter(items) if self.all then return items end local ret = {} ---@type snacks.picker.finder.Item[] for _, item in ipairs(items) do if self:match(item) then table.insert(ret, item) end end return ret end M.__call = M.filter return M ================================================ FILE: lua/snacks/picker/core/finder.lua ================================================ local Async = require("snacks.picker.util.async") ---@class snacks.picker.Finder ---@field _find snacks.picker.finder ---@field task snacks.picker.Async ---@field items snacks.picker.finder.Item[] ---@field filter? snacks.picker.Filter local M = {} M.__index = M ---@class snacks.picker.finder.ctx ---@field picker snacks.Picker ---@field filter snacks.picker.Filter ---@field async snacks.picker.Async ---@field meta table ---@field _opts? snacks.picker.Config local Ctx = {} Ctx.__index = Ctx ---@param picker snacks.Picker ---@param filter snacks.picker.Filter function Ctx.new(picker, filter) local notified = false local self = setmetatable({}, Ctx) self.picker = picker self.filter = filter self.meta = {} self.async = setmetatable({}, { __index = function() if not notified then notified = true Snacks.notify.warn("You can only use the `async` object in async functions") end end, }) return self end ---@param opts? snacks.picker.Config ---@return snacks.picker.finder.ctx function Ctx:clone(opts) return setmetatable({ _opts = opts }, { __index = self }) end ---@generic T: snacks.picker.Config ---@param opts T ---@return T function Ctx:opts(opts) self._opts = setmetatable(opts or {}, { __index = self._opts or self.picker.opts }) return self._opts end function Ctx:cwd() return self.filter.cwd end function Ctx:git_root() return Snacks.git.get_root(self:cwd()) or self:cwd() end ---@alias snacks.picker.finder.async fun(cb:async fun(item:snacks.picker.finder.Item)) ---@alias snacks.picker.finder.result snacks.picker.finder.Item[] | snacks.picker.finder.async ---@alias snacks.picker.finder fun(opts: snacks.picker.Config, ctx: snacks.picker.finder.ctx): snacks.picker.finder.result ---@alias snacks.picker.finder.multi (snacks.picker.finder|string)[] local YIELD_FIND = 1 -- ms ---@param find snacks.picker.finder function M.new(find) local self = setmetatable({}, M) self._find = find self.task = Async.nop() self.items = {} return self end function M:running() return self.task:running() end function M:abort() self.task:abort() end function M:count() return #self.items end function M:close() self.task:abort() self.task = Async.nop() self._find = function() return {} end end ---@param picker snacks.Picker function M:ctx(picker) return Ctx.new(picker, self.filter) end ---@param filter snacks.picker.Filter ---@return boolean changed function M:init(filter) local ret = not (self.filter and (self.filter.search == filter.search and self.filter.source_id == filter.source_id)) self.filter = filter return ret end ---@param picker snacks.Picker function M:run(picker) local default_score = require("snacks.picker.core.matcher").DEFAULT_SCORE self.task:abort() self.items = {} local yield ---@type fun() local ctx = self:ctx(picker) local finder = self._find(picker.opts, ctx) local limit = (picker.opts.live and picker.opts.limit_live or picker.opts.limit) or math.huge ---@param item snacks.picker.finder.Item local function add(item) item.idx, item.score = #self.items + 1, default_score self.items[item.idx] = item end if picker.opts.transform then local transform = Snacks.picker.config.transform(picker.opts) ---@param item snacks.picker.finder.Item function add(item) local t = transform(item, ctx) item = type(t) == "table" and t or item if t ~= false then item.idx, item.score = #self.items + 1, default_score self.items[item.idx] = item end end end -- PERF: if finder is a table, we can skip the async part if type(finder) == "table" then local items = finder --[[@as snacks.picker.finder.Item[] ]] for _, item in ipairs(items) do add(item) end return end local running = true collectgarbage("stop") -- moar speed ---@cast finder snacks.picker.finder.async ---@diagnostic disable-next-line: await-in-sync self.task = Async.new(function() ctx.async = Async.running() ---@async finder(function(item) if #self.items >= limit then return self.task:abort() end if not running then Snacks.debug.backtrace({ "Finder yielded after done. This is a bug.", ("- aborted: `%s`"):format(self.task:aborted() or false), "", "# Backtrace", }, { level = vim.log.levels.ERROR, title = "Snacks Picker Finder", }) return end add(item) picker.matcher.task:resume() yield = yield or Async.yielder(YIELD_FIND) yield() end) end):on("done", function() collectgarbage("restart") if not self.task:aborted() then picker.matcher.task:resume() picker:update() end running = false end) end ---@param finders snacks.picker.finder[] ---@return snacks.picker.finder function M.multi(finders) return function(opts, ctx) local filter = ctx.filter ---@type snacks.picker.finder.result[] local results = {} local need_async = false for source_id, finder in ipairs(finders) do if filter.source_id == nil or filter.source_id == source_id then results[#results + 1] = finder(opts, ctx) or {} else results[#results + 1] = {} end need_async = need_async or type(results[#results]) == "function" end ---@async ---@type snacks.picker.finder.async local function collect(cb) for source_id, find in ipairs(results) do if type(find) == "table" then for _, item in ipairs(find) do item.source_id = source_id cb(item) end else ---@async find(function(item) item.source_id = source_id cb(item) end) end end end if need_async then return collect end -- not async, so collect all items local items = {} ---@type snacks.picker.finder.Item[] collect(function(item) items[#items + 1] = item end) return items end end return M ================================================ FILE: lua/snacks/picker/core/frecency.lua ================================================ -- Frecency based on exponential decay. Roughly based on: -- https://wiki.mozilla.org/User:Jesse/NewFrecency?title=User:Jesse/NewFrecency ---@class snacks.picker.Frecency ---@field now number ---@field cache table local M = {} M.__index = M local uv = vim.uv or vim.loop local store_file = vim.fn.stdpath("data") .. "/snacks/picker-frecency" local HALF_LIFE = 30 * 24 * 3600 -- Half-life = 30 days (in seconds) local LAMBDA = math.log(2) / HALF_LIFE -- λ = ln(2) / half_life local SEED_VALUE = 1 local DEFAULT_VALUE = 1 local MAX_STORE_SIZE = 10000 ---@class snacks.picker.frecency.Store ---@field set fun(self:snacks.picker.frecency.Store, key:string, value:number) ---@field get fun(self:snacks.picker.frecency.Store, key:string):number ---@field close fun(self:snacks.picker.frecency.Store) ---@field get_all fun(self:snacks.picker.frecency.Store):table -- Global store of frecency deadlinesl ---@type snacks.picker.frecency.Store? M.store = nil function M.setup() if not pcall(function() local db = require("snacks.picker.util.db").new(store_file .. ".sqlite3", "number") M.store = db --[[@as snacks.picker.frecency.Store]] -- Cleanup old entries local cutoff = db:prepare("SELECT value FROM data ORDER BY value DESC LIMIT 1 OFFSET ?;") if cutoff:exec({ MAX_STORE_SIZE - 1 }) == 100 then -- 100 == SQLITE_ROW db:prepare("DELETE FROM data WHERE value < ?;"):exec({ cutoff:col("number") }) end end) then M.store = require("snacks.picker.util.kv").new(store_file .. ".dat", { max_size = MAX_STORE_SIZE }) --[[@as snacks.picker.frecency.Store]] end local group = vim.api.nvim_create_augroup("snacks_picker_frecency", {}) vim.api.nvim_create_autocmd("ExitPre", { group = group, callback = function() if M.store then M.store:close() M.store = nil end end, }) vim.api.nvim_create_autocmd({ "BufWinEnter" }, { group = group, callback = function(ev) local current_win = vim.api.nvim_get_current_win() if vim.api.nvim_win_get_config(current_win).relative ~= "" then return end M.visit_buf(ev.buf) end, }) -- Visit existing buffers for _, buf in ipairs(vim.api.nvim_list_bufs()) do M.visit_buf(buf) end end function M.new() local self = setmetatable({}, M) self.now = os.time() if not M.store then M.setup() end self.cache = M.store:get_all() return self end --- Convert from a current score s into a "deadline date" --- t = now() + (ln(s) / λ) ---@param score number function M:to_deadline(score) return self.now + (math.log(score) / LAMBDA) end --- Convert from a "deadline date" back into a current score --- s = e^(λ * (deadline - now)) function M:to_score(deadline) return math.exp(LAMBDA * (deadline - self.now)) end --- Get the current frecency score for an item. --- If the item is not tracked yet, it will seed it --- based on the last used time or last modified time. ---@param item snacks.picker.Item ---@param opts? {seed?: boolean} function M:get(item, opts) opts = opts or {} local path = Snacks.picker.util.path(item) if not path then return 0 end if item.dir then -- frecency of a directory is the sum of frecencies of all files in it local score = 0 local prefix = path .. "/" for k, v in pairs(self.cache) do if k:find(prefix, 1, true) == 1 then score = score + self:to_score(v) end end return score end local deadline = self.cache[path] if not deadline then return opts.seed ~= false and self:seed(item) or 0 end return self:to_score(deadline) end ---@param item snacks.picker.Item ---@param value? number function M:seed(item, value) -- only seed recent files or items with buffer info if not (item.info or item.recent) then return 0 end local last_used = type(item.info) == "table" and item.info.lastused or nil local path = Snacks.picker.util.path(item) if not path then return 0 end if not last_used then local stat = uv.fs_stat(path) last_used = stat and stat.mtime.sec end if not last_used then return 0 end -- Calculate decayed single-visit score local dt = self.now - last_used -- in seconds return (value or SEED_VALUE) * math.exp(-LAMBDA * dt) end --- Add a "visit" to the item. --- If the item doesn't exist, it is created with initial score = `visit_value`. --- Otherwise, the new score is old_score + visit_value. ---@param item snacks.picker.Item ---@param value? number @the "points" to add (e.g. typed=2, clicked=1, etc.) function M:visit(item, value) local path = Snacks.picker.util.path(item) if not path then return end local score = self:get(item, { seed = false }) + (value or DEFAULT_VALUE) self.store:set(path, self:to_deadline(score)) end ---@param buf number ---@param value? number function M.visit_buf(buf, value) if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" or not vim.bo[buf].buflisted then return end local file = vim.api.nvim_buf_get_name(buf) if file == "" or not vim.uv.fs_stat(file) then return end local frecency = M.new() frecency:visit({ text = "", idx = 1, score = 0, file = file, buf = buf, info = vim.fn.getbufinfo(buf)[1], }, value) return true end return M ================================================ FILE: lua/snacks/picker/core/input.lua ================================================ ---@class snacks.picker.input ---@field win snacks.win ---@field mode? string ---@field totals string ---@field picker snacks.Picker ---@field filter snacks.picker.Filter ---@field paused? boolean local M = {} M.__index = M local ns = vim.api.nvim_create_namespace("snacks.picker.input") ---@param picker snacks.Picker function M.new(picker) local self = setmetatable({}, M) self.totals = "" self.picker = picker self.filter = require("snacks.picker.core.filter").new(picker) self.mode = vim.fn.mode() picker.matcher:init(self.filter.pattern) self.win = Snacks.win(Snacks.win.resolve(picker.opts.win.input, { show = false, enter = false, height = 1, ft = "regex", on_buf = function(win) -- HACK: this is needed to prevent Neovim from stopping insert mode, -- for any other picker input we are leaving. local buf = vim.api.nvim_get_current_buf() if buf ~= win.buf and vim.bo[buf].filetype == "snacks_picker_input" then vim.bo[buf].buftype = "nofile" end vim.fn.prompt_setprompt(win.buf, "") vim.bo[win.buf].modified = false local text = picker.opts.live and self.filter.search or self.filter.pattern vim.api.nvim_buf_set_lines(win.buf, 0, -1, false, { text }) vim.bo[win.buf].modified = false end, on_win = function() self:highlights() end, bo = { filetype = "snacks_picker_input", buftype = "prompt", }, wo = { statuscolumn = self:statuscolumn(), cursorline = false, winhighlight = Snacks.picker.highlight.winhl("SnacksPickerInput"), }, })) self.win:on("BufEnter", function() vim.bo[self.win.buf].buftype = "prompt" if vim.fn.mode() == "t" then vim.schedule(function() vim.cmd("startinsert!") end) else vim.cmd("startinsert!") end end, { buf = true }) local ref = Snacks.util.ref(self) self.win:on( { "TextChangedI", "TextChanged" }, Snacks.util.throttle(function() local input = ref() if not input or not input.win:valid() then return end vim.bo[input.win.buf].modified = false -- only one line -- Can happen when someone pastes a multiline string if vim.api.nvim_buf_line_count(input.win.buf) > 1 then local line = vim.trim(input.win:text():gsub("\n", " ")) vim.api.nvim_buf_set_lines(input.win.buf, 0, -1, false, { line }) vim.api.nvim_win_set_cursor(input.win.win, { 1, #line + 1 }) end vim.bo[input.win.buf].modified = false local pattern = input:get() if input.picker.opts.live then input.filter.search = pattern else input.filter.pattern = pattern end vim.schedule(function() input.picker:find({ refresh = false }) end) end, { ms = picker.opts.live and 200 or 30 }), { buf = true } ) return self end function M:highlights() local m = vim.fn.matchadd vim.api.nvim_win_call(self.win.win, function() m("@punctuation.delimiter", "\\v(^|\\s|:|\\!)\\zs['^]") m("@punctuation.delimiter", "\\v['$]\\ze(\\s|$)") m("DiagnosticWarn", "\\v(^|\\s|:)\\zs\\!") m("@keyword", "\\v(^|\\s)\\zs\\w+:") m("@operator", "\\v\\s\\zs\\|\\ze\\s") end) end function M:close() self.win:destroy() self.picker = nil -- needed for garbage collection of the picker end function M:stopinsert() -- only stop insert mode if needed if not vim.fn.mode():find("^i") then return end local buf = vim.api.nvim_get_current_buf() -- if the other buffer is a prompt, then don't stop insert mode if buf ~= self.win.buf and vim.bo[buf].buftype == "prompt" then return end vim.cmd("stopinsert") end function M:statuscolumn() local parts = {} ---@type string[] local function add(str, hl) if str then parts[#parts + 1] = ("%%#%s#%s%%*"):format(hl, str:gsub("%%", "%%")) end end local pattern = self.picker.opts.live and self.filter.pattern or self.filter.search if pattern ~= "" then if #pattern > 20 then pattern = Snacks.picker.util.truncate(pattern, 20) end add(pattern, "SnacksPickerInputSearch") end add(self.picker.opts.prompt or " ", "SnacksPickerPrompt") return table.concat(parts, " ") end function M:update() if not self.win:valid() then return end local sc = self:statuscolumn() if self.win.opts.wo.statuscolumn ~= sc then self.win.opts.wo.statuscolumn = sc Snacks.util.wo(self.win.win, { statuscolumn = sc }) end local line = {} ---@type snacks.picker.Highlight[] if self.picker:is_active() and self.spinner ~= false then line[#line + 1] = { Snacks.util.spinner(), "SnacksPickerSpinner" } line[#line + 1] = { " " } end local selected = #self.picker.list.selected if selected > 0 then line[#line + 1] = { ("(%d)"):format(selected), "SnacksPickerTotals" } line[#line + 1] = { " " } end line[#line + 1] = { ("%d/%d"):format(self.picker.list:count(), #self.picker.finder.items), "SnacksPickerTotals" } line[#line + 1] = { " " } local totals = table.concat(vim.tbl_map(function(v) return v[1] end, line)) if self.totals == totals then return end self.totals = totals vim.api.nvim_buf_set_extmark(self.win.buf, ns, 0, 0, { id = 999, virt_text = line, virt_text_pos = "right_align", }) end function M:get() return self.win:line() end function M:pause(ms) self.paused = true vim.defer_fn(function() self.paused = false self:update() end, ms or 100) end ---@param pattern? string ---@param search? string function M:set(pattern, search) self.filter.pattern = pattern or self.filter.pattern self.filter.search = search or self.filter.search vim.api.nvim_buf_set_lines(self.win.buf, 0, -1, false, { self.picker.opts.live and self.filter.search or self.filter.pattern, }) vim.bo[self.win.buf].modified = false vim.api.nvim_win_set_cursor(self.win.win, { 1, #self:get() + 1 }) self.totals = "" self.win.opts.wo.statuscolumn = "" self:update() self.picker:update_titles() end return M ================================================ FILE: lua/snacks/picker/core/list.lua ================================================ ---@class snacks.picker.list ---@field picker snacks.Picker ---@field items snacks.picker.Item[] ---@field top number ---@field cursor number ---@field win snacks.win ---@field dirty boolean ---@field state snacks.picker.list.State ---@field paused boolean ---@field topk snacks.picker.MinHeap ---@field _current? snacks.picker.Item ---@field did_preview? boolean ---@field reverse? boolean ---@field selected snacks.picker.Item[] ---@field selected_map table ---@field matcher snacks.picker.Matcher matcher for formatting list items ---@field matcher_regex snacks.picker.Matcher matcher for formatting list items ---@field target? {cursor: number, top?: number} ---@field visible snacks.picker.Item[] local M = {} M.__index = M ---@class snacks.picker.list.State ---@field height number ---@field scrolloff number ---@field scroll number ---@field mousescroll number local ns = vim.api.nvim_create_namespace("snacks.picker.list") local function minmax(value, min, max) return math.max(min, math.min(value, max)) end local SCROLL_WHEEL_UP = Snacks.util.keycode("") local SCROLL_WHEEL_DOWN = Snacks.util.keycode("") ---@type table local lists = setmetatable({}, { __mode = "v" }) local stats = { render = 0, render_full = 0 } -- track mouse scrolling vim.on_key(function(key, typed) key = typed or key if key ~= SCROLL_WHEEL_UP and key ~= SCROLL_WHEEL_DOWN then return end local up = key == SCROLL_WHEEL_UP local mouse_win = vim.fn.getmousepos().winid local list = lists[mouse_win] if list and list.win:valid() then vim.schedule(function() if list and list.win:valid() then list:scroll((up and -1 or 1) * list.state.mousescroll) end end) return "" -- on Neovim 0.11, this will prevent the default scroll end end) ---@param picker snacks.Picker function M.new(picker) local self = setmetatable({}, M) self.reverse = picker.resolved_layout.reverse self.picker = picker self.selected = {} self.selected_map = {} self.matcher = require("snacks.picker.core.matcher").new(picker.opts.matcher) self.matcher_regex = require("snacks.picker.core.matcher").new({ regex = true }) local win_opts = Snacks.win.resolve(picker.opts.win.list, { show = false, enter = false, on_win = function() self:on_show() lists[ self.win.win --[[@as number]] ] = self end, minimal = true, bo = { modifiable = false, filetype = "snacks_picker_list" }, wo = { foldenable = false, foldmethod = "manual", cursorline = false, winhighlight = Snacks.picker.highlight.winhl("SnacksPickerList", { CursorLine = "Visual" }), linebreak = true, breakindent = true, }, }) self.visible = {} self.win = Snacks.win(win_opts) self.top, self.cursor = 1, 1 self.items = {} self.state = { height = 0, scrolloff = 0, scroll = 0, mousescroll = 1 } self.dirty = true self.topk = require("snacks.picker.util.minheap").new({ capacity = 1000, cmp = self.picker.sort, }) self.win:on("CursorMoved", function() if not self.win:valid() then return end local cursor = vim.api.nvim_win_get_cursor(self.win.win) local view = vim.api.nvim_win_call(self.win.win, vim.fn.winsaveview) local row = cursor[1] - view.topline + 1 if cursor[1] ~= self:idx2row(self.cursor) then local idx = self:row2idx(row) self:_move(idx, true, true) end end, { buf = true }) self.win:on("VimResized", function() self.state.height = vim.api.nvim_win_get_height(self.win.win) self.dirty = true self:update() end) self.win:on("WinResized", function() if not self.win:win_valid() then return end local height = vim.api.nvim_win_get_height(self.win.win) if height == self.state.height then return end self.state.height = height self.dirty = true self:update() end) -- reset topline. Only needed for Neovim < 0.11, -- but won't hurt on newer versions self.win:on("WinScrolled", function() for win in pairs(vim.v.event) do if (tonumber(win) or -1) == self.win.win then vim.api.nvim_win_call(self.win.win, function() vim.fn.winrestview({ topline = 1 }) end) end end end) local focused = false self.win:on({ "WinEnter", "WinLeave" }, function() local f = vim.api.nvim_get_current_win() == self.win.win if focused ~= f then focused = f self:update_cursorline() end end) return self end --- View the list at the given cursor and top. --- These are the normalized values, so are unaffected by reverse. ---@param cursor number ---@param top? number ---@param render? boolean function M:view(cursor, top, render) if top then self:_scroll(top, true, false) end self:_move(cursor, true, render) if self.cursor < cursor then self.target = { cursor = cursor, top = top } else self.target = nil end end --- Sets the target cursor/top for the next render. --- Useful to keep the cursor/top, right before triggering a `find`. --- If an existing target is set, it will be kept, unless `opts.force` is set. ---@param cursor? number ---@param top? number ---@param opts? {force?: boolean} function M:set_target(cursor, top, opts) if self.target and not (opts and opts.force) then return end self.target = { cursor = cursor or self.cursor, top = top or self.top } end ---@param idx number function M:idx2row(idx) local ret = idx - self.top + 1 if not self.reverse then return ret end return self.state.height - ret + 1 end ---@param row number function M:row2idx(row) local ret = row + self.top - 1 if not self.reverse then return ret end return self.state.height - ret + 1 end function M:on_show() self.state.scrolloff = vim.wo[self.win.win].scrolloff self.state.scroll = vim.wo[self.win.win].scroll self.state.height = vim.api.nvim_win_get_height(self.win.win) self.state.mousescroll = tonumber(vim.o.mousescroll:match("ver:(%d+)")) or 1 Snacks.util.wo(self.win.win, { scrolloff = 0 }) self.dirty = true self:update_cursorline() self:update({ force = true }) end function M:count() return #self.items end function M:close() self.win:destroy() self.picker = nil for w, l in pairs(lists) do if l == self then lists[w] = nil end end -- Keep all items so actions can be performed on them, -- even when the picker closed end function M:scrolloff() local scrolloff = math.min(self.state.scrolloff, math.floor((self:height() - 1) / 2)) local offset = math.min(self.cursor, self:count() - self.cursor) return offset > scrolloff and scrolloff or 0 end ---@param to number ---@param absolute? boolean ---@param render? boolean function M:_scroll(to, absolute, render) local old_top = self.top self.top = absolute and to or self.top + to local maxtop = self:count() - self:height() + 1 self.top = minmax(self.top, 1, maxtop) if self.top == maxtop or self.top == 1 then self.cursor = absolute and to or self.cursor + to end local scrolloff = self:scrolloff() self.cursor = minmax(self.cursor, self.top + scrolloff, self.top + self:height() - 1 - scrolloff) self.dirty = self.dirty or self.top ~= old_top if render ~= false then self:render() end end ---@param to number ---@param absolute? boolean ---@param render? boolean function M:scroll(to, absolute, render) if self.reverse then to = absolute and (self:count() - to + 1) or -1 * to end self:_scroll(to, absolute, render) end ---@param to number ---@param absolute? boolean ---@param render? boolean function M:_move(to, absolute, render) local old_top = self.top local height = self:height() if height <= 1 then self.cursor, self.top = 1, 1 else self.cursor = absolute and to or self.cursor + to if self.picker.resolved_layout.cycle then self.cursor = (self.cursor - 1) % self:count() + 1 end self.cursor = minmax(self.cursor, 1, self:count()) local scrolloff = self:scrolloff() self.top = minmax(self.top, self.cursor - self:height() + scrolloff + 1, self.cursor - scrolloff) end self.dirty = self.dirty or self.top ~= old_top if render ~= false then self:render() end end ---@param to number ---@param absolute? boolean ---@param render? boolean function M:move(to, absolute, render) if self.reverse then to = absolute and (self:count() - to + 1) or -1 * to end self:_move(to, absolute, render) end function M:clear() self.topk:clear() self.top, self.cursor = 1, 1 self.items = {} self.dirty = true if next(self.items) == nil then return end self:update() end function M:pause(ms) self.paused = true vim.defer_fn(function() self:unpause() end, ms) end ---@param item snacks.picker.Item ---@param sort? boolean function M:add(item, sort) local idx = #self.items + 1 self.items[idx] = item -- if the visible items are less than the height, then we need to render self.dirty = self.dirty or #self.visible < (self.state.height or 50) if sort ~= false then local added, prev = self.topk:add(item) if added then -- check if item is before the last visible item if not self.dirty and #self.visible > 0 then self.dirty = self.topk.cmp(item, self.visible[#self.visible]) end item.match_topk = item.match_tick if prev then -- replace with previous item, since new item is now in topk self.items[idx] = prev prev.match_topk = nil end end end end ---@return snacks.picker.Item? function M:current() return self:get(self.cursor) end --- Returns the item at the given sorted index. --- Item will be taken from topk if available, otherwise from items. --- In case the matcher is running, the item will be taken from the finder. ---@param idx number ---@return snacks.picker.Item? function M:get(idx) return self.topk:get(idx) or self.items[idx] end function M:height() return math.min(self.state.height, self:count()) end ---@param opts? {force?: boolean} function M:update(opts) if opts and opts.force then self.dirty = true end if vim.in_fast_event() then return vim.schedule(function() self:update() end) end if self.paused and #self.items < self.state.height then return end self:render() end -- Toggle selection of current item ---@param item? snacks.picker.Item function M:select(item) if item == nil and vim.fn.mode():find("^[vV]") and vim.api.nvim_get_current_buf() == self.win.buf then -- stop visual mode vim.cmd("normal! " .. vim.fn.mode():sub(1, 1)) local from = vim.api.nvim_buf_get_mark(0, "<") local to = vim.api.nvim_buf_get_mark(0, ">") for i = math.min(from[1], to[1]), math.max(from[1], to[1]) do local it = self:get(self:row2idx(i)) if it then self:select(it) end end return end item = item or self:current() if not item then return end if self:unselect(item) then return end local key = self:select_key(item) self.selected_map[key] = item table.insert(self.selected, item) self.picker.input:update() self.dirty = true self:render() end ---@param item? snacks.picker.Item function M:unselect(item) item = item or self:current() if not item then return end local key = self:select_key(item) if not self.selected_map[key] then return end self.selected_map[key] = nil self.selected = vim.tbl_filter(function(v) return self:select_key(v) ~= key end, self.selected) self.picker.input:update() self.dirty = true self:render() return true end function M:select_all() self:set_selected(#self.selected == self:count() and {} or self.items) end ---@param item snacks.picker.Item ---@return string function M:select_key(item) item._select_key = item._select_key or Snacks.picker.util.text(item, { "text", "file", "key", "id", "pos", "end_pos" }) return item._select_key end ---@param items? snacks.picker.Item[] function M:set_selected(items) items = items or {} self.selected = items self.selected_map = {} for _, item in ipairs(items) do self.selected_map[self:select_key(item)] = item end self.picker.input:update() self.dirty = true self:update() end ---@param item snacks.picker.Item function M:is_selected(item) return self.selected_map[self:select_key(item)] ~= nil end function M:unpause() if not self.paused then return end self.paused = false self:update() end ---@param item snacks.picker.Item function M:format(item) Snacks.picker.util.resolve(item) -- Add selected and debug info local prefix = {} ---@type snacks.picker.Highlight[] if #self.selected > 0 or self.picker.opts.formatters.selected.show_always then vim.list_extend(prefix, Snacks.picker.format.selected(item, self.picker)) else prefix[#prefix + 1] = { " " } end if self.picker.opts.debug.scores then vim.list_extend(prefix, Snacks.picker.format.debug(item, self.picker)) end local text, extmarks = Snacks.picker.highlight.to_text(prefix) -- Add the formatted item local line = self.picker.format(item, self.picker) line = Snacks.picker.highlight.resolve(line, vim.api.nvim_win_get_width(self.win.win)) while #line > 0 and type(line[#line][1]) == "string" and line[#line][1]:find("^%s*$") do table.remove(line) end local line_text, line_extmarks = Snacks.picker.highlight.to_text(line, { offset = #text }) vim.list_extend(extmarks, line_extmarks) text = text .. line_text -- Highlight match positions for field patterns local fields = self.matcher:fields() for _, extmark in ipairs(extmarks) do if extmark.col and extmark.end_col and extmark.field and vim.tbl_contains(fields, extmark.field) then local field = extmark.field --[[@as string]] ---@type snacks.picker.Item local it = { idx = 1, score = 0, file = item.file, text = "", } it[field] = text:sub(extmark.col + 1, extmark.end_col) local positions = self.matcher:positions(it) Snacks.picker.highlight.matches(extmarks, positions[field] or {}, extmark.col) end end -- Highlight match positions for text local it = { text = text:gsub("%s*$", ""), idx = 1, score = 0, file = item.file } local positions = self.matcher:positions(it).text or {} if not item.positions then vim.list_extend(positions, self.matcher_regex:positions(it).text or {}) end Snacks.picker.highlight.matches(extmarks, positions) return text, extmarks end ---@param item snacks.picker.Item ---@param row number function M:_render(item, row) local text, extmarks = self:format(item) text = text:gsub("\n", " ") vim.api.nvim_buf_set_lines(self.win.buf, row - 1, row, false, { text }) for _, extmark in ipairs(extmarks) do local col = extmark.col extmark.col = nil extmark.row = nil extmark.field = nil local ok, err = pcall(vim.api.nvim_buf_set_extmark, self.win.buf, ns, row - 1, col, extmark) if not ok and self.picker.opts.debug.extmarks then Snacks.notify.error("Failed to set extmark.\n" .. err .. "\n```lua\n" .. vim.inspect(extmark) .. "\n```") end end end function M:update_cursorline() if self.win:win_valid() then Snacks.util.wo(self.win.win, { cursorline = self:count() > 0, winhighlight = Snacks.util.winhl(vim.wo[self.win.win].winhighlight, { CursorLine = self.picker:is_focused() and "SnacksPickerListCursorLine" or "CursorLine", }), }) end end function M:render() if not self.win:valid() then return end stats.render = stats.render + 1 if self.target then self:view(self.target.cursor, self.target.top, false) if not self.picker:is_active() then self.target = nil end else self:move(0, false, false) self:scroll(0, false, false) end local redraw = false if self.dirty then stats.render_full = stats.render_full + 1 local height = self:height() self.dirty = false vim.api.nvim_win_call(self.win.win, function() vim.fn.winrestview({ topline = 1, leftcol = 0 }) end) vim.api.nvim_buf_clear_namespace(self.win.buf, ns, 0, -1) vim.bo[self.win.buf].modifiable = true local lines = vim.split(string.rep("\n", self.state.height), "\n") vim.api.nvim_buf_set_lines(self.win.buf, 0, -1, false, lines) -- matcher for highlighting should include the search filter local pattern = vim.trim(self.picker.input.filter.pattern) if self.matcher.pattern ~= pattern then self.matcher:init(pattern) end local search = Snacks.picker.util.parse(vim.trim(self.picker.input.filter.search)) if self.matcher_regex.pattern ~= search then self.matcher_regex:init(search) end self.visible = {} -- render items for i = self.top, math.min(self:count(), self.top + height - 1) do local item = assert(self:get(i), "item not found") self.visible[i - self.top + 1] = item local row = self:idx2row(i) self:_render(item, row) end vim.bo[self.win.buf].modifiable = false redraw = true end -- Fix cursor and cursorline self:update_cursorline() local cursor = vim.api.nvim_win_get_cursor(self.win.win) if cursor[1] ~= self:idx2row(self.cursor) then vim.api.nvim_win_set_cursor(self.win.win, { self:idx2row(self.cursor), 0 }) end -- force redraw if list changed if redraw then self.win:redraw() end if self.target then return end -- check if current item changed local current = self:current() if self._current ~= current then self._current = current if not self.did_preview then -- show first preview instantly self.did_preview = true self.picker:show_preview() else vim.schedule(function() if self.picker then self.picker:show_preview() end end) end end end -- vim.uv.new_timer():start( -- 500, -- 500, -- vim.schedule_wrap(function() -- Snacks.notify(vim.inspect(stats), { ft = "lua", id = "list_stats" }) -- end) -- ) return M ================================================ FILE: lua/snacks/picker/core/main.lua ================================================ ---@class snacks.picker.Main ---@field opts snacks.picker.main.Config ---@field win number local M = {} M.__index = M ---@class snacks.picker.main.Config ---@field float? boolean main window can be a floating window (defaults to false) ---@field file? boolean main window should be a file (defaults to true) ---@field current? boolean main window should be the current window (defaults to false) ---@param opts? snacks.picker.main.Config function M.new(opts) opts = vim.tbl_extend("force", { float = false, file = true, current = false, }, opts or {}) local self = setmetatable({}, M) self.opts = opts self:update() return self end function M:get() if not self.win or not vim.api.nvim_win_is_valid(self.win) then self.win = self:find() end return self.win end function M:update() self.win = self:find() end ---@param win number function M:set(win) self.win = win end ---@param extra? number[] function M:find(extra) local current = vim.api.nvim_get_current_win() self.win = self.win or current if self.opts.current then return current end local prev = vim.fn.win_getid(vim.fn.winnr("#")) local non_float = 0 local wins = { current, self.win, prev } local all = vim.api.nvim_tabpage_list_wins(0) -- sort all by lastused of the win buffer table.sort(all, function(a, b) local ba = vim.api.nvim_win_get_buf(a) local bb = vim.api.nvim_win_get_buf(b) return vim.fn.getbufinfo(ba)[1].lastused > vim.fn.getbufinfo(bb)[1].lastused end) vim.list_extend(wins, all) ---@param win number wins = vim.tbl_filter(function(win) -- exclude invalid windows if win == 0 or not vim.api.nvim_win_is_valid(win) then return false end local buf = vim.api.nvim_win_get_buf(win) if vim.w[win].snacks_main or vim.b[buf].snacks_main then return true end local win_config = vim.api.nvim_win_get_config(win) local is_float = win_config.relative ~= "" if not is_float then non_float = win end if vim.w[win].snacks_layout then return false end -- exclude non-file buffers if self.opts.file and vim.bo[buf].buftype ~= "" then return false end -- exclude floating windows and non-focusable windows if is_float and (not self.opts.float or not win_config.focusable) then return false end return true end, wins) return wins[1] or non_float end return M ================================================ FILE: lua/snacks/picker/core/matcher.lua ================================================ local Async = require("snacks.picker.util.async") ---@class snacks.picker.Item ---@field match_tick? number ---@field match_topk? number ---@class snacks.picker.matcher.Config ---@field regex? boolean used internally for positions of sources that use regex ---@field on_match? fun(matcher: snacks.picker.Matcher, item: snacks.picker.Item) ---@field on_done? fun(matcher: snacks.picker.Matcher) ---@field keep_parents? boolean ---@field sort? boolean ---@class snacks.picker.Matcher ---@field opts snacks.picker.matcher.Config ---@field mods snacks.picker.matcher.Mods[][] ---@field one? snacks.picker.matcher.Mods ---@field pattern string ---@field tick number ---@field task snacks.picker.Async ---@field live? boolean ---@field score snacks.picker.Score ---@field sorting? boolean ---@field file? {path: string, pos: snacks.picker.Pos} ---@field cwd string ---@field frecency? snacks.picker.Frecency ---@field subset? boolean local M = {} M.__index = M M.DEFAULT_SCORE = 1000 M.INVERSE_SCORE = 1000 local BONUS_FRECENCY = 8 local BONUS_CWD = 10 local YIELD_MATCH = 1 -- ms ---@class snacks.picker.matcher.Mods ---@field pattern string ---@field chars string[] ---@field entropy number higher entropy is less likely to match ---@field field? string ---@field ignorecase? boolean ---@field fuzzy? boolean ---@field regex? boolean ---@field word? boolean ---@field exact_suffix? boolean ---@field exact_prefix? boolean ---@field inverse? boolean ---@param opts? snacks.picker.matcher.Config|{} function M.new(opts) local self = setmetatable({}, M) self.opts = vim.tbl_deep_extend("force", { fuzzy = true, smartcase = true, ignorecase = true, }, opts or {}) self.pattern = "" self.task = Async.nop() self.mods = {} self.sorting = true self.tick = 0 self.score = require("snacks.picker.core.score").new(self.opts) self.frecency = self.opts.frecency and require("snacks.picker.core.frecency").new() or nil return self end function M:empty() return not next(self.mods) end function M:running() return self.task:running() end function M:abort() self.task:abort() end function M:close() self:abort() self.task = Async.nop() end ---@param picker snacks.Picker ---@param item snacks.picker.Item function M:on_match(picker, item) if self.opts.on_match then self.opts.on_match(self, item) end if not self.opts.keep_parents or item.score == 0 then return end local parent = item.parent item.child_match_only = false while parent and not parent.root do if parent.score == 0 or parent.match_tick ~= self.tick then parent.score = 1 parent.child_match_only = true parent.match_tick = self.tick parent.match_topk = nil picker.list:add(parent, self.sorting) else break end parent = parent.parent end end ---@param picker snacks.Picker function M:on_done(picker) vim.schedule(function() if self.opts.on_done then self.opts.on_done(self) end if not self.opts.keep_parents or picker.closed then return end for item, idx in picker:iter() do if not item.child_match_only then picker.list:view(idx) return end end end) end ---@param picker snacks.Picker function M:run(picker) self.task:abort() picker.list:clear() self.cwd = svim.fs.normalize(picker.opts.cwd or (vim.uv or vim.loop).cwd() or ".") self.sorting = self.opts.sort ~= false and (not self:empty() or picker.opts.matcher.sort_empty) -- PERF: fast path for empty pattern if not self.sorting and not picker.finder.task:running() and self:empty() then picker.list.items = picker.finder.items picker:update({ force = true }) self:on_done(picker) return end ---@async self.task = Async.new(function() local yield = Async.yielder(YIELD_MATCH) ---@async ---@param item snacks.picker.Item local function check(item) if self:update(picker, item) then picker.list:add(item, self.sorting) end yield() end local count = #picker.finder.items -- process topk first for i = 1, count do local item = picker.finder.items[i] if item.match_topk then item.match_topk = nil check(item) else item.match_topk = nil end end -- process matches next for i = 1, count do local item = picker.finder.items[i] if item.score > 0 and item.match_tick ~= self.tick then check(item) end end -- if pattern is a subset of the previous pattern, then -- only process items that didn't match previously if self.subset then for i = 1, count do local item = picker.finder.items[i] if item.score == 0 and item.match_tick == self.tick - 1 then item.match_tick = self.tick end end end -- then the rest local idx = 0 repeat while idx < #picker.finder.items do idx = idx + 1 local item = picker.finder.items[idx] if item.match_tick ~= self.tick then check(item) end end -- suspend till we have more items if picker.finder.task:running() then Async.suspend() end until idx >= #picker.finder.items and not picker.finder.task:running() picker:update({ force = true }) self:on_done(picker) end) end ---@param pattern string ---@return boolean changed function M:init(pattern) pattern = vim.trim(pattern) if pattern == self.pattern then return false end self.tick = self.tick + 1 self.file = nil self.mods = {} self.subset = self.pattern ~= "" and pattern:find(self.pattern, 1, true) == 1 and not pattern:find("[^%s%w]") self.pattern = pattern self:abort() self.one = nil if pattern == "" then return true end if self.opts.regex then self.mods = { { self:_prepare(pattern) } } else local is_or = false for _, p in ipairs(vim.split(pattern, " +")) do if p == "|" then is_or = true else local mods = self:_prepare(p) if mods.pattern ~= "" then if is_or and #self.mods > 0 then table.insert(self.mods[#self.mods], mods) else table.insert(self.mods, { mods }) end end is_or = false end end end for _, ors in ipairs(self.mods) do -- sort by entropy, lower entropy is more likely to match table.sort(ors, function(a, b) return a.entropy < b.entropy end) end -- sort by entropy, higher entropy is less likely to match table.sort(self.mods, function(a, b) return a[1].entropy > b[1].entropy end) if #self.mods == 1 and #self.mods[1] == 1 then self.one = self.mods[1][1] end return true end ---@param pattern string ---@return snacks.picker.matcher.Mods function M:_prepare(pattern) ---@type snacks.picker.matcher.Mods local mods = { pattern = pattern, entropy = 0, chars = {} } if self.opts.regex then mods.regex = true else local file_patterns = { "^(.*[/\\].*):(%d*):(%d*)$", "^(.*[/\\].*):(%d*)$", "^(.+%.[a-z_]+):(%d*):(%d*)$", "^(.+%.[a-z_]+):(%d*)$", } for _, p in ipairs(file_patterns) do local file, line, col = pattern:match(p) if file then mods.field = "file" mods.pattern = file .. "$" self.file = { path = file, pos = { tonumber(line) or 1, tonumber(col) or 0 }, } break end end -- minimum two chars for field pattern local field, p = pattern:match("^([%w_][%w_]+):(.*)$") if field then mods.field = field mods.pattern = p end mods.ignorecase = self.opts.ignorecase local is_lower = mods.pattern:lower() == mods.pattern if self.opts.smartcase then mods.ignorecase = is_lower end mods.fuzzy = self.opts.fuzzy if not mods.fuzzy then mods.entropy = mods.entropy + 10 end if mods.pattern:sub(1, 1) == "!" then mods.fuzzy, mods.inverse = false, true mods.pattern = mods.pattern:sub(2) mods.entropy = mods.entropy - 1 end if mods.pattern:sub(1, 1) == "'" then mods.fuzzy = false mods.pattern = mods.pattern:sub(2) mods.entropy = mods.entropy + 10 if mods.pattern:sub(-1, -1) == "'" then mods.word = true mods.pattern = mods.pattern:sub(1, -2) mods.entropy = mods.entropy + 10 end elseif mods.pattern:sub(1, 1) == "^" then mods.fuzzy, mods.exact_prefix = false, true mods.pattern = mods.pattern:sub(2) mods.entropy = mods.entropy + 20 end if mods.pattern:sub(-1, -1) == "$" then mods.fuzzy = false mods.exact_suffix = true mods.pattern = mods.pattern:sub(1, -2) mods.entropy = mods.entropy + 20 end local rare_chars = #mods.pattern:gsub("[%w%s]", "") mods.entropy = mods.entropy + math.min(#mods.pattern, 20) + rare_chars * 2 if not mods.ignorecase and not is_lower then mods.entropy = mods.entropy * 2 end if mods.ignorecase then mods.pattern = mods.pattern:lower() end end for c = 1, #mods.pattern do mods.chars[c] = mods.pattern:sub(c, c) end return mods end ---@param picker snacks.Picker ---@param item snacks.picker.Item ---@return boolean matched function M:update(picker, item) if item.match_pos then item.pos = nil end local score = self:match(item) item.match_tick, item.match_topk = self.tick, nil if score ~= 0 then if item.score_add then score = score + item.score_add end if item.score_mul then score = score * item.score_mul end if self.file and not item.pos then item.pos = self.file.pos item.match_pos = true end if item.file then if self.frecency then item.frecency = item.frecency or self.frecency:get(item) score = score + (1 - 1 / (1 + item.frecency)) * BONUS_FRECENCY end if self.opts.cwd_bonus and (self.cwd == item.cwd or Snacks.picker.util.path(item):find(self.cwd, 1, true) == 1) then score = score + BONUS_CWD end end item.score = score self:on_match(picker, item) else item.score = 0 end return score > 0 end --- Matches an item and returns the score. --- Score is 0 if no match is found. ---@param item snacks.picker.Item function M:match(item) if self:empty() then return M.DEFAULT_SCORE -- empty pattern matches everything end local score, s = 0, nil -- fast path for single pattern if self.one then return self:_match(item, self.one) or 0 end for _, any in ipairs(self.mods) do -- fast path for single OR pattern if #any == 1 then s = self:_match(item, any[1]) else for _, mods in ipairs(any) do s = self:_match(item, mods) if s then break end end end if not s then return 0 end score = score + s end return score end --- Returns the fields that are used in the pattern. ---@return string[] function M:fields() local ret = {} ---@type table for _, any in ipairs(self.mods) do for _, mods in ipairs(any) do ret[mods.field or "text"] = true end end return vim.tbl_keys(ret) end --- Returns the positions of the matched pattern in the item. --- All search patterns are combined with OR. ---@param item snacks.picker.Item function M:positions(item) local all = {} ---@type snacks.picker.matcher.Mods[] local ret = {} ---@type table for _, any in ipairs(self.mods) do vim.list_extend(all, any) end for _, mods in ipairs(all) do local _, from, to, str = self:_match(item, mods) if from and to and str then local field = mods.field or "text" ret[field] = ret[field] or {} local pos = ret[field] if mods.fuzzy then vim.list_extend(pos, self:fuzzy_positions(str, mods.chars, from)) else for c = from, to do pos[#pos + 1] = c end end end end return ret end --- Returns the column of the first position of the matched pattern in the item. ---@param buf number ---@param item snacks.picker.Item ---@return snacks.picker.Pos? function M:bufpos(buf, item) if not item.pos then return end local line = vim.api.nvim_buf_get_lines(buf, item.pos[1] - 1, item.pos[1], false)[1] or "" local positions = self:positions({ text = line, idx = 1, score = 0 }).text or {} table.sort(positions) return #positions > 0 and { item.pos[1], positions[1] - 1 } or nil end ---@param str string ---@param pattern string[] ---@param from number function M:fuzzy_positions(str, pattern, from) local ret = { from } ---@type number[] for i = 2, #pattern do ret[#ret + 1] = string.find(str, pattern[i], ret[#ret] + 1, true) end return ret end ---@param str string ---@param pattern string ---@return number? score, number? from, number? to, string? str function M:regex(str, pattern) local ok, re = pcall(vim.regex, pattern) if not ok then return end local from, to = re:match_str(str) if from and to then from = from + 1 return self.score:get(str, from, to), from, to, str end end ---@param item snacks.picker.Item ---@param mods snacks.picker.matcher.Mods ---@return number? score, number? from, number? to, string? str function M:_match(item, mods) self.score.is_file = item.file ~= nil local str = item.text if mods.regex then return self:regex(str, mods.pattern) end if mods.field then if item[mods.field] == nil then if mods.inverse then return M.INVERSE_SCORE end return end str = tostring(item[mods.field]) end local str_orig = str str = mods.ignorecase and str:lower() or str local from, to ---@type number?, number? if mods.fuzzy then return self:fuzzy(str, str_orig, mods.chars) end if mods.exact_prefix then if str:sub(1, #mods.pattern) == mods.pattern then from, to = 1, #mods.pattern end elseif mods.exact_suffix then if str:sub(-#mods.pattern) == mods.pattern then from, to = #str - #mods.pattern + 1, #str end else from, to = str:find(mods.pattern, 1, true) -- word match while mods.word and from and to do local bound_left = self.score:is_left_boundary(str, from) local bound_right = self.score:is_right_boundary(str, to) if bound_left and bound_right then break end from, to = str:find(mods.pattern, to + 1, true) end end if mods.inverse then if not from then return M.INVERSE_SCORE end return end if from then ---@cast to number return self.score:get(str_orig, from, to), from, to, str end end ---@param str string ---@param str_orig string ---@param pattern string[] ---@param init? number ---@return number? from, number? to function M:fuzzy_find(str, str_orig, pattern, init) local from = string.find(str, pattern[1], init or 1, true) if not from then return end self.score:init(str_orig, from) ---@type number?, number local last, n = from, #pattern for i = 2, n do last = string.find(str, pattern[i], last + 1, true) if last then self.score:update(last) else return end end return from, last end --- Does a forward scan followed by a backward scan for each end position, --- to find the best match. ---@param str string ---@param str_orig string ---@param pattern string[] ---@return number? score, number? from, number? to, string? str function M:fuzzy(str, str_orig, pattern) local from, to = self:fuzzy_find(str, str_orig, pattern) if not from then return end ---@cast to number local best_from, best_to, best_score = from, to, self.score.score while from do if self.score.score > best_score then best_from, best_to, best_score = from, to, self.score.score end from, to = self:fuzzy_find(str, str_orig, pattern, from + 1) end return best_score, best_from, best_to, str end return M ================================================ FILE: lua/snacks/picker/core/picker.lua ================================================ local Async = require("snacks.picker.util.async") local Finder = require("snacks.picker.core.finder") local uv = vim.uv or vim.loop Async.BUDGET = 10 local _id = 0 ---@alias snacks.Picker.ref (fun():snacks.Picker?)|{value?: snacks.Picker} ---@class snacks.Picker ---@field id number ---@field opts snacks.picker.Config ---@field init_opts? snacks.picker.Config ---@field finder snacks.picker.Finder ---@field format snacks.picker.format ---@field input snacks.picker.input ---@field layout snacks.layout ---@field resolved_layout snacks.picker.layout.Config ---@field list snacks.picker.list ---@field matcher snacks.picker.Matcher ---@field main number ---@field _main snacks.picker.Main ---@field preview snacks.picker.Preview ---@field shown? boolean ---@field sort snacks.picker.sort ---@field updater uv.uv_timer_t ---@field start_time number ---@field title string ---@field closed? boolean ---@field history snacks.picker.History ---@field visual? snacks.picker.Visual local M = {} --- Keep track of garbage collection ---@type table M._pickers = setmetatable({}, { __mode = "k" }) --- These are active, so don't garbage collect them ---@type table M._active = {} ---@alias snacks.picker.history.Record {pattern: string, search: string, live?: boolean} function M:__index(key) if M[key] then return M[key] end if key == "main" then return self._main:get() end end function M:__newindex(key, value) if key == "main" then self._main:set(value) else rawset(self, key, value) end end ---@param opts? {source?: string, tab?: boolean} function M.get(opts) opts = opts or {} local ret = {} ---@type snacks.Picker[] for picker in pairs(M._active) do local want = (not opts.source or picker.opts.source == opts.source) and (opts.tab == false or picker:on_current_tab()) and not picker.closed if want then ret[#ret + 1] = picker end end table.sort(ret, function(a, b) return a.id < b.id end) return ret end ---@hide ---@param opts? snacks.picker.Config ---@return snacks.Picker function M.new(opts) ---@type snacks.Picker local self = setmetatable({}, M) _id = _id + 1 self.id = _id self.init_opts = opts self.opts = Snacks.picker.config.get(opts) self.history = require("snacks.picker.util.history").new("picker_" .. (self.opts.source or "custom"), { ---@param hist snacks.picker.history.Record filter = function(hist) if hist.pattern == "" and hist.search == "" then return false end return true end, }) self:cleanup() self.visual = Snacks.picker.util.visual() self.start_time = uv.hrtime() self._main = require("snacks.picker.core.main").new(self.opts.main) local actions = require("snacks.picker.core.actions").get(self) self.opts.win.input.actions = actions self.opts.win.list.actions = actions self.opts.win.preview.actions = actions self.sort = Snacks.picker.config.sort(self.opts) self.updater = assert(uv.new_timer()) self.matcher = require("snacks.picker.core.matcher").new(self.opts.matcher) self.finder = Finder.new(Snacks.picker.config.finder(self.opts.finder) or function() return self.opts.items or {} end) self.format = Snacks.picker.config.format(self.opts) M._pickers[self] = true M._active[self] = true local layout = Snacks.picker.config.layout(self.opts) self.resolved_layout = layout self.list = require("snacks.picker.core.list").new(self) self.input = require("snacks.picker.core.input").new(self) self.preview = require("snacks.picker.core.preview").new(self) self.title = self.opts.title or Snacks.picker.util.title(self.opts.source or "search") self:init_layout(layout) local ref = self:ref() self._throttled_preview = Snacks.util.throttle(function() local this = ref() if this then this:_show_preview() end end, { ms = 60, name = "preview" }) if not (opts and opts.find == false) then self:find() end return self end function M:is_focused() return self:current_win() ~= nil end ---@return string? name, snacks.win? win function M:current_win() local current = vim.api.nvim_get_current_win() for w, win in pairs(self.layout.wins or {}) do if win.win == current then return w, win end end end --- Check if any remnants of previous pickers need to be cleaned up. --- Normally not needed. ---@private function M:cleanup() local picker_count = vim.tbl_count(M._pickers) - vim.tbl_count(M._active) if picker_count > 0 then -- clear items from previous pickers for garbage collection for picker, _ in pairs(M._pickers) do if not M._active[picker] then picker.finder.items = {} picker.list.items = {} picker.list:clear() picker.list.picker = nil end end end if self.opts.debug.leaks and picker_count > 0 then collectgarbage("collect") picker_count = vim.tbl_count(M._pickers) if picker_count > 0 then local pickers = vim.tbl_keys(M._pickers) ---@type snacks.Picker[] table.sort(pickers, function(a, b) return a.id < b.id end) local lines = { ("# ` %d ` active pickers:"):format(picker_count) } for _, picker in ipairs(pickers) do lines[#lines + 1] = ("- [%s]: **pattern**=%q, **search**=%q"):format( picker.opts.source or "custom", picker.input.filter.pattern, picker.input.filter.search ) end Snacks.notify.error(lines, { title = "Snacks Picker", id = "snacks_picker_leaks" }) Snacks.debug.metrics() else Snacks.notify( "Picker leaks cleared after `collectgarbage`", { title = "Snacks Picker", id = "snacks_picker_leaks" } ) end end end function M:on_current_tab() return self.layout:valid() and self.layout.root:on_current_tab() end --- Execute the callback in normal mode. --- When still in insert mode, stop insert mode first, --- and then`vim.schedule` the callback. ---@param cb fun() function M:norm(cb) if vim.fn.mode():sub(1, 1) == "i" then vim.cmd.stopinsert() vim.schedule(cb) return end cb() return true end ---@param layout? snacks.picker.layout.Config ---@private function M:init_layout(layout) layout = layout or Snacks.picker.config.layout(self.opts) self.resolved_layout = vim.deepcopy(layout) self.resolved_layout.cycle = self.resolved_layout.cycle == true self.preview:update(self) local opts = layout --[[@as snacks.layout.Config]] local backdrop = nil if self.preview.main then backdrop = false end self.layout = Snacks.layout.new(vim.tbl_deep_extend("force", opts, { show = false, win = { wo = { winhighlight = Snacks.picker.highlight.winhl("SnacksPicker"), }, }, wins = { input = self.input.win, list = self.list.win, preview = self.preview.win, }, hidden = layout.hidden, on_close = function() self:close() end, on_update = function() self.preview:refresh(self) self.input:update() self.list:update({ force = true }) self:update_titles() end, on_update_pre = function() self:update_titles() end, layout = { backdrop = backdrop, }, })) self:attach() -- apply box highlight groups local boxwhl = Snacks.picker.highlight.winhl("SnacksPickerBox") for _, win in pairs(self.layout.box_wins) do win.opts.wo.winhighlight = Snacks.util.winhl(boxwhl, win.opts.wo.winhighlight) end return layout end --- Attaches to the layout ---@private function M:attach() -- Check if we need to load another layout self.layout.root:on("VimResized", function() vim.schedule(function() self:set_layout(Snacks.picker.config.layout(self.opts)) end) end) -- close if we enter a window that is not part of the picker local preview = false self.layout.root:on("WinEnter", function() if vim.v.vim_did_enter == 0 then return end if self.closed or Snacks.util.is_float() then return end if self:is_focused() then if preview then -- re-open preview when needed self:toggle("preview", { enable = true }) preview = false end return end -- close main preview when auto_close is disabled if self.opts.auto_close == false then if self.preview.main and self.preview.win:valid() then self:toggle("preview", { enable = false }) preview = true end return end -- close picker when we enter another window vim.schedule(function() self:close() end) end) -- Check if we need to auto close any picker windows self.layout.root:on("WinEnter", function() if not self:is_focused() then return end local current = self:current_win() for name, win in pairs(self.layout.wins) do local auto_hide = vim.tbl_contains(self.resolved_layout.auto_hide or {}, name) if name ~= current and auto_hide and win:valid() then self:toggle(name, { enable = false }) end end end) -- prevent entering the root window for split layouts local left_picker = true -- left a picker window local last_pwin ---@type number? self.layout.root:on("WinLeave", function() left_picker = self:is_focused() end) self.layout.root:on("WinEnter", function() if self:is_focused() then last_pwin = vim.api.nvim_get_current_win() end end) self.layout.root:on("WinEnter", function() if left_picker then local pos = self.layout.root.opts.position local wincmds = { left = "l", right = "h", top = "j", bottom = "k" } vim.cmd("wincmd " .. wincmds[pos]) elseif last_pwin and vim.api.nvim_win_is_valid(last_pwin) then vim.api.nvim_set_current_win(last_pwin) else self:focus() end end, { buf = true, nested = true }) end --- Set the picker layout. Can be either the name of a preset layout --- or a custom layout configuration. ---@param layout? string|snacks.picker.layout.Config function M:set_layout(layout) layout = layout or Snacks.picker.config.layout(self.opts) layout = type(layout) == "string" and Snacks.picker.config.layout(layout) or layout ---@cast layout snacks.picker.layout.Config layout.cycle = layout.cycle == true if vim.deep_equal(layout, self.resolved_layout) then -- no need to update return end if self.list.reverse ~= layout.reverse then Snacks.notify.warn( "Heads up! This layout changed the list order,\nso `up` goes down and `down` goes up.", { title = "Snacks Picker", id = "snacks_picker_layout_change" } ) end self.list.reverse = layout.reverse self.layout.opts.on_close = nil -- prevent closing the picker when changing layout self.layout:close({ wins = false }) self:init_layout(layout) self.layout:show() end -- Get the word under the cursor or the current visual selection function M:word() return self.visual and self.visual.text or vim.fn.expand("") end --- Update title templates ---@hide function M:update_titles() local data = { source = self.title, title = self.title, live = self.opts.live and self.opts.icons.ui.live or "", preview = vim.trim(self.preview.title or ""), } local toggles = {} ---@type snacks.picker.Text[] for name, toggle in pairs(self.opts.toggles) do if toggle then toggle = type(toggle) == "string" and { icon = toggle } or toggle toggle = toggle == true and { icon = name:sub(1, 1) } or toggle toggle = toggle == false and { enabled = false } or toggle local want = toggle.value if toggle.value == nil then want = true end ---@cast toggle snacks.picker.toggle if toggle.enabled ~= false and self.opts[name] == want then local hl = table.concat(vim.tbl_map(function(a) return a:sub(1, 1):upper() .. a:sub(2) end, vim.split(name, "_"))) toggles[#toggles + 1] = { " " .. toggle.icon .. " ", "SnacksPickerToggle" .. hl } toggles[#toggles + 1] = { " ", "FloatTitle" } end end end local wins = { self.layout.root } vim.list_extend(wins, vim.tbl_values(self.layout.wins)) vim.list_extend(wins, vim.tbl_values(self.layout.box_wins)) for _, win in pairs(wins) do if win.opts.title then local tpl = win.meta.title_tpl or win.opts.title win.meta.title_tpl = tpl tpl = type(tpl) == "string" and { { tpl, "FloatTitle" } } or tpl ---@cast tpl snacks.picker.Text[] local has_flags = false local ret = {} ---@type snacks.picker.Text[] for _, chunk in ipairs(tpl) do local text = chunk[1] if text:find("{flags}", 1, true) then text = text:gsub("{flags}", "") has_flags = true end text = vim.trim(Snacks.picker.util.tpl(text, data)):gsub("([%w%p])%s+", "%1 ") if text ~= "" then -- HACK: add extra space when last char is non word like an icon text = text:sub(-1):match("[%w%p]") and text or text .. " " ret[#ret + 1] = { text, chunk[2] } end end if #ret > 0 then table.insert(ret, { " ", "FloatTitle" }) table.insert(ret, 1, { " ", "FloatTitle" }) end if has_flags and #toggles > 0 then vim.list_extend(ret, toggles) end win:set_title(ret) end end end --- Actual preview code ---@hide function M:_show_preview() if self.closed then return end if self.opts.on_change then self.opts.on_change(self, self:current()) end if not (self.preview and self.preview.win:valid()) then return end self.preview:show(self) self:update_titles() end -- Throttled preview M._throttled_preview = M._show_preview -- Show the preview. Show instantly when no item is yet in the preview, -- otherwise throttle the preview. function M:show_preview() if self.closed then return end -- don't show preview when cursor is not on target if self.list.target then return end if not self.preview.item then return self:_show_preview() end return self:_throttled_preview() end ---@hide function M:show() if self.shown or self.closed then return end self.shown = true self.layout:show() if self.opts.focus ~= false and self.opts.enter ~= false then self:focus() end if self.opts.on_show then self.opts.on_show(self) end end --- Focuses the given or configured window. --- Falls back to the first available window if the window is hidden. ---@param win? "input"|"list"|"preview" ---@param opts? {show?: boolean} when enable is true, the window will be shown if hidden function M:focus(win, opts) opts = opts or {} if win and opts.show and self.layout:is_hidden(win) then return self:toggle(win, { enable = true, focus = true }) end win = win or self.opts.focus or "input" local ret ---@type snacks.win? for _, name in ipairs({ "input", "list", "preview" }) do local w = self.layout.wins[name] if w and w:valid() and not self.layout:is_hidden(name) then if name == win then ret = w break end ret = ret or w end end if ret then ret:focus() end end --- Toggle the given window and optionally focus ---@param win "input"|"list"|"preview" ---@param opts? {enable?: boolean, focus?: boolean|string} function M:toggle(win, opts) opts = opts or {} self.layout:toggle(win, opts.enable, function(enabled) -- called if changed and before updating the layout local focus = opts.focus == true and win or opts.focus or self:current_win() --[[@as string]] if not focus then return end if not enabled then -- make sure we don't lose focus when toggling off self:focus(focus) else --- schedule to focus after the layout is updated vim.schedule(function() self:focus(focus) end) end end) end ---@param item snacks.picker.Item? function M:resolve(item) if not item then return end Snacks.picker.util.resolve(item) Snacks.picker.util.resolve_loc(item) return item end --- Returns an iterator over the filtered items in the picker. --- Items will be in sorted order. ---@return fun():(snacks.picker.Item?, number?) function M:iter() local i = 0 local n = self.list:count() return function() i = i + 1 if i <= n then return self:resolve(self.list:get(i)), i end end end --- Get all filtered items in the picker. function M:items() local ret = {} ---@type snacks.picker.Item[] for item in self:iter() do ret[#ret + 1] = item end return ret end --- Get the current item at the cursor ---@param opts? {resolve?: boolean} default is `true` function M:current(opts) opts = opts or {} local ret = self.list:current() if ret and opts.resolve ~= false then ret = self:resolve(ret) end return ret end --- Returns the directory of the current item or the cwd. --- When the item is a directory, return item path, --- otherwise return the directory of the item. function M:dir() local item = self:current() if item then return Snacks.picker.util.dir(item) end return self:cwd() end --- Get the selected items. --- If `fallback=true` and there is no selection, return the current item. ---@param opts? {fallback?: boolean} default is `false` ---@return snacks.picker.Item[] function M:selected(opts) opts = opts or {} local ret = vim.deepcopy(self.list.selected) if #ret == 0 and opts.fallback then ret = { self:current() } end return vim.tbl_map(function(item) return self:resolve(item) end, ret) end --- Total number of items in the picker function M:count() return self.finder:count() end --- Check if the picker is empty function M:empty() return self:count() == 0 end ---@return snacks.Picker.ref function M:ref() return Snacks.util.ref(self) end --- Close the picker function M:close() self.input:stopinsert() if self.closed then return end if self.opts.on_close then self.opts.on_close(self) end self:hist_record(true) self.closed = true for toggle in pairs(self.opts.toggles) do self.init_opts[toggle] = self.opts[toggle] end require("snacks.picker.resume").add(self) local current = vim.api.nvim_get_current_win() local is_picker_win = vim.tbl_contains({ self.input.win.win, self.list.win.win, self.preview.win.win }, current) if is_picker_win and vim.api.nvim_win_is_valid(self.main) then pcall(vim.api.nvim_set_current_win, self.main) end self.updater:stop() if not self.updater:is_closing() then self.updater:close() end self.finder:abort() self.matcher:abort() M._active[self] = nil vim.schedule(function() self.finder:close() self.matcher:close() self.layout:close() self.list:close() self.input:close() self.preview:close() self.resolved_layout = nil self.preview = nil self.matcher = nil self.updater = nil self.history = nil end) end --- Check if the finder or matcher is running function M:is_active() return self.finder:running() or self.matcher:running() end ---@private function M:progress(ms) if self.updater:is_active() or self.closed then return end local ref = self:ref() self.updater = vim.defer_fn(function() local self = ref() if not self then return end self:update() if not self.closed and self:is_active() then -- slower progress when we filled topk local topk, height = self.list.topk:count(), self.list.state.height or 50 self:progress(topk > height and 30 or 10) end end, ms or 10) end ---@hide ---@param opts? {force?: boolean} function M:update(opts) opts = opts or {} if self.closed then return end -- Schedule the update if we are in a fast event if vim.in_fast_event() then return vim.schedule(function() self:update(opts) end) end local count = self.finder:count() local list_count = self.list:count() -- Check if we should show the picker if not self.shown then -- Always show live pickers if self.opts.live then self:show() elseif not self:is_active() then if count == 0 and not self.opts.show_empty then -- no results found local msg = "No results" if self.opts.source then msg = ("No results found for `%s`"):format(self.opts.source) end Snacks.notify.warn(msg, { title = "Snacks Picker" }) self:close() return elseif count == 1 and self.opts.auto_confirm then -- auto confirm if only one result self:action("confirm") self:close() return else -- show the picker if we have results self.list:unpause() self:show() end elseif vim.uv.hrtime() - self.start_time > (self.opts.show_delay * 1e6) then -- show the picker after show_delay ms if there are no results yet self:show() elseif list_count > 1 or (list_count == 1 and not self.opts.auto_confirm) then -- show the picker if we have results self:show() end end if self.shown then if not self:is_active() or list_count > 3 then self.list:unpause() end -- update list and input if not self.input.paused then self.input:update() end self.list:update(opts) end end --- Execute the given action(s) ---@param actions string|string[] function M:action(actions) return self.input.win:execute(actions) end --- Add current filter to history ---@param force? boolean ---@private function M:hist_record(force) if not force and not self.history:is_current() then return end self.history:record({ pattern = self.input.filter.pattern, search = self.input.filter.search, live = self.opts.live, }) end function M:cwd() return self.input.filter.cwd end function M:set_cwd(cwd) self.input.filter:set_cwd(cwd) self.opts.cwd = cwd end --- Move the history cursor ---@param forward? boolean function M:hist(forward) self:hist_record() if forward then self.history:next() else self.history:prev() end local hist = self.history:get() --[[@as snacks.picker.history.Record]] self.opts.live = hist.live self.input:set(hist.pattern, hist.search) end --- Clears the selection, set the target to the current item, --- and refresh the finder and matcher. function M:refresh() self.list:set_selected() self.list:set_target() self:find({ refresh = true }) end --- Check if the finder and/or matcher need to run, --- based on the current pattern and search string. ---@param opts? { on_done?: fun(), refresh?: boolean } function M:find(opts) if self.closed then return end opts = opts or {} local filter = self.input.filter:clone({ trim = true }) local refresh = opts.refresh ~= false if filter.opts.transform then refresh = filter.opts.transform(self, filter) or refresh end self:hist_record() local finding = false if self.finder:init(filter) or refresh then finding = true self:update_titles() if self:count() > 0 then -- pause rapid list updates to prevent flickering self.list:pause(2000) end self.finder:run(self) end -- re-run matcher if finder or pattern changed if self.matcher:init(filter.pattern) or finding then self.matcher:run(self) if opts.on_done then if self.matcher.task:running() then self.matcher.task:on("done", vim.schedule_wrap(opts.on_done)) else opts.on_done() end end self.input:pause(60) self:progress() end end --- Get the active filter function M:filter() return self.input.filter:clone() end return M ================================================ FILE: lua/snacks/picker/core/preview.lua ================================================ ---@class snacks.picker.Preview ---@field item? snacks.picker.Item ---@field pos? snacks.picker.Pos ---@field win snacks.win ---@field filter? snacks.picker.Filter ---@field preview snacks.picker.preview ---@field state table ---@field main? number ---@field win_opts {main: snacks.win.Config|{}, layout: snacks.win.Config|{}, win: snacks.win.Config|{}} ---@field winhl string ---@field title? string ---@field split_layout? boolean ---@field opts? snacks.picker.previewers.Config ---@field _spinner? snacks.util.Spinner local M = {} M.__index = M ---@class snacks.picker.preview.ctx ---@field picker snacks.Picker ---@field item snacks.picker.Item ---@field prev? snacks.picker.Item ---@field preview snacks.picker.Preview ---@field buf number ---@field win number local ns = vim.api.nvim_create_namespace("snacks.picker.preview") local ns_loc = vim.api.nvim_create_namespace("snacks.picker.preview.loc") -- HACK: work-around for buffer-local window options mess. From the docs: -- > When editing a buffer that has been edited before, the options from the window -- > that was last closed are used again. If this buffer has been edited in this -- > window, the values from back then are used. Otherwise the values from the -- > last closed window where the buffer was edited last are used. vim.api.nvim_create_autocmd("BufWinEnter", { group = vim.api.nvim_create_augroup("snacks.picker.preview.wo", { clear = true }), callback = function(ev) local buf = ev.buf if not vim.b[buf].snacks_previewed then return end local win = vim.api.nvim_get_current_win() if buf ~= vim.api.nvim_win_get_buf(win) or vim.w[win].snacks_picker_preview or Snacks.util.is_float(win) then return end vim.b[buf].snacks_previewed = nil local reset = { "winhighlight", "cursorline", "number", "relativenumber", "signcolumn" } for _, k in ipairs(reset) do vim.api.nvim_set_option_value(k, nil, { win = win, scope = "local" }) end end, }) ---@param picker snacks.Picker function M.new(picker) local opts = picker.opts local self = setmetatable({}, M) self.opts = opts.previewers self.winhl = Snacks.picker.highlight.winhl("SnacksPickerPreview", { CursorLine = "Visual" }) local win_opts = Snacks.win.resolve( { title_pos = "center", minimal = false, wo = { cursorline = false, colorcolumn = "", number = opts.win.preview.minimal ~= true, relativenumber = false, list = false, }, }, opts.win.preview, { show = false, enter = false, width = 0, height = 0, on_win = function() self.item = nil self:reset() end, wo = { winhighlight = self.winhl, }, scratch_ft = "snacks_picker_preview", w = { snacks_picker_preview = true, }, } ) self.win_opts = { main = { relative = "win", backdrop = false, zindex = 40, -- Lower than default (50) so input/help windows stay on top }, layout = { backdrop = win_opts.backdrop == true, }, } self.win = Snacks.win(win_opts) self:update(picker) self.state = {} self.win:on("WinClosed", function() self:clear(self.win.buf) vim.schedule(function() local ei = vim.o.eventignore vim.o.eventignore = "all" for _, buf in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_loaded(buf) and vim.b[buf].snacks_picker_loaded and not vim.bo[buf].buflisted and #vim.fn.win_findbuf(buf) == 0 then vim.api.nvim_buf_delete(buf, { force = true }) end end vim.o.eventignore = ei end) end, { win = true }) self.preview = Snacks.picker.config.preview(opts) return self end function M:close() self.win:destroy() self.item = nil self.win_opts = { main = {}, layout = {}, win = {} } end ---@param picker snacks.Picker function M:update(picker) local main = picker.resolved_layout.preview == "main" and picker.main or nil self.main = main self.win_opts.main.win = main self.win.opts = vim.tbl_deep_extend("force", self.win.opts, main and self.win_opts.main or self.win_opts.layout) if not main then self.win.opts.relative = nil self.win.opts.win = nil self.win.layout = nil end local winhl = self.winhl if main then winhl = (vim.wo[main].winhighlight .. ",Normal:Normal," .. "CursorLine:SnacksPickerPreviewCursorLine"):gsub( "^,", "" ) end self.win.opts.wo.winhighlight = winhl end --- refresh the preview after layout change ---@param picker snacks.Picker function M:refresh(picker) self.item = nil self:reset() if self.main then self.win:update() end vim.schedule(function() picker:show_preview() end) end ---@param picker snacks.Picker ---@param opts? {force?: boolean} function M:show(picker, opts) if not self.win:valid() then return end opts = opts or {} self.split_layout = not picker.layout.root:is_floating() local item, prev = picker:current({ resolve = false }), self.item if not opts.force and self.item == item and self.pos == (item and item.pos or nil) then return end Snacks.picker.util.resolve(item) self.item = item self.filter = picker:filter() self.pos = item and item.pos or nil self:spinner(false) if item then local buf = self.win.buf local ok, err = pcall( self.preview, setmetatable({ preview = self, item = item, prev = prev, picker = picker, }, { __index = function(_, k) if k == "buf" then return self.win.buf elseif k == "win" then return self.win.win end end, }) ) if not ok then self:notify(err --[[@as string]], "error") end if self.win.buf ~= buf then self:clear(buf) end else self:reset() end end ---@param title? string function M:set_title(title) self.title = title end ---@param wo vim.wo|{} function M:wo(wo) if self.win:win_valid() then Snacks.util.wo(self.win.win, wo) end end ---@param buf? number function M:clear(buf) if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) vim.api.nvim_buf_clear_namespace(buf, ns_loc, 0, -1) end ---@param buf number function M:set_buf(buf) vim.b[buf].snacks_previewed = true self.win:set_buf(buf) if self.item and self.item.wo and self.win:win_valid() then Snacks.util.wo(self.win.win, self.item.wo) end end function M:reset() if not self.win:valid() then return end if self.win.scratch_buf and vim.api.nvim_buf_is_valid(self.win.scratch_buf) then self.win:set_buf(self.win.scratch_buf) else self.win:scratch() end vim.api.nvim_buf_clear_namespace(self.win.buf, -1, 0, -1) self:set_title() self:spinner(false) vim.treesitter.stop(self.win.buf) vim.bo[self.win.buf].modifiable = true self:set_lines({}) self:clear(self.win.buf) local ei = vim.o.eventignore vim.o.eventignore = "all" vim.bo[self.win.buf].filetype = "snacks_picker_preview" vim.bo[self.win.buf].syntax = "" vim.bo[self.win.buf].buftype = "nofile" self:wo({ cursorline = false }) self:wo(self.win.opts.wo) vim.o.eventignore = ei end function M:minimal() self:wo({ number = false, relativenumber = false, signcolumn = "no" }) end -- create a new scratch buffer function M:scratch() local buf = vim.api.nvim_create_buf(false, true) vim.bo[buf].bufhidden = "wipe" local ei = vim.o.eventignore vim.o.eventignore = "all" vim.bo[buf].filetype = "snacks_picker_preview" vim.o.eventignore = ei self.win:set_buf(buf) self.win:map() self:minimal() return buf end --- highlight the buffer ---@param opts? {file?:string, buf?:number, ft?:string, lang?:string} function M:highlight(opts) opts = opts or {} local ft = opts.ft if not ft and opts.buf then local modeline = Snacks.picker.util.modeline(opts.buf) ft = modeline and modeline.ft end if not ft and (opts.file or opts.buf) then ft = vim.filetype.match({ buf = opts.buf or self.win.buf, filename = opts.file, }) end local lang = Snacks.util.get_lang(opts.lang or ft) if lang == "markdown" then return self:markdown() end if not (lang and pcall(vim.treesitter.start, self.win.buf, lang)) and ft then vim.bo[self.win.buf].syntax = ft end end function M:ns() return ns end -- show the item location function M:loc() vim.api.nvim_buf_clear_namespace(self.win.buf, ns_loc, 0, -1) if not self.item then return end local line_count = vim.api.nvim_buf_line_count(self.win.buf) Snacks.picker.util.resolve_loc(self.item, self.win.buf) local function show(pos) local center = true if self.split_layout and self.main and self.item and self.item.buf then local main_buf = vim.api.nvim_win_get_buf(self.main) if main_buf == self.item.buf then center = false local view = vim.api.nvim_win_call(self.main, vim.fn.winsaveview) vim.api.nvim_win_call(self.win.win, function() vim.fn.winrestview(view) end) end end vim.api.nvim_win_set_cursor(self.win.win, pos) vim.api.nvim_win_call(self.win.win, function() if center then vim.cmd("norm! zzze") end self:wo({ cursorline = true }) end) end if self.item.pos and self.item.pos[1] > 0 and self.item.pos[1] <= line_count then show(self.item.pos) if self.item.positions then for _, extmark in ipairs(Snacks.picker.highlight.matches({}, self.item.positions)) do local col, row = extmark.col, self.item.pos[1] extmark.col = nil extmark.row = nil extmark.field = nil extmark.hl_group = "SnacksPickerSearch" pcall(vim.api.nvim_buf_set_extmark, self.win.buf, ns_loc, row - 1, col, extmark) end elseif self.item.end_pos then vim.api.nvim_buf_set_extmark(self.win.buf, ns_loc, self.item.pos[1] - 1, self.item.pos[2], { end_row = self.item.end_pos[1] - 1, end_col = self.item.end_pos[2], hl_group = "SnacksPickerSearch", }) elseif self.filter and vim.trim(self.filter.search) ~= "" then local ok, re = pcall(vim.regex, vim.trim(self.filter.search)) if ok and re then local start = self.item.pos[2] local from, to ---@type number?, number? pcall(function() from, to = re:match_line(self.win.buf, self.item.pos[1] - 1, start) end) if from and to then show({ self.item.pos[1], start + to }) -- make sure the to column is visible vim.api.nvim_buf_set_extmark(self.win.buf, ns_loc, self.item.pos[1] - 1, start + from, { end_col = start + to, hl_group = "SnacksPickerSearch", }) end end end elseif self.item.search then vim.api.nvim_win_call(self.win.win, function() if pcall(vim.cmd, ":0;" .. self.item.search) then vim.fn.histdel("search", -1) -- remove from search history vim.cmd("norm! zzze") self:wo({ cursorline = true }) end end) else -- no position info, go to top vim.api.nvim_win_set_cursor(self.win.win, { 1, 0 }) end end ---@param lines string[] ---@param offset? number function M:set_lines(lines, offset) lines = vim.split(table.concat(lines, "\n"), "\n", { plain = true }) vim.bo[self.win.buf].modifiable = true vim.api.nvim_buf_set_lines(self.win.buf, offset or 0, -1, false, lines) vim.bo[self.win.buf].modifiable = false end ---@param msg string ---@param level? "info" | "warn" | "error" ---@param opts? {item?:boolean} function M:notify(msg, level, opts) if not self.win:buf_valid() then Snacks.notify(msg, { level = level }) return end self:reset() level = level or "info" local lines = vim.split(level .. ": " .. msg, "\n", { plain = true }) local msg_len = #lines if not (opts and opts.item == false) then lines[#lines + 1] = "" vim.list_extend(lines, vim.split(vim.inspect(self.item), "\n", { plain = true })) end self:set_lines(lines) vim.api.nvim_buf_set_extmark(self.win.buf, ns, 0, 0, { hl_group = "Diagnostic" .. level:sub(1, 1):upper() .. level:sub(2), end_row = msg_len, }) self:highlight({ lang = "lua" }) end function M:markdown() if not self.win:valid() then return end require("snacks.picker.util.markdown").render(self.win.buf) end function M:spinner(enable) if enable == false then if self._spinner then self._spinner:stop() self._spinner = nil end return end assert(self.win:buf_valid(), "invalid buffer") local ret = Snacks.picker.util.spinner(self.win.buf) self._spinner = ret return ret end return M ================================================ FILE: lua/snacks/picker/core/score.lua ================================================ --- This is a port of the scoring logic from fzf. See: --- https://github.com/junegunn/fzf/blob/master/src/algo/algo.go ---@class snacks.picker.Score ---@field score number ---@field consecutive number ---@field prev? number ---@field prev_class number ---@field is_file boolean ---@field first_bonus number ---@field str string ---@field opts snacks.picker.matcher.Config ---@field bonus_matrix number[][] ---@field bonus_boundary_white number ---@field bonus_boundary_delimiter number local M = {} M.__index = M -- Scoring constants. Same as fzf: local SCORE_MATCH = 16 local SCORE_GAP_START = -3 local SCORE_GAP_EXTENSION = -1 local BONUS_BOUNDARY = SCORE_MATCH / 2 -- 8 local BONUS_NONWORD = SCORE_MATCH / 2 -- 8 local BONUS_CAMEL_123 = BONUS_BOUNDARY - 1 -- 7 local BONUS_CONSECUTIVE = -(SCORE_GAP_START + SCORE_GAP_EXTENSION) -- 4 local BONUS_FIRST_CHAR_MULTIPLIER = 2 local BONUS_NO_PATH_SEP = BONUS_BOUNDARY - 2 -- added when there is no path separator following the from position local PATH_SEP = package.config:sub(1, 1) -- ASCII char classes (simplified); adapt as needed: local CHAR_WHITE = 0 local CHAR_NONWORD = 1 local CHAR_DELIMITER = 2 local CHAR_LOWER = 3 local CHAR_UPPER = 4 local CHAR_LETTER = 5 local CHAR_NUMBER = 6 -- Table to classify ASCII bytes quickly: local CHAR_CLASS = {} ---@type number[] for b = 0, 255 do local c = CHAR_NONWORD local char = string.char(b) if char:match("%s") then c = CHAR_WHITE elseif char:match("[/\\,:;|]") then c = CHAR_DELIMITER elseif b >= 48 and b <= 57 then -- '0'..'9' c = CHAR_NUMBER elseif b >= 65 and b <= 90 then -- 'A'..'Z' c = CHAR_UPPER elseif b >= 97 and b <= 122 then -- 'a'..'z' c = CHAR_LOWER end CHAR_CLASS[b] = c end ---@param opts? snacks.picker.matcher.Config function M.new(opts) local self = setmetatable({}, M) self.opts = opts or {} self.score = 0 self.is_file = true self.consecutive = 0 self.prev_class = CHAR_WHITE self.str = "" self.first_bonus = 0 self.bonus_matrix = {} self.bonus_boundary_white = BONUS_BOUNDARY + 2 self.bonus_boundary_delimiter = BONUS_BOUNDARY + 1 if self.opts.history_bonus then self.bonus_boundary_white = BONUS_BOUNDARY self.bonus_boundary_delimiter = BONUS_BOUNDARY end self:compute_bonus_matrix() return self end function M:compute_bonus_matrix() for prev = 0, 6 do self.bonus_matrix[prev] = {} for curr = 0, 6 do self.bonus_matrix[prev][curr] = self:compute_bonus(prev, curr) end end end -- Helper to compute boundary/camelCase bonuses (mimics fzf approach) function M:compute_bonus(prev, curr) -- If transitioning from whitespace/delimiter/nonword to letter => boundary bonus if curr > CHAR_NONWORD then if prev == CHAR_WHITE then return self.bonus_boundary_white elseif prev == CHAR_DELIMITER then return self.bonus_boundary_delimiter elseif prev == CHAR_NONWORD then return BONUS_BOUNDARY end end -- camelCase transitions or letter->number transitions if (prev == CHAR_LOWER and curr == CHAR_UPPER) or (prev ~= CHAR_NUMBER and curr == CHAR_NUMBER) then return BONUS_CAMEL_123 end if curr == CHAR_NONWORD or curr == CHAR_DELIMITER then return BONUS_NONWORD elseif curr == CHAR_WHITE then return BONUS_BOUNDARY + 2 end return 0 end ---@param str string ---@param pos number function M:is_left_boundary(str, pos) return pos == 1 or CHAR_CLASS[str:byte(pos - 1)] < CHAR_LOWER end ---@param str string ---@param pos number function M:is_right_boundary(str, pos) return pos == #str or CHAR_CLASS[str:byte(pos + 1)] < CHAR_LOWER end ---@param str string ---@param first number function M:init(str, first) self.str = str self.score = 0 self.consecutive = 0 self.prev_class = CHAR_WHITE self.prev = nil self.first_bonus = 0 if first > 1 then self.prev_class = CHAR_CLASS[str:byte(first - 1)] or CHAR_NONWORD end if self.is_file and self.opts.filename_bonus and not str:find(PATH_SEP, first + 1, true) and not (PATH_SEP ~= "/" and str:find("/", first + 1, true)) then self.score = self.score + BONUS_NO_PATH_SEP end self:update(first) end ---@param pos number function M:update(pos) local b = self.str:byte(pos) local class = CHAR_CLASS[b] or CHAR_NONWORD local bonus = 0 local gap = self.prev and pos - self.prev - 1 or 0 if gap > 0 then self.prev_class = CHAR_CLASS[self.str:byte(pos - 1)] or CHAR_NONWORD bonus = self.bonus_matrix[self.prev_class][class] or 0 self.score = self.score + SCORE_GAP_START + (gap - 1) * SCORE_GAP_EXTENSION self.consecutive = 0 self.first_bonus = 0 else bonus = self.bonus_matrix[self.prev_class][class] or 0 -- No gap => consecutive chunk if self.consecutive == 0 then -- New chunk => store the boundary/camel bonus self.first_bonus = bonus else -- If we see a bigger boundary/camel bonus than what started the chunk, update if bonus >= BONUS_BOUNDARY and bonus > self.first_bonus then self.first_bonus = bonus end -- Take the max of the current bonus, the chunk's firstBonus, or BONUS_CONSECUTIVE bonus = math.max(bonus, self.first_bonus, BONUS_CONSECUTIVE) end self.consecutive = self.consecutive + 1 end if not self.prev then bonus = (bonus * BONUS_FIRST_CHAR_MULTIPLIER) end self.score = self.score + SCORE_MATCH + bonus -- Update for next iteration self.prev_class = class self.prev = pos end ---@param str string ---@param from number ---@param to number function M:get(str, from, to) self:init(str, from) for i = from + 1, to do self:update(i) end return self.score end return M ================================================ FILE: lua/snacks/picker/format.lua ================================================ ---@class snacks.picker.formatters ---@field [string] snacks.picker.format local M = {} local uv = vim.uv or vim.loop function M.severity(item, picker) local ret = {} ---@type snacks.picker.Highlight[] local severity = item.severity severity = type(severity) == "number" and vim.diagnostic.severity[severity] or severity if not severity or type(severity) == "number" then return ret end ---@cast severity string local lower = severity:lower() local cap = severity:sub(1, 1):upper() .. lower:sub(2) if picker.opts.formatters.severity.pos == "right" then return { { col = 0, virt_text = { { picker.opts.icons.diagnostics[cap], "Diagnostic" .. cap } }, virt_text_pos = "right_align", hl_mode = "combine", }, } end if picker.opts.formatters.severity.icons then ret[#ret + 1] = { picker.opts.icons.diagnostics[cap], "Diagnostic" .. cap, virtual = true } ret[#ret + 1] = { " ", virtual = true } end if picker.opts.formatters.severity.level then ret[#ret + 1] = { lower:upper(), "Diagnostic" .. cap, virtual = true } ret[#ret + 1] = { " ", virtual = true } end return ret end function M.filename(item, picker) ---@type snacks.picker.Highlight[] local ret = {} if not item.file then return ret end local path = Snacks.picker.util.path(item) or item.file if picker.opts.icons.files.enabled ~= false then local name, cat = path, (item.dir and "directory" or "file") if item.buf and vim.api.nvim_buf_is_loaded(item.buf) and vim.bo[item.buf].buftype ~= "" then name = vim.bo[item.buf].filetype cat = "filetype" end local icon, hl = Snacks.util.icon(name, cat, { fallback = picker.opts.icons.files, }) if item.buftype == "terminal" then icon, hl = " ", "Special" end if item.dir and item.open then icon = picker.opts.icons.files.dir_open end icon = Snacks.picker.util.align(icon, picker.opts.formatters.file.icon_width or 2) ret[#ret + 1] = { icon, hl, virtual = true } end local base_hl = item.dir and "SnacksPickerDirectory" or "SnacksPickerFile" local function is(prop) local it = item while it do if it[prop] then return true end it = it.parent end end if is("ignored") then base_hl = "SnacksPickerPathIgnored" elseif item.filename_hl then base_hl = item.filename_hl elseif is("hidden") then base_hl = "SnacksPickerPathHidden" end local dir_hl = "SnacksPickerDir" if picker.opts.formatters.file.filename_only then path = vim.fn.fnamemodify(item.file, ":t") path = path == "" and item.file or path ret[#ret + 1] = { path, base_hl, field = "file" } else ret[#ret + 1] = { "", resolve = function(max_width) local truncpath = Snacks.picker.util.truncpath( path, math.max(max_width, picker.opts.formatters.file.min_width or 20), { cwd = picker:cwd(), kind = picker.opts.formatters.file.truncate } ) local dir, base = truncpath:match("^(.*)/(.+)$") local resolved = {} ---@type snacks.picker.Highlight[] if base and dir then if picker.opts.formatters.file.filename_first then resolved[#resolved + 1] = { base, base_hl, field = "file" } resolved[#resolved + 1] = { " " } resolved[#resolved + 1] = { dir, dir_hl, field = "file" } else resolved[#resolved + 1] = { dir .. "/", dir_hl, field = "file" } resolved[#resolved + 1] = { base, base_hl, field = "file" } end else resolved[#resolved + 1] = { truncpath, base_hl, field = "file" } end return resolved end, } end if item.pos and item.pos[1] > 0 then ret[#ret + 1] = { ":", "SnacksPickerDelim" } ret[#ret + 1] = { tostring(item.pos[1]), "SnacksPickerRow" } if item.pos[2] > 0 then ret[#ret + 1] = { ":", "SnacksPickerDelim" } ret[#ret + 1] = { tostring(item.pos[2]), "SnacksPickerCol" } end end ret[#ret + 1] = { " " } if item.type == "link" then local real = uv.fs_realpath(item.file) local broken = not real real = real or uv.fs_readlink(item.file) if real then ret[#ret + 1] = { "-> ", "SnacksPickerDelim" } ret[#ret + 1] = { Snacks.picker.util.truncpath(real, 20), broken and "SnacksPickerLinkBroken" or "SnacksPickerLink" } ret[#ret + 1] = { " " } end end return ret end function M.file(item, picker) ---@type snacks.picker.Highlight[] local ret = {} if item.label then ret[#ret + 1] = { item.label, "SnacksPickerLabel" } ret[#ret + 1] = { " ", virtual = true } end if item.parent then vim.list_extend(ret, M.tree(item, picker)) end if item.status then vim.list_extend(ret, M.file_git_status(item, picker)) end if item.severity then vim.list_extend(ret, M.severity(item, picker)) end vim.list_extend(ret, M.filename(item, picker)) if item.comment then table.insert(ret, { item.comment, "SnacksPickerComment" }) table.insert(ret, { " " }) end if item.line then if item.positions then local offset = Snacks.picker.highlight.offset(ret) Snacks.picker.highlight.matches(ret, item.positions, offset) end Snacks.picker.highlight.format(item, item.line, ret) table.insert(ret, { " " }) end return ret end function M.commit_message(item, picker) local ret = {} ---@type snacks.picker.Highlight[] local msg = item.msg ---@type string local type, scope, breaking, body = msg:match("^(%S+)%s*(%(.-%))(!?):%s*(.*)$") if not type then type, breaking, body = msg:match("^(%S+)(!?):%s*(.*)$") end local msg_hl = "SnacksPickerGitMsg" if type and body then local dimmed = vim.tbl_contains({ "chore", "bot", "build", "ci", "style", "test" }, type) msg_hl = dimmed and "SnacksPickerDimmed" or "SnacksPickerGitMsg" ret[#ret + 1] = { type, breaking ~= "" and "SnacksPickerGitBreaking" or dimmed and "SnacksPickerBold" or "SnacksPickerGitType" } if scope and scope ~= "" then ret[#ret + 1] = { scope, "SnacksPickerGitScope" } end if breaking ~= "" then ret[#ret + 1] = { "!", "SnacksPickerGitBreaking" } end ret[#ret + 1] = { ":", "SnacksPickerDelim" } ret[#ret + 1] = { " " } msg = body end ret[#ret + 1] = { msg, msg_hl } Snacks.picker.highlight.markdown(ret) Snacks.picker.highlight.highlight(ret, { ["#%d+"] = "SnacksPickerGitIssue", }) return ret end function M.git_log(item, picker) local a = Snacks.picker.util.align local ret = {} ---@type snacks.picker.Highlight[] ret[#ret + 1] = { picker.opts.icons.git.commit, "SnacksPickerGitCommit" } local c = item.commit or item.branch or "HEAD" ret[#ret + 1] = { a(c, 8, { truncate = true }), "SnacksPickerGitCommit" } ret[#ret + 1] = { " " } if item.date then ret[#ret + 1] = { a(item.date, 16), "SnacksPickerGitDate" } end ret[#ret + 1] = { " " } Snacks.picker.highlight.extend(ret, M.commit_message(item, picker)) if item.author then ret[#ret + 1] = { " <" .. item.author .. ">", "SnacksPickerGitAuthor" } end return ret end function M.git_branch(item, picker) local a = Snacks.picker.util.align local ret = {} ---@type snacks.picker.Highlight[] if item.current then ret[#ret + 1] = { a("", 2), "SnacksPickerGitBranchCurrent" } else ret[#ret + 1] = { a("", 2) } end if item.detached then ret[#ret + 1] = { a("(detached HEAD)", 30, { truncate = true }), "SnacksPickerGitDetached" } else ret[#ret + 1] = { a(item.branch, 30, { truncate = true }), "SnacksPickerGitBranch" } end ret[#ret + 1] = { " " } Snacks.picker.highlight.extend(ret, M.git_log(item, picker)) return ret end function M.git_stash(item, picker) local a = Snacks.picker.util.align local ret = {} ---@type snacks.picker.Highlight[] ret[#ret + 1] = { a(item.stash, 10), "SnacksPickerIdx" } ret[#ret + 1] = { " " } ret[#ret + 1] = { a(item.branch, 10, { truncate = true }), "SnacksPickerGitBranch" } ret[#ret + 1] = { " " } Snacks.picker.highlight.extend(ret, M.git_log(item, picker)) return ret end function M.tree(item, picker) local ret = {} ---@type snacks.picker.Highlight[] local icons = picker.opts.icons.tree local indent = {} ---@type string[] local node = item while node and node.parent do local is_last, icon = node.last, "" if node ~= item then icon = is_last and " " or icons.vertical else icon = is_last and icons.last or icons.middle end table.insert(indent, 1, icon) node = node.parent end ret[#ret + 1] = { table.concat(indent), "SnacksPickerTree" } return ret end function M.undo(item, picker) local ret = {} ---@type snacks.picker.Highlight[] local entry = item.item ---@type vim.fn.undotree.entry local a = Snacks.picker.util.align if item.current then ret[#ret + 1] = { a("", 2), "SnacksPickerUndoCurrent" } else ret[#ret + 1] = { a("", 2) } end vim.list_extend(ret, M.tree(item, picker)) local w = vim.api.nvim_strwidth(ret[#ret][1]) ret[#ret + 1] = { tostring(entry.seq), "SnacksPickerIdx" } ret[#ret + 1] = { " " } ret[#ret + 1] = { a(" ", 8 - w - #tostring(entry.seq)) } ret[#ret + 1] = { a(Snacks.picker.util.reltime(entry.time), 15), "SnacksPickerTime" } ret[#ret + 1] = { " " } local function num(v, prefix) v = v or 0 return a((v and v > 0 and prefix .. v or ""), 4) end ret[#ret + 1] = { num(item.added, "+"), "SnacksPickerUndoAdded" } ret[#ret + 1] = { " " } ret[#ret + 1] = { num(item.removed, "-"), "SnacksPickerUndoRemoved" } if entry.save then ret[#ret + 1] = { " " } ret[#ret + 1] = { a(picker.opts.icons.undo.saved, 2), "SnacksPickerUndoSaved" } end return ret end function M.lsp_symbol(item, picker) local opts = picker.opts --[[@as snacks.picker.lsp.symbols.Config]] local ret = {} ---@type snacks.picker.Highlight[] if item.tree and not opts.workspace then vim.list_extend(ret, M.tree(item, picker)) end local kind = item.lsp_kind or item.kind or "Unknown" ---@type string kind = picker.opts.icons.kinds[kind] and kind or "Unknown" local kind_hl = "SnacksPickerIcon" .. kind ret[#ret + 1] = { picker.opts.icons.kinds[kind], kind_hl } ret[#ret + 1] = { " " } local name = vim.trim(item.name:gsub("\r?\n", " ")) name = name == "" and item.detail or name Snacks.picker.highlight.format(item, name, ret) if opts.workspace then local offset = Snacks.picker.highlight.offset(ret, { char_idx = true }) ret[#ret + 1] = { Snacks.picker.util.align(" ", 40 - offset) } vim.list_extend(ret, M.filename(item, picker)) end return ret end ---@param opts snacks.picker.ui_select.Opts ---@return snacks.picker.format function M.ui_select(opts) return function(item, picker) local count = picker:count() local ret = {} ---@type snacks.picker.Highlight[] local idx = tostring(item.idx) idx = (" "):rep(#tostring(count) - #idx) .. idx ret[#ret + 1] = { idx .. ".", "SnacksPickerIdx" } ret[#ret + 1] = { " " } if opts.kind == "codeaction" then ---@type lsp.Command|lsp.CodeAction, lsp.HandlerContext local action, ctx = item.item.action, item.item.ctx local client = vim.lsp.get_client_by_id(ctx.client_id) ret[#ret + 1] = { action.title } if client then ret[#ret + 1] = { " " } ret[#ret + 1] = { ("[%s]"):format(client.name), "SnacksPickerSpecial" } end elseif opts.format_item then local t = opts.format_item(item.item, true) if type(t) == "string" then ret[#ret + 1] = { t } elseif type(t) == "table" then vim.list_extend(ret, t) end else ret[#ret + 1] = { tostring(item.item) } end return ret end end function M.lines(item) local ret = {} ---@type snacks.picker.Highlight[] local line_count = vim.api.nvim_buf_line_count(item.buf) local idx = Snacks.picker.util.align(tostring(item.idx), #tostring(line_count), { align = "right" }) ret[#ret + 1] = { idx, "LineNr", virtual = true } ret[#ret + 1] = { " ", virtual = true } ret[#ret + 1] = { item.text } local offset = #idx + 2 for _, extmark in ipairs(item.highlights or {}) do extmark = vim.deepcopy(extmark) if type(extmark[1]) ~= "string" then ---@cast extmark snacks.picker.Extmark extmark.col = extmark.col + offset if extmark.end_col then extmark.end_col = extmark.end_col + offset end end ret[#ret + 1] = extmark end return ret end function M.text(item, picker) local ret = {} ---@type snacks.picker.Highlight[] local ft = item.ft or picker.opts.formatters.text.ft if ft then Snacks.picker.highlight.format(item, item.text, ret, { lang = ft }) else ret[#ret + 1] = { item.text, item.text_hl } end return ret end function M.command(item) local ret = {} ---@type snacks.picker.Highlight[] ret[#ret + 1] = { item.cmd, "SnacksPickerCmd" .. (item.cmd:find("^[a-z]") and "Builtin" or "") } if item.desc then ret[#ret + 1] = { " " } ret[#ret + 1] = { item.desc, "SnacksPickerDesc" } end return ret end function M.diagnostic(item, picker) local ret = {} ---@type snacks.picker.Highlight[] local diag = item.item ---@type vim.Diagnostic if item.severity then vim.list_extend(ret, M.severity(item, picker)) end local message = diag.message ret[#ret + 1] = { message } Snacks.picker.highlight.markdown(ret) ret[#ret + 1] = { " " } if diag.source then ret[#ret + 1] = { diag.source, "SnacksPickerDiagnosticSource" } ret[#ret + 1] = { " " } end if diag.code then ret[#ret + 1] = { ("(%s)"):format(diag.code), "SnacksPickerDiagnosticCode" } ret[#ret + 1] = { " " } end vim.list_extend(ret, M.filename(item, picker)) return ret end function M.autocmd(item) local ret = {} ---@type snacks.picker.Highlight[] ---@type vim.api.keyset.get_autocmds.ret local au = item.item local a = Snacks.picker.util.align ret[#ret + 1] = { a(au.event, 15), "SnacksPickerAuEvent" } ret[#ret + 1] = { " " } ret[#ret + 1] = { a(au.pattern, 10), "SnacksPickerAuPattern" } ret[#ret + 1] = { " " } ret[#ret + 1] = { a(tostring(au.group_name or ""), 15), "SnacksPickerAuGroup" } ret[#ret + 1] = { " " } if au.command ~= "" then Snacks.picker.highlight.format(item, au.command, ret, { lang = "vim" }) else ret[#ret + 1] = { "callback", "Function" } end return ret end function M.hl(item) local ret = {} ---@type snacks.picker.Highlight[] ret[#ret + 1] = { item.hl_group, item.hl_group } return ret end function M.man(item) local a = Snacks.picker.util.align local ret = {} ---@type snacks.picker.Highlight[] ret[#ret + 1] = { a(item.page, 20), "SnacksPickerManPage" } ret[#ret + 1] = { " " } ret[#ret + 1] = { ("(%s)"):format(item.section), "SnacksPickerManSection" } ret[#ret + 1] = { " " } ret[#ret + 1] = { item.desc, "SnacksPickerManDesc" } return ret end -- Pretty keymaps using which-key icons when available function M.keymap(item, picker) local ret = {} ---@type snacks.picker.Highlight[] ---@type vim.api.keyset.get_keymap local k = item.item local a = Snacks.picker.util.align if package.loaded["which-key"] then local Icons = require("which-key.icons") local icon, hl = Icons.get({ keymap = k, desc = k.desc }) if icon then ret[#ret + 1] = { a(icon, 3), hl } else ret[#ret + 1] = { " " } end end local lhs = Snacks.util.normkey(k.lhs) ret[#ret + 1] = { k.mode, "SnacksPickerKeymapMode" } ret[#ret + 1] = { " " } ret[#ret + 1] = { a(lhs, 15), "SnacksPickerKeymapLhs" } ret[#ret + 1] = { " " } local icon_nowait = picker.opts.icons.keymaps.nowait if k.nowait == 1 then ret[#ret + 1] = { icon_nowait, "SnacksPickerKeymapNowait" } else ret[#ret + 1] = { (" "):rep(vim.api.nvim_strwidth(icon_nowait)) } end ret[#ret + 1] = { " " } if k.buffer and k.buffer > 0 then ret[#ret + 1] = { a("buf:" .. k.buffer, 6), "SnacksPickerBufNr" } else ret[#ret + 1] = { a("", 6) } end ret[#ret + 1] = { " " } local rhs_len = 0 if k.rhs and k.rhs ~= "" then local rhs = k.rhs or "" rhs_len = #rhs local cmd = rhs:lower():find("") if cmd then ret[#ret + 1] = { rhs:sub(1, cmd + 4), "NonText" } rhs = rhs:sub(cmd + 5) local cr = rhs:lower():find("$") if cr then rhs = rhs:sub(1, cr - 1) end Snacks.picker.highlight.format(item, rhs, ret, { lang = "vim" }) if cr then ret[#ret + 1] = { "", "NonText" } end elseif rhs:lower():find("^") then ret[#ret + 1] = { "", "NonText" } local plug = rhs:sub(7):gsub("^%(", ""):gsub("%)$", "") ret[#ret + 1] = { "(", "SnacksPickerDelim" } Snacks.picker.highlight.format(item, plug, ret, { lang = "vim" }) ret[#ret + 1] = { ")", "SnacksPickerDelim" } elseif rhs:find("v:lua%.") then ret[#ret + 1] = { "v:lua", "NonText" } ret[#ret + 1] = { ".", "SnacksPickerDelim" } Snacks.picker.highlight.format(item, rhs:sub(7), ret, { lang = "lua" }) else ret[#ret + 1] = { k.rhs, "SnacksPickerKeymapRhs" } end else ret[#ret + 1] = { "callback", "Function" } rhs_len = 8 end if rhs_len < 15 then ret[#ret + 1] = { (" "):rep(15 - rhs_len) } end ret[#ret + 1] = { " " } ret[#ret + 1] = { a(k.desc or "", 20) } if item.file then ret[#ret + 1] = { " " } vim.list_extend(ret, M.filename(item, picker)) end return ret end function M.git_status(item, picker) local status = item.status if not status and item.block then local block = item.block ---@type snacks.picker.diff.Block status = block.new and "A" or block.delete and "D" or block.rename and "R" or block.copy and "C" or "M" status = block.unmerged and (status .. status) or item.staged and (status .. " ") or (" " .. status) elseif not status then return M.filename(item, picker) end local ret = {} ---@type snacks.picker.Highlight[] local a = Snacks.picker.util.align local s = vim.trim(status):sub(1, 1) local hls = { ["A"] = "SnacksPickerGitStatusAdded", ["M"] = "SnacksPickerGitStatusModified", ["D"] = "SnacksPickerGitStatusDeleted", ["R"] = "SnacksPickerGitStatusRenamed", ["C"] = "SnacksPickerGitStatusCopied", ["?"] = "SnacksPickerGitStatusUntracked", } local hl = hls[s] or "SnacksPickerGitStatus" hl = status:sub(1, 1) == "M" and "SnacksPickerGitStatusStaged" or hl ret[#ret + 1] = { a(status, 2, { align = "right" }), hl } ret[#ret + 1] = { " " } if item.rename then local file = item.file item.file = item.rename item._path = nil vim.list_extend(ret, M.filename(item, picker)) item.file = file item._path = nil ret[#ret + 1] = { "-> ", "SnacksPickerDelim" } ret[#ret + 1] = { " " } end vim.list_extend(ret, M.filename(item, picker)) return ret end function M.file_git_status(item, picker) local ret = {} ---@type snacks.picker.Highlight[] local status = require("snacks.picker.source.git").git_status(item.status) local hl = "SnacksPickerGitStatus" if status.unmerged then hl = "SnacksPickerGitStatusUnmerged" elseif status.staged then hl = "SnacksPickerGitStatusStaged" else hl = "SnacksPickerGitStatus" .. status.status:sub(1, 1):upper() .. status.status:sub(2) end if picker.opts.formatters.file.git_status_hl then item.filename_hl = hl end local icon = status.status:sub(1, 1):upper() icon = status.status == "untracked" and "?" or status.status == "ignored" and "!" or icon if picker.opts.icons.git.enabled then icon = picker.opts.icons.git[status.unmerged and "unmerged" or status.status] or icon --[[@as string]] if status.staged then icon = picker.opts.icons.git.staged end end ret[#ret + 1] = { col = 0, virt_text = { { icon, hl }, { " " } }, virt_text_pos = "right_align", hl_mode = "combine", } return ret end function M.register(item) local ret = {} ---@type snacks.picker.Highlight[] ret[#ret + 1] = { " " } ret[#ret + 1] = { "[", "SnacksPickerDelim" } ret[#ret + 1] = { item.reg, "SnacksPickerRegister" } ret[#ret + 1] = { "]", "SnacksPickerDelim" } ret[#ret + 1] = { " " } ret[#ret + 1] = { item.value } return ret end function M.buffer(item, picker) local ret = {} ---@type snacks.picker.Highlight[] ret[#ret + 1] = { Snacks.picker.util.align(tostring(item.buf), 3), "SnacksPickerBufNr" } ret[#ret + 1] = { " " } ret[#ret + 1] = { Snacks.picker.util.align(item.flags, 2, { align = "right" }), "SnacksPickerBufFlags" } ret[#ret + 1] = { " " } vim.list_extend(ret, M.filename(item, picker)) if item.buftype ~= "" then ret[#ret + 1] = { " " } vim.list_extend(ret, { { "[", "SnacksPickerDelim" }, { item.buftype, "SnacksPickerBufType" }, { "]", "SnacksPickerDelim" }, }) end if item.name == "" and item.filetype ~= "" then ret[#ret + 1] = { " " } vim.list_extend(ret, { { "[", "SnacksPickerDelim" }, { item.filetype, "SnacksPickerFileType" }, { "]", "SnacksPickerDelim" }, }) end return ret end function M.selected(item, picker) local a = Snacks.picker.util.align local selected = picker.opts.icons.ui.selected local unselected = picker.opts.icons.ui.unselected local width = math.max(vim.api.nvim_strwidth(selected), vim.api.nvim_strwidth(unselected)) local ret = {} ---@type snacks.picker.Highlight[] if picker.list:is_selected(item) then ret[#ret + 1] = { a(selected, width), "SnacksPickerSelected", virtual = true } elseif picker.opts.formatters.selected.unselected then ret[#ret + 1] = { a(unselected, width), "SnacksPickerUnselected", virtual = true } else ret[#ret + 1] = { a("", width) } end return ret end function M.debug(item, picker) local score = item.score if not picker.matcher.sorting then score = picker.matcher.DEFAULT_SCORE if item.score_add then score = score + item.score_add end if item.score_mul then score = score * item.score_mul end end local ret = {} ---@type snacks.picker.Highlight[] ret[#ret + 1] = { ("%.2f "):format(score), "Number" } return ret end function M.icon(item, picker) local a = Snacks.picker.util.align ---@cast item snacks.picker.Icon local ret = {} ---@type snacks.picker.Highlight[] local icon_width = vim.api.nvim_strwidth(item.icon) ret[#ret + 1] = { a(item.icon, icon_width > 3 and 15 or 3), "SnacksPickerIcon" } ret[#ret + 1] = { " " } ret[#ret + 1] = { a(item.source, 10), "SnacksPickerIconSource" } ret[#ret + 1] = { " " } ret[#ret + 1] = { a(item.name, 30), "SnacksPickerIconName" } ret[#ret + 1] = { " " } ret[#ret + 1] = { a(item.category, 8), "SnacksPickerIconCategory" } return ret end function M.notification(item, picker) local a = Snacks.picker.util.align local ret = {} ---@type snacks.picker.Highlight[] local notif = item.item ---@type snacks.notifier.Notif ret[#ret + 1] = { a(os.date("%R", notif.added), 5), "SnacksPickerTime" } ret[#ret + 1] = { " " } if item.severity then vim.list_extend(ret, M.severity(item, picker)) end ret[#ret + 1] = { " " } ret[#ret + 1] = { a(notif.title or "", 15), "SnacksNotifierHistoryTitle" } ret[#ret + 1] = { " " } ret[#ret + 1] = { notif.msg, "SnacksPickerNotificationMessage" } Snacks.picker.highlight.markdown(ret) -- ret[#ret + 1] = { " " } return ret end return M ================================================ FILE: lua/snacks/picker/init.lua ================================================ ---@class snacks.picker ---@field actions snacks.picker.actions ---@field config snacks.picker.config ---@field format snacks.picker.formatters ---@field preview snacks.picker.previewers ---@field sort snacks.picker.sorters ---@field util snacks.picker.util ---@field current? snacks.Picker ---@field highlight snacks.picker.highlight ---@field resume fun(opts?: snacks.picker.Config):snacks.Picker ---@field sources snacks.picker.sources.Config ---@overload fun(opts: snacks.picker.Config): snacks.Picker ---@overload fun(source: string, opts: snacks.picker.Config): snacks.Picker local M = setmetatable({}, { __call = function(M, ...) return M.pick(...) end, ---@param M snacks.picker __index = function(M, k) if type(k) ~= "string" then return end local mods = { "actions", "config", "format", "preview", "util", "sort", highlight = "util.highlight", sources = "config.sources", } for m, mod in pairs(mods) do mod = mod == k and k or m == k and mod or nil if mod then ---@diagnostic disable-next-line: no-unknown M[k] = require("snacks.picker." .. mod) return rawget(M, k) end end return M.config.wrap(k, { check = true }) end, }) ---@type snacks.meta.Meta M.meta = { desc = "Picker for selecting items", needs_setup = true, merge = { config = "config.defaults", picker = "core.picker", "actions" }, } ---@class snacks.picker.resume.Opts ---@field source? string ---@field include? string[] ---@field exclude? string[] -- create actual picker functions for autocomplete vim.defer_fn(function() M.config.setup() end, 10) --- Create a new picker ---@param source? string ---@param opts? snacks.picker.Config ---@overload fun(opts: snacks.picker.Config): snacks.Picker function M.pick(source, opts) if not opts and type(source) == "table" then opts, source = source, nil end opts = opts or {} opts.source = source or opts.source -- Show pickers if no source, items or finder is provided if not (opts.source or opts.items or opts.finder or opts.multi) then opts.source = "pickers" return M.pick(opts) end local current = opts.source and M.get({ source = opts.source })[1] if current then current:close() return end return require("snacks.picker.core.picker").new(opts) end --- Implementation for `vim.ui.select` ---@type snacks.picker.ui_select function M.select(...) return require("snacks.picker.select").select(...) end ---@private function M.setup() if M.config.get().ui_select then vim.ui.select = M.select end end ---@private function M.health() require("snacks.picker.core._health").health() end --- Get active pickers, optionally filtered by source, --- or the current tab ---@param opts? {source?: string, tab?: boolean} tab defaults to true function M.get(opts) return require("snacks.picker.core.picker").get(opts) end ---@param opts? snacks.picker.resume.Opts ---@overload fun(source:string):snacks.Picker? ---@return snacks.Picker? function M.resume(opts) return require("snacks.picker.resume").resume(opts) end return M ================================================ FILE: lua/snacks/picker/preview.lua ================================================ ---@class snacks.picker.previewers local M = {} local uv = vim.uv or vim.loop local ns = vim.api.nvim_create_namespace("snacks.picker.preview") ---@param ctx snacks.picker.preview.ctx function M.directory(ctx) ctx.preview:reset() ctx.preview:minimal() local path = Snacks.picker.util.path(ctx.item) if not path then ctx.preview:notify("Item has no `file`", "error") return end local name = vim.fn.fnamemodify(path, ":t") ctx.preview:set_title(ctx.item.title or name) local ls = {} ---@type {file:string, type:"file"|"directory"}[] for file, t in vim.fs.dir(path) do t = t or Snacks.util.path_type(path .. "/" .. file) ls[#ls + 1] = { file = file, type = t } end ctx.preview:set_lines(vim.split(string.rep("\n", #ls), "\n")) table.sort(ls, function(a, b) if a.type ~= b.type then return a.type == "directory" end return a.file < b.file end) for i, item in ipairs(ls) do local is_dir = item.type == "directory" local cat = is_dir and "directory" or "file" local hl = is_dir and "Directory" or nil local icon, icon_hl = Snacks.util.icon(item.file, cat, { fallback = ctx.picker.opts.icons.files, }) local line = { { icon .. " ", icon_hl }, { item.file, hl } } vim.api.nvim_buf_set_extmark(ctx.buf, ns, i - 1, 0, { virt_text = line, }) end end ---@param ctx snacks.picker.preview.ctx function M.image(ctx) local buf = ctx.preview:scratch() ctx.preview:set_title(ctx.item.title or vim.fn.fnamemodify(ctx.item.file, ":t")) Snacks.image.buf.attach(buf, { src = Snacks.picker.util.path(ctx.item) }) end ---@param ctx snacks.picker.preview.ctx function M.none(ctx) ctx.preview:reset() ctx.preview:notify("no preview available", "warn") end ---@param ctx snacks.picker.preview.ctx function M.preview(ctx) if ctx.item.preview == "file" then return M.file(ctx) end assert(type(ctx.item.preview) == "table", "item.preview must be a table") ctx.preview:reset() local lines = vim.split(ctx.item.preview.text, "\n") ctx.preview:set_lines(lines) if ctx.item.preview.ft then ctx.preview:highlight({ ft = ctx.item.preview.ft }) end for _, extmark in ipairs(ctx.item.preview.extmarks or {}) do local e = vim.deepcopy(extmark) e.col, e.row = nil, nil vim.api.nvim_buf_set_extmark(ctx.buf, ns, (extmark.row or 1) - 1, extmark.col, e) end if ctx.item.preview.loc ~= false then ctx.preview:loc() end end ---@param ctx snacks.picker.preview.ctx function M.file(ctx) if ctx.item.buf and not ctx.item.file and not vim.api.nvim_buf_is_valid(ctx.item.buf) then ctx.preview:notify("Buffer no longer exists", "error") return end if ctx.item.buf and not vim.api.nvim_buf_is_valid(ctx.item.buf) and (ctx.item.file or ""):sub(1, 1) == "[" then ctx.preview:notify("Buffer no longer exists", "error") return end local title = ctx.item.preview_title or ctx.item.title -- used by some LSP servers that load buffers with custom URIs if ctx.item.buf and vim.uri_from_bufnr(ctx.item.buf):sub(1, 4) ~= "file" then if not vim.api.nvim_buf_is_loaded(ctx.item.buf) then vim.b[ctx.item.buf].snacks_picker_loaded = true vim.fn.bufload(ctx.item.buf) end elseif ctx.item.file and ctx.item.file:find("^%w+://") then ctx.item.buf = vim.fn.bufadd(ctx.item.file) vim.b[ctx.item.buf].snacks_picker_loaded = true vim.fn.bufload(ctx.item.buf) end if ctx.item.buf and vim.api.nvim_buf_is_loaded(ctx.item.buf) then if not title then local name = vim.api.nvim_buf_get_name(ctx.item.buf) title = uv.fs_stat(name) and vim.fn.fnamemodify(name, ":t") or name end ctx.preview:set_title(title) ctx.preview:set_buf(ctx.item.buf) else local path = Snacks.picker.util.path(ctx.item) if not path then ctx.preview:notify("Item has no `file`", "error") return end if Snacks.image.supports_file(path) and Snacks.image.config.enabled ~= false then return M.image(ctx) end -- re-use existing preview when path is the same if path ~= Snacks.picker.util.path(ctx.prev) then ctx.preview:reset() vim.bo[ctx.buf].buftype = "" title = title or vim.fn.fnamemodify(path, ":t") ctx.preview:set_title(title) local stat = uv.fs_stat(path) if not stat then ctx.preview:notify("file not found: " .. path, "error") return false end if stat.type == "directory" then return M.directory(ctx) end local max_size = ctx.picker.opts.previewers.file.max_size or (1024 * 1024) if stat.size > max_size then ctx.preview:notify("large file > 1MB", "warn") return false end if stat.size == 0 then ctx.preview:notify("empty file", "warn") return false end local file = assert(io.open(path, "r")) local is_binary = false local ft = ctx.picker.opts.previewers.file.ft or vim.filetype.match({ filename = path }) if ft == "bigfile" then ft = nil end local lines = {} for line in file:lines() do ---@cast line string if #line > ctx.picker.opts.previewers.file.max_line_length then line = line:sub(1, ctx.picker.opts.previewers.file.max_line_length) .. "..." end -- Check for binary data in the current line if line:find("[%z\1-\8\11\12\14-\31]") then is_binary = true if not ft then ctx.preview:notify("binary file", "warn") return end end table.insert(lines, line) end file:close() if is_binary then ctx.preview:wo({ number = false, relativenumber = false, cursorline = false, signcolumn = "no" }) end ctx.preview:set_lines(lines) ctx.preview:highlight({ file = path, ft = ctx.picker.opts.previewers.file.ft, buf = ctx.buf }) end end ctx.preview:loc() end ---@param diff string|string[]|snacks.picker.diff.Block[] ---@param ft "diff"|"git" ---@param ctx snacks.picker.preview.ctx local function fancy_diff(diff, ft, ctx) local buf = ctx.preview:scratch() ctx.preview.win:map() require("snacks.picker.util.diff").render(buf, ns, diff, { annotations = ctx.item.annotations or ctx.picker.opts.annotations, }) Snacks.util.wo(ctx.win, ctx.picker.opts.previewers.diff.wo or {}) end ---@param cmd string[] ---@param ctx snacks.picker.preview.ctx ---@param opts? snacks.job.Opts|{ft?: string} function M.cmd(cmd, ctx, opts) opts = opts or {} local Job = require("snacks.util.job") local buf = ctx.preview:scratch() vim.bo[buf].buftype = "nofile" opts = Snacks.config.merge(opts, { debug = ctx.picker.opts.debug.proc, term = opts.term ~= false and not opts.ft and opts.pty ~= false, width = vim.api.nvim_win_get_width(ctx.win), height = vim.api.nvim_win_get_height(ctx.win), cwd = ctx.item.cwd or ctx.picker.opts.cwd, env = { PAGER = "cat", DELTA_PAGER = "cat", }, }) local style = ctx.picker.opts.previewers.diff.style if style == "fancy" and vim.tbl_contains({ "diff", "git" }, opts.ft) then opts.on_line = function() end or nil -- disable default line handler opts.on_lines = function(_, lines) fancy_diff(lines, opts.ft, ctx) end end local job = Job.new(buf, cmd, opts) if opts.ft and style ~= "fancy" then ctx.preview:highlight({ ft = opts.ft }) end return job end ---@param ctx snacks.picker.preview.ctx ---@return string[], boolean terminal local function git(ctx, ...) local terminal = ctx.picker.opts.previewers.diff.style == "terminal" local ret = { "git" } vim.list_extend(ret, not terminal and { "--no-pager" } or {}) vim.list_extend(ret, ctx.picker.opts.previewers.git.args or {}) vim.list_extend(ret, { ... }) return ret, terminal end ---@param ctx snacks.picker.preview.ctx function M.git_show(ctx) local cmd, terminal = git(ctx, "show", ctx.item.commit) local pathspec = ctx.item.files or ctx.item.file pathspec = type(pathspec) == "table" and pathspec or { pathspec } if #pathspec > 0 then cmd[#cmd + 1] = "--" vim.list_extend(cmd, pathspec) end M.cmd(cmd, ctx, { ft = not terminal and "git" or nil }) end ---@param ctx snacks.picker.preview.ctx function M.git_log(ctx) local cmd = git( ctx, "--no-pager", "log", "--pretty=format:%h %s (%ch) <%an>", "--abbrev-commit", "--decorate", "--date=short", "--color=never", "--no-show-signature", "--no-patch", ctx.item.commit ) M.cmd(cmd, ctx, { ft = "git", ---@param text string on_line = function(_, text) local commit, msg, date, author = text:match("^(%S+) (.*) %((.*)%) <(.*)>$") if commit then local hl = Snacks.picker.format.git_log({ idx = 1, score = 0, text = "", commit = commit, msg = msg, date = date, author = author, }, ctx.picker) Snacks.picker.highlight.render(ctx.buf, ns, { hl }, { append = true }) Snacks.util.wo(ctx.win, { breakindent = true, wrap = true, linebreak = true }) end end, }) end ---@param ctx snacks.picker.preview.ctx function M.diff(ctx) local style = ctx.picker.opts.previewers.diff.style local cmd = vim.deepcopy(ctx.picker.opts.previewers.diff.cmd) style = style == "terminal" and vim.fn.executable(cmd[1]) == 0 and "fancy" or style if style == "syntax" then ctx.item.preview = { text = ctx.item.diff, ft = "diff", loc = false } return M.preview(ctx) elseif style ~= "terminal" then return fancy_diff(ctx.item.diff, "diff", ctx) end if cmd[1] == "delta" and not vim.tbl_contains(cmd, "--dark") and not vim.tbl_contains(cmd, "--light") then table.insert(cmd, 2, "--" .. vim.o.background) end M.cmd(cmd, ctx, { input = ctx.item.diff, }) end ---@param ctx snacks.picker.preview.ctx function M.git_diff(ctx) local cmd, terminal = git(ctx, "diff") if not ctx.item.status then cmd[#cmd + 1] = "HEAD" -- generic diff against HEAD elseif ctx.item.status:find("[UAD][UAD]") then cmd[#cmd + 1] = "--cc" -- combined diff for conflicts elseif ctx.item.status:sub(1, 1) ~= " " then cmd[#cmd + 1] = "--cached" -- staged changes end if ctx.item.file then vim.list_extend(cmd, { "--", ctx.item.file }) end M.cmd(cmd, ctx, { ft = not terminal and "diff" or nil, }) end ---@param ctx snacks.picker.preview.ctx function M.git_stash(ctx) local cmd, terminal = git(ctx, "stash", "show", "--patch", ctx.item.stash) M.cmd(cmd, ctx, { ft = not terminal and "diff" or nil }) end ---@param ctx snacks.picker.preview.ctx function M.git_status(ctx) local ss = ctx.item.status if ss:find("^[A?]") then M.file(ctx) else M.git_diff(ctx) end end ---@param ctx snacks.picker.preview.ctx function M.colorscheme(ctx) if not ctx.preview.state.colorscheme then ctx.preview.state.colorscheme = vim.g.colors_name or "default" ctx.preview.state.background = vim.o.background ctx.preview.win:on("WinClosed", function() vim.schedule(function() if not ctx.preview.state.colorscheme then return end vim.cmd("colorscheme " .. ctx.preview.state.colorscheme) vim.o.background = ctx.preview.state.background end) end, { win = true }) end vim.schedule(function() vim.cmd("colorscheme " .. ctx.item.text) end) Snacks.picker.preview.file(ctx) end ---@param ctx snacks.picker.preview.ctx function M.man(ctx) M.cmd({ "man", ctx.item.section, ctx.item.page }, ctx, { ft = "man", env = { MANPAGER = ctx.picker.opts.previewers.man_pager or vim.fn.executable("col") == 1 and "col -bx" or "cat", MANWIDTH = tostring(ctx.preview.win:dim().width), MANPATH = vim.env.MANPATH, }, }) end return M ================================================ FILE: lua/snacks/picker/resume.lua ================================================ local M = {} M.state = {} ---@type table ---@param picker snacks.Picker function M.add(picker) for toggle in pairs(picker.opts.toggles) do picker.init_opts[toggle] = picker.opts[toggle] end local source = picker.opts.source or "custom" ---@class snacks.picker.resume.State local state = { opts = picker.init_opts or {}, selected = picker:selected({ fallback = false }), cursor = picker.list.cursor, topline = picker.list.top, filter = picker.input.filter, added = vim.uv.hrtime(), items = source:find("^lsp_") and picker.finder.items or nil, } state.opts.live = picker.opts.live M.state[source] = state end ---@param state snacks.picker.resume.State function M._resume(state) state.opts.pattern = state.filter.pattern state.opts.search = state.filter.search if state.items then state.opts.finder = function() return state.items end end local ret = Snacks.picker.pick(state.opts) ret.list:set_selected(state.selected) ret.list:update() ret.input:update() ret.matcher.task:on( "done", vim.schedule_wrap(function() if ret.closed then return end ret.list:view(state.cursor, state.topline) end) ) return ret end ---@param opts? snacks.picker.resume.Opts ---@overload fun(source:string):snacks.Picker? function M.resume(opts) opts = type(opts) == "string" and { source = opts } or opts or {} local sources = opts.source and { opts.source } or opts.include or vim.tbl_keys(M.state) local states = {} ---@type snacks.picker.resume.State[] for _, source in ipairs(sources) do if M.state[source] and not vim.tbl_contains(opts.exclude or {}, source) then states[#states + 1] = M.state[source] end end table.sort(states, function(a, b) return a.added > b.added end) local last = states[1] if not last then if opts.source then return Snacks.picker.pick(opts.source) end Snacks.notify.error("No picker to resume") Snacks.picker.pickers() return end return M._resume(last) end return M ================================================ FILE: lua/snacks/picker/select.lua ================================================ local M = {} ---@alias vim.ui.select.on_choice fun(item?: any, idx?: number) ---@alias snacks.picker.ui_select fun(items: any[], opts?: snacks.picker.ui_select.Opts, on_choice: vim.ui.select.on_choice) ---@class snacks.picker.ui_select.Opts: vim.ui.select.Opts ---@field format_item? fun(item: any, supports_chunks: boolean):(string|snacks.picker.Highlight[]) ---@field snacks? snacks.picker.Config ---@generic T ---@param items T[] Arbitrary items ---@param opts? snacks.picker.ui_select.Opts ---@param on_choice fun(item?: T, idx?: number) function M.select(items, opts, on_choice) assert(type(on_choice) == "function", "on_choice must be a function") opts = opts or {} local title = opts.prompt or "Select" title = title:gsub("^%s*", ""):gsub("[%s:]*$", "") local completed = false ---@type snacks.picker.select.Config local picker_opts = { source = "select", finder = function() ---@type snacks.picker.finder.Item[] local ret = {} for idx, item in ipairs(items) do local text = (opts.format_item or tostring)(item) ---@type snacks.picker.finder.Item local it = type(item) == "table" and setmetatable({}, { __index = item }) or {} it.text = idx .. " " .. text it.item = item it.idx = idx ret[#ret + 1] = it end return ret end, format = Snacks.picker.format.ui_select(opts), title = title, layout = { config = function(layout) -- Fit list height to number of items, up to 10 for _, box in ipairs(layout.layout) do if box.win == "list" and not box.height then box.height = math.max(math.min(#items, vim.o.lines * 0.8 - 10), 2) end end end, }, actions = { confirm = function(picker, item) if completed then return end completed = true picker:close() vim.schedule(function() on_choice(item and item.item, item and item.idx) end) end, }, on_close = function() if completed then return end completed = true vim.schedule(on_choice) end, } -- merge custom picker options if opts.snacks then picker_opts = Snacks.config.merge({}, vim.deepcopy(picker_opts), opts.snacks) end -- get full picker config picker_opts = Snacks.picker.config.get(picker_opts) -- merge kind options local kind_opts = picker_opts.kinds and picker_opts.kinds[opts.kind] if kind_opts then picker_opts = Snacks.config.merge({}, picker_opts, kind_opts) end return Snacks.picker.pick(picker_opts) end return M ================================================ FILE: lua/snacks/picker/sort.lua ================================================ ---@class snacks.picker.sorters local M = {} ---@alias snacks.picker.sort.Field { name: string, desc: boolean, len?: boolean } ---@class snacks.picker.sort.Config ---@field fields? (snacks.picker.sort.Field|string)[] ---@param opts? snacks.picker.sort.Config function M.default(opts) local fields = {} ---@type snacks.picker.sort.Field[] for _, f in ipairs(opts and opts.fields or { { name = "score", desc = true }, "idx" }) do if type(f) == "string" then local desc, len = false, nil if f:sub(1, 1) == "#" then f, len = f:sub(2), true end if f:sub(-5) == ":desc" then f, desc = f:sub(1, -6), true elseif f:sub(-4) == ":asc" then f = f:sub(1, -5) end table.insert(fields, { name = f, desc = desc, len = len }) else table.insert(fields, f) end end ---@param a snacks.picker.Item ---@param b snacks.picker.Item return function(a, b) for _, field in ipairs(fields) do local av, bv = a[field.name], b[field.name] if av ~= nil and bv ~= nil then if field.len then av, bv = #av, #bv end if av ~= bv then if type(av) == "boolean" then av, bv = av and 0 or 1, bv and 0 or 1 end if field.desc then return av > bv else return av < bv end end end end return false end end function M.idx() ---@param a snacks.picker.Item ---@param b snacks.picker.Item return function(a, b) return a.idx < b.idx end end return M ================================================ FILE: lua/snacks/picker/source/buffers.lua ================================================ local M = {} ---@param opts snacks.picker.buffers.Config ---@type snacks.picker.finder function M.buffers(opts, ctx) opts = vim.tbl_extend("force", { hidden = false, unloaded = true, current = true, nofile = false, sort_lastused = true, }, opts) local items = {} ---@type snacks.picker.finder.Item[] local current_buf = vim.api.nvim_get_current_buf() local alternate_buf = vim.fn.bufnr("#") for _, buf in ipairs(vim.api.nvim_list_bufs()) do local keep = (opts.hidden or vim.bo[buf].buflisted) and (opts.unloaded or vim.api.nvim_buf_is_loaded(buf)) and (opts.current or buf ~= current_buf) and (opts.nofile or vim.bo[buf].buftype ~= "nofile") and (not opts.modified or vim.bo[buf].modified) if keep then local name = vim.api.nvim_buf_get_name(buf) if name == "" then name = "[Scratch]" end local info = vim.fn.getbufinfo(buf)[1] local mark = vim.api.nvim_buf_get_mark(buf, '"') local flags = { buf == current_buf and "%" or (buf == alternate_buf and "#" or ""), info.hidden == 1 and "h" or (#(info.windows or {}) > 0) and "a" or "", vim.bo[buf].readonly and "=" or "", info.changed == 1 and "+" or "", } table.insert(items, { flags = table.concat(flags), buf = buf, name = vim.api.nvim_buf_get_name(buf), buftype = vim.bo[buf].buftype, filetype = vim.bo[buf].filetype, file = name, info = info, pos = mark[1] ~= 0 and mark or { info.lnum, 0 }, }) items[#items].text = Snacks.picker.util.text(items[#items], { "buf", "name", "filetype", "buftype" }) end end if opts.sort_lastused then table.sort(items, function(a, b) return a.info.lastused > b.info.lastused end) end return ctx.filter:filter(items) end return M ================================================ FILE: lua/snacks/picker/source/diagnostics.lua ================================================ local M = {} local uv = vim.uv or vim.loop ---@param opts snacks.picker.diagnostics.Config ---@type snacks.picker.finder function M.diagnostics(opts, ctx) local items = {} ---@type snacks.picker.finder.Item[] local current_buf = vim.api.nvim_get_current_buf() local cwd = svim.fs.normalize(uv.cwd() or ".") for _, diag in ipairs(vim.diagnostic.get(ctx.filter.buf, { severity = opts.severity })) do local buf = diag.bufnr if buf and vim.api.nvim_buf_is_valid(buf) then local file = svim.fs.normalize(vim.api.nvim_buf_get_name(buf), { _fast = true }) local severity = diag.severity severity = type(severity) == "number" and vim.diagnostic.severity[severity] or severity ---@cast severity string? items[#items + 1] = { text = table.concat({ severity or "", tostring(diag.code or ""), file, diag.source or "", diag.message }, " "), file = file, buf = diag.bufnr, is_current = buf == current_buf and 0 or 1, is_cwd = file:sub(1, #cwd) == cwd and 0 or 1, lnum = diag.lnum, severity = diag.severity, pos = { diag.lnum + 1, diag.col }, end_pos = diag.end_lnum and { diag.end_lnum + 1, diag.end_col } or nil, item = diag, comment = diag.message, } end end return ctx.filter:filter(items) end return M ================================================ FILE: lua/snacks/picker/source/diff.lua ================================================ local M = {} ---@class snacks.picker.diff.Config: snacks.picker.proc.Config ---@field cmd? string optional since diff can be passed as string ---@field group? boolean Group hunks by file ---@field diff? string|number diff string or buffer number ---@field annotations? snacks.diff.Annotation[] ---@class snacks.picker.diff.hunk.Pos ---@field line number ---@field count number ---@class snacks.picker.Diff ---@field header string[] ---@field blocks snacks.picker.diff.Block[] ---@class snacks.picker.diff.Hunk ---@field diff string[] ---@field line number ---@field context? string ---@field left snacks.picker.diff.hunk.Pos old (normal) /ours (merge) ---@field right snacks.picker.diff.hunk.Pos new (normal) /working (merge) ---@field parents? snacks.picker.diff.hunk.Pos[] theirs (merge) ---@class snacks.picker.diff.Block ---@field unmerged? boolean ---@field file string ---@field left? string ---@field right? string ---@field header string[] ---@field hunks snacks.picker.diff.Hunk[] ---@field mode? {from:string, to:string} ---@field copy? {from:string, to:string} ---@field rename? {from:string, to:string} ---@field delete? string (mode of deleted file) ---@field new? string (mode of new file) ---@field similarity? number ---@field dissimilarity? number ---@field index? {from:string, to:string, mode:string} ---@param opts? snacks.picker.diff.Config ---@type snacks.picker.finder function M.diff(opts, ctx) opts = opts or {} local lines = {} ---@type string[] local finder ---@type snacks.picker.finder.result? do if opts.cmd then finder = require("snacks.picker.source.proc").proc(opts, ctx) else local diff = opts.diff if not diff and vim.bo.filetype == "diff" then diff = 0 end if type(diff) == "number" then lines = vim.api.nvim_buf_get_lines(diff, 0, -1, false) elseif type(diff) == "string" then lines = vim.split(diff, "\n", { plain = true }) else Snacks.notify.error("snacks.picker.diff: opts.diff must be a string or buffer number") return {} end end end local cwd = opts.cwd or ctx.filter.cwd return function(cb) if finder then finder(function(proc_item) lines[#lines + 1] = proc_item.text end) end ---@param file string ---@param line? number ---@param diff string[] ---@param block snacks.picker.diff.Block local function add(file, line, diff, block) line = line or 1 cb({ text = file .. ":" .. line, diff = table.concat(diff, "\n"), file = file, cwd = cwd, rename = block.rename and block.rename.from or nil, annotations = opts.annotations, block = block, pos = { line, 0 }, }) end local diff = M.parse(lines) for _, block in ipairs(diff.blocks) do local diffs = {} ---@type string[] for _, h in ipairs(block.hunks) do if opts.group then vim.list_extend(diffs, h.diff) else add(block.file, h.line, vim.list_extend(vim.deepcopy(block.header), h.diff), block) end end if opts.group or #block.hunks == 0 then local line = block.hunks[1] and block.hunks[1].line or 1 add(block.file, line, vim.list_extend(vim.deepcopy(block.header), diffs), block) end end end end ---@param lines string[] function M.parse(lines) local hunk ---@type snacks.picker.diff.Hunk? local block ---@type snacks.picker.diff.Block? local ret = {} ---@type snacks.picker.diff.Block[] local header = {} ---@type string[] ---@param file? string ---@param strip_prefix? boolean ---@return string? local function norm(file, strip_prefix) if file then file = file:gsub("\t.*$", "") -- remove tab and after file = file:gsub('^"(.-)"$', "%1") -- remove quotes if file == "/dev/null" then -- no file return end if strip_prefix == false then return file end local prefix = { "a", "b", "i", "w", "c", "o", "old", "new" } for _, s in ipairs(prefix) do -- remove prefixes if file:sub(1, #s + 1) == s .. "/" then return file:sub(#s + 2) end end return file end end local function emit() if block and hunk then hunk = nil elseif not block then return end for _, line in ipairs(block.header) do if line:find("^%-%-%- ") then block.left = norm(line:sub(5)) elseif line:find("^%+%+%+ ") then block.right = norm(line:sub(5)) elseif line:find("^rename from") then block.rename = block.rename or {} block.left = norm(line:match("^rename from (.*)"), false) block.rename.from = block.left elseif line:find("^rename to") then block.rename = block.rename or {} block.right = norm(line:match("^rename to (.*)"), false) block.rename.to = block.right elseif line:find("^copy from") then block.copy = block.copy or {} block.left = norm(line:match("^copy from (.*)"), false) block.copy.from = block.left elseif line:find("^copy to") then block.copy = block.copy or {} block.right = norm(line:match("^copy to (.*)"), false) block.copy.to = block.right elseif line:find("^new file mode") then block.new = line:match("^new file mode (.*)") elseif line:find("^deleted file mode") then block.delete = line:match("^deleted file mode (.*)") elseif line:find("^old mode") then block.mode = block.mode or {} block.mode.from = line:match("^old mode (.*)") elseif line:find("^new mode") then block.mode = block.mode or {} block.mode.to = line:match("^new mode (.*)") elseif line:find("^similarity index") then local sim = line:match("^similarity index (%d+)%%") block.similarity = tonumber(sim) or 0 elseif line:find("^dissimilarity index") then local dis = line:match("^dissimilarity index (%d+)%%") block.dissimilarity = tonumber(dis) or 0 elseif line:find("^index ") then local from, to, mode = line:match("^index (%S+)%.%.(%S+)%s*(%d*)$") block.index = { from = from, to = to, mode = mode ~= "" and mode or nil } end end local first = block.header[1] or "" if not block.right and not block.left and first:find("^diff") then -- no left/right so for sure no rename. -- this means the diff header is for the same file if first:find("^diff %-%-cc") then block.left = norm(first:match("^diff %-%-cc (.+)$")) block.right = block.left else first = first:gsub("^diff ", ""):gsub("^%s*%-%S+%s*", "") --[[@as string]] local idx = 1 while idx <= #first do local s = first:find(" ", idx, true) if not s then break end idx = s + 1 local l = norm(first:sub(1, s - 1)) local r = norm(first:sub(s + 1)) if l == r then block.left = l block.right = r break end end end end block.file = block.right or block.left or block.file table.sort(block.hunks, function(a, b) return a.line < b.line end) ret[#ret + 1] = block block = nil end local with_diff_header = false for _, text in ipairs(lines) do if not block and text:find("^%s*$") then -- Ignore empty lines before a diff block elseif text:find("^diff") or (not with_diff_header and text:find("^%-%-%- ") and (not block or hunk)) then with_diff_header = with_diff_header or text:find("^diff") == 1 emit() block = { file = "", --file or "unknown", header = { text }, hunks = {}, } elseif text:find("@@", 1, true) == 1 and block then -- Hunk header hunk = M.parse_hunk_header(text) if hunk then block.unmerged = block.unmerged or (hunk.parents ~= nil) or nil block.hunks[#block.hunks + 1] = hunk else Snacks.notify.error("Invalid hunk header: " .. text, { title = "Snacks Picker Diff" }) end elseif hunk then -- Hunk body hunk.diff[#hunk.diff + 1] = text elseif block then block.header[#block.header + 1] = text elseif #ret == 0 then header[#header + 1] = text else Snacks.notify.error("Unexpected line: " .. text, { title = "Snacks Picker Diff" }) end end emit() ---@type snacks.picker.Diff return { blocks = ret, header = header } end ---@param line string function M.parse_hunk_header(line) local count_start, inner, count_end, context = line:match("^(@+)%s*(.-)%s*(@+)%s*(.*)$") if not count_start or not count_end or count_start ~= count_end or #count_start < 2 then return end local ret = {} ---@type {line:number, count:number}[] for _, part in ipairs(vim.split(inner, "%s+")) do local l, c = part:match("^[%-+](%d+),?(%d*)$") if not l then return end ret[#ret + 1] = { line = tonumber(l) or 1, count = tonumber(c) or 1 } end if #ret ~= #count_start then return end local right = table.remove(ret) ---@type snacks.picker.diff.Hunk return { diff = { line }, line = right and right.line or 1, left = table.remove(ret, 1), right = right, parents = #ret > 0 and ret or nil, context = context ~= "" and context or nil, } end return M ================================================ FILE: lua/snacks/picker/source/explorer.lua ================================================ ---@diagnostic disable: await-in-sync local Actions = require("snacks.explorer.actions") local Tree = require("snacks.explorer.tree") local M = {} M.actions = Actions.actions ---@type table M._state = setmetatable({}, { __mode = "k" }) local uv = vim.uv or vim.loop ---@class snacks.picker.explorer.Item: snacks.picker.finder.Item ---@field file string ---@field dir? boolean ---@field parent? snacks.picker.explorer.Item ---@field open? boolean ---@field last? boolean ---@field sort? string ---@field internal? boolean internal parent directories not part of fd output ---@field status? string local function norm(path) return svim.fs.normalize(path) end ---@class snacks.picker.explorer.State ---@field on_find? fun()? local State = {} State.__index = State ---@param picker snacks.Picker function State.new(picker) local self = setmetatable({}, State) local opts = picker.opts --[[@as snacks.picker.explorer.Config]] local r = picker:ref() local function ref() local v = r.value return v and not v.closed and v or nil end Tree:refresh(picker:cwd()) local buf = vim.api.nvim_win_get_buf(picker.main) local buf_file = svim.fs.normalize(vim.api.nvim_buf_get_name(buf)) if uv.fs_stat(buf_file) then Tree:open(buf_file) end if opts.watch then local on_close = picker.opts.on_close picker.opts.on_close = function(p) vim.schedule(function() require("snacks.explorer.watch").watch() end) if on_close then on_close(p) end end end picker.list.win:on("BufWritePost", function(_, ev) local p = ref() if p then Tree:refresh(ev.file) Actions.update(p) end end) picker.list.win:on("TabEnter", function(_, ev) local p = ref() if p and p:on_current_tab() then Actions.update(p) end end) picker.list.win:on("WinEnter", function(_, ev) local p = ref() if p then p._main:update() end end) picker.list.win:on("DirChanged", function(_, ev) local p = ref() if p then p:set_cwd(svim.fs.normalize(ev.file)) p:find() end end) if opts.diagnostics then local dirty = false local diag_update = Snacks.util.debounce(function() dirty = false local p = ref() if p then if require("snacks.explorer.diagnostics").update(p:cwd()) then p.list:set_target() p:find() end end end, { ms = 200 }) picker.list.win:on({ "InsertLeave", "DiagnosticChanged" }, function(_, ev) dirty = dirty or ev.event == "DiagnosticChanged" if vim.fn.mode() == "n" and dirty then diag_update() end end) end -- schedule initial follow if opts.follow_file then picker.list.win:on({ "WinEnter", "BufEnter" }, function(_, ev) vim.schedule(function() if ev.buf ~= vim.api.nvim_get_current_buf() then return end local p = ref() if not p or p:is_focused() or not p:on_current_tab() or p.closed then return end local win = vim.api.nvim_get_current_win() if vim.api.nvim_win_get_config(win).relative ~= "" then return end local file = vim.api.nvim_buf_get_name(ev.buf) local item = p:current() if item and item.file == norm(file) then return end Actions.update(p, { target = file }) end) end) self.on_find = function() local p = ref() if p and buf_file then Actions.update(p, { target = buf_file }) end end end return self end ---@param ctx snacks.picker.finder.ctx function State:setup(ctx) local opts = ctx.picker.opts --[[@as snacks.picker.explorer.Config]] if opts.watch then require("snacks.explorer.watch").watch() end return not ctx.filter:is_empty() end ---@param opts snacks.picker.explorer.Config function M.setup(opts) local searching = false return Snacks.config.merge(opts, { actions = { confirm = Actions.actions.confirm, }, filter = { --- Trigger finder when pattern toggles between empty / non-empty ---@param picker snacks.Picker ---@param filter snacks.picker.Filter transform = function(picker, filter) local s = not filter:is_empty() if searching ~= s then searching = s filter.meta.searching = searching return true end end, }, formatters = { file = { filename_only = opts.tree, }, }, }) end ---@param picker snacks.Picker function M.get_state(picker) if not M._state[picker] then M._state[picker] = State.new(picker) end return M._state[picker] end ---@param opts snacks.picker.explorer.Config ---@type snacks.picker.finder function M.explorer(opts, ctx) local state = M.get_state(ctx.picker) ctx.picker.matcher.opts.keep_parents = false if state:setup(ctx) then ctx.picker.matcher.opts.keep_parents = true return M.search(opts, ctx) end -- initial on_find (typically for follow_file), has to be done both for: -- * regular explorer view -- * when git status refreshes the view local on_find = state.on_find state.on_find = nil if opts.git_status then require("snacks.explorer.git").update(ctx.filter.cwd, { untracked = opts.git_untracked, on_update = function() if ctx.picker.closed then return end ctx.picker.list:set_target() ctx.picker:find({ on_done = on_find }) end, }) end if opts.diagnostics then require("snacks.explorer.diagnostics").update(ctx.filter.cwd) end return function(cb) if on_find then assert(ctx.picker.matcher.task:running()) ctx.picker.matcher.task:on("done", vim.schedule_wrap(on_find)) end local items = {} ---@type table local top = Tree:find(ctx.filter.cwd) local last = {} ---@type table Tree:get(ctx.filter.cwd, function(node) local parent = node.parent and items[node.parent.path] or nil local status = node.status if not status and parent and parent.dir_status then status = parent.dir_status end local item = { file = node.path, dir = node.dir, open = node.open, dir_status = node.dir_status or parent and parent.dir_status, text = node.path, parent = parent, hidden = node.hidden, ignored = node.ignored, status = (not node.dir or not node.open or opts.git_status_open) and status or nil, last = true, type = node.type, severity = (not node.dir or not node.open or opts.diagnostics_open) and node.severity or nil, } if last[node.parent] then last[node.parent].last = false end last[node.parent] = item if top == node then item.hidden = false item.ignored = false end items[node.path] = item cb(item) end, { hidden = opts.hidden, ignored = opts.ignored, exclude = opts.exclude, include = opts.include }) end end ---@param opts snacks.picker.explorer.Config ---@type snacks.picker.finder function M.search(opts, ctx) opts = Snacks.picker.util.shallow_copy(opts) opts.cmd = "fd" opts.cwd = ctx.filter.cwd opts.notify = false opts.args = { "--type", "d", -- include directories "--path-separator", -- same everywhere "/", } opts.dirs = { ctx.filter.cwd } ctx.picker.list:set_target() ---@type snacks.picker.explorer.Item local root = { file = opts.cwd, dir = true, open = true, text = "", sort = "", internal = true, } local files = require("snacks.picker.source.files").files(opts, ctx) local dirs = {} ---@type table local last = {} ---@type table ---@async return function(cb) cb(root) ---@param item snacks.picker.explorer.Item local function add(item) local dirname, basename = item.file:match("(.*)/(.*)") dirname, basename = dirname or "", basename or item.file local parent = dirs[dirname] ~= item and dirs[dirname] or root -- hierarchical sorting if item.dir then item.sort = parent.sort .. "!" .. basename .. " " else item.sort = parent.sort .. "#" .. basename .. " " end item.hidden = basename:sub(1, 1) == "." item.text = item.text:sub(1, #opts.cwd) == opts.cwd and item.text:sub(#opts.cwd + 2) or item.text local node = Tree:node(item.file) if node then item.dir = node.dir item.type = node.type item.status = (not node.dir or opts.git_status_open) and node.status or nil end if opts.tree then -- tree item.parent = parent if not last[parent] or last[parent].sort < item.sort then if last[parent] then last[parent].last = false end item.last = true last[parent] = item end end -- add to picker cb(item) end -- get files and directories files(function(item) ---@cast item snacks.picker.explorer.Item item.cwd = nil -- we use absolute paths -- Directories if item.file:sub(-1) == "/" then item.dir = true item.file = item.file:sub(1, -2) if dirs[item.file] then dirs[item.file].internal = false return end item.open = true dirs[item.file] = item end -- Add parents when needed for dir in Snacks.picker.util.parents(item.file, opts.cwd) do if dirs[dir] then break else dirs[dir] = { text = dir, file = dir, dir = true, open = true, internal = true, } add(dirs[dir]) end end add(item) end) end end return M ================================================ FILE: lua/snacks/picker/source/files.lua ================================================ local M = {} local uv = vim.uv or vim.loop ---@type {cmd:string[], args:string[], enabled?:boolean, available?:boolean|string}[] local commands = { { cmd = { "fd", "fdfind" }, args = { "--type", "f", "--type", "l", "--color", "never", "-E", ".git" }, }, { cmd = { "rg" }, args = { "--files", "--no-messages", "--color", "never", "-g", "!.git" }, }, { cmd = { "find" }, args = { ".", "-type", "f", "-not", "-path", "*/.git/*" }, enabled = vim.fn.has("win-32") == 0, }, } ---@param cmd? string ---@return string? cmd, string[]? args function M.get_cmd(cmd) local checked = {} ---@type string[] for _, command in ipairs(commands) do if command.enabled ~= false and command.available ~= false and (not cmd or vim.tbl_contains(command.cmd, cmd)) then if command.available then assert(type(command.available) == "string", "available must be a string") return command.available, vim.deepcopy(command.args) end for _, c in ipairs(command.cmd) do table.insert(checked, c) if vim.fn.executable(c) == 1 then command.available = c return c, vim.deepcopy(command.args) end end command.available = false end end checked = #checked == 0 and cmd and { cmd } or checked checked = vim.tbl_map(function(c) return "`" .. c .. "`" end, checked) Snacks.notify.error("No supported finder found:\n- " .. table.concat(checked, "\n-")) end function M.get_fd() return M.get_cmd("fd") end ---@param opts snacks.picker.files.Config ---@param filter snacks.picker.Filter local function get_cmd(opts, filter) local cmd, args = M.get_cmd(opts.cmd) if not cmd or not args then return end local is_fd, is_fd_rg, is_find, is_rg = cmd == "fd" or cmd == "fdfind", cmd ~= "find", cmd == "find", cmd == "rg" -- exclude for _, e in ipairs(opts.exclude or {}) do if is_fd then vim.list_extend(args, { "-E", e }) elseif is_rg then vim.list_extend(args, { "-g", "!" .. e }) elseif is_find then table.insert(args, "-not") table.insert(args, "-path") table.insert(args, e) end end -- extensions local ft = opts.ft or {} ft = type(ft) == "string" and { ft } or ft ---@cast ft string[] for _, e in ipairs(ft) do if is_fd then table.insert(args, "-e") table.insert(args, e) elseif is_rg then table.insert(args, "-g") table.insert(args, "*." .. e) elseif is_find then table.insert(args, "-name") table.insert(args, "*." .. e) end end -- hidden if opts.hidden and is_fd_rg then table.insert(args, "--hidden") elseif not opts.hidden and is_find then vim.list_extend(args, { "-not", "-path", "*/.*" }) end -- ignored if opts.ignored and is_fd_rg then args[#args + 1] = "--no-ignore" end -- follow if opts.follow then args[#args + 1] = "-L" end -- extra args vim.list_extend(args, opts.args or {}) -- file glob ---@type string? local pattern, pargs = Snacks.picker.util.parse(filter.search) vim.list_extend(args, pargs) pattern = pattern ~= "" and pattern or nil if pattern then if is_fd then table.insert(args, pattern) elseif is_rg then table.insert(args, "--glob") table.insert(args, pattern) elseif is_find then table.insert(args, "-name") table.insert(args, pattern) end end -- dirs local dirs = opts.dirs or {} if opts.rtp then vim.list_extend(dirs, Snacks.picker.util.rtp()) end if #dirs > 0 then dirs = vim.tbl_map(svim.fs.normalize, dirs) ---@type string[] if is_fd and not pattern then args[#args + 1] = "." end if is_find then table.remove(args, 1) for _, d in pairs(dirs) do table.insert(args, 1, d) end else vim.list_extend(args, dirs) end end return cmd, args end ---@param opts snacks.picker.files.Config ---@type snacks.picker.finder function M.files(opts, ctx) local cwd = not (opts.rtp or (opts.dirs and #opts.dirs > 0)) and svim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil local cmd, args = get_cmd(opts, ctx.filter) if not cmd then return function() end end if opts.debug.files then Snacks.notify(cmd .. " " .. table.concat(args or {}, " ")) end return require("snacks.picker.source.proc").proc( ctx:opts({ cmd = cmd, args = args, notify = not opts.live, ---@param item snacks.picker.finder.Item transform = function(item) item.cwd = cwd item.file = item.text end, }), ctx ) end ---@param opts snacks.picker.proc.Config ---@type snacks.picker.finder function M.zoxide(opts, ctx) return require("snacks.picker.source.proc").proc( ctx:opts({ cmd = "zoxide", args = { "query", "--list" }, ---@param item snacks.picker.finder.Item transform = function(item) item.file = item.text item.dir = true end, }), ctx ) end return M ================================================ FILE: lua/snacks/picker/source/gh.lua ================================================ local Actions = require("snacks.gh.actions") local Api = require("snacks.gh.api") local M = {} M.actions = setmetatable({}, { __index = function(t, k) if type(k) ~= "string" then return end if not Actions.actions[k] then return nil end ---@type snacks.picker.Action local action = { desc = Actions.actions[k].desc, action = function(picker, item, action) local items = picker:selected({ fallback = true }) if item.gh_item then item = item.gh_item items = { item } end ---@diagnostic disable-next-line: param-type-mismatch return Actions.actions[k].action(item, { picker = picker, items = items, action = action, }) end, } rawset(t, k, action) return action end, }) ---@param opts snacks.picker.gh.list.Config ---@type snacks.picker.finder function M.gh(opts, ctx) if ctx.filter.search ~= "" then opts.search = ctx.filter.search end ---@async return function(cb) Api.list(opts.type, function(items) for _, item in ipairs(items) do cb(item) end end, opts):wait() end end ---@param opts snacks.picker.gh.issue.Config ---@type snacks.picker.finder function M.issue(opts, ctx) return M.gh( vim.tbl_extend("force", { type = "issue", }, opts), ctx ) end ---@param opts snacks.picker.gh.pr.Config ---@type snacks.picker.finder function M.pr(opts, ctx) return M.gh( vim.tbl_extend("force", { type = "pr", }, opts), ctx ) end ---@param opts snacks.picker.gh.actions.Config ---@type snacks.picker.finder function M.get_actions(opts, ctx) opts = opts or {} ---@async return function(cb) local item = opts.item if not opts.item and not opts.number then item = Api.current_pr() end if not item then local required = { "type", "repo", "number" } local missing = vim.tbl_filter(function(field) return opts[field] == nil end, required) ---@type string[] if #missing > 0 then Snacks.notify.error({ "Missing required options for `Snacks.picker.gh_actions()`:", "- `" .. table.concat(missing, ", ") .. "`", "", "Either provide the fields, or run in a git repo with a **current PR**.", }, { title = "Snacks Picker GH Actions" }) return end item = Api.get({ type = opts.type or "pr", repo = opts.repo, number = opts.number }) if not item then Snacks.notify.error("snacks.picker.gh.get_actions: Failed to get item") return end end local actions = ctx.async:schedule(function() return Actions.get_actions(item, { picker = ctx.picker, items = { item }, }) end) actions.gh_actions = nil -- remove this action actions.gh_perform_action = nil -- remove this action local items = {} ---@type snacks.picker.finder.Item[] for name, action in pairs(actions) do ---@class snacks.picker.gh.Action: snacks.picker.finder.Item items[#items + 1] = { text = Snacks.picker.util.text(action, { "name", "desc" }), file = item.uri, name = name, item = item, desc = action.desc or name, action = action, } end table.sort(items, function(a, b) local pa = a.action.priority or 0 local pb = b.action.priority or 0 if pa ~= pb then return pa > pb end return a.desc < b.desc end) for i, it in ipairs(items) do it.text = ("%d. %s"):format(i, it.text) cb(it) end end end ---@param opts snacks.picker.gh.diff.Config ---@type snacks.picker.finder function M.diff(opts, ctx) opts = opts or {} if not opts.pr then Snacks.notify.error("snacks.picker.gh.diff: `opts.pr` is required") return {} end local cwd = ctx:git_root() local args = { "pr", "diff", tostring(opts.pr) } if opts.repo then vim.list_extend(args, { "--repo", opts.repo }) end opts.previewers.diff.style = "fancy" -- only fancy style support inline review comments local Render = require("snacks.gh.render") local Diff = require("snacks.picker.source.diff") ---@async return function(cb) local item = Api.get({ type = "pr", repo = opts.repo, number = opts.pr }) -- fetch on the main thread since rendering uses non-fast APIs local annotations = ctx.async:schedule(function() return Render.annotations(item) end) Diff.diff( ctx:opts({ cmd = "gh", args = args, cwd = cwd, annotations = annotations, }), ctx )(function(it) it.gh_item = item cb(it) end) end end ---@param opts snacks.picker.gh.reactions.Config ---@type snacks.picker.finder function M.reactions(opts, ctx) if not opts.repo then Snacks.notify.error("snacks.picker.gh.reactions: `opts.repo` is required") return {} end if not opts.number then Snacks.notify.error("snacks.picker.gh.reactions: `opts.number` is required") return {} end local all = { "+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes" } ---@async return function(cb) local items = {} ---@type table local user = Api.user() ---@type {user:snacks.gh.User, content:string}[] local reactions = Api.request_sync({ endpoint = ("/repos/%s/issues/%s/reactions"):format(opts.repo, opts.number), }) for _, r in ipairs(reactions) do if r.user.login == user.login then items[r.content] = setmetatable({ text = r.content, reaction = r.content, added = true, }, { __index = r }) end end for _, reaction in ipairs(all) do cb(items[reaction] or { text = reaction, reaction = reaction, added = false, }) end end end ---@param opts snacks.picker.gh.labels.Config ---@type snacks.picker.finder function M.labels(opts, ctx) if not opts.repo then Snacks.notify.error("snacks.picker.gh.labels: `opts.repo` is required") return {} end if not opts.number then Snacks.notify.error("snacks.picker.gh.labels: `opts.number` is required") return {} end ---@async return function(cb) ---@type {labels: snacks.gh.Label[]} local repo = Api.fetch_sync({ fields = { "labels" }, args = { "repo", "view", opts.repo }, }) local item = Api.get_cached(opts) assert(item, "Failed to get item for labels") local added = {} ---@type table for _, label in ipairs(item.labels or {}) do added[label.name] = true end repo.labels = repo.labels or {} table.sort(repo.labels, function(a, b) if added[a.name] ~= added[b.name] then return added[a.name] == true end return a.name:lower() < b.name:lower() end) for _, r in ipairs(repo.labels or {}) do cb({ text = r.name, label = r.name, added = added[r.name] == true, item = r, }) end end end ---@param item snacks.picker.gh.Item ---@type snacks.picker.format function M.format(item, picker) local ret = {} ---@type snacks.picker.Highlight[] local a = Snacks.picker.util.align local config = require("snacks.gh").config() -- Status Icon local icons = config.icons[item.type] local status = icons[item.status] and item.status or "other" if status then local icon = icons[status] local icon_hl = "SnacksGh" .. Snacks.picker.util.title(item.type) .. Snacks.picker.util.title(status) ret[#ret + 1] = { a(icon, 2), icon_hl } ret[#ret + 1] = { " " } end -- Number / Hash if item.hash then ret[#ret + 1] = { a(item.hash, 8), "SnacksPickerDimmed" } end -- Updated At -- if item.updated then -- ret[#ret + 1] = { a(Snacks.picker.util.reltime(item.updated), 12), "SnacksPickerGitDate" } -- end -- Title if item.title then item.msg = item.title Snacks.picker.highlight.extend(ret, Snacks.picker.format.commit_message(item, picker)) end -- Author if item.author and not item.item.author.is_bot then ret[#ret + 1] = { " ", nil } ret[#ret + 1] = { "@" .. item.author, "SnacksPickerGitAuthor" } end -- Labels for _, label in ipairs(item.item.labels or {}) do ret[#ret + 1] = { " ", nil } local color = label.color or "888888" local badge = Snacks.picker.highlight.badge(label.name, "#" .. color) vim.list_extend(ret, badge) end return ret end ---@param ctx snacks.picker.preview.ctx function M.preview_diff(ctx) Snacks.picker.preview.diff(ctx) local item = ctx.item.gh_item ---@type snacks.picker.gh.Item? if item then vim.b[ctx.buf].snacks_gh = { repo = item.repo, type = item.type, number = item.number, } end end ---@param ctx snacks.picker.preview.ctx function M.preview(ctx) local config = require("snacks.gh").config() local item = ctx.item item.wo = config.wo item.bo = config.bo item.preview_title = ("%s %s %s"):format( config.icons.logo, (item.type == "issue" and "Issue" or "PR"), (item.hash or "") ) return Snacks.picker.preview.file(ctx) end ---@type snacks.picker.format function M.format_label(item, picker) local ret = {} ---@type snacks.picker.Highlight[] local added = item.added if picker.list:is_selected(item) then added = not added -- reflect the change that will happen on action end ret[#ret + 1] = { added and "󰱒 " or "󰄱 ", "SnacksPickerDelim" } ret[#ret + 1] = { " " } local color = item.item.color or "888888" local badge = Snacks.picker.highlight.badge(item.label, "#" .. color) vim.list_extend(ret, badge) return ret end ---@param item snacks.picker.gh.Action ---@type snacks.picker.format function M.format_action(item, picker) local ret = {} ---@type snacks.picker.Highlight[] if item.action.icon then ret[#ret + 1] = { item.action.icon, "Special" } ret[#ret + 1] = { " " } end local count = picker:count() local idx = tostring(item.idx) idx = (" "):rep(#tostring(count) - #idx) .. idx ret[#ret + 1] = { idx .. ".", "SnacksPickerIdx" } ret[#ret + 1] = { " " } if item.desc then ret[#ret + 1] = { item.desc or item.name } Snacks.picker.highlight.highlight(ret, { ["#%d+"] = "Number", }) end return ret end ---@type snacks.picker.format function M.format_reaction(item, picker) local config = require("snacks.gh").config() local ret = {} ---@type snacks.picker.Highlight[] local name = item.reaction name = name == "+1" and "thumbs_up" or name == "-1" and "thumbs_down" or name local added = item.added if picker.list:is_selected(item) then added = not added -- reflect the change that will happen on action end ret[#ret + 1] = { added and "󰱒 " or "󰄱 ", "SnacksPickerDelim" } ret[#ret + 1] = { " " } ret[#ret + 1] = { config.icons.reactions[name] or name } return ret end return M ================================================ FILE: lua/snacks/picker/source/git.lua ================================================ local M = {} local uv = vim.uv or vim.loop local commit_pat = ("[a-z0-9]"):rep(7) ---@class snacks.picker.git.Args ---@field args? string[] additional arguments to pass to `git` ---@field cmd_args? string[] additional arguments to pass to the `git `` ---@param cmd string ---@param ... string|snacks.picker.git.Args function M.git(cmd, ...) local args, cmd_args = {}, {} ---@type string[], string[] for i = 1, select("#", ...) do local arg = select(i, ...) if type(arg) == "string" then cmd_args[#cmd_args + 1] = arg else vim.list_extend(args, arg.args or {}) vim.list_extend(cmd_args, arg.cmd_args or {}) end end local ret = { "-c", "core.quotepath=false" } ---@type string[] vim.list_extend(ret, args) ret[#ret + 1] = cmd vim.list_extend(ret, cmd_args) return ret end ---@param opts snacks.picker.git.files.Config ---@type snacks.picker.finder function M.files(opts, ctx) local args = M.git("ls-files", "--exclude-standard", "--cached", opts) if opts.untracked then table.insert(args, "--others") elseif opts.submodules then table.insert(args, "--recurse-submodules") end if not opts.cwd then opts.cwd = ctx:git_root() ctx.picker:set_cwd(opts.cwd) end local cwd = svim.fs.normalize(opts.cwd) or nil return require("snacks.picker.source.proc").proc( ctx:opts({ cmd = "git", args = args, ---@param item snacks.picker.finder.Item transform = function(item) item.cwd = cwd item.file = item.text end, }), ctx ) end ---@param opts snacks.picker.git.grep.Config ---@type snacks.picker.finder function M.grep(opts, ctx) if opts.need_search ~= false and ctx.filter.search == "" then return function() end end local args = M.git("grep", "--line-number", "--column", "--no-color", "-I", opts) if opts.untracked then table.insert(args, "--untracked") elseif opts.submodules then table.insert(args, "--recurse-submodules") end if opts.ignorecase then table.insert(args, "-i") end local pattern, pargs = Snacks.picker.util.parse(ctx.filter.search) table.insert(args, pattern) args[#args + 1] = "--" vim.list_extend(args, pargs) local pathspec = type(opts.pathspec) == "table" and opts.pathspec or { opts.pathspec } ---@cast pathspec string[] vim.list_extend(args, pathspec) if not opts.cwd then opts.cwd = ctx:git_root() ctx.picker:set_cwd(opts.cwd) end local cwd = svim.fs.normalize(opts.cwd) or nil return require("snacks.picker.source.proc").proc( ctx:opts({ cmd = "git", args = args, notify = false, ---@param item snacks.picker.finder.Item transform = function(item) item.cwd = cwd local file, line, col, text = item.text:match("^(.+):(%d+):(%d+):(.*)$") if not file then if not item.text:match("WARNING") then Snacks.notify.error("invalid grep output:\n" .. item.text) end return false else item.line = text item.file = file item.pos = { tonumber(line), tonumber(col) - 1 } end end, }), ctx ) end ---@param opts snacks.picker.git.log.Config ---@type snacks.picker.finder function M.log(opts, ctx) local args = M.git( "log", "--pretty=format:%h %s (%ch) <%an>", "--abbrev-commit", "--decorate", "--date=short", "--color=never", "--no-show-signature", "--no-patch", opts ) if opts.author then table.insert(args, "--author=" .. opts.author) end local file ---@type string? if opts.current_line then local cursor = vim.api.nvim_win_get_cursor(ctx.filter.current_win) file = vim.api.nvim_buf_get_name(ctx.filter.current_buf) local line = cursor[1] args[#args + 1] = "-L" args[#args + 1] = line .. ",+1:" .. file elseif opts.current_file then file = vim.api.nvim_buf_get_name(ctx.filter.current_buf) if opts.follow then args[#args + 1] = "--follow" end args[#args + 1] = "--" args[#args + 1] = file end if ctx.filter.search ~= "" then vim.list_extend(args, { "-S", ctx.filter.search }) end local Proc = require("snacks.picker.source.proc") file = file and svim.fs.normalize(file) or nil local cwd = svim.fs.normalize(file and vim.fn.fnamemodify(file, ":h") or opts and opts.cwd or uv.cwd() or ".") or nil cwd = Snacks.git.get_root(cwd) or cwd local renames = { file } ---@type string[] return function(cb) if file then -- detect renames local is_rename = false Proc.proc({ cmd = "git", cwd = cwd, args = M.git( "log", "-z", "--follow", "--name-status", "--pretty=format:''", "--diff-filter=R", "--", file, opts ), }, ctx)(function(item) for _, text in ipairs(vim.split(item.text, "\0")) do if text:find("^R%d%d%d$") then is_rename = true elseif is_rename then is_rename = false renames[#renames + 1] = text end end end) end Proc.proc( ctx:opts({ cwd = cwd, cmd = "git", args = args, ---@param item snacks.picker.finder.Item transform = function(item) local commit, msg, date, author = item.text:match("^(%S+) (.*) %((.*)%) <(.*)>$") if not commit then Snacks.notify.error(("failed to parse log item:\n%q"):format(item.text)) return false end item.cwd = cwd item.commit = commit item.msg = msg item.date = date item.author = author item.file = file item.files = renames end, }), ctx )(cb) end end ---@param opts snacks.picker.git.status.Config ---@type snacks.picker.finder function M.status(opts, ctx) local args = M.git("status", "-uall", "--porcelain=v1", "-z", { args = { "--no-pager" } }, opts) if opts.ignored then table.insert(args, "--ignored=matching") end local cwd = ctx:git_root() ctx.picker:set_cwd(cwd) local prev ---@type snacks.picker.finder.Item? return require("snacks.picker.source.proc").proc( ctx:opts({ sep = "\0", cwd = cwd, cmd = "git", args = args, ---@param item snacks.picker.finder.Item transform = function(item) local status, file = item.text:match("^(..) (.+)$") if status then item.cwd = cwd item.status = status item.file = file prev = item elseif prev and prev.status:find("R") then prev.rename = item.text return false else return false end end, }), ctx ) end ---@param opts snacks.picker.git.diff.Config ---@type snacks.picker.finder function M.diff(opts, ctx) opts = opts or {} local args = M.git("diff", "--no-color", "--no-ext-diff", "--diff-filter=u", { args = { "--no-pager" } }, opts) if opts.base then vim.list_extend(args, { "--merge-base", opts.base }) end if opts.staged then table.insert(args, "--cached") end local cwd = ctx:git_root() ctx.picker:set_cwd(cwd) local Diff = require("snacks.picker.source.diff") local finders = {} ---@type snacks.picker.finder.result[] finders[#finders + 1] = Diff.diff( ctx:opts({ cmd = "git", args = args, cwd = cwd, }), ctx ) if opts.staged == nil and opts.base == nil then finders[#finders + 1] = Diff.diff( ctx:opts({ cmd = "git", args = vim.list_extend(vim.deepcopy(args), { "--cached" }), cwd = cwd, }), ctx ) end return function(cb) local items = {} ---@type snacks.picker.finder.Item[] for f, finder in ipairs(finders) do finder(function(item) if not opts.base then item.staged = opts.staged or f == 2 end items[#items + 1] = item end) end table.sort(items, function(a, b) if a.file ~= b.file then return a.file < b.file end return a.pos[1] < b.pos[1] end) for _, item in ipairs(items) do cb(item) end end end ---@param opts snacks.picker.git.branches.Config ---@type snacks.picker.finder function M.branches(opts, ctx) local args = M.git("branch", "--no-color", "-vvl", { args = { "--no-pager" } }, opts) if opts.all then table.insert(args, "--all") end local cwd = ctx:git_root() local patterns = { -- stylua: ignore start --- e.g. "* (HEAD detached at f65a2c8) f65a2c8 chore(build): auto-generate docs" "^(.)%s(%b())%s+(" .. commit_pat .. ")%s*(.*)$", --- e.g. " main d2b2b7b [origin/main: behind 276] chore(build): auto-generate docs" "^(.)%s(%S+)%s+(".. commit_pat .. ")%s*(.*)$", -- stylua: ignore end } ---@type string[] return require("snacks.picker.source.proc").proc( ctx:opts({ cwd = cwd, cmd = "git", args = args, ---@param item snacks.picker.finder.Item transform = function(item) item.cwd = cwd if item.text:find("HEAD.*%->") then return false end for p, pattern in ipairs(patterns) do local status, branch, commit, msg = item.text:match(pattern) if status then local detached = p == 1 item.current = status == "*" item.branch = not detached and branch or nil item.commit = commit item.msg = msg item.detached = detached return end end Snacks.notify.warn("failed to parse branch: " .. item.text) return false -- skip items we could not parse end, }), ctx ) end ---@param opts snacks.picker.git.Config ---@type snacks.picker.finder function M.stash(opts, ctx) local args = M.git("stash", "list", { args = { "--no-pager" } }, opts) local cwd = ctx:git_root() return require("snacks.picker.source.proc").proc( ctx:opts({ cwd = cwd, cmd = "git", args = args, ---@param item snacks.picker.finder.Item transform = function(item) if item.text:find("autostash", 1, true) then return false end local stash, branch, msg = item.text:gsub(": On (%S+):", ": WIP on %1:"):match("^(%S+): WIP on (%S+): (.*)$") if stash then local commit, m = msg:match("^(" .. commit_pat .. ") (.*)") item.cwd = cwd item.stash = stash item.branch = branch item.commit = commit item.msg = m or msg return end Snacks.notify.warn("failed to parse stash:\n```git\n" .. item.text .. "\n```") return false -- skip items we could not parse end, }), ctx ) end ---@class snacks.picker.git.Status ---@field xy string ---@field status "modified" | "deleted" | "added" | "untracked" | "renamed" | "copied" | "ignored" ---@field unmerged? boolean ---@field staged? boolean ---@field priority? number ---@param xy string ---@return snacks.picker.git.Status function M.git_status(xy) local ss = { A = "added", D = "deleted", M = "modified", R = "renamed", C = "copied", ["?"] = "untracked", ["!"] = "ignored", } local prios = "!?CRDAM" ---@param status string ---@param unmerged? boolean ---@param staged? boolean local function s(status, unmerged, staged) local prio = (prios:find(status, 1, true) or 0) + (unmerged and 20 or 0) if not staged and not status:find("[!]") then prio = prio + 10 end return { xy = xy, status = ss[status], unmerged = unmerged, staged = staged, priority = prio, } end ---@param c string local function f(c) return xy:gsub("T", "M"):match(c) --[[@as string?]] end if f("%?%?") then return s("?") elseif f("!!") then return s("!") elseif f("UU") then return s("M", true) elseif f("DD") then return s("D", true) elseif f("AA") then return s("A", true) elseif f("U") then return s(f("A") and "A" or "D", true) end local m = f("^([MADRC])") if m then return s(m, nil, true) end m = f("([MADRC])$") if m then return s(m) end error("unknown status: " .. xy) end ---@param a string ---@param b string function M.merge_status(a, b) if a == b then return a end local as = M.git_status(a) local bs = M.git_status(b) if as.unmerged or bs.unmerged then return as.priority > bs.priority and as.xy or bs.xy end if not as.staged or not bs.staged then if as.status == bs.status then return as.staged and b or a end return " M" end return "M " end return M ================================================ FILE: lua/snacks/picker/source/grep.lua ================================================ local M = {} local uv = vim.uv or vim.loop local MATCH_SEP = "󰄊󱥳󱥰" ---@param opts snacks.picker.grep.Config ---@param filter snacks.picker.Filter local function get_cmd(opts, filter) local cmd = "rg" local args = { "--color=never", "--no-heading", "--with-filename", "--line-number", "--replace", ("%s${0}%s"):format(MATCH_SEP, MATCH_SEP), "--column", "--smart-case", "--max-columns=500", "--max-columns-preview", "--glob=!.bare", "--glob=!.git", "-0", } args = vim.deepcopy(args) -- exclude for _, e in ipairs(opts.exclude or {}) do vim.list_extend(args, { "-g", "!" .. e }) end -- hidden if opts.hidden then table.insert(args, "--hidden") else table.insert(args, "--no-hidden") end -- ignored if opts.ignored then args[#args + 1] = "--no-ignore" end -- follow if opts.follow then args[#args + 1] = "-L" end local types = type(opts.ft) == "table" and opts.ft or { opts.ft } ---@cast types string[] for _, t in ipairs(types) do args[#args + 1] = "-t" args[#args + 1] = t end if opts.regex == false then args[#args + 1] = "--fixed-strings" end local glob = type(opts.glob) == "table" and opts.glob or { opts.glob } ---@cast glob string[] for _, g in ipairs(glob) do args[#args + 1] = "-g" args[#args + 1] = g end -- extra args vim.list_extend(args, opts.args or {}) -- search pattern local pattern, pargs = Snacks.picker.util.parse(filter.search) vim.list_extend(args, pargs) args[#args + 1] = "--" table.insert(args, pattern) local paths = {} ---@type string[] if opts.buffers then for _, buf in ipairs(vim.api.nvim_list_bufs()) do local name = vim.api.nvim_buf_get_name(buf) if name ~= "" and vim.bo[buf].buflisted and uv.fs_stat(name) then paths[#paths + 1] = name end end end vim.list_extend(paths, opts.dirs or {}) if opts.rtp then vim.list_extend(paths, Snacks.picker.util.rtp()) end -- dirs if #paths > 0 then paths = vim.tbl_map(svim.fs.normalize, paths) ---@type string[] vim.list_extend(args, paths) end return cmd, args end ---@param opts snacks.picker.grep.Config ---@type snacks.picker.finder function M.grep(opts, ctx) if opts.need_search ~= false and ctx.filter.search == "" then return function() end end local absolute = (opts.dirs and #opts.dirs > 0) or opts.buffers or opts.rtp local cwd = not absolute and svim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil local cmd, args = get_cmd(opts, ctx.filter) if opts.debug.grep then Snacks.notify.info("grep: " .. cmd .. " " .. table.concat(args, " ")) end return require("snacks.picker.source.proc").proc( ctx:opts({ notify = false, -- never notify on grep errors, since it's impossible to know if the error is due to the search pattern cmd = cmd, args = args, ---@param item snacks.picker.finder.Item transform = function(item) item.cwd = cwd -- Split on NUL byte (which comes from rg's -0 flag) local file_sep = item.text:find("\0") if not file_sep then if not item.text:match("WARNING") then Snacks.notify.error("invalid grep output:\n" .. item.text) end return false end local file = item.text:sub(1, file_sep - 1) local rest = item.text:sub(file_sep + 1) ---@type string?, string?, string? local line, col, text = rest:match("^(%d+):(%d+):(.*)$") if not (line and col and text) then if not item.text:match("WARNING") then Snacks.notify.error("invalid grep output:\n" .. item.text:gsub("%z", " ")) end return false end item.text = file .. ":" .. rest:gsub(MATCH_SEP, "") -- indices of matches local from = tonumber(col) item.pos = { tonumber(line), from - 1 } item.resolve = function() local positions = {} ---@type number[] local offset = 0 local in_match = false while from < #text do local idx = text:find(MATCH_SEP, from, true) if not idx then break end if in_match then for i = from, idx - 1 do positions[#positions + 1] = i - offset end item.end_pos = item.end_pos or { item.pos[1], idx - offset - 1 } end in_match = not in_match offset = offset + #MATCH_SEP from = idx + #MATCH_SEP end item.positions = #positions > 0 and positions or nil item.line = text:gsub(MATCH_SEP, "") end item.file = file end, }), ctx ) end return M ================================================ FILE: lua/snacks/picker/source/help.lua ================================================ local M = {} ---@param opts snacks.picker.help.Config ---@type snacks.picker.finder function M.help(opts, ctx) local langs = opts.lang or vim.opt.helplang:get() ---@type string[] local rtp = vim.o.runtimepath if package.loaded.lazy then rtp = rtp .. "," .. table.concat(require("lazy.core.util").get_unloaded_rtp(""), ",") end local files = vim.fn.globpath(rtp, "doc/*", true, true) ---@type string[] if not vim.tbl_contains(langs, "en") then langs[#langs + 1] = "en" end local tag_files = {} ---@type string[] for _, file in ipairs(files) do local name = vim.fn.fnamemodify(file, ":t") local lang = "en" if name == "tags" or name:sub(1, 5) == "tags-" then lang = name:match("^tags%-(..)$") or lang if vim.tbl_contains(langs, lang) then tag_files[#tag_files + 1] = file end end end ---@async ---@param cb async fun(item: snacks.picker.finder.Item) return function(cb) local done = {} ---@type table for _, file in ipairs(tag_files) do local dir = vim.fs.dirname(file) for line in io.lines(file) do local fields = vim.split(line, string.char(9), { plain = true }) local tag = fields[1] if not line:match("^!_TAG_") and #fields >= 3 and not done[tag] then done[tag] = true ---@type snacks.picker.finder.Item local item = { text = tag, tag = tag, file = dir .. "/" .. fields[2], search = "/\\V" .. fields[3]:sub(2), } if tag:find("^[vbg]?:") or tag:find("^/") then item.ft = "vim" elseif tag:find("%(%)$") then item.ft = "lua" elseif tag:find("^'.*'$") then item.text_hl = "String" elseif tag:find("^E%d+$") then item.text_hl = "Error" elseif tag:find("^hl%-") then item.text_hl = tag:sub(4) end if item.file then cb(item) end end end end end end return M ================================================ FILE: lua/snacks/picker/source/icons.lua ================================================ local M = {} ---@class snacks.picker.icons.Source ---@field url string ---@field v? number ---@field priority? number ---@field build fun(data:table):snacks.picker.Icon[] ---@alias snacks.picker.icons.source.Item {[1]:string, [2]:string}|{icon:string, name:string, category:string} local NERDFONTS_SETS = { cod = "Codicons", dev = "Devicons", fa = "Font Awesome", fae = "Font Awesome Extension", iec = "IEC Power Symbols", linux = "Font Logos", logos = "Font Logos", oct = "Octicons", ple = "Powerline Extra", pom = "Pomicons", seti = "Seti-UI", weather = "Weather Icons", md = "Material Design Icons", } ---@param source string local function custom_source(source, url) ---@type snacks.picker.icons.Source return { v = 3, url = url, build = function(data) ---@cast data snacks.picker.icons.source.Item[] local ret = {} ---@type snacks.picker.Icon[] for _, info in ipairs(data) do table.insert(ret, { name = vim.trim(info.name or info[2] or ""), icon = vim.trim(info.icon or info[1] or ""), category = info.category, source = source, }) end return ret end, } end ---@type table M.sources = { nerd_fonts = { priority = 10, url = "https://github.com/ryanoasis/nerd-fonts/raw/refs/heads/master/glyphnames.json", v = 4, build = function(data) ---@cast data table local ret = {} ---@type snacks.picker.Icon[] for name, info in pairs(data) do if name ~= "METADATA" then local font, icon = name:match("^([%w_]+)%-(.*)$") if not font then error("Invalid icon name: " .. name) end table.insert(ret, { name = icon, icon = info.char, source = "nerd fonts", category = NERDFONTS_SETS[font] or font, }) end end return ret end, }, emoji = { url = "https://raw.githubusercontent.com/muan/unicode-emoji-json/refs/heads/main/data-by-emoji.json", priority = 20, v = 4, build = function(data) ---@cast data table local ret = {} ---@type snacks.picker.Icon[] for icon, info in pairs(data) do table.insert(ret, { name = info.name, icon = icon, source = "emoji", category = info.group, }) end return ret end, }, } ---@class snacks.picker.Icon: snacks.picker.finder.Item ---@field icon string ---@field name string ---@field source string ---@field category string ---@field desc? string ---@param source_name string local function load(source_name) local source = M.sources[source_name] if not source then Snacks.notify.error("Unknown icon source: " .. source_name) return {} end -- Load from local file if not a URL if not source.url:find("^https?://") then local fd = assert(io.open(source.url, "r")) local data = fd:read("*a") fd:close() return source.build(vim.json.decode(data)) end local parts = { source_name, "v" .. (source.v or 1), "-", vim.fn.sha256(source.url):sub(1, 8), ".json" } local file = vim.fn.stdpath("cache") .. "/snacks/picker/icons/" .. table.concat(parts, "") vim.fn.mkdir(vim.fn.fnamemodify(file, ":h"), "p") if vim.fn.filereadable(file) == 1 then local fd = assert(io.open(file, "r")) local data = fd:read("*a") fd:close() return vim.json.decode(data) ---@type snacks.picker.Icon[] end Snacks.notify("Fetching `" .. source_name .. "` icons") if vim.fn.executable("curl") == 0 then Snacks.notify.error("`curl` is required to fetch icons") return {} end local out = vim.fn.system({ "curl", "-s", "-L", source.url }) if vim.v.shell_error ~= 0 then Snacks.notify.error(out, { title = "Icons Picker" }) return {} end local icons = source.build(vim.json.decode(out)) local fd = assert(io.open(file, "w")) fd:write(vim.json.encode(icons)) fd:close() return icons end ---@param opts snacks.picker.icons.Config ---@type snacks.picker.finder function M.icons(opts) local ret = {} ---@type snacks.picker.Icon[] for source, url in pairs(opts.custom_sources or {}) do M.sources[source] = custom_source(source, url) end local sources = opts.icon_sources or vim.tbl_keys(M.sources) table.sort(sources, function(a, b) local sa = M.sources[a] and M.sources[a].priority or 0 local sb = M.sources[b] and M.sources[b].priority or 0 return sa > sb end) for _, source in ipairs(sources) do vim.list_extend(ret, load(source)) end for _, icon in ipairs(ret) do icon.text = Snacks.picker.util.text(icon, { "source", "category", "name" }) icon.data = icon.icon end return ret end return M ================================================ FILE: lua/snacks/picker/source/lazy.lua ================================================ local M = {} ---@type snacks.picker.finder function M.spec(opts, ctx) local spec = require("lazy.core.config").spec local Util = require("lazy.core.util") local paths = {} ---@type string[] for _, import in ipairs(spec.modules) do Util.lsmod(import, function(_, modpath) paths[#paths + 1] = modpath end) end local names = {} ---@type string[] for _, frag in pairs(spec.meta.fragments.fragments) do local name = frag.spec[1] or frag.name if not vim.tbl_contains(names, name) then names[#names + 1] = name end end local regex = "\\M\\['\"]\\(" .. table.concat(names, "\\|") .. "\\)\\['\"]" local re = vim.regex(regex) local ret = {} ---@type snacks.picker.finder.Item[] for _, path in ipairs(paths) do local lines = Snacks.picker.util.lines(path) for l, line in ipairs(lines) do local from, to = re:match_str(line) if from then ret[#ret + 1] = { file = path, line = line, text = line, pos = { l, from }, end_pos = { l, to }, } end end end return ret end return M ================================================ FILE: lua/snacks/picker/source/lines.lua ================================================ local M = {} ---@param opts snacks.picker.lines.Config ---@type snacks.picker.finder function M.lines(opts, ctx) local buf = opts.buf or ctx.filter.current_buf buf = buf == 0 and vim.api.nvim_get_current_buf() or buf local extmarks = require("snacks.picker.util.highlight").get_highlights({ buf = buf, extmarks = true }) local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) local items = {} ---@type snacks.picker.finder.Item[] for l, line in ipairs(lines) do ---@type snacks.picker.finder.Item local item = { buf = buf, text = line, pos = { l, (line:find("%S") or 1) - 1 }, highlights = extmarks[l], } items[#items + 1] = item end return items end return M ================================================ FILE: lua/snacks/picker/source/lsp/config.lua ================================================ ---@diagnostic disable: await-in-sync local M = {} local has_11 = vim.fn.has("nvim-0.11") == 1 ---@class snacks.picker.lsp.config.Item: snacks.picker.finder.Item ---@field name string ---@field config? vim.lsp.ClientConfig ---@field docs? string ---@field buffers table ---@field attached? boolean ---@field attached_buf? boolean ---@field enabled? boolean ---@field installed? boolean ---@field deprecated? boolean ---@field cmd? string[] ---@field bin? string ---@class snacks.picker.lsp.config.Config ---@field config vim.lsp.Config ---@field enabled? boolean ---@field docs? string ---@field deprecated? boolean ---@param name string local function is_enabled(name) if has_11 then return vim.lsp.is_enabled(name) end local lspconfig = require("lspconfig.configs") return lspconfig[name] and lspconfig[name].manager ~= nil end ---@param name string local function get_config(name) local modpath = vim.api.nvim_get_runtime_file("lsp/" .. name .. ".lua", false)[1] local ret = { config = {} } ---@type snacks.picker.lsp.config.Config local deprecate = vim.deprecate vim.deprecate = function() ret.deprecated = true end local ok, config = pcall(function() return has_11 and vim.lsp.config[name] or loadfile(modpath)() or {} end) vim.deprecate = deprecate ret.config = ok and config or {} ret.enabled = is_enabled(name) local lines = modpath and Snacks.picker.util.lines(modpath) or {} local header = {} ---@type string[] for _, line in ipairs(lines) do if line:match("^%s*%-%-") then if not line:match("@brief") then header[#header + 1] = line:gsub("^%s*%-%-+%s?", "") end elseif not line:match("^%s*$") then break end end ret.docs = vim.trim(table.concat(header, "\n")) return ret end ---@param opts snacks.picker.lsp.config.Config ---@type snacks.picker.finder function M.find(opts, ctx) local all = vim.api.nvim_get_runtime_file("lsp/*.lua", true) local available = {} ---@type table for _, f in ipairs(all) do local name = f:match("([^/\\]+)%.lua$") if name then available[name] = name end end for name in pairs(has_11 and vim.lsp.config._configs or {}) do available[name] = name end if vim.tbl_count(available) == 0 then Snacks.notify.warn("No LSP configurations found?") return {} end local main_buf = vim.api.nvim_win_get_buf(ctx.picker.main) ---@param item snacks.picker.lsp.config.Item local function resolve(item) local mod = get_config(item.name) item.docs = item.docs or mod.docs item.config = item.config or mod.config item.cmd = item.cmd or mod.config.cmd item.enabled = item.enabled or mod.enabled item.deprecated = mod.deprecated end local items = {} ---@type table for _, client in ipairs(vim.lsp.get_clients()) do items[client.name] = items[client.name] or { name = client.name, buffers = {}, config = client.config, attached = true, enabled = true, cmd = client.config.cmd, } for buf in pairs(client.attached_buffers) do items[client.name].buffers[buf] = true end items[client.name].attached_buf = items[client.name].buffers[main_buf] end for name in pairs(available) do items[name] = items[name] or { name = name, buffers = {}, } items[name].resolve = resolve end ---@param cb async fun(item: snacks.picker.finder.Item) return function(cb) local bins = Snacks.picker.util.get_bins() for name, item in pairs(items) do Snacks.picker.util.resolve(item) local config = item.config or {} local cmd = item.cmd or type(config.cmd) == "table" and config.cmd or nil local bin ---@type string? local installed = false if type(cmd) == "table" and #cmd > 0 then ---@type string[] cmd = vim.deepcopy(cmd) cmd[1] = svim.fs.normalize(cmd[1]) if cmd[1]:find("/") then installed = vim.fn.filereadable(cmd[1]) == 1 bin = cmd[1] else bin = bins[cmd[1]] or cmd[1] installed = bins[cmd[1]] ~= nil end cmd[1] = vim.fs.basename(cmd[1]) end local want = (not opts.installed or installed) and (not opts.configured or item.enabled) if opts.attached == true and not item.attached then want = false elseif type(opts.attached) == "number" then local buf = opts.attached == 0 and main_buf or opts.attached if not item.buffers[buf] then want = false end end want = want and not item.deprecated if want then cb({ name = name, cmd = cmd, bin = bin, installed = installed, enabled = item.enabled or false, buffers = item.buffers, attached = item.attached or false, attached_buf = item.attached_buf or false, text = name .. " " .. table.concat(config.filetypes or {}, " "), docs = item.docs, config = config, }) end end end end ---@param item snacks.picker.Item ---@param picker snacks.Picker function M.format(item, picker) local a = Snacks.picker.util.align local ret = {} ---@type snacks.picker.Highlight[] local config = item.config ---@type vim.lsp.ClientConfig local icons = picker.opts.icons.lsp if item.attached_buf then ret[#ret + 1] = { a(icons.attached, 2), "SnacksPickerLspAttachedBuf" } elseif item.attached then ret[#ret + 1] = { a(icons.attached, 2), "SnacksPickerLspAttached" } elseif item.enabled then ret[#ret + 1] = { a(icons.enabled, 2), "SnacksPickerLspEnabled" } elseif item.installed then ret[#ret + 1] = { a(icons.disabled, 2), "SnacksPickerLspDisabled" } else ret[#ret + 1] = { a(icons.unavailable, 2), "SnacksPickerLspUnavailable" } end ret[#ret + 1] = { a(item.name, 20) } for _, ft in ipairs(config.filetypes or {}) do ret[#ret + 1] = { " " } local icon, hl = Snacks.util.icon(ft, "filetype") ret[#ret + 1] = { a(icon, 2), hl } ret[#ret + 1] = { ft, "SnacksPickerDimmed" } end return ret end ---@param ctx snacks.picker.preview.ctx function M.preview(ctx) local config = ctx.item.config ---@type vim.lsp.ClientConfig local item = ctx.item --[[@as snacks.picker.lsp.config.Item]] local lines = {} ---@type string[] lines[#lines + 1] = "# `" .. item.name .. "`" lines[#lines + 1] = "" ---@param path string local function norm(path) return vim.fn.fnamemodify(path, ":p:~"):gsub("[\\/]$", "") end local function list(values) return table.concat( vim.tbl_map(function(v) return "`" .. vim.inspect(v) .. "`" end, values), ", " ) end if item.cmd then local cmd = type(item.cmd) == "function" and "" or table.concat(item.cmd, " ") lines[#lines + 1] = "- **cmd**: `" .. cmd .. "`" end if item.installed then lines[#lines + 1] = "- **installed**: `" .. norm(item.bin) .. "`" lines[#lines + 1] = "- **enabled**: " .. (item.enabled and "yes" or "no") else lines[#lines + 1] = "- **installed**: " .. (item.bin and "`" .. item.bin .. "` " or "") .. "not installed" end local ft = config.filetypes or {} if #ft > 0 then lines[#lines + 1] = "- **filetypes**: " .. list(ft) end -- root markers local markers = config.root_markers or {} if #markers > 0 then lines[#lines + 1] = "- **root markers**: " .. list(markers) end local clients = vim.lsp.get_clients({ name = item.name }) if #clients > 0 then for _, client in ipairs(clients) do lines[#lines + 1] = "" lines[#lines + 1] = "## Client [id=" .. client.id .. "]" lines[#lines + 1] = "" -- server info for k, v in pairs(client.server_info or {}) do lines[#lines + 1] = ("- **%s**: `%s`"):format(k, v) end -- workspaces local roots = {} ---@type string[] for _, ws in ipairs(client.workspace_folders or {}) do roots[#roots + 1] = vim.uri_to_fname(ws.uri) end roots = #roots == 0 and { client.root_dir } or roots if #roots > 0 then if #roots > 1 then lines[#lines + 1] = "- **workspace**:" for _, root in ipairs(roots) do lines[#lines + 1] = " - `" .. norm(root) .. "`" end else lines[#lines + 1] = "- **workspace**: `" .. norm(roots[1]) .. "`" end end -- buffers lines[#lines + 1] = "- **buffers**: " .. list(vim.tbl_keys(client.attached_buffers)) local function format_cap(method, value) if not value then return end value = type(value) == "table" and value or {} ---@cast value table local details = {} ---@type string[] local checks = { ["workspace/executeCommand"] = "commands", ["textDocument/codeAction"] = "codeActionKinds", } for m, k in pairs(checks) do if method == m and type(value[k]) == "table" then details = value[k] --[[@as string[] ]] break end end lines[#lines + 1] = (" * **%s**:%s"):format(method, #details > 0 and "" or " `true`") if #details > 0 then for _, detail in ipairs(details) do lines[#lines + 1] = " - `" .. detail .. "`" end end end -- server capabilities local methods = vim.tbl_keys(vim.lsp.protocol._request_name_to_server_capability or {}) --[[@as string[] ]] table.sort(methods) if #methods > 0 then lines[#lines + 1] = "- **server capabilities**:" for _, method in ipairs(methods) do local cap = vim.lsp.protocol._request_name_to_server_capability[method] local value = vim.tbl_get(client.server_capabilities, unpack(cap)) format_cap(method, value) end end -- dynamic capabilities methods = vim.tbl_keys(vim.tbl_get(client, "dynamic_capabilities", "capabilities") or {}) --[[@as string[] ]] table.sort(methods) if #methods > 0 then lines[#lines + 1] = "- **dynamic capabilities**:" for _, cap in ipairs(methods) do local method = table.concat(vim.lsp.protocol._provider_to_client_registration[cap] or {}, "/") local regs = client.dynamic_capabilities.capabilities[cap] for _, reg in ipairs(regs or {}) do format_cap(method, reg.registerOptions or {}) end end end -- settings local settings = vim.inspect(client.settings) if not vim.tbl_isempty(client.settings) then lines[#lines + 1] = "- **settings**:" lines[#lines + 1] = "```lua\n" .. settings .. "\n```" end -- init options if not vim.tbl_isempty(client.config.init_options or {}) then local init_options = vim.inspect(client.config.init_options) lines[#lines + 1] = "- **init options**:" lines[#lines + 1] = "```lua\n" .. init_options .. "\n```" end end end if item.docs then lines[#lines + 1] = "" lines[#lines + 1] = "## Docs" lines[#lines + 1] = "" lines[#lines + 1] = item.docs end ctx.preview:set_lines(lines) ctx.preview:highlight({ ft = "markdown" }) end return M ================================================ FILE: lua/snacks/picker/source/lsp/init.lua ================================================ ---@diagnostic disable: await-in-sync local Async = require("snacks.picker.util.async") ---@module 'uv' local M = {} ---@alias lsp.Symbol lsp.SymbolInformation|lsp.DocumentSymbol ---@alias lsp.Loc lsp.LocationLink|lsp.Location ---@class snacks.picker.lsp.Loc: lsp.Location ---@field encoding string ---@field resolved? boolean local kinds = nil ---@type table --- Gets the original symbol kind name from its number. --- Some plugins override the symbol kind names, so this function is needed to get the original name. ---@param kind lsp.SymbolKind ---@return string function M.symbol_kind(kind) if not kinds then kinds = {} for k, v in pairs(vim.lsp.protocol.SymbolKind) do if type(v) == "number" then kinds[v] = k end end end return kinds[kind] or "Unknown" end --- Neovim 0.11 uses a lua class for clients, while older versions use a table. --- Wraps older style clients to be compatible with the new style. ---@param client vim.lsp.Client ---@return vim.lsp.Client local function wrap(client) local meta = getmetatable(client) if meta and meta.request then return client end ---@diagnostic disable-next-line: undefined-field if client.wrapped then return client end local methods = { "request", "supports_method", "cancel_request", "notify" } -- old style return setmetatable({ wrapped = true }, { __index = function(_, k) if k == "supports_method" then -- supports_method doesn't support the bufnr argument return function(_, method) return client[k](method) end end if vim.tbl_contains(methods, k) then return function(_, ...) return client[k](...) end end return client[k] end, }) end ---@param item snacks.picker.finder.Item ---@param result lsp.Loc ---@param client vim.lsp.Client function M.add_loc(item, result, client) ---@type snacks.picker.lsp.Loc local loc = { uri = result.uri or result.targetUri, range = result.range or result.targetSelectionRange, encoding = client.offset_encoding, } item.loc = loc item.pos = { loc.range.start.line + 1, loc.range.start.character } item.end_pos = { loc.range["end"].line + 1, loc.range["end"].character } item.file = vim.uri_to_fname(loc.uri) return item end ---@param buf number ---@param method string ---@return vim.lsp.Client[] function M.get_clients(buf, method) ---@param client vim.lsp.Client local clients = vim.tbl_map(function(client) return wrap(client) ---@diagnostic disable-next-line: deprecated end, (vim.lsp.get_clients or vim.lsp.get_active_clients)({ bufnr = buf })) ---@param client vim.lsp.Client return vim.tbl_filter(function(client) return client:supports_method(method, buf) ---@diagnostic disable-next-line: deprecated end, clients) end ---@class snacks.picker.lsp.Requester ---@field async snacks.picker.Async ---@field requests table ---@field pending integer ---@field autocmd_id? number local R = {} R.__index = R R._id = 0 function R.new() local self = setmetatable({}, R) self.async = Async.running() self.requests = {} self.pending = 0 R._id = R._id + 1 self.async :on( "abort", vim.schedule_wrap(function() self:cancel() end) ) :on( "done", vim.schedule_wrap(function() pcall(vim.api.nvim_del_autocmd, self.autocmd_id) end) ) return self end ---@param clients vim.lsp.Client[] ---@param ctx lsp.HandlerContext function R:debug(clients, ctx) Snacks.debug.inspect({ error = "LSP request callback yielded after done.", method = ctx.method, requests = vim.deepcopy(self.requests), pending = self.pending, client_id = ctx.client_id, ---@param c vim.lsp.Client clients = vim.tbl_map(function(c) return { id = c.id, name = c.name } end, clients), }) end ---@param client_id number ---@param request_id number ---@param completed? boolean function R:track(client_id, request_id, completed) local key = ("%d:%d"):format(client_id, request_id) if completed and self.requests[key] and not self.requests[key].done then self.requests[key].done = true self.pending = self.pending - 1 self.async:resume() return elseif not completed then self.requests[key] = { client_id = client_id, request_id = request_id, done = false } self.pending = self.pending + 1 end end function R:cancel() while #self.requests > 0 do local req = table.remove(self.requests) local client = vim.lsp.get_client_by_id(req.client_id) if client then client:cancel_request(req.request_id) end end end function R:track_cancel() if self.autocmd_id then return end self.autocmd_id = vim.api.nvim_create_autocmd("LspRequest", { group = vim.api.nvim_create_augroup("snacks.picker.lsp.cancel." .. R._id, { clear = true }), callback = function(ev) if ev.data.request.type == "cancel" then self:track(ev.data.client_id, ev.data.request_id, true) end end, }) end ---@param buf number|vim.lsp.Client ---@param method string ---@param params fun(client:vim.lsp.Client):table ---@param cb fun(client:vim.lsp.Client, result:table, params:table) ---@async function R:request(buf, method, params, cb) self.pending = self.pending + 1 vim.schedule(function() self:track_cancel() -- setup autocmd here, since this must be called in the main loop ---@diagnostic disable-next-line: param-type-mismatch local clients = type(buf) == "number" and M.get_clients(buf, method) or { wrap(buf) } self.pending = self.pending + #clients for _, client in ipairs(clients) do local done = false local status, request_id ---@type boolean, number? status, request_id = client:request(method, params(client), function(err, result, ctx) done = true if not err and result and not self.async:aborted() then if not self.async:running() or self.pending <= 0 then self:debug(clients, ctx) end cb(client, result, ctx.params) end if request_id then self:track(client.id, request_id, true) end end) -- skip tracking if the request failed -- or is already done (in-process syncronous response) if status and request_id and not done then self:track(client.id, request_id) end end self.pending = self.pending - 1 - #clients self.async:resume() end) return self end function R:wait() while self.pending > 0 do self.async:suspend() end end ---@param buf number ---@param method string ---@param params fun(client:vim.lsp.Client):table ---@param cb fun(client:vim.lsp.Client, result:table, params:table) ---@async function M.request(buf, method, params, cb) R.new():request(buf, method, params, cb):wait() end -- Support for older versions of neovim ---@param locs vim.quickfix.entry[] function M.fix_locs(locs) for _, loc in ipairs(locs) do local range = loc.user_data and loc.user_data.range or nil ---@type lsp.Range? if range then if not loc.end_lnum then if range.start.line == range["end"].line then loc.end_lnum = loc.lnum loc.end_col = loc.col + range["end"].character - range.start.character end end end end end function M.bufmap() local bufmap = {} ---@type table for _, b in ipairs(vim.api.nvim_list_bufs()) do if vim.bo[b].buflisted and vim.bo[b].buftype == "" and vim.api.nvim_buf_is_loaded(b) then local name = vim.api.nvim_buf_get_name(b) if name ~= "" then bufmap[name] = b end end end return bufmap end ---@param method string ---@param opts snacks.picker.lsp.Config|{context?:lsp.ReferenceContext} ---@param filter snacks.picker.Filter function M.get_locations(method, opts, filter) local win = filter.current_win local buf = filter.current_buf local fname = vim.api.nvim_buf_get_name(buf) fname = svim.fs.normalize(fname) local cursor = vim.api.nvim_win_get_cursor(win) local bufmap = M.bufmap() ---@async ---@param cb async fun(item: snacks.picker.finder.Item) return function(cb) M.request(buf, method, function(client) local params = vim.lsp.util.make_position_params(win, client.offset_encoding) ---@diagnostic disable-next-line: inject-field params.context = opts.context return params end, function(client, result) result = result or {} -- Result can be a single item or a list of items result = vim.tbl_isempty(result) and {} or svim.islist(result) and result or { result } local items = vim.lsp.util.locations_to_items(result or {}, client.offset_encoding) M.fix_locs(items) if not opts.include_current then ---@param item vim.quickfix.entry items = vim.tbl_filter(function(item) if svim.fs.normalize(item.filename) ~= fname then return true end if not item.lnum then return true end if item.lnum == cursor[1] then return false end if not item.end_lnum then return true end return not (item.lnum <= cursor[1] and item.end_lnum >= cursor[1]) end, items) end local done = {} ---@type table for _, loc in ipairs(items) do ---@type snacks.picker.finder.Item local item = { text = loc.filename .. " " .. loc.text, buf = bufmap[loc.filename], file = loc.filename, pos = { loc.lnum, loc.col - 1 }, end_pos = loc.end_lnum and loc.end_col and { loc.end_lnum, loc.end_col - 1 } or nil, line = loc.text, } local loc_key = loc.filename .. ":" .. loc.lnum if filter:match(item) and not (done[loc_key] and opts.unique_lines) then ---@diagnostic disable-next-line: await-in-sync cb(item) done[loc_key] = true end end end) end end ---@alias lsp.ResultItem lsp.Symbol|lsp.CallHierarchyItem|{text?:string} ---@param client vim.lsp.Client ---@param results lsp.ResultItem[] ---@param opts? {default_uri?:string, filter?:(fun(result:lsp.ResultItem):boolean), text_with_file?:boolean} function M.results_to_items(client, results, opts) opts = opts or {} local items = {} ---@type snacks.picker.finder.Item[] local last = {} ---@type table ---@param result lsp.ResultItem ---@param parent snacks.picker.finder.Item local function add(result, parent) ---@type snacks.picker.finder.Item local item = { kind = M.symbol_kind(result.kind), parent = parent, detail = result.detail, name = result.name, text = "", range = result.range or result.selectionRange, item = result, } local uri = result.location and result.location.uri or result.uri or opts.default_uri local loc = result.location or { range = result.selectionRange or result.range, uri = uri } loc.uri = loc.uri or uri M.add_loc(item, loc, client) local text = table.concat({ M.symbol_kind(result.kind), result.name }, " ") if opts.text_with_file and item.file then text = text .. " " .. item.file end item.text = text if not opts.filter or opts.filter(result) then items[#items + 1] = item last[parent] = item parent = item end for _, child in ipairs(result.children or {}) do add(child, parent) end result.children = nil end local root = { text = "", root = true } ---@type snacks.picker.finder.Item ---@type snacks.picker.finder.Item for _, result in ipairs(results) do add(result, root) end for _, item in pairs(last) do item.last = true end return items end ---@param opts snacks.picker.lsp.symbols.Config ---@type snacks.picker.finder function M.symbols(opts, ctx) if opts.keep_parents then ctx.picker.matcher.opts.keep_parents = true ctx.picker.matcher.opts.sort = false end local buf = ctx.filter.current_buf -- For unloaded buffers, load the buffer and -- refresh the picker on every LspAttach event -- for 10 seconds. Also defer to ensure the file is loaded by the LSP. if not vim.api.nvim_buf_is_loaded(buf) then local id = vim.api.nvim_create_autocmd("LspAttach", { buffer = buf, callback = vim.schedule_wrap(function() if ctx.picker:count() > 0 then return true end ctx.picker:find() vim.defer_fn(function() if ctx.picker:count() == 0 then ctx.picker:find() end end, 1000) end), }) pcall(vim.fn.bufload, buf) vim.defer_fn(function() vim.api.nvim_del_autocmd(id) end, 10000) return function() ctx.async:sleep(2000) end end local bufmap = M.bufmap() local filter = opts.filter[vim.bo[buf].filetype] if filter == nil then filter = opts.filter.default end ---@param kind string? local function want(kind) kind = kind or "Unknown" return type(filter) == "boolean" or vim.tbl_contains(filter, kind) end local method = opts.workspace and "workspace/symbol" or "textDocument/documentSymbol" local p = opts.workspace and { query = ctx.filter.search } or { textDocument = vim.lsp.util.make_text_document_params(buf) } ---@async ---@param cb async fun(item: snacks.picker.finder.Item) return function(cb) M.request(buf, method, function() return p end, function(client, result, params) local items = M.results_to_items(client, result, { default_uri = params.textDocument and params.textDocument.uri or nil, text_with_file = opts.workspace, filter = function(item) return want(M.symbol_kind(item.kind)) end, }) -- Fix sorting if not opts.workspace then table.sort(items, function(a, b) if a.pos[1] == b.pos[1] then return a.pos[2] < b.pos[2] end return a.pos[1] < b.pos[1] end) end -- fix last local last = {} ---@type table for _, item in ipairs(items) do item.last = nil local parent = item.parent if parent then if last[parent] then last[parent].last = nil end last[parent] = item item.last = true end end for _, item in ipairs(items) do item.tree = opts.tree item.buf = bufmap[item.file] ---@diagnostic disable-next-line: await-in-sync cb(item) end end) end end ---@param opts snacks.picker.lsp.Config ---@param filter snacks.picker.Filter ---@param incoming? boolean function M.call_hierarchy(opts, filter, incoming) local method = ("callHierarchy/%sCalls"):format(incoming and "incoming" or "outgoing") local buf = filter.current_buf local win = filter.current_win ---@async ---@param cb async fun(item: snacks.picker.finder.Item) return function(cb) local requester = R.new() requester:request(buf, "textDocument/prepareCallHierarchy", function(client) return vim.lsp.util.make_position_params(win, client.offset_encoding) end, function(client, result) ---@cast result lsp.CallHierarchyItem[] for _, res in ipairs(result or {}) do requester:request(client, method, function() return { item = res } end, function(_, calls) ---@cast calls (lsp.CallHierarchyIncomingCall|lsp.CallHierarchyOutgoingCall)[] local call_items = {} ---@type lsp.CallHierarchyItem[] ---@param call lsp.CallHierarchyIncomingCall|lsp.CallHierarchyOutgoingCall for _, call in ipairs(calls) do if incoming then for _, range in ipairs(call.fromRanges or {}) do local from = vim.deepcopy(call.from) from.selectionRange = range or from.selectionRange table.insert(call_items, from) end else table.insert(call_items, call.to) end end local items = M.results_to_items(client, call_items, { default_uri = res.uri }) vim.tbl_map(cb, items) end) end end) requester:wait() end end ---@param opts snacks.picker.lsp.references.Config ---@type snacks.picker.finder function M.references(opts, ctx) opts = opts or {} return M.get_locations( "textDocument/references", vim.tbl_deep_extend("force", opts, { context = { includeDeclaration = opts.include_declaration }, }), ctx.filter ) end ---@param opts snacks.picker.lsp.Config ---@type snacks.picker.finder function M.incoming_calls(opts, ctx) return M.call_hierarchy(opts, ctx.filter, true) end ---@param opts snacks.picker.lsp.Config ---@type snacks.picker.finder function M.outgoing_calls(opts, ctx) return M.call_hierarchy(opts, ctx.filter, false) end ---@param opts snacks.picker.lsp.Config ---@type snacks.picker.finder function M.definitions(opts, ctx) return M.get_locations("textDocument/definition", opts, ctx.filter) end ---@param opts snacks.picker.lsp.Config ---@type snacks.picker.finder function M.type_definitions(opts, ctx) return M.get_locations("textDocument/typeDefinition", opts, ctx.filter) end ---@param opts snacks.picker.lsp.Config ---@type snacks.picker.finder function M.implementations(opts, ctx) return M.get_locations("textDocument/implementation", opts, ctx.filter) end ---@param opts snacks.picker.lsp.Config ---@type snacks.picker.finder function M.declarations(opts, ctx) return M.get_locations("textDocument/declaration", opts, ctx.filter) end return M ================================================ FILE: lua/snacks/picker/source/meta.lua ================================================ local M = {} ---@param file string ---@param t table function M.table(file, t) file = Snacks.meta.file(file) local values = vim.tbl_keys(t) table.sort(values) ---@param value string return vim.tbl_map(function(value) return { file = file, text = value, search = ("/^M\\.%s = \\|function M\\.%s("):format(value, value), } end, values) end ---@param opts snacks.picker.Config ---@type snacks.picker.finder function M.pickers(opts) return M.table("picker/config/sources.lua", opts.sources or {}) end ---@param opts snacks.picker.Config ---@type snacks.picker.finder function M.layouts(opts) return M.table("picker/config/layouts.lua", opts.layouts or {}) end function M.actions() return M.table("picker/actions.lua", require("snacks.picker.actions")) end function M.preview() return M.table("picker/preview.lua", require("snacks.picker.preview")) end function M.format() return M.table("picker/format.lua", require("snacks.picker.format")) end return M ================================================ FILE: lua/snacks/picker/source/proc.lua ================================================ ---@diagnostic disable: await-in-sync local Async = require("snacks.picker.util.async") local M = {} local uv = vim.uv or vim.loop M.USE_QUEUE = true ---@class snacks.picker.proc.Config: snacks.picker.Config ---@field cmd string ---@field sep? string ---@field args? string[] ---@field env? table ---@field cwd? string ---@field notify? boolean Notify on failure ---@field transform? snacks.picker.transform ---@field raw? boolean Return raw output without processing ---@param opts snacks.picker.proc.Config ---@type snacks.picker.finder function M.proc(opts, ctx) ---@cast opts snacks.picker.proc.Config assert(opts.cmd, "`opts.cmd` is required") ---@async return function(cb) if opts.transform then local _cb = cb cb = function(item) local t = opts.transform(item, ctx) item = type(t) == "table" and t or item if t ~= false then _cb(item) end end end if ctx.picker.opts.debug.proc then vim.schedule(function() ---@diagnostic disable-next-line: param-type-mismatch Snacks.debug.cmd(ctx:opts({ group = true })) end) end local sep = opts.sep or "\n" local aborted = false local stdout = assert(uv.new_pipe()) local self = Async.running() local spawn_opts = { args = opts.args, stdio = { nil, stdout, nil }, cwd = opts.cwd and svim.fs.normalize(opts.cwd) or nil, env = opts.env, hide = true, } local handle ---@type uv.uv_process_t ---@diagnostic disable-next-line: missing-fields handle = uv.spawn(opts.cmd, spawn_opts, function(code, _signal) if not aborted and code ~= 0 and opts.notify ~= false then local full = { opts.cmd or "" } vim.list_extend(full, opts.args or {}) Snacks.notify.error(("Command failed:\n- cmd: `%s`"):format(table.concat(full, " "))) end handle:close() self:resume() end) if not handle then return Snacks.notify.error("Failed to spawn " .. opts.cmd) end local prev ---@type string? local queue = require("snacks.picker.util.queue").new() self:on("abort", function() stdout:read_stop() if not stdout:is_closing() then stdout:close() end aborted = true queue:clear() cb = function() end if not handle:is_closing() then handle:kill("sigterm") vim.defer_fn(function() if not handle:is_closing() then handle:kill("sigkill") end end, 200) end end) ---@param data? string local function process(data) if aborted then return end if not data then return prev and cb({ text = prev }) end if opts.raw then return cb({ text = data }) end local from = 1 while from <= #data do local nl = data:find(sep, from, true) if nl then local cr = data:byte(nl - 1, nl - 1) == 13 -- \r local line = data:sub(from, nl - (cr and 2 or 1)) if prev then line, prev = prev .. line, nil end cb({ text = line }) from = nl + 1 elseif prev then prev = prev .. data:sub(from) break else prev = data:sub(from) break end end end stdout:read_start(function(err, data) assert(not err, err) if aborted or not data then stdout:close() self:resume() return end if M.USE_QUEUE then queue:push(data) self:resume() else process(data) end end) while not (stdout:is_closing() and queue:empty()) do if queue:empty() then self:suspend() else process(queue:pop()) end end -- process the last line if prev then cb({ text = prev }) end end end ---@param opts snacks.picker.proc.Config|{[1]: snacks.picker.Config, [2]: snacks.picker.proc.Config} ---@type snacks.picker.finder function M.json(opts, ctx) opts = get_opts(opts) --[[@as snacks.picker.proc.Config]] opts.raw = true local transform = opts.transform opts.transform = nil return function(cb) local Buffer = require("string.buffer") local data = Buffer.new() M.proc(opts, ctx)(function(item) data:put(item.text) end) local json = vim.json.decode(data:get()) assert(svim.islist(json), "Expected JSON array") ---@cast json snacks.picker.finder.Item[] for _, item in ipairs(json) do item = setmetatable({ item = item }, { __index = item }) item.text = item.text or "" local t = transform and transform(item, ctx) or nil item = type(t) == "table" and t or item if t ~= false then cb(item) end end end end ---@param opts {cmd: string, args?: string[], cwd?: string} function M.debug(opts) vim.schedule(function() local lines = { opts.cmd } ---@type string[] for _, arg in ipairs(opts.args or {}) do arg = arg:find("[$%s]") and vim.fn.shellescape(arg) or arg if #arg + #lines[#lines] > 40 then lines[#lines] = lines[#lines] .. " \\" table.insert(lines, " " .. arg) else lines[#lines] = lines[#lines] .. " " .. arg end end local id = opts.cmd for _, a in ipairs(opts.args or {}) do if a:find("^-") then id = id .. " " .. a end end Snacks.notify.info( ("- **cwd**: `%s`\n```sh\n%s\n```"):format( vim.fn.fnamemodify(svim.fs.normalize(opts.cwd or uv.cwd() or "."), ":~"), table.concat(lines, "\n") ), { id = "snacks.picker.proc." .. id, title = "Snacks Proc" } ) end) end return M ================================================ FILE: lua/snacks/picker/source/qf.lua ================================================ local M = {} ---Represents an item in a Neovim quickfix/loclist. ---@class qf.item ---@field bufnr? number The buffer number where the item originates. ---@field filename? string ---@field lnum number The start line number for the item. ---@field end_lnum? number The end line number for the item. ---@field pattern string A pattern related to the item. It can be a search pattern or any relevant string. ---@field col? number The column number where the item starts. ---@field end_col? number The column number where the item ends. ---@field module? string Module information (if any) associated with the item. ---@field nr? number A unique number or ID for the item. ---@field text? string A description or message related to the item. ---@field type? string The type of the item. E.g., "W" might stand for "Warning". ---@field valid number A flag indicating if the item is valid (1) or not (0). ---@field user_data? any Any user data associated with the item. ---@field vcol? number Visual column number. Indicates if the column number is a visual column number (when set to 1) or a byte index (when set to 0). ---@class snacks.picker.qf.Config ---@field qf_win? number ---@field filter? snacks.picker.filter.Config local severities = { E = vim.diagnostic.severity.ERROR, W = vim.diagnostic.severity.WARN, I = vim.diagnostic.severity.INFO, H = vim.diagnostic.severity.HINT, N = vim.diagnostic.severity.HINT, } ---@param opts snacks.picker.qf.Config ---@type snacks.picker.finder function M.qf(opts, ctx) local win = opts.qf_win win = win == 0 and vim.api.nvim_get_current_win() or win local list = win and vim.fn.getloclist(win, { all = true }) or vim.fn.getqflist({ all = true }) ---@cast list { items?: qf.item[] }? local ret = {} ---@type snacks.picker.finder.Item[] for _, item in pairs(list and list.items or {}) do local row = item.lnum == 0 and 1 or item.lnum local col = (item.col == 0 and 1 or item.col) - 1 local end_row = item.end_lnum == 0 and row or item.end_lnum local end_col = item.end_col == 0 and col or (item.end_col - 1) if item.valid == 1 then local file = item.filename or item.bufnr and vim.api.nvim_buf_get_name(item.bufnr) or nil local text = item.text or "" ret[#ret + 1] = { pos = { row, col }, end_pos = item.end_lnum ~= 0 and { end_row, end_col } or nil, text = file .. " " .. text, line = text, file = file, severity = severities[item.type] or 0, buf = item.bufnr, item = item, } elseif #ret > 0 and ret[#ret].text and item.text then ret[#ret].text = ret[#ret].text .. "\n" .. item.text ret[#ret].line = ret[#ret].line .. "\n" .. item.text end end return ctx.filter:filter(ret) end return M ================================================ FILE: lua/snacks/picker/source/recent.lua ================================================ local M = {} local uv = vim.uv or vim.loop ---@param filter snacks.picker.Filter ---@param extra? string[] local function oldfiles(filter, extra) local done = {} ---@type table local files = {} ---@type string[] vim.list_extend(files, extra or {}) vim.list_extend(files, vim.v.oldfiles) local i = 0 return function() for f = i + 1, #files do i = f local file = files[f] file = vim.fn.fnamemodify(file, ":p") file = svim.fs.normalize(file, { _fast = true, expand_env = false }) local want = not done[file] and filter:match({ file = file, text = "" }) done[file] = true if want and uv.fs_stat(file) then return file end end end end --- Get the most recent files, optionally filtered by the --- current working directory or a custom directory. ---@param opts snacks.picker.recent.Config ---@type snacks.picker.finder function M.files(opts, ctx) local current_file = svim.fs.normalize(vim.api.nvim_buf_get_name(0), { _fast = true }) ---@type number[] local bufs = vim.tbl_filter(function(b) return vim.api.nvim_buf_get_name(b) ~= "" and vim.bo[b].buftype == "" end, vim.api.nvim_list_bufs()) table.sort(bufs, function(a, b) return vim.fn.getbufinfo(a)[1].lastused > vim.fn.getbufinfo(b)[1].lastused end) local extra = vim.tbl_map(function(b) return vim.api.nvim_buf_get_name(b) end, bufs) ---@async ---@param cb async fun(item: snacks.picker.finder.Item) return function(cb) for file in oldfiles(ctx.filter, extra) do if file ~= current_file then cb({ file = file, text = file, recent = true }) end end end end M.recent = M.files --- Get the most recent projects based on git roots of recent files. --- The default action will change the directory to the project root, --- try to restore the session and open the picker if the session is not restored. --- You can customize the behavior by providing a custom action. ---@param opts snacks.picker.projects.Config ---@type snacks.picker.finder function M.projects(opts, ctx) local args = { "-H", "-t", "f", "-t", "s", "-t", "d", "--max-depth", tostring(opts.max_depth or 2), "--follow", "--absolute-path", } vim.list_extend(args, { "-g", "{" .. table.concat(opts.patterns or {}, ",") .. "}" }) local dev = type(opts.dev) == "string" and { opts.dev } or opts.dev or {} ---@cast dev string[] vim.list_extend(args, vim.tbl_map(svim.fs.normalize, dev)) local fd = require("snacks.picker.source.files").get_fd() if not fd then Snacks.notify.warn("`fd` or `fdfind` is required for projects") end local proc = fd and require("snacks.picker.source.proc").proc({ cmd = fd, args = args, notify = false }, ctx) ---@async ---@param cb async fun(item: snacks.picker.finder.Item) return function(cb) local dirs = {} ---@type table ---@async local function add(dir) if dir and not dirs[dir] then dirs[dir] = true cb({ file = dir, text = dir, dir = true }) end end if opts.recent then for file in oldfiles(ctx.filter) do local dir = Snacks.git.get_root(file) add(dir) end end vim.tbl_map(add, opts.projects or {}) if not proc then return end ---@async proc(function(item) local path = svim.fs.normalize(item.text) path = path:sub(-1) == "/" and path:sub(1, -2) or path path = vim.fs.dirname(path) if ctx.filter:match({ file = path, text = path }) then add(path) end end) end end return M ================================================ FILE: lua/snacks/picker/source/scratch.lua ================================================ local M = {} ---@class snacks.scratch.actions ---@field [string] snacks.picker.Action.spec M.actions = { scratch_open = function(picker, item) picker:close() if not item then return end Snacks.scratch.open({ icon = item.item.icon, file = item.item.file, name = item.item.name, ft = item.item.ft }) end, scratch_delete = function(picker, item) local current = item.file os.remove(current) os.remove(current .. ".meta") picker:refresh() end, scratch_new = function(picker) picker:close() Snacks.scratch.open() end, } ---@param opts snacks.picker.proc.Config ---@type snacks.picker.finder function M.scratch(opts) local list = Snacks.scratch.list() local items = {} ---@type snacks.picker.finder.Item[] for _, item in ipairs(list) do items[#items + 1] = { file = item.file, item = item, title = item.name, text = Snacks.picker.util.text(item, { "name", "branch", "ft" }), branch = item.branch and ("branch:%s"):format(item.branch) or "", } end return items end ---@type snacks.picker.format function M.format(item, picker) local file = item.item local ret = {} ---@type snacks.picker.Highlight[] local a = Snacks.picker.util.align local icon, icon_hl = file.icon, nil if not icon then icon, icon_hl = Snacks.util.icon(file.ft, "filetype") end ret[#ret + 1] = { a(icon, 3), icon_hl } ret[#ret + 1] = { a(file.name, 20, { truncate = true }) } ret[#ret + 1] = { " " } ret[#ret + 1] = { a(item.branch, 20, { truncate = true }), "Number" } ret[#ret + 1] = { " " } ---@diagnostic disable-next-line: missing-fields vim.list_extend(ret, Snacks.picker.format.filename({ text = "", dir = true, file = file.cwd }, picker)) return ret end return M ================================================ FILE: lua/snacks/picker/source/snacks.lua ================================================ local M = {} ---@param opts snacks.picker.notifications.Config function M.notifier(opts) local notifs = Snacks.notifier.get_history({ filter = opts.filter, reverse = true }) local items = {} ---@type snacks.picker.finder.Item[] for _, notif in ipairs(notifs) do items[#items + 1] = { text = Snacks.picker.util.text(notif, { "level", "title", "msg" }), item = notif, severity = notif.level, preview = { text = notif.msg, ft = "markdown", }, } end return items end return M ================================================ FILE: lua/snacks/picker/source/system.lua ================================================ local M = {} ---@param opts snacks.picker.proc.Config ---@type snacks.picker.finder function M.cliphist(opts, ctx) return require("snacks.picker.source.proc").proc( ctx:opts({ cmd = "cliphist", args = { "list" }, ---@param item snacks.picker.finder.Item transform = function(item) local id, content = item.text:match("^(%d+)%s+(.+)$") if id and content and not content:find("^%[%[%s+binary data") then item.text = content setmetatable(item, { __index = function(_, k) if k == "data" then local data = vim.fn.system({ "cliphist", "decode", id }) rawset(item, "data", data) if vim.v.shell_error ~= 0 then error(data) end return data elseif k == "preview" then return { text = item.data, ft = "text", } end end, }) else return false end end, }), ctx ) end ---@param opts snacks.picker.proc.Config ---@type snacks.picker.finder function M.man(opts, ctx) return require("snacks.picker.source.proc").proc( ctx:opts({ cmd = "man", args = { "-k", "." }, ---@param item snacks.picker.finder.Item transform = function(item) local page, section, desc = item.text:match("^(%S+)%s*%((%S-)%)%s+-%s+(.+)$") if page and section and desc then item.section = section item.desc = desc item.page = page item.section = section item.ref = ("%s(%s)"):format(item.page, item.section or 1) else return false end end, }), ctx ) end return M ================================================ FILE: lua/snacks/picker/source/treesitter.lua ================================================ local M = {} ---@class snacks.picker.treesitter.Match ---@field id string ---@field name string ---@field node TSNode ---@field text string ---@field meta table ---@field pos {[1]: number, [2]: number} ---@field end_pos {[1]: number, [2]: number} ---@field kind? string ---@field scope? "parent" | "local" | "global" ---@field children? snacks.picker.treesitter.Match[] -- stylua: ignore local kind_mapping = { constant = "Constant", type = "Class", enum = "Enum", field = "Field", ["function"] = "Function", macro = "Function", method = "Method", namespace = "Namespace", import = "Module", var = "Variable", -- associated = "Reference", -- parameter = "Parameter", } local function sort(nodes) table.sort(nodes, function(a, b) if a.pos[1] ~= b.pos[1] then return a.pos[1] < b.pos[1] end if a.pos[2] ~= b.pos[2] then return a.pos[2] < b.pos[2] end if a.end_pos[1] ~= b.end_pos[1] then return a.end_pos[1] < b.end_pos[1] end return a.end_pos[2] < b.end_pos[2] end) end function M.get_locals(buf) local ok, parser = pcall(vim.treesitter.get_parser, buf) if not ok or not parser then return {} end parser:parse(true) local query = vim.treesitter.query.get(parser:lang(), "locals") if not query then return {} end local defs = {} ---@type snacks.picker.treesitter.Match[] local scopes = {} ---@type table for _, tree in ipairs(parser:trees()) do for id, node, meta in query:iter_captures(tree:root(), buf) do local name = query.captures[id] local range = { node:range() } ---@type snacks.picker.treesitter.Match local match = { id = node:id(), node = node, name = name, meta = meta, text = vim.treesitter.get_node_text(node, buf), pos = { range[1] + 1, range[2] }, end_pos = { range[3] + 1, range[4] }, } local kind = name:match("^local%.definition%.(.*)$") if kind then match.kind = kind match.scope = meta["definition.method.scope"] or "local" defs[#defs + 1] = match elseif name == "local.scope" then match.kind = "scope" scopes[match.id] = match end end end ---@param node TSNode local function find_scope(node) local n = node:parent() ---@type TSNode? while n do if scopes[n:id()] then return scopes[n:id()] end n = n:parent() end end -- put defs in their scope nodes for _, def in ipairs(defs) do local scope = find_scope(def.node) if scope then scope.children = scope.children or {} table.insert(scope.children, def) end end -- put scopes in their parents local ret = {} ---@type snacks.picker.treesitter.Match[] for _, scope in pairs(scopes) do local parent = find_scope(scope.node) if parent then parent.children = parent.children or {} table.insert(parent.children, scope) else ret[#ret + 1] = scope end end return ret end ---@param opts snacks.picker.treesitter.Config ---@type snacks.picker.finder function M.symbols(opts, ctx) local buf = ctx.filter.current_buf local tree = M.get_locals(buf) local items = {} ---@type snacks.picker.finder.Item[] local last = {} ---@type table local filter = opts.filter[vim.bo[buf].filetype] if filter == nil then filter = opts.filter.default end ---@param kind string? local function want(kind) kind = kind or "Unknown" return type(filter) == "boolean" or vim.tbl_contains(filter, kind) end ---@type snacks.picker.finder.Item local root = { text = "root" } ---@param match snacks.picker.treesitter.Match ---@param parent snacks.picker.finder.Item? ---@return snacks.picker.finder.Item? local function add(match, parent, depth) local item ---@type snacks.picker.finder.Item? local kind = match.kind and kind_mapping[match.kind] if want(kind) then item = { text = match.text, depth = depth or 0, tree = opts.tree, buf = buf, name = match.text, kind = kind_mapping[match.kind] or "Unknown", ts_kind = match.kind, pos = match.pos, end_pos = match.end_pos, last = true, parent = parent, } if parent then if last[parent] then last[parent].last = false end last[parent] = item end items[#items + 1] = item end local children = match.children or {} sort(children) for _, child in ipairs(children) do local c = add(child, item or parent, depth + 1) -- first item in a scope is the scope itself if match.kind == "scope" and c and c.depth == depth + 1 then item = item or c end end return item end sort(tree) for _, scope in ipairs(tree) do add(scope, root, 0) end return items end return M ================================================ FILE: lua/snacks/picker/source/vim.lua ================================================ local M = {} ---@class snacks.picker.history.Config: snacks.picker.Config ---@field name string function M.commands() local commands = vim.api.nvim_get_commands({}) for k, v in pairs(vim.api.nvim_buf_get_commands(0, {})) do if type(k) == "string" then -- fixes vim.empty_dict() bug commands[k] = v end end for _, c in ipairs(vim.fn.getcompletion("", "command")) do if not commands[c] and c:find("^[a-z]") then commands[c] = { definition = "completion" } end end ---@async ---@param cb async fun(item: snacks.picker.finder.Item) return function(cb) ---@type string[] local names = vim.tbl_keys(commands) table.sort(names) for _, name in pairs(names) do local def = commands[name] cb({ text = name, desc = def.script_id and def.script_id < 0 and def.definition or nil, command = def, cmd = name, preview = { text = vim.inspect(def), ft = "lua", }, }) end end end ---@param opts snacks.picker.history.Config function M.history(opts) local count = vim.fn.histnr(opts.name) local items = {} for i = count, 1, -1 do local line = vim.fn.histget(opts.name, i) if not line:find("^%s*$") then table.insert(items, { text = line, cmd = line, preview = { text = line, ft = "text", }, }) end end return items end ---@param opts snacks.picker.marks.Config ---@type snacks.picker.finder function M.marks(opts, ctx) local marks = {} ---@type vim.fn.getmarklist.ret.item[] local current_buf = ctx.filter.current_buf if opts.global then vim.list_extend(marks, vim.fn.getmarklist()) end if opts["local"] then vim.list_extend(marks, vim.fn.getmarklist(current_buf)) end ---@type snacks.picker.finder.Item[] local items = {} local bufname = vim.api.nvim_buf_get_name(current_buf) for _, mark in ipairs(marks) do local file = mark.file or bufname local buf = mark.pos[1] and mark.pos[1] > 0 and mark.pos[1] or nil local line ---@type string? if buf and mark.pos[2] > 0 and vim.api.nvim_buf_is_valid(mark.pos[1]) then line = vim.api.nvim_buf_get_lines(buf, mark.pos[2] - 1, mark.pos[2], false)[1] end local label = mark.mark:sub(2, 2) items[#items + 1] = { text = table.concat({ label, file, line }, " "), label = label, line = line, buf = buf, file = file, pos = mark.pos[2] > 0 and { mark.pos[2], mark.pos[3] }, } end table.sort(items, function(a, b) return a.label < b.label end) return items end function M.jumps() local jumps = vim.fn.getjumplist()[1] local items = {} ---@type snacks.picker.finder.Item[] for _, jump in ipairs(jumps) do local buf = jump.bufnr and vim.api.nvim_buf_is_valid(jump.bufnr) and jump.bufnr or 0 local file = jump.filename or buf and vim.api.nvim_buf_get_name(buf) or nil if buf or file then local line ---@type string? if buf then line = vim.api.nvim_buf_get_lines(buf, jump.lnum - 1, jump.lnum, false)[1] end local label = tostring(#jumps - #items) table.insert(items, 1, { label = Snacks.picker.util.align(label, #tostring(#jumps), { align = "right" }), buf = buf, line = line, text = table.concat({ file, line }, " "), file = file, pos = jump.lnum and jump.lnum > 0 and { jump.lnum, jump.col } or nil, }) end end return items end function M.autocmds() local autocmds = vim.api.nvim_get_autocmds({}) local items = {} ---@type snacks.picker.finder.Item[] for _, au in ipairs(autocmds) do local item = au --[[@as snacks.picker.finder.Item]] item.text = Snacks.picker.util.text(item, { "event", "group_name", "pattern", "command" }) item.preview = { text = vim.inspect(au), ft = "lua", } item.item = au if au.callback then local info = debug.getinfo(au.callback, "S") if info.what == "Lua" then item.file = info.source:sub(2) item.pos = { info.linedefined, 0 } item.preview = "file" end end items[#items + 1] = item end return items end function M.highlights() local hls = vim.api.nvim_get_hl(0, {}) --[[@as table ]] local items = {} ---@type snacks.picker.finder.Item[] for group, hl in pairs(hls) do local defs = {} ---@type {group:string, hl:vim.api.keyset.get_hl_info}[] defs[#defs + 1] = { group = group, hl = hl } local link = hl.link local done = { [group] = true } ---@type table while link and not done[link] do done[link] = true local hl_link = hls[link] if hl_link then defs[#defs + 1] = { group = link, hl = hl_link } link = hl_link.link else break end end local code = {} ---@type string[] local extmarks = {} ---@type snacks.picker.Extmark[] local row = 1 for _, def in ipairs(defs) do for _, prop in ipairs({ "fg", "bg", "sp" }) do local v = def.hl[prop] if type(v) == "number" then def.hl[prop] = ("#%06X"):format(v) end end code[#code + 1] = ("%s = %s"):format(def.group, vim.inspect(def.hl)) extmarks[#extmarks + 1] = { row = row, col = 0, hl_group = def.group, end_col = #def.group } row = row + #vim.split(code[#code], "\n") + 1 end items[#items + 1] = { text = vim.inspect(defs):gsub("\n", " "), hl_group = group, preview = { text = table.concat(code, "\n\n"), ft = "lua", extmarks = extmarks, }, } end table.sort(items, function(a, b) return a.hl_group < b.hl_group end) return items end function M.colorschemes() local items = {} ---@type snacks.picker.finder.Item[] local rtp = vim.o.runtimepath if package.loaded.lazy then rtp = rtp .. "," .. table.concat(require("lazy.core.util").get_unloaded_rtp(""), ",") end local files = vim.fn.globpath(rtp, "colors/*", false, true) ---@type string[] for _, file in ipairs(files) do local name = vim.fn.fnamemodify(file, ":t:r") local ext = vim.fn.fnamemodify(file, ":e") if ext == "vim" or ext == "lua" then items[#items + 1] = { text = name, file = file, } end end return items end ---@param opts snacks.picker.keymaps.Config function M.keymaps(opts) local items = {} ---@type snacks.picker.finder.Item[] local maps = {} ---@type vim.api.keyset.get_keymap[] for _, mode in ipairs(opts.modes) do if opts.global then vim.list_extend(maps, vim.api.nvim_get_keymap(mode)) end if opts["local"] then vim.list_extend(maps, vim.api.nvim_buf_get_keymap(0, mode)) end end local done = {} ---@type table for _, km in ipairs(maps) do local key = Snacks.picker.util.text(km, { "mode", "lhs", "buffer" }) local keep = true if opts.plugs == false and km.lhs:match("^") then keep = false end if keep and not done[key] then done[key] = true local item = { mode = km.mode, item = km, key = km.lhs, preview = { text = vim.inspect(km), ft = "lua", }, } if km.callback then local info = debug.getinfo(km.callback, "S") item.info = info if info.what == "Lua" then local source = info.source:sub(2) item.file = source:gsub("^vim/", vim.env.VIMRUNTIME .. "/lua/vim/") if source:find("^vim/") and info.linedefined == 0 then item.search = "/vim\\.keymap\\.set.*['\"]" .. km.lhs else item.pos = { info.linedefined, 0 } end item.preview = "file" end end item.text = Snacks.util.normkey(km.lhs) .. " " .. Snacks.picker.util.text(km, { "mode", "lhs", "rhs", "desc" }) .. (item.file or "") items[#items + 1] = item end end return items end function M.registers() local registers = '*+"-:.%/#=_abcdefghijklmnopqrstuvwxyz0123456789' local items = {} ---@type snacks.picker.finder.Item[] local is_osc52 = vim.g.clipboard and vim.g.clipboard.name == "OSC 52" local has_clipboard = vim.g.loaded_clipboard_provider == 2 for i = 1, #registers, 1 do local reg = registers:sub(i, i) local value = "" if is_osc52 and reg:match("[%+%*]") then value = "OSC 52 detected, register not checked to maintain compatibility" elseif has_clipboard or not reg:match("[%+%*]") then local ok, reg_value = pcall(vim.fn.getreg, reg, 1) value = (ok and reg_value or "") --[[@as string]] end if value ~= "" then table.insert(items, { text = ("%s: %s"):format(reg, value:gsub("\n", "\\n"):gsub("\r", "\\r")), reg = reg, label = reg, data = value, value = value:gsub("\n", "\\n"):gsub("\r", "\\r"), preview = { text = value, ft = "text", }, }) end end return items end function M.spelling() local buf = vim.api.nvim_get_current_buf() local win = vim.api.nvim_get_current_win() local cursor = vim.api.nvim_win_get_cursor(0) local line = vim.api.nvim_buf_get_lines(buf, cursor[1] - 1, cursor[1], false)[1] -- get a misspelled word from under the cursor, if not found, then use the cursor_word instead local bad = vim.fn.spellbadword() ---@type string[] local word = bad[1] == "" and vim.fn.expand("") or bad[1] local suggestions = vim.fn.spellsuggest(word, 25, bad[2] == "caps") local items = {} ---@type snacks.picker.finder.Item[] for _, label in ipairs(suggestions) do table.insert(items, { text = label, action = function() -- skip whitespace local col = cursor[2] + 1 while line:sub(col, col):match("%s") and col < #line do col = col + 1 vim.api.nvim_win_set_cursor(win, { cursor[1], col - 1 }) end vim.cmd('normal! "_ciw' .. label) end, }) end return items end ---@class snacks.picker.tags.Tag ---@field name string ---@field filename string ---@field cmd string ---@field kind? string ---@field static? string ---@param opts snacks.picker.tags.Config ---@type snacks.picker.finder function M.tags(opts, ctx) local buf = ctx.filter.current_buf ---@type snacks.picker.tags.Tag[] local tags = vim.fn.taglist(ctx.filter.search == "" and ".*" or ctx.filter.search, vim.api.nvim_buf_get_name(buf)) local ret = {} ---@type snacks.picker.finder.Item[] local lsp_kinds = { c = "Class", d = "Constant", e = "Enum", f = "Function", g = "EnumMember", l = "Variable", m = "Method", s = "Struct", t = "TypeParameter", v = "Variable", F = "Field", M = "Module", n = "Namespace", P = "Property", S = "Struct", T = "TypeParameter", } for _, tag in ipairs(tags) do ---@type snacks.picker.finder.Item local item = { text = tag.name, name = tag.name, file = tag.filename, search = tag.cmd, kind = tag.kind, lsp_kind = lsp_kinds[tag.kind] or "Text", } ret[#ret + 1] = item end return ret end ---@param opts snacks.picker.undo.Config ---@type snacks.picker.finder function M.undo(opts, ctx) local tree = vim.fn.undotree() local buf = vim.api.nvim_get_current_buf() local file = vim.api.nvim_buf_get_name(buf) local items = {} ---@type snacks.picker.finder.Item[] local diff_fn = vim.text and vim.text.diff or vim.diff -- Copy the current buffer to a temporary file and load the undo history. -- This is done to prevent the current buffer from being modified, -- and is way better for performance, since LSP change tracking won't be triggered local tmp_file = vim.fn.stdpath("cache") .. "/snacks-undo" local tmp_undo = tmp_file .. ".undo" local tmpbuf = vim.fn.bufadd(tmp_file) vim.bo[tmpbuf].swapfile = false vim.fn.writefile(vim.api.nvim_buf_get_lines(buf, 0, -1, false), tmp_file) vim.fn.bufload(tmpbuf) vim.api.nvim_buf_call(buf, function() vim.cmd("silent wundo! " .. tmp_undo) end) vim.api.nvim_buf_call(tmpbuf, function() pcall(vim.cmd, "silent rundo " .. tmp_undo) end) ---@param item snacks.picker.finder.Item local function resolve(item) local entry = item.item ---@type vim.fn.undotree.entry ---@type string[], string[] local before, after = {}, {} local ei = vim.o.eventignore vim.o.eventignore = "all" vim.api.nvim_buf_call(tmpbuf, function() -- state after the undo vim.cmd("noautocmd silent undo " .. entry.seq) after = vim.api.nvim_buf_get_lines(tmpbuf, 0, -1, false) -- state before the undo vim.cmd("noautocmd silent undo") before = vim.api.nvim_buf_get_lines(tmpbuf, 0, -1, false) end) vim.o.eventignore = ei local diff = diff_fn(table.concat(before, "\n") .. "\n", table.concat(after, "\n") .. "\n", opts.diff) --[[@as string]] local changes = {} ---@type string[] local added_lines = {} ---@type string[] local removed_lines = {} ---@type string[] for _, line in ipairs(vim.split(diff, "\n")) do if line:sub(1, 1) == "+" then changes[#changes + 1] = line:sub(2) added_lines[#added_lines + 1] = line:sub(2) elseif line:sub(1, 1) == "-" then changes[#changes + 1] = line:sub(2) removed_lines[#removed_lines + 1] = line:sub(2) end end diff = Snacks.picker.util.tpl( "diff --git a/{file} b/{file}\n--- {file}\n+++ {file}\n{diff}", { file = vim.fn.fnamemodify(file, ":."), diff = diff } ) item.text = table.concat(changes, " ") item.data = table.concat(added_lines, "\n") item.added_lines = table.concat(added_lines, "\n") item.removed_lines = table.concat(removed_lines, "\n") item.added = #added_lines item.removed = #removed_lines item.diff = diff end ---@param entries? vim.fn.undotree.entry[] ---@param parent? snacks.picker.finder.Item local function add(entries, parent) entries = entries or {} table.sort(entries, function(a, b) return a.seq < b.seq end) local last ---@type snacks.picker.finder.Item? for e, entry in ipairs(entries) do add(entry.alt, last or parent) local item = { seq = entry.seq, buf = buf, resolve = resolve, file = file, item = entry, current = entry.seq == tree.seq_cur, parent = parent, last = e == #entries, action = function() vim.api.nvim_buf_call(buf, function() vim.cmd("undo " .. entry.seq) end) end, } items[#items + 1] = item last = item end end add(tree.entries) -- Resolve the items in batches to prevent blocking the UI ---@param cb async fun(item: snacks.picker.finder.Item) ---@async return function(cb) ctx.async:on("done", function() vim.schedule(function() -- Clean up the temporary files vim.api.nvim_buf_delete(tmpbuf, { force = true }) vim.fn.delete(tmp_file) vim.fn.delete(tmp_undo) end) end) for i = #items, 1, -1 do local item = items[i] cb(item) if item.current then ctx.picker.list:set_target(#items - i + 1) end end while #items > 0 do vim.schedule(function() local count = 0 while #items > 0 and count < 5 do count = count + 1 local item = table.remove(items, 1) Snacks.picker.util.resolve(item) end ctx.async:resume() end) ctx.async:suspend() end end end return M ================================================ FILE: lua/snacks/picker/transform.lua ================================================ ---@class snacks.picker.transformers ---@field [string] snacks.picker.transform local M = {} function M.unique_file(item, ctx) ctx.meta.done = ctx.meta.done or {} ---@type table local path = Snacks.picker.util.path(item) if not path or ctx.meta.done[path] then return false end ctx.meta.done[path] = true end function M.text_to_file(item, ctx) item.file = item.file or item.text end return M ================================================ FILE: lua/snacks/picker/types.lua ================================================ ---@meta _ ---@class snacks.picker ---@field autocmds fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field buffers fun(opts?: snacks.picker.buffers.Config|{}): snacks.Picker ---@field cliphist fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field colorschemes fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field command_history fun(opts?: snacks.picker.history.Config|{}): snacks.Picker ---@field commands fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field diagnostics fun(opts?: snacks.picker.diagnostics.Config|{}): snacks.Picker ---@field diagnostics_buffer fun(opts?: snacks.picker.diagnostics.Config|{}): snacks.Picker ---@field explorer fun(opts?: snacks.picker.explorer.Config|{}): snacks.Picker ---@field files fun(opts?: snacks.picker.files.Config|{}): snacks.Picker ---@field gh_actions fun(opts?: snacks.picker.gh.actions.Config|{}): snacks.Picker ---@field gh_diff fun(opts?: snacks.picker.gh.diff.Config|{}): snacks.Picker ---@field gh_issue fun(opts?: snacks.picker.gh.issue.Config|{}): snacks.Picker ---@field gh_labels fun(opts?: snacks.picker.gh.labels.Config|{}): snacks.Picker ---@field gh_pr fun(opts?: snacks.picker.gh.pr.Config|{}): snacks.Picker ---@field gh_reactions fun(opts?: snacks.picker.gh.reactions.Config|{}): snacks.Picker ---@field git_branches fun(opts?: snacks.picker.git.branches.Config|{}): snacks.Picker ---@field git_diff fun(opts?: snacks.picker.git.diff.Config|{}): snacks.Picker ---@field git_files fun(opts?: snacks.picker.git.files.Config|{}): snacks.Picker ---@field git_grep fun(opts?: snacks.picker.git.grep.Config|{}): snacks.Picker ---@field git_log fun(opts?: snacks.picker.git.log.Config|{}): snacks.Picker ---@field git_log_file fun(opts?: snacks.picker.git.log.Config|{}): snacks.Picker ---@field git_log_line fun(opts?: snacks.picker.git.log.Config|{}): snacks.Picker ---@field git_stash fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field git_status fun(opts?: snacks.picker.git.status.Config|{}): snacks.Picker ---@field grep fun(opts?: snacks.picker.grep.Config|{}): snacks.Picker ---@field grep_buffers fun(opts?: snacks.picker.grep.Config|{}): snacks.Picker ---@field grep_word fun(opts?: snacks.picker.grep.Config|{}): snacks.Picker ---@field help fun(opts?: snacks.picker.help.Config|{}): snacks.Picker ---@field highlights fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field icons fun(opts?: snacks.picker.icons.Config|{}): snacks.Picker ---@field jumps fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field keymaps fun(opts?: snacks.picker.keymaps.Config|{}): snacks.Picker ---@field lazy fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field lines fun(opts?: snacks.picker.lines.Config|{}): snacks.Picker ---@field loclist fun(opts?: snacks.picker.qf.Config|{}): snacks.Picker ---@field lsp_config fun(opts?: snacks.picker.lsp.config.Config|{}): snacks.Picker ---@field lsp_declarations fun(opts?: snacks.picker.lsp.Config|{}): snacks.Picker ---@field lsp_definitions fun(opts?: snacks.picker.lsp.Config|{}): snacks.Picker ---@field lsp_implementations fun(opts?: snacks.picker.lsp.Config|{}): snacks.Picker ---@field lsp_incoming_calls fun(opts?: snacks.picker.lsp.Config|{}): snacks.Picker ---@field lsp_outgoing_calls fun(opts?: snacks.picker.lsp.Config|{}): snacks.Picker ---@field lsp_references fun(opts?: snacks.picker.lsp.references.Config|{}): snacks.Picker ---@field lsp_symbols fun(opts?: snacks.picker.lsp.symbols.Config|{}): snacks.Picker ---@field lsp_type_definitions fun(opts?: snacks.picker.lsp.Config|{}): snacks.Picker ---@field lsp_workspace_symbols fun(opts?: snacks.picker.lsp.symbols.Config|{}): snacks.Picker ---@field man fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field marks fun(opts?: snacks.picker.marks.Config|{}): snacks.Picker ---@field notifications fun(opts?: snacks.picker.notifications.Config|{}): snacks.Picker ---@field picker_actions fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field picker_format fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field picker_layouts fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field picker_preview fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field pickers fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field projects fun(opts?: snacks.picker.projects.Config|{}): snacks.Picker ---@field qflist fun(opts?: snacks.picker.qf.Config|{}): snacks.Picker ---@field recent fun(opts?: snacks.picker.recent.Config|{}): snacks.Picker ---@field registers fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field resume fun(): snacks.Picker ---@field scratch fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field search_history fun(opts?: snacks.picker.history.Config|{}): snacks.Picker ---@field smart fun(opts?: snacks.picker.smart.Config|{}): snacks.Picker ---@field spelling fun(opts?: snacks.picker.Config|{}): snacks.Picker ---@field tags fun(opts?: snacks.picker.tags.Config|{}): snacks.Picker ---@field treesitter fun(opts?: snacks.picker.treesitter.Config|{}): snacks.Picker ---@field undo fun(opts?: snacks.picker.undo.Config|{}): snacks.Picker ---@field zoxide fun(opts?: snacks.picker.Config|{}): snacks.Picker ================================================ FILE: lua/snacks/picker/util/async.lua ================================================ ---@class snacks.picker.async local M = {} ---@type snacks.picker.Async[] M._active = {} ---@type snacks.picker.Async[] M._suspended = {} M._executor = assert((vim.uv or vim.loop).new_check()) M.BUDGET = 10 ---@type table M._threads = setmetatable({}, { __mode = "k" }) local uv = (vim.uv or vim.loop) function M.exiting() return vim.v.exiting ~= vim.NIL end ---@alias snacks.picker.AsyncEvent "done" | "error" | "yield" | "ok" | "abort" ---@class snacks.picker.Waitable ---@field wait async fun() ---@class snacks.picker.Async ---@field _co? thread ---@field _fn fun() ---@field _suspended? boolean ---@field _aborted? boolean ---@field _start number ---@field _on table local Async = {} Async.__index = Async ---@param fn async fun() --- function Async.new(fn) local self = setmetatable({}, Async) return self:init(fn) end ---@param fn async fun() ---@return snacks.picker.Async function Async:init(fn) self._fn = fn self._on = {} self._start = uv.hrtime() self._co = coroutine.create(function() local ok, err = xpcall(self._fn, function(err) return debug.traceback(err, 2) end) if not ok then if self._aborted then self:_emit("abort") else self:_error(err) end end self:_done() end) M._threads[self._co] = self return M.add(self) end function Async:aborted() return self._aborted end function Async:_done() if self._co == nil then return end self:_emit("done") self._fn = nil M._threads[self._co] = nil self._co = nil self._on = {} end function Async:delta() return (uv.hrtime() - self._start) / 1e6 end ---@param event snacks.picker.AsyncEvent ---@param cb async fun(res:any, async:snacks.picker.Async) function Async:on(event, cb) if event == "done" and not self:running() then cb(nil, self) return self end self._on[event] = self._on[event] or {} table.insert(self._on[event], cb) return self end ---@private ---@param event snacks.picker.AsyncEvent ---@param res any function Async:_emit(event, res) for _, cb in ipairs(self._on[event] or {}) do cb(res, self) end end function Async:_error(err) if vim.tbl_isempty(self._on.error or {}) then Snacks.notify.error("Unhandled async error:\n" .. err) end self:_emit("error", err) end function Async:running() return self._co and coroutine.status(self._co) ~= "dead" and not self._aborted end ---@async function Async:sleep(ms) self:defer(ms, function() end) end --- Suspends the current async context. --- Runs `fn` on the main thread and resumes the async context, --- returning the result of `fn` or raising an error if `fn` errors. ---@generic T: any? ---@param fn fun(): T? ---@async ---@return T function Async:schedule(fn) self:assert() local ret ---@type {[1]: boolean, [number]:any} vim.schedule(function() ret = { pcall(fn) } self:resume() end) self:suspend() if not ret[1] then error(ret[2]) end return select(2, unpack(ret)) end function Async:assert() assert(coroutine.running() == self._co, "Not in an async context") end --- Same as schedule, but defers the execution by `ms` milliseconds. ---@generic T: any ---@param fn fun(): T? ---@param ms number ---@async ---@return T function Async:defer(ms, fn) self:assert() local ret ---@type {[1]: boolean, [number]:any} vim.defer_fn(function() ret = { pcall(fn) } self:resume() end, ms) self:suspend() if not ret[1] then error(ret[2]) end return select(2, unpack(ret)) end ---@async ---@param yield? boolean function Async:suspend(yield) self._suspended = true if coroutine.running() == self._co and yield ~= false then M.yield() end end function Async:resume() if not self._suspended then return end self._suspended = false M._run() end ---@async ---@param yield? boolean function Async:wake(yield) local async = M.running() assert(async, "Not in an async context") self:on("done", function() async:resume() end) async:suspend(yield) end ---@async function Async:wait() if not self:running() then return self end if coroutine.running() == self._co then error("Cannot wait on self") end local async = M.running() if async then self:wake() else while self:running() do vim.wait(10) end end return self end function Async:step() if self._suspended then return true end if not self._co then return false end local status = coroutine.status(self._co) if status == "suspended" then local ok, res = coroutine.resume(self._co, self._aborted and "abort" or nil) if not ok then error(res) elseif res then self:_emit("yield", res) end end return self:running() end function Async:abort() if not self:running() then return end self._aborted = true if self._co and coroutine.running() == self._co then error("aborted", 2) end self:resume() end function M.abort() for _, async in ipairs(M._active) do async:abort() end end ---@async function M.yield() if coroutine.yield() == "abort" then error("aborted", 2) end end function M.step() local start = uv.hrtime() for _ = 1, #M._active do if M.exiting() or uv.hrtime() - start > M.BUDGET * 1e6 then break end local state = table.remove(M._active, 1) ---@type snacks.picker.Async if state:step() then if state._suspended then table.insert(M._suspended, state) else table.insert(M._active, state) end end end for _ = 1, #M._suspended do local state = table.remove(M._suspended, 1) table.insert(state._suspended and M._suspended or M._active, state) end -- M.debug() if #M._active == 0 or M.exiting() then return M._executor:stop() end end function M.debug() local lines = { "- active: " .. #M._active, "- suspended: " .. #M._suspended, } for _, async in ipairs(M._active) do local info = debug.getinfo(async._fn) local file = vim.fn.fnamemodify(info.short_src:sub(1), ":~:.") table.insert(lines, ("%s:%d"):format(file, info.linedefined)) if #lines > 10 then break end end local msg = table.concat(lines, "\n") M._notif = vim.notify(msg, nil, { replace = M._notif }) end ---@param async snacks.picker.Async function M.add(async) table.insert(M._active, async) M._run() return async end ---@async function M.suspend() local async = assert(M.running(), "Not in an async context") async:suspend() end function M._run() if not M.exiting() and not M._executor:is_active() then -- M._executor:start(vim.schedule_wrap(M.step)) M._executor:start(M.step) end end function M.running() local co = coroutine.running() if co then return M._threads[co] end end ---@async ---@param ms number function M.sleep(ms) local async = M.running() assert(async, "Not in an async context") async:sleep(ms) end ---@param ms? number function M.yielder(ms) if not coroutine.running() then return function() end end local ns, count, start = (ms or 5) * 1e6, 0, uv.hrtime() ---@async return function() count = count + 1 if count % 100 == 0 then if uv.hrtime() - start > ns then M.yield() start = uv.hrtime() end end end end local nop ---@type snacks.picker.Async --- Returns a no-op async function function M.nop() if not nop then nop = Async.new(function() end) nop:step() M._active = vim.tbl_filter(function(a) return a ~= nop end, M._active) end return nop end M.Async = Async M.new = Async.new return M ================================================ FILE: lua/snacks/picker/util/db.lua ================================================ local ffi = require("ffi") ffi.cdef([[ typedef struct sqlite3 sqlite3; typedef struct sqlite3_stmt sqlite3_stmt; int sqlite3_open(const char *filename, sqlite3 **ppDb); int sqlite3_close(sqlite3*); int sqlite3_exec( sqlite3*, const char *sql, int (*callback)(void*,int,char**,char**), void*, char **errmsg); int sqlite3_prepare_v2( sqlite3*, const char *zSql, int nByte, sqlite3_stmt **ppStmt, const char **pzTail); int sqlite3_reset(sqlite3_stmt*); int sqlite3_step(sqlite3_stmt*); int sqlite3_finalize(sqlite3_stmt*); int sqlite3_bind_text(sqlite3_stmt*, int, const char*, int n, void(*)(void*)); int sqlite3_bind_int64(sqlite3_stmt*, int, long long); const unsigned char *sqlite3_column_text(sqlite3_stmt*, int); long long sqlite3_column_int64(sqlite3_stmt*, int); ]]) local function sqlite3_lib() local opts = Snacks.picker.config.get() if opts.db.sqlite3_path then return opts.db.sqlite3_path end if jit.os ~= "Windows" then return "sqlite3" end local sqlite_path = vim.fn.stdpath("cache") .. "\\sqlite3.dll" if vim.fn.filereadable(sqlite_path) == 0 then Snacks.notify("Downloading `sqlite3.dll`") local url = ("https://www.sqlite.org/2025/sqlite-dll-win-%s-3480000.zip"):format(jit.arch) local out = vim.fn.system({ "powershell", "-Command", [[ $url = "]] .. url .. [["; $zipPath = "$env:TEMP\sqlite.zip"; $extractPath = "$env:TEMP\sqlite"; Invoke-WebRequest -Uri $url -OutFile $zipPath; Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $extractPath); $dllPath = "$extractPath\sqlite3.dll"; if (Test-Path $dllPath) { Move-Item -Path $dllPath -Destination "]] .. sqlite_path .. [[" -Force; } else { Write-Host "sqlite3.dll not found at $dllPath"; } ]], }) if vim.v.shell_error ~= 0 then Snacks.notify.error("Failed to download `sqlite3.dll`:\n" .. out) else Snacks.notify("Downloaded `sqlite3.dll`") end end return sqlite_path end local sqlite = ffi.load(sqlite3_lib()) ---@alias sqlite3* ffi.cdata* ---@alias sqlite3_stmt* ffi.cdata* ---@class snacks.picker.db ---@field type type ---@field db sqlite3* ---@field handle ffi.cdata* ---@field insert snacks.picker.db.Query ---@field select snacks.picker.db.Query local M = {} M.__index = M ---@param stmt ffi.cdata* ---@param idx number ---@param value any ---@param value_type? type local function bind(stmt, idx, value, value_type) value_type = value_type or type(value) if value_type == "string" then return sqlite.sqlite3_bind_text(stmt, idx, value, #value, nil) elseif value_type == "number" then return sqlite.sqlite3_bind_int64(stmt, idx, value) elseif value_type == "boolean" then return sqlite.sqlite3_bind_int64(stmt, idx, value and 1 or 0) else error("Unsupported value type: " .. type(value) .. " (" .. tostring(value) .. ")") end end ---@class snacks.picker.db.Query ---@field stmt sqlite3_stmt* ---@field handle ffi.cdata* local Query = {} Query.__index = Query function Query.new(db, query) local self = setmetatable({}, Query) local stmt = ffi.new("sqlite3_stmt*[1]") local code = sqlite.sqlite3_prepare_v2(db.db, query, #query, stmt, nil) --[[@as number]] if code ~= 0 then error("Failed to prepare statement: " .. code) end self.handle = stmt ffi.gc(stmt, function() self:close() end) self.stmt = stmt[0] return self end function Query:reset() return sqlite.sqlite3_reset(self.stmt) end ---@param binds? any[] function Query:exec(binds) self:reset() for i, value in ipairs(binds or {}) do if bind(self.stmt, i, value) ~= 0 then error(("Failed to bind %d=%s"):format(i, value)) end end return self:step() end ---@return number function Query:step() return sqlite.sqlite3_step(self.stmt) end function Query:close() if self.stmt then sqlite.sqlite3_finalize(self.stmt) self.stmt = nil end end function Query:bind(idx, value) return bind(self.stmt, idx, value) end ---@param idx? number ---@param value_type type function Query:col(value_type, idx) idx = idx or 0 local ret = ffi.string(sqlite.sqlite3_column_text(self.stmt, idx)) if value_type == "string" then return ret elseif value_type == "number" then return tonumber(ret) elseif value_type == "boolean" then return ret == "1" end error("Unsupported value type: " .. value_type) end function M.new(path, value_type) local self = setmetatable({}, M) local handle = ffi.new("sqlite3*[1]") if sqlite.sqlite3_open(path, handle) ~= 0 then error("Failed to open database: " .. path) end self.handle = handle self.db = handle[0] self.type = value_type or "number" self:exec("PRAGMA journal_mode=WAL") -- Create the table if it doesn't exist self:exec(([[ CREATE TABLE IF NOT EXISTS data ( key TEXT PRIMARY KEY, value %s NOT NULL ); ]]):format(({ number = "INTEGER", string = "TEXT", boolean = "INTEGER", })[self.type])) self.insert = self:prepare("INSERT OR REPLACE INTO data (key, value) VALUES (?, ?);") self.select = self:prepare("SELECT value FROM data WHERE key = ?;") ffi.gc(handle, function() self:close() end) return self end ---@param query string function M:prepare(query) return Query.new(self, query) end function M:close() if self.db then sqlite.sqlite3_close(self.db) self.db = nil self.handle = nil end end function M:set(key, value) if self.insert:exec({ key, value }) ~= 101 then -- 101 == SQLITE_DONE error("Failed to execute insert statement") end end ---@param query string function M:exec(query) query = query:sub(-1) ~= ";" and query .. ";" or query local errmsg = ffi.new("char*[1]") if sqlite.sqlite3_exec(self.db, query, nil, nil, errmsg) ~= 0 then error(ffi.string(errmsg[0])) end end function M:begin() self:exec("BEGIN") end function M:commit() self:exec("COMMIT") end function M:rollback() self:exec("ROLLBACK") end ---@param key string function M:get(key) if self.select:exec({ key }) == 100 then -- 100 == SQLITE_ROW return self.select:col(self.type) end end function M:count() local query = self:prepare("SELECT COUNT(*) FROM data;") if query:exec() == 100 then return query:col("number") end end function M:get_all() local query = self:prepare("SELECT key, value FROM data;") local ret = {} ---@type table local code = query:exec() while code == 100 do -- 100 == SQLITE_ROW local k = query:col("string", 0) -- key is always a string local v = query:col(self.type, 1) -- value type is whatever you set ret[k] = v code = query:step() end query:close() return ret end return M ================================================ FILE: lua/snacks/picker/util/diff.lua ================================================ local M = {} ---@class snacks.diff.Config ---@field max_hunk_lines? number only show last N lines of each hunk (used by GitHub PRs) ---@field hunk_header? boolean whether to show hunk header (default: true) ---@field annotations? snacks.diff.Annotation[] ---@class snacks.diff.Annotation ---@field file string ---@field side "left" | "right" ---@field left? number ---@field right? number ---@field line number ---@field text snacks.picker.Highlight[][] ---@class snacks.diff.Meta ---@field side "left" | "right" ---@field file string ---@field line number ---@field code string ---@class snacks.diff.ctx ---@field diff snacks.picker.Diff ---@field opts snacks.diff.Config ---@field block? snacks.picker.diff.Block ---@field hunk? snacks.picker.diff.Hunk local C = {} C.__index = C ---@param ctx snacks.diff.ctx|{} ---@return snacks.diff.ctx function C:extend(ctx) return setmetatable(ctx, { __index = self }) end ---@param ... string local function diff_linenr(...) local fg = Snacks.util.color(vim.list_extend({ ... }, { "NormalFloat", "Normal" })) local bg = Snacks.util.color(vim.list_extend({ ... }, { "NormalFloat", "Normal" }), "bg") bg = bg or vim.o.background == "dark" and "#1e1e1e" or "#f5f5f5" fg = fg or vim.o.background == "dark" and "#f5f5f5" or "#1e1e1e" return { fg = fg, bg = Snacks.util.blend(fg, bg, 0.1), } end local CONFLICT_MARKERS = { "<<<<<<<", "=======", ">>>>>>>", "|||||||" } require("snacks.picker") -- ensure picker hl groups are available Snacks.util.set_hl({ DiffHeader = "DiagnosticVirtualTextInfo", DiffAdd = "DiffAdd", DiffDelete = "DiffDelete", HunkHeader = "Normal", DiffContext = "DiffChange", DiffConflict = "DiagnosticVirtualTextWarn", DiffAddLineNr = diff_linenr("DiffAdd"), DiffLabel = "@property", DiffDeleteLineNr = diff_linenr("DiffDelete"), DiffContextLineNr = diff_linenr("DiffChange"), DiffConflictLineNr = diff_linenr("DiagnosticVirtualTextWarn"), }, { default = true, prefix = "Snacks" }) local H = Snacks.picker.highlight local U = Snacks.picker.util ---@param diff string|string[]|snacks.picker.Diff function M.get_diff(diff) if type(diff) == "string" then diff = vim.split(diff, "\n", { plain = true }) end ---@cast diff snacks.picker.Diff|string[] if type(diff[1]) == "string" then diff = require("snacks.picker.source.diff").parse(diff) end ---@cast diff snacks.picker.Diff return diff end ---@param buf number ---@param ns number ---@param diff string|string[]|snacks.picker.Diff ---@param opts? snacks.diff.Config function M.render(buf, ns, diff, opts) diff = M.get_diff(diff) local ret = M.format(diff, opts) return H.render(buf, ns, ret) end ---@param diff string|string[]|snacks.picker.Diff ---@param opts? snacks.diff.Config function M.format(diff, opts) local ctx = C:extend({ diff = M.get_diff(diff), opts = opts or {}, }) local ret = {} ---@type snacks.picker.Highlight[][] vim.list_extend(ret, M.format_header(ctx)) for _, block in ipairs(ctx.diff.blocks) do vim.list_extend(ret, M.format_block(ctx:extend({ block = block }))) end return ret end ---@param ctx snacks.diff.ctx function M.format_header(ctx) if #(ctx.diff.header or {}) == 0 then return {} end local popts = Snacks.picker.config.get({}) local ret = {} ---@type snacks.picker.Highlight[][] local msg = {} ---@type string[] for _, line in ipairs(ctx.diff.header or {}) do local hash = line:match("^commit%s+(%S+)$") if hash then ret[#ret + 1] = { { "Commit", "SnacksDiffLabel" }, { ": ", "SnacksPickerDelim" }, { popts.icons.git.commit, "SnacksPickerGitCommit" }, { hash:sub(1, 8), "SnacksPickerGitCommit" }, } else local label, value = line:match("^(%S+):%s*(.-)%s*$") if label and value then ret[#ret + 1] = { { label, "SnacksDiffLabel" }, { ": ", "SnacksPickerDelim" }, { value, "SnacksPickerGit" .. label }, } elseif line:match("^ ") then msg[#msg + 1] = line:match("^ (.-)%s*$") else ret[#ret + 1] = { { line } } end end end local subject = table.remove(msg, 1) or "" if subject then ret[#ret + 1] = {} ---@diagnostic disable-next-line: missing-fields ret[#ret + 1] = Snacks.picker.format.commit_message({ msg = subject }, {}) end if #msg > 0 then ret[#ret + 1] = H.rule() local virt_lines = H.get_virtual_lines(table.concat(msg, "\n"), { ft = "markdown" }) for _, vl in ipairs(virt_lines) do ret[#ret + 1] = vl end end ret[#ret + 1] = H.rule() return ret end ---@param ctx snacks.diff.ctx function M.format_block(ctx) local ret = {} ---@type snacks.picker.Highlight[][] vim.list_extend(ret, M.format_block_header(ctx)) for _, hunk in ipairs(ctx.block.hunks) do local hunk_lines = M.format_hunk(ctx:extend({ hunk = hunk })) if ctx.opts and ctx.opts.max_hunk_lines and #hunk_lines > ctx.opts.max_hunk_lines then hunk_lines = vim.list_slice(hunk_lines, #hunk_lines - ctx.opts.max_hunk_lines + 1) end vim.list_extend(ret, hunk_lines) end return ret end ---@param ctx snacks.diff.ctx function M.format_block_header(ctx) local block = assert(ctx.block) local ret = {} ---@type snacks.picker.Highlight[][] ret[#ret + 1] = H.add_eol({}, "SnacksDiffHeader") local icon, icon_hl = Snacks.util.icon(block.file) local file = {} ---@type snacks.picker.Highlight[] file[#file + 1] = { " " } -- needed to play nice with markview / markdown-renderer file[#file + 1] = { col = 0, virt_text = { { " ", "SnacksDiffHeader" } }, virt_text_pos = "overlay" } file[#file + 1] = { icon, icon_hl, inline = true } file[#file + 1] = { " " } if block.rename then file[#file + 1] = { block.rename.from } file[#file + 1] = { " -> ", "SnacksPickerDelim" } file[#file + 1] = { block.rename.to } else file[#file + 1] = { block.file } end H.insert_hl(file, "SnacksDiffHeader") H.add_eol(file, "SnacksDiffHeader") ret[#ret + 1] = file ret[#ret + 1] = H.add_eol({}, "SnacksDiffHeader") return ret end ---@param ctx snacks.diff.ctx function M.parse_hunk(ctx) local block = assert(ctx.block) local hunk = assert(ctx.hunk) local diff = vim.deepcopy(hunk.diff) local versions = {} ---@type snacks.picker.diff.hunk.Pos[] local unmerged = #versions > 2 local lines, prefixes, conflict_markers = {}, {}, {} ---@type string[], string[], table -- build versions versions[#versions + 1] = hunk.left vim.list_extend(versions, hunk.parents or {}) versions[#versions + 1] = hunk.right while #versions < 2 do -- normally should not happen, but just in case versions[#versions + 1] = { line = hunk.line, count = 0 } end -- setup diff lines table.remove(diff, 1) -- remove hunk header line while #diff > 0 and diff[#diff]:match("^%s*$") do table.remove(diff) -- remove trailing empty lines end -- parse diff lines for l, line in ipairs(diff) do prefixes[#prefixes + 1] = line:sub(1, #versions - 1) local code_line = line:sub(#versions) if unmerged and vim.tbl_contains(CONFLICT_MARKERS, code_line:match("^%s*(%S+)")) then conflict_markers[l] = code_line code_line = "" end lines[#lines + 1] = code_line end -- generate virt lines table.insert(lines, 1, hunk.context or "") -- add hunk context for syntax highlighting local ft = vim.filetype.match({ filename = block.file, contents = lines }) or "" local text = H.get_virtual_lines(table.concat(lines, "\n"), { ft = ft }) local context = table.remove(text, 1) -- remove hunk context virt lines table.remove(lines, 1) -- remove hunk context code line ---@class snacks.diff.hunk.Parse local ret = { len = #diff, -- number of lines in hunk versions = versions, -- positions of each version lines = lines, -- code lines of hunk text = text, -- virt lines of hunk prefixes = prefixes, -- diff prefixes of hunk conflict_markers = conflict_markers, -- conflict markers lines of hunk context = context, -- virt lines of hunk context unmerged = unmerged, -- whether hunk is unmerged } return ret end --- Build hunk line index for each version ---@param parse snacks.diff.hunk.Parse function M.build_hunk_index(parse) local versions = parse.versions local index = {} ---@type table[]|{max: number} local idx = {} ---@type number[] for p, pos in ipairs(versions) do idx[p] = idx[p] or ((pos.line or 1) - 1) end local max = 0 for l = 1, parse.len do local prefix = parse.prefixes[l] index[l] = {} if not parse.conflict_markers[l] then -- Increment parent versions for i = 1, #versions - 1 do local char = prefix:sub(i, i) if char == " " or char == "-" then idx[i] = idx[i] + 1 index[l][i] = idx[i] max = math.max(max, #tostring(idx[i])) end end end -- Increment working (right) -- Working increments if any char is ' ' or '+' (i.e., NOT all are '-') local has_working = false for i = 1, #prefix do if prefix:sub(i, i) ~= "-" then has_working = true break end end if has_working then idx[#idx] = idx[#idx] + 1 index[l][#idx] = idx[#idx] max = math.max(max, #tostring(idx[#idx])) end end index.max = max return index end ---@param parse snacks.diff.hunk.Parse function M.format_hunk_header(parse) local ret = {} ---@type snacks.picker.Highlight[][] local header = {} ---@type snacks.picker.Highlight[] header[#header + 1] = { " " } header[#header + 1] = { " ", "Special" } header[#header + 1] = { " " } H.extend(header, parse.context) local context_width = H.offset(parse.context) ret[#ret + 1] = { { string.rep("─", context_width + 7) .. "┐", "FloatBorder" }, } header[#header + 1] = { " │", "FloatBorder" } ret[#ret + 1] = header ret[#ret + 1] = { { string.rep("─", context_width + 7) .. "┘", "FloatBorder" }, } return ret end ---@param ctx snacks.diff.ctx function M.format_hunk(ctx) local block = assert(ctx.block) local ret = {} ---@type snacks.picker.Highlight[][] local parse = M.parse_hunk(ctx) local annotations = {} ---@type table for _, annotation in ipairs(ctx.opts.annotations or {}) do if annotation.file == block.file then annotations[("%s:%d"):format(annotation.side, annotation.line)] = annotation.text end end local index = M.build_hunk_index(parse) if ctx.opts.hunk_header ~= false then vim.list_extend(ret, M.format_hunk_header(parse)) end local in_conflict = false for l = 1, parse.len do local have_left, have_right = index[l][1] ~= nil, index[l][#parse.versions] ~= nil local hl = (parse.conflict_markers[l] and "SnacksDiffConflict") or (have_right and not have_left and "SnacksDiffAdd") or (have_left and not have_right and "SnacksDiffDelete") or "SnacksDiffContext" local prefix = parse.prefixes[l] if parse.unmerged then local p = " " local marker = parse.conflict_markers[l] or "" marker = marker:match("^%s*(%S+)") or "" if marker == "<<<<<<<" then in_conflict = true p = "┌╴" elseif marker == ">>>>>>>" then in_conflict = false p = "└╴" elseif marker == "=======" or marker == "|||||||" then p = "├╴" elseif in_conflict then p = "│ " end prefix = U.align(p, 2) .. prefix end local line = {} ---@type snacks.picker.Highlight[] local line_nr = {} ---@type string[] for i = 1, #parse.versions do line_nr[i] = U.align(tostring(index[l][i] or ""), index.max, { align = i == #parse.versions and "right" or "left" }) end local line_col = " " .. table.concat(line_nr, " ") .. " " local prefix_col = " " .. prefix .. " " -- empty linenr overlay that will be used for wrapped lines line[#line + 1] = { col = 0, virt_text = { { string.rep(" ", #line_col), hl .. "LineNr" } }, virt_text_pos = "overlay", hl_mode = "replace", virt_text_repeat_linebreak = true, } -- linenr overlay line[#line + 1] = { col = 0, virt_text = { { line_col, hl .. "LineNr" } }, virt_text_pos = "overlay", hl_mode = "replace", } -- empty prefix overlay that will be used for wrapped lines local ws = (parse.conflict_markers[l] or parse.lines[l]):match("^(%s*)") -- add ws for breakindent line[#line + 1] = { col = #line_col, virt_text = { { U.align(prefix_col:gsub("[%-%+]", " "), #ws + #prefix_col), hl } }, virt_text_pos = "overlay", hl_mode = "replace", virt_text_repeat_linebreak = true, } -- prefix overlay line[#line + 1] = { col = #line_col, virt_text = { { prefix_col, hl } }, virt_text_pos = "overlay", hl_mode = "replace", } if have_left or have_right then line[#line + 1] = { "", meta = { ---@type snacks.diff.Meta diff = { side = have_right and "right" or "left", file = block.file, line = have_right and index[l][#parse.versions] or index[l][1], code = parse.lines[l], }, }, } end ret[#ret + 1] = line local annot_left = "left:" .. (index[l][1] or "") local annot_right = "right:" .. (index[l][#parse.versions] or "") local ann = annotations[annot_left] or annotations[annot_right] if ann then vim.list_extend( ret, M.format_annotation(ann, { indent = { line[1] }, indent_width = #line_col, hl = hl, }) ) end local vl = H.indent({}, #line_col + #prefix_col) if parse.conflict_markers[l] then vl[#vl + 1] = { parse.conflict_markers[l], hl } else vim.list_extend(vl, parse.text[l] or {}) end H.insert_hl(vl, hl) H.extend(line, vl) H.add_eol(line, hl) end return ret end ---@param annotation snacks.picker.Highlight[][] ---@param ctx {indent: snacks.picker.Highlight[][], indent_width: number, hl: string} function M.format_annotation(annotation, ctx) local ret = {} ---@type snacks.picker.Highlight[][] local box, width = M.format_box(annotation) local empty = vim.deepcopy(ctx.indent) ---@type snacks.picker.Highlight[] vim.list_extend(empty, H.indent({}, ctx.indent_width + 2, ctx.hl)) H.add_eol(empty, ctx.hl) ret[#ret + 1] = vim.deepcopy(empty) for _, line in ipairs(box) do for _, chunk in ipairs(line) do if chunk.virt_text_win_col then chunk.virt_text_win_col = chunk.virt_text_win_col + ctx.indent_width + 2 end end local al = vim.deepcopy(ctx.indent) local vl = H.indent({}, ctx.indent_width + 2, ctx.hl) vl[#vl + 1] = { -- repeat indent for the space before box col = ctx.indent_width, virt_text = { { " ", ctx.hl } }, virt_text_pos = "overlay", hl_mode = "replace", virt_text_repeat_linebreak = true, } H.extend(al, vl) H.extend(al, vim.deepcopy(line)) H.add_eol(al, ctx.hl, width + ctx.indent_width + 6) ret[#ret + 1] = al end ret[#ret + 1] = vim.deepcopy(empty) return ret end ---@param lines snacks.picker.Highlight[][] ---@param border_hl? string function M.format_box(lines, border_hl) border_hl = border_hl or "FloatBorder" local ret = {} ---@type snacks.picker.Highlight[][] local width = 0 for _, line in ipairs(lines) do width = math.max(width, H.offset(line, { char_idx = true })) end width = math.max(width, 50) --[[@as number]] ---@param text snacks.picker.Highlight[] ---@param col? number local function vt(text, col) ---@type snacks.picker.Highlight return { col = 0, virt_text_pos = "overlay", virt_text_win_col = col, virt_text = text, virt_text_repeat_linebreak = true, } end ret[#ret + 1] = { vt({ { "┌", border_hl }, { string.rep("─", width + 2), border_hl }, { "┐", border_hl }, }), } for _, line in ipairs(lines) do ret[#ret + 1] = { vt({ { "│", border_hl }, { " " }, }), { " " }, } H.extend(ret[#ret], vim.deepcopy(line)) table.insert(ret[#ret], vt({ { "│", border_hl } }, width + 3)) end ret[#ret + 1] = { vt({ { "└", border_hl }, { string.rep("─", width + 2), border_hl }, { "┘", border_hl }, }), } return ret, width end return M ================================================ FILE: lua/snacks/picker/util/highlight.lua ================================================ ---@class snacks.picker.highlight local M = {} ---@class (private) vim.var_accessor ---@field snacks_meta? table M.langs = {} ---@type table M._scratch = {} ---@type table ---@param source string ---@param lang string function M.scratch_buf(source, lang) local buf = M._scratch[lang] if not (buf and vim.api.nvim_buf_is_valid(buf)) then buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_name(buf, "snacks://picker/highlight/" .. lang) M._scratch[lang] = buf end vim.bo[buf].fixeol = false vim.bo[buf].eol = false vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(source, "\n", { plain = true })) return buf end ---@param opts? {buf?:number, code?:string, ft?:string, lang?:string, file?:string, extmarks?:boolean} function M.get_highlights(opts) opts = opts or {} assert(opts.buf or opts.code, "buf or code is required") assert(not (opts.buf and opts.code), "only one of buf or code is allowed") local ret = {} ---@type table local ft = opts.ft or (opts.buf and vim.bo[opts.buf].filetype) or (opts.file and vim.filetype.match({ filename = opts.file, buf = 0 })) or vim.bo.filetype local lang = Snacks.util.get_lang(opts.lang or ft) lang = lang and lang:lower() or nil local parser, buf ---@type vim.treesitter.LanguageTree?, number? if lang then local ok = false buf = opts.buf or M.scratch_buf(opts.code, lang) ok, parser = pcall(vim.treesitter.get_parser, buf, lang) parser = ok and parser or nil end if parser and buf then parser:parse(true) parser:for_each_tree(function(tstree, tree) if not tstree then return end local query = vim.treesitter.query.get(tree:lang(), "highlights") -- Some injected languages may not have highlight queries. if not query then return end for capture, node, metadata in query:iter_captures(tstree:root(), buf) do ---@type string local name = query.captures[capture] if name ~= "spell" then local range = { node:range() } ---@type number[] local multi = range[1] ~= range[3] local text = multi and vim.split(vim.treesitter.get_node_text(node, buf, metadata[capture]), "\n", { plain = true }) or {} for row = range[1] + 1, range[3] + 1 do local first, last = row == range[1] + 1, row == range[3] + 1 local end_col = last and range[4] or #(text[row - range[1]] or "") end_col = multi and first and end_col + range[2] or end_col ret[row] = ret[row] or {} table.insert(ret[row], { col = first and range[2] or 0, end_col = end_col, priority = (tonumber(metadata.priority or metadata[capture] and metadata[capture].priority) or 100), conceal = metadata.conceal or metadata[capture] and metadata[capture].conceal, hl_group = "@" .. name .. "." .. lang, }) end end end end) end --- Add buffer extmarks if opts.buf and opts.extmarks then local extmarks = vim.api.nvim_buf_get_extmarks(opts.buf, -1, 0, -1, { details = true }) for _, extmark in pairs(extmarks) do local row = extmark[2] + 1 ret[row] = ret[row] or {} local e = extmark[4] if e then e.sign_name = nil e.sign_text = nil e.ns_id = nil e.end_row = nil e.col = extmark[3] if e.virt_text_pos and not vim.tbl_contains({ "eol", "overlay", "right_align", "inline" }, e.virt_text_pos) then e.virt_text = nil e.virt_text_pos = nil end table.insert(ret[row], e) end end end return ret end ---@param source string|number ---@param opts? {ft:string, bg?: string} ---@return snacks.picker.Text[][] function M.get_virtual_lines(source, opts) opts = opts or {} local lines = type(source) == "number" and vim.api.nvim_buf_get_lines(source, 0, -1, false) or vim.split(source --[[@as string]], "\n") local extmarks = M.get_highlights({ buf = type(source) == "number" and source or nil, code = type(source) == "string" and source or nil, ft = opts.ft, lang = nil, }) if not extmarks then return vim.tbl_map(function(line) return { { line } } end, lines) end local index = {} ---@type table> for row, exts in pairs(extmarks) do for _, e in ipairs(exts) do if e.hl_group and e.end_col then index[row] = index[row] or {} for i = e.col + 1, e.end_col do index[row][i] = e.hl_group end end end end local ret = {} ---@type snacks.picker.Text[][] for i = 1, #lines do ret[i] = {} local line = lines[i] local from = 0 local hl_group = nil ---@type string? ---@param to number local function add(to) if to >= from then local text = line:sub(from, to) local hl = opts.bg and { hl_group or "Normal", opts.bg } or hl_group if #text > 0 then table.insert(ret[i], { text, hl }) end end from = to + 1 hl_group = nil end for col = 1, #line do local hl = index[i] and index[i][col] if hl ~= hl_group then add(col - 1) hl_group = hl end end add(#line) end return ret end ---@param line snacks.picker.Highlight[] ---@param opts? {char_idx?:boolean} function M.offset(line, opts) opts = opts or {} local offset = 0 for _, t in ipairs(line) do if type(t[1]) == "string" and not t.inline then if t.virtual then offset = offset + vim.api.nvim_strwidth(t[1]) elseif opts.char_idx then offset = offset + vim.api.nvim_strwidth(t[1]) else offset = offset + #t[1] end elseif t.virt_text_pos == "inline" and t.virt_text and opts.char_idx then offset = offset + M.offset(t.virt_text) + (t.col or 0) end end return offset end function M.rule() ---@type snacks.picker.Highlight[] return { { col = 0, virt_text_win_col = 0, virt_text = { { string.rep("-", math.max(vim.o.columns, 500)), "SnacksPickerRule" } }, priority = 100, }, } end ---@param line snacks.picker.Highlight[] ---@param positions number[] ---@param offset? number function M.matches(line, positions, offset) offset = offset or 0 for _, pos in ipairs(positions) do table.insert(line, { col = pos - 1 + offset, end_col = pos + offset, hl_group = "SnacksPickerMatch", }) end return line end ---@param line snacks.picker.Highlight[] ---@param item snacks.picker.Item ---@param text string ---@param opts? {hl_group?:string, lang?:string} function M.format(item, text, line, opts) opts = opts or {} local offset = M.offset(line) item._ = item._ or {} item._.ts = item._.ts or {} local highlights = item._.ts[text] ---@type table? if not highlights then highlights = M.get_highlights({ code = text, ft = item.ft, lang = opts.lang or item.lang, file = item.file })[1] or {} item._.ts[text] = highlights end highlights = vim.deepcopy(highlights) for _, extmark in ipairs(highlights) do extmark.col = extmark.col + offset extmark.end_col = extmark.end_col + offset line[#line + 1] = extmark end line[#line + 1] = { text, opts.hl_group } end ---@param line snacks.picker.Highlight[] ---@param patterns table function M.highlight(line, patterns) local offset = M.offset(line) local text ---@type string? for i = #line, 1, -1 do if type(line[i][1]) == "string" then text = line[i][1] break end end if not text then return end offset = offset - #text for pattern, hl in pairs(patterns) do local from, to, match = text:find(pattern) while from do if match then from, to = text:find(match, from, true) end table.insert(line, { col = offset + from - 1, end_col = offset + to, hl_group = hl, }) from, to = text:find(pattern, to + 1) end end end ---@param line snacks.picker.Highlight[] function M.markdown(line) M.highlight(line, { ["^# .*"] = "@markup.heading.1.markdown", ["^## .*"] = "@markup.heading.2.markdown", ["^### .*"] = "@markup.heading.3.markdown", ["^#### .*"] = "@markup.heading.4.markdown", ["^##### .*"] = "@markup.heading.5.markdown", ["`.-`"] = "SnacksPickerCode", ["^%s*[%-%*]"] = "@markup.list.markdown", ["%*.-%*"] = "SnacksPickerItalic", ["%*%*.-%*%*"] = "SnacksPickerBold", }) end ---@param prefix string ---@param links? table function M.winhl(prefix, links) links = links or {} local winhl = { NormalFloat = "", FloatBorder = "Border", FloatTitle = "Title", FloatFooter = "Footer", CursorLine = "CursorLine", } local ret = {} ---@type string[] local groups = {} ---@type table for k, v in pairs(winhl) do groups[v] = links[k] or (prefix == "SnacksPicker" and k or ("SnacksPicker" .. v)) ret[#ret + 1] = ("%s:%s%s"):format(k, prefix, v) end Snacks.util.set_hl(groups, { prefix = prefix, default = true }) return table.concat(ret, ",") end --- Resolves the first flex text in the line. ---@param line snacks.picker.Highlight[] ---@param max_width number function M.resolve(line, max_width) while true do local offset = 0 local width = 0 local resolve ---@type number? for t, text in ipairs(line) do local w = M.offset({ text }, { char_idx = true }) if not resolve and type(text) == "table" and text.resolve then ---@cast text snacks.picker.Text resolve = t elseif resolve then width = width + w else width = width + w offset = offset + w end end if resolve then local ret = {} ---@type snacks.picker.Highlight[] vim.list_extend(ret, line, 1, resolve - 1) offset = M.offset(ret) vim.list_extend(ret, line[resolve].resolve(math.max(max_width - width, 1))) local diff = M.offset(ret) - offset vim.list_extend(ret, line, resolve + 1) M.fix_offset(ret, diff, resolve + 1) line = ret else return line end end end ---@param line snacks.picker.Highlight[] ---@param hl_group string function M.insert_hl(line, hl_group) for _, t in ipairs(line) do if type(t[1]) == "string" then if t[2] == nil then t[2] = hl_group elseif type(t[2]) == "string" then t[2] = { hl_group, t[2] } elseif type(t[2]) == "table" then table.insert(t[2], 1, hl_group) end end end return line end ---@param line snacks.picker.Highlight[] ---@param indent number ---@param hl_group? string|string[] function M.indent(line, indent, hl_group) local ret = {} ---@type snacks.picker.Highlight[] ret[#ret + 1] = { string.rep(" ", indent), hl_group } M.extend(ret, line) return ret end ---@param line snacks.picker.Highlight[] ---@param hl_group string ---@param offset? number function M.add_eol(line, hl_group, offset) line[#line + 1] = { col = M.offset(line), virt_text = { { string.rep(" ", 1000), hl_group } }, virt_text_pos = "overlay", hl_mode = "replace", virt_text_win_col = offset, virt_text_repeat_linebreak = true, } return line end ---@param line snacks.picker.Highlight[] ---@param opts? {offset?:number} function M.to_text(line, opts) local offset = opts and opts.offset or 0 local ret = {} ---@type snacks.picker.Extmark[] local meta = {} ---@type snacks.picker.Meta local col = offset local parts = {} ---@type string[] for _, text in ipairs(line) do if (type(text[2]) == "string" and text[1] == nil) or vim.tbl_isempty(text) then text[1] = "" end for k, v in pairs(text.meta or {}) do meta[k] = v end if type(text[1]) == "string" and #text[1] > 0 then ---@cast text snacks.picker.Text if text.virtual then table.insert(ret, { col = col, virt_text = { { text[1], text[2] } }, virt_text_pos = "overlay", hl_mode = "combine", }) parts[#parts + 1] = string.rep(" ", vim.api.nvim_strwidth(text[1])) elseif text.inline then table.insert(ret, { col = col, virt_text = { { text[1], text[2] } }, virt_text_pos = "inline", hl_mode = "replace", }) parts[#parts + 1] = "" else table.insert(ret, { col = col, end_col = col + #text[1], hl_group = text[2], field = text.field, }) parts[#parts + 1] = text[1] end col = col + #parts[#parts] elseif type(text[1]) ~= "string" then text = vim.deepcopy(text) text.col = text.col or 0 if text.col < 0 then text.col = col + text.col end if text.end_col and text.end_col < 0 then text.end_col = col + text.end_col end ---@cast text snacks.picker.Extmark -- fix extmark col and end_col text.col = text.col + offset if text.end_col then text.end_col = text.end_col + offset end table.insert(ret, text) end end return table.concat(parts), ret, not vim.tbl_isempty(meta) and meta or nil end ---@param hl snacks.picker.Highlight[] ---@param start_idx? number function M.fix_offset(hl, offset, start_idx) for i, t in ipairs(hl) do if start_idx == nil or i >= start_idx then if t.col and t.col >= 0 then t.col = t.col + offset end if t.end_col and t.end_col >= 0 then t.end_col = t.end_col + offset end end end return hl end --- tables with number as keys are stored in vim.b as an array, --- so we need to filter out vim.NIL ---@param buf number function M.meta(buf) local ret = {} ---@type table for k, v in pairs(vim.b[buf].snacks_meta or {}) do if v ~= vim.NIL then ret[k] = v end end return not vim.tbl_isempty(ret) and ret or nil end ---@param dst snacks.picker.Highlight[] ---@param src snacks.picker.Highlight[] function M.extend(dst, src) local offset = M.offset(dst) M.fix_offset(src, offset) return vim.list_extend(dst, src) end ---@param buf number ---@param ns number ---@param lines snacks.picker.Highlight[][] ---@param opts? {append?:boolean} function M.render(buf, ns, lines, opts) opts = opts or {} local old_lines = opts.append and {} or vim.api.nvim_buf_get_lines(buf, 0, -1, false) vim.bo[buf].modifiable = true if not opts.append then vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) end local meta = {} ---@type table local changed = #lines ~= #old_lines local offset = opts.append and vim.api.nvim_buf_line_count(buf) or 0 offset = offset == 1 and (vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or ""):find("^%s*$") and 0 or offset for l, line in ipairs(lines) do local line_text, extmarks, line_meta = Snacks.picker.highlight.to_text(line) if line_text ~= old_lines[l] then vim.api.nvim_buf_set_lines(buf, offset + l - 1, offset + l, false, { line_text }) changed = true end if line_meta then meta[offset + l] = line_meta end for _, extmark in ipairs(extmarks) do local e = vim.deepcopy(extmark) e.col, e.row, e.field = nil, nil, nil local ok, err = pcall(vim.api.nvim_buf_set_extmark, buf, ns, offset + l - 1, extmark.col, e) if not ok then Snacks.notify.error( "Failed to set extmark. This should not happen. Please report.\n" .. err .. "\n```lua\n" .. vim.inspect(extmark) .. "\n```" ) end end end if not opts.append and #lines < #old_lines then vim.api.nvim_buf_set_lines(buf, #lines, -1, false, {}) end if not vim.tbl_isempty(meta) then vim.b[buf].snacks_meta = meta end vim.bo[buf].modified = false vim.bo[buf].modifiable = false return changed end ---@alias snacks.picker.badge.color string|{ fg:string, bg:string } local badge_cache = {} ---@type table ---@param color snacks.picker.badge.color local function badge_hl(color) local key = type(color) == "string" and color or ("%s:%s"):format(color.fg or "", color.bg or "") if badge_cache[key] then return badge_cache[key].hl end local fg, bg ---@type string, string if type(color) == "string" then if color:sub(1, 1) == "#" then bg = color else fg, bg = Snacks.util.color(color, "fg"), Snacks.util.color(color, "bg") end else fg, bg = color.fg, color.bg end if not fg and not bg then -- default to inverse of Normal fg = Snacks.util.color("Normal", "bg") or "#ffffff" bg = Snacks.util.color("Normal", "fg") or "#000000" elseif fg and not bg then -- set bg to a blended version of fg and Normal bg bg = bg or Snacks.util.color("Normal", "bg") or "#000000" bg = Snacks.util.blend(fg, bg, 0.1) elseif bg and not fg then -- calculate fg based on bg brightness local light, dark = "#ffffff", "#000000" do local normal_fg = Snacks.util.color("Normal", "fg") local normal_bg = Snacks.util.color("Normal", "bg") if vim.o.background == "light" then normal_fg, normal_bg = normal_bg, normal_fg end light = normal_fg or light dark = normal_bg or dark end local r, g, b = bg:match("#?(%x%x)(%x%x)(%x%x)") r, g, b = tonumber(r, 16), tonumber(g, 16), tonumber(b, 16) local yiq = (r * 299 + g * 587 + b * 114) / 1000 fg = yiq >= 128 and dark or light end local hl_group = ("SnacksBadge_%s_%s"):format(fg:sub(2), bg:sub(2)) vim.api.nvim_set_hl(0, hl_group, { fg = fg, bg = bg }) vim.api.nvim_set_hl(0, hl_group .. "Inv", { fg = bg }) badge_cache[key] = { hl = hl_group, color = color } return hl_group end --- Renders a badge ---@param text string ---@param color snacks.picker.badge.color ---@param opts? {virtual?:boolean} function M.badge(text, color, opts) local left_sep, right_sep = "", "" local hl_group = badge_hl(color) ---@type snacks.picker.Highlight[] return { { left_sep, hl_group .. "Inv", inline = true }, { text, hl_group }, { right_sep, hl_group .. "Inv", inline = true }, { " " }, } end vim.api.nvim_create_autocmd("ColorScheme", { group = vim.api.nvim_create_augroup("snacks.picker.highlight,badges", { clear = true }), callback = function(ev) local badges = badge_cache badge_cache = {} for _, v in pairs(badges) do badge_hl(v.color) end end, }) return M ================================================ FILE: lua/snacks/picker/util/history.lua ================================================ ---@class snacks.picker.History ---@field path string ---@field kv snacks.picker.KeyValue ---@field idx number ---@field cursor number local M = {} M.__index = M ---@type table M.stores = {} -- Save the history on exit vim.api.nvim_create_autocmd("ExitPre", { group = vim.api.nvim_create_augroup("snacks_history", { clear = true }), callback = function() for n, kv in pairs(M.stores) do kv:close() M.stores[n] = nil end end, }) ---@param name string ---@param opts? {filter?: fun(value: string): boolean} function M.new(name, opts) opts = opts or {} local self = setmetatable({}, M) self.path = vim.fn.stdpath("data") .. "/snacks/" .. name .. ".history" if not M.stores[name] then M.stores[name] = require("snacks.picker.util.kv").new(self.path, { max_size = 1000, ---@param a snacks.picker.KeyValue.entry ---@param b snacks.picker.KeyValue.entry cmp = function(a, b) return a.key > b.key end, }) end self.kv = M.stores[name] -- re-index the data self.kv.data = vim.tbl_values(self.kv.data) if opts.filter then self.kv.data = vim.tbl_filter(opts.filter, self.kv.data) end self.idx = #self.kv.data + 1 self.cursor = self.idx return self end function M:is_current() return self.cursor == self.idx end function M:record(value) -- don't record value if it's identical to the last recorded value if vim.deep_equal(self.kv:get(math.max(self.idx - 1, 1)), value) then return end self.kv:set(self.idx, value) end function M:next() self.cursor = math.min(self.cursor + 1, self.idx) return self:get() end function M:prev() self.cursor = math.max(self.cursor - 1, 1) return self:get() end function M:get() return self.kv:get(self.cursor) end return M ================================================ FILE: lua/snacks/picker/util/init.lua ================================================ ---@class snacks.picker.util local M = {} local uv = vim.uv or vim.loop local str_byteindex_new = pcall(vim.str_byteindex, "aa", "utf-8", 1) ---@param item snacks.picker.Item ---@return string? function M.path(item) if not (item and item.file) then return end item._path = item._path or svim.fs.normalize(item.cwd and item.cwd .. "/" .. item.file or item.file, { _fast = true, expand_env = false }) return item._path end ---@param path string ---@param len number ---@param opts? {cwd?: string, kind?: "left" | "center" | "right"} function M.truncpath(path, len, opts) opts = opts or {} local cwd = svim.fs.normalize(opts and opts.cwd or vim.fn.getcwd(0), { _fast = true, expand_env = false }) local home = svim.fs.normalize("~") path = svim.fs.normalize(path, { _fast = true, expand_env = false }) if path:find(cwd .. "/", 1, true) == 1 and #path > #cwd then path = path:sub(#cwd + 2) else local root = Snacks.git.get_root(path) if root and root ~= "" and path:find(root, 1, true) == 1 then local tail = vim.fn.fnamemodify(root, ":t") path = "⋮" .. tail .. "/" .. path:sub(#root + 2) elseif path:find(home, 1, true) == 1 then path = "~" .. path:sub(#home + 1) end end path = path:gsub("/$", "") if opts.kind == "left" then return M.truncate(path, len, true) elseif opts.kind == "right" then return M.truncate(path, len, false) end if vim.api.nvim_strwidth(path) <= len then return path end local parts = vim.split(path, "/") if #parts < 2 then return path end local ret = table.remove(parts) local first = table.remove(parts, 1) if first == "~" and #parts > 0 then first = "~/" .. table.remove(parts, 1) end local width = vim.api.nvim_strwidth(ret) + vim.api.nvim_strwidth(first) + 3 if width > len then return first .. "/…/" .. M.truncate(ret, len - vim.api.nvim_strwidth(first) - 3, true) end while width < len and #parts > 0 do local part = table.remove(parts) .. "/" local w = vim.api.nvim_strwidth(part) if width + w > len then break end ret = part .. ret width = width + w end return first .. "/…/" .. ret end ---@param prompt string ---@param fn fun() function M.confirm(prompt, fn) Snacks.picker.select({ "No", "Yes" }, { prompt = prompt, snacks = { layout = { layout = { max_width = 60, }, }, }, }, function(_, idx) if idx == 2 then fn() end end) end ---@alias snacks.picker.util.cmd.Opts {env?: table, cwd?: string, input?: string} ---@param cmd string|string[] ---@param cb fun(output: string[], code: number) ---@param opts? snacks.picker.util.cmd.Opts function M.cmd(cmd, cb, opts) opts = opts or {} local output = {} ---@type string[] local id = vim.fn.jobstart( cmd, vim.tbl_extend("force", opts or {}, { on_stdout = function(_, data) output[#output + 1] = table.concat(data, "\n") end, on_stderr = function(_, data) output[#output + 1] = table.concat(data, "\n") end, on_exit = function(_, code) if code == 0 then cb(output, code) return end Snacks.debug.cmd({ header = "Command failed", cmd = cmd, props = { code = code, ["vim.o.shell"] = vim.o.shell }, footer = vim.trim(table.concat(output, "")), level = vim.log.levels.ERROR, }) end, }) ) if id <= 0 then Snacks.notify.error(("Failed to start job `%s`"):format(cmd)) elseif opts.input then vim.fn.chansend(id, opts.input .. "\n") vim.fn.chanclose(id, "stdin") end return id > 0 and id or nil end ---@param item table ---@param keys string[] function M.text(item, keys) local buffer = require("string.buffer").new() for _, key in ipairs(keys) do if item[key] then if #buffer > 0 then buffer:put(" ") end if key == "pos" or key == "end_pos" then buffer:putf("%d:%d", item[key][1], item[key][2]) else buffer:put(tostring(item[key])) end end end return buffer:get() end ---@param text? string ---@param width number ---@param opts? {align?: "left" | "right" | "center", truncate?: boolean} function M.align(text, width, opts) text = text or "" opts = opts or {} opts.align = opts.align or "left" local tw = vim.api.nvim_strwidth(text) if tw > width then return opts.truncate and (vim.fn.strcharpart(text, 0, width - 1) .. "…") or text end local left = math.floor((width - tw) / 2) local right = width - tw - left if opts.align == "left" then left, right = 0, width - tw elseif opts.align == "right" then left, right = width - tw, 0 end return (" "):rep(left) .. text .. (" "):rep(right) end ---@param text string ---@param width number ---@param left? boolean function M.truncate(text, width, left) local tw = vim.api.nvim_strwidth(text) if tw > width then return left and "…" .. vim.fn.strcharpart(text, tw - width + 1, width - 1) or vim.fn.strcharpart(text, 0, width - 1) .. "…" end return text end -- Stops visual mode and returns the selected text function M.visual() local modes = { "v", "V", Snacks.util.keycode("") } local mode = vim.fn.mode():sub(1, 1) ---@type string if not vim.tbl_contains(modes, mode) then return end -- stop visual mode vim.cmd("normal! " .. mode) local pos = vim.api.nvim_buf_get_mark(0, "<") local end_pos = vim.api.nvim_buf_get_mark(0, ">") -- for some reason, sometimes the column is off by one -- see: https://github.com/folke/snacks.nvim/issues/190 local col_to = math.min(end_pos[2] + 1, #vim.api.nvim_buf_get_lines(0, end_pos[1] - 1, end_pos[1], false)[1]) local lines = vim.api.nvim_buf_get_text(0, pos[1] - 1, pos[2], end_pos[1] - 1, col_to, {}) local text = table.concat(lines, "\n") ---@class snacks.picker.Visual local ret = { buf = vim.api.nvim_get_current_buf(), pos = pos, end_pos = end_pos, text = text, } return ret end ---@param str string ---@param data table|table[] ---@param opts? {prefix?: string, indent?: boolean, offset?: number[]} function M.tpl(str, data, opts) opts = opts or {} local function get(key) if not vim.tbl_isempty(data) and svim.islist(data) and not getmetatable(data) then for _, d in ipairs(data) do if d[key] ~= nil then return d[key] end end else if data[key] ~= nil then return data[key] end end end local ret = ( str:gsub( "(" .. vim.pesc(opts.prefix or "") .. "%b{}" .. ")", ---@param w string function(w) local inner = w:sub(2 + #(opts.prefix or ""), -2) local key, default = inner:match("^(.-):(.*)$") local ret = get(key or inner) if ret == "" and default then return default end return ret or w end ) ) if opts.indent then local lines = vim.split(ret:gsub("\t", " "), "\n", { plain = true }) local indent = 1000 for _, line in ipairs(lines) do indent = math.min(indent, line:find("%S") or 1000) end for l, line in ipairs(lines) do lines[l] = line:sub(indent) end end return ret end ---@param str string function M.title(str) return table.concat( vim.tbl_map(function(s) return s:sub(1, 1):upper() .. s:sub(2) end, vim.split(str, "_")), " " ) end function M.rtp() local ret = {} ---@type string[] vim.list_extend(ret, vim.api.nvim_get_runtime_file("", true)) if package.loaded.lazy then local extra = require("lazy.core.util").get_unloaded_rtp("") vim.list_extend(ret, extra) end return ret end ---@param str string ---@return string text, string[] args function M.parse(str) -- Format: this is a test -- -g=hello local t, a = str:match("^(.-)%s+%-%-%s*(.*)$") if not t then return str, {} end t, a = vim.trim(t), vim.trim(a:gsub("%s+", " ")) local args = {} ---@type string[] -- tokenize the args, keeping quoted strings together local in_quote = nil ---@type string? local c = 1 for i = 1, #a do local char = a:sub(i, i) if char == "'" or char == '"' then if in_quote == char then in_quote = nil else in_quote = char end elseif char == " " and not in_quote then args[#args + 1] = a:sub(c, i - 1) c = i + 1 end end if c <= #a then args[#args + 1] = a:sub(c) end return t, args end --- Resolves the item if it has a resolve function ---@param item snacks.picker.Item? function M.resolve(item) if item and item.resolve then item.resolve(item) item.resolve = nil end return item end --- Reads the lines of a file. --- This is about 8x faster than `vim.fn.readfile` --- and 3x faster than `io.lines` using a --- test files of 225KB and 8300 lines. ---@param file string function M.lines(file) local fd = uv.fs_open(file, "r", 438) if not fd then return {} end local stat = assert(uv.fs_fstat(fd)) local data = assert(uv.fs_read(fd, stat.size, 0)) uv.fs_close(fd) local lines, from = {}, 1 --- @type string[], number while from <= #data do local nl = data:find("\n", from, true) if nl then local cr = data:byte(nl - 1, nl - 1) == 13 -- \r local line = data:sub(from, nl - (cr and 2 or 1)) lines[#lines + 1] = line from = nl + 1 else lines[#lines + 1] = data:sub(from) break end end return lines end ---@param s string ---@param index number ---@param encoding string ---@param strict_indexing? boolean function M.str_byteindex(s, index, encoding, strict_indexing) if str_byteindex_new then return vim.str_byteindex(s, encoding, index, strict_indexing) elseif vim.str_byteindex then ---@diagnostic disable-next-line: param-type-mismatch return vim.str_byteindex(s, index, encoding == "utf-16") elseif vim.lsp.util._str_byteindex then return vim.lsp.util._str_byteindex(s, index, encoding) end error("No str_byteindex function available") end --- Resolves the location of an item to byte positions ---@param item snacks.picker.Item ---@param buf? number function M.resolve_loc(item, buf) if not item or not item.loc or item.loc.resolved then return item end local lines = {} ---@type string[] if item.buf and vim.api.nvim_buf_is_loaded(item.buf) then -- valid and loaded buffer lines = vim.api.nvim_buf_get_lines(item.buf, 0, -1, false) elseif item.buf and vim.uri_from_bufnr(item.buf):sub(1, 4) ~= "file" then -- item buffer with a custom uri vim.fn.bufload(item.buf) lines = vim.api.nvim_buf_get_lines(item.buf, 0, -1, false) elseif buf and vim.api.nvim_buf_is_valid(buf) then -- custom buffer (typically for preview) lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) elseif item.file then -- last resort, read the file lines = M.lines(item.file) end ---@param pos lsp.Position? local function resolve(pos) if not pos then return end local line = lines[pos.line + 1] local col = line and M.str_byteindex(line, pos.character, item.loc.encoding) or pos.character return { pos.line + 1, col } end item.pos = resolve(item.loc.range["start"]) item.end_pos = resolve(item.loc.range["end"]) or item.end_pos item.loc.resolved = true return item end --- Returns the relative time from a given time --- as ... ago ---@param time number in seconds function M.reltime(time) local delta = os.time() - time local tpl = { { 1, 60, "just now", "just now" }, { 60, 3600, "a minute ago", "%d minutes ago" }, { 3600, 3600 * 24, "an hour ago", "%d hours ago" }, { 3600 * 24, 3600 * 24 * 7, "yesterday", "%d days ago" }, { 3600 * 24 * 7, 3600 * 24 * 7 * 4, "a week ago", "%d weeks ago" }, } for _, v in ipairs(tpl) do if delta < v[2] then local value = math.floor(delta / v[1] + 0.5) return value == 1 and v[3] or v[4]:format(value) end end if os.date("%Y", time) == os.date("%Y") then return os.date("%b %d", time) ---@type string end return os.date("%b %d, %Y", time) ---@type string end ---@generic T: table ---@param t T ---@return T function M.shallow_copy(t) local ret = {} for k, v in pairs(t) do ret[k] = v end return setmetatable(ret, getmetatable(t)) end ---@param opts? {main?: number, float?:boolean, filter?: fun(win:number, buf:number):boolean?} function M.pick_win(opts) opts = Snacks.config.merge({ filter = function(win, buf) return not vim.bo[buf].filetype:find("^snacks") end, }, opts) local overlays = {} ---@type snacks.win[] local chars = "asdfghjkl" local wins = {} ---@type number[] for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do local buf = vim.api.nvim_win_get_buf(win) local keep = (opts.float or vim.api.nvim_win_get_config(win).relative == "") and (not opts.filter or opts.filter(win, buf)) if keep then wins[#wins + 1] = win end end if #wins == 1 then return wins[1] elseif #wins == 0 then return end for _, win in ipairs(wins) do local c = chars:sub(1, 1) chars = chars:sub(2) overlays[c] = Snacks.win({ backdrop = false, win = win, focusable = false, enter = false, relative = "win", width = 7, height = 3, text = (" \n %s \n "):format(c), wo = { winhighlight = "NormalFloat:SnacksPickerPickWin" .. (win == opts.main and "Current" or ""), }, }) end vim.cmd([[redraw!]]) local char = vim.fn.getcharstr() for _, overlay in pairs(overlays) do overlay:close() end local win = (char == Snacks.util.keycode("")) or overlays[char] if win and type(win) == "table" then return win.opts.win elseif win then return opts.main end end ---@param path string ---@param cwd? string ---@return fun(): string? function M.parents(path, cwd) cwd = cwd or uv.cwd() if not (cwd and path:sub(1, #cwd) == cwd and #path > #cwd) then return function() end end local to = #cwd + 1 ---@type number? return function() to = path:find("/", to + 1, true) return to and path:sub(1, to - 1) or nil end end --- Checks if the path is a directory, --- if not it returns the parent directory ---@param item string|snacks.picker.Item function M.dir(item) local path = type(item) == "table" and M.path(item) or item ---@cast path string path = svim.fs.normalize(path) return Snacks.util.path_type(path) == "directory" and path or vim.fs.dirname(path) end ---@param paths string[] ---@param dir string function M.copy(paths, dir) dir = svim.fs.normalize(dir) paths = vim.tbl_map(svim.fs.normalize, paths) ---@type string[] for _, path in ipairs(paths) do local name = vim.fn.fnamemodify(path, ":t") local to = dir .. "/" .. name M.copy_path(path, to) end end ---@param from string ---@param to string function M.copy_path(from, to) if not uv.fs_stat(from) then Snacks.notify.error(("File `%s` does not exist"):format(from)) return end if Snacks.util.path_type(from) == "directory" then M.copy_dir(from, to) else M.copy_file(from, to) end end ---@param from string ---@param to string function M.copy_file(from, to) if vim.fn.filereadable(from) == 0 then Snacks.notify.error(("File `%s` is not readable"):format(from)) return end if uv.fs_stat(to) then Snacks.notify.error(("File `%s` already exists"):format(to)) return end local dir = vim.fs.dirname(to) vim.fn.mkdir(dir, "p") local ok, err = uv.fs_copyfile(from, to, { excl = true, ficlone = true }) if not ok then Snacks.notify.error(("Failed to copy file:\n - from: `%s`\n- to: `%s`\n%s"):format(from, to, err)) end end ---@param from string ---@param to string function M.copy_dir(from, to) if vim.fn.isdirectory(from) == 0 then Snacks.notify.error(("Directory `%s` does not exist"):format(from)) return end vim.fn.mkdir(to, "p") for fname in vim.fs.dir(from, { follow = false }) do local path = from .. "/" .. fname M.copy_path(path, to .. "/" .. fname) end end ---@param buf number ---@return (vim.bo|vim.wo)? function M.modeline(buf) if not vim.api.nvim_buf_is_valid(buf) then return end local lines = vim.api.nvim_buf_get_lines(buf, 0, vim.o.modelines, false) vim.list_extend(lines, vim.api.nvim_buf_get_lines(buf, -vim.o.modelines, -1, false)) for _, line in ipairs(lines) do local m, options = line:match("%S*%s+(%w+):%s*(.-)%s*$") if vim.tbl_contains({ "vi", "vim", "ex" }, m) and options then local set = vim.split(options, "[:%s]+") local ret = {} ---@type table for _, v in ipairs(set) do if v ~= "" then local k, val = v:match("([^=]+)=(.+)") if k then ret[k] = tonumber(val) or val else ret[v:gsub("^no", "")] = v:find("^no") and false or true end end end return ret end end end --- Gets the list of binaries in the PATH. --- This won't check if the binary is executable. --- On Windows, additional extensions are checked. function M.get_bins() local is_win = jit.os:find("Windows") local path = vim.split(os.getenv("PATH") or "", is_win and ";" or ":", { plain = true }) local bins = {} ---@type table for _, p in ipairs(path) do p = svim.fs.normalize(p) for file, t in vim.fs.dir(p) do if t ~= "directory" then local fpath = p .. "/" .. file local base, ext = file:match("^(.*)%.(%a+)$") if is_win then if base and ext and vim.tbl_contains({ "exe", "bat", "com", "cmd" }, ext) then bins[base] = bins[base] or fpath end else bins[file] = bins[file] or fpath end end end end return bins end ---@param glob string function M.glob2pattern(glob) local pattern = "" local i = 1 while i <= #glob do local c = glob:sub(i, i) if c == "*" then if i + 1 <= #glob and glob:sub(i + 1, i + 1) == "*" then -- '**' pattern = pattern .. ".*" i = i + 2 else -- '*' pattern = pattern .. "[^/]*" i = i + 1 end elseif c == "?" then pattern = pattern .. "[^/]" -- Match exactly one non-'/' character i = i + 1 else c = c:match("^[%^%$%(%)%%%.%[%]%+%-]$") and "%" .. c or c pattern = pattern .. c i = i + 1 end end pattern = pattern .. "$" pattern = pattern :gsub("^" .. vim.pesc("[^/]*"), "") :gsub("^" .. vim.pesc(".*"), "") :gsub(vim.pesc("[^/]*$") .. "$", "") :gsub(vim.pesc(".*$") .. "$", "") return pattern end ---@param globs string[] ---@return fun(file: string): boolean function M.globber(globs) local patterns = {} ---@type string[] for _, glob in ipairs(globs) do table.insert(patterns, M.glob2pattern(glob)) end ---@param file string return function(file) for _, pattern in ipairs(patterns) do if file:find(pattern) then return true end end return false end end ---@param buf number function M.spinner(buf) return require("snacks.picker.util.spinner").new(buf) end return M ================================================ FILE: lua/snacks/picker/util/kv.lua ================================================ ---@class snacks.picker.KeyValue ---@field data table ---@field loaded_time number ---@field path string ---@field max_size number ---@field cmp fun(a:snacks.picker.KeyValue.entry, b:snacks.picker.KeyValue.entry): boolean local M = {} M.__index = M local uv = vim.uv or vim.loop ---@alias snacks.picker.KeyValue.entry {key:string, value:number} ---@param path string ---@param opts? {max_size?: number, cmp?: fun(a:snacks.picker.KeyValue.entry, b:snacks.picker.KeyValue.entry): boolean} function M.new(path, opts) local self = setmetatable({}, M) self.data = {} self.path = path self.max_size = opts and opts.max_size or 10000 ---@param a snacks.picker.KeyValue.entry ---@param b snacks.picker.KeyValue.entry self.cmp = opts and opts.cmp or function(a, b) return a.value > b.value end self.loaded_time = os.time() local fd = io.open(path, "rb") if fd then ---@type string local data = fd:read("*a") fd:close() local ok, decoded = pcall(require("string.buffer").decode, data) self.data = ok and decoded or {} --[[@as table]] end return self end function M:set(key, value) self.data[key] = value end function M:get(key) return self.data[key] end function M:get_all() return self.data end function M:close() vim.fn.mkdir(vim.fn.fnamemodify(self.path, ":h"), "p") local stat = uv.fs_stat(self.path) -- check if the file was modified since we loaded it if self.loaded_time > 0 and stat and stat.mtime.sec > self.loaded_time then return end local entries = {} ---@type snacks.picker.KeyValue.entry[] for k, v in pairs(self.data) do table.insert(entries, { key = k, value = v }) end table.sort(entries, self.cmp) self.data = {} for i = 1, math.min(#entries, self.max_size) do local entry = entries[i] self.data[entry.key] = entry.value end local data = require("string.buffer").encode(self.data) local fd = io.open(self.path, "w+b") if not fd then return end fd:write(data) fd:close() end return M ================================================ FILE: lua/snacks/picker/util/markdown.lua ================================================ local M = {} local ns = vim.api.nvim_create_namespace("snacks.picker.util.markdown") local did_setup = false ---@private local function setup() if did_setup then return end did_setup = true -- trigger plugin loading if available pcall(require, "render-markdown") pcall(require, "markview") end ---@param buf number ---@param opts? {images: boolean, bullets?: boolean} function M.render(buf, opts) setup() opts = opts or {} local ft = vim.bo[buf].filetype if not ft:find("^markdown") then ft = ft:gsub("%.?markdown%.?", "") -- set filetype to markdown but preserve existing ft as a suffix -- use eventignore to avoid triggering autocmds local ei = vim.o.eventignore vim.o.eventignore = "all" vim.bo[buf].filetype = table.concat({ "markdown", ft ~= "" and ft or "" }, ".") vim.o.eventignore = ei end if not pcall(vim.treesitter.start, buf, "markdown") then vim.bo[buf].syntax = "markdown" end if opts.images ~= false then vim.b[buf].snacks_image_conceal = true Snacks.image.doc.attach(buf) end if package.loaded["render-markdown"] then require("render-markdown").render({ buf = buf, event = "Snacks", config = { render_modes = true, bullet = { enabled = opts.bullets ~= false }, }, }) elseif package.loaded["markview"] then local render = require("markview").strict_render render:render(buf, nil, { markdown = { list_items = { enable = opts.bullets ~= false, }, }, }) else M.render_fallback(buf) end end -- Fallback simple highlighting for headings and horizontal rules ---@param buf number function M.render_fallback(buf) vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) for l, line in ipairs(lines) do local _, level = line:find("^#+()") if level then vim.api.nvim_buf_set_extmark(buf, ns, l - 1, 0, { line_hl_group = "@markup.heading." .. tostring(level) .. ".markdown", }) elseif line:find("^%-%-%-+%s*$") then vim.api.nvim_buf_set_extmark(buf, ns, l - 1, 0, { virt_text_win_col = 0, virt_text = { { string.rep("-", vim.go.columns), "SnacksPickerRule" } }, priority = 100, }) end end end return M ================================================ FILE: lua/snacks/picker/util/minheap.lua ================================================ ---@class snacks.picker.MinHeap ---@field data any[] -- the heap array ---@field cmp fun(a:any, b:any):boolean -- determines "priority"; if cmp(a,b) == true, a is considered 'larger' for top-k ---@field capacity number ---@field sorted? snacks.picker.Item[] local M = {} M.__index = M ---@class snacks.picker.minheap.Config ---@field cmp? fun(a, b):boolean ---@field capacity number ---@param opts? snacks.picker.minheap.Config function M.new(opts) opts = opts or {} local self = setmetatable({}, M) -- Default comparator means: a > b => a is 'better' (we want the top by value) -- So if we want the top K largest items, the heap is min-heap based on that comparator self.cmp = opts.cmp or function(a, b) return a > b end self.capacity = assert(opts.capacity, "capacity is required") assert(self.capacity > 0, "capacity must be greater than 0") self.data = {} return self end function M:clear() self.data = {} self.sorted = nil end -- Private: swap two indices function M:_swap(i, j) self.data[i], self.data[j] = self.data[j], self.data[i] end -- Private: heapify up (bubble up) function M:_heapify_up(idx) while idx > 1 do local parent = math.floor(idx / 2) -- If child is 'less' than parent under the min-heap logic, swap -- Because self.cmp(child, parent) == true => child is 'bigger' => for min-heap we want bigger below -- So we invert self.cmp because we want to keep the smallest at top: if self.cmp(self.data[parent], self.data[idx]) then self:_swap(parent, idx) idx = parent else break end end end -- Private: heapify down function M:_heapify_down(idx) local size = #self.data while true do local left = 2 * idx local right = left + 1 local smallest = idx if left <= size and self.cmp(self.data[smallest], self.data[left]) then smallest = left end if right <= size and self.cmp(self.data[smallest], self.data[right]) then smallest = right end if smallest ~= idx then self:_swap(idx, smallest) idx = smallest else break end end end --- Insert value into the min-heap of capacity K. --- If the heap is not full, just insert. --- If it's full and the value is 'larger' than the min (root), replace the root & heapify. ---@generic T ---@param value T ---@return boolean added, T? evicted function M:add(value) local size = #self.data if size < self.capacity then -- Just insert at the end, heapify up table.insert(self.data, value) self:_heapify_up(#self.data) self.sorted = nil return true else -- If new value is larger than the root (which is the smallest in the min-heap), -- then pop root & insert new value if self.cmp(value, self.data[1]) then local evicted = self.data[1] self.data[1] = value self:_heapify_down(1) self.sorted = nil return true, evicted end end return false end function M:count() return #self.data end ---@return any|nil function M:min() return self.data[1] end ---@return any|nil function M:max() -- might need to scan if you want the max element in a min-heap local size = #self.data if size == 0 then return nil end local maximum = self.data[1] for i = 2, size do if self.cmp(self.data[i], maximum) then maximum = self.data[i] end end return maximum end ---@param idx number ---@return snacks.picker.Item? ---@overload fun(self: snacks.picker.MinHeap): snacks.picker.Item[] function M:get(idx) if not self.sorted then self.sorted = {} for i = 1, #self.data do table.insert(self.sorted, self.data[i]) end table.sort(self.sorted, self.cmp) end if idx then return self.sorted[idx] end return self.sorted end return M ================================================ FILE: lua/snacks/picker/util/queue.lua ================================================ --- Efficient queue implementation. --- Prevents need to shift elements when popping. ---@class snacks.picker.queue ---@field queue any[] ---@field first number ---@field last number local M = {} M.__index = M function M.new() local self = setmetatable({}, M) self:clear() return self end function M:push(value) self.last = self.last + 1 self.queue[self.last] = value end function M:size() return self.last - self.first + 1 end function M:empty() return self:size() == 0 end function M:clear() self.first, self.last, self.queue = 0, -1, {} end function M:pop() if self:empty() then return end local value = self.queue[self.first] self.queue[self.first] = nil self.first = self.first + 1 return value end return M ================================================ FILE: lua/snacks/picker/util/spinner.lua ================================================ ---@class snacks.util.spinner.Opts ---@field extmark? fun(spinner:string): vim.api.keyset.set_extmark ---@class snacks.util.Spinner ---@field buf number ---@field opts snacks.util.spinner.Opts ---@field timer? uv.uv_timer_t ---@field extmark_id? number local M = {} M.__index = M local ns = vim.api.nvim_create_namespace("snacks.picker.util.spinner") ---@param opts? snacks.util.spinner.Opts ---@param buf number function M.new(buf, opts) local self = setmetatable({}, M) self.buf = buf self.opts = opts or {} self:start() return self end function M:start() if self:running() then return end self:stop() if not self:buf_valid() then return end self.timer = assert(vim.uv.new_timer()) self.timer:start(0, 60, function() vim.schedule(function() self:step() end) end) end function M:buf_valid() return self.buf and vim.api.nvim_buf_is_valid(self.buf) end function M:step() if not self:running() then return end if not self:buf_valid() then return self:stop() end local lines = vim.api.nvim_buf_get_lines(self.buf, 0, -1, false) local row = math.max(#lines - 1, 0) while row > 0 and lines[row + 1]:match("^%s*$") do row = row - 1 end local spinner = Snacks.util.spinner() ---@type vim.api.keyset.set_extmark local extmark = {} if type(self.opts.extmark) == "function" then extmark = self.opts.extmark(spinner) else if row > 0 then extmark.virt_lines = { { { spinner, "SnacksPickerSpinner" } } } else extmark.virt_text = { { spinner, "SnacksPickerSpinner" } } end end extmark.id = self.extmark_id extmark.priority = 1000 self.extmark_id = vim.api.nvim_buf_set_extmark(self.buf, ns, row, 0, extmark) end function M:running() return self.timer and not self.timer:is_closing() end function M:stop() if self.timer and not self.timer:is_closing() then self.timer:stop() self.timer:close() self.timer = nil end if self:buf_valid() then vim.api.nvim_buf_clear_namespace(self.buf, ns, 0, -1) end end ---@param msg? string ---@param opts? snacks.win.Config function M.loading(msg, opts) opts = opts or {} local parent_win = opts.win or vim.api.nvim_get_current_win() msg = msg or "Loading..." msg = " " .. msg opts = Snacks.win.resolve({ backdrop = false, win = vim.api.nvim_get_current_win(), focusable = false, enter = false, relative = "win", zindex = (vim.api.nvim_win_get_config(parent_win).zindex or 50) + 1, width = vim.api.nvim_strwidth(msg) + 1, height = 1, border = "rounded", text = msg, }, opts) local win = Snacks.win(opts) local spinner ---@type snacks.util.Spinner win:on("WinClosed", function(_, ev) if ev.match == tostring(parent_win) then win:close() spinner:stop() end end) spinner = M.new(win.buf, { extmark = function(text) return { virt_text = { { text, "SnacksPickerSpinner" } }, virt_text_pos = "overlay", virt_text_win_col = 1, } end, }) local stop = spinner.stop spinner.stop = function() stop(spinner) win:close() end return spinner end return M ================================================ FILE: lua/snacks/profiler/core.lua ================================================ ---@class snacks.profiler.core local M = {} local hrtime = (vim.uv or vim.loop).hrtime local nvim_create_autocmd = vim.api.nvim_create_autocmd M._require = _G.require M.attached = {} ---@type table M.events = {} ---@type snacks.profiler.Event[] M.filter_fn = error ---@type fun(str:string):boolean M.filter_mod = error ---@type fun(str:string):boolean M.id = 0 M.me = debug.getinfo(1, "S").source:sub(2) M.pids = {} ---@type table M.running = false M.skips = { -- these modules are always be skipped ["_G"] = true, ["bit"] = true, ["coroutine"] = true, ["debug"] = true, ["ffi"] = true, ["io"] = true, ["jit"] = true, ["jit.opt"] = true, ["jit.profile"] = true, ["lpeg"] = true, ["luv"] = true, ["math"] = true, ["mpack"] = true, ["os"] = true, ["package"] = true, ["snacks.debug"] = true, ["snacks.profiler"] = true, ["snacks.profiler.core"] = true, ["snacks.profiler.loc"] = true, ["snacks.profiler.picker"] = true, ["snacks.profiler.tracer"] = true, ["snacks.profiler.ui"] = true, ["string"] = true, ["table"] = true, } function M.skip(it) M.attached[it] = true end ---@param spec table ---@return fun(str:string):boolean function M.filter(spec) local filters = {} ---@type {pattern:string, want:boolean, exact:boolean}[] local default = spec.default default = default == nil and true or default for pattern, want in pairs(spec) do if pattern ~= "default" then table.insert(filters, { pattern = pattern, want = want, exact = pattern:sub(1, 1) ~= "^" }) end end -- sort by longest pattern first table.sort(filters, function(a, b) return #a.pattern > #b.pattern end) return function(str) for _, filter in ipairs(filters) do if filter.exact then if str == filter.pattern then return filter.want end elseif str:find(filter.pattern) then return filter.want end end return default end end ---@param opts snacks.profiler.Trace.opts ---@param caller? snacks.profiler.Loc ---@return ... function M.trace(opts, caller, ...) local start = hrtime() local thread = tostring(coroutine.running() or "main") local pid = M.pids[thread] or 0 M.id = M.id + 1 M.pids[thread] = M.id ---@type snacks.profiler.Event local entry = { id = M.id, start = start, pid = pid, ref = caller, opts = opts } M.events[#M.events + 1] = entry local ret = { pcall(opts.fn, ...) } M.pids[thread] = pid entry.stop = hrtime() if not ret[1] then error(ret[2]) end return select(2, unpack(ret)) end ---@param depth? number ---@param max_depth? number ---@return snacks.profiler.Loc? function M.caller(depth, max_depth) for i = depth or 3, max_depth or 10 do local info = debug.getinfo(i, "Sl") if not info then return end local source = info.source:sub(2) if info.what ~= "C" and source ~= M.me then return { file = source, line = info.currentline } end end end ---@param opts snacks.profiler.Trace.opts function M.attach_fn(opts) if M.attached[opts.fn] then return opts.fn end M.attached[opts.fn] = true local ret = function(...) if not M.running then return opts.fn(...) end return M.trace(opts, M.caller() or nil, ...) end M.attached[ret] = true return ret end ---@param modname string ---@param mod table ---@param opts? {force?:boolean} function M.attach_mod(modname, mod, opts) if type(mod) ~= "table" or M.attached[mod] then return end opts = opts or {} if (M.skips[modname] or not M.filter_mod(modname)) and opts.force ~= true then return end M.attached[mod] = true for k, v in pairs(mod) do if type(k) == "string" and type(v) == "function" and not M.attached[v] then local name = modname .. "." .. k if M.filter_fn(name) then mod[k] = M.attach_fn({ modname = modname, fname = k, name = name, fn = v }) end end end end function M.require(modname) if not M.running or package.loaded[modname] or M.skips[modname] then return M._require(modname) end local ret = { M.trace({ fname = "require", name = "require:" .. modname, require = modname, fn = M._require, }, M.caller(), modname), } if type(ret[1]) == "table" then M.attach_mod(modname, ret[1]) end return unpack(ret) end ---@param event any (string|array) Event(s) that will trigger the handler (`callback` or `command`). ---@param opts vim.api.keyset.create_autocmd Options dict: function M.autocmd(event, opts) if opts and type(opts.callback) == "function" then local name = { type(event) == "string" and event or table.concat(event, "|") } if opts.pattern then name[#name + 1] = type(opts.pattern) == "string" and opts.pattern or table.concat(opts.pattern, "|") end local autocmd = table.concat(name, ":") local trace = { name = "autocmd:" .. autocmd, fn = opts.callback, autocmd = autocmd } opts.callback = function(...) if not M.running then return trace.fn(...) end return M.trace(trace, M.caller(), ...) end end return nvim_create_autocmd(event, opts) end ---@param opts snacks.profiler.Config function M.start(opts) assert(not M.running, "Profiler is already enabled") -- Clear events M.events = {} -- Setup filters and include globals local filter_mod = vim.deepcopy(opts.filter_mod) for _, global in ipairs(opts.globals) do filter_mod[global] = true end M.filter_mod = M.filter(filter_mod) M.filter_fn = M.filter(opts.filter_fn) -- Attach to require _G.require = M.require -- Attach to autocmds if opts.autocmds then vim.api.nvim_create_autocmd = M.autocmd end -- Attach to globals for _, name in ipairs(opts.globals) do M.attach_mod(name, vim.tbl_get(_G, unpack(vim.split(name, ".", { plain = true })))) end -- Attach to loaded modules ---@diagnostic disable-next-line: no-unknown for modname, mod in pairs(package.loaded) do M.attach_mod(modname, mod) end -- Enable the profiler M.running = true vim.api.nvim_exec_autocmds("User", { pattern = "SnacksProfilerStarted", modeline = false }) end function M.stop() assert(M.running, "Profiler is not enabled") _G.require = M._require vim.api.nvim_create_autocmd = nvim_create_autocmd M.running = false vim.api.nvim_exec_autocmds("User", { pattern = "SnacksProfilerStopped", modeline = false }) end return M ================================================ FILE: lua/snacks/profiler/init.lua ================================================ require("snacks") -- ### Traces -- ---@class snacks.profiler.Trace ---@field name string fully qualified name of the function ---@field time number time in nanoseconds ---@field depth number stack depth ---@field [number] snacks.profiler.Trace child traces ---@field fname string function name ---@field fn function function reference ---@field modname? string module name ---@field require? string special case for require ---@field autocmd? string special case for autocmd ---@field count? number number of calls ---@field def? snacks.profiler.Loc location of the definition ---@field ref? snacks.profiler.Loc location of the reference (caller) ---@field loc? snacks.profiler.Loc normalized location ---@class snacks.profiler.Loc ---@field file string path to the file ---@field line number line number ---@field loc? string normalized location ---@field modname? string module name ---@field plugin? string plugin name -- ### Pick: grouping, filtering and sorting -- ---@class snacks.profiler.Find ---@field structure? boolean show traces as a tree or flat list ---@field sort? "time"|"count"|false sort by time or count, or keep original order ---@field loc? "def"|"ref" what location to show in the preview ---@field group? boolean|snacks.profiler.Field group traces by field ---@field filter? snacks.profiler.Filter filter traces by field(s) ---@field min_time? number only show grouped traces with `time >= min_time` ---@class snacks.profiler.Pick: snacks.profiler.Find ---@field picker? snacks.profiler.Picker ---@alias snacks.profiler.Picker "snacks"|"trouble" ---@alias snacks.profiler.Pick.spec snacks.profiler.Pick|{preset?:string}|fun():snacks.profiler.Pick ---@alias snacks.profiler.Field ---| "name" fully qualified name of the function ---| "def" definition ---| "ref" reference (caller) ---| "require" require ---| "autocmd" autocmd ---| "modname" module name of the called function ---| "def_file" file of the definition ---| "def_modname" module name of the definition ---| "def_plugin" plugin that defines the function ---| "ref_file" file of the reference ---| "ref_modname" module name of the reference ---| "ref_plugin" plugin that references the function ---@class snacks.profiler.Filter ---@field name? string|boolean fully qualified name of the function ---@field def? string|boolean location of the definition ---@field ref? string|boolean location of the reference (caller) ---@field require? string|boolean special case for require ---@field autocmd? string|boolean special case for autocmd ---@field modname? string|boolean module name ---@field def_file? string|boolean file of the definition ---@field def_modname? string|boolean module name of the definition ---@field def_plugin? string|boolean plugin that defines the function ---@field ref_file? string|boolean file of the reference ---@field ref_modname? string|boolean module name of the reference ---@field ref_plugin? string|boolean plugin that references the function -- ### UI -- ---@alias snacks.profiler.Badge {icon:string, text:string, padding?:boolean, level?:string} ---@alias snacks.profiler.Badge.type "time"|"pct"|"count"|"name"|"trace" ---@class snacks.profiler.Highlights ---@field min_time? number only highlight entries with time >= min_time ---@field max_shade? number -- time in ms for the darkest shade ---@field badges? snacks.profiler.Badge.type[] badges to show ---@field align? "right"|"left"|number align the badges right, left or at a specific column -- ### Other -- ---@class snacks.profiler.Startup ---@field event? string ---@field pattern? string|string[] pattern to match for the autocmd ---@alias snacks.profiler.GroupFn fun(entry:snacks.profiler.Trace):{key:string, name?:string}? ---@class snacks.profiler ---@field core snacks.profiler.core ---@field loc snacks.profiler.loc ---@field tracer snacks.profiler.tracer ---@field ui snacks.profiler.ui ---@field picker snacks.profiler.picker local M = {} M.meta = { desc = "Neovim lua profiler", } local mods = { core = true, loc = true, tracer = true, ui = true, picker = true } setmetatable(M, { __index = function(t, k) if mods[k] then ---@diagnostic disable-next-line: no-unknown t[k] = require("snacks.profiler." .. k) end return rawget(t, k) end, }) ---@class snacks.profiler.Config local defaults = { autocmds = true, runtime = vim.env.VIMRUNTIME, ---@type string -- thresholds for buttons to be shown as info, warn or error -- value is a tuple of [warn, error] thresholds = { time = { 2, 10 }, pct = { 10, 20 }, count = { 10, 100 }, }, on_stop = { highlights = true, -- highlight entries after stopping the profiler pick = true, -- show a picker after stopping the profiler (uses the `on_stop` preset) }, ---@type snacks.profiler.Highlights highlights = { min_time = 0, -- only highlight entries with time > min_time (in ms) max_shade = 20, -- time in ms for the darkest shade badges = { "time", "pct", "count", "trace" }, align = 80, }, pick = { picker = "snacks", ---@type snacks.profiler.Picker ---@type snacks.profiler.Badge.type[] badges = { "time", "count", "name" }, ---@type snacks.profiler.Highlights preview = { badges = { "time", "pct", "count" }, align = "right", }, }, startup = { event = "VimEnter", -- stop profiler on this event. Defaults to `VimEnter` after = true, -- stop the profiler **after** the event. When false it stops **at** the event pattern = nil, -- pattern to match for the autocmd pick = true, -- show a picker after starting the profiler (uses the `startup` preset) }, ---@type table presets = { startup = { min_time = 1, sort = false }, on_stop = {}, filter_by_plugin = function() return { filter = { def_plugin = vim.fn.input("Filter by plugin: ") } } end, }, ---@type string[] globals = { -- "vim", -- "vim.api", -- "vim.keymap", -- "Snacks.dashboard.Dashboard", }, -- filter modules by pattern. -- longest patterns are matched first filter_mod = { default = true, -- default value for unmatched patterns ["^vim%."] = false, ["mason-core.functional"] = false, ["mason-core.functional.data"] = false, ["mason-core.optional"] = false, ["which-key.state"] = false, }, filter_fn = { default = true, ["^.*%._[^%.]*$"] = false, ["trouble.filter.is"] = false, ["trouble.item.__index"] = false, ["which-key.node.__index"] = false, ["smear_cursor.draw.wo"] = false, ["^ibl%.utils%."] = false, }, -- stylua: ignore icons = { time = " ", pct = " ", count = " ", require = "󰋺 ", modname = "󰆼 ", plugin = " ", autocmd = "⚡", file = " ", fn = "󰊕 ", status = "󰈸 ", }, debug = false, } M.config = Snacks.config.get("profiler", defaults) local attached_debug = false local loaded = false -- Toggle the profiler function M.toggle() if M.core.running then M.stop() else M.start() end return M.core.running end -- Statusline component function M.status() return { function() return ("%s %d events"):format(M.config.icons.status, #M.core.events) end, color = "DiagnosticError", cond = function() return M.core.running end, } end -- Start the profiler ---@param opts? snacks.profiler.Config function M.start(opts) if M.core.running then return Snacks.notify.warn("Profiler is already enabled") end M.config = Snacks.config.get("profiler", defaults, opts) M.highlight(false) M.core.start(M.config) end local function load() if loaded then return end loaded = true M.tracer.load() -- load traces M.loc.load() -- add and normalize locations M.ui.load() -- load highlights vim.api.nvim_exec_autocmds("User", { pattern = "SnacksProfilerLoaded", modeline = false }) end -- Stop the profiler ---@param opts? {highlights?:boolean, pick?:snacks.profiler.Pick.spec} function M.stop(opts) if not M.core.running then return Snacks.notify.warn("Profiler is not enabled") end M.core.stop() opts = vim.tbl_extend("force", {}, M.config.on_stop, opts or {}) if opts.pick == true then opts.pick = M.config.presets.on_stop or {} elseif opts.pick == false then opts.pick = nil end loaded = false vim.schedule(function() load() if opts.highlights then M.highlight(true) end if opts.pick then M.pick(opts.pick) end end) end -- Check if the profiler is running function M.running() return M.core.running end -- Profile the profiler ---@private function M.debug() if not M.core.running then return Snacks.notify.warn("Profiler is not enabled") end if loaded then return Snacks.notify.warn("Profiler is already loaded") end if not attached_debug then attached_debug = true M.core.skip(M.core.caller) M.core.skip(M.core.trace) M.core.skip(M.loc.loc) M.core.skip(M.loc.norm) M.core.skip(M.loc.realpath) M.core.attach_mod("vim.fs", vim.fs, { force = true }) M.core.attach_mod("snacks.profiler", M, { force = true }) for mod in pairs(mods) do M.core.attach_mod("snacks.profiler." .. mod, M[mod], { force = true }) end end local event_count = #M.core.events local me = M.core.me M.core.me = "__ignore__" load() M.pick({ picker = "foo", group = "name", structure = true }) M.core.events = vim.list_slice(M.core.events, event_count) loaded = false M.stop() M.core.me = me end -- Group and filter traces ---@param opts snacks.profiler.Find function M.find(opts) load() return M.tracer.find(opts) end -- Group and filter traces and open a picker ---@param opts? snacks.profiler.Pick.spec function M.pick(opts) load() if type(opts) == "function" then opts = opts() if not opts then return end end opts = opts or {} if opts.preset then local preset = M.config.presets[opts.preset] preset = type(preset) == "function" and preset() if not preset then return end opts = vim.tbl_deep_extend("force", {}, preset, opts) end ---@cast opts snacks.profiler.Pick return M.picker.open(opts) end --- Open a scratch buffer with the profiler picker options function M.scratch() return Snacks.scratch({ ft = "lua", icon = " ", name = "Profiler Picker Options", template = ("---@module 'snacks'\n\nSnacks.profiler.pick(%s)"):format(vim.inspect({ structure = true, group = "name", sort = "time", min_time = 1, })), }) end -- Start the profiler on startup, and stop it after the event has been triggered. ---@param opts snacks.profiler.Config function M.startup(opts) M.config = Snacks.config.get("profiler", defaults, opts) local event, pattern = M.config.startup.event or "VimEnter", M.config.startup.pattern if event == "VeryLazy" then event, pattern = "User", event end local cb = function() local pick = M.config.startup.pick and M.config.presets.startup Snacks.profiler.stop({ pick = pick }) end if M.config.startup.after then cb = vim.schedule_wrap(cb) end vim.api.nvim_create_autocmd(event, { pattern = pattern, once = true, callback = cb }) M.start(opts) end -- Toggle the profiler highlights ---@param enable? boolean function M.highlight(enable) if enable == nil then enable = not M.ui.enabled end if enable == M.ui.enabled then return end if enable then load() M.ui.show() else M.ui.hide() end end return M ================================================ FILE: lua/snacks/profiler/loc.lua ================================================ ---@class snacks.profiler.loc ---@field vim_runtime string ---@field user_runtime string ---@field user_config string local M = {} local fun_cache = {} ---@type table local norm_cache = {} ---@type table> local path_cache = {} ---@type table local ts_cache = {} ---@type table> local ts_query ---@type vim.treesitter.Query? -- add and normalize locations function M.load() local opts = Snacks.profiler.config M.vim_runtime = M.realpath(vim.env.VIMRUNTIME) M.user_runtime = M.realpath(opts.runtime or M.vim_runtime) M.user_config = M.realpath(vim.fn.stdpath("config") .. "") Snacks.profiler.tracer.walk(function(entry) entry.def = M.loc(entry) entry.ref = entry.ref and M.norm(entry.ref) or nil end) end -- Get the location at the cursor function M.current() local cursor = vim.api.nvim_win_get_cursor(0) return M.norm({ file = vim.api.nvim_buf_get_name(0), line = cursor[1] }) end --- Get the real path of a file ---@param path string function M.realpath(path) if path_cache[path] then return path_cache[path] end path = svim.fs.normalize(path, { expand_env = false }) path_cache[path] = svim.fs.normalize(vim.uv.fs_realpath(path) or path, { expand_env = false, _fast = true }) return path_cache[path] end ---@param loc snacks.profiler.Loc function M.norm(loc) local file, line = loc.file, loc.line local ret = norm_cache[file] and norm_cache[file][line] if not ret then ret = M._norm(loc) norm_cache[file] = norm_cache[file] or {} norm_cache[file][line] = ret end return ret end ---@param loc snacks.profiler.Loc function M._norm(loc) if loc.file:sub(1, 4) == "vim/" then loc.file = M.user_runtime .. "/lua/" .. loc.file elseif loc.file:find("runtime", 1, true) then if loc.file:sub(1, #M.vim_runtime) == M.vim_runtime then loc.file = M.user_runtime .. "/" .. loc.file:sub(#M.vim_runtime + 2) end end loc.file = M.realpath(loc.file) loc.line = loc.line == 0 and 1 or loc.line loc.loc = ("%s:%d"):format(loc.file, loc.line) if loc.file:find(M.user_config, 1, true) == 1 then local relpath = loc.file:sub(#M.user_config + 2) local modpath = relpath:match("^lua/(.*)%.lua$") loc.modname = modpath and modpath:gsub("/", "."):gsub("%.init$", "") or "vimrc" loc.plugin = "user" else local plugin, modpath = loc.file:match("/([^/]+)/lua/(.*)%.lua$") if plugin and modpath then plugin = plugin == "runtime" and "nvim" or plugin loc.plugin = plugin loc.modname = modpath:gsub("/", "."):gsub("%.init$", "") end end return loc end ---@param entry snacks.profiler.Trace ---@return snacks.profiler.Loc? function M.loc(entry) local ret = fun_cache[entry.fn] if ret == nil then local info = debug.getinfo(entry.fn, "S") if info and info.what ~= "C" then ret = { file = info.source:sub(2), line = info.linedefined } if entry.fname and ret.file:sub(1, 4) == "vim/" then ret.file = M.user_runtime .. "/lua/" .. ret.file local ts_loc = M.ts_locs(ret.file)[entry.fname] if ts_loc then ret.file, ret.line = ts_loc.file, ts_loc.line end end ret = M.norm(ret) end fun_cache[entry.fn] = ret or false end return ret or nil end ---@param file string function M.ts_locs(file) if ts_cache[file] then return ts_cache[file] end ts_query = ts_query or vim.treesitter.query.parse( "lua", [[((function_declaration name: (_) @fun_name) @fun (#has-parent? @fun chunk)) ((return_statement (expression_list (identifier) @ret_name)) @ret (#has-parent? @ret chunk))]] ) local source = table.concat(vim.fn.readfile(file), "\n") local parser = vim.treesitter.get_string_parser(source, "lua") parser:parse() local ret, ret_name = {}, nil ---@type table, string? local funs = {} ---@type table for id, node in ts_query:iter_captures(parser:trees()[1]:root(), source) do local name = ts_query.captures[id] if name == "fun_name" then funs[vim.treesitter.get_node_text(node, source)] = node:start() + 1 elseif name == "ret_name" then ret_name = vim.treesitter.get_node_text(node, source) end end for fname, line in pairs(funs) do fname = ret_name and fname:gsub("^" .. ret_name .. "%.", "") or fname ret[fname] = { file = file, line = line } end ts_cache[file] = ret return ret end return M ================================================ FILE: lua/snacks/profiler/picker.lua ================================================ ---@class snacks.profiler.picker local M = {} ---@param opts? snacks.profiler.Pick function M.open(opts) opts = opts or {} local picker = opts and opts.picker or Snacks.profiler.config.pick.picker -- special case for trouble, since it does its own thing if picker == "trouble" then return require("trouble").open({ mode = "profiler", params = opts }) end local traces, _, fopts = Snacks.profiler.tracer.find(opts) return Snacks.picker({ title = "Snacks Profiler", finder = function() local items = {} ---@type snacks.picker.finder.Item[] for _, trace in ipairs(traces) do items[#items + 1] = { text = trace.name, file = trace.loc and trace.loc.file, pos = trace.loc and { trace.loc.line, 0 }, item = trace, } end return items end, format = function(item) ---@type snacks.profiler.Trace local trace = item.item local ret = Snacks.profiler.ui.format( Snacks.profiler.ui.badges(trace, { badges = Snacks.profiler.config.pick.badges, indent = fopts.group == false or fopts.structure, }), { widths = { 8, 4, 1 } } ) for _, text in ipairs(ret) do if text[2] == "Normal" or text[2] == "SnacksProfilerBadgeTrace" then text[2] = nil end end return ret end, preview = function(ctx) Snacks.picker.preview.file(ctx) Snacks.util.wo(ctx.win, { cursorline = true }) Snacks.profiler.ui.highlight( ctx.buf, vim.tbl_extend("force", {}, Snacks.profiler.config.pick.preview, { file = ctx.item.file }) ) end, }) end return M ================================================ FILE: lua/snacks/profiler/tracer.lua ================================================ ---@alias snacks.profiler.Trace.opts snacks.profiler.Trace|{id?:number, pid?:number, time?:number, depth?:number} ---@alias snacks.profiler.Event {id:number, pid:number, start:number, stop:number, measurements:number, ref?:snacks.profiler.Loc, idx:number, opts:snacks.profiler.Trace.opts} ---@alias snacks.profiler.Node {group:string, trace:snacks.profiler.Trace, children:table, order:(string|number)[]} ---@class snacks.profiler.tracer local M = {} M.root = {} ---@type snacks.profiler.Trace[] function M.load() M.root = {} local traces = {} ---@type snacks.profiler.Trace[] for _, event in ipairs(Snacks.profiler.core.events) do local trace = setmetatable({}, { __index = event.opts }) trace.id = event.id trace.pid = event.pid trace.ref = event.ref trace.depth = 0 if event.stop then trace.time = event.stop - event.start traces[event.id] = trace if traces[event.pid] then trace.depth = traces[event.pid].depth + 1 table.insert(traces[event.pid], trace) elseif trace.time then table.insert(M.root, trace) end end end end ---@param on_start? fun(entry:snacks.profiler.Trace):any? ---@param on_end? fun(entry:snacks.profiler.Trace, start?:any) function M.walk(on_start, on_end) ---@param entry snacks.profiler.Trace local function walk(entry) local start = on_start and on_start(entry) for _, child in ipairs(entry) do walk(child) end if on_end then on_end(entry, start) end end for _, child in ipairs(M.root) do walk(child) end end ---@param fn snacks.profiler.GroupFn ---@param opts? {structure?:boolean, sort?:"time"|"count"} function M.group(fn, opts) opts = opts or {} ---@type snacks.profiler.Node[] local nodes = { { children = {}, order = {} } } -- root node ---@param entry snacks.profiler.Trace M.walk(function(entry) local group = fn(entry) if group then local key, parent, recursive = group.key, nodes[1], false for n = 2, #nodes do local node = nodes[n] if node.group == key then recursive = true break end parent = opts.structure and node or parent end local node = parent.children[key] if not node then local trace = vim.tbl_extend("force", { time = 0, count = 0, name = key, depth = #nodes - 1 }, group) node = { group = key, trace = trace, children = {}, order = {} } ---@type snacks.profiler.Node ---@diagnostic disable-next-line: no-unknown parent.children[key] = node table.insert(parent.order, key) end if not recursive then table.insert(nodes, node) node.trace.time = node.trace.time + entry.time end node.trace.count = node.trace.count + 1 table.insert(node.trace, entry) return not recursive end end, function(_, start) if start then table.remove(nodes) end end) assert(#nodes == 1, "node stack not empty") return nodes[1] end ---@param node snacks.profiler.Node ---@param opts? snacks.profiler.Find function M.flatten(node, opts) opts = opts or {} local ret = {} ---@type snacks.profiler.Trace[] ---@param n snacks.profiler.Node local function walk(n) if n.trace and (n.trace.time / 1e6 >= (opts.min_time or 0)) then table.insert(ret, n.trace) end if opts.sort then local children = vim.tbl_values(n.children) ---@type snacks.profiler.Node[] if opts.sort == "time" then table.sort(children, function(a, b) return a.trace.time > b.trace.time end) elseif opts.sort == "count" then table.sort(children, function(a, b) return a.trace.count > b.trace.count end) end for _, child in ipairs(children) do walk(child) end else for _, key in ipairs(n.order) do walk(n.children[key]) end end end walk(node) return ret end ---@param opts snacks.profiler.Find function M.find(opts) opts = opts or {} opts = vim.tbl_extend("force", { group = "name", structure = opts.group ~= false, sort = (opts.group ~= false) and "time", }, opts or {}) opts.group = opts.group == true and "name" or opts.group opts.sort = opts.sort == true and "time" or opts.sort ---@cast opts snacks.profiler.Find local key_parts = {} ---@type table local id = 0 ---@param entry snacks.profiler.Trace ---@param key string|false local function get(entry, key) if key == false then id = id + 1 return tostring(id), entry.name end local parts = key_parts[key] if not parts then parts = vim.split(key, "[_%.]") if #parts == 1 and (parts[1] == "ref" or parts[1] == "def") then parts[2] = "loc" end key_parts[key] = parts end local value = vim.tbl_get(entry, unpack(parts)) ---@type string? if not value then return end local name, loc = value, entry.def if parts[1] == "ref" or parts[1] == "require" then loc = entry.ref elseif parts[1] == "name" and entry.require then loc = entry.ref end if parts[2] == "def" or parts[1] == "name" then name = entry.name else name = parts[#parts] .. ":" .. value end return value, name, loc end -- Build the filter local filter = {} ---@type table local current ---@type snacks.profiler.Trace? for k, v in pairs(opts.filter or {}) do if v == true then -- If the value is true, then we want the current location if k:find("[rd]ef") then if not current then local loc = Snacks.profiler.loc.current() ---@diagnostic disable-next-line: missing-fields current = { def = loc, ref = loc } end v = get(current, k) or false else -- match all v = "^.*$" end end filter[k] = v end ---@param entry snacks.profiler.Trace local function match(entry) for key, m in pairs(filter) do local value = get(entry, key) or false if type(m) == "string" and m:sub(1, 1) == "^" then if not (value and value:find(m)) then return false end elseif value ~= m then return false end end return true end ---@type snacks.profiler.GroupFn local group_fn = function(entry) if opts.filter and not match(entry) then return end local key, name, loc = get(entry, opts.group --[[@as string|false]]) if key then loc = opts.loc and entry[opts.loc] or loc or entry.def or entry.ref return { key = key, name = name, loc = loc, ref = entry.ref, def = entry.def } end end local node = M.group(group_fn, opts) return M.flatten(node, opts), node, opts end return M ================================================ FILE: lua/snacks/profiler/ui.lua ================================================ ---@class snacks.profiler.ui local M = {} M.highlights = {} ---@type table> M.max_time = 0 M.ns = vim.api.nvim_create_namespace("snacks_profiler") M.shades = 20 M.enabled = true M.max_time = 0 ---@type table M.badge_formats = { time = function(entry) local ms = entry.time / 1e6 return { icon = Snacks.profiler.config.icons.time, text = ("%.2f ms"):format(ms), level = M.get_level(ms, "time") } end, pct = function(entry) local pct = entry.time / M.max_time * 100 return { icon = Snacks.profiler.config.icons.pct, text = ("%d%%"):format(pct), level = M.get_level(pct, "pct") } end, count = function(entry) local count = entry.count or 1 return { icon = " ", text = ("%d"):format(count), level = M.get_level(count, "count") } end, trace = function(entry) local field, value = entry.name:match("^(%w+):(.*)$") ---@type string?, string? value = field == "file" and vim.fn.fnamemodify(value, ":~:.") or value value = field == "require" and ("require(%q)"):format(value) or value value = field == "autocmd" and ("autocmd %s"):format(value) or value value = Snacks.profiler.config.icons[field] and value or entry.name return { icon = Snacks.profiler.config.icons[field] or Snacks.profiler.config.icons.fn, text = value, padding = false, level = "Trace", } end, } function M.toggle() if M.enabled then M.hide() else M.show() end end function M.hide() assert(M.enabled, "Highlights are not enabled") M.enabled = false for _, buf in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].buftype == "" then vim.api.nvim_buf_clear_namespace(buf, M.ns, 0, -1) end end end function M.show() assert(not M.enabled, "Highlights are already enabled") M.enabled = true for _, buf in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].buftype == "" then M.highlight(buf, Snacks.profiler.config.highlights) end end vim.api.nvim_create_autocmd("BufReadPost", { group = vim.api.nvim_create_augroup("snacks_profiler_highlights", { clear = true }), callback = function(ev) if M.enabled then M.highlight(ev.buf, Snacks.profiler.config.highlights) end end, }) end ---@param trace snacks.profiler.Trace function M.dump(trace) local ret = {} ---@diagnostic disable-next-line: no-unknown for k, v in pairs(trace) do if type(k) == "string" then ---@diagnostic disable-next-line: no-unknown ret[k] = v end end return ret end function M.load() M.highlights = {} M.max_time = 10 * 1e6 M.colors() local groups = { defs = Snacks.profiler.tracer.find({ group = "def", structure = false, sort = false }), refs = Snacks.profiler.tracer.find({ group = "ref", structure = false, sort = false }), } for group, entries in pairs(groups) do for _, entry in pairs(entries) do local loc = entry.loc if loc then ---@diagnostic disable-next-line: inject-field entry._group = group M.max_time = math.max(M.max_time, entry.time) M.highlights[loc.file] = M.highlights[loc.file] or {} if Snacks.profiler.config.debug and M.highlights[loc.file][loc.line] then local old = M.highlights[loc.file][loc.line] Snacks.debug.inspect({ group = group, old = M.dump(old), new = M.dump(entry) }) end M.highlights[loc.file][loc.line] = entry end end end end function M.get_level(value, t) return value > Snacks.profiler.config.thresholds[t][2] and "Error" or value > Snacks.profiler.config.thresholds[t][1] and "Warn" or "Info" end ---@param entry snacks.profiler.Trace ---@param opts? { badges?: snacks.profiler.Badge.type[], indent?: boolean } ---@return snacks.profiler.Badge[] function M.badges(entry, opts) opts = opts or {} opts.badges = opts.badges or { "time", "pct", "count", "name", "trace" } local ret = {} ---@type snacks.profiler.Badge[] local done = {} ---@type table local indented = false for _, b in ipairs(opts.badges) do if b == "trace" or b == "name" then local entries = {} ---@type snacks.profiler.Trace[] if b == "name" then table.insert(entries, entry) end if b == "trace" then vim.list_extend(entries, entry) end for _, e in ipairs(entries) do if not done[e.name] then done[e.name] = true local badge = M.badge_formats.trace(e) if opts.indent and not indented then indented = true badge.text = (" "):rep(e.depth) .. badge.text end table.insert(ret, badge) end end else table.insert(ret, M.badge_formats[b](entry)) end end return ret end ---@param badges snacks.profiler.Badge[] ---@param opts? {widths?:number[]} function M.format(badges, opts) local text = {} ---@type string[][] text[#text + 1] = { " ", "Normal" } for b, badge in ipairs(badges) do local level = badge.level or "" local padding = badge.padding ~= false and opts and opts.widths and (opts.widths[b] - vim.api.nvim_strwidth(badge.text)) or 0 text[#text + 1] = { badge.icon, "SnacksProfilerIcon" .. level } text[#text + 1] = { " " .. (" "):rep(padding) .. badge.text .. " ", "SnacksProfilerBadge" .. level } text[#text + 1] = { " ", "Normal" } end return text end ---@param buf number ---@param opts? snacks.profiler.Highlights|{file?:string} function M.highlight(buf, opts) opts = opts or {} vim.api.nvim_buf_clear_namespace(buf, M.ns, 0, -1) local file = Snacks.profiler.loc.norm({ file = opts.file or vim.api.nvim_buf_get_name(buf), line = 0 }).file local highlights = M.highlights[file] if not highlights then return end local keep = {} ---@type table for l, entry in pairs(highlights) do if entry.time >= (opts.min_time or 0) then keep[l] = entry end end highlights = keep local align = opts.align or 80 local buttons = {} ---@type table local widths = {} ---@type number[] for line, entry in pairs(highlights) do buttons[line] = M.badges(entry, opts --[[@as snacks.profiler.Highlights]]) for b, button in ipairs(buttons[line]) do widths[b] = math.max(widths[b] or 0, vim.api.nvim_strwidth(button.text)) end end for line, entry in pairs(highlights) do local text = M.format(buttons[line], { widths = widths }) if type(align) == "number" then text[#text + 1] = { (" "):rep(vim.o.columns), "Normal" } end local mmax = math.min(M.max_time, 1e6 * Snacks.profiler.config.highlights.max_shade) vim.api.nvim_buf_set_extmark(buf, M.ns, line - 1, 0, { hl_mode = "combine", virt_text = text, virt_text_win_col = type(align) == "number" and align or nil, virt_text_pos = align == "right" and "right_align" or align == "left" and "eol" or nil, line_hl_group = ("SnacksProfilerHot%02d"):format( math.max(math.min(math.floor(entry.time / mmax * M.shades), M.shades), 1) ), }) end end function M.colors() ---@type snacks.util.hl local hl_groups = { Icon = "SnacksProfilerIconInfo", Badge = "SnacksProfilerBadgeInfo", IconTrace = "SnacksProfilerIconInfo", BadgeTrace = "SnacksProfilerBadgeInfo", } local fallbacks = { Info = "#0ea5e9", Warn = "#f59e0b", Error = "#dc2626" } local bg = Snacks.util.color("Normal", "bg") or "#000000" local red = Snacks.util.color("DiagnosticError") or fallbacks.Error for _, s in ipairs({ "Info", "Warn", "Error" }) do local color = Snacks.util.color("Diagnostic" .. s) or fallbacks[s] hl_groups["Icon" .. s] = { fg = color, bg = Snacks.util.blend(color, bg, 0.3) } hl_groups["Badge" .. s] = { fg = color, bg = Snacks.util.blend(color, bg, 0.1) } end for i = 1, M.shades do hl_groups[("Hot%02d"):format(i)] = { bg = Snacks.util.blend(red, bg, i / (M.shades + 1)) } end Snacks.util.set_hl(hl_groups, { prefix = "SnacksProfiler", managed = false, default = true }) vim.api.nvim_create_autocmd("ColorScheme", { group = vim.api.nvim_create_augroup("snacks_profiler_colors", { clear = true }), callback = M.colors, }) end return M ================================================ FILE: lua/snacks/quickfile.lua ================================================ ---@private ---@class snacks.quickfile local M = {} M.meta = { desc = "When doing `nvim somefile.txt`, it will render the file as quickly as possible, before loading your plugins.", needs_setup = true, } ---@class snacks.quickfile.Config local defaults = { -- any treesitter langs to exclude exclude = { "latex" }, } ---@private function M.setup() local opts = Snacks.config.get("quickfile", defaults) -- Skip if we already entered vim if vim.v.vim_did_enter == 1 then return end if vim.bo.filetype == "bigfile" then return end local buf = vim.api.nvim_get_current_buf() -- Try to guess the filetype (may change later on during Neovim startup) local ft = vim.filetype.match({ buf = buf }) if ft then -- Add treesitter highlights and fallback to syntax local lang = vim.treesitter.language.get_lang(ft) -- disable treesitter for some langs if vim.tbl_contains(opts.exclude, lang) then lang = nil end if not (lang and pcall(vim.treesitter.start, buf, lang)) then vim.bo[buf].syntax = ft end -- Trigger early redraw vim.cmd([[redraw]]) end end return M ================================================ FILE: lua/snacks/rename.lua ================================================ ---@class snacks.rename local M = {} M.meta = { desc = "LSP-integrated file renaming with support for plugins like [neo-tree.nvim](https://github.com/nvim-neo-tree/neo-tree.nvim) and [mini.files](https://github.com/nvim-mini/mini.files).", } -- Renames the provided file, or the current buffer's file. -- Prompt for the new filename if `to` is not provided. -- do the rename, and trigger LSP handlers ---@param opts? {from?: string, to?:string, on_rename?: fun(to:string, from:string, ok:boolean)} function M.rename_file(opts) opts = opts or {} local from = vim.fn.fnamemodify(opts.from or opts.file or vim.api.nvim_buf_get_name(0), ":p") local to = opts.to and vim.fn.fnamemodify(opts.to, ":p") or nil from, to = svim.fs.normalize(from), to and svim.fs.normalize(to) or nil local function rename() assert(to, "to is required") M.on_rename_file(from, to, function() local ok = M._rename(from, to) if opts.on_rename then opts.on_rename(to, from, ok) end end) end if to then return rename() end local root = svim.fs.normalize(vim.fn.getcwd(0)) if from:find(root, 1, true) ~= 1 then -- file is outside cwd, use its parent dir as root root = vim.fs.dirname(from) end local extra = from:sub(#root + 2) vim.ui.input({ prompt = "New File Name: ", default = extra, completion = "file", }, function(value) if not value or value == "" or value == extra then return end to = svim.fs.normalize(root .. "/" .. value) rename() end) end --- Rename a file and update buffers ---@param from string ---@param to string ---@return boolean ok function M._rename(from, to) from = vim.fn.fnamemodify(from, ":p") to = vim.fn.fnamemodify(to, ":p") vim.fn.mkdir(vim.fs.dirname(to), "p") -- ensure target directory exists -- rename the file local ret = vim.fn.rename(from, to) if ret ~= 0 then Snacks.notify.error("Failed to rename file: `" .. from .. "`") return false end -- replace buffer in all windows local from_buf = vim.fn.bufnr(from) if from_buf >= 0 then local to_buf = vim.fn.bufadd(to) vim.bo[to_buf].buflisted = true for _, win in ipairs(vim.fn.win_findbuf(from_buf)) do vim.api.nvim_win_call(win, function() vim.cmd("buffer " .. to_buf) end) end vim.api.nvim_buf_delete(from_buf, { force = true }) end return true end --- Lets LSP clients know that a file has been renamed ---@param from string ---@param to string ---@param rename? fun() function M.on_rename_file(from, to, rename) local changes = { files = { { oldUri = vim.uri_from_fname(from), newUri = vim.uri_from_fname(to), } } } local clients = (vim.lsp.get_clients or vim.lsp.get_active_clients)() for _, client in ipairs(clients) do if client.supports_method("workspace/willRenameFiles") then local resp = client.request_sync("workspace/willRenameFiles", changes, 1000, 0) if resp and resp.result ~= nil then vim.lsp.util.apply_workspace_edit(resp.result, client.offset_encoding) end end end if rename then rename() end for _, client in ipairs(clients) do if client.supports_method("workspace/didRenameFiles") then client.notify("workspace/didRenameFiles", changes) end end end return M ================================================ FILE: lua/snacks/scope.lua ================================================ ---@class snacks.scope local M = {} M.meta = { desc = "Scope detection, text objects and jumping based on treesitter or indent", needs_setup = true, } ---@class snacks.scope.Opts: snacks.scope.Config,{} ---@field buf? number ---@field pos? {[1]:number, [2]:number} -- (1,0) indexed ---@field end_pos? {[1]:number, [2]:number} -- (1,0) indexed ---@field async? boolean run scope detection asynchronously (defaults to true) ---@class snacks.scope.TextObject: snacks.scope.Opts ---@field linewise? boolean if nil, use visual mode. Defaults to `false` when not in visual mode ---@field notify? boolean show a notification when no scope is found (defaults to true) ---@class snacks.scope.Jump: snacks.scope.Opts ---@field bottom? boolean if true, jump to the bottom of the scope, otherwise to the top ---@field notify? boolean show a notification when no scope is found (defaults to true) ---@alias snacks.scope.Attach.cb fun(win: number, buf: number, scope:snacks.scope.Scope?, prev:snacks.scope.Scope?) ---@class snacks.scope.Config ---@field max_size? number ---@field enabled? boolean local defaults = { -- absolute minimum size of the scope. -- can be less if the scope is a top-level single line scope min_size = 2, -- try to expand the scope to this size max_size = nil, cursor = true, -- when true, the column of the cursor is used to determine the scope edge = true, -- include the edge of the scope (typically the line above and below with smaller indent) siblings = false, -- expand single line scopes with single line siblings -- what buffers to attach to filter = function(buf) return vim.bo[buf].buftype == "" and vim.b[buf].snacks_scope ~= false and vim.g.snacks_scope ~= false end, -- debounce scope detection in ms debounce = 30, treesitter = { -- detect scope based on treesitter. -- falls back to indent based detection if not available enabled = true, injections = true, -- include language injections when detecting scope (useful for languages like `vue`) ---@type string[]|{enabled?:boolean} blocks = { enabled = false, -- enable to use the following blocks "function_declaration", "function_definition", "method_declaration", "method_definition", "class_declaration", "class_definition", "do_statement", "while_statement", "repeat_statement", "if_statement", "for_statement", }, -- these treesitter fields will be considered as blocks field_blocks = { "local_declaration", }, }, -- These keymaps will only be set if the `scope` plugin is enabled. -- Alternatively, you can set them manually in your config, -- using the `Snacks.scope.textobject` and `Snacks.scope.jump` functions. keys = { ---@type table textobject = { ii = { min_size = 2, -- minimum size of the scope edge = false, -- inner scope cursor = false, treesitter = { blocks = { enabled = false } }, desc = "inner scope", }, ai = { cursor = false, min_size = 2, -- minimum size of the scope treesitter = { blocks = { enabled = false } }, desc = "full scope", }, }, ---@type table jump = { ["[i"] = { min_size = 1, -- allow single line scopes bottom = false, cursor = false, edge = true, treesitter = { blocks = { enabled = false } }, desc = "jump to top edge of scope", }, ["]i"] = { min_size = 1, -- allow single line scopes bottom = true, cursor = false, edge = true, treesitter = { blocks = { enabled = false } }, desc = "jump to bottom edge of scope", }, }, }, } local id = 0 ---@alias snacks.scope.scope {buf: number, from: number, to: number, indent?: number} ---@class snacks.scope.Scope ---@field buf number ---@field from number ---@field to number ---@field indent? number ---@field opts snacks.scope.Opts local Scope = {} Scope.__index = Scope ---@generic T: snacks.scope.Scope ---@param self T ---@param scope snacks.scope.scope ---@param opts snacks.scope.Opts ---@return T function Scope:new(scope, opts) local ret = setmetatable(scope, { __index = self, __eq = self.__eq, __tostring = self.__tostring }) ret.opts = opts return ret end function Scope:__eq(other) return other and self.buf == other.buf and self.from == other.from and self.to == other.to and self.indent == other.indent end ---@generic T: snacks.scope.Scope ---@param self T ---@param opts snacks.scope.Opts ---@return T? function Scope:find(opts) error("not implemented") end ---@generic T: snacks.scope.Scope ---@param self T ---@return T? function Scope:parent() error("not implemented") end ---@generic T: snacks.scope.Scope ---@param self T ---@return T function Scope:with_edge() error("not implemented") end ---@generic T: snacks.scope.scope ---@param self T ---@return T function Scope:inner() error("not implemented") end ---@param line number function Scope.get_indent(line) local ret = vim.fn.indent(line) return ret == -1 and nil or ret, line end ---@generic T: snacks.scope.Scope ---@param self T ---@param opts {buf?: number, from?: number, to?: number, indent?: number}} ---@return T? function Scope:with(opts) opts = vim.tbl_extend("keep", opts, self) return setmetatable(opts, getmetatable(self)) --[[ @as snacks.scope.Scope ]] end function Scope:size() return self.to - self.from + 1 end function Scope:size_with_edge() return self:with_edge():size() end ---@generic T: snacks.scope.Scope ---@param self T ---@return T? function Scope:expand(line) local ret = self ---@type snacks.scope.Scope? while ret do if line >= ret.from and line <= ret.to then return ret end ret = ret:parent() end end ---@class snacks.scope.IndentScope: snacks.scope.Scope local IndentScope = setmetatable({}, Scope) IndentScope.__index = IndentScope ---@param line number 1-indexed ---@param indent number ---@param up? boolean function IndentScope._expand(line, indent, up) local next = up and vim.fn.prevnonblank or vim.fn.nextnonblank while line do local i, l = IndentScope.get_indent(next(line + (up and -1 or 1))) if (i or 0) == 0 or i < indent or l == 0 then return line end line = l end return line end -- Inner indent scope is all lines with higher indent than the current scope function IndentScope:inner() local from, to, indent = nil, nil, math.huge for l = self.from, self.to do local i, il = IndentScope.get_indent(vim.fn.nextnonblank(l)) if il == l then if i > self.indent then from = from or l to = l indent = math.min(indent, i) end end end return from and to and self:with({ from = from, to = to, indent = indent }) or self end function IndentScope:with_edge() if self.indent == 0 then return self end local before_i, before_l = Scope.get_indent(vim.fn.prevnonblank(self.from - 1)) local after_i, after_l = Scope.get_indent(vim.fn.nextnonblank(self.to + 1)) local indent = math.min(math.max(before_i or self.indent, after_i or self.indent), self.indent) local from = before_i and before_i == indent and before_l or self.from local to = after_i and after_i == indent and after_l or self.to if from == 0 or to == 0 or indent < 0 then return self end return self:with({ from = from, to = to, indent = indent }) end ---@param opts snacks.scope.Opts function IndentScope:find(opts) local indent, line = Scope.get_indent(opts.pos[1]) local prev_i, prev_l = Scope.get_indent(vim.fn.prevnonblank(line - 1)) local next_i, next_l = Scope.get_indent(vim.fn.nextnonblank(line + 1)) -- fix indent when line is empty if vim.fn.prevnonblank(line) ~= line then indent, line = Scope.get_indent(prev_i > next_i and prev_l or next_l) prev_i, prev_l = Scope.get_indent(vim.fn.prevnonblank(line - 1)) next_i, next_l = Scope.get_indent(vim.fn.nextnonblank(line + 1)) end if line == 0 then return end -- adjust line to the nearest indent block if prev_i <= indent and next_i > indent then -- at top edge line = next_l indent = next_i elseif next_i <= indent and prev_i > indent then -- at bottom edge line = prev_l indent = prev_i elseif next_i > indent and prev_i > indent then -- at edge of two blocks. Prefer the one below. line = next_l indent = next_i end if opts.cursor then indent = math.min(indent, vim.fn.virtcol(opts.pos) + 1) end -- expand to include bigger indents return IndentScope:new({ buf = opts.buf, from = IndentScope._expand(line, indent, true), to = IndentScope._expand(line, indent, false), indent = indent, }, opts) end function IndentScope:parent() for i = self.indent - 1, 1, -1 do local u, d = IndentScope._expand(self.from, i, true), IndentScope._expand(self.to, i, false) if u ~= self.from or d ~= self.to then -- update only when expanded return self:with({ from = u, to = d, indent = i }) end end end ---@class snacks.scope.TSScope: snacks.scope.Scope ---@field node TSNode local TSScope = setmetatable({}, Scope) TSScope.__index = TSScope -- Expand the scope to fill the range of the node function TSScope:fill() local n = self.node local u, _, d = n:range() while n do local uu, _, dd = n:range() if uu == u and dd == d and not self:is_field(n) then self.node = n else break end n = n:parent() end end function TSScope:fix() self:fill() self.from, _, self.to = self.node:range() self.from, self.to = self.from + 1, self.to + 1 self.indent = math.min(vim.fn.indent(self.from), vim.fn.indent(self.to)) return self end ---@param node? TSNode function TSScope:is_field(node) node = node or self.node local parent = node:parent() parent = parent ~= node:tree():root() and parent or nil if not parent then return false end for child, field in parent:iter_children() do if child == node then return not (field == nil or vim.tbl_contains(self.opts.treesitter.field_blocks, field)) end end error("node not found in parent") end function TSScope:with_edge() local ret = self ---@type snacks.scope.TSScope? while ret do if ret:size() >= 1 and not ret:is_field() then return ret end ret = ret:parent() end return self end function TSScope:root() if type(self.opts.treesitter.blocks) ~= "table" or not self.opts.treesitter.blocks.enabled then return self:fix() end local root = self.node --[[@as TSNode?]] while root do if vim.tbl_contains(self.opts.treesitter.blocks, root:type()) then return self:with({ node = root }) end root = root:parent() end return self:fix() end ---@param opts {buf?: number, from?: number, to?: number, indent?: number, node?: TSNode}} function TSScope:with(opts) local ret = Scope.with(self, opts) --[[ @as snacks.scope.TSScope ]] return ret:fix() end ---@param opts snacks.scope.Opts function TSScope:parser(opts) local lang = vim.bo[opts.buf].filetype local has_parser, parser = pcall(vim.treesitter.get_parser, opts.buf, lang, { error = false }) return has_parser and parser or nil end ---@param cb fun() ---@param opts snacks.scope.Opts function TSScope:init(cb, opts) local parser = self:parser(opts) if not parser then return cb() end if opts.async == false then parser:parse() cb() else Snacks.util.parse(parser, opts.treesitter.injections, cb) end end ---@param opts snacks.scope.Opts function TSScope:find(opts) local lang = vim.treesitter.language.get_lang(vim.bo[opts.buf].filetype) local line = vim.fn.nextnonblank(opts.pos[1]) line = line == 0 and vim.fn.prevnonblank(opts.pos[1]) or line -- FIXME: local pos = { math.max(line - 1, 0), (vim.fn.getline(line):find("%S") or 1) - 1, -- find first non-space character } local node = vim.treesitter.get_node({ pos = pos, bufnr = opts.buf, lang = lang, ignore_injections = not opts.treesitter.injections, }) if not node then return end if opts.cursor then -- expand to biggest ancestor with a lower start position local n = node ---@type TSNode? local virtcol = vim.fn.virtcol(opts.pos) while n and n ~= n:tree():root() do local r, c = n:range() local virtcol_n = vim.fn.virtcol({ r + 1, c }) if virtcol_n > virtcol then node, n = n, n:parent() else break end end end local ret = TSScope:new({ buf = opts.buf, node = node }, opts):root() return ret end function TSScope:parent() local parent = self.node:parent() return parent and parent ~= self.node:tree():root() and self:with({ node = parent }):root() or nil end -- Inner treesitter scope includes all lines for which the node -- has a start position lower than the start of the scope. function TSScope:inner() local from, to, indent = nil, nil, math.huge for l = self.from + 1, self.to do if l == vim.fn.nextnonblank(l) then local col = (vim.fn.getline(l):find("%S") or 1) - 1 local node = vim.treesitter.get_node({ pos = { l - 1, col }, bufnr = self.buf }) local s = TSScope:new({ buf = self.buf, node = node }, self.opts):fix() if s and s.from > self.from and s.to <= self.to then from = from or l to = l indent = math.min(indent, vim.fn.indent(l)) end end end return from and to and IndentScope:new({ from = from, to = to, indent = indent }, self.opts) or self end function Scope:__tostring() local meta = getmetatable(self) return ("%s(buf=%d, from=%d, to=%d, indent=%d)"):format( rawequal(meta, TSScope) and "TSScope" or rawequal(meta, IndentScope) and "IndentSCope" or "Scope", self.buf or -1, self.from or -1, self.to or -1, self.indent or 0 ) end ---@param cb fun(scope?: snacks.scope.Scope) ---@param opts? snacks.scope.Opts|{parse?:boolean} function M.get(cb, opts) opts = Snacks.config.get("scope", defaults, opts or {}) --[[ @as snacks.scope.Opts ]] opts.buf = (opts.buf == nil or opts.buf == 0) and vim.api.nvim_get_current_buf() or opts.buf if not opts.pos then assert(opts.buf == vim.api.nvim_win_get_buf(0), "missing pos") opts.pos = vim.api.nvim_win_get_cursor(0) end -- run in the context of the buffer if not current if vim.api.nvim_get_current_buf() ~= opts.buf then vim.api.nvim_buf_call(opts.buf, function() M.get(cb, opts) end) return end ---@type snacks.scope.Scope local Class = (opts.treesitter.enabled and Snacks.util.get_lang(opts.buf)) and TSScope or IndentScope if rawequal(Class, TSScope) and opts.parse ~= false then TSScope:init(function() opts.parse = false M.get(cb, opts) end, opts) return end local scope = Class:find(opts) --[[ @as snacks.scope.Scope? ]] -- fallback to indent based detection if not scope and rawequal(Class, TSScope) then Class = IndentScope scope = Class:find(opts) end -- when end_pos is provided, get its scope and expand the current scope -- to include it. if scope and opts.end_pos and not vim.deep_equal(opts.pos, opts.end_pos) then local end_scope = Class:find(vim.tbl_extend("keep", { pos = opts.end_pos }, opts)) --[[ @as snacks.scope.Scope? ]] if end_scope and end_scope.from < scope.from then scope = scope:expand(end_scope.from) or scope end if end_scope and end_scope.to > scope.to then scope = scope:expand(end_scope.to) or scope end end local min_size = opts.min_size or 2 local max_size = opts.max_size or min_size -- expand block with ancestors until min_size is reached -- or max_size is reached if scope then local s = scope --- @type snacks.scope.Scope? while s do if opts.edge and scope:size_with_edge() >= min_size and s:size_with_edge() > max_size then break elseif not opts.edge and scope:size() >= min_size and s:size() > max_size then break end scope, s = s, s:parent() end -- expand with edge if opts.edge then scope = scope:with_edge() --[[@as snacks.scope.Scope]] end end -- expand single line blocks with single line siblings if opts.siblings and scope and scope:size() == 1 then while scope and scope:size() < min_size do local prev, next = vim.fn.prevnonblank(scope.from - 1), vim.fn.nextnonblank(scope.to + 1) ---@type number, number local prev_dist, next_dist = math.abs(opts.pos[1] - prev), math.abs(opts.pos[1] - next) local prev_s = prev > 0 and Class:find(vim.tbl_extend("keep", { pos = { prev, 0 } }, opts)) local next_s = next > 0 and Class:find(vim.tbl_extend("keep", { pos = { next, 0 } }, opts)) prev_s = prev_s and prev_s:size() == 1 and prev_s next_s = next_s and next_s:size() == 1 and next_s local s = prev_dist < next_dist and prev_s or next_s or prev_s if s and (s.from < scope.from or s.to > scope.to) then scope = Scope.with(scope, { from = math.min(scope.from, s.from), to = math.max(scope.to, s.to) }) else break end end end cb(scope) end ---@class snacks.scope.Listener ---@field id integer ---@field cb snacks.scope.Attach.cb ---@field opts snacks.scope.Config ---@field dirty table ---@field timer uv.uv_timer_t ---@field augroup integer ---@field enabled boolean ---@field active table local Listener = {} ---@param cb snacks.scope.Attach.cb ---@param opts? snacks.scope.Config function Listener.new(cb, opts) local self = setmetatable({}, { __index = Listener }) self.cb = cb self.dirty = {} self.timer = assert((vim.uv or vim.loop).new_timer()) self.enabled = false self.opts = Snacks.config.get("scope", defaults, opts or {}) --[[ @as snacks.scope.Opts ]] id = id + 1 self.id = id self.active = {} return self end --- Check if the scope has changed in the window / buffer function Listener:check(win) local buf = vim.api.nvim_win_get_buf(win) if not self.opts.filter(buf) then if self.active[win] then local prev = self.active[win] self.active[win] = nil self.cb(win, buf, nil, prev) end return end M.get( function(scope) local prev = self.active[win] if prev == scope then return -- no change end self.active[win] = scope self.cb(win, buf, scope, prev) end, vim.tbl_extend("keep", { buf = buf, pos = vim.api.nvim_win_get_cursor(win), }, self.opts) ) end --- Get the active scope for a window function Listener:get(win) local scope = self.active[win] return scope and vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == scope.buf and scope or nil end --- Cleanup invalid scopes function Listener:clean() for win in pairs(self.active) do self.active[win] = self:get(win) end end --- Iterate over active scopes function Listener:iter() self:clean() return pairs(self.active) end --- Schedule a scope update ---@param wins? number|number[] ---@param opts? {now?: boolean} function Listener:update(wins, opts) wins = type(wins) == "number" and { wins } or wins or vim.api.nvim_list_wins() --[[ @as number[] ]] for _, b in ipairs(wins) do self.dirty[b] = true end local function update() self:_update() end if opts and opts.now then update() end self.timer:start(self.opts.debounce, 0, vim.schedule_wrap(update)) end --- Process all pending updates function Listener:_update() for win in pairs(self.dirty) do if vim.api.nvim_win_is_valid(win) then self:check(win) end end self.dirty = {} end --- Start listening for scope changes function Listener:enable() assert(not self.enabled, "already enabled") self.enabled = true self.augroup = vim.api.nvim_create_augroup("snacks_scope_" .. self.id, { clear = true }) vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { group = self.augroup, callback = function(ev) for _, win in ipairs(vim.fn.win_findbuf(ev.buf)) do self:update(win) end end, }) vim.api.nvim_create_autocmd({ "WinClosed", "BufDelete", "BufWipeout" }, { group = self.augroup, callback = function() self:clean() end, }) self:update(nil, { now = true }) end --- Stop listening for scope changes function Listener:disable() assert(self.enabled, "already disabled") self.enabled = false vim.api.nvim_del_augroup_by_id(self.augroup) self.timer:stop() self.active = {} self.dirty = {} end --- Attach a scope listener ---@param cb snacks.scope.Attach.cb ---@param opts? snacks.scope.Config ---@return snacks.scope.Listener function M.attach(cb, opts) local ret = Listener.new(cb, opts) ret:enable() return ret end -- Text objects for indent scopes. -- Best to use with Treesitter disabled. -- When in visual mode, it will select the scope containing the visual selection. -- When the scope is the same as the visual selection, it will select the parent scope instead. ---@param opts? snacks.scope.TextObject function M.textobject(opts) opts = Snacks.config.get("scope", defaults, opts or {}) --[[ @as snacks.scope.TextObject ]] local mode = vim.fn.mode() local selection = mode:find("[vV]") ~= nil -- prepare for visual mode and determine linewise if mode == "v" then vim.cmd("normal! v") elseif mode == "V" then vim.cmd("normal! V") opts.linewise = opts.linewise == nil and true or opts.linewise end -- use the actual range instead of the cursor position -- in case of visual mode if selection then opts.pos = vim.api.nvim_buf_get_mark(0, "<") opts.end_pos = vim.api.nvim_buf_get_mark(0, ">") end local inner = not opts.edge opts.edge = true -- always include the edge of the scope to make inner work opts.async = false -- run synchronously M.get(function(scope) if not scope then return opts.notify ~= false and Snacks.notify.warn("No scope in range") end scope = inner and scope:inner() or scope -- determine scope range local from, to = { scope.from, opts.linewise and 0 or vim.fn.indent(scope.from) }, { scope.to, opts.linewise and 0 or vim.fn.col({ scope.to, "$" }) - 2 } -- select the range vim.api.nvim_win_set_cursor(0, from) vim.cmd("normal! " .. (opts.linewise and "V" or "v")) vim.api.nvim_win_set_cursor(0, to) end, opts) end --- Jump to the top or bottom of the scope --- If the scope is the same as the current scope, it will jump to the parent scope instead. ---@param opts? snacks.scope.Jump function M.jump(opts) opts = Snacks.config.get("scope", defaults, opts or {}) --[[ @as snacks.scope.Jump ]] M.get(function(scope) if not scope then return opts.notify ~= false and Snacks.notify.warn("No scope in range") end while scope do local line = opts.bottom and scope.to or scope.from local pos = { line, vim.fn.indent(line) } if not vim.deep_equal(vim.api.nvim_win_get_cursor(0), pos) then return vim.api.nvim_win_set_cursor(0, { line, vim.fn.indent(line) }) end scope = scope:parent() end end, opts) end ---@private function M.setup() local keys = Snacks.config.get("scope", defaults).keys for key, opts in pairs(keys.textobject) do if opts then vim.keymap.set({ "x", "o" }, key, function() M.textobject(opts) end, { silent = true, desc = opts.desc }) end end for key, opts in pairs(keys.jump) do if opts then vim.keymap.set({ "n", "x", "o" }, key, function() M.jump(opts) end, { silent = true, desc = opts.desc }) end end end M.TSScope = TSScope M.IdentScope = IndentScope return M ================================================ FILE: lua/snacks/scratch.lua ================================================ local uv = vim.uv or vim.loop ---@class snacks.scratch ---@overload fun(opts?: snacks.scratch.Config): snacks.win local M = setmetatable({}, { __call = function(M, ...) return M.open(...) end, }) M.meta = { desc = "Scratch buffers with a persistent file", } M.version = 1 M.version_checked = false ---@class snacks.scratch.File ---@field file string full path to the scratch buffer ---@field name string name of the scratch buffer ---@field ft string file type ---@field icon? string icon for the file type ---@field icon_hl? string highlight group for the icon ---@field cwd? string current working directory ---@field branch? string Git branch ---@field count? number vim.v.count1 used to open the buffer ---@field id? string unique id used instead of name for the filename hash ---@class snacks.scratch.Config ---@field win? snacks.win.Config scratch window ---@field template? string template for new buffers ---@field file? string scratch file path. You probably don't need to set this. ---@field ft? string|fun():string the filetype of the scratch buffer local defaults = { name = "Scratch", ft = function() if vim.bo.buftype == "" and vim.bo.filetype ~= "" then return vim.bo.filetype end return "markdown" end, ---@type string|string[]? icon = nil, -- `icon|{icon, icon_hl}`. defaults to the filetype icon root = vim.fn.stdpath("data") .. "/scratch", autowrite = true, -- automatically write when the buffer is hidden -- unique key for the scratch file is based on: -- * name -- * ft -- * vim.v.count1 (useful for keymaps) -- * cwd (optional) -- * branch (optional) filekey = { id = nil, ---@type string? unique id used instead of name for the filename hash cwd = true, -- use current working directory branch = true, -- use current branch name count = true, -- use vim.v.count1 }, win = { style = "scratch" }, ---@type table win_by_ft = { lua = { keys = { ["source"] = { "", function(self) local name = "scratch." .. vim.fn.fnamemodify(vim.api.nvim_buf_get_name(self.buf), ":e") Snacks.debug.run({ buf = self.buf, name = name }) end, desc = "Source buffer", mode = { "n", "x" }, }, }, }, }, } Snacks.util.set_hl({ Title = "FloatTitle", }, { prefix = "SnacksScratch", default = true }) Snacks.config.style("scratch", { width = 100, height = 30, bo = { buftype = "", buflisted = false, bufhidden = "hide", swapfile = false }, minimal = false, noautocmd = false, -- position = "right", zindex = 20, wo = { winhighlight = "NormalFloat:Normal" }, footer_keys = true, border = true, }) --- Return a list of scratch buffers sorted by mtime. ---@return snacks.scratch.File[] function M.list() M.migrate() local root = Snacks.config.get("scratch", defaults).root ---@type (snacks.scratch.File|{stat:uv.fs_stat.result})[] local ret = {} for file, t in vim.fs.dir(root) do if t == "file" and file:sub(-5) == ".meta" then local path = svim.fs.normalize(root .. "/" .. file:sub(1, -6)) local stat = uv.fs_stat(path) if stat then ret[#ret + 1] = M.get({ file = path }) ret[#ret].stat = stat end end end table.sort(ret, function(a, b) return a.stat.mtime.sec > b.stat.mtime.sec end) return ret end --- Migrate old scratch files to the new format. ---@private function M.migrate() if M.version_checked then return end M.version_checked = true local root = Snacks.config.get("scratch", defaults).root local ok, version = pcall(vim.fn.readfile, root .. "/.version") if ok and tonumber(version[1]) == M.version then return end vim.fn.mkdir(root .. "/bak", "p") for file, t in vim.fs.dir(root) do if t == "file" then -- old format. Keep for backward compatibility local decoded = Snacks.util.file_decode(file) local count, icon, name, cwd, branch, ft = decoded:match("^(%d*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)%.([^|]*)$") if count and icon and name and cwd and branch and ft then local path = svim.fs.normalize(root .. "/" .. file) ---@type snacks.scratch.File local scratch = { file = path, count = count ~= "" and tonumber(count) or nil, icon = icon ~= "" and icon or nil, name = name, cwd = cwd ~= "" and cwd or nil, branch = branch ~= "" and branch or nil, ft = ft, } -- backup file vim.fn.filecopy(path, root .. "/bak/" .. file) vim.fn.rename(path, M._write_meta(root, scratch)) end end end vim.fn.writefile({ tostring(M.version) }, root .. "/.version") end --- Select a scratch buffer from a list of scratch buffers. function M.select() return Snacks.picker.scratch() end --- Open a scratch buffer with the given options. --- If a window is already open with the same buffer, --- it will be closed instead. ---@param opts? snacks.scratch.Config function M.open(opts) M.migrate() opts = Snacks.config.get("scratch", defaults, opts) local scratch = M.get(opts) opts.win = Snacks.win.resolve("scratch", opts.win_by_ft[scratch.ft], opts.win, { show = false, bo = { filetype = scratch.ft }, }) opts.win.title = { { " ", "SnacksScratchTitle" }, { scratch.icon .. string.rep(" ", 2 - vim.api.nvim_strwidth(scratch.icon)), scratch.icon_hl }, { " ", "SnacksScratchTitle" }, { opts.name .. (vim.v.count1 > 1 and " " .. vim.v.count1 or ""), "SnacksScratchTitle" }, { " ", "SnacksScratchTitle" }, } local is_new = not uv.fs_stat(scratch.file) local buf = vim.fn.bufadd(scratch.file) local win = vim.fn.bufwinid(buf) if win ~= -1 then vim.schedule(function() vim.api.nvim_win_call(win, function() vim.cmd([[close]]) end) end) return end opts.win.zindex = Snacks.win.zindex(opts.win.zindex or 20) is_new = is_new and vim.api.nvim_buf_line_count(buf) == 0 and #(vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or "") == 0 if not vim.api.nvim_buf_is_loaded(buf) then vim.fn.bufload(buf) end if opts.template then local function reset() vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(opts.template, "\n")) end opts.win.keys = opts.win.keys or {} opts.win.keys.reset = { "R", reset, desc = "Reset buffer" } if is_new then reset() end end opts.win.buf = buf if opts.autowrite then vim.api.nvim_create_autocmd("BufHidden", { group = vim.api.nvim_create_augroup("snacks_scratch_autowrite_" .. buf, { clear = true }), buffer = buf, callback = function(ev) vim.api.nvim_buf_call(ev.buf, function() vim.cmd("silent! write") vim.bo[ev.buf].buflisted = false end) end, }) end return Snacks.win(opts.win):show() end ---@param opts? snacks.scratch.Config ---@private function M.get(opts) opts = Snacks.config.get("scratch", defaults, opts) -- File type local ft = "markdown" ---@type string if opts.file then ft = vim.filetype.match({ filename = opts.file }) or ft elseif type(opts.ft) == "function" then ft = opts.ft() elseif type(opts.ft) == "string" then ft = opts.ft --[[@as string]] end -- Icon local icon = opts.icon or {} icon = type(icon) == "string" and { icon } or icon ---@cast icon string[] if not icon[1] and opts.file then icon[1], icon[2] = Snacks.util.icon(opts.file or "", "file") elseif not icon[1] and ft then icon[1], icon[2] = Snacks.util.icon(ft, "filetype") end ---@type snacks.scratch.File local ret = { file = "", name = opts.name, ft = ft, icon = icon[1], icon_hl = icon[2], } -- File if opts.file then ret.file = svim.fs.normalize(opts.file) local meta = ret.file .. ".meta" if uv.fs_stat(meta) then local ok, decoded = pcall(vim.json.decode, table.concat(vim.fn.readfile(meta), "\n")) if ok and type(decoded) == "table" then ret = Snacks.config.merge(ret, decoded, { file = ret.file }) end end else ret.count = opts.filekey.count and vim.v.count1 or nil ret.cwd = opts.filekey.cwd and svim.fs.normalize(assert(uv.cwd())) or nil if opts.filekey.branch and uv.fs_stat(".git") then local out = vim.trim(vim.fn.systemlist("git branch --show-current")[1] or "") ret.branch = vim.v.shell_error == 0 and out ~= "" and out or nil end ret.file = M._write_meta(opts.root, ret) end return ret end ---@param root string ---@param scratch snacks.scratch.File ---@private function M._write_meta(root, scratch) local key = { scratch.id or scratch.name } key[#key + 1] = scratch.count and tostring(scratch.count) or nil key[#key + 1] = scratch.cwd and scratch.cwd or nil key[#key + 1] = scratch.branch and scratch.branch or nil vim.fn.mkdir(root, "p") local hash = vim.fn.sha256(table.concat(key, "|")):sub(1, 8) local file = svim.fs.normalize(("%s/%s.%s"):format(root, hash, scratch.ft)) vim.fn.writefile(vim.split(vim.json.encode(scratch), "\n"), file .. ".meta") return file end return M ================================================ FILE: lua/snacks/scroll.lua ================================================ ---@class snacks.scroll local M = {} M.meta = { desc = "Smooth scrolling", needs_setup = true, } ---@alias snacks.scroll.View {topline:number, lnum:number} ---@class snacks.scroll.State ---@field anim? snacks.animate.Animation ---@field win number ---@field buf number ---@field view vim.fn.winsaveview.ret ---@field current vim.fn.winsaveview.ret ---@field target vim.fn.winsaveview.ret ---@field scrolloff number ---@field changedtick number ---@field last number vim.uv.hrtime of last scroll ---@field _wo vim.wo Backup of window options local State = {} State.__index = State ---@class snacks.scroll.Config ---@field animate snacks.animate.Config|{} ---@field animate_repeat snacks.animate.Config|{}|{delay:number} local defaults = { animate = { duration = { step = 10, total = 200 }, easing = "linear", }, -- faster animation when repeating scroll after delay animate_repeat = { delay = 100, -- delay in ms before using the repeat animation duration = { step = 5, total = 50 }, easing = "linear", }, -- what buffers to animate filter = function(buf) return vim.g.snacks_scroll ~= false and vim.b[buf].snacks_scroll ~= false and vim.bo[buf].buftype ~= "terminal" end, debug = false, } local mouse_scrolling = false M.enabled = false local SCROLL_UP, SCROLL_DOWN = Snacks.util.keycode(""), Snacks.util.keycode("") local uv = vim.uv or vim.loop local stats = { targets = 0, animating = 0, reset = 0, skipped = 0, mousescroll = 0, scrolls = 0 } local config = Snacks.config.get("scroll", defaults) local debug_timer = assert((vim.uv or vim.loop).new_timer()) local states = {} ---@type table local function is_enabled(buf) return M.enabled and buf and not vim.o.paste and vim.fn.reg_executing() == "" and vim.fn.reg_recording() == "" and config.filter(buf) and Snacks.animate.enabled({ buf = buf, name = "scroll" }) end ---@param win number function State.get(win) local buf = vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) if not buf or not is_enabled(buf) then states[win] = nil return nil end local view = vim.api.nvim_win_call(win, vim.fn.winsaveview) ---@type vim.fn.winsaveview.ret local ret = states[win] if not (ret and ret:valid()) then if ret then ret:stop() end ret = setmetatable({}, State) ret.buf = buf ret._wo = {} ret.changedtick = vim.api.nvim_buf_get_changedtick(buf) ret.current = vim.deepcopy(view) ret.last = 0 ret.target = vim.deepcopy(view) ret.win = win end ret.scrolloff = ret._wo.scrolloff or vim.wo[win].scrolloff ret.view = view states[win] = ret return ret end function State:stop() self:wo() -- restore window options if self.anim then self.anim:stop() self.anim = nil end end --- Save or restore window options ---@param opts? vim.wo|{} function State:wo(opts) if not opts then if vim.api.nvim_win_is_valid(self.win) then for k, v in pairs(self._wo) do vim.wo[self.win][k] = v end end self._wo = {} return else for k, v in pairs(opts) do self._wo[k] = self._wo[k] or vim.wo[self.win][k] vim.wo[self.win][k] = v end end end function State:valid() return M.enabled and states[self.win] == self and vim.api.nvim_win_is_valid(self.win) and vim.api.nvim_buf_is_valid(self.buf) and vim.api.nvim_win_get_buf(self.win) == self.buf and vim.api.nvim_buf_get_changedtick(self.buf) == self.changedtick end function State:update() if vim.api.nvim_win_is_valid(self.win) then self.current = vim.api.nvim_win_call(self.win, vim.fn.winsaveview) end end --- Reset the scroll state for a buffer ---@param win number function State.reset(win) if states[win] then states[win]:stop() states[win] = nil end end function M.enable() if M.enabled then return end M.enabled = true states = {} if config.debug then M.debug() end -- get initial state for all windows for _, win in ipairs(vim.api.nvim_list_wins()) do State.get(win) end local group = vim.api.nvim_create_augroup("snacks_scroll", { clear = true }) -- track mouse scrolling Snacks.util.on_key("", function() mouse_scrolling = true end) Snacks.util.on_key("", function() mouse_scrolling = true end) -- initialize state for buffers entering windows vim.api.nvim_create_autocmd("BufWinEnter", { group = group, callback = vim.schedule_wrap(function(ev) for _, win in ipairs(vim.fn.win_findbuf(ev.buf)) do State.get(win) end end), }) -- update state when leaving insert mode or changing text in normal mode vim.api.nvim_create_autocmd({ "InsertLeave", "TextChanged", "TextChangedI" }, { group = group, callback = function(ev) for _, win in ipairs(vim.fn.win_findbuf(ev.buf)) do State.get(win) end end, }) -- update current state on cursor move vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { group = group, callback = vim.schedule_wrap(function(ev) for _, win in ipairs(vim.fn.win_findbuf(ev.buf)) do if states[win] then states[win]:update() end end end), }) -- clear scroll state when leaving the cmdline after a search with incsearch vim.api.nvim_create_autocmd({ "CmdlineLeave" }, { group = group, callback = function(ev) if (ev.file == "/" or ev.file == "?") and vim.o.incsearch then for _, win in ipairs(vim.fn.win_findbuf(ev.buf)) do State.reset(win) end end end, }) -- listen to scroll events with topline changes vim.api.nvim_create_autocmd("WinScrolled", { group = group, callback = function() for win, changes in pairs(vim.v.event) do win = tonumber(win) if win and changes.topline ~= 0 then M.check(win) end end end, }) end function M.disable() if not M.enabled then return end M.enabled = false states = {} vim.api.nvim_del_augroup_by_name("snacks_scroll") end --- Determines the amount of scrollable lines between two window views, --- taking folds and virtual lines into account. ---@param from vim.fn.winsaveview.ret ---@param to vim.fn.winsaveview.ret local function scroll_lines(win, from, to) if from.topline == to.topline then return math.abs(from.topfill - to.topfill) end if to.topline < from.topline then from, to = to, from end local start_row, end_row, offset = from.topline - 1, to.topline - 1, 0 if from.topfill > 0 then start_row = start_row + 1 offset = from.topfill + 1 end if to.topfill > 0 then offset = offset - to.topfill end if not vim.api.nvim_win_text_height then return end_row - start_row + offset end return vim.api.nvim_win_text_height(win, { start_row = start_row, end_row = end_row }).all + offset - 1 end --- Check if we need to animate the scroll ---@param win number ---@private function M.check(win) local state = State.get(win) if not state then return end -- only animate the current window when scrollbind is enabled if vim.wo[state.win].scrollbind and vim.api.nvim_get_current_win() ~= state.win then state:stop() return end -- if delta is 0, then we're animating. -- also skip if the difference is less than the mousescroll value, -- since most terminals support smooth mouse scrolling. if mouse_scrolling then state:stop() mouse_scrolling = false stats.mousescroll = stats.mousescroll + 1 state.current = vim.deepcopy(state.view) return elseif math.abs(state.view.topline - state.current.topline) <= 1 then stats.skipped = stats.skipped + 1 state.current = vim.deepcopy(state.view) return end stats.scrolls = stats.scrolls + 1 -- new target stats.targets = stats.targets + 1 state.target = vim.deepcopy(state.view) state:stop() -- stop any ongoing animation state:wo({ virtualedit = "all", scrolloff = 0 }) local now = uv.hrtime() local repeat_delta = (now - state.last) / 1e6 state.last = now local is_repeat = repeat_delta <= config.animate_repeat.delay ---@type snacks.animate.Opts local opts = vim.tbl_extend("force", vim.deepcopy(is_repeat and config.animate_repeat or config.animate), { int = true, id = ("scroll%s%d"):format(is_repeat and "_repeat_" or "_", win), buf = state.buf, }) local scrolls = 0 local col_from, col_to = 0, 0 local move_from, move_to = 0, 0 vim.api.nvim_win_call(state.win, function() move_to = vim.fn.winline() vim.fn.winrestview(state.current) -- reset to current state move_from = vim.fn.winline() state:update() -- calculate the amount of lines to scroll, taking folds into account scrolls = scroll_lines(state.win, state.current, state.target) col_from = vim.fn.virtcol({ state.current.lnum, state.current.col }) col_to = vim.fn.virtcol({ state.target.lnum, state.target.col }) end) local down = state.target.topline > state.current.topline or (state.target.topline == state.current.topline and state.target.topfill < state.current.topfill) local scrolled = 0 state.anim = Snacks.animate(0, scrolls, function(value, ctx) if not state:valid() then state:stop() return end vim.api.nvim_win_call(win, function() if ctx.done then vim.fn.winrestview(state.target) state:update() state:stop() return end local count = vim.v.count -- backup count local commands = {} ---@type string[] -- scroll local scroll_target = math.floor(value) local scroll = scroll_target - scrolled --[[@as number]] if scroll > 0 then scrolled = scrolled + scroll commands[#commands + 1] = ("%d%s"):format(scroll, down and SCROLL_DOWN or SCROLL_UP) end -- move the cursor vertically local move = math.floor(value * math.abs(move_to - move_from) / scrolls) -- delta to move this step local move_target = move_from + ((move_to < move_from) and -1 or 1) * move -- target line commands[#commands + 1] = ("%dH"):format(move_target) -- move the cursor horizontally local virtcol = math.floor(col_from + (col_to - col_from) * value / scrolls) commands[#commands + 1] = ("%d|"):format(virtcol + 1) -- execute all commands in one go vim.cmd(("keepjumps normal! %s"):format(table.concat(commands, ""))) -- restore count (see #1024) if vim.v.count ~= count then local cursor = vim.api.nvim_win_get_cursor(win) vim.cmd(("keepjumps normal! %dzh"):format(count)) vim.api.nvim_win_set_cursor(win, cursor) end state:update() end) end, opts) end ---@private function M.debug() if debug_timer:is_active() then return debug_timer:stop() end local last = {} debug_timer:start(50, 50, function() local data = vim.tbl_extend("force", { stats = stats }, states) for key, value in pairs(data) do if not vim.deep_equal(last[key], value) then Snacks.notify(vim.inspect(value), { ft = "lua", id = "snacks_scroll_debug_" .. key, title = "Snacks Scroll Debug " .. key, }) end end last = vim.deepcopy(data) end) end return M ================================================ FILE: lua/snacks/statuscolumn.lua ================================================ local Snacks = require("snacks") ---@class snacks.statuscolumn ---@overload fun(): string local M = setmetatable({}, { __call = function(t) return t.get() end, }) M.meta = { desc = "Pretty status column", needs_setup = true, } ---@class snacks.statuscolumn.FoldInfo ---@field start number Line number where deepest fold starts ---@field level number Fold level, when zero other fields are N/A ---@field llevel number Lowest level that starts in v:lnum ---@field lines number Number of lines from v:lnum to end of closed fold ---@type ffi.namespace* local C local function _ffi() if not C then local ffi = require("ffi") ffi.cdef([[ typedef struct {} Error; typedef struct {} win_T; typedef struct { int start; // line number where deepest fold starts int level; // fold level, when zero other fields are N/A int llevel; // lowest level that starts in v:lnum int lines; // number of lines from v:lnum to end of closed fold } foldinfo_T; foldinfo_T fold_info(win_T* wp, int lnum); win_T *find_window_by_handle(int Window, Error *err); ]]) C = ffi.C end return C end -- Returns fold info for a given window and line number ---@param win number ---@param lnum number local function fold_info(win, lnum) pcall(_ffi) if not C then return end local ffi = require("ffi") local err = ffi.new("Error") local wp = C.find_window_by_handle(win, err) if wp == nil then return end return C.fold_info(wp, lnum) ---@type snacks.statuscolumn.FoldInfo end ---@alias snacks.statuscolumn.Component "mark"|"sign"|"fold"|"git" ---@alias snacks.statuscolumn.Components snacks.statuscolumn.Component[]|fun(win:number,buf:number,lnum:number):snacks.statuscolumn.Component[] ---@alias snacks.statuscolumn.Wanted table ---@class snacks.statuscolumn.Config ---@field left snacks.statuscolumn.Components ---@field right snacks.statuscolumn.Components ---@field enabled? boolean local defaults = { left = { "mark", "sign" }, -- priority of signs on the left (high to low) right = { "fold", "git" }, -- priority of signs on the right (high to low) folds = { open = false, -- show open fold icons git_hl = false, -- use Git Signs hl for fold icons }, git = { -- patterns to match Git signs patterns = { "GitSign", "MiniDiffSign" }, }, refresh = 50, -- refresh at most every 50ms } local config = Snacks.config.get("statuscolumn", defaults) ---@private ---@alias snacks.statuscolumn.Sign.type "mark"|"sign"|"fold"|"git" ---@alias snacks.statuscolumn.Sign {name:string, text:string, texthl:string, priority:number, type:snacks.statuscolumn.Sign.type} -- Cache for signs per buffer and line ---@type table> local sign_cache = {} local cache = {} ---@type table local icon_cache = {} ---@type table local did_setup = false ---@private function M.setup() if did_setup then return end did_setup = true Snacks.util.set_hl({ Mark = "DiagnosticHint", }, { prefix = "SnacksStatusColumn", default = true }) local timer = assert((vim.uv or vim.loop).new_timer()) timer:start(config.refresh, config.refresh, function() sign_cache = {} cache = {} end) end ---@private ---@param name string function M.is_git_sign(name) for _, pattern in ipairs(config.git.patterns) do if name:find(pattern) then return true end end end -- Returns a list of regular and extmark signs sorted by priority (low to high) ---@private ---@param wanted snacks.statuscolumn.Wanted ---@return table ---@param buf number function M.buf_signs(buf, wanted) -- Get regular signs ---@type table local signs = {} if wanted.git or wanted.sign then if vim.fn.has("nvim-0.10") == 0 then -- Only needed for Neovim <0.10 -- Newer versions include legacy signs in nvim_buf_get_extmarks for _, sign in ipairs(vim.fn.sign_getplaced(buf, { group = "*" })[1].signs) do local ret = vim.fn.sign_getdefined(sign.name)[1] --[[@as snacks.statuscolumn.Sign]] if ret then ret.priority = sign.priority ret.type = M.is_git_sign(sign.name) and "git" or "sign" signs[sign.lnum] = signs[sign.lnum] or {} if wanted[ret.type] then table.insert(signs[sign.lnum], ret) end end end end -- Get extmark signs local extmarks = vim.api.nvim_buf_get_extmarks(buf, -1, 0, -1, { details = true, type = "sign" }) for _, extmark in pairs(extmarks) do local lnum = extmark[2] + 1 signs[lnum] = signs[lnum] or {} local name = extmark[4].sign_hl_group or extmark[4].sign_name or "" local ret = { name = name, type = M.is_git_sign(name) and "git" or "sign", text = extmark[4].sign_text, texthl = extmark[4].sign_hl_group, priority = extmark[4].priority, } if wanted[ret.type] then table.insert(signs[lnum], ret) end end end -- Add marks if wanted.mark then local marks = vim.fn.getmarklist(buf) vim.list_extend(marks, vim.fn.getmarklist()) for _, mark in ipairs(marks) do if mark.pos[1] == buf and mark.mark:match("[a-zA-Z]") then local lnum = mark.pos[2] signs[lnum] = signs[lnum] or {} table.insert(signs[lnum], { text = mark.mark:sub(2), texthl = "SnacksStatusColumnMark", type = "mark" }) end end end return signs end -- Returns a list of regular and extmark signs sorted by priority (high to low) ---@private ---@param win number ---@param buf number ---@param lnum number ---@param wanted snacks.statuscolumn.Wanted ---@return snacks.statuscolumn.Sign[] function M.line_signs(win, buf, lnum, wanted) local buf_signs = sign_cache[buf] if not buf_signs then buf_signs = M.buf_signs(buf, wanted) sign_cache[buf] = buf_signs end local signs = buf_signs[lnum] or {} -- Get fold signs if wanted.fold then local info = fold_info(win, lnum) if info and info.level > 0 then if info.lines > 0 then signs[#signs + 1] = { text = vim.opt.fillchars:get().foldclose or "", texthl = "Folded", type = "fold" } elseif config.folds.open and info.start == lnum then signs[#signs + 1] = { text = vim.opt.fillchars:get().foldopen or "", type = "fold" } end end end -- Sort by priority table.sort(signs, function(a, b) return (a.priority or 0) > (b.priority or 0) end) return signs end ---@private ---@param sign? snacks.statuscolumn.Sign function M.icon(sign) if not sign then return " " end local key = (sign.text or "") .. (sign.texthl or "") if icon_cache[key] then return icon_cache[key] end local text = vim.fn.strcharpart(sign.text or "", 0, 2) ---@type string text = text .. string.rep(" ", 2 - vim.fn.strchars(text)) icon_cache[key] = sign.texthl and ("%#" .. sign.texthl .. "#" .. text .. "%*") or text return icon_cache[key] end ---@return string function M._get() if not did_setup then M.setup() end local win = vim.g.statusline_winid local nu = vim.wo[win].number local rnu = vim.wo[win].relativenumber local show_signs = vim.v.virtnum == 0 and vim.wo[win].signcolumn ~= "no" local show_folds = vim.v.virtnum == 0 and vim.wo[win].foldcolumn ~= "0" local buf = vim.api.nvim_win_get_buf(win) local left_c = type(config.left) == "function" and config.left(win, buf, vim.v.lnum) or config.left --[[@as snacks.statuscolumn.Component[] ]] local right_c = type(config.right) == "function" and config.right(win, buf, vim.v.lnum) or config.right --[[@as snacks.statuscolumn.Component[] ]] ---@type snacks.statuscolumn.Wanted local wanted = { sign = show_signs } for _, c in ipairs(left_c) do wanted[c] = wanted[c] ~= false end for _, c in ipairs(right_c) do wanted[c] = wanted[c] ~= false end local components = { "", "", "" } -- left, middle, right if not (show_signs or nu or rnu) then return "" end if (nu or rnu) and vim.v.virtnum == 0 then local num ---@type number if rnu and nu and vim.v.relnum == 0 then num = vim.v.lnum elseif rnu then num = vim.v.relnum else num = vim.v.lnum end components[2] = "%=" .. num .. " " end if show_signs or show_folds then local signs = M.line_signs(win, buf, vim.v.lnum, wanted) if #signs > 0 then local signs_by_type = {} ---@type table for _, s in ipairs(signs) do signs_by_type[s.type] = signs_by_type[s.type] or s end ---@param types snacks.statuscolumn.Sign.type[] local function find(types) for _, t in ipairs(types) do if signs_by_type[t] then return signs_by_type[t] end end end local left, right = find(left_c), find(right_c) if config.folds.git_hl then local git = signs_by_type.git if git and left and left.type == "fold" then left.texthl = git.texthl end if git and right and right.type == "fold" then right.texthl = git.texthl end end components[1] = left and M.icon(left) or " " -- left components[3] = right and M.icon(right) or " " -- right else components[1] = " " components[3] = " " end end components[1] = vim.b[buf].snacks_statuscolumn_left ~= false and components[1] or "" components[3] = vim.b[buf].snacks_statuscolumn_right ~= false and components[3] or "" local ret = table.concat(components, "") return "%@v:lua.require'snacks.statuscolumn'.click_fold@" .. ret .. "%T" end function M.get() local win = vim.g.statusline_winid local buf = vim.api.nvim_win_get_buf(win) local key = ("%d:%d:%d:%d:%d"):format(win, buf, vim.v.lnum, vim.v.virtnum ~= 0 and 1 or 0, vim.v.relnum) if cache[key] then return cache[key] end local ok, ret = pcall(M._get) if ok then cache[key] = ret return ret end return "" end ---@private function M.health() local ready = vim.o.statuscolumn:find("snacks.statuscolumn", 1, true) if config.enabled and not ready then Snacks.health.warn(("is not configured\n- `vim.o.statuscolumn = %q`"):format(vim.o.statuscolumn)) elseif not config.enabled and ready then Snacks.health.ok(("is manually configured\n- `vim.o.statuscolumn = %q`"):format(vim.o.statuscolumn)) end end function M.click_fold() local pos = vim.fn.getmousepos() vim.api.nvim_win_set_cursor(pos.winid, { pos.line, 1 }) vim.api.nvim_win_call(pos.winid, function() if vim.fn.foldlevel(pos.line) > 0 then vim.cmd("normal! za") end end) end return M ================================================ FILE: lua/snacks/terminal.lua ================================================ ---@class snacks.terminal: snacks.win ---@field cmd? string | string[] ---@field opts snacks.terminal.Opts ---@overload fun(cmd?: string|string[], opts?: snacks.terminal.Opts): snacks.terminal local M = setmetatable({}, { __call = function(t, ...) return t.toggle(...) end, }) M.meta = { desc = "Create and toggle floating/split terminals", } ---@class snacks.terminal.Config ---@field win? snacks.win.Config|{} ---@field shell? string|string[] The shell to use. Defaults to `vim.o.shell` ---@field override? fun(cmd?: string|string[], opts?: snacks.terminal.Opts) Use this to use a different terminal implementation local defaults = { win = { style = "terminal" }, } ---@class snacks.terminal.Opts: snacks.terminal.Config ---@field cwd? string ---@field count? integer ---@field env? table ---@field start_insert? boolean start insert mode when starting the terminal ---@field auto_insert? boolean start insert mode when entering the terminal buffer ---@field auto_close? boolean close the terminal buffer when the process exits ---@field interactive? boolean shortcut for `start_insert`, `auto_close` and `auto_insert` (default: true) Snacks.config.style("terminal", { bo = { filetype = "snacks_terminal", }, wo = {}, stack = true, -- when enabled, multiple split windows with the same position will be stacked together (useful for terminals) keys = { q = "hide", gf = function(self) local f = vim.fn.findfile(vim.fn.expand(""), "**") if f == "" then Snacks.notify.warn("No file under cursor") else self:hide() vim.schedule(function() vim.cmd("e " .. f) end) end end, term_normal = { "", function(self) self.esc_timer = self.esc_timer or (vim.uv or vim.loop).new_timer() if self.esc_timer:is_active() then self.esc_timer:stop() vim.cmd("stopinsert") else self.esc_timer:start(200, 0, function() end) return "" end end, mode = "t", expr = true, desc = "Double escape to normal mode", }, }, }) ---@type table local terminals = setmetatable({}, { __mode = "v", }) local function jobstart(cmd, opts) opts = opts or {} local fn = vim.fn.jobstart if vim.fn.termopen then opts.term = nil fn = vim.fn.termopen end return fn(cmd, vim.tbl_isempty(opts) and vim.empty_dict() or opts) end --- Open a new terminal window. ---@param cmd? string | string[] ---@param opts? snacks.terminal.Opts function M.open(cmd, opts) opts = Snacks.config.get("terminal", defaults --[[@as snacks.terminal.Opts]], opts) local id = opts.count or vim.v.count1 opts.win = Snacks.win.resolve("terminal", { position = cmd and "float" or "bottom", }, opts.win, { show = false }) opts = vim.deepcopy(opts) opts.win.wo.winbar = opts.win.wo.winbar or (opts.win.position == "float" and "" or (id .. ": %{get(b:, 'term_title', '')}")) if opts.override then return opts.override(cmd, opts) end local interactive = opts.interactive ~= false local auto_insert = opts.auto_insert or (opts.auto_insert == nil and interactive) local start_insert = opts.start_insert or (opts.start_insert == nil and interactive) local auto_close = opts.auto_close or (opts.auto_close == nil and interactive) local on_buf = opts.win and opts.win.on_buf ---@param self snacks.terminal opts.win.on_buf = function(self) self.cmd = cmd vim.b[self.buf].snacks_terminal = { cmd = cmd, id = id, cwd = opts.cwd, env = opts.env } if on_buf then on_buf(self) end end local on_win = opts.win and opts.win.on_win ---@param self snacks.terminal opts.win.on_win = function(self) if start_insert and vim.api.nvim_get_current_buf() == self.buf then vim.cmd.startinsert() end if on_win then on_win(self) end end local terminal = Snacks.win(opts.win) local tid = M.tid(cmd, opts) terminals[tid] = terminal if auto_insert then terminal:on("BufEnter", function() vim.cmd.startinsert() end, { buf = true }) end if auto_close then terminal:on("TermClose", function() if type(vim.v.event) == "table" and vim.v.event.status ~= 0 then Snacks.notify.error("Terminal exited with code " .. vim.v.event.status .. ".\nCheck for any errors.") return end terminal:close() vim.cmd.checktime() end, { buf = true }) end terminal:on("ExitPre", function() terminal:close() end) terminal:on("BufWipeout", function() terminals[tid] = nil vim.schedule(function() terminal:close() end) end, { buf = true }) terminal:show() vim.api.nvim_buf_call(terminal.buf, function() jobstart(cmd or M.parse(opts.shell or vim.o.shell), { cwd = opts.cwd, env = opts.env, term = true, }) end) vim.cmd("noh") return terminal end --- Get a terminal id based on the `cmd`, `cwd`, `env` and `vim.v.count1` options. ---@param cmd? string | string[] ---@param opts? snacks.terminal.Opts function M.tid(cmd, opts) opts = opts or {} return vim.inspect({ cmd = type(cmd) == "table" and cmd or { cmd }, cwd = opts.cwd or vim.fn.getcwd(0), env = opts.env, count = opts.count or vim.v.count1, }) end --- Get or create a terminal window. --- The terminal id is based on the `cmd`, `cwd`, `env` and `vim.v.count1` options. --- `opts.create` defaults to `true`. ---@param cmd? string | string[] ---@param opts? snacks.terminal.Opts| {create?: boolean} ---@return snacks.win? terminal, boolean? created function M.get(cmd, opts) opts = opts or {} local id = M.tid(cmd, opts) local created = false if not (terminals[id] and terminals[id]:buf_valid()) and (opts.create ~= false) then local ret = M.open(cmd, opts) ret:on("BufWipeout", function() terminals[id] = nil end, { buf = true }) assert(terminals[id], "Terminal was not created") created = true end return terminals[id], created end ---@return snacks.win[] function M.list() return vim.tbl_filter(function(t) return t:buf_valid() end, terminals) end --- Toggle a terminal window. --- The terminal id is based on the `cmd`, `cwd`, `env` and `vim.v.count1` options. ---@param cmd? string | string[] ---@param opts? snacks.terminal.Opts function M.toggle(cmd, opts) local terminal, created = M.get(cmd, opts) return created and terminal or assert(terminal):toggle() end --- Focus a terminal window. If already focused, hide it. --- The terminal id is based on the `cmd`, `cwd`, `env` and `vim.v.count1` options. ---@param cmd? string | string[] ---@param opts? snacks.terminal.Opts function M.focus(cmd, opts) local terminal, created = M.get(cmd, opts) if terminal and not created and vim.api.nvim_get_current_buf() == terminal.buf then terminal:hide() return terminal, created end return created and terminal or assert(terminal):show():focus() end --- Parses a shell command into a table of arguments. --- - spaces inside quotes (only double quotes are supported) are preserved --- - backslash ---@private ---@param cmd string|string[] ---@return string[] function M.parse(cmd) if type(cmd) == "table" then return cmd end local args = {} local in_quotes, escape_next, current = false, false, "" local function add() if #current > 0 then table.insert(args, current) current = "" end end for i = 1, #cmd do local char = cmd:sub(i, i) if escape_next then current = current .. ((char == '"' or char == "\\") and "" or "\\") .. char escape_next = false elseif char == "\\" and in_quotes then escape_next = true elseif char == '"' then in_quotes = not in_quotes elseif char:find("[ \t]") and not in_quotes then add() else current = current .. char end end add() return args end --- Colorize the current buffer. --- Replaces ansii color codes with the actual colors. --- --- Example: --- --- ```sh --- ls -la --color=always | nvim - -c "lua Snacks.terminal.colorize()" --- ``` function M.colorize() vim.wo.number = false vim.wo.relativenumber = false vim.wo.statuscolumn = "" vim.wo.signcolumn = "no" vim.opt.listchars = { space = " " } local buf = vim.api.nvim_get_current_buf() local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) while #lines > 0 and vim.trim(lines[#lines]) == "" do lines[#lines] = nil end vim.api.nvim_buf_set_lines(buf, 0, -1, false, {}) vim.api.nvim_chan_send(vim.api.nvim_open_term(buf, {}), table.concat(lines, "\r\n")) vim.keymap.set("n", "q", "q", { silent = true, buffer = buf }) vim.api.nvim_create_autocmd("TextChanged", { buffer = buf, callback = function() pcall(vim.api.nvim_win_set_cursor, 0, { #lines, 0 }) end, }) vim.api.nvim_create_autocmd("TermEnter", { buffer = buf, command = "stopinsert" }) end ---@private function M.health() local opts = Snacks.config.get("terminal", defaults --[[@as snacks.terminal.Opts]]) local cmd = M.parse(opts.shell or vim.o.shell) local ok = cmd[1] and (vim.fn.executable(cmd[1]) == 1) local msg = ("shell %s\n- `vim.o.shell`: %s\n- `parsed`: %s"):format( ok and "configured" or "not found", vim.o.shell, vim.inspect(cmd) ) Snacks.health[ok and "ok" or "error"](msg) end return M ================================================ FILE: lua/snacks/toggle.lua ================================================ ---@class snacks.toggle ---@overload fun(... :snacks.toggle.Opts): snacks.toggle.Class local M = setmetatable({}, { __call = function(M, ...) return M.new(...) end, }) M.meta = { desc = "Toggle keymaps integrated with which-key icons / colors", } ---@class snacks.toggle.Config ---@field icon? string|{ enabled: string, disabled: string } ---@field color? string|{ enabled: string, disabled: string } ---@field wk_desc? string|{ enabled: string, disabled: string } ---@field map? fun(mode: string|string[], lhs: string, rhs: string|fun(), opts?: vim.keymap.set.Opts) ---@field which_key? boolean ---@field notify? boolean|fun(state:boolean, opts: snacks.toggle.Opts) local defaults = { map = vim.keymap.set, -- keymap.set function to use which_key = true, -- integrate with which-key to show enabled/disabled icons and colors notify = true, -- show a notification when toggling -- icons for enabled/disabled states icon = { enabled = " ", disabled = " ", }, -- colors for enabled/disabled states color = { enabled = "green", disabled = "yellow", }, wk_desc = { enabled = "Disable ", disabled = "Enable ", }, } ---@type table M.toggles = {} ---@class snacks.toggle.Opts: snacks.toggle.Config ---@field id? string ---@field name string ---@field get fun():boolean ---@field set fun(state:boolean) ---@class snacks.toggle.Class ---@field opts snacks.toggle.Opts local Toggle = {} Toggle.__index = Toggle ---@param ... snacks.toggle.Opts function M.new(...) ---@type snacks.toggle.Class local self = setmetatable({}, Toggle) self.opts = Snacks.config.get("toggle", defaults, ...) --[[@as snacks.toggle.Opts]] local id = self.opts.id or self.opts.name:lower():gsub("%W+", "_"):gsub("_+$", ""):gsub("^_+", "") self.opts.id = id M.toggles[id] = self return self end ---@param id string ---@return snacks.toggle.Class? function M.get(id) if not M.toggles[id] and M[id] then M[id]() end return M.toggles[id] end function Toggle:get() local ok, ret = pcall(self.opts.get) if not ok then Snacks.notify.error({ "Failed to get state for `" .. self.opts.name .. "`:\n", ret --[[@as string]], }, { title = self.opts.name, once = true }) return false end return ret end ---@param state boolean function Toggle:set(state) local ok, err = pcall(self.opts.set, state) ---@type boolean, string? if not ok then Snacks.notify.error({ "Failed to set state for `" .. self.opts.name .. "`:\n", err --[[@as string]], }, { title = self.opts.name, once = true }) end end function Toggle:toggle() local state = not self:get() self:set(state) if not self.opts.notify then return end if type(self.opts.notify) == "function" then self.opts.notify(state, self.opts) return end Snacks.notify( (state and "Enabled" or "Disabled") .. " **" .. self.opts.name .. "**", { title = self.opts.name, level = state and vim.log.levels.INFO or vim.log.levels.WARN } ) end ---@param keys string ---@param opts? vim.keymap.set.Opts | { mode: string|string[]} function Toggle:map(keys, opts) opts = opts or {} local mode = opts.mode or "n" opts.mode = nil opts.desc = opts.desc or ("Toggle " .. self.opts.name) self.opts.map(mode, keys, function() self:toggle() end, opts) if self.opts.which_key then Snacks.util.on_module("which-key", function() self:_wk(keys, mode) end) end return self end function Toggle:_wk(keys, mode) require("which-key").add({ { keys, mode = mode, real = true, icon = function() local key = self:get() and "enabled" or "disabled" return { icon = type(self.opts.icon) == "string" and self.opts.icon or self.opts.icon[key], color = type(self.opts.color) == "string" and self.opts.color or self.opts.color[key], } end, desc = function() local key = self:get() and "enabled" or "disabled" return (type(self.opts.wk_desc) == "string" and self.opts.wk_desc or self.opts.wk_desc[key]) .. self.opts.name end, }, }) end ---@param option string ---@param opts? snacks.toggle.Config | {on?: unknown, off?: unknown, global?: boolean} function M.option(option, opts) opts = opts or {} local on = opts.on == nil and true or opts.on local off = opts.off ~= nil and opts.off or false return M.new({ id = option, name = option, get = function() return vim.api.nvim_get_option_value(option, { scope = opts.global and "global" or "local" }) == on end, set = function(state) local value = state and on or off vim.api.nvim_set_option_value(option, value, { scope = opts.global and "global" or "local" }) end, }, opts) end ---@param opts? snacks.toggle.Config function M.treesitter(opts) return M.new({ id = "treesitter", name = "Treesitter Highlight", get = function() return vim.b.ts_highlight end, set = function(state) vim.treesitter[state and "start" or "stop"]() end, }, opts) end ---@param opts? snacks.toggle.Config function M.line_number(opts) local number, relativenumber = true, true return M.new({ id = "line_number", name = "Line Numbers", get = function() return vim.opt_local.number:get() or vim.opt_local.relativenumber:get() end, set = function(state) if state then vim.opt_local.number, vim.opt_local.relativenumber = number, relativenumber else number, relativenumber = vim.opt_local.number:get(), vim.opt_local.relativenumber:get() vim.opt_local.number, vim.opt_local.relativenumber = false, false end end, }, opts) end ---@param opts? snacks.toggle.Config function M.inlay_hints(opts) return M.new({ id = "inlay_hints", name = "Inlay Hints", get = function() return vim.lsp.inlay_hint.is_enabled({ bufnr = 0 }) end, set = function(state) vim.lsp.inlay_hint.enable(state, { bufnr = 0 }) end, }, opts) end ---@param opts? snacks.toggle.Config function M.diagnostics(opts) return M.new({ id = "diagnostics", name = "Diagnostics", get = function() local enabled = false if vim.diagnostic.is_enabled then enabled = vim.diagnostic.is_enabled() elseif vim.diagnostic.is_disabled then enabled = not vim.diagnostic.is_disabled() end return enabled end, set = function(state) if vim.fn.has("nvim-0.10") == 0 then if state then pcall(vim.diagnostic.enable) else pcall(vim.diagnostic.disable) end else vim.diagnostic.enable(state) end end, }, opts) end ---@private function M.health() local ok = pcall(require, "which-key") Snacks.health[ok and "ok" or "warn"](("{which-key} is %s"):format(ok and "installed" or "not installed")) end function M.profiler() return M.new({ id = "profiler", name = "Profiler", get = function() return Snacks.profiler.running() end, set = function(state) if state then Snacks.profiler.start() else Snacks.profiler.stop() end end, }) end function M.profiler_highlights() return M.new({ id = "profiler_highlights", name = "Profiler Highlights", get = function() return Snacks.profiler.ui.enabled end, set = function(state) if state then Snacks.profiler.ui.show() else Snacks.profiler.ui.hide() end end, }) end function M.indent() return M.new({ id = "indent", name = "Indent Guides", get = function() return Snacks.indent.enabled end, set = function(state) if state then Snacks.indent.enable() else Snacks.indent.disable() end end, }) end function M.dim() return M.new({ id = "dim", name = "Dimming", get = function() return Snacks.dim.enabled end, set = function(state) if state then Snacks.dim.enable() else Snacks.dim.disable() end end, }) end function M.words() return M.new({ id = "words", name = "LSP Words", get = function() return Snacks.words.enabled end, set = function(state) if state then Snacks.words.enable() else Snacks.words.disable() end end, }) end function M.scroll() return M.new({ id = "scroll", name = "Smooth Scroll", get = function() return Snacks.scroll.enabled end, set = function(state) if state then Snacks.scroll.enable() else Snacks.scroll.disable() end end, }) end function M.zen() return M.new({ id = "zen", name = "Zen Mode", get = function() return Snacks.zen.win and Snacks.zen.win:valid() or false end, set = function(state) if state then Snacks.zen() elseif Snacks.zen.win then Snacks.zen.win:close() end end, }) end function M.zoom() return M.new({ id = "zoom", name = "Zoom Mode", get = function() return Snacks.zen.win and Snacks.zen.win:valid() or false end, set = function(state) if state then Snacks.zen.zoom() elseif Snacks.zen.win then Snacks.zen.win:close() end end, }) end function M.animate() return M.new({ id = "animate", name = "Animations", get = function() return vim.g.snacks_animate ~= false end, set = function(state) vim.g.snacks_animate = state end, }) end return M ================================================ FILE: lua/snacks/util/init.lua ================================================ ---@class snacks.util ---@field spawn snacks.spawn ---@field lsp snacks.lsp local M = setmetatable({}, { ---@param M snacks.util __index = function(M, k) if vim.tbl_contains({ "spawn", "lsp" }, k) then M[k] = require("snacks.util." .. k) end return rawget(M, k) end, }) M.meta = { desc = "Utility functions for Snacks _(library)_", } M.is_win = jit.os:find("Windows") local uv = vim.uv or vim.loop local key_cache = {} ---@type table local langs = {} ---@type table ---@alias snacks.util.hl table local hl_groups = {} ---@type table vim.api.nvim_create_autocmd("ColorScheme", { group = vim.api.nvim_create_augroup("snacks_util_hl", { clear = true }), callback = function() for hl_group, hl in pairs(hl_groups) do vim.api.nvim_set_hl(0, hl_group, hl) end end, }) ---@param lang string|number|nil ---@overload fun(buf:number):string? ---@overload fun(ft:string):string? ---@return string? function M.get_lang(lang) lang = type(lang) == "number" and vim.bo[lang].filetype or lang --[[@as string?]] lang = lang and vim.treesitter.language.get_lang(lang) or lang if lang and lang ~= "" and langs[lang] == nil then local ok, ret = pcall(vim.treesitter.language.add, lang) langs[lang] = (ok and ret) or (ok and vim.fn.has("nvim-0.11") == 0) end return langs[lang] and lang or nil end --- Ensures the hl groups are always set, even after a colorscheme change. ---@param groups snacks.util.hl ---@param opts? { prefix?:string, default?:boolean, managed?:boolean } function M.set_hl(groups, opts) opts = opts or {} for hl_group, hl in pairs(groups) do hl_group = opts.prefix and opts.prefix .. hl_group or hl_group hl = type(hl) == "string" and { link = hl } or hl --[[@as vim.api.keyset.highlight]] hl.default = opts.default if opts.managed ~= false then hl_groups[hl_group] = hl end vim.api.nvim_set_hl(0, hl_group, hl) end end ---@param group string|string[] hl group to get color from ---@param prop? string property to get. Defaults to "fg" function M.color(group, prop) prop = prop or "fg" group = type(group) == "table" and group or { group } ---@cast group string[] for _, g in ipairs(group) do local hl = vim.api.nvim_get_hl(0, { name = g, link = false, create = false }) if hl[prop] then return string.format("#%06x", hl[prop]) end end end --- Set window-local options. ---@param win number ---@param wo vim.wo|{}|{winhighlight: string|table} function M.wo(win, wo) for k, v in pairs(wo or {}) do if k == "winhighlight" and type(v) == "table" then local parts = {} ---@type string[] for kk, vv in pairs(v) do if vv ~= "" then parts[#parts + 1] = ("%s:%s"):format(kk, vv) end end v = table.concat(parts, ",") end vim.api.nvim_set_option_value(k, v, { scope = "local", win = win }) end end --- Set buffer-local options. ---@param buf number ---@param bo vim.bo|{} function M.bo(buf, bo) for k, v in pairs(bo or {}) do vim.api.nvim_set_option_value(k, v, { buf = buf }) end end --- Merges vim.wo.winhighlight options. --- Option values can be a string or a dictionary. ---@param ... string|table function M.winhl(...) local ret = {} ---@type table[] for i = 1, select("#", ...) do local winhl = select(i, ...) if type(winhl) == "string" then winhl = vim.trim(winhl) local parts = winhl == "" and {} or vim.split(winhl, ",") winhl = {} for _, p in ipairs(parts) do local k, v = p:match("^%s*(.-):(.-)%s*$") if k and v then winhl[k] = v end end end ret[#ret + 1] = winhl end return Snacks.config.merge(unpack(ret)) end --- Get an icon from `mini.icons` or `nvim-web-devicons`. ---@param name string ---@param cat? string "file"|"filetype"|"extension"|"directory" ---@param opts? { fallback?: {dir?:string, file?:string} } ---@return string, string? function M.icon(name, cat, opts) opts = opts or {} opts.fallback = opts.fallback or {} local try = { function() return MiniIcons.get(cat or "file", name) end, function() if cat == "directory" then return opts.fallback.dir or "󰉋 ", "Directory" end local Icons = require("nvim-web-devicons") if cat == "filetype" then return Icons.get_icon_by_filetype(name, { default = false }) elseif cat == "file" then local ext = name:match("%.(%w+)$") return Icons.get_icon(name, ext, { default = false }) --[[@as string, string]] elseif cat == "extension" then return Icons.get_icon(nil, name, { default = false }) --[[@as string, string]] end end, } for _, fn in ipairs(try) do local ret = { pcall(fn) } if ret[1] and ret[2] then return ret[2], ret[3] end end return opts.fallback.file or "󰈔 " end -- Encodes a string to be used as a file name. ---@param str string function M.file_encode(str) return str:gsub("([^%w%-_%.\t ])", function(c) return string.format("_%%%02X", string.byte(c)) end) end -- Decodes a file name to a string. ---@param str string function M.file_decode(str) return str:gsub("_%%(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end) end ---@param fg string foreground color ---@param bg string background color ---@param alpha number number between 0 and 1. 0 results in bg, 1 results in fg function M.blend(fg, bg, alpha) local bg_rgb = { tonumber(bg:sub(2, 3), 16), tonumber(bg:sub(4, 5), 16), tonumber(bg:sub(6, 7), 16) } local fg_rgb = { tonumber(fg:sub(2, 3), 16), tonumber(fg:sub(4, 5), 16), tonumber(fg:sub(6, 7), 16) } local blend = function(i) local ret = (alpha * fg_rgb[i] + ((1 - alpha) * bg_rgb[i])) return math.floor(math.min(math.max(0, ret), 255) + 0.5) end return string.format("#%02x%02x%02x", blend(1), blend(2), blend(3)) end local transparent ---@type boolean? --- Check if the colorscheme is transparent. function M.is_transparent() if transparent == nil then transparent = M.color("Normal", "bg") == nil vim.api.nvim_create_autocmd("ColorScheme", { group = vim.api.nvim_create_augroup("snacks_util_transparent", { clear = true }), callback = function() transparent = nil end, }) end return transparent end --- Redraw the range of lines in the window. --- Optimized for Neovim >= 0.10 ---@param win number ---@param from number -- 1-indexed, inclusive ---@param to number -- 1-indexed, inclusive function M.redraw_range(win, from, to) if vim.api.nvim__redraw and vim.api.nvim_win_is_valid(win) then vim.api.nvim__redraw({ win = win, range = { math.floor(from - 1), math.floor(to) }, valid = true, flush = false }) else vim.cmd([[redraw!]]) end end --- Redraw the window. --- Optimized for Neovim >= 0.10 ---@param win number function M.redraw(win) if vim.api.nvim__redraw then vim.api.nvim__redraw({ win = win, valid = false, flush = false }) else vim.cmd([[redraw!]]) end end local mod_timer = assert(uv.new_timer()) local mod_cb = {} ---@type table ---@return boolean waiting local function mod_check() for modname, cbs in pairs(mod_cb) do if package.loaded[modname] then mod_cb[modname] = nil for _, cb in ipairs(cbs) do cb(modname) end end end return next(mod_cb) ~= nil end --- Call a function when a module is loaded. --- The callback is called immediately if the module is already loaded. --- Otherwise, it is called when the module is loaded. ---@param modname string ---@param cb fun(modname:string) function M.on_module(modname, cb) mod_cb[modname] = mod_cb[modname] or {} table.insert(mod_cb[modname], cb) if mod_check() then mod_timer:start( 100, 100, vim.schedule_wrap(function() return not mod_check() and mod_timer:stop() end) ) end end ---@param str string function M.keycode(str) return vim.api.nvim_replace_termcodes(str, true, true, true) end --- Get a buffer or global variable. ---@generic T ---@param buf? number ---@param name string ---@param default? T ---@return T function M.var(buf, name, default) local ok, ret = pcall(function() return vim.b[buf or 0][name] end) if ok and ret ~= nil then return ret end ret = vim.g[name] if ret ~= nil then return ret end return default end local keys = {} ---@type table local on_key_ns ---@type number? ---@param key string ---@param cb fun(key:string) function M.on_key(key, cb) local code = M.keycode(key) keys[code] = keys[code] or {} table.insert(keys[code], cb) on_key_ns = on_key_ns or vim.on_key(function(resolved, typed) for _, c in ipairs(keys[typed or resolved] or {}) do pcall(c, typed) end end) end ---@generic T ---@param t T ---@return { value?:T }|fun():T? function M.ref(t) return setmetatable({ value = t }, { __mode = "v", __call = function(m) return m.value end, }) end ---@generic T ---@param fn T ---@param opts? {ms?:number} ---@return T function M.throttle(fn, opts) local timer = assert(uv.new_timer()) local trailing, ms = false, opts and opts.ms or 20 local running = false local function run() running = true if vim.in_fast_event() then return vim.schedule(run) end fn() running = false end return function() if running or timer:is_active() then trailing = true return end trailing = false run() timer:start(ms, 0, function() return trailing and run() end) end end ---@generic T ---@param fn T ---@param opts? {ms?:number} ---@return T function M.debounce(fn, opts) local timer = assert(uv.new_timer()) local ms = opts and opts.ms or 20 return function() timer:start(ms, 0, vim.schedule_wrap(fn)) end end ---@param key string function M.normkey(key) if key_cache[key] then return key_cache[key] end local function norm(v) local l = v:lower() if l == "leader" then return M.normkey("") elseif l == "localleader" then return M.normkey("") end return vim.fn.keytrans(M.keycode(("<%s>"):format(v))) end local orig = key key = key:gsub("", "<") local lower = key:lower() if lower == "" then key = vim.g.mapleader key = vim.fn.keytrans((not key or key == "") and "\\" or key) elseif lower == "" then key = vim.g.maplocalleader key = vim.fn.keytrans((not key or key == "") and "\\" or key) else local extracted = {} ---@type string[] local function extract(v) v = v:sub(2, -2) if v:sub(2, 2) == "-" and v:sub(1, 1):find("[aAmMcCsS]") then local m = v:sub(1, 1):upper() m = m == "A" and "M" or m local k = v:sub(3) if #k > 1 then return norm(v) end if m == "C" then k = k:upper() elseif m == "S" then return k:upper() end return ("<%s-%s>"):format(m, k) end return norm(v) end local placeholder = "_#_" ---@param v string key = key:gsub("(%b<>)", function(v) table.insert(extracted, extract(v)) return placeholder end) key = vim.fn.keytrans(key):gsub("", "<") -- Restore extracted %b<> sequences local i = 0 key = key:gsub(placeholder, function() i = i + 1 return extracted[i] or "" end) end key_cache[orig] = key key_cache[key] = key return key end ---@param win? number function M.is_float(win) return vim.api.nvim_win_get_config(win or 0).relative ~= "" end function M.spinner() local spinner = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" } return spinner[math.floor(uv.hrtime() / (1e6 * 80)) % #spinner + 1] end M.base64 = vim.base64 and vim.base64.encode or function(data) data = tostring(data) local bit = require("bit") local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local b64, len = "", #data for i = 1, len, 3 do local a, b, c = data:byte(i, i + 2) local buffer = bit.bor(bit.lshift(a, 16), bit.lshift(b or 0, 8), c or 0) for j = 0, 3 do local index = bit.rshift(buffer, (3 - j) * 6) % 64 b64 = b64 .. b64chars:sub(index + 1, index + 1) end end local padding = (3 - len % 3) % 3 b64 = b64:sub(1, -1 - padding) .. ("="):rep(padding) return b64 end --- Parse async when available. ---@param parser vim.treesitter.LanguageTree ---@param range boolean|Range|nil: Parse this range in the parser's source. ---@param on_parse fun(err?: string, trees?: table) Function invoked when parsing completes. function M.parse(parser, range, on_parse) ---@diagnostic disable-next-line: invisible local have_async = vim.fn.has("nvim-0.11.4") == 1 or (vim.treesitter.languagetree or {})._async_parse ~= nil if have_async then parser:parse(range, on_parse) else parser:parse(range) on_parse(nil, parser:trees()) end end ---@param handle? uv.uv_handle_t|uv.uv_timer_t function M.stop(handle) if handle and not handle:is_closing() then if handle.stop then handle:stop() end handle:close() end end --- Better validation to check if path is a dir or a file ---@param path string ---@return "directory"|"file" function M.path_type(path) local stat = uv.fs_stat(path) if stat and stat.type then return stat.type end if vim.fn.isdirectory(path) == 1 then return "directory" end return "file" end return M ================================================ FILE: lua/snacks/util/job.lua ================================================ ---@class vim.fn.jobstart.Opts ---@field clear_env? boolean ---@field cwd? string ---@field detach? boolean ---@field env? table ---@field height? number ---@field on_exit? fun(job_id: number, exit_code: number, event_type: string) ---@field on_stdout? fun(job_id: number, data: string[], event_type: string) ---@field on_stderr? fun(job_id: number, data: string[], event_type: string) ---@field overlapped? boolean ---@field pty? boolean ---@field rpc? boolean ---@field stderr_buffered? boolean ---@field stdin? "pipe" | "null" ---@field stdout_buffered? boolean ---@field term? boolean ---@field width? number ---@field sync? boolean ---@class snacks.job.Opts: vim.fn.jobstart.Opts ---@field input? string ---@field output? string ---@field debug? boolean ---@field ansi? boolean ---@field start? boolean ---@field on_line? fun(job_id: number, text: string, line: number) ---@field on_lines? fun(job_id: number, lines: string[]) local M = {} ---@param opts snacks.job.Opts|vim.fn.jobstart.Opts ---@return vim.fn.jobstart.Opts local function get_opts(opts) opts = vim.deepcopy(opts) opts.input = nil if opts.term == false then opts.term = nil end return vim.tbl_isempty(opts) and vim.empty_dict() or opts end ---@generic F: function ---@param fn F ---@param orig? F ---@return F local function wrap(fn, orig) return function(...) fn(...) if orig then orig(...) end end end ---@param cmd string | string[] ---@param opts? vim.fn.jobstart.Opts local function jobstart(cmd, opts) opts = opts or {} if opts.term and vim.fn.has("nvim-0.11.4") == 0 then opts.term = nil ---@diagnostic disable-next-line: deprecated return vim.fn.termopen(cmd, get_opts(opts)) end return vim.fn.jobstart(cmd, get_opts(opts)) end ---@class snacks.Job ---@field buf number ---@field cmd string | string[] ---@field opts snacks.job.Opts ---@field lines string[] ---@field line number ---@field id? number ---@field chan? number ---@field killed? boolean local Job = {} Job.__index = Job ---@param buf number ---@param cmd string | string[] ---@param opts? snacks.job.Opts function Job.new(buf, cmd, opts) local self = setmetatable({}, Job) self.buf = buf self.opts = opts or {} self.cmd = cmd self.lines = { "" } self.line = 1 self:setup() if self.opts.start ~= false then self:start() end return self end function Job:setup() self.opts.term = self.opts.term ~= false self.opts.sync = self.opts.sync ~= false if self.opts.term and self.opts.input then -- NOTE: term jobs do not support input self.opts.term, self.opts.ansi = false, true end local on_output = function(_, data) self:on_output(data) end self.opts.on_stdout = wrap(on_output, self.opts.on_stdout) self.opts.on_stderr = wrap(on_output, self.opts.on_stderr) self.opts.on_exit = wrap(function(_, code) self:on_exit(code) end, self.opts.on_exit) if not self.opts.term and not self.opts.ansi then self.opts.on_line = self.opts.on_line or function(_, text, line) self:on_line(text, line) end end end function Job:on_exit(code) if not self:buf_valid() then return end self:emit() if self.opts.on_lines then self.opts.on_lines(self.id, self.lines) end if self.opts.term then self:hide_process_exited() end self:set_cursor() if not self.killed and code ~= 0 then self:error( ("Job exited with code `%s`"):format(code), ("\n- `vim.o.shell = %q`\n\nOutput:\n%s"):format(vim.o.shell, vim.trim(table.concat(self.lines, "\n"))) ) end end function Job:set_cursor() if not self:buf_valid() then return end for _, win in ipairs(vim.fn.win_findbuf(self.buf)) do vim.api.nvim_win_set_cursor(win, { 1, 0 }) end end ---@param text string ---@param line number function Job:on_line(text, line) if self:buf_valid() then vim.bo[self.buf].modifiable = true vim.api.nvim_buf_set_lines(self.buf, line == 1 and 0 or -1, -1, true, { text }) vim.bo[self.buf].modifiable = false end end ---@param msg string ---@param footer? string function Job:error(msg, footer) Snacks.debug.cmd({ title = "Job Error", level = vim.log.levels.ERROR, header = msg, footer = footer, cmd = self.cmd, cwd = self.opts.cwd, group = true, }) end function Job:start() if self.opts.debug then vim.schedule(function() Snacks.debug.cmd({ cmd = self.cmd, cwd = self.opts.cwd, group = true, props = { cwd = self.opts.cwd, term = self.opts.term, pty = self.opts.pty, input = self.opts.input and "", output = self.opts.output and "", ansi = self.opts.ansi, }, }) end) end if self.opts.output or (not self.opts.term and self.opts.ansi) then self.chan = vim.api.nvim_open_term(self.buf, {}) if self.opts.output then vim.api.nvim_chan_send(self.chan, self.opts.output) return end end self.id = vim.api.nvim_buf_call(self.buf, function() return jobstart(self.cmd, self.opts) end) if self.id <= 0 then self.id = nil return self:error("Failed to start job") end vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, { buffer = self.buf, callback = function() self:stop() end, }) if self.opts.input then vim.fn.chansend(self.id, self.opts.input .. "\n") vim.fn.chanclose(self.id, "stdin") end end function Job:stop() if self.id then self.killed = true vim.fn.jobstop(self.id) end end function Job:set_lines(from, to, lines) if self:buf_valid() then vim.bo[self.buf].modifiable = true vim.api.nvim_buf_set_lines(self.buf, from, to, true, lines) vim.bo[self.buf].modifiable = false end end function Job:hide_process_exited() local timer = assert(vim.uv.new_timer()) local stop = function() return timer:is_active() and timer:stop() == 0 and timer:close() end local check = function() if self:buf_valid() then for i, line in ipairs(vim.api.nvim_buf_get_lines(self.buf, 0, -1, true)) do if line:find("^%[Process exited 0%]") then self:set_lines(i - 1, i, {}) return stop() end end end end timer:start(30, 30, vim.schedule_wrap(check)) vim.defer_fn(stop, 1000) end function Job:running() return self.id and vim.fn.jobwait({ self.id }, 0)[1] == -1 end function Job:buf_valid() return self.buf and vim.api.nvim_buf_is_valid(self.buf) end function Job:emit() if not self:buf_valid() then return end while self.line < #self.lines do self.lines[self.line] = self.lines[self.line]:gsub("\r$", "") if self.opts.on_line then self.opts.on_line(self.id, self.lines[self.line], self.line) end self.line = self.line + 1 end end ---@param data string[] function Job:on_output(data) if not self:buf_valid() then return end if self.chan then vim.api.nvim_chan_send(self.chan, table.concat(data, "\n")) end self.lines[#self.lines] = self.lines[#self.lines] .. data[1] vim.list_extend(self.lines, data, 2) self:emit() end function Job:refresh() if not self:buf_valid() then return end -- HACK: this forces a refresh of the terminal buffer and prevents flickering vim.bo[self.buf].scrollback = 9999 vim.bo[self.buf].scrollback = 9998 end M.new = Job.new return M ================================================ FILE: lua/snacks/util/lsp.lua ================================================ ---@class snacks.lsp local M = {} ---@alias snacks.lsp.handler.cb fun(buf: number, client: vim.lsp.Client):any? ---@class snacks.lsp.Handler ---@field filter vim.lsp.get_clients.Filter ---@field cb snacks.lsp.handler.cb ---@field done table local _handlers = {} ---@type snacks.lsp.Handler[] local did_setup = false ---@param filter vim.lsp.get_clients.Filter local function _handle(filter) ---@param h snacks.lsp.Handler local handlers = vim.tbl_filter(function(h) ---@diagnostic disable-next-line: no-unknown for k, v in pairs(filter) do if h.filter[k] ~= nil and h.filter[k] ~= v then return false end end return true end, _handlers) if #handlers == 0 then return end for _, state in ipairs(handlers) do local f = vim.deepcopy(state.filter) f = vim.tbl_extend("force", f, filter) local clients = vim.lsp.get_clients(f) for _, client in ipairs(clients) do for buf in pairs(client.attached_buffers) do local key = ("%d:%d"):format(client.id, buf) if not state.done[key] then state.done[key] = true local ok, err = pcall(state.cb, buf, client) if not ok then vim.schedule(function() Snacks.notify.error(("Error in handler:\n%s\n```lua\n%s\n```"):format(err, vim.inspect(state.filter))) end) end end end end end end local function setup() if did_setup then return end did_setup = true local register_capability = vim.lsp.handlers["client/registerCapability"] vim.lsp.handlers["client/registerCapability"] = function(err, res, ctx) ---@cast res lsp.RegistrationParams local ret = register_capability(err, res, ctx) ---@type any vim.schedule(function() for _, m in ipairs(res.registrations or {}) do _handle({ method = m.method, id = ctx.client_id }) end end) return ret end local group = vim.api.nvim_create_augroup("snacks.lsp.on_attach", { clear = true }) vim.api.nvim_create_autocmd("LspAttach", { group = group, callback = function(ev) vim.schedule(function() _handle({ id = ev.data.client_id, buffer = ev.buf }) end) end, }) vim.api.nvim_create_autocmd("LspDetach", { group = group, callback = function(ev) local key = ("%d:%d"):format(ev.data.client_id, ev.buf) for _, state in ipairs(_handlers) do state.done[key] = nil end end, }) end ---@param filter? vim.lsp.get_clients.Filter ---@param cb snacks.lsp.handler.cb ---@overload fun(cb: snacks.lsp.handler.cb) function M.on(filter, cb) setup() filter = filter or {} if type(filter) == "function" then cb = filter filter = {} end table.insert(_handlers, { filter = filter, cb = cb, done = {} }) _handle(filter) end return M ================================================ FILE: lua/snacks/util/spawn.lua ================================================ local Async = require("snacks.picker.util.async") ---@class snacks.spawn local M = {} local uv = vim.uv or vim.loop ---@class snacks.spawn.Config: uv.spawn.options,{} ---@field cmd string ---@field args? (string|number)[] ---@field timeout? number ---@field run? boolean ---@field debug? boolean ---@field input? string ---@field on_stdout? fun(proc: snacks.spawn.Proc, data: string) ---@field on_stderr? fun(proc: snacks.spawn.Proc, data: string) ---@field on_exit? fun(proc: snacks.spawn.Proc, err: boolean) ---@class snacks.spawn.Multi: snacks.spawn.Config,{} ---@field cmd? nil ---@field on_exit? fun(procs: snacks.spawn.Proc[], err: boolean) ---@class snacks.spawn.Proc: snacks.picker.Waitable ---@field opts snacks.spawn.Config ---@field handle? uv.uv_process_t ---@field stdout uv.uv_pipe_t ---@field stderr uv.uv_pipe_t ---@field stdin? uv.uv_pipe_t ---@field code? number ---@field signal? number ---@field timer? uv.uv_timer_t ---@field aborted? boolean ---@field data table ---@field async? snacks.picker.Async ---@field did_exit? boolean local Proc = {} Proc.__index = Proc ---@param handle uv.uv_handle_t? local function close(handle) if handle and not handle:is_closing() then handle:close() end end ---@param opts snacks.spawn.Config function Proc.new(opts) local self = setmetatable({}, Proc) self.opts = opts self.code, self.signal = 0, 0 self.data = {} if opts.run ~= false then self:run() end return self end function Proc:running() return self.handle and not self.handle:is_closing() end ---@param signal? string|number function Proc:kill(signal) close(self.stdout) close(self.stderr) if self:running() then self.aborted = true self.handle:kill(signal or "sigterm") end end function Proc:failed() if self.aborted then return true end if self:running() then return false end return self.code ~= 0 or self.signal ~= 0 end ---@param opts? snacks.debug.cmd|{} function Proc:debug(opts) ---@type snacks.debug.cmd opts = Snacks.config.merge({}, opts or {}, { cmd = self.opts.cmd, args = self.opts.args, cwd = self.opts.cwd, }) opts.props = opts.props or {} if not self:running() then opts.props.code = ("`%d`"):format(self.code) opts.props.signal = ("`%d`"):format(self.signal) if self.aborted then opts.props.aborted = "`true`" end end if self:failed() then opts.level = "error" end local out = vim.trim(self:out() .. "\n" .. self:err()) if out ~= "" then opts.footer = "# Output\n```\n" .. out .. "\n```" end return Snacks.debug.cmd(opts) end function Proc:setup_async() self.async = Async.running() if self.async then self.async:on("abort", function() if self:running() then self:kill() end end) end end ---@async function Proc:wait() self:setup_async() assert(self.async, "Not in an async context") assert(self.async == Async.running(), "Not in the current async context") while not self.did_exit or self:running() do self.async:suspend() end return self end function Proc:run() assert(not self.handle, "already running") if self.aborted then return self:on_exit() end self:setup_async() self.stdout = assert(uv.new_pipe()) self.stderr = assert(uv.new_pipe()) self.stdin = self.opts.input and assert(uv.new_pipe()) or nil self.data = { [self.stdout] = {}, [self.stderr] = {} } if self.opts.debug then vim.schedule(function() self:debug() end) end local opts = vim.tbl_deep_extend("force", self.opts, { stdio = { self.stdin, self.stdout, self.stderr }, hide = true, args = vim.tbl_map(tostring, self.opts.args or {}), }) self.handle = uv.spawn(self.opts.cmd, opts, function(code, signal) self.code = code self.signal = signal self:on_exit() end) if not self.handle then self.code = 1 self.data[self.stderr] = { "Failed to spawn " .. self.opts.cmd } close(self.stdout) close(self.stderr) return self:on_exit() end if self.stdin and self.opts.input then self.stdin:write(self.opts.input) self.stdin:shutdown() self.stdin:close() end if self.opts.timeout then self.timer = assert(uv.new_timer()) self.timer:start(self.opts.timeout, 0, function() self:kill("sigterm") end) end for _, handle in ipairs({ self.stdout, self.stderr }) do handle:read_start(function(err, data) assert(not err, err) if data then self:on_data(data, handle) else close(handle) end end) end end function Proc:json() return vim.json.decode(self:out()) end function Proc:out() return table.concat(self.data[self.stdout] or {}) end function Proc:err() return table.concat(self.data[self.stderr] or {}) end function Proc:lines() return vim.split(self:out(), "\n", { plain = true }) end ---@param data string ---@param handle uv.uv_pipe_t function Proc:on_data(data, handle) table.insert(self.data[handle], data) if self.opts.on_stdout and handle == self.stdout then self.opts.on_stdout(self, data) elseif self.opts.on_stderr and handle == self.stderr then self.opts.on_stderr(self, data) end end function Proc:on_exit() close(self.timer) close(self.handle) local check = assert(uv.new_check()) check:start(function() for _, handle in ipairs({ self.stdout, self.stderr }) do if handle and not handle:is_closing() then return end end check:stop() close(check) close(self.stdout) close(self.stderr) if self.opts.on_exit then self.opts.on_exit(self, self.code ~= 0 or self.signal ~= 0 or self.aborted or false) end self.did_exit = true if self.async then self.async:resume() end end) end ---@param procs snacks.spawn.Proc[] ---@param opts? snacks.spawn.Multi function M.multi(procs, opts) if #procs == 0 then return end opts = opts or {} local current = 0 local function done() if opts.on_exit then opts.on_exit(procs, procs[current]:failed()) end end local function next() current = current + 1 assert(current <= #procs, "current > #procs") local proc = procs[current] proc.opts = Snacks.config.merge(vim.deepcopy(opts), proc.opts, { on_exit = function(_, err) if err or current == #procs then done() else next() end end, }) proc:run() end ---@type snacks.spawn.Proc|{procs: snacks.spawn.Proc[]} local ret = setmetatable({ procs = procs, run = next, }, { __index = function(_, k) return procs[current][k] end, }) if opts.run ~= false then next() end return ret end M.new = Proc.new ---@param cmd string[] ---@async function M.exec(cmd) return vim.trim(M.new({ cmd = cmd[1], args = vim.list_slice(cmd, 2), stdout_buffered = true, stderr_buffered = true, }) :wait() :out()) end return M ================================================ FILE: lua/snacks/win.lua ================================================ ---@class snacks.win ---@field id number ---@field buf? number ---@field scratch_buf? number ---@field win? number ---@field opts snacks.win.Config ---@field augroup? number ---@field backdrop? snacks.win ---@field keys snacks.win.Keys[] ---@field events (snacks.win.Event|{event:string|string[]})[] ---@field meta table ---@field closed? boolean ---@overload fun(opts? :snacks.win.Config|{}): snacks.win local M = setmetatable({}, { __call = function(t, ...) return t.new(...) end, }) M.__index = M M.meta = { desc = "Create and manage floating windows or splits", } ---@class snacks.win.Keys: vim.api.keyset.keymap ---@field [1]? string ---@field [2]? string|string[]|fun(self: snacks.win): string? ---@field mode? string|string[] ---@class snacks.win.Event: vim.api.keyset.create_autocmd ---@field buf? true ---@field win? true ---@field callback? fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean? ---@class snacks.win.Backdrop ---@field bg? string ---@field blend? number ---@field transparent? boolean defaults to true ---@field win? snacks.win.Config overrides the backdrop window config ---@class snacks.win.Dim ---@field width number width of the window, without borders ---@field height number height of the window, without borders ---@field row number row of the window (0-indexed) ---@field col number column of the window (0-indexed) ---@field border? boolean whether the window has a border ---@alias snacks.win.Action.fn fun(self: snacks.win):(boolean|string?) ---@alias snacks.win.Action.spec snacks.win.Action|snacks.win.Action.fn ---@class snacks.win.Action ---@field action snacks.win.Action.fn ---@field desc? string ---@class snacks.win.Config: vim.api.keyset.win_config ---@field style? string merges with config from `Snacks.config.styles[style]` ---@field show? boolean Show the window immediately (default: true) ---@field footer_keys? boolean|string[] Show keys footer. When string[], only show those keys with lhs (default: false) ---@field height? number|fun(self:snacks.win):number Height of the window. Use <1 for relative height. 0 means full height. (default: 0.9) ---@field width? number|fun(self:snacks.win):number Width of the window. Use <1 for relative width. 0 means full width. (default: 0.9) ---@field min_height? number Minimum height of the window ---@field max_height? number Maximum height of the window ---@field min_width? number Minimum width of the window ---@field max_width? number Maximum width of the window ---@field col? number|fun(self:snacks.win):number Column of the window. Use <1 for relative column. (default: center) ---@field row? number|fun(self:snacks.win):number Row of the window. Use <1 for relative row. (default: center) ---@field minimal? boolean Disable a bunch of options to make the window minimal (default: true) ---@field position? "float"|"bottom"|"top"|"left"|"right"|"current" ---@field border? "none"|"top"|"right"|"bottom"|"left"|"top_bottom"|"hpad"|"vpad"|"rounded"|"single"|"double"|"solid"|"shadow"|"bold"|string[]|false|true ---@field buf? number If set, use this buffer instead of creating a new one ---@field file? string If set, use this file instead of creating a new buffer ---@field enter? boolean Enter the window after opening (default: false) ---@field backdrop? number|false|snacks.win.Backdrop Opacity of the backdrop (default: 60) ---@field wo? vim.wo|{} window options ---@field bo? vim.bo|{} buffer options ---@field b? table buffer local variables ---@field w? table window local variables ---@field ft? string filetype to use for treesitter/syntax highlighting. Won't override existing filetype ---@field scratch_ft? string filetype to use for scratch buffers ---@field keys? table Key mappings ---@field on_buf? fun(self: snacks.win) Callback after opening the buffer ---@field on_win? fun(self: snacks.win) Callback after opening the window ---@field on_close? fun(self: snacks.win) Callback after closing the window ---@field fixbuf? boolean don't allow other buffers to be opened in this window ---@field text? string|string[]|fun():(string[]|string) Initial lines to set in the buffer ---@field actions? table Actions that can be used in key mappings ---@field resize? boolean Automatically resize the window when the editor is resized ---@field stack? boolean When enabled, multiple split windows with the same position will be stacked together (useful for terminals) local defaults = { show = true, fixbuf = true, relative = "editor", position = "float", minimal = true, wo = { winhighlight = "Normal:SnacksNormal,NormalNC:SnacksNormalNC,WinBar:SnacksWinBar,WinBarNC:SnacksWinBarNC,FloatTitle:SnacksTitle,FloatFooter:SnacksFooter,WinSeparator:SnacksWinSeparator", }, bo = {}, title_pos = "center", keys = { q = "close", }, footer_pos = "center", footer_keys = false, } Snacks.config.style("float", { position = "float", backdrop = 60, height = 0.9, width = 0.9, zindex = 50, }) Snacks.config.style("help", { position = "float", backdrop = false, border = "top", row = -1, width = 0, height = 0.3, }) Snacks.config.style("split", { position = "bottom", height = 0.4, width = 0.4, }) Snacks.config.style("minimal", { wo = { cursorcolumn = false, cursorline = false, cursorlineopt = "both", colorcolumn = "", fillchars = "eob: ,lastline:…", foldcolumn = "0", list = false, listchars = "extends:…,tab: ", number = false, relativenumber = false, signcolumn = "no", spell = false, winbar = "", statuscolumn = "", wrap = false, sidescrolloff = 0, }, }) local SCROLL_UP, SCROLL_DOWN = Snacks.util.keycode(""), Snacks.util.keycode("") local split_commands = { editor = { top = "topleft", right = "vertical botright", bottom = "botright", left = "vertical topleft", }, win = { top = "aboveleft", right = "vertical rightbelow", bottom = "belowright", left = "vertical leftabove", }, } local win_opts = { "anchor", "border", "bufpos", "col", "external", "fixed", "focusable", "footer", "footer_pos", "height", "hide", "noautocmd", "relative", "row", "style", "title", "title_pos", "width", "win", "zindex", } ---@type table local borders = { left = { "", "", "", "", "", "", "", "│" }, right = { "", "", "", "│", "", "", "", "" }, top = { "", "─", "", "", "", "", "", "" }, bottom = { "", "", "", "", "", "─", "", "" }, top_bottom = { "", "─", "", "", "", "─", "", "" }, hpad = { "", "", "", " ", "", "", "", " " }, vpad = { "", " ", "", "", "", " ", "", "" }, } Snacks.util.set_hl({ Backdrop = { bg = "#000000" }, Footer = "FloatFooter", FooterDesc = "DiagnosticInfo", FooterKey = "DiagnosticVirtualTextInfo", Normal = "NormalFloat", NormalNC = "NormalFloat", Title = "FloatTitle", WinBar = "Title", WinBarNC = "SnacksWinBar", WinKey = "Keyword", WinKeySep = "NonText", WinKeyDesc = "Function", WinSeparator = "WinSeparator", }, { prefix = "Snacks", default = true }) local id = 0 local event_stack = {} ---@type string[] --@private ---@param ...? snacks.win.Config|string|{} ---@return snacks.win.Config function M.resolve(...) local done = {} ---@type table local merge = {} ---@type snacks.win.Config[] local stack = {} for i = 1, select("#", ...) do local next = select(i, ...) ---@type snacks.win.Config|string? if next then table.insert(stack, next) end end while #stack > 0 do local next = table.remove(stack) next = type(next) == "string" and Snacks.config.styles[next] or next ---@cast next snacks.win.Config? if next and type(next) == "table" then table.insert(merge, 1, next) if next.style and not done[next.style] then done[next.style] = true table.insert(stack, next.style) end end end local ret = #merge == 0 and {} or #merge == 1 and merge[1] or vim.tbl_deep_extend("force", {}, unpack(merge)) ret.style = nil return ret end ---@param opts? snacks.win.Config|{} ---@return snacks.win function M.new(opts) local self = setmetatable({}, M) id = id + 1 self.id = id self.meta = {} opts = M.resolve(Snacks.config.get("win", defaults), opts) if opts.minimal then opts = M.resolve("minimal", opts) end if opts.position == "float" then opts = M.resolve("float", opts) else opts = M.resolve("split", opts) local vertical = opts.position == "left" or opts.position == "right" opts.wo.winfixheight = not vertical opts.wo.winfixwidth = vertical end if opts.relative == "win" then opts.win = opts.win or vim.api.nvim_get_current_win() end self.keys = {} self.events = {} local done = {} ---@type table for key, spec in pairs(opts.keys) do if spec then if type(spec) == "string" then spec = { key, spec, desc = spec } elseif type(spec) == "function" then spec = { key, spec } elseif type(spec) == "table" and spec[1] and not spec[2] then spec = vim.deepcopy(spec) -- deepcopy just in case spec[1], spec[2] = key, spec[1] end ---@cast spec snacks.win.Keys local lhs = Snacks.util.normkey(spec[1] or "") local mode = type(spec.mode) == "table" and spec.mode or { spec.mode or "n" } ---@cast mode string[] mode = #mode == 0 and { "n" } or mode for _, m in ipairs(mode) do local k = m .. ":" .. lhs if done[k] then Snacks.notify.warn( ("# Duplicate key mapping for `%s` mode=%s (check case):\n```lua\n%s\n```\n```lua\n%s\n```"):format( lhs, m, vim.inspect(done[k]), vim.inspect(spec) ) ) end done[k] = spec end table.insert(self.keys, spec) end end -- last defined mapping is found first, so for `nowait` to work, -- we need to sort in reverse order table.sort(self.keys, function(a, b) return (a[1] or "") > (b[1] or "") end) self:on("WinClosed", self.on_close, { win = true }) self:on("WinResized", function() if self.backdrop and not self:is_floating() then self.backdrop:close() self.backdrop = nil end end) -- update window size when resizing self:on("VimResized", self.on_resize) ---@cast opts snacks.win.Config self.opts = opts if opts.show ~= false then self:show() end return self end function M:on_resize() if self.opts.resize ~= false then self:update() end end ---@param actions string|string[] function M:execute(actions) return self:action(actions)() end ---@param actions string|string[] ---@return (fun(): boolean|string?) action, string? desc function M:action(actions) actions = type(actions) == "string" and { actions } or actions ---@cast actions string[] local desc = {} ---@type string[] for a, name in ipairs(actions) do desc[a] = name:gsub("_", " ") if self.opts.actions and self.opts.actions[name] then local action = self.opts.actions[name] desc[a] = type(action) == "table" and action.desc and action.desc or desc[a] end end return function() for _, name in ipairs(actions) do if self.opts.actions and self.opts.actions[name] then local a = self.opts.actions[name] local fn = type(a) == "function" and a or a.action local ret = fn(self) if ret then return type(ret) == "string" and ret or nil end elseif self[name] then self[name](self) return else return name end end end, table.concat(desc, ", ") end ---@param opts? {col_width?: number, key_width?: number, win?: snacks.win.Config} function M:toggle_help(opts) opts = opts or {} local col_width, key_width = opts.col_width or 30, opts.key_width or 10 for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do local buf = vim.api.nvim_win_get_buf(win) if vim.bo[buf].filetype == "snacks_win_help" then vim.api.nvim_win_close(win, true) return end end local ns = vim.api.nvim_create_namespace("snacks.win.help") local win = M.new(M.resolve({ style = "help" }, opts.win or {}, { show = false, focusable = false, zindex = self.opts.zindex + 1, bo = { filetype = "snacks_win_help" }, })) self:on("WinClosed", function() win:close() end, { win = true }) self:on("BufLeave", function() win:close() end, { buf = true }) local dim = win:dim() -- NOTE: we use the actual buffer keymaps instead of self.keys, -- since we want to show all keymaps, not just the ones we've defined on the window local keys = {} ---@type vim.api.keyset.get_keymap[] vim.list_extend(keys, vim.api.nvim_buf_get_keymap(self.buf, "n")) vim.list_extend(keys, vim.api.nvim_buf_get_keymap(self.buf, "i")) table.sort(keys, function(a, b) return (a.desc or a.lhs or "") < (b.desc or b.lhs or "") end) local done = {} ---@type table keys = vim.tbl_filter(function(keymap) local key = Snacks.util.normkey(keymap.lhs or "") if done[key] or (keymap.desc and keymap.desc:find("which%-key")) then return false end done[key] = true return true end, keys) local cols = math.floor((dim.width - 1) / col_width) local rows = math.ceil(#keys / cols) win.opts.height = rows local help = {} ---@type {[1]:string, [2]:string}[][] local row, col = 0, 1 ---@param str string ---@param len number ---@param align? "left"|"right" local function trunc(str, len, align) local w = vim.api.nvim_strwidth(str) if w > len then return vim.fn.strcharpart(str, 0, len - 1) .. "…" end return align == "right" and (string.rep(" ", len - w) .. str) or (str .. string.rep(" ", len - w)) end for _, keymap in ipairs(keys) do local key = Snacks.util.normkey(keymap.lhs or "") row = row + 1 if row > rows then row, col = 1, col + 1 end help[row] = help[row] or {} vim.list_extend(help[row], { { trunc(key, key_width, "right"), "SnacksWinKey" }, { " " }, { "➜", "SnacksWinKeySep" }, { " " }, { trunc(keymap.desc or "", col_width - key_width - 3), "SnacksWinKeyDesc" }, }) end win:show() for l, line in ipairs(help) do vim.api.nvim_buf_set_lines(win.buf, l - 1, l, false, { "" }) vim.api.nvim_buf_set_extmark(win.buf, ns, l - 1, 0, { virt_text = line, virt_text_pos = "overlay", }) end end ---@param event string|string[] ---@param cb fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean? ---@param opts? snacks.win.Event function M:on(event, cb, opts) opts = opts or {} opts.callback = cb table.insert(self.events, vim.tbl_extend("keep", { event = event }, opts)) if self:valid() then self:_on(event, opts) end end ---@param event string|string[] ---@param opts snacks.win.Event function M:_on(event, opts) local event_opts = {} ---@type vim.api.keyset.create_autocmd local skip = { "buf", "win", "event" } for k, v in pairs(opts) do if not vim.tbl_contains(skip, k) then event_opts[k] = v end end event_opts.group = event_opts.group or self.augroup event_opts.callback = function(ev) table.insert(event_stack, ev.event) local ok, err = pcall(opts.callback, self, ev) table.remove(event_stack) return not ok and error(err) or err end if event_opts.pattern or event_opts.buffer then -- don't alter the pattern or buffer elseif opts.win then event_opts.pattern = self.win .. "" elseif opts.buf then event_opts.buffer = self.buf end vim.api.nvim_create_autocmd(event, event_opts) end function M:focus() if self:valid() then vim.api.nvim_set_current_win(self.win) end end function M:redraw() if vim.api.nvim__redraw then vim.api.nvim__redraw({ win = self.win, valid = false, flush = true, cursor = false }) else vim.cmd("redraw") end end ---@param left? boolean function M:hscroll(left) vim.api.nvim_win_call(self.win, function() vim.cmd(("normal! %s"):format(left and "zh" or "zl")) end) end ---@param up? boolean function M:scroll(up) vim.api.nvim_win_call(self.win, function() vim.cmd(("normal! %d%s"):format(vim.wo[self.win].scroll, up and SCROLL_UP or SCROLL_DOWN)) end) end function M:destroy() pcall(function() self:close() end) self.events = {} self.keys = {} self.meta = {} -- self.opts = {} end ---@param opts? { buf: boolean } function M:close(opts) opts = opts or {} local wipe = opts.buf ~= false and self.buf == self.scratch_buf local win = self.win local buf = wipe and self.buf local scratch_buf = self.scratch_buf ~= self.buf and self.scratch_buf or nil self:on_close() self.win = nil if scratch_buf then self.scratch_buf = nil end if buf then self.buf = nil end local close = function() local errors = {} ---@type string[] if win and vim.api.nvim_win_is_valid(win) then local ok, err = pcall(vim.api.nvim_win_close, win, true) if not ok and (err and err:find("E444")) then -- last window, so creat a split and close it again vim.cmd("silent! vsplit") pcall(vim.api.nvim_win_close, win, true) elseif not ok then errors[#errors + 1] = err end end if buf and vim.api.nvim_buf_is_valid(buf) then local ok, err = pcall(vim.api.nvim_buf_delete, buf, { force = true }) errors[#errors + 1] = not ok and err or nil end if scratch_buf and vim.api.nvim_buf_is_valid(scratch_buf) then local ok, err = pcall(vim.api.nvim_buf_delete, scratch_buf, { force = true }) errors[#errors + 1] = not ok and err or nil end if self.augroup then pcall(vim.api.nvim_del_augroup_by_id, self.augroup) self.augroup = nil end if #errors > 0 then error(table.concat(errors, "\n")) end end local retries = 0 local try_close ---@type fun() try_close = function() local ok, err = pcall(close) if ok or not err then return end -- command window is open if err:find("E11") then vim.defer_fn(try_close, 200) return end -- text lock if err:find("E565") and retries < 20 then retries = retries + 1 vim.defer_fn(try_close, 50) return end if not ok then Snacks.notify.error("Failed to close window: " .. err) end end -- HACK: WinClosed is not recursive, so we need to schedule it -- if we're in a WinClosed event if vim.tbl_contains(event_stack, "WinClosed") or not pcall(close) then vim.schedule(try_close) end end function M:hide() self:close({ buf = false }) return self end function M:toggle() if self:valid() then self:hide() else self:show() end return self end ---@param title string|{[1]:string, [2]:string}[] ---@param pos? "center"|"left"|"right" function M:set_title(title, pos) if not self:has_border() then return end if type(title) == "string" then title = vim.trim(title) if title ~= "" then -- HACK: add extra space when last char is non word -- like for icons etc if not title:sub(-1):match("%w") then title = title .. " " end title = " " .. title .. " " end elseif #title == 0 then title = "" end pos = pos or self.opts.title_pos or "center" if vim.deep_equal(self.opts.title, title) and self.opts.title_pos == pos then return end self.opts.title = title self.opts.title_pos = pos if not self:valid() then return end -- Don't try to update if the relative window is invalid. -- It will be fixed once a full update is done. local relative_win = vim.api.nvim_win_get_config(self.win).win if relative_win and not vim.api.nvim_win_is_valid(relative_win) then return end vim.api.nvim_win_set_config(self.win, { title = self.opts.title, title_pos = self.opts.title_pos, }) end ---@private function M:open_buf() if self.buf and vim.api.nvim_buf_is_valid(self.buf) then -- keep existing buffer self.buf = self.buf elseif self.scratch_buf and vim.api.nvim_buf_is_valid(self.scratch_buf) then -- keep existing scratch buffer self.buf = self.scratch_buf elseif self.opts.file then self.buf = vim.fn.bufadd(self.opts.file) if not vim.api.nvim_buf_is_loaded(self.buf) then vim.bo[self.buf].readonly = true vim.bo[self.buf].swapfile = false vim.fn.bufload(self.buf) vim.bo[self.buf].modifiable = false end elseif self.opts.buf then self.buf = self.opts.buf else self:scratch() end return self.buf end function M:scratch() if self.buf == self.scratch_buf and self:buf_valid() then return end self.buf = vim.api.nvim_create_buf(false, true) vim.bo[self.buf].swapfile = false self.scratch_buf = self.buf local text = type(self.opts.text) == "function" and self.opts.text() or self.opts.text text = type(text) == "string" and vim.split(text, "\n") or text if text then ---@cast text string[] vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, text) end if not self.opts.bo.filetype then if self.opts.scratch_ft then vim.bo[self.buf].filetype = self.opts.scratch_ft else vim.bo[self.buf].filetype = self.opts.bo.filetype or "snacks_win" end vim.bo[self.buf].syntax = "" end if self:win_valid() then vim.api.nvim_win_set_buf(self.win, self.buf) end end ---@private function M:open_win() local relative = self.opts.relative or "editor" local position = self.opts.position or "float" local enter = self.opts.enter == nil or self.opts.enter or false if self.opts.focusable == false then enter = false end local opts = self:win_opts() if position == "float" then self.win = vim.api.nvim_open_win(self.buf, enter, opts) elseif position == "current" then self.win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(self.win, self.buf) else --split local parent = self.opts.win and vim.api.nvim_win_is_valid(self.opts.win) and self.opts.win or 0 local vertical = position == "left" or position == "right" -- When stacking is enabled, find an existing window with the same relative/position -- and stack the new window perpendicular to it instead of creating a new split if parent == 0 and self.opts.stack then for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do if vim.w[win].snacks_win and vim.w[win].snacks_win.relative == relative and vim.w[win].snacks_win.position == position and vim.w[win].snacks_win.stack == true then parent = win relative = "win" position = vertical and "bottom" or "right" vertical = not vertical break end end end local cmd = split_commands[relative][position] local size = vertical and opts.width or opts.height local resize = ("%sresize %s"):format(vertical and "vertical " or "", size) vim.api.nvim_win_call(parent, function() vim.cmd("silent noswapfile " .. cmd .. " sbuffer " .. self.buf .. " | " .. resize) self.win = vim.api.nvim_get_current_win() end) if enter then vim.api.nvim_set_current_win(self.win) end vim.schedule(function() self:equalize() end) end vim.w[self.win].snacks_win = { id = self.id, position = self.opts.position, relative = self.opts.relative, stack = self.opts.stack, } end ---@private function M:equalize() if self:is_floating() then return end local all = vim.tbl_filter(function(win) return vim.w[win].snacks_win and vim.w[win].snacks_win.relative == self.opts.relative and vim.w[win].snacks_win.position == self.opts.position end, vim.api.nvim_tabpage_list_wins(0)) if #all <= 1 then return end local vertical = self.opts.position == "left" or self.opts.position == "right" local parent_size = self:parent_size()[vertical and "height" or "width"] local size = math.floor(parent_size / #all) for _, win in ipairs(all) do vim.api.nvim_win_call(win, function() vim.cmd(("%s resize %s"):format(vertical and "horizontal" or "vertical", size)) end) end end function M:update() if self:valid() then Snacks.util.bo(self.buf, self.opts.bo) Snacks.util.wo(self.win, self.opts.wo) if self:is_floating() then local opts = self:win_opts() opts.noautocmd = nil vim.api.nvim_win_set_config(self.win, opts) end end end function M:on_current_tab() return self:win_valid() and vim.api.nvim_get_current_tabpage() == vim.api.nvim_win_get_tabpage(self.win) end function M:show() if self:valid() then self:update() return self end self.augroup = vim.api.nvim_create_augroup("snacks_win_" .. self.id, { clear = true }) self:open_buf() -- buffer local variables for k, v in pairs(self.opts.b or {}) do vim.b[self.buf][k] = v end -- OPTIM: prevent treesitter or syntax highlighting to attach on FileType if it's not already enabled local optim_hl = not vim.b[self.buf].ts_highlight and vim.bo[self.buf].syntax == "" vim.b[self.buf].ts_highlight = optim_hl or vim.b[self.buf].ts_highlight Snacks.util.bo(self.buf, self.opts.bo) vim.b[self.buf].ts_highlight = not optim_hl and vim.b[self.buf].ts_highlight or nil if self.opts.on_buf then self.opts.on_buf(self) end if self.opts.footer_keys then self.opts.footer = {} table.sort(self.keys, function(a, b) return a[1] < b[1] end) local want = type(self.opts.footer_keys) == "table" and self.opts.footer_keys or nil ---@cast want string[]|nil want = want and vim.tbl_map(Snacks.util.normkey, want) or nil --[[@as string[]?]] for _, key in ipairs(self.keys) do local keymap = Snacks.util.normkey(key[1]) if want == nil or vim.tbl_contains(want, keymap) then table.insert(self.opts.footer, { " ", "SnacksFooter" }) table.insert(self.opts.footer, { " " .. keymap .. " ", "SnacksFooterKey" }) table.insert(self.opts.footer, { " " .. (key.desc or keymap) .. " ", "SnacksFooterDesc" }) end end table.insert(self.opts.footer, { " ", "SnacksFooter" }) end self:open_win() self.closed = false -- window local variables for k, v in pairs(self.opts.w or {}) do vim.w[self.win][k] = v end if Snacks.util.is_transparent() then self.opts.wo.winblend = 0 end Snacks.util.wo(self.win, self.opts.wo) if self.opts.on_win then self.opts.on_win(self) end -- syntax highlighting local ft = self.opts.ft or vim.bo[self.buf].filetype if ft and not ft:find("^snacks_") and not vim.b[self.buf].ts_highlight and vim.bo[self.buf].syntax == "" then local lang = vim.treesitter.language.get_lang(ft) if not (lang and pcall(vim.treesitter.start, self.buf, lang)) then vim.bo[self.buf].syntax = ft end end for _, event in ipairs(self.events) do self:_on(event.event, event) end -- swap buffers when opening a new buffer in the same window vim.api.nvim_create_autocmd("BufWinEnter", { group = self.augroup, nested = true, callback = function() return self:fixbuf() end, }) self:map() self:drop() return self end function M:fixbuf() -- window closes, so delete the autocmd if not self:win_valid() then return true end if not self:buf_valid() then return end if not self:on_current_tab() then return end local buf = vim.api.nvim_win_get_buf(self.win) -- same buffer if buf == self.buf then return end -- don't swap if fixbuf is disabled if self.opts.fixbuf == false then self.buf = buf -- update window options Snacks.util.wo(self.win, self.opts.wo) return end -- another buffer was opened in this window -- find another window to swap with local main ---@type number? for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do local win_buf = vim.api.nvim_win_get_buf(win) local is_float = vim.api.nvim_win_get_config(win).zindex ~= nil if win ~= self.win and not is_float then if vim.bo[win_buf].buftype == "" or vim.b[win_buf].snacks_main or vim.w[win].snacks_main then main = win break end end end if main then vim.api.nvim_win_set_buf(self.win, self.buf) vim.api.nvim_win_set_buf(main, buf) vim.api.nvim_set_current_win(main) vim.cmd.stopinsert() else -- no main window found, so close this window vim.api.nvim_win_set_buf(self.win, self.buf) vim.schedule(function() vim.cmd.stopinsert() vim.cmd("sbuffer " .. buf) if self.win and vim.api.nvim_win_is_valid(self.win) then vim.api.nvim_win_close(self.win, true) end end) end end ---@param buf number function M:set_buf(buf) assert(self:valid(), "Window is not valid") self.buf = buf vim.api.nvim_win_set_buf(self.win, buf) Snacks.util.wo(self.win, self.opts.wo) end function M:map() if not self:buf_valid() then return end for _, spec in pairs(self.keys) do local opts = vim.deepcopy(spec) opts[1] = nil opts[2] = nil opts.mode = nil ---@diagnostic disable-next-line: cast-type-mismatch ---@cast opts vim.keymap.set.Opts opts.buffer = self.buf opts.nowait = true local rhs = spec[2] local is_action = type(rhs) == "string" or type(rhs) == "table" if is_action then local desc = spec.desc ---@cast rhs string|string[] rhs, desc = self:action(rhs) opts.desc = opts.desc or desc else rhs = function() return spec[2](self) end end spec.desc = spec.desc or opts.desc ---@cast spec snacks.win.Keys vim.keymap.set(spec.mode or "n", spec[1], rhs, opts) end end ---@private function M:on_close() -- close the backdrop if self.backdrop then self.backdrop:close() self.backdrop = nil end if self.closed then return end self.closed = true if self.opts.on_close then self.opts.on_close(self) end -- Go back to the previous window when closing, -- and it's the current window if vim.api.nvim_get_current_win() == self.win then pcall(vim.cmd.wincmd, "p") end end function M:add_padding() local listchars = vim.split(self.opts.wo.listchars or "", ",") listchars = vim.tbl_filter(function(s) return not s:find("eol:") and s ~= "" end, listchars) table.insert(listchars, "eol: ") self.opts.wo.listchars = table.concat(listchars, ",") self.opts.wo.list = true self.opts.wo.statuscolumn = " " end function M:is_floating() return self:valid() and vim.api.nvim_win_get_config(self.win).zindex ~= nil end ---@private function M:drop() if self.backdrop then self.backdrop:close() self.backdrop = nil end local backdrop = self.opts.backdrop if not backdrop then return end backdrop = type(backdrop) == "number" and { blend = backdrop } or backdrop backdrop = backdrop == true and {} or backdrop backdrop = vim.tbl_extend("force", { bg = "#000000", blend = 60, transparent = true }, backdrop) ---@cast backdrop snacks.win.Backdrop if (Snacks.util.is_transparent() and backdrop.transparent) or not vim.o.termguicolors or backdrop.blend == 100 or not self:is_floating() then return end local bg, winblend = backdrop.bg or "#000000", backdrop.blend if not backdrop.transparent then if Snacks.util.is_transparent() then bg = nil else bg = Snacks.util.blend(Snacks.util.color("Normal", "bg"), bg, winblend / 100) end winblend = 0 end local group = ("SnacksBackdrop_%s"):format(bg and bg:sub(2) or "T") vim.api.nvim_set_hl(0, group, { bg = bg }) self.backdrop = M.new(M.resolve({ enter = false, backdrop = false, relative = "editor", height = 0, width = 0, style = "minimal", border = "none", focusable = false, zindex = self.opts.zindex - 1, wo = { winhighlight = "Normal:" .. group, winblend = winblend, colorcolumn = "", }, bo = { buftype = "nofile", filetype = "snacks_win_backdrop", }, }, backdrop.win)) end function M:line(line) return self:lines(line, line)[1] or "" end ---@param from? number 1-indexed, inclusive ---@param to? number 1-indexed, inclusive function M:lines(from, to) return self:buf_valid() and vim.api.nvim_buf_get_lines(self.buf, from and from - 1 or 0, to or -1, false) or {} end ---@param from? number 1-indexed, inclusive ---@param to? number 1-indexed, inclusive function M:text(from, to) return table.concat(self:lines(from, to), "\n") end ---@return { height: number, width: number } function M:parent_size() if self.opts.relative == "win" and vim.api.nvim_win_is_valid(self.opts.win) then return { height = vim.api.nvim_win_get_height(self.opts.win), width = vim.api.nvim_win_get_width(self.opts.win), } end return { height = vim.o.lines, width = vim.o.columns, } end ---@private function M:win_opts() local opts = {} ---@type vim.api.keyset.win_config for _, k in ipairs(win_opts) do opts[k] = self.opts[k] end local border = self:border() opts.border = border and (borders[border] or border) or "none" if opts.relative == "cursor" then self.opts.row = self.opts.row or 0 self.opts.col = self.opts.col or 0 end local dim = self:dim() opts.height, opts.width = dim.height, dim.width opts.row, opts.col = dim.row, dim.col if vim.fn.has("nvim-0.10") == 0 then opts.footer, opts.footer_pos = nil, nil end if border then opts.title_pos = opts.title and (opts.title_pos or "center") or nil opts.footer_pos = opts.footer and (opts.footer_pos or "center") or nil else opts.title, opts.footer = nil, nil opts.title_pos, opts.footer_pos = nil, nil end return opts end ---@return { height: number, width: number } function M:size() local opts = self:win_opts() local height = opts.height local width = opts.width if self:has_border() then height = height + 2 width = width + 2 end return { height = height, width = width } end function M:has_border() return self:border() ~= nil end function M.is_border(border) return border and border ~= "" and border ~= "none" end function M:border() if not M.is_border(self.opts.border) then return end if self.opts.border == true then local border ---@type string|string[]|nil pcall(function() border = vim.o.winborder border = border:find(",") and vim.split(border, ",") or border end) return M.is_border(border) and border or "rounded" end return self.opts.border end --- Calculate the size of the border function M:border_size() -- The array specifies the eight -- chars building up the border in a clockwise fashion -- starting with the top-left corner. -- { "╔", "═" ,"╗", "║", "╝", "═", "╚", "║" } local border = self:border() or { "" } border = type(border) == "string" and borders[border] or border border = type(border) == "string" and { "x" } or border assert(type(border) == "table", "Invalid border type") ---@cast border string[] while #border < 8 do vim.list_extend(border, border) end -- remove border hl groups border = vim.tbl_map(function(b) return type(b) == "table" and b[1] or b end, border) local function size(from, to) for i = from, to do if border[i] ~= "" then return 1 end end return 0 end ---@type { top: number, right: number, bottom: number, left: number } return { top = size(1, 3), right = size(3, 5), bottom = size(5, 7), left = math.max(size(7, 8), size(1, 1)), } end function M:border_text_width() if not self:has_border() then return 0 end local ret = 0 for _, t in ipairs({ "title", "footer" }) do local str = self.opts[t] or {} str = type(str) == "string" and { str } or str ---@cast str (string|string[])[] ret = math.max(ret, #table.concat( vim.tbl_map(function(s) return type(s) == "string" and s or s[1] end, str), "" )) end return ret end function M:buf_valid() return self.buf and vim.api.nvim_buf_is_valid(self.buf) end function M:win_valid() return self.win and vim.api.nvim_win_is_valid(self.win) end function M:valid() return self:win_valid() and self:buf_valid() and vim.api.nvim_win_get_buf(self.win) == self.buf end ---@param parent? snacks.win.Dim function M:dim(parent) parent = parent or self:parent_size() ---@type snacks.win.Dim local ret = { height = 0, width = 0, col = 0, row = 0, border = self:has_border(), } ---@param s? number|fun(win:snacks.win):number? size ---@param ps number parent size local function size(s, ps, border_offset) s = type(s) == "function" and s(self) or s or 0 ---@cast s number if s == 0 then -- full size return ps - border_offset elseif s < 1 then -- relative size return math.floor(ps * s) - border_offset end return s end ---@param p? number|fun(win:snacks.win):number? pos ---@param s number size ---@param ps number parent size local function pos(p, s, ps, border_from, border_to) p = type(p) == "function" and p(self) or p ---@cast p number? if self.opts.relative == "cursor" then return p or 0 end if not p then -- center return math.floor((ps - s) / 2) - border_from end ---@cast p number if p < 0 then -- negative position return ps - s + p - border_from - border_to elseif p < 1 and p > 0 then -- relative position return math.floor(ps * p) + border_from end return p end local border = self:border_size() ret.height = size(self.opts.height, parent.height, border.top + border.bottom) ret.height = math.max(ret.height, self.opts.min_height or 0, 1) ret.height = math.min(ret.height, self.opts.max_height or ret.height, parent.height) ret.height = math.max(ret.height, 1) ret.width = size(self.opts.width, parent.width, border.left + border.right) ret.width = math.max(ret.width, self.opts.min_width or 0, 1) ret.width = math.min(ret.width, self.opts.max_width or ret.width, parent.width) ret.width = math.max(ret.width, 1) ret.row = pos(self.opts.row, ret.height, parent.height, border.top, border.bottom) ret.col = pos(self.opts.col, ret.width, parent.width, border.left, border.right) return ret end --- Calculate the next available zindex for snacks windows. --- New windows open on top of existing ones. ---@param opts? { zindex?: number, tab?: number|boolean, all?: boolean, max?: number } ---@overload fun(zindex: number): number function M.zindex(opts) opts = opts or {} opts = type(opts) == "number" and { zindex = opts } or opts local zindex = opts.zindex or 50 local max = opts.max or 100 local wins = opts.tab == false and vim.api.nvim_list_wins() or vim.api.nvim_tabpage_list_wins(tonumber(opts.tab) or 0) for _, win in ipairs(wins) do if opts.all ~= false or vim.w[win].snacks_win then local other = (vim.api.nvim_win_get_config(win).zindex or 0) -- ignore very high zindex windows, like notifications, completion, etc if other > zindex and other < max then zindex = math.max(zindex, other + 2) --[[@as number]] end end end return zindex end return M ================================================ FILE: lua/snacks/words.lua ================================================ ---@class snacks.words local M = {} M.meta = { desc = "Auto-show LSP references and quickly navigate between them", needs_setup = true, } ---@private ---@alias LspWord {from:{[1]:number, [2]:number}, to:{[1]:number, [2]:number}} 1-0 indexed ---@class snacks.words.Config ---@field enabled? boolean local defaults = { debounce = 200, -- time in ms to wait before updating notify_jump = false, -- show a notification when jumping notify_end = true, -- show a notification when reaching the end foldopen = true, -- open folds after jumping jumplist = true, -- set jump point before jumping modes = { "n", "i", "c" }, -- modes to show references filter = function(buf) -- what buffers to enable `snacks.words` return vim.g.snacks_words ~= false and vim.b[buf].snacks_words ~= false end, } M.enabled = false local config = Snacks.config.get("words", defaults) local ns = vim.api.nvim_create_namespace("vim_lsp_references") local ns2 = vim.api.nvim_create_namespace("nvim.lsp.references") local timer = (vim.uv or vim.loop).new_timer() function M.enable() if M.enabled then return end M.enabled = true local group = vim.api.nvim_create_augroup("snacks_words", { clear = true }) vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI", "ModeChanged" }, { group = group, callback = function() if not M.is_enabled({ modes = true }) then M.clear() return end if not ({ M.get() })[2] then M.update() end end, }) end function M.disable() if not M.enabled then return end M.enabled = false vim.api.nvim_del_augroup_by_name("snacks_words") for _, buf in ipairs(vim.api.nvim_list_bufs()) do vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) vim.api.nvim_buf_clear_namespace(buf, ns2, 0, -1) end end function M.clear() vim.lsp.buf.clear_references() end ---@private function M.update() local buf = vim.api.nvim_get_current_buf() timer:start(config.debounce, 0, function() vim.schedule(function() if vim.api.nvim_buf_is_valid(buf) then vim.api.nvim_buf_call(buf, function() if not M.is_enabled({ modes = true }) then return end vim.lsp.buf.document_highlight() M.clear() end) end end) end) end ---@param opts? number|{buf?:number, modes:boolean} if modes is true, also check if the current mode is enabled function M.is_enabled(opts) if not M.enabled then return false end opts = type(opts) == "number" and { buf = opts } or opts or {} if opts.modes then local mode = vim.api.nvim_get_mode().mode:lower() mode = mode:gsub("\22", "v"):gsub("\19", "s") mode = mode:sub(1, 2) == "no" and "o" or mode mode = mode:sub(1, 1):match("[ncitsvo]") or "n" if not vim.tbl_contains(config.modes, mode) then return false end end local buf = opts.buf or vim.api.nvim_get_current_buf() if not config.filter(buf) then return false end local clients = {} ---@type vim.lsp.Client[] if vim.fn.has("nvim-0.11") == 1 then clients = vim.lsp.get_clients({ bufnr = buf, method = "textDocument/documentHighlight" }) else clients = (vim.lsp.get_clients or vim.lsp.get_active_clients)({ bufnr = buf }) clients = vim.tbl_filter(function(client) return client.supports_method("textDocument/documentHighlight", { bufnr = buf }) end, clients) end return #clients > 0 end ---@private ---@return LspWord[] words, number? current function M.get() local cursor = vim.api.nvim_win_get_cursor(0) local current, ret = nil, {} ---@type number?, LspWord[] local extmarks = {} ---@type vim.api.keyset.get_extmark_item[] vim.list_extend(extmarks, vim.api.nvim_buf_get_extmarks(0, ns, 0, -1, { details = true })) vim.list_extend(extmarks, vim.api.nvim_buf_get_extmarks(0, ns2, 0, -1, { details = true })) for _, extmark in ipairs(extmarks) do local w = { from = { extmark[2] + 1, extmark[3] }, to = { extmark[4].end_row + 1, extmark[4].end_col }, } ret[#ret + 1] = w if cursor[1] >= w.from[1] and cursor[1] <= w.to[1] and cursor[2] >= w.from[2] and cursor[2] <= w.to[2] then current = #ret end end return ret, current end ---@param count? number ---@param cycle? boolean function M.jump(count, cycle) count = count or 1 local words, idx = M.get() if not idx then return end idx = idx + count if cycle then idx = (idx - 1) % #words + 1 end local target = words[idx] if target then if config.jumplist then vim.cmd.normal({ "m`", bang = true }) end vim.api.nvim_win_set_cursor(0, target.from) if config.notify_jump then Snacks.notify.info(("Reference [%d/%d]"):format(idx, #words), { id = "snacks.words.jump", title = "Words" }) end if config.foldopen then vim.cmd.normal({ "zv", bang = true }) end elseif config.notify_end then Snacks.notify.warn("No more references", { id = "snacks.words.jump", title = "Words" }) end end return M ================================================ FILE: lua/snacks/zen.lua ================================================ ---@class snacks.zen ---@overload fun(opts: snacks.zen.Config): snacks.win local M = setmetatable({}, { __call = function(M, ...) return M.zen(...) end, }) M.meta = { desc = "Zen mode • distraction-free coding", } ---@class snacks.zen.Config local defaults = { -- You can add any `Snacks.toggle` id here. -- Toggle state is restored when the window is closed. -- Toggle config options are NOT merged. ---@type table toggles = { dim = true, git_signs = false, mini_diff_signs = false, -- diagnostics = false, -- inlay_hints = false, }, center = true, -- center the window show = { statusline = false, -- can only be shown when using the global statusline tabline = false, }, ---@type snacks.win.Config win = { style = "zen" }, --- Callback when the window is opened. ---@param win snacks.win on_open = function(win) end, --- Callback when the window is closed. ---@param win snacks.win on_close = function(win) end, --- Options for the `Snacks.zen.zoom()` ---@type snacks.zen.Config zoom = { toggles = {}, center = false, show = { statusline = true, tabline = true }, win = { backdrop = false, width = 0, -- full width }, }, } Snacks.config.style("zen", { enter = true, fixbuf = false, minimal = false, width = 120, height = 0, backdrop = { transparent = true, blend = 40 }, keys = { q = false }, zindex = 40, wo = { winhighlight = "NormalFloat:Normal", }, w = { snacks_main = true, }, }) -- fullscreen indicator -- only shown when the window is maximized Snacks.config.style("zoom_indicator", { text = "▍ zoom 󰊓 ", minimal = true, enter = false, focusable = false, height = 1, row = 0, col = -1, backdrop = false, }) Snacks.util.set_hl({ Icon = "DiagnosticWarn", }, { prefix = "SnacksZen", default = true }) ---@param opts? {statusline: boolean, tabline: boolean} local function get_main(opts) opts = opts or {} local bottom = vim.o.cmdheight + (opts.statusline and vim.o.laststatus == 3 and 1 or 0) local top = opts.tabline and ((vim.o.showtabline == 2 or (vim.o.showtabline == 1 and #vim.api.nvim_list_tabpages() > 1)) and 1 or 0) or 0 ---@class snacks.zen.Main values are 0-indexed local ret = { width = vim.o.columns, row = top, height = vim.o.lines - top - bottom, } return ret end M.win = nil ---@type snacks.win? ---@param opts? snacks.zen.Config function M.zen(opts) local toggles = opts and opts.toggles opts = Snacks.config.get("zen", defaults, opts) opts.toggles = toggles or opts.toggles -- close if already open if M.win and M.win:valid() then M.win:close() M.win = nil return end local parent_win = vim.api.nvim_get_current_win() local parent_zindex = vim.api.nvim_win_get_config(parent_win).zindex local buf = vim.api.nvim_get_current_buf() local win_opts = Snacks.win.resolve({ style = "zen" }, opts.win, { buf = buf }) win_opts.zindex = parent_zindex and parent_zindex + 1 or win_opts.zindex if Snacks.util.is_transparent() and type(win_opts.backdrop) == "table" then win_opts.backdrop.transparent = false end local zoom_indicator ---@type snacks.win? local show_indicator = false -- calculate window size if win_opts.height == 0 and (opts.show.statusline or opts.show.tabline or vim.o.cmdheight > 0) then local main = get_main(opts.show) win_opts.row = main.row win_opts.height = function() return get_main(opts.show).height end if type(win_opts.backdrop) == "table" then win_opts.backdrop.win = win_opts.backdrop.win or {} win_opts.backdrop.win.row = win_opts.row win_opts.backdrop.win.height = win_opts.height end if win_opts.width == 0 then show_indicator = true end end -- create window local win = Snacks.win(win_opts) if opts.center and vim.bo[buf].buftype ~= "terminal" then vim.cmd([[norm! zz]]) else local view = vim.api.nvim_win_call(parent_win, vim.fn.winsaveview) vim.api.nvim_win_call(win.win, function() vim.fn.winrestview(view) end) end M.win = win if show_indicator then zoom_indicator = Snacks.win({ show = false, style = "zoom_indicator", zindex = win.opts.zindex + 1, wo = { winhighlight = "NormalFloat:SnacksZenIcon" }, }) zoom_indicator:open_buf() local lines = vim.api.nvim_buf_get_lines(zoom_indicator.buf, 0, -1, false) zoom_indicator.opts.width = vim.api.nvim_strwidth(lines[1] or "") zoom_indicator:show() end -- set toggle states ---@type {toggle: snacks.toggle.Class, state: unknown}[] local states = {} for id, state in pairs(opts.toggles) do local toggle = Snacks.toggle.get(id) if toggle then table.insert(states, { toggle = toggle, state = toggle:get() }) toggle:set(state) end end opts.on_open(win) -- sync cursor with the parent window vim.api.nvim_create_autocmd("CursorMoved", { group = win.augroup, callback = function() if win:win_valid() and vim.api.nvim_win_is_valid(parent_win) then vim.api.nvim_win_set_cursor(parent_win, vim.api.nvim_win_get_cursor(win.win)) end end, }) -- restore toggle states when window is closed win:on("WinClosed", function() if zoom_indicator then zoom_indicator:close() end for _, state in ipairs(states) do state.toggle:set(state.state) end opts.on_close(win) end, { win = true }) -- update the buffer of the parent window -- when the zen buffer changes win:on("BufWinEnter", function() vim.api.nvim_win_set_buf(parent_win, win.buf) end) -- close when entering another window win:on("WinEnter", function() local w = vim.api.nvim_get_current_win() if w == win.win then return end -- exit if other window is not a floating window if vim.api.nvim_win_get_config(w).relative == "" then -- schedule so that WinClosed is properly triggered vim.schedule(function() win:close() end) end end) return win end ---@param opts? snacks.zen.Config function M.zoom(opts) opts = Snacks.config.get("zen", defaults, opts) return M.zen(opts and opts.zoom or nil) end return M ================================================ FILE: lua/trouble/sources/profiler.lua ================================================ ---@module 'trouble' ---@diagnostic disable: inject-field local Item = require("trouble.item") ---@type trouble.Source local M = {} ---@diagnostic disable-next-line: missing-fields M.config = { formatters = { badges = function(ctx) local trace = ctx.item.item ---@type snacks.profiler.Trace local badges = Snacks.profiler.ui.badges(trace, { badges = { "time", "count" } }) local text = Snacks.profiler.ui.format(badges) return vim.tbl_map(function(t) return { text = t[1], hl = t[2] } end, text) end, }, modes = { profiler = { events = { { event = "User", pattern = "SnacksProfilerLoaded" } }, source = "profiler", groups = { -- { "tag", format = "{todo_icon} {tag}" }, -- { "directory" }, { "loc.plugin", format = "{file_icon} {loc.plugin} {count}" }, }, -- sort = { { buf = 0 }, "filename", "pos", "name" }, sort = { "-time" }, format = "{name} {badges} {pos}", }, }, } function M.preview(item, ctx) Snacks.profiler.ui.highlight(ctx.buf, { file = item.item.loc.file }) end function M.get(cb, ctx) ---@type snacks.profiler.Find local opts = vim.tbl_deep_extend( "force", { group = "name", structure = true }, type(ctx.opts.params) == "table" and ctx.opts.params or {} ) local _, node = Snacks.profiler.find(opts) local items = {} ---@type trouble.Item[] local id = 0 ---@param n snacks.profiler.Node local function add(n) if n.trace.def then id = id + 1 local loc = n.trace.def local item = Item.new({ id = id, pos = { n.trace.def.line, 0 }, text = n.trace.name, filename = loc and loc.file, item = n.trace, source = "profiler", }) items[#items + 1] = item for _, child in pairs(n.children) do item:add_child(add(child)) end return item end end for _, child in pairs(node.children or {}) do add(child) end cb(items) end return M ================================================ FILE: plugin/snacks.lua ================================================ require("snacks") ================================================ FILE: queries/css/images.scm ================================================ (declaration (call_expression (function_name) @fn (#eq? @fn "url") (arguments [(plain_value) @image.src (string_value (string_content) @image.src)])) ) @image ================================================ FILE: queries/html/images.scm ================================================ (element (start_tag (tag_name) @tag (#eq? @tag "img") (attribute (attribute_name) @attr_name (#eq? @attr_name "src") (quoted_attribute_value (attribute_value) @image.src) ) ) ) @image (self_closing_tag (tag_name) @tag (#eq? @tag "img") (attribute (attribute_name) @attr_name (#eq? @attr_name "src") (quoted_attribute_value (attribute_value) @image.src) ) ) @image (element (start_tag (tag_name) @tag (#eq? @tag "svg")) (#set! image.ext "svg") ) @image @image.content ================================================ FILE: queries/javascript/images.scm ================================================ (jsx_element (jsx_opening_element (identifier) @tag (#any-of? @tag "img" "Image") (jsx_attribute (property_identifier) @attr_name (#eq? @attr_name "src") (string (string_fragment) @image.src) ) ) ) @image (jsx_self_closing_element (identifier) @tag (#any-of? @tag "img" "Image") (jsx_attribute (property_identifier) @attr_name (#eq? @attr_name "src") (string (string_fragment) @image.src) ) ) @image ================================================ FILE: queries/latex/images.scm ================================================ (inline_formula (#set! image.ext "math.tex")) @image.content @image (displayed_equation (#set! image.ext "math.tex")) @image.content @image ((math_environment (#set! image.ext "math.tex")) @image.content @image (#not-has-ancestor? @image "displayed_equation" "math_environment")) (graphics_include (_ (path) @image.src) ) @image ================================================ FILE: queries/lua/highlights.scm ================================================ ;; extends ((identifier) @namespace.builtin (#any-of? @namespace.builtin "Snacks" "svim")) ================================================ FILE: queries/lua/injections.scm ================================================ ; extends ((comment content: (comment_content) @injection.language) (#lua-match? @injection.language "inject%s*:%s*%S+") (#gsub! @injection.language "^%s*inject%s*:%s*(%S+).*" "%1") . (_ (string content: (string_content) @injection.content))) ================================================ FILE: queries/markdown/images.scm ================================================ ; extends (fenced_code_block (info_string (language) @lang) (#eq? @lang "math") (code_fence_content) @image.content (#set! injection.language "latex") (#set! image.ext "math.tex") ) @image (fenced_code_block (info_string (language) @lang) (#eq? @lang "mermaid") (code_fence_content) @image.content (#set! injection.language "mermaid") (#set! image.ext "chart.mmd") ) @image ================================================ FILE: queries/markdown/injections.scm ================================================ ; extends (fenced_code_block (info_string (language) @lang) (#eq? @lang "math") (code_fence_content) @injection.content (#set! injection.language "latex") ) ================================================ FILE: queries/markdown_inline/images.scm ================================================ (image [ (link_destination) @image.src (image_description (shortcut_link ((link_text) @image.src))) ] (#gsub! @image.src "|.*" "") ; remove wikilink image options (#gsub! @image.src "^<" "") ; remove bracket link (#gsub! @image.src ">$" "") ) @image ================================================ FILE: queries/norg/images.scm ================================================ (infirm_tag (tag_name) @tag (#eq? @tag "image") (tag_parameters (tag_param) @image.src) ) @image (inline_math (#set! image.lang "latex") (#set! image.ext "math.tex") ) @image.content @image ================================================ FILE: queries/scss/images.scm ================================================ (declaration (call_expression (function_name) @fn (#eq? @fn "url") (arguments [ (plain_value) @image.src (string_value) @image.src ; Remove quotes from the image URL (#gsub! @image.src "^['\"]" "") (#gsub! @image.src "['\"]$" "") ])) ) @image ================================================ FILE: queries/svelte/images.scm ================================================ ; inherits: html ; extends ================================================ FILE: queries/tsx/images.scm ================================================ ; inherits: javascript ================================================ FILE: queries/typst/images.scm ================================================ (call (ident) @ident (#eq? @ident "image") (group (string) @image.src) (#offset! @image.src 0 1 0 -1) ) @image (math (#set! image.ext "math.typ") ) @image.content @image ================================================ FILE: queries/vue/images.scm ================================================ ; inherits: html ; extends ================================================ FILE: scripts/docs ================================================ #!/bin/env bash nvim -u tests/minit.lua --headless +'lua require("snacks.meta.docs").build()' +qa ================================================ FILE: scripts/docs-post ================================================ #!/bin/env bash nvim -u tests/minit.lua --headless +'lua require("snacks.meta.docs").fix_titles()' +qa ================================================ FILE: scripts/test ================================================ #!/usr/bin/env bash nvim -l tests/minit.lua --minitest "$@" ================================================ FILE: selene.toml ================================================ std="vim" [lints] mixed_table="allow" ================================================ FILE: stylua.toml ================================================ indent_type = "Spaces" indent_width = 2 column_width = 120 [sort_requires] enabled = true ================================================ FILE: tests/config_spec.lua ================================================ ---@module 'luassert' local function d(v) return vim.inspect(v):gsub("%s+", " ") end describe("config", function() local tests = { { { 1, 2 }, { 3, 4 }, { 3, 4 }, }, { { 1, 2 }, nil, { 1, 2 }, }, { { a = 1, b = 2 }, { c = 3 }, { a = 1, b = 2, c = 3 }, }, { { 1, 2, a = 1 }, { 3, 4, b = 2 }, { 3, 4, b = 2 }, }, { { 3, 4, b = 2 }, { 1, 2 }, { 1, 2 }, }, { { 1, 2, a = 1 }, { b = 2 }, { 1, 2, b = 2, a = 1 }, }, } for _, t in ipairs(tests) do it("merges correctly " .. d(t), function() local ret = Snacks.config.merge(t[1], t[2]) assert.are.same(ret, t[3]) end) end end) ================================================ FILE: tests/gitbrowse_spec.lua ================================================ ---@module "luassert" local gitbrowse = require("snacks.gitbrowse") -- stylua: ignore local git_remotes_cases = { ["https://github.com/LazyVim/LazyVim.git"] = "https://github.com/LazyVim/LazyVim", ["https://github.com/LazyVim/LazyVim"] = "https://github.com/LazyVim/LazyVim", ["git@github.com:LazyVim/LazyVim"] = "https://github.com/LazyVim/LazyVim", ["git@ssh.dev.azure.com:v3/neovim-org/owner/repo"] = "https://dev.azure.com/neovim-org/owner/_git/repo", ["https://folkelemaitre@bitbucket.org/samiulazim/neovim.git"] = "https://bitbucket.org/samiulazim/neovim", ["git@bitbucket.org:samiulazim/neovim.git"] = "https://bitbucket.org/samiulazim/neovim", ["git@gitlab.com:inkscape/inkscape.git"] = "https://gitlab.com/inkscape/inkscape", ["https://gitlab.com/inkscape/inkscape.git"] = "https://gitlab.com/inkscape/inkscape", ["git@github.com:torvalds/linux.git"] = "https://github.com/torvalds/linux", ["https://github.com/torvalds/linux.git"] = "https://github.com/torvalds/linux", ["git@bitbucket.org:team/repo.git"] = "https://bitbucket.org/team/repo", ["https://bitbucket.org/team/repo.git"] = "https://bitbucket.org/team/repo", ["git@gitlab.com:example-group/example-project.git"] = "https://gitlab.com/example-group/example-project", ["https://gitlab.com/example-group/example-project.git"] = "https://gitlab.com/example-group/example-project", ["git@ssh.dev.azure.com:v3/org/project/repo"] = "https://dev.azure.com/org/project/_git/repo", ["https://username@dev.azure.com/org/project/_git/repo"] = "https://dev.azure.com/org/project/_git/repo", ["ssh://git@ghe.example.com:2222/org/repo.git"] = "https://ghe.example.com/org/repo", ["https://ghe.example.com/org/repo.git"] = "https://ghe.example.com/org/repo", ["git-codecommit.us-east-1.amazonaws.com/v1/repos/MyDemoRepo"] = "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/MyDemoRepo", ["https://git-codecommit.us-east-1.amazonaws.com/v1/repos/MyDemoRepo"] = "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/MyDemoRepo", ["ssh://git@source.developers.google.com:2022/p/project/r/repo"] = "https://source.developers.google.com/p/project/r/repo", ["https://source.developers.google.com/p/project/r/repo"] = "https://source.developers.google.com/p/project/r/repo", ["git@git.sr.ht:~user/repo"] = "https://git.sr.ht/~user/repo", ["https://git.sr.ht/~user/repo"] = "https://git.sr.ht/~user/repo", ["git@git.sr.ht:~user/another-repo"] = "https://git.sr.ht/~user/another-repo", ["https://git.sr.ht/~user/another-repo"] = "https://git.sr.ht/~user/another-repo", } describe("util.lazygit", function() for remote, expected in pairs(git_remotes_cases) do it("should parse git remote " .. remote, function() local url = gitbrowse.get_repo(remote) assert.are.equal(expected, url) end) end end) ================================================ FILE: tests/image/big.md ================================================ - chapters 3 to 8 from the book [measure theory](https://measure.axler.net/), by Sheldon Axler. [LICENSE](https://creativecommons.org/licenses/by-nc/4.0/) ## Chapter 3 ## Integration To remedy deficiencies of Riemann integration that were discussed in Section 1B, in the last chapter we developed measure theory as an extension of the notion of the length of an interval. Having proved the fundamental results about measures, we are now ready to use measures to develop integration with respect to a measure. As we will see, this new method of integration fixes many of the problems with Riemann integration. In particular, we will develop good theorems for interchanging limits and integrals. ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-088.jpg?height=932&width=1055&top_left_y=811&top_left_x=120) Statue in Milan of Maria Gaetana Agnesi, who in 1748 published one of the first calculus textbooks. A translation of her book into English was published in 1801. In this chapter, we develop a method of integration more powerful than methods contemplated by the pioneers of calculus. @Giovanni Dall'Orto ## 3A Integration with Respect to a Measure ## Integration of Nonnegative Functions We will first define the integral of a nonnegative function with respect to a measure. Then by writing a real-valued function as the difference of two nonnegative functions, we will define the integral of a real-valued function with respect to a measure. We begin this process with the following definition. ### 3.1 Definition $\mathcal{S}$-partition Suppose $\mathcal{S}$ is a $\sigma$-algebra on a set $X$. An $\mathcal{S}$-partition of $X$ is a finite collection $A_{1}, \ldots, A_{m}$ of disjoint sets in $\mathcal{S}$ such that $A_{1} \cup \cdots \cup A_{m}=X$. The next definition should remind you of the definition of the lower Riemann sum (see 1.3). However, now we are working with an arbitrary measure and We adopt the convention that $0 \cdot \infty$ and $\infty \cdot 0$ should both be interpreted to be 0 . thus $X$ need not be a subset of $\mathbf{R}$. More importantly, even in the case when $X$ is a closed interval $[a, b]$ in $\mathbf{R}$ and $\mu$ is Lebesgue measure on the Borel subsets of $[a, b]$, the sets $A_{1}, \ldots, A_{m}$ in the definition below do not need to be subintervals of $[a, b]$ as they do for the lower Riemann sum-they need only be Borel sets. ### 3.2 Definition lower Lebesgue sum Suppose $(X, \mathcal{S}, \mu)$ is a measure space, $f: X \rightarrow[0, \infty]$ is an $\mathcal{S}$-measurable function, and $P$ is an $\mathcal{S}$-partition $A_{1}, \ldots, A_{m}$ of $X$. The lower Lebesgue sum $\mathcal{L}(f, P)$ is defined by $$ \mathcal{L}(f, P)=\sum_{j=1}^{m} \mu\left(A_{j}\right) \inf _{A_{j}} f $$ Suppose $(X, \mathcal{S}, \mu)$ is a measure space. We will denote the integral of an $\mathcal{S}$ measurable function $f$ with respect to $\mu$ by $\int f d \mu$. Our basic requirements for an integral are that we want $\int \chi_{E} d \mu$ to equal $\mu(E)$ for all $E \in \mathcal{S}$, and we want $\int(f+g) d \mu=\int f d \mu+\int g d \mu$. As we will see, the following definition satisfies both of those requirements (although this is not obvious). Think about why the following definition is reasonable in terms of the integral equaling the area under the graph of the function (in the special case of Lebesgue measure on an interval of $\mathbf{R}$ ). ### 3.3 Definition integral of a nonnegative function Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f: X \rightarrow[0, \infty]$ is an $\mathcal{S}$-measurable function. The integral of $f$ with respect to $\mu$, denoted $\int f d \mu$, is defined by $$ \int f d \mu=\sup \{\mathcal{L}(f, P): P \text { is an } \mathcal{S} \text {-partition of } X\} $$ Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f: X \rightarrow[0, \infty]$ is an $\mathcal{S}$-measurable function. Each $\mathcal{S}$-partition $A_{1}, \ldots, A_{m}$ of $X$ leads to an approximation of $f$ from below by the $\mathcal{S}$-measurable simple function $\sum_{j=1}^{m}\left(\inf _{A_{j}} f\right) \chi_{A_{j}}$. This suggests that $$ \sum_{j=1}^{m} \mu\left(A_{j}\right) \inf _{A_{j}} f $$ should be an approximation from below of our intuitive notion of $\int f d \mu$. Taking the supremum of these approximations leads to our definition of $\int f d \mu$. The following result gives our first example of evaluating an integral. ## 3.4 integral of a characteristic function Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $E \in \mathcal{S}$. Then $$ \int \chi_{E} d \mu=\mu(E) . $$ Proof If $P$ is the $\mathcal{S}$-partition of $X$ consisting of $E$ and its complement $X \backslash E$, then clearly $\mathcal{L}\left(\chi_{E}, P\right)=\mu(E)$. Thus $\int \chi_{E} d \mu \geq \mu(E)$. To prove the inequality in the other direction, suppose $P$ is an $\mathcal{S}$-partition $A_{1}, \ldots, A_{m}$ of $X$. Then $\mu\left(A_{j}\right) \inf _{A_{j}} \chi_{E}$ equals $\mu\left(A_{j}\right)$ if $A_{j} \subset E$ and equals 0 otherwise. Thus $$ \begin{aligned} \mathcal{L}\left(\chi_{E}, P\right) & =\sum_{\left\{j: A_{j} \subset E\right\}} \mu\left(A_{j}\right) \\ & =\mu\left(\bigcup_{\left\{j: A_{j} \subset E\right\}} A_{j}\right) \\ & \leq \mu(E) . \end{aligned} $$ The symbol $d$ in the expression $\int f \mathrm{~d} \mu$ has no independent meaning, but it often usefully separates $f$ from $\mu$. Because the $d$ in $\int f \mathrm{~d} \mu$ does not represent another object, some mathematicians prefer typesetting an upright $\mathrm{d}$ in this situation, producing $\int f \mathrm{~d} \mu$. However, the upright $\mathrm{d}$ looks jarring to some readers who are accustomed to italicized symbols. This book takes the compromise position of using slanted d instead of math-mode italicized $d$ in integrals. Thus $\int \chi_{E} d \mu \leq \mu(E)$, completing the proof. ### 3.5 Example integrals of $\chi_{\mathbf{Q}}$ and $\chi_{[0,1] \backslash \mathbf{Q}}$ Suppose $\lambda$ is Lebesgue measure on $\mathbf{R}$. As a special case of the result above, we have $\int \chi_{\mathbf{Q}} d \lambda=0$ (because $|\mathbf{Q}|=0$ ). Recall that $\chi_{\mathbf{Q}}$ is not Riemann integrable on $[0,1]$. Thus even at this early stage in our development of integration with respect to a measure, we have fixed one of the deficiencies of Riemann integration. Note also that 3.4 implies that $\int \chi_{[0,1] \backslash \mathbf{Q}} d \lambda=1$ (because $|[0,1] \backslash \mathbf{Q}|=1$ ), which is what we want. In contrast, the lower Riemann integral of $\chi_{[0,1] \backslash \mathbf{Q}}$ on $[0,1]$ equals 0 , which is not what we want. ### 3.6 Example integration with respect to counting measure is summation Suppose $\mu$ is counting measure on $\mathbf{Z}^{+}$and $b_{1}, b_{2}, \ldots$ is a sequence of nonnegative numbers. Think of $b$ as the function from $\mathbf{Z}^{+}$to $[0, \infty)$ defined by $b(k)=b_{k}$. Then $$ \int b d \mu=\sum_{k=1}^{\infty} b_{k} $$ as you should verify. Integration with respect to a measure can be called Lebesgue integration. The next result shows that Lebesgue integration behaves as expected on simple functions represented as linear combinations of characteristic functions of disjoint sets. ## 3.7 integral of a simple function Suppose $(X, \mathcal{S}, \mu)$ is a measure space, $E_{1}, \ldots, E_{n}$ are disjoint sets in $\mathcal{S}$, and $c_{1}, \ldots, c_{n} \in[0, \infty]$. Then $$ \int\left(\sum_{k=1}^{n} c_{k} \chi_{E_{k}}\right) d \mu=\sum_{k=1}^{n} c_{k} \mu\left(E_{k}\right) $$ Proof Without loss of generality, we can assume that $E_{1}, \ldots, E_{n}$ is an $\mathcal{S}$-partition of $X$ [by replacing $n$ by $n+1$ and setting $E_{n+1}=X \backslash\left(E_{1} \cup \ldots \cup E_{n}\right)$ and $c_{n+1}=0$ ]. If $P$ is the $\mathcal{S}$-partition $E_{1}, \ldots, E_{n}$ of $X$, then $\mathcal{L}\left(\sum_{k=1}^{n} c_{k} \chi_{E_{k}}{ }^{\prime} P\right)=\sum_{k=1}^{n} c_{k} \mu\left(E_{k}\right)$. Thus $$ \int\left(\sum_{k=1}^{n} c_{k} \chi_{E_{k}}\right) d \mu \geq \sum_{k=1}^{n} c_{k} \mu\left(E_{k}\right) $$ To prove the inequality in the other direction, suppose that $P$ is an $\mathcal{S}$-partition $A_{1}, \ldots, A_{m}$ of $X$. Then $$ \begin{aligned} \mathcal{L}\left(\sum_{k=1}^{n} c_{k} \chi_{E_{k}}, P\right) & =\sum_{j=1}^{m} \mu\left(A_{j}\right) \min _{\left\{i: A_{j} \cap E_{i} \neq \varnothing\right\}} c_{i} \\ & =\sum_{j=1}^{m} \sum_{k=1}^{n} \mu\left(A_{j} \cap E_{k}\right)_{\left\{i: A_{j} \cap E_{i} \neq \varnothing\right\}} c_{i} \\ & \leq \sum_{j=1}^{m} \sum_{k=1}^{n} \mu\left(A_{j} \cap E_{k}\right) c_{k} \\ & =\sum_{k=1}^{n} c_{k} \sum_{j=1}^{m} \mu\left(A_{j} \cap E_{k}\right) \\ & =\sum_{k=1}^{n} c_{k} \mu\left(E_{k}\right) . \end{aligned} $$ The inequality above implies that $\int\left(\sum_{k=1}^{n} c_{k} \chi_{E_{k}}\right) d \mu \leq \sum_{k=1}^{n} c_{k} \mu\left(E_{k}\right)$, completing the proof. The next easy result gives an unsurprising property of integrals. ## 3.8 integration is order preserving Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f, g: X \rightarrow[0, \infty]$ are $\mathcal{S}$-measurable functions such that $f(x) \leq g(x)$ for all $x \in X$. Then $\int f \mathrm{~d} \mu \leq \int g d \mu$. Proof Suppose $P$ is an $\mathcal{S}$-partition $A_{1}, \ldots, A_{m}$ of $X$. Then $$ \inf _{A_{j}} f \leq \inf _{A_{j}} g $$ for each $j=1, \ldots, m$. Thus $\mathcal{L}(f, P) \leq \mathcal{L}(g, P)$. Hence $\int f d \mu \leq \int g d \mu$. ## Monotone Convergence Theorem For the proof of the Monotone Convergence Theorem (and several other results), we will need to use the following mild restatement of the definition of the integral of a nonnegative function. ## 3.9 integrals via simple functions Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f: X \rightarrow[0, \infty]$ is $\mathcal{S}$-measurable. Then 3.10 $\int f d \mu=\sup \left\{\sum_{j=1}^{m} c_{j} \mu\left(A_{j}\right): A_{1}, \ldots, A_{m}\right.$ are disjoint sets in $\mathcal{S}$, $$ \begin{aligned} & c_{1}, \ldots, c_{m} \in[0, \infty), \text { and } \\ & \left.f(x) \geq \sum_{j=1}^{m} c_{j} \chi_{A_{j}}(x) \text { for every } x \in X\right\} \end{aligned} $$ Proof First note that the left side of 3.10 is bigger than or equal to the right side by 3.7 and 3.8 . To prove that the right side of 3.10 is bigger than or equal to the left side, first assume that $\inf _{A} f<\infty$ for every $A \in \mathcal{S}$ with $\mu(A)>0$. Then for $P$ an $\mathcal{S}$-partition $A_{1}, \ldots, A_{m}$ of nonempty subsets of $X$, take $c_{j}=\inf _{A_{j}} f$, which shows that $\mathcal{L}(f, P)$ is in the set on the right side of 3.10. Thus the definition of $\int f d \mu$ shows that the right side of 3.10 is bigger than or equal to the left side. The only remaining case to consider is when there exists a set $A \in \mathcal{S}$ such that $\mu(A)>0$ and $\inf _{A} f=\infty$ [which implies that $f(x)=\infty$ for all $x \in A$ ]. In this case, for arbitrary $t \in(0, \infty)$ we can take $m=1, A_{1}=A$, and $c_{1}=t$. These choices show that the right side of 3.10 is at least $t \mu(A)$. Because $t$ is an arbitrary positive number, this shows that the right side of 3.10 equals $\infty$, which of course is greater than or equal to the left side, completing the proof. The next result allows us to interchange limits and integrals in certain circumstances. We will see more theorems of this nature in the next section. ### 3.11 Monotone Convergence Theorem Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $0 \leq f_{1} \leq f_{2} \leq \cdots$ is an increasing sequence of $\mathcal{S}$-measurable functions. Define $f: X \rightarrow[0, \infty]$ by $$ f(x)=\lim _{k \rightarrow \infty} f_{k}(x) $$ Then $$ \lim _{k \rightarrow \infty} \int f_{k} d \mu=\int f d \mu $$ Proof The function $f$ is $\mathcal{S}$-measurable by 2.53 . Because $f_{k}(x) \leq f(x)$ for every $x \in X$, we have $\int f_{k} d \mu \leq \int f d \mu$ for each $k \in \mathbf{Z}^{+}$(by 3.8). Thus $\lim _{k \rightarrow \infty} \int f_{k} d \mu \leq \int f d \mu$. To prove the inequality in the other direction, suppose $A_{1}, \ldots, A_{m}$ are disjoint sets in $\mathcal{S}$ and $c_{1}, \ldots, c_{m} \in[0, \infty)$ are such that $$ f(x) \geq \sum_{j=1}^{m} c_{j} \chi_{A_{j}}(x) \quad \text { for every } x \in X $$ Let $t \in(0,1)$. For $k \in \mathbf{Z}^{+}$, let $$ E_{k}=\left\{x \in X: f_{k}(x) \geq t \sum_{j=1}^{m} c_{j} \chi_{A_{j}}(x)\right\} $$ Then $E_{1} \subset E_{2} \subset \cdots$ is an increasing sequence of sets in $\mathcal{S}$ whose union equals $X$. Thus $\lim _{k \rightarrow \infty} \mu\left(A_{j} \cap E_{k}\right)=\mu\left(A_{j}\right)$ for each $j \in\{1, \ldots, m\}$ (by 2.59). If $k \in \mathbf{Z}^{+}$, then $$ f_{k}(x) \geq \sum_{j=1}^{m} t c_{j} \chi_{A_{j} \cap E_{k}}(x) $$ for every $x \in X$. Thus (by 3.9) $$ \int f_{k} d \mu \geq t \sum_{j=1}^{m} c_{j} \mu\left(A_{j} \cap E_{k}\right) $$ Taking the limit as $k \rightarrow \infty$ of both sides of the inequality above gives $$ \lim _{k \rightarrow \infty} \int f_{k} d \mu \geq t \sum_{j=1}^{m} c_{j} \mu\left(A_{j}\right) $$ Now taking the limit as $t$ increases to 1 shows that $$ \lim _{k \rightarrow \infty} \int f_{k} d \mu \geq \sum_{j=1}^{m} c_{j} \mu\left(A_{j}\right) $$ Taking the supremum of the inequality above over all $\mathcal{S}$-partitions $A_{1}, \ldots, A_{m}$ of $X$ and all $c_{1}, \ldots, c_{m} \in[0, \infty$ ) satisfying 3.12 shows (using 3.9) that we have $\lim _{k \rightarrow \infty} \int f_{k} d \mu \geq \int f d \mu$, completing the proof. The proof that the integral is additive will use the Monotone Convergence Theorem and our next result. The representation of a simple function $h: X \rightarrow[0, \infty]$ in the form $\sum_{k=1}^{n} c_{k} \chi_{E_{k}}$ is not unique. Requiring the numbers $c_{1}, \ldots, c_{n}$ to be distinct and $E_{1}, \ldots, E_{n}$ to be nonempty and disjoint with $E_{1} \cup \cdots \cup E_{n}=X$ produces what is called the standard representation of a simple function [take $E_{k}=h^{-1}\left(\left\{c_{k}\right\}\right)$, where $c_{1}, \ldots, c_{n}$ are the distinct values of $\left.h\right]$. The following lemma shows that all representations (including representations with sets that are not disjoint) of a simple measurable function give the same sum that we expect from integration. ### 3.13 integral-type sums for simple functions Suppose $(X, \mathcal{S}, \mu)$ is a measure space. Suppose $a_{1}, \ldots, a_{m}, b_{1}, \ldots, b_{n} \in[0, \infty]$ and $A_{1}, \ldots, A_{m}, B_{1}, \ldots, B_{n} \in \mathcal{S}$ are such that $\sum_{j=1}^{m} a_{j} \chi_{A_{j}}=\sum_{k=1}^{n} b_{k} \chi_{B_{k}}$. Then $$ \sum_{j=1}^{m} a_{j} \mu\left(A_{j}\right)=\sum_{k=1}^{n} b_{k} \mu\left(B_{k}\right) $$ Proof We assume $A_{1} \cup \cdots \cup A_{m}=X$ (otherwise add the term $0 \chi_{X \backslash\left(A_{1} \cup \cdots \cup A_{m}\right.}$ ). Suppose $A_{1}$ and $A_{2}$ are not disjoint. Then we can write ### 3.14 $$ a_{1} \chi_{A_{1}}+a_{2} \chi_{A_{2}}=a_{1} \chi_{A_{1} \backslash A_{2}}+a_{2} \chi_{A_{2} \backslash A_{1}}+\left(a_{1}+a_{2}\right) \chi_{A_{1} \cap A_{2}}, $$ where the three sets appearing on the right side of the equation above are disjoint. Now $A_{1}=\left(A_{1} \backslash A_{2}\right) \cup\left(A_{1} \cap A_{2}\right)$ and $A_{2}=\left(A_{2} \backslash A_{1}\right) \cup\left(A_{1} \cap A_{2}\right)$; each of these unions is a disjoint union. Thus $\mu\left(A_{1}\right)=\mu\left(A_{1} \backslash A_{2}\right)+\mu\left(A_{1} \cap A_{2}\right)$ and $\mu\left(A_{2}\right)=\mu\left(A_{2} \backslash A_{1}\right)+\mu\left(A_{1} \cap A_{2}\right)$. Hence $$ a_{1} \mu\left(A_{1}\right)+a_{2} \mu\left(A_{2}\right)=a_{1} \mu\left(A_{1} \backslash A_{2}\right)+a_{2} \mu\left(A_{2} \backslash A_{1}\right)+\left(a_{1}+a_{2}\right) \mu\left(A_{1} \cap A_{2}\right) . $$ The equation above, in conjunction with 3.14 , shows that if we replace the two sets $A_{1}, A_{2}$ by the three disjoint sets $A_{1} \backslash A_{2}, A_{2} \backslash A_{1}, A_{1} \cap A_{2}$ and make the appropriate adjustments to the coefficients $a_{1}, \ldots, a_{m}$, then the value of the sum $\sum_{j=1}^{m} a_{j} \mu\left(A_{j}\right)$ is unchanged (although $m$ has increased by 1 ). Repeating this process with all pairs of subsets among $A_{1}, \ldots, A_{m}$ that are not disjoint after each step, in a finite number of steps we can convert the initial list $A_{1}, \ldots, A_{m}$ into a disjoint list of subsets without changing the value of $\sum_{j=1}^{m} a_{j} \mu\left(A_{j}\right)$. The next step is to make the numbers $a_{1}, \ldots, a_{m}$ distinct. This is done by replacing the sets corresponding to each $a_{j}$ by the union of those sets, and using finite additivity of the measure $\mu$ to show that the value of the sum $\sum_{j=1}^{m} a_{j} \mu\left(A_{j}\right)$ does not change. Finally, drop any terms for which $A_{j}=\varnothing$, getting the standard representation for a simple function. We have now shown that the original value of $\sum_{j=1}^{m} a_{j} \mu\left(A_{j}\right)$ is equal to the value if we use the standard representation of the simple function $\sum_{j=1}^{m} a_{j} \chi_{A_{j}}$. The same procedure can be used with the representation $\sum_{k=1}^{n} b_{k} \chi_{B_{k}}$ to show that $\sum_{k=1}^{n} b_{k} \mu\left(\chi_{B_{k}}\right)$ equals what we would get with the standard representation. Thus the equality of the functions $\sum_{j=1}^{m} a_{j} \chi_{A_{j}}$ and $\sum_{k=1}^{n} b_{k} \chi_{B_{k}}$ implies the equality $\sum_{j=1}^{m} a_{j} \mu\left(A_{j}\right)=\sum_{k=1}^{n} b_{k} \mu\left(B_{k}\right)$. Now we can show that our definition of integration does the right thing with simple measurable functions that might not be expressed in the standard representation. The result below differs from 3.7 mainly because the sets $E_{1}, \ldots, E_{n}$ in the result below are not required to be disjoint. Like the previous result, the next result would follow immediately from the linearity of integration if that property had already been proved. If we had already proved that integration is linear, then we could quickly get the conclusion of the previous result by integrating both sides of the equation $\sum_{j=1}^{m} a_{j} \chi_{A_{j}}=\sum_{k=1}^{n} b_{k} \chi_{B_{k}}$ with respect to $\mu$. However, we need the previous result to prove the next result, which is used in our proof that integration is linear. ### 3.15 integral of a linear combination of characteristic functions Suppose $(X, \mathcal{S}, \mu)$ is a measure space, $E_{1}, \ldots, E_{n} \in \mathcal{S}$, and $c_{1}, \ldots, c_{n} \in[0, \infty]$. Then $$ \int\left(\sum_{k=1}^{n} c_{k} \chi_{E_{k}}\right) d \mu=\sum_{k=1}^{n} c_{k} \mu\left(E_{k}\right) $$ Proof The desired result follows from writing the simple function $\sum_{k=1}^{n} c_{k} \chi_{E_{k}}$ in the standard representation for a simple function and then using 3.7 and 3.13. Now we can prove that integration is additive on nonnegative functions. ### 3.16 additivity of integration Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f, g: X \rightarrow[0, \infty]$ are $\mathcal{S}$-measurable functions. Then $$ \int(f+g) d \mu=\int f d \mu+\int g d \mu $$ Proof The desired result holds for simple nonnegative $\mathcal{S}$-measurable functions (by 3.15). Thus we approximate by such functions. Specifically, let $f_{1}, f_{2}, \ldots$ and $g_{1}, g_{2}, \ldots$ be increasing sequences of simple nonnegative $\mathcal{S}$-measurable functions such that $$ \lim _{k \rightarrow \infty} f_{k}(x)=f(x) \quad \text { and } \quad \lim _{k \rightarrow \infty} g_{k}(x)=g(x) $$ for all $x \in X$ (see 2.89 for the existence of such increasing sequences). Then $$ \begin{aligned} \int(f+g) d \mu & =\lim _{k \rightarrow \infty} \int\left(f_{k}+g_{k}\right) d \mu \\ & =\lim _{k \rightarrow \infty} \int f_{k} d \mu+\lim _{k \rightarrow \infty} \int g_{k} d \mu \\ & =\int f d \mu+\int g d \mu, \end{aligned} $$ where the first and third equalities follow from the Monotone Convergence Theorem and the second equality holds by 3.15 . The lower Riemann integral is not additive, even for bounded nonnegative measurable functions. For example, if $f=\chi_{\mathbf{Q} \cap[0,1]}$ and $g=\chi_{[0,1] \backslash \mathbf{Q}}$, then $$ L(f,[0,1])=0 \quad \text { and } \quad L(g,[0,1])=0 \quad \text { but } \quad L(f+g,[0,1])=1 \text {. } $$ In contrast, if $\lambda$ is Lebesgue measure on the Borel subsets of $[0,1]$, then $$ \int f d \lambda=0 \quad \text { and } \quad \int g d \lambda=1 \quad \text { and } \quad \int(f+g) d \lambda=1 $$ More generally, we have just proved that $\int(f+g) d \mu=\int f d \mu+\int g d \mu$ for every measure $\mu$ and for all nonnegative measurable functions $f$ and $g$. Recall that integration with respect to a measure is defined via lower Lebesgue sums in a similar fashion to the definition of the lower Riemann integral via lower Riemann sums (with the big exception of allowing measurable sets instead of just intervals in the partitions). However, we have just seen that the integral with respect to a measure (which could have been called the lower Lebesgue integral) has considerably nicer behavior (additivity!) than the lower Riemann integral. ## Integration of Real-Valued Functions The following definition gives us a standard way to write an arbitrary real-valued function as the difference of two nonnegative functions. ### 3.17 Definition $f^{+} ; f^{-}$ Suppose $f: X \rightarrow[-\infty, \infty]$ is a function. Define functions $f^{+}$and $f^{-}$from $X$ to $[0, \infty]$ by $$ f^{+}(x)=\left\{\begin{array}{ll} f(x) & \text { if } f(x) \geq 0, \\ 0 & \text { if } f(x)<0 \end{array} \quad \text { and } \quad f^{-}(x)= \begin{cases}0 & \text { if } f(x) \geq 0 \\ -f(x) & \text { if } f(x)<0\end{cases}\right. $$ Note that if $f: X \rightarrow[-\infty, \infty]$ is a function, then $$ f=f^{+}-f^{-} \quad \text { and } \quad|f|=f^{+}+f^{-} . $$ The decomposition above allows us to extend our definition of integration to functions that take on negative as well as positive values. 3.18 Definition integral of a real-valued function; $\int f d \mu$ Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f: X \rightarrow[-\infty, \infty]$ is an $\mathcal{S}$-measurable function such that at least one of $\int f^{+} d \mu$ and $\int f^{-} d \mu$ is finite. The integral of $f$ with respect to $\mu$, denoted $\int f d \mu$, is defined by $$ \int f d \mu=\int f^{+} d \mu-\int f^{-} d \mu $$ If $f \geq 0$, then $f^{+}=f$ and $f^{-}=0$; thus this definition is consistent with the previous definition of the integral of a nonnegative function. The condition $\int|f| d \mu<\infty$ is equivalent to the condition $\int f^{+} d \mu<\infty$ and $\int f^{-} d \mu<\infty$ (because $|f|=f^{+}+f^{-}$). ### 3.19 Example a function whose integral is not defined Suppose $\lambda$ is Lebesgue measure on $\mathbf{R}$ and $f: \mathbf{R} \rightarrow \mathbf{R}$ is the function defined by $$ f(x)= \begin{cases}1 & \text { if } x \geq 0 \\ -1 & \text { if } x<0\end{cases} $$ Then $\int f d \lambda$ is not defined because $\int f^{+} d \lambda=\infty$ and $\int f^{-} d \lambda=\infty$. The next result says that the integral of a number times a function is exactly what we expect. ### 3.20 integration is homogeneous Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f: X \rightarrow[-\infty, \infty]$ is a function such that $\int f d \mu$ is defined. If $c \in \mathbf{R}$, then $$ \int c f d \mu=c \int f d \mu . $$ Proof First consider the case where $f$ is a nonnegative function and $c \geq 0$. If $P$ is an $\mathcal{S}$-partition of $X$, then clearly $\mathcal{L}(c f, P)=c \mathcal{L}(f, P)$. Thus $\int c f d \mu=c \int f d \mu$. Now consider the general case where $f$ takes values in $[-\infty, \infty]$. Suppose $c \geq 0$. Then $$ \begin{aligned} \int c f d \mu & =\int(c f)^{+} d \mu-\int(c f)^{-} d \mu \\ & =\int c f^{+} d \mu-\int c f^{-} d \mu \\ & =c\left(\int f^{+} d \mu-\int f^{-} d \mu\right) \\ & =c \int f d \mu, \end{aligned} $$ where the third line follows from the first paragraph of this proof. Finally, now suppose $c<0$ (still assuming that $f$ takes values in $[-\infty, \infty]$ ). Then $-c>0$ and $$ \begin{aligned} \int c f d \mu & =\int(c f)^{+} d \mu-\int(c f)^{-} d \mu \\ & =\int(-c) f^{-} d \mu-\int(-c) f^{+} d \mu \\ & =(-c)\left(\int f^{-} d \mu-\int f^{+} d \mu\right) \\ & =c \int f d \mu, \end{aligned} $$ completing the proof. Now we prove that integration with respect to a measure has the additive property required for a good theory of integration. ### 3.21 additivity of integration Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f, g: X \rightarrow \mathbf{R}$ are $\mathcal{S}$-measurable functions such that $\int|f| d \mu<\infty$ and $\int|g| d \mu<\infty$. Then $$ \int(f+g) d \mu=\int f d \mu+\int g d \mu $$ Proof Clearly $$ \begin{aligned} (f+g)^{+}-(f+g)^{-} & =f+g \\ & =f^{+}-f^{-}+g^{+}-g^{-} \end{aligned} $$ Thus $$ (f+g)^{+}+f^{-}+g^{-}=(f+g)^{-}+f^{+}+g^{+} . $$ Both sides of the equation above are sums of nonnegative functions. Thus integrating both sides with respect to $\mu$ and using 3.16 gives $\int(f+g)^{+} d \mu+\int f^{-} d \mu+\int g^{-} d \mu=\int(f+g)^{-} d \mu+\int f^{+} d \mu+\int g^{+} d \mu$. Rearranging the equation above gives $\int(f+g)^{+} d \mu-\int(f+g)^{-} d \mu=\int f^{+} d \mu-\int f^{-} d \mu+\int g^{+} d \mu-\int g^{-} d \mu$, where the left side is not of the form $\infty-\infty$ because $(f+g)^{+} \leq f^{+}+g^{+}$and $(f+g)^{-} \leq f^{-}+g^{-}$. The equation above can be rewritten as $$ \int(f+g) d \mu=\int f d \mu+\int g d \mu $$ completing the proof. Gottfried Leibniz (1646-1716) invented the symbol $\int$ to denote integration in 1675. The next result resembles 3.8, but now the functions are allowed to be real valued. ### 3.22 integration is order preserving Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f, g: X \rightarrow \mathbf{R}$ are $\mathcal{S}$-measurable functions such that $\int f d \mu$ and $\int g d \mu$ are defined. Suppose also that $f(x) \leq g(x)$ for all $x \in X$. Then $\int f d \mu \leq \int g d \mu$. Proof The cases where $\int f d \mu= \pm \infty$ or $\int g d \mu= \pm \infty$ are left to the reader. Thus we assume that $\int|f| d \mu<\infty$ and $\int|g| d \mu<\infty$. The additivity (3.21) and homogeneity ( 3.20 with $c=-1$ ) of integration imply that $$ \int g d \mu-\int f d \mu=\int(g-f) d \mu $$ The last integral is nonnegative because $g(x)-f(x) \geq 0$ for all $x \in X$. The inequality in the next result receives frequent use. ### 3.23 absolute value of integral $\leq$ integral of absolute value Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f: X \rightarrow[-\infty, \infty]$ is a function such that $\int f d \mu$ is defined. Then $$ \left|\int f d \mu\right| \leq \int|f| d \mu $$ Proof Because $\int f d \mu$ is defined, $f$ is an $\mathcal{S}$-measurable function and least one of $\int f^{+} d \mu$ and $\int f^{-} d \mu$ is finite. Thus $$ \begin{aligned} \left|\int f d \mu\right| & =\left|\int f^{+} d \mu-\int f^{-} d \mu\right| \\ & \leq \int f^{+} d \mu+\int f^{-} d \mu \\ & =\int\left(f^{+}+f^{-}\right) d \mu \\ & =\int|f| d \mu, \end{aligned} $$ as desired. ## EXERCISES 3A 1 Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f: X \rightarrow[0, \infty]$ is an $\mathcal{S}$-measurable function such that $\int f d \mu<\infty$. Explain why $$ \inf _{E} f=0 $$ for each set $E \in \mathcal{S}$ with $\mu(E)=\infty$. 2 Suppose $X$ is a set, $\mathcal{S}$ is a $\sigma$-algebra on $X$, and $c \in X$. Define the Dirac measure $\delta_{c}$ on $(X, \mathcal{S})$ by $$ \delta_{c}(E)= \begin{cases}1 & \text { if } c \in E \\ 0 & \text { if } c \notin E\end{cases} $$ Prove that if $f: X \rightarrow[0, \infty]$ is $\mathcal{S}$-measurable, then $\int f \mathrm{~d} \delta_{c}=f(c)$. [Careful: $\{c\}$ may not be in $\mathcal{S}$.] 3 Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f: X \rightarrow[0, \infty]$ is an $\mathcal{S}$-measurable function. Prove that $$ \int f d \mu>0 \text { if and only if } \mu(\{x \in X: f(x)>0\})>0 \text {. } $$ 4 Give an example of a Borel measurable function $f:[0,1] \rightarrow(0, \infty)$ such that $L(f,[0,1])=0$. [Recall that $L(f,[0,1])$ denotes the lower Riemann integral, which was defined in Section 1A. If $\lambda$ is Lebesgue measure on $[0,1]$, then the previous exercise states that $\int f d \lambda>0$ for this function $f$, which is what we expect of a positive function. Thus even though both $L(f,[0,1])$ and $\int f d \lambda$ are defined by taking the supremum of approximations from below, Lebesgue measure captures the right behavior for this function $f$ and the lower Riemann integral does not.] 5 Verify the assertion that integration with respect to counting measure is summation (Example 3.6). 6 Suppose $(X, \mathcal{S}, \mu)$ is a measure space, $f: X \rightarrow[0, \infty]$ is $\mathcal{S}$-measurable, and $P$ and $P^{\prime}$ are $\mathcal{S}$-partitions of $X$ such that each set in $P^{\prime}$ is contained in some set in $P$. Prove that $\mathcal{L}(f, P) \leq \mathcal{L}\left(f, P^{\prime}\right)$. 7 Suppose $X$ is a set, $\mathcal{S}$ is the $\sigma$-algebra of all subsets of $X$, and $w: X \rightarrow[0, \infty]$ is a function. Define a measure $\mu$ on $(X, \mathcal{S})$ by $$ \mu(E)=\sum_{x \in E} w(x) $$ for $E \subset X$. Prove that if $f: X \rightarrow[0, \infty]$ is a function, then $$ \int f d \mu=\sum_{x \in X} w(x) f(x) $$ where the infinite sums above are defined as the supremum of all sums over finite subsets of $E$ (first sum) or $X$ (second sum). 8 Suppose $\lambda$ denotes Lebesgue measure on R. Give an example of a sequence $f_{1}, f_{2}, \ldots$ of simple Borel measurable functions from $\mathbf{R}$ to $[0, \infty)$ such that $\lim _{k \rightarrow \infty} f_{k}(x)=0$ for every $x \in \mathbf{R}$ but $\lim _{k \rightarrow \infty} \int f_{k} d \lambda=1$. 9 Suppose $\mu$ is a measure on a measurable space $(X, \mathcal{S})$ and $f: X \rightarrow[0, \infty]$ is an $\mathcal{S}$-measurable function. Define $v: \mathcal{S} \rightarrow[0, \infty]$ by $$ v(A)=\int \chi_{A} f d \mu $$ for $A \in \mathcal{S}$. Prove that $v$ is a measure on $(X, \mathcal{S})$. 10 Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f_{1}, f_{2}, \ldots$ is a sequence of nonnegative $\mathcal{S}$-measurable functions. Define $f: X \rightarrow[0, \infty]$ by $f(x)=\sum_{k=1}^{\infty} f_{k}(x)$. Prove that $$ \int f d \mu=\sum_{k=1}^{\infty} \int f_{k} d \mu $$ 11 Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f_{1}, f_{2}, \ldots$ are $\mathcal{S}$-measurable functions from $X$ to $\mathbf{R}$ such that $\sum_{k=1}^{\infty} \int\left|f_{k}\right| d \mu<\infty$. Prove that there exists $E \in \mathcal{S}$ such that $\mu(X \backslash E)=0$ and $\lim _{k \rightarrow \infty} f_{k}(x)=0$ for every $x \in E$. 12 Show that there exists a Borel measurable function $f: \mathbf{R} \rightarrow(0, \infty)$ such that $\int \chi_{I} f d \lambda=\infty$ for every nonempty open interval $I \subset \mathbf{R}$, where $\lambda$ denotes Lebesgue measure on $\mathbf{R}$. 13 Give an example to show that the Monotone Convergence Theorem (3.11) can fail if the hypothesis that $f_{1}, f_{2}, \ldots$ are nonnegative functions is dropped. 14 Give an example to show that the Monotone Convergence Theorem can fail if the hypothesis of an increasing sequence of functions is replaced by a hypothesis of a decreasing sequence of functions. [This exercise shows that the Monotone Convergence Theorem should be called the Increasing Convergence Theorem. However, see Exercise 20.] 15 Suppose $\lambda$ is Lebesgue measure on $\mathbf{R}$ and $f: \mathbf{R} \rightarrow[-\infty, \infty]$ is a Borel measurable function such that $\int f d \lambda$ is defined. (a) For $t \in \mathbf{R}$, define $f_{t}: \mathbf{R} \rightarrow[-\infty, \infty]$ by $f_{t}(x)=f(x-t)$. Prove that $\int f_{t} d \lambda=\int f d \lambda$ for all $t \in \mathbf{R}$. (b) For $t \in \mathbf{R}$, define $f_{t}: \mathbf{R} \rightarrow[-\infty, \infty]$ by $f_{t}(x)=f(t x)$. Prove that $\int f_{t} d \lambda=\frac{1}{|t|} \int f d \lambda$ for all $t \in \mathbf{R} \backslash\{0\}$. 16 Suppose $\mathcal{S}$ and $\mathcal{T}$ are $\sigma$-algebras on a set $X$ and $\mathcal{S} \subset \mathcal{T}$. Suppose $\mu_{1}$ is a measure on $(X, \mathcal{S}), \mu_{2}$ is a measure on $(X, \mathcal{T})$, and $\mu_{1}(E)=\mu_{2}(E)$ for all $E \in \mathcal{S}$. Prove that if $f: X \rightarrow[0, \infty]$ is $\mathcal{S}$-measurable, then $\int f d \mu_{1}=\int f d \mu_{2}$. For $x_{1}, x_{2}, \ldots$ a sequence in $[-\infty, \infty]$, define $\underset{k \rightarrow \infty}{\lim \inf } x_{k}$ by $$ \liminf _{k \rightarrow \infty} x_{k}=\lim _{k \rightarrow \infty} \inf \left\{x_{k}, x_{k+1}, \ldots\right\} $$ Note that $\inf \left\{x_{k}, x_{k+1}, \ldots\right\}$ is an increasing function of $k$; thus the limit above on the right exists in $[-\infty, \infty]$. 17 Suppose that $(X, \mathcal{S}, \mu)$ is a measure space and $f_{1}, f_{2}, \ldots$ is a sequence of nonnegative $\mathcal{S}$-measurable functions on $X$. Define a function $f: X \rightarrow[0, \infty]$ by $f(x)=\liminf _{k \rightarrow \infty} f_{k}(x)$. (a) Show that $f$ is an $\mathcal{S}$-measurable function. (b) Prove that $$ \int f d \mu \leq \liminf _{k \rightarrow \infty} \int f_{k} d \mu $$ (c) Give an example showing that the inequality in (b) can be a strict inequality even when $\mu(X)<\infty$ and the family of functions $\left\{f_{k}\right\}_{k \in \mathbf{Z}^{+}}$is uniformly bounded. [The result in (b) is called Fatou's Lemma. Some textbooks prove Fatou's Lemma and then use it to prove the Monotone Convergence Theorem. Here we are taking the reverse approach-you should be able to use the Monotone Convergence Theorem to give a clean proof of Fatou's Lemma.] 18 Give an example of a sequence $x_{1}, x_{2}, \ldots$ of real numbers such that $$ \lim _{n \rightarrow \infty} \sum_{k=1}^{n} x_{k} \text { exists in } \mathbf{R} $$ but $\int x d \mu$ is not defined, where $\mu$ is counting measure on $\mathbf{Z}^{+}$and $x$ is the function from $\mathbf{Z}^{+}$to $\mathbf{R}$ defined by $x(k)=x_{k}$. 19 Show that if $(X, \mathcal{S}, \mu)$ is a measure space and $f: X \rightarrow[0, \infty)$ is $\mathcal{S}$-measurable, then $$ \mu(X) \inf _{X} f \leq \int f d \mu \leq \mu(X) \sup _{X} f $$ 20 Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f_{1}, f_{2}, \ldots$ is a monotone (meaning either increasing or decreasing) sequence of $\mathcal{S}$-measurable functions. Define $f: X \rightarrow[-\infty, \infty]$ by $$ f(x)=\lim _{k \rightarrow \infty} f_{k}(x) $$ Prove that if $\int\left|f_{1}\right| d \mu<\infty$, then $$ \lim _{k \rightarrow \infty} \int f_{k} d \mu=\int f d \mu $$ 21 Henri Lebesgue wrote the following about his method of integration: I have to pay a certain sum, which I have collected in my pocket. I take the bills and coins out of my pocket and give them to the creditor in the order I find them until I have reached the total sum. This is the Riemann integral. But I can proceed differently. After I have taken all the money out of my pocket I order the bills and coins according to identical values and then I pay the several heaps one after the other to the creditor. This is my integral. Use 3.15 to explain what Lebesgue meant and to explain why integration of a function with respect to a measure can be thought of as partitioning the range of the function, in contrast to Riemann integration, which depends upon partitioning the domain of the function. [The quote above is taken from page 796 of The Princeton Companion to Mathematics, edited by Timothy Gowers.] ## 3B Limits of Integrals \& Integrals of Limits This section focuses on interchanging limits and integrals. Those tools allow us to characterize the Riemann integrable functions in terms of Lebesgue measure. We also develop some good approximation tools that will be useful in later chapters. ## Bounded Convergence Theorem We begin this section by introducing some useful notation. 3.24 Definition integration on a subset; $\int_{E} f d \mu$ Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $E \in \mathcal{S}$. If $f: X \rightarrow[-\infty, \infty]$ is an $\mathcal{S}$-measurable function, then $\int_{E} f \mathrm{~d} \mu$ is defined by $$ \int_{E} f d \mu=\int \chi_{E} f d \mu $$ if the right side of the equation above is defined; otherwise $\int_{E} f d \mu$ is undefined. Alternatively, you can think of $\int_{E} f d \mu$ as $\left.\int f\right|_{E} d \mu_{E}$, where $\mu_{E}$ is the measure obtained by restricting $\mu$ to the elements of $\mathcal{S}$ that are contained in $E$. Notice that according to the definition above, the notation $\int_{X} f d \mu$ means the same as $\int f d \mu$. The following easy result illustrates the use of this new notation. ### 3.25 bounding an integral Suppose $(X, \mathcal{S}, \mu)$ is a measure space, $E \in \mathcal{S}$, and $f: X \rightarrow[-\infty, \infty]$ is a function such that $\int_{E} f d \mu$ is defined. Then $$ \left|\int_{E} f d \mu\right| \leq \mu(E) \sup _{E}|f| $$ Proof Let $c=\sup _{E}|f|$. We have $$ \begin{aligned} \left|\int_{E} f d \mu\right| & =\left|\int \chi_{E} f d \mu\right| \\ & \leq \int \chi_{E}|f| d \mu \\ & \leq \int c \chi_{E} d \mu \\ & =c \mu(E), \end{aligned} $$ where the second line comes from 3.23, the third line comes from 3.8, and the fourth line comes from 3.15. The next result could be proved as a special case of the Dominated Convergence Theorem (3.31), which we prove later in this section. Thus you could skip the proof here. However, sometimes you get more insight by seeing an easier proof of an important special case. Thus you may want to read the easy proof of the Bounded Convergence Theorem that is presented next. ### 3.26 Bounded Convergence Theorem Suppose $(X, \mathcal{S}, \mu)$ is a measure space with $\mu(X)<\infty$. Suppose $f_{1}, f_{2}, \ldots$ is a sequence of $\mathcal{S}$-measurable functions from $X$ to $\mathbf{R}$ that converges pointwise on $X$ to a function $f: X \rightarrow \mathbf{R}$. If there exists $c \in(0, \infty)$ such that $$ \left|f_{k}(x)\right| \leq c $$ for all $k \in \mathbf{Z}^{+}$and all $x \in X$, then $$ \lim _{k \rightarrow \infty} \int f_{k} d \mu=\int f d \mu $$ Proof The function $f$ is $\mathcal{S}$-measurable by 2.48 . Suppose $c$ satisfies the hypothesis of this theorem. Let $\varepsilon>0$. By Egorov's Theorem (2.85), there exists $E \in \mathcal{S}$ such that $\mu(X \backslash E)<\frac{\varepsilon}{4 c}$ and $f_{1}, f_{2}, \ldots$ converges uniformly to $f$ on $E$. Now $$ \begin{aligned} \left|\int f_{k} d \mu-\int f d \mu\right| & =\left|\int_{X \backslash E} f_{k} d \mu-\int_{X \backslash E} f d \mu+\int_{E}\left(f_{k}-f\right) d \mu\right| \\ & \leq \int_{X \backslash E}\left|f_{k}\right| d \mu+\int_{X \backslash E}|f| d \mu+\int_{E}\left|f_{k}-f\right| d \mu \\ & <\frac{\varepsilon}{2}+\mu(E) \sup _{E}\left|f_{k}-f\right|, \end{aligned} $$ where the last inequality follows from 3.25 . Because $f_{1}, f_{2}, \ldots$ converges uniformly to $f$ on $E$ and $\mu(E)<\infty$, the right side of the inequality above is less than $\varepsilon$ for $k$ sufficiently large, which completes the proof. ## Sets of Measure 0 in Integration Theorems Suppose $(X, \mathcal{S}, \mu)$ is a measure space. If $f, g: X \rightarrow[-\infty, \infty]$ are $\mathcal{S}$-measurable functions and $$ \mu(\{x \in X: f(x) \neq g(x)\})=0, $$ then the definition of an integral implies that $\int f d \mu=\int g d \mu$ (or both integrals are undefined). Because what happens on a set of measure 0 often does not matter, the following definition is useful. ### 3.27 Definition almost every Suppose $(X, \mathcal{S}, \mu)$ is a measure space. A set $E \in \mathcal{S}$ is said to contain $\mu$-almost every element of $X$ if $\mu(X \backslash E)=0$. If the measure $\mu$ is clear from the context, then the phrase almost every can be used (abbreviated by some authors to $a . e$.). For example, almost every real number is irrational (with respect to the usual Lebesgue measure on $\mathbf{R}$ ) because $|\mathbf{Q}|=0$. Theorems about integrals can almost always be relaxed so that the hypotheses apply only almost everywhere instead of everywhere. For example, consider the Bounded Convergence Theorem (3.26), one of whose hypotheses is that $$ \lim _{k \rightarrow \infty} f_{k}(x)=f(x) $$ for all $x \in X$. Suppose that the hypotheses of the Bounded Convergence Theorem hold except that the equation above holds only almost everywhere, meaning there is a set $E \in \mathcal{S}$ such that $\mu(X \backslash E)=0$ and the equation above holds for all $x \in E$. Define new functions $g_{1}, g_{2}, \ldots$ and $g$ by $$ g_{k}(x)=\left\{\begin{array}{ll} f_{k}(x) & \text { if } x \in E, \\ 0 & \text { if } x \in X \backslash E \end{array} \quad \text { and } \quad g(x)= \begin{cases}f(x) & \text { if } x \in E \\ 0 & \text { if } x \in X \backslash E\end{cases}\right. $$ Then $$ \lim _{k \rightarrow \infty} g_{k}(x)=g(x) $$ for all $x \in X$. Hence the Bounded Convergence Theorem implies that $$ \lim _{k \rightarrow \infty} \int g_{k} d \mu=\int g d \mu $$ which immediately implies that $$ \lim _{k \rightarrow \infty} \int f_{k} d \mu=\int f d \mu $$ because $\int g_{k} d \mu=\int f_{k} d \mu$ and $\int g d \mu=\int f d \mu$. ## Dominated Convergence Theorem The next result tells us that if a nonnegative function has a finite integral, then its integral over all small sets (in the sense of measure) is small. ### 3.28 integrals on small sets are small Suppose $(X, \mathcal{S}, \mu)$ is a measure space, $g: X \rightarrow[0, \infty]$ is $\mathcal{S}$-measurable, and $\int g d \mu<\infty$. Then for every $\varepsilon>0$, there exists $\delta>0$ such that $$ \int_{B} g d \mu<\varepsilon $$ for every set $B \in \mathcal{S}$ such that $\mu(B)<\delta$. Proof Suppose $\varepsilon>0$. Let $h: X \rightarrow[0, \infty)$ be a simple $\mathcal{S}$-measurable function such that $0 \leq h \leq g$ and $$ \int g d \mu-\int h d \mu<\frac{\varepsilon}{2} $$ the existence of a function $h$ with these properties follows from 3.9. Let $$ H=\max \{h(x): x \in X\} $$ and let $\delta>0$ be such that $H \delta<\frac{\varepsilon}{2}$. Suppose $B \in \mathcal{S}$ and $\mu(B)<\delta$. Then $$ \begin{aligned} \int_{B} g d \mu & =\int_{B}(g-h) d \mu+\int_{B} h d \mu \\ & \leq \int(g-h) d \mu+H \mu(B) \\ & <\frac{\varepsilon}{2}+H \delta \\ & <\varepsilon, \end{aligned} $$ as desired. Some theorems, such as Egorov's Theorem (2.85) have as a hypothesis that the measure of the entire space is finite. The next result sometimes allows us to get around this hypothesis by restricting attention to a key set of finite measure. 3.29 integrable functions live mostly on sets of finite measure Suppose $(X, \mathcal{S}, \mu)$ is a measure space, $g: X \rightarrow[0, \infty]$ is $\mathcal{S}$-measurable, and $\int g d \mu<\infty$. Then for every $\varepsilon>0$, there exists $E \in \mathcal{S}$ such that $\mu(E)<\infty$ and $$ \int_{X \backslash E} g d \mu<\varepsilon $$ Proof Suppose $\varepsilon>0$. Let $P$ be an $\mathcal{S}$-partition $A_{1}, \ldots, A_{m}$ of $X$ such that $$ \int g d \mu<\varepsilon+\mathcal{L}(g, P) . $$ Let $E$ be the union of those $A_{j}$ such that $\inf _{A_{j}} g>0$. Then $\mu(E)<\infty$ (because otherwise we would have $\mathcal{L}(g, P)=\infty$, which contradicts the hypothesis that $\left.\int g d \mu<\infty\right)$. Now $$ \begin{aligned} \int_{X \backslash E} g d \mu & =\int g d \mu-\int \chi_{E} g d \mu \\ & <(\varepsilon+\mathcal{L}(g, P))-\mathcal{L}\left(\chi_{E} g, P\right) \\ & =\varepsilon, \end{aligned} $$ where the second line follows from 3.30 and the definition of the integral of a nonnegative function, and the last line holds because $\inf _{A_{j}} g=0$ for each $A_{j}$ not contained in $E$. Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $f_{1}, f_{2}, \ldots$ is a sequence of $\mathcal{S}$-measurable functions on $X$ such that $\lim _{k \rightarrow \infty} f_{k}(x)=f(x)$ for every (or almost every) $x \in X$. In general, it is not true that $\lim _{k \rightarrow \infty} \int f_{k} d \mu=\int f d \mu$ (see Exercises 1 and 2 ). We already have two good theorems about interchanging limits and integrals. However, both of these theorems have restrictive hypotheses. Specifically, the Monotone Convergence Theorem (3.11) requires all the functions to be nonnegative and it requires the sequence of functions to be increasing. The Bounded Convergence Theorem (3.26) requires the measure of the whole space to be finite and it requires the sequence of functions to be uniformly bounded by a constant. The next theorem is the grand result in this area. It does not require the sequence of functions to be nonnegative, it does not require the sequence of functions to be increasing, it does not require the measure of the whole space to be finite, and it does not require the sequence of functions to be uniformly bounded. All these hypotheses are replaced only by a requirement that the sequence of functions is pointwise bounded by a function with a finite integral. Notice that the Bounded Convergence Theorem follows immediately from the result below (take $g$ to be an appropriate constant function and use the hypothesis in the Bounded Convergence Theorem that $\mu(X)<\infty$ ). ### 3.31 Dominated Convergence Theorem Suppose $(X, \mathcal{S}, \mu)$ is a measure space, $f: X \rightarrow[-\infty, \infty]$ is $\mathcal{S}$-measurable, and $f_{1}, f_{2}, \ldots$ are $\mathcal{S}$-measurable functions from $X$ to $[-\infty, \infty]$ such that $$ \lim _{k \rightarrow \infty} f_{k}(x)=f(x) $$ for almost every $x \in X$. If there exists an $\mathcal{S}$-measurable function $g: X \rightarrow[0, \infty]$ such that $$ \int g d \mu<\infty \quad \text { and } \quad\left|f_{k}(x)\right| \leq g(x) $$ for every $k \in \mathbf{Z}^{+}$and almost every $x \in X$, then $$ \lim _{k \rightarrow \infty} \int f_{k} d \mu=\int f d \mu $$ Proof Suppose $g: X \rightarrow[0, \infty]$ satisfies the hypotheses of this theorem. If $E \in \mathcal{S}$, then $$ \begin{aligned} \left|\int f_{k} d \mu-\int f d \mu\right| & =\left|\int_{X \backslash E} f_{k} d \mu-\int_{X \backslash E} f d \mu+\int_{E} f_{k} d \mu-\int_{E} f d \mu\right| \\ & \leq\left|\int_{X \backslash E} f_{k} d \mu\right|+\left|\int_{X \backslash E} f d \mu\right|+\left|\int_{E} f_{k} d \mu-\int_{E} f d \mu\right| \\ & \leq 2 \int_{X \backslash E} g d \mu+\left|\int_{E} f_{k} d \mu-\int_{E} f d \mu\right| . \end{aligned} $$ Case 1: Suppose $\mu(X)<\infty$. Let $\varepsilon>0$. By 3.28 , there exists $\delta>0$ such that $$ \int_{B} g d \mu<\frac{\varepsilon}{4} $$ for every set $B \in \mathcal{S}$ such that $\mu(B)<\delta$. By Egorov's Theorem (2.85), there exists a set $E \in \mathcal{S}$ such that $\mu(X \backslash E)<\delta$ and $f_{1}, f_{2}, \ldots$ converges uniformly to $f$ on $E$. Now 3.32 and 3.33 imply that $$ \left|\int f_{k} d \mu-\int f d \mu\right|<\frac{\varepsilon}{2}+\left|\int_{E}\left(f_{k}-f\right) d \mu\right| $$ Because $f_{1}, f_{2}, \ldots$ converges uniformly to $f$ on $E$ and $\mu(E)<\infty$, the last term on the right is less than $\frac{\varepsilon}{2}$ for all sufficiently large $k$. Thus $\lim _{k \rightarrow \infty} \int f_{k} d \mu=\int f d \mu$, completing the proof of case 1. Case 2: Suppose $\mu(X)=\infty$. Let $\varepsilon>0$. By 3.29, there exists $E \in \mathcal{S}$ such that $\mu(E)<\infty$ and $$ \int_{X \backslash E} g d \mu<\frac{\varepsilon}{4} $$ The inequality above and 3.32 imply that $$ \left|\int f_{k} d \mu-\int f d \mu\right|<\frac{\varepsilon}{2}+\left|\int_{E} f_{k} d \mu-\int_{E} f d \mu\right| . $$ By case 1 as applied to the sequence $\left.f_{1}\right|_{E},\left.f_{2}\right|_{E}, \ldots$, the last term on the right is less than $\frac{\varepsilon}{2}$ for all sufficiently large $k$. Thus $\lim _{k \rightarrow \infty} \int f_{k} d \mu=\int f d \mu$, completing the proof of case 2 . ## Riemann Integrals and Lebesgue Integrals We can now use the tools we have developed to characterize the Riemann integrable functions. In the theorem below, the left side of the last equation denotes the Riemann integral. ### 3.34 Riemann integrable $\Longleftrightarrow$ continuous almost everywhere Suppose $a0$, there exists a simple function $g \in \mathcal{L}^{1}(\mu)$ such that $$ \|f-g\|_{1}<\varepsilon $$ Proof Suppose $\varepsilon>0$. Then there exist simple functions $g_{1}, g_{2} \in \mathcal{L}^{1}(\mu)$ such that $0 \leq g_{1} \leq f^{+}$and $0 \leq g_{2} \leq f^{-}$and $$ \int\left(f^{+}-g_{1}\right) d \mu<\frac{\varepsilon}{2} \quad \text { and } \quad \int\left(f^{-}-g_{2}\right) d \mu<\frac{\varepsilon}{2} \text {, } $$ where we have used 3.9 to provide the existence of $g_{1}, g_{2}$ with these properties. Let $g=g_{1}-g_{2}$. Then $g$ is a simple function in $\mathcal{L}^{1}(\mu)$ and $$ \begin{aligned} \|f-g\|_{1} & =\left\|\left(f^{+}-g_{1}\right)-\left(f^{-}-g_{2}\right)\right\|_{1} \\ & =\int\left(f^{+}-g_{1}\right) d \mu+\int\left(f^{-}-g_{2}\right) d \mu \\ & <\varepsilon, \end{aligned} $$ as desired. Definition $\quad \mathcal{L}^{1}(\mathbf{R}) ;\|f\|_{1}$ - The notation $\mathcal{L}^{1}(\mathbf{R})$ denotes $\mathcal{L}^{1}(\lambda)$, where $\lambda$ is Lebesgue measure on either the Borel subsets of $\mathbf{R}$ or the Lebesgue measurable subsets of $\mathbf{R}$. - When working with $\mathcal{L}^{1}(\mathbf{R})$, the notation $\|f\|_{1}$ denotes the integral of the absolute value of $f$ with respect to Lebesgue measure on $\mathbf{R}$. ### 3.46 Definition step function A step function is a function $g: \mathbf{R} \rightarrow \mathbf{R}$ of the form $$ g=a_{1} \chi_{I_{1}}+\cdots+a_{n} \chi_{I_{n}} $$ where $I_{1}, \ldots, I_{n}$ are intervals of $\mathbf{R}$ and $a_{1}, \ldots, a_{n}$ are nonzero real numbers. Suppose $g$ is a step function of the form above and the intervals $I_{1}, \ldots, I_{n}$ are disjoint. Then $$ \|g\|_{1}=\left|a_{1}\right|\left|I_{1}\right|+\cdots+\left|a_{n}\right|\left|I_{n}\right| . $$ In particular, $g \in \mathcal{L}^{1}(\mathbf{R})$ if and only if all the intervals $I_{1}, \ldots, I_{n}$ are bounded. The intervals in the definition of a step function can be open intervals, closed intervals, or half-open intervals. We will be using step functions in integrals, where the inclusion or exclusion of the endpoints of the intervals does not matter. Even though the coefficients $a_{1}, \ldots, a_{n}$ in the definition of a step function are required to be nonzero, the function 0 that is identically 0 on $\mathbf{R}$ is a step function. To see this, take $n=1, a_{1}=1$, and $I_{1}=\varnothing$. ### 3.47 approximation by step functions Suppose $f \in \mathcal{L}^{1}(\mathbf{R})$. Then for every $\varepsilon>0$, there exists a step function $g \in \mathcal{L}^{1}(\mathbf{R})$ such that $$ \|f-g\|_{1}<\varepsilon $$ Proof Suppose $\varepsilon>0$. By 3.44, there exist Borel (or Lebesgue) measurable subsets $A_{1}, \ldots, A_{n}$ of $\mathbf{R}$ and nonzero numbers $a_{1}, \ldots, a_{n}$ such that $\left|A_{k}\right|<\infty$ for all $k \in$ $\{1, \ldots, n\}$ and $$ \left\|f-\sum_{k=1}^{n} a_{k} \chi_{A_{k}}\right\|_{1}<\frac{\varepsilon}{2} $$ For each $k \in\{1, \ldots, n\}$, there is an open subset $G_{k}$ of $\mathbf{R}$ that contains $A_{k}$ and whose Lebesgue measure is as close as we want to $\left|A_{k}\right|$ [by part (e) of 2.71]. Each open subset of $\mathbf{R}$, including each $G_{k}$, is a countable union of disjoint open intervals. Thus for each $k$, there is a set $E_{k}$ that is a finite union of bounded open intervals contained in $G_{k}$ whose Lebesgue measure is as close as we want to $\left|G_{k}\right|$. Hence for each $k$, there is a set $E_{k}$ that is a finite union of bounded intervals such that $$ \begin{aligned} \left|E_{k} \backslash A_{k}\right|+\left|A_{k} \backslash E_{k}\right| & \leq\left|G_{k} \backslash A_{k}\right|+\left|G_{k} \backslash E_{k}\right| \\ & <\frac{\varepsilon}{2\left|a_{k}\right| n} ; \end{aligned} $$ in other words, $$ \left\|\chi_{A_{k}}-\chi_{E_{k}}\right\|_{1}<\frac{\varepsilon}{2\left|a_{k}\right| n} $$ Now $$ \begin{aligned} \left\|f-\sum_{k=1}^{n} a_{k} \chi_{E_{k}}\right\|_{1} & \leq\left\|f-\sum_{k=1}^{n} a_{k} \chi_{A_{k}}\right\|_{1}+\left\|\sum_{k=1}^{n} a_{k} \chi_{A_{k}}-\sum_{k=1}^{n} a_{k} \chi_{E_{k}}\right\|_{1} \\ & <\frac{\varepsilon}{2}+\sum_{k=1}^{n}\left|a_{k}\right|\left\|\chi_{A_{k}}-\chi_{E_{k}}\right\|_{1} \\ & <\varepsilon . \end{aligned} $$ Each $E_{k}$ is a finite union of bounded intervals. Thus the inequality above completes the proof because $\sum_{k=1}^{n} a_{k} \chi_{E_{k}}$ is a step function. Luzin's Theorem (2.91 and 2.93) gives a spectacular way to approximate a Borel measurable function by a continuous function. However, the following approximation theorem is usually more useful than Luzin's Theorem. For example, the next result plays a major role in the proof of the Lebesgue Differentiation Theorem (4.10). ### 3.48 approximation by continuous functions Suppose $f \in \mathcal{L}^{1}(\mathbf{R})$. Then for every $\varepsilon>0$, there exists a continuous function $g: \mathbf{R} \rightarrow \mathbf{R}$ such that $$ \|f-g\|_{1}<\varepsilon $$ and $\{x \in \mathbf{R}: g(x) \neq 0\}$ is a bounded set. Proof For every $a_{1}, \ldots, a_{n}, b_{1}, \ldots, b_{n}, c_{1}, \ldots, c_{n} \in \mathbf{R}$ and $g_{1}, \ldots, g_{n} \in \mathcal{L}^{1}(\mathbf{R})$, we have $$ \begin{aligned} \left\|f-\sum_{k=1}^{n} a_{k} g_{k}\right\|_{1} & \leq\left\|f-\sum_{k=1}^{n} a_{k} \chi_{\left[b_{k}, c_{k}\right]}\right\|_{1}+\left\|\sum_{k=1}^{n} a_{k}\left(\chi_{\left[b_{k}, c_{k}\right]}-g_{k}\right)\right\|_{1} \\ & \leq\left\|f-\sum_{k=1}^{n} a_{k} \chi_{\left[b_{k}, c_{k}\right]}\right\|_{1}+\sum_{k=1}^{n}\left|a_{k}\right|\left\|\chi_{\left[b_{k}, c_{k}\right]}-g_{k}\right\|_{1}, \end{aligned} $$ where the inequalities above follow from 3.43. By 3.47, we can choose $a_{1}, \ldots, a_{n}, b_{1}, \ldots, b_{n}, c_{1}, \ldots, c_{n} \in \mathbf{R}$ to make $\left\|f-\sum_{k=1}^{n} a_{k} \chi_{\left[b_{k}, c_{k}\right]}\right\|_{1}$ as small as we wish. The figure here then shows that there exist continuous functions $g_{1}, \ldots, g_{n} \in \mathcal{L}^{1}(\mathbf{R})$ that make $\sum_{k=1}^{n}\left|a_{k}\right|\left\|\chi_{\left[b_{k}, c_{k}\right]}-g_{k}\right\|_{1}$ as small as we wish. Now take $g=\sum_{k=1}^{n} a_{k} g_{k}$. ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-113.jpg?height=310&width=539&top_left_y=1724&top_left_x=688) The graph of a continuous function $g_{k}$ such that $\left\|\chi_{\left[b_{k}, c_{k}\right]}-g_{k}\right\|_{1}$ is small. ## EXERCISES 3B 1 Give an example of a sequence $f_{1}, f_{2}, \ldots$ of functions from $\mathbf{Z}^{+}$to $[0, \infty)$ such that $$ \lim _{k \rightarrow \infty} f_{k}(m)=0 $$ for every $m \in \mathbf{Z}^{+}$but $\lim _{k \rightarrow \infty} \int f_{k} d \mu=1$, where $\mu$ is counting measure on $\mathbf{Z}^{+}$. 2 Give an example of a sequence $f_{1}, f_{2}, \ldots$ of continuous functions from $\mathbf{R}$ to $[0,1]$ such that $$ \lim _{k \rightarrow \infty} f_{k}(x)=0 $$ for every $x \in \mathbf{R}$ but $\lim _{k \rightarrow \infty} \int f_{k} d \lambda=\infty$, where $\lambda$ is Lebesgue measure on $\mathbf{R}$. 3 Suppose $\lambda$ is Lebesgue measure on $\mathbf{R}$ and $f: \mathbf{R} \rightarrow \mathbf{R}$ is a Borel measurable function such that $\int|f| d \lambda<\infty$. Define $g: \mathbf{R} \rightarrow \mathbf{R}$ by $$ g(x)=\int_{(-\infty, x)} f d \lambda $$ Prove that $g$ is uniformly continuous on $\mathbf{R}$. 4 (a) Suppose $(X, \mathcal{S}, \mu)$ is a measure space with $\mu(X)<\infty$. Suppose that $f: X \rightarrow[0, \infty)$ is a bounded $\mathcal{S}$-measurable function. Prove that $$ \int f d \mu=\inf \left\{\sum_{j=1}^{m} \mu\left(A_{j}\right) \sup _{A_{j}} f: A_{1}, \ldots, A_{m} \text { is an } \mathcal{S} \text {-partition of } X\right\} $$ (b) Show that the conclusion of part (a) can fail if the hypothesis that $f$ is bounded is replaced by the hypothesis that $\int f d \mu<\infty$. (c) Show that the conclusion of part (a) can fail if the condition that $\mu(X)<\infty$ is deleted. [Part (a) of this exercise shows that if we had defined an upper Lebesgue sum, then we could have used it to define the integral. However, parts (b) and (c) show that the hypotheses that $f$ is bounded and that $\mu(X)<\infty$ would be needed if defining the integral via the equation above. The definition of the integral via the lower Lebesgue sum does not require these hypotheses, showing the advantage of using the approach via the lower Lebesgue sum.] 5 Let $\lambda$ denote Lebesgue measure on $\mathbf{R}$. Suppose $f: \mathbf{R} \rightarrow \mathbf{R}$ is a Borel measurable function such that $\int|f| d \lambda<\infty$. Prove that $$ \lim _{k \rightarrow \infty} \int_{[-k, k]} f d \lambda=\int f d \lambda . $$ 6 Let $\lambda$ denote Lebesgue measure on $\mathbf{R}$. Give an example of a continuous function $f:[0, \infty) \rightarrow \mathbf{R}$ such that $\lim _{t \rightarrow \infty} \int_{[0, t]} f d \lambda$ exists (in $\mathbf{R}$ ) but $\int_{[0, \infty)} f d \lambda$ is not defined. 7 Let $\lambda$ denote Lebesgue measure on $\mathbf{R}$. Give an example of a continuous function $f:(0,1) \rightarrow \mathbf{R}$ such that $\lim _{n \rightarrow \infty} \int_{\left(\frac{1}{n}, 1\right)} f d \lambda$ exists (in $\mathbf{R}$ ) but $\int_{(0,1)} f d \lambda$ is not defined. 8 Verify the assertion in 3.38. 9 Verify the assertion in Example 3.41. 10 (a) Suppose $(X, \mathcal{S}, \mu)$ is a measure space such that $\mu(X)<\infty$. Suppose $p, r$ are positive numbers with $p0$, define $f_{t}: \mathbf{R} \rightarrow \mathbf{R}$ by $f_{t}(x)=f(t x)$. Prove that $\lim _{t \rightarrow 1}\left\|f-f_{t}\right\|_{1}=0$. ## Chapter 4 ## Differentiation Does there exist a Lebesgue measurable set that fills up exactly half of each interval? To get a feeling for this question, consider the set $E=\left[0, \frac{1}{8}\right] \cup\left[\frac{1}{4}, \frac{3}{8}\right] \cup\left[\frac{1}{2}, \frac{5}{8}\right] \cup\left[\frac{3}{4}, \frac{7}{8}\right]$. This set $E$ has the property that $$ |E \cap[0, b]|=\frac{b}{2} $$ for $b=0, \frac{1}{4}, \frac{1}{2}, \frac{3}{4}, 1$. Does there exist a Lebesgue measurable set $E \subset[0,1]$, perhaps constructed in a fashion similar to the Cantor set, such that the equation above holds for all $b \in[0,1]$ ? In this chapter we see how to answer this question by considering differentiation issues. We begin by developing a powerful tool called the Hardy-Littlewood maximal inequality. This tool is used to prove an almost everywhere version of the Fundamental Theorem of Calculus. These results lead us to an important theorem about the density of Lebesgue measurable sets. ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-116.jpg?height=561&width=1180&top_left_y=1124&top_left_x=61) Trinity College at the University of Cambridge in England. G. H. Hardy (1877-1947) and John Littlewood (1885-1977) were students and later faculty members here. If you have not already done so, you should read Hardy's remarkable book A Mathematician's Apology (do not skip the fascinating Foreword by C. $P$. Snow) and see the movie The Man Who Knew Infinity, which focuses on Hardy, Littlewood, and Srinivasa Ramanujan (1887-1920). CC-BY-SA Rafa Esteve ## 4A Hardy-Littlewood Maximal Function ## Markov's Inequality The following result, called Markov's inequality, has a sweet, short proof. We will make good use of this result later in this chapter (see the proof of 4.10). Markov's inequality also leads to Chebyshev's inequality (see Exercise 2 in this section). ### 4.1 Markov's inequality Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $h \in \mathcal{L}^{1}(\mu)$. Then $$ \mu(\{x \in X:|h(x)| \geq c\}) \leq \frac{1}{c}\|h\|_{1} $$ for every $c>0$. Proof Suppose $c>0$. Then $$ \begin{aligned} \mu(\{x \in X:|h(x)| \geq c\}) & =\frac{1}{c} \int_{\{x \in X:|h(x)| \geq c\}} c d \mu \\ & \leq \frac{1}{c} \int_{\{x \in X:|h(x)| \geq c\}}|h| d \mu \\ & \leq \frac{1}{c}\|h\|_{1}, \end{aligned} $$ as desired. ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-117.jpg?height=673&width=1158&top_left_y=1281&top_left_x=68) St. Petersburg University along the Neva River in St. Petersburg, Russia. Andrei Markov (1856-1922) was a student and then a faculty member here. CC-BY-SA A. Savin ## Vitali Covering Lemma ### 4.2 Definition 3 times a bounded nonempty open interval Suppose $I$ is a bounded nonempty open interval of $\mathbf{R}$. Then $3 * I$ denotes the open interval with the same center as $I$ and three times the length of $I$. ### 4.3 Example 3 times an interval If $I=(0,10)$, then $3 * I=(-10,20)$. The next result is a key tool in the proof of the Hardy-Littlewood maximal inequality (4.8). ### 4.4 Vitali Covering Lemma Suppose $I_{1}, \ldots, I_{n}$ is a list of bounded nonempty open intervals of $\mathbf{R}$. Then there exists a disjoint sublist $I_{k_{1}}, \ldots, I_{k_{m}}$ such that $$ I_{1} \cup \cdots \cup I_{n} \subset\left(3 * I_{k_{1}}\right) \cup \cdots \cup\left(3 * I_{k_{m}}\right) . $$ ### 4.5 Example Vitali Covering Lemma Suppose $n=4$ and $$ I_{1}=(0,10), \quad I_{2}=(9,15), \quad I_{3}=(14,22), \quad I_{4}=(21,31) . $$ Then $$ 3 * I_{1}=(-10,20), \quad 3 * I_{2}=(3,21), \quad 3 * I_{3}=(6,30), \quad 3 * I_{4}=(11,41) . $$ Thus $$ I_{1} \cup I_{2} \cup I_{3} \cup I_{4} \subset\left(3 * I_{1}\right) \cup\left(3 * I_{4}\right) $$ In this example, $I_{1}, I_{4}$ is the only sublist of $I_{1}, I_{2}, I_{3}, I_{4}$ that produces the conclusion of the Vitali Covering Lemma. Proof of 4.4 Let $k_{1}$ be such that $$ \left|I_{k_{1}}\right|=\max \left\{\left|I_{1}\right|, \ldots,\left|I_{n}\right|\right\} $$ Suppose $k_{1}, \ldots, k_{j}$ have been chosen. Let $k_{j+1}$ be such that $\left|I_{k_{j+1}}\right|$ is as large as possible subject to the condition that $I_{k_{1}}, \ldots, I_{k_{j+1}}$ are disjoint. If there is no choice of $k_{j+1}$ such that $I_{k_{1}}, \ldots, I_{k_{j+1}}$ are disjoint, then the procedure terminates. The technique used here is called a greedy algorithm because at each stage we select the largest remaining interval that is disjoint from the previously selected intervals. Because we start with a finite list, the procedure must eventually terminate after some number $m$ of choices. Suppose $j \in\{1, \ldots, n\}$. To complete the proof, we must show that $$ I_{j} \subset\left(3 * I_{k_{1}}\right) \cup \cdots \cup\left(3 * I_{k_{m}}\right) $$ If $j \in\left\{k_{1}, \ldots, k_{m}\right\}$, then the inclusion above obviously holds. Thus assume that $j \notin\left\{k_{1}, \ldots, k_{m}\right\}$. Because the process terminated without selecting $j$, the interval $I_{j}$ is not disjoint from all of $I_{k_{1}}, \ldots, I_{k_{m}}$. Let $I_{k_{L}}$ be the first interval on this list not disjoint from $I_{j}$; thus $I_{j}$ is disjoint from $I_{k_{1}}, \ldots, I_{k_{L-1}}$. Because $j$ was not chosen in step $L$, we conclude that $\left|I_{k_{L}}\right| \geq\left|I_{j}\right|$. Because $I_{k_{L}} \cap I_{j} \neq \varnothing$, this last inequality implies (easy exercise) that $I_{j} \subset 3 * I_{k_{L}}$, completing the proof. ## Hardy-Littlewood Maximal Inequality Now we come to a brilliant definition that turns out to be extraordinarily useful. ### 4.6 Definition Hardy-Littlewood maximal function; $h^{*}$ Suppose $h: \mathbf{R} \rightarrow \mathbf{R}$ is a Lebesgue measurable function. Then the HardyLittlewood maximal function of $h$ is the function $h^{*}: \mathbf{R} \rightarrow[0, \infty]$ defined by $$ h^{*}(b)=\sup _{t>0} \frac{1}{2 t} \int_{b-t}^{b+t}|h| $$ In other words, $h^{*}(b)$ is the supremum over all bounded intervals centered at $b$ of the average of $|h|$ on those intervals. ### 4.7 Example Hardy-Littlewood maximal function of $\chi_{[0,1]}$ As usual, let $\chi_{[0,1]}$ denote the characteristic function of the interval $[0,1]$. Then $$ \left(\chi_{[0,1]}\right)^{*}(b)= \begin{cases}\frac{1}{2(1-b)} & \text { if } b \leq 0 \\ 1 & \text { if } 0c\right\}$ is an open subset of $\mathbf{R}$, as you are asked to prove in Exercise 9 in this section. Thus $h^{*}$ is a Borel measurable function. Suppose $h \in \mathcal{L}^{1}(\mathbf{R})$ and $c>0$. Markov's inequality (4.1) estimates the size of the set on which $|h|$ is larger than $c$. Our next result estimates the size of the set on which $h^{*}$ is larger than $c$. The Hardy-Littlewood maximal inequality proved in the next result is a key ingredient in the proof of the Lebesgue Differentiation Theorem (4.10). Note that this next result is considerably deeper than Markov's inequality. ### 4.8 Hardy-Littlewood maximal inequality Suppose $h \in \mathcal{L}^{1}(\mathbf{R})$. Then $$ \left|\left\{b \in \mathbf{R}: h^{*}(b)>c\right\}\right| \leq \frac{3}{c}\|h\|_{1} $$ for every $c>0$. Proof Suppose $F$ is a closed bounded subset of $\left\{b \in \mathbf{R}: h^{*}(b)>c\right\}$. We will show that $|F| \leq \frac{3}{c} \int_{-\infty}^{\infty}|h|$, which implies our desired result [see Exercise 24(a) in Section 2D]. For each $b \in F$, there exists $t_{b}>0$ such that $$ \frac{1}{2 t_{b}} \int_{b-t_{b}}^{b+t_{b}}|h|>c $$ Clearly $$ F \subset \bigcup_{b \in F}\left(b-t_{b}, b+t_{b}\right) . $$ The Heine-Borel Theorem (2.12) tells us that this open cover of a closed bounded set has a finite subcover. In other words, there exist $b_{1}, \ldots, b_{n} \in F$ such that $$ F \subset\left(b_{1}-t_{b_{1}}, b_{1}+t_{b_{1}}\right) \cup \cdots \cup\left(b_{n}-t_{b_{n}}, b_{n}+t_{b_{n}}\right) . $$ To make the notation cleaner, relabel the open intervals above as $I_{1}, \ldots, I_{n}$. Now apply the Vitali Covering Lemma (4.4) to the list $I_{1}, \ldots, I_{n}$, producing a disjoint sublist $I_{k_{1}}, \ldots, I_{k_{m}}$ such that $$ I_{1} \cup \cdots \cup I_{n} \subset\left(3 * I_{k_{1}}\right) \cup \cdots \cup\left(3 * I_{k_{m}}\right) . $$ Thus $$ \begin{aligned} |F| & \leq\left|I_{1} \cup \cdots \cup I_{n}\right| \\ & \leq\left|\left(3 * I_{k_{1}}\right) \cup \cdots \cup\left(3 * I_{k_{m}}\right)\right| \\ & \leq\left|3 * I_{k_{1}}\right|+\cdots+\left|3 * I_{k_{m}}\right| \\ & =3\left(\left|I_{k_{1}}\right|+\cdots+\left|I_{k_{m}}\right|\right) \\ & <\frac{3}{c}\left(\int_{I_{k_{1}}}|h|+\cdots+\int_{I_{k_{m}}}|h|\right) \\ & \leq \frac{3}{c} \int_{-\infty}^{\infty}|h|, \end{aligned} $$ where the second-to-last inequality above comes from 4.9 (note that $\left|I_{k_{j}}\right|=2 t_{b}$ for the choice of $b$ corresponding to $I_{k_{j}}$ ) and the last inequality holds because $I_{k_{1}}, \ldots, I_{k_{m}}$ are disjoint. The last inequality completes the proof. ## EXERCISES 4A 1 Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $h: X \rightarrow \mathbf{R}$ is an $\mathcal{S}$-measurable function. Prove that $$ \mu(\{x \in X:|h(x)| \geq c\}) \leq \frac{1}{c^{p}} \int|h|^{p} d \mu $$ for all positive numbers $c$ and $p$. 2 Suppose $(X, \mathcal{S}, \mu)$ is a measure space with $\mu(X)=1$ and $h \in \mathcal{L}^{1}(\mu)$. Prove that $$ \mu\left(\left\{x \in X:\left|h(x)-\int h d \mu\right| \geq c\right\}\right) \leq \frac{1}{c^{2}}\left(\int h^{2} d \mu-\left(\int h d \mu\right)^{2}\right) $$ for all $c>0$. [The result above is called Chebyshev's inequality; it plays an important role in probability theory. Pafnuty Chebyshev (1821-1894) was Markov's thesis advisor.] 3 Suppose $(X, \mathcal{S}, \mu)$ is a measure space. Suppose $h \in \mathcal{L}^{1}(\mu)$ and $\|h\|_{1}>0$. Prove that there is at most one number $c \in(0, \infty)$ such that $$ \mu(\{x \in X:|h(x)| \geq c\})=\frac{1}{c}\|h\|_{1} . $$ 4 Show that the constant 3 in the Vitali Covering Lemma (4.4) cannot be replaced by a smaller positive constant. 5 Prove the assertion left as an exercise in the last sentence of the proof of the Vitali Covering Lemma (4.4). 6 Verify the formula in Example 4.7 for the Hardy-Littlewood maximal function of $\chi_{[0,1]}$. 7 Find a formula for the Hardy-Littlewood maximal function of the characteristic function of $[0,1] \cup[2,3]$. 8 Find a formula for the Hardy-Littlewood maximal function of the function $h: \mathbf{R} \rightarrow[0, \infty)$ defined by $$ h(x)= \begin{cases}x & \text { if } 0 \leq x \leq 1 \\ 0 & \text { otherwise }\end{cases} $$ 9 Suppose $h: \mathbf{R} \rightarrow \mathbf{R}$ is Lebesgue measurable. Prove that $$ \left\{b \in \mathbf{R}: h^{*}(b)>c\right\} $$ is an open subset of $\mathbf{R}$ for every $c \in \mathbf{R}$. 10 Prove or give a counterexample: If $h: \mathbf{R} \rightarrow[0, \infty)$ is an increasing function, then $h^{*}$ is an increasing function. 11 Give an example of a Borel measurable function $h: \mathbf{R} \rightarrow[0, \infty)$ such that $h^{*}(b)<\infty$ for all $b \in \mathbf{R}$ but $\sup \left\{h^{*}(b): b \in \mathbf{R}\right\}=\infty$. 12 Show that $\left|\left\{b \in \mathbf{R}: h^{*}(b)=\infty\right\}\right|=0$ for every $h \in \mathcal{L}^{1}(\mathbf{R})$. 13 Show that there exists $h \in \mathcal{L}^{1}(\mathbf{R})$ such that $h^{*}(b)=\infty$ for every $b \in \mathbf{Q}$. 14 Suppose $h \in \mathcal{L}^{1}(\mathbf{R})$. Prove that $$ \left|\left\{b \in \mathbf{R}: h^{*}(b) \geq c\right\}\right| \leq \frac{3}{c}\|h\|_{1} $$ for every $c>0$. [This result slightly strengthens the Hardy-Littlewood maximal inequality (4.8) because the set on the left side above includes those $b \in \mathbf{R}$ such that $h^{*}(b)=c$. A much deeper strengthening comes from replacing the constant 3 in the HardyLittlewood maximal inequality with a smaller constant. In 2003, Antonios Melas answered what had been an open question about the best constant. He proved that the smallest constant that can replace 3 in the Hardy-Littlewood maximal inequality is $(11+\sqrt{61}) / 12 \approx 1.56752$; see Annals of Mathematics 157 (2003), 647-688.] ## 4B Derivatives of Integrals ## Lebesgue Differentiation Theorem The next result states that the average amount by which a function in $\mathcal{L}^{1}(\mathbf{R})$ differs from its values is small almost everywhere on small intervals. The 2 in the denominator of the fraction in the result below could be deleted, but its presence makes the length of the interval of integration nicely match the denominator $2 t$. The next result is called the Lebesgue Differentiation Theorem, even though no derivative is in sight. However, we will soon see how another version of this result deals with derivatives. The hard work takes place in the proof of this first version. ### 4.10 Lebesgue Differentiation Theorem, first version Suppose $f \in \mathcal{L}^{1}(\mathbf{R})$. Then $$ \lim _{t \downarrow 0} \frac{1}{2 t} \int_{b-t}^{b+t}|f-f(b)|=0 $$ for almost every $b \in \mathbf{R}$. Before getting to the formal proof of this first version of the Lebesgue Differentiation Theorem, we pause to provide some motivation for the proof. If $b \in \mathbf{R}$ and $t>0$, then 3.25 gives the easy estimate $$ \frac{1}{2 t} \int_{b-t}^{b+t}|f-f(b)| \leq \sup \{|f(x)-f(b)|:|x-b| \leq t\} $$ If $f$ is continuous at $b$, then the right side of this inequality has limit 0 as $t \downarrow 0$, proving 4.10 in the special case in which $f$ is continuous on $\mathbf{R}$. To prove the Lebesgue Differentiation Theorem, we will approximate an arbitrary function in $\mathcal{L}^{1}(\mathbf{R})$ by a continuous function (using 3.48). The previous paragraph shows that the continuous function has the desired behavior. We will use the HardyLittlewood maximal inequality (4.8) to show that the approximation produces approximately the desired behavior. Now we are ready for the formal details of the proof. Proof of 4.10 Let $\delta>0$. By 3.48, for each $k \in \mathbf{Z}^{+}$there exists a continuous function $h_{k}: \mathbf{R} \rightarrow \mathbf{R}$ such that 4.11 $$ \left\|f-h_{k}\right\|_{1}<\frac{\delta}{k 2^{k}} . $$ Let $$ B_{k}=\left\{b \in \mathbf{R}:\left|f(b)-h_{k}(b)\right| \leq \frac{1}{k} \text { and }\left(f-h_{k}\right)^{*}(b) \leq \frac{1}{k}\right\} \text {. } $$ Then $4.12 \mathbf{R} \backslash B_{k}=\left\{b \in \mathbf{R}:\left|f(b)-h_{k}(b)\right|>\frac{1}{k}\right\} \cup\left\{b \in \mathbf{R}:\left(f-h_{k}\right)^{*}(b)>\frac{1}{k}\right\}$. Markov's inequality (4.1) as applied to the function $f-h_{k}$ and 4.11 imply that 4.13 $$ \left|\left\{b \in \mathbf{R}:\left|f(b)-h_{k}(b)\right|>\frac{1}{k}\right\}\right|<\frac{\delta}{2^{k}} $$ The Hardy-Littlewood maximal inequality (4.8) as applied to the function $f-h_{k}$ and 4.11 imply that 4.14 $$ \left|\left\{b \in \mathbf{R}:\left(f-h_{k}\right)^{*}(b)>\frac{1}{k}\right\}\right|<\frac{3 \delta}{2^{k}} . $$ Now 4.12, 4.13, and 4.14 imply that $$ \left|\mathbf{R} \backslash B_{k}\right|<\frac{\delta}{2^{k-2}} $$ Let $$ B=\bigcap_{k=1}^{\infty} B_{k} $$ Then 4.15 $$ |\mathbf{R} \backslash B|=\left|\bigcup_{k=1}^{\infty}\left(\mathbf{R} \backslash B_{k}\right)\right| \leq \sum_{k=1}^{\infty}\left|\mathbf{R} \backslash B_{k}\right|<\sum_{k=1}^{\infty} \frac{\delta}{2^{k-2}}=4 \delta $$ Suppose $b \in B$ and $t>0$. Then for each $k \in \mathbf{Z}^{+}$we have $$ \begin{aligned} \frac{1}{2 t} \int_{b-t}^{b+t}|f-f(b)| & \leq \frac{1}{2 t} \int_{b-t}^{b+t}\left(\left|f-h_{k}\right|+\left|h_{k}-h_{k}(b)\right|+\left|h_{k}(b)-f(b)\right|\right) \\ & \leq\left(f-h_{k}\right)^{*}(b)+\left(\frac{1}{2 t} \int_{b-t}^{b+t}\left|h_{k}-h_{k}(b)\right|\right)+\left|h_{k}(b)-f(b)\right| \\ & \leq \frac{2}{k}+\frac{1}{2 t} \int_{b-t}^{b+t}\left|h_{k}-h_{k}(b)\right| . \end{aligned} $$ Because $h_{k}$ is continuous, the last term is less than $\frac{1}{k}$ for all $t>0$ sufficiently close to 0 (how close is sufficiently close depends upon $k$ ). In other words, for each $k \in \mathbf{Z}^{+}$, we have $$ \frac{1}{2 t} \int_{b-t}^{b+t}|f-f(b)|<\frac{3}{k} $$ for all $t>0$ sufficiently close to 0 . Hence we conclude that $$ \lim _{t \downarrow 0} \frac{1}{2 t} \int_{b-t}^{b+t}|f-f(b)|=0 $$ for all $b \in B$. Let $A$ denote the set of numbers $a \in \mathbf{R}$ such that $$ \lim _{t \downarrow 0} \frac{1}{2 t} \int_{a-t}^{a+t}|f-f(a)| $$ either does not exist or is nonzero. We have shown that $A \subset(\mathbf{R} \backslash B)$. Thus $$ |A| \leq|\mathbf{R} \backslash B|<4 \delta $$ where the last inequality comes from 4.15 . Because $\delta$ is an arbitrary positive number, the last inequality implies that $|A|=0$, completing the proof. ## Derivatives You should remember the following definition from your calculus course. ### 4.16 Definition derivative; $g^{\prime}$; differentiable Suppose $g: I \rightarrow \mathbf{R}$ is a function defined on an open interval $I$ of $\mathbf{R}$ and $b \in I$. The derivative of $g$ at $b$, denoted $g^{\prime}(b)$, is defined by $$ g^{\prime}(b)=\lim _{t \rightarrow 0} \frac{g(b+t)-g(b)}{t} $$ if the limit above exists, in which case $g$ is called differentiable at $b$. We now turn to the Fundamental Theorem of Calculus and a powerful extension that avoids continuity. These results show that differentiation and integration can be thought of as inverse operations. You saw the next result in your calculus class, except now the function $f$ is only required to be Lebesgue measurable (and its absolute value must have a finite Lebesgue integral). Of course, we also need to require $f$ to be continuous at the crucial point $b$ in the next result, because changing the value of $f$ at a single number would not change the function $g$. ### 4.17 Fundamental Theorem of Calculus Suppose $f \in \mathcal{L}^{1}(\mathbf{R})$. Define $g: \mathbf{R} \rightarrow \mathbf{R}$ by $$ g(x)=\int_{-\infty}^{x} f $$ Suppose $b \in \mathbf{R}$ and $f$ is continuous at $b$. Then $g$ is differentiable at $b$ and $$ g^{\prime}(b)=f(b) $$ Proof If $t \neq 0$, then $$ \begin{aligned} \left|\frac{g(b+t)-g(b)}{t}-f(b)\right| & =\left|\frac{\int_{-\infty}^{b+t} f-\int_{-\infty}^{b} f}{t}-f(b)\right| \\ & =\left|\frac{\int_{b}^{b+t} f}{t}-f(b)\right| \\ & =\left|\frac{\int_{b}^{b+t}(f-f(b))}{t}\right| \\ & \leq \sup _{\{x \in \mathbf{R}:|x-b|<|t|\}}|f(x)-f(b)| . \end{aligned} $$ If $\varepsilon>0$, then by the continuity of $f$ at $b$, the last quantity is less than $\varepsilon$ for $t$ sufficiently close to 0 . Thus $g$ is differentiable at $b$ and $g^{\prime}(b)=f(b)$. A function in $\mathcal{L}^{1}(\mathbf{R})$ need not be continuous anywhere. Thus the Fundamental Theorem of Calculus (4.17) might provide no information about differentiating the integral of such a function. However, our next result states that all is well almost everywhere, even in the absence of any continuity of the function being integrated. ### 4.19 Lebesgue Differentiation Theorem, second version Suppose $f \in \mathcal{L}^{1}(\mathbf{R})$. Define $g: \mathbf{R} \rightarrow \mathbf{R}$ by $$ g(x)=\int_{-\infty}^{x} f $$ Then $g^{\prime}(b)=f(b)$ for almost every $b \in \mathbf{R}$. Proof Suppose $t \neq 0$. Then from 4.18 we have $$ \begin{aligned} \left|\frac{g(b+t)-g(b)}{t}-f(b)\right| & =\left|\frac{\int_{b}^{b+t}(f-f(b))}{t}\right| \\ & \leq \frac{1}{t} \int_{b}^{b+t}|f-f(b)| \\ & \leq \frac{1}{t} \int_{b-t}^{b+t}|f-f(b)| \end{aligned} $$ for all $b \in \mathbf{R}$. By the first version of the Lebesgue Differentiation Theorem (4.10), the last quantity has limit 0 as $t \rightarrow 0$ for almost every $b \in \mathbf{R}$. Thus $g^{\prime}(b)=f(b)$ for almost every $b \in \mathbf{R}$. Now we can answer the question raised on the opening page of this chapter. ### 4.20 no set constitutes exactly half of each interval There does not exist a Lebesgue measurable set $E \subset[0,1]$ such that $$ |E \cap[0, b]|=\frac{b}{2} $$ for all $b \in[0,1]$ Proof Suppose there does exist a Lebesgue measurable set $E \subset[0,1]$ with the property above. Define $g: \mathbf{R} \rightarrow \mathbf{R}$ by $$ g(b)=\int_{-\infty}^{b} \chi_{E} . $$ Thus $g(b)=\frac{b}{2}$ for all $b \in[0,1]$. Hence $g^{\prime}(b)=\frac{1}{2}$ for all $b \in(0,1)$. The Lebesgue Differentiation Theorem (4.19) implies that $g^{\prime}(b)=\chi_{E}(b)$ for almost every $b \in \mathbf{R}$. However, $\chi_{E}$ never takes on the value $\frac{1}{2}$, which contradicts the conclusion of the previous paragraph. This contradiction completes the proof. The next result says that a function in $\mathcal{L}^{1}(\mathbf{R})$ is equal almost everywhere to the limit of its average over small intervals. These two-sided results generalize more naturally to higher dimensions (take the average over balls centered at $b$ ) than the one-sided results. ## $4.21 \mathcal{L}^{1}(\mathbf{R})$ function equals its local average almost everywhere Suppose $f \in \mathcal{L}^{1}(\mathbf{R})$. Then $$ f(b)=\lim _{t \downarrow 0} \frac{1}{2 t} \int_{b-t}^{b+t} f $$ for almost every $b \in \mathbf{R}$. Proof Suppose $t>0$. Then $$ \begin{aligned} \left|\left(\frac{1}{2 t} \int_{b-t}^{b+t} f\right)-f(b)\right| & =\left|\frac{1}{2 t} \int_{b-t}^{b+t}(f-f(b))\right| \\ & \leq \frac{1}{2 t} \int_{b-t}^{b+t}|f-f(b)| . \end{aligned} $$ The desired result now follows from the first version of the Lebesgue Differentiation Theorem (4.10). Again, the conclusion of the result above holds at every number $b$ at which $f$ is continuous. The remarkable part of the result above is that even if $f$ is discontinuous everywhere, the conclusion holds for almost every real number $b$. ## Density The next definition captures the notion of the proportion of a set in small intervals centered at a number $b$. ### 4.22 Definition density Suppose $E \subset \mathbf{R}$. The density of $E$ at a number $b \in \mathbf{R}$ is $$ \lim _{t \downarrow 0} \frac{|E \cap(b-t, b+t)|}{2 t} $$ if this limit exists (otherwise the density of $E$ at $b$ is undefined). 4.23 Example density of an interval The density of $[0,1]$ at $b= \begin{cases}1 & \text { if } b \in(0,1), \\ \frac{1}{2} & \text { if } b=0 \text { or } b=1 \\ 0 & \text { otherwise. }\end{cases}$ The next beautiful result shows the power of the techniques developed in this chapter. ### 4.24 Lebesgue Density Theorem Suppose $E \subset \mathbf{R}$ is a Lebesgue measurable set. Then the density of $E$ is 1 at almost every element of $E$ and is 0 at almost every element of $\mathbf{R} \backslash E$. Proof First suppose $|E|<\infty$. Thus $\chi_{E} \in \mathcal{L}^{1}(\mathbf{R})$. Because $$ \frac{|E \cap(b-t, b+t)|}{2 t}=\frac{1}{2 t} \int_{b-t}^{b+t} \chi_{E} $$ for every $t>0$ and every $b \in \mathbf{R}$, the desired result follows immediately from 4.21. Now consider the case where $|E|=\infty$ [which means that $\chi_{E} \notin \mathcal{L}^{1}(\mathbf{R})$ and hence 4.21 as stated cannot be used]. For $k \in \mathbf{Z}^{+}$, let $E_{k}=E \cap(-k, k)$. If $|b|0$. To prove 4.26, let $J$ be a closed interval contained in $G$ such that $0<|J|$. Let $r_{1}, r_{2}, \ldots$ be a list of all the rational numbers. Let $$ F=J \backslash \bigcup_{k=1}^{\infty}\left(r_{k}-\frac{|J|}{2^{k+2}}, r_{k}+\frac{|J|}{2^{k+2}}\right) $$ Then $F$ is a closed subset of $\mathbf{R}$ and $F \subset J \backslash \mathbf{Q} \subset G \backslash \mathbf{Q}$. Also, $|J \backslash F| \leq \frac{1}{2}|J|$ because $J \backslash F \subset \bigcup_{k=1}^{\infty}\left(r_{k}-\frac{|J|}{2^{k+2}}, r_{k}+\frac{|J|}{2^{k+2}}\right)$. Thus $$ |F|=|J|-|J \backslash F| \geq \frac{1}{2}|J|>0 $$ completing the proof of 4.26 . To construct the set $E$ with the desired properties, let $I_{1}, I_{2}, \ldots$ be a sequence consisting of all nonempty bounded open intervals of $\mathbf{R}$ with rational endpoints. Let $F_{0}=\widehat{F}_{0}=\varnothing$, and inductively construct sequences $F_{1}, F_{2}, \ldots$ and $\widehat{F}_{1}, \widehat{F}_{2}, \ldots$ of closed subsets of $\mathbf{R}$ as follows: Suppose $n \in \mathbf{Z}^{+}$and $F_{0}, \ldots, F_{n-1}$ and $\widehat{F}_{0}, \ldots, \widehat{F}_{n-1}$ have been chosen as closed sets that contain no rational numbers. Thus $$ I_{n} \backslash\left(\widehat{F_{0}} \cup \ldots \cup \widehat{F}_{n-1}\right) $$ is a nonempty open set (nonempty because it contains all rational numbers in $I_{n}$ ). Applying 4.26 to the open set above, we see that there is a closed set $F_{n}$ contained in the set above such that $F_{n}$ contains no rational numbers and $\left|F_{n}\right|>0$. Applying 4.26 again, but this time to the open set $$ I_{n} \backslash\left(F_{0} \cup \ldots \cup F_{n}\right) $$ which is nonempty because it contains all rational numbers in $I_{n}$, we see that there is a closed set $\widehat{F}_{n}$ contained in the set above such that $\widehat{F}_{n}$ contains no rational numbers and $\left|\widehat{F}_{n}\right|>0$. Now let $$ E=\bigcup_{k=1}^{\infty} F_{k} $$ Our construction implies that $F_{k} \cap \widehat{F}_{n}=\varnothing$ for all $k, n \in \mathbf{Z}^{+}$. Thus $E \cap \widehat{F}_{n}=\varnothing$ for all $n \in \mathbf{Z}^{+}$. Hence $\widehat{F}_{n} \subset I_{n} \backslash E$ for all $n \in \mathbf{Z}^{+}$. Suppose $I$ is a nonempty bounded open interval. Then $I_{n} \subset I$ for some $n \in \mathbf{Z}^{+}$. Thus $$ 0<\left|F_{n}\right| \leq\left|E \cap I_{n}\right| \leq|E \cap I| $$ Also, $$ |E \cap I|=|I|-|I \backslash E| \leq|I|-\left|I_{n} \backslash E\right| \leq|I|-\left|\widehat{F}_{n}\right|<|I|, $$ completing the proof. ## EXERCISES 4B For $f \in \mathcal{L}^{1}(\mathrm{R})$ and $I$ an interval of $\mathrm{R}$ with $0<|I|<\infty$, let $f_{I}$ denote the average of $f$ on I. In other words, $f_{I}=\frac{1}{|I|} \int_{I} f$. 1 Suppose $f \in \mathcal{L}^{1}(\mathbf{R})$. Prove that $$ \lim _{t \downarrow 0} \frac{1}{2 t} \int_{b-t}^{b+t}\left|f-f_{[b-t, b+t]}\right|=0 $$ for almost every $b \in \mathbf{R}$. 2 Suppose $f \in \mathcal{L}^{1}(\mathbf{R})$. Prove that $$ \lim _{t \downarrow 0} \sup \left\{\frac{1}{|I|} \int_{I}\left|f-f_{I}\right|: I \text { is an interval of length } t \text { containing } b\right\}=0 $$ for almost every $b \in \mathbf{R}$. 3 Suppose $f: \mathbf{R} \rightarrow \mathbf{R}$ is a Lebesgue measurable function such that $f^{2} \in \mathcal{L}^{1}(\mathbf{R})$. Prove that $$ \lim _{t \downarrow 0} \frac{1}{2 t} \int_{b-t}^{b+t}|f-f(b)|^{2}=0 $$ for almost every $b \in \mathbf{R}$. 4 Prove that the Lebesgue Differentiation Theorem (4.19) still holds if the hypothesis that $\int_{-\infty}^{\infty}|f|<\infty$ is weakened to the requirement that $\int_{-\infty}^{x}|f|<\infty$ for all $x \in \mathbf{R}$. 5 Suppose $f: \mathbf{R} \rightarrow \mathbf{R}$ is a Lebesgue measurable function. Prove that $$ |f(b)| \leq f^{*}(b) $$ for almost every $b \in \mathbf{R}$. 6 Prove that if $h \in \mathcal{L}^{1}(\mathbf{R})$ and $\int_{-\infty}^{s} h=0$ for all $s \in \mathbf{R}$, then $h(s)=0$ for almost every $s \in \mathbf{R}$. 7 Give an example of a Borel subset of $\mathbf{R}$ whose density at 0 is not defined. 8 Give an example of a Borel subset of $\mathbf{R}$ whose density at 0 is $\frac{1}{3}$. 9 Prove that if $t \in[0,1]$, then there exists a Borel set $E \subset \mathbf{R}$ such that the density of $E$ at 0 is $t$. 10 Suppose $E$ is a Lebesgue measurable subset of $\mathbf{R}$ such that the density of $E$ equals 1 at every element of $E$ and equals 0 at every element of $\mathbf{R} \backslash E$. Prove that $E=\varnothing$ or $E=\mathbf{R}$. ## Chapter 5 ## Product Measures Lebesgue measure on $\mathbf{R}$ generalizes the notion of the length of an interval. In this chapter, we see how two-dimensional Lebesgue measure on $\mathbf{R}^{2}$ generalizes the notion of the area of a rectangle. More generally, we construct new measures that are the products of two measures. Once these new measures have been constructed, the question arises of how to compute integrals with respect to these new measures. Beautiful theorems proved in the first decade of the twentieth century allow us to compute integrals with respect to product measures as iterated integrals involving the two measures that produced the product. Furthermore, we will see that under reasonable conditions we can switch the order of an iterated integral. ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-131.jpg?height=639&width=1167&top_left_y=934&top_left_x=64) Main building of Scuola Normale Superiore di Pisa, the university in Pisa, Italy, where Guido Fubini (1879-1943) received his PhD in 1900. In 1907 Fubini proved that under reasonable conditions, an integral with respect to a product measure can be computed as an iterated integral and that the order of integration can be switched. Leonida Tonelli (1885-1943) also taught for many years in Pisa; he also proved a crucial theorem about interchanging the order of integration in an iterated integral. CC-BY-SA Lucarelli ## 5A Products of Measure Spaces ## Products of $\sigma$-Algebras Our first step in constructing product measures is to construct the product of two $\sigma$-algebras. We begin with the following definition. ### 5.1 Definition rectangle Suppose $X$ and $Y$ are sets. A rectangle in $X \times Y$ is a set of the form $A \times B$, where $A \subset X$ and $B \subset Y$. ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-132.jpg?height=397&width=509&top_left_y=690&top_left_x=699) Now we can define the product of two $\sigma$-algebras. 5.2 Definition product of two $\sigma$-algebras; $\mathcal{S} \otimes \mathcal{T}$; measurable rectangle Suppose $(X, \mathcal{S})$ and $(Y, \mathcal{T})$ are measurable spaces. Then - the product $\mathcal{S} \otimes \mathcal{T}$ is defined to be the smallest $\sigma$-algebra on $X \times Y$ that contains $$ \{A \times B: A \in \mathcal{S}, B \in \mathcal{T}\} $$ - a measurable rectangle in $\mathcal{S} \otimes \mathcal{T}$ is a set of the form $A \times B$, where $A \in \mathcal{S}$ and $B \in \mathcal{T}$. Using the terminology introduced in the second bullet point above, we can say that $\mathcal{S} \otimes \mathcal{T}$ is the smallest $\sigma$-algebra containing all the measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$. Exercise 1 in this section asks you to show that the measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$ are the only rectangles in The notation $\mathcal{S} \times \mathcal{T}$ is not used because $\mathcal{S}$ and $\mathcal{T}$ are sets (of sets), and thus the notation $\mathcal{S} \times \mathcal{T}$ already is defined to mean the set of all ordered pairs of the form $(A, B)$, where $A \in \mathcal{S}$ and $B \in \mathcal{T}$. $X \times Y$ that are in $\mathcal{S} \otimes \mathcal{T}$. The notion of cross sections plays a crucial role in our development of product measures. First, we define cross sections of sets, and then we define cross sections of functions. 5.3 Definition cross sections of sets; $[E]_{a}$ and $[E]^{b}$ Suppose $X$ and $Y$ are sets and $E \subset X \times Y$. Then for $a \in X$ and $b \in Y$, the cross sections $[E]_{a}$ and $[E]^{b}$ are defined by $$ [E]_{a}=\{y \in Y:(a, y) \in E\} \quad \text { and } \quad[E]^{b}=\{x \in X:(x, b) \in E\} $$ 5.4 Example cross sections of a subset of $X \times Y$ ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-133.jpg?height=344&width=1124&top_left_y=578&top_left_x=100) ### 5.5 Example cross sections of rectangles Suppose $X$ and $Y$ are sets and $A \subset X$ and $B \subset Y$. If $a \in X$ and $b \in Y$, then $$ [A \times B]_{a}=\left\{\begin{array}{ll} B & \text { if } a \in A, \\ \varnothing & \text { if } a \notin A \end{array} \quad \text { and } \quad[A \times B]^{b}= \begin{cases}A & \text { if } b \in B \\ \varnothing & \text { if } b \notin B\end{cases}\right. $$ as you should verify. The next result shows that cross sections preserve measurability. ## 5.6 cross sections of measurable sets are measurable Suppose $\mathcal{S}$ is a $\sigma$-algebra on $X$ and $\mathcal{T}$ is a $\sigma$-algebra on $Y$. If $E \in \mathcal{S} \otimes \mathcal{T}$, then $$ [E]_{a} \in \mathcal{T} \text { for every } a \in X \quad \text { and } \quad[E]^{b} \in \mathcal{S} \text { for every } b \in Y $$ Proof Let $\mathcal{E}$ denote the collection of subsets $E$ of $X \times Y$ for which the conclusion of this result holds. Then $A \times B \in \mathcal{E}$ for all $A \in \mathcal{S}$ and all $B \in \mathcal{T}$ (by Example 5.5). The collection $\mathcal{E}$ is closed under complementation and countable unions because $$ [(X \times Y) \backslash E]_{a}=Y \backslash[E]_{a} $$ and $$ \left[E_{1} \cup E_{2} \cup \cdots\right]_{a}=\left[E_{1}\right]_{a} \cup\left[E_{2}\right]_{a} \cup \cdots $$ for all subsets $E, E_{1}, E_{2}, \ldots$ of $X \times Y$ and all $a \in X$, as you should verify, with similar statements holding for cross sections with respect to all $b \in Y$. Because $\mathcal{E}$ is a $\sigma$-algebra containing all the measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$, we conclude that $\mathcal{E}$ contains $\mathcal{S} \otimes \mathcal{T}$. Now we define cross sections of functions. 5.7 Definition cross sections of functions; $[f]_{a}$ and $[f]^{b}$ Suppose $X$ and $Y$ are sets and $f: X \times Y \rightarrow \mathbf{R}$ is a function. Then for $a \in X$ and $b \in Y$, the cross section functions $[f]_{a}: Y \rightarrow \mathbf{R}$ and $[f]^{b}: X \rightarrow \mathbf{R}$ are defined by $$ [f]_{a}(y)=f(a, y) \text { for } y \in Y \quad \text { and }[f]^{b}(x)=f(x, b) \text { for } x \in X $$ ### 5.8 Example cross sections - Suppose $f: \mathbf{R} \times \mathbf{R} \rightarrow \mathbf{R}$ is defined by $f(x, y)=5 x^{2}+y^{3}$. Then $$ [f]_{2}(y)=20+y^{3} \text { and }[f]^{3}(x)=5 x^{2}+27 $$ for all $y \in \mathbf{R}$ and all $x \in \mathbf{R}$, as you should verify. - Suppose $X$ and $Y$ are sets and $A \subset X$ and $B \subset Y$. If $a \in X$ and $b \in Y$, then $$ \left[\chi_{A \times B}\right]_{a}=\chi_{A}(a) \chi_{B} \quad \text { and } \quad\left[\chi_{A \times B}\right]^{b}=\chi_{B}(b) \chi_{A} \text {, } $$ as you should verify. The next result shows that cross sections preserve measurability, this time in the context of functions rather than sets. ## 5.9 cross sections of measurable functions are measurable Suppose $\mathcal{S}$ is a $\sigma$-algebra on $X$ and $\mathcal{T}$ is a $\sigma$-algebra on $Y$. Suppose $f: X \times Y \rightarrow \mathbf{R}$ is an $\mathcal{S} \otimes \mathcal{T}$-measurable function. Then $[f]_{a}$ is a $\mathcal{T}$-measurable function on $Y$ for every $a \in X$ and $$ [f]^{b} \text { is an } \mathcal{S} \text {-measurable function on } X \text { for every } b \in Y \text {. } $$ Proof Suppose $D$ is a Borel subset of $\mathbf{R}$ and $a \in X$. If $y \in Y$, then $$ \begin{aligned} y \in\left([f]_{a}\right)^{-1}(D) & \Longleftrightarrow[f]_{a}(y) \in D \\ & \Longleftrightarrow f(a, y) \in D \\ & \Longleftrightarrow(a, y) \in f^{-1}(D) \\ & \Longleftrightarrow y \in\left[f^{-1}(D)\right]_{a} . \end{aligned} $$ Thus $$ \left([f]_{a}\right)^{-1}(D)=\left[f^{-1}(D)\right]_{a} . $$ Because $f$ is an $\mathcal{S} \otimes \mathcal{T}$-measurable function, $f^{-1}(D) \in \mathcal{S} \otimes \mathcal{T}$. Thus the equation above and $5.6 \mathrm{imply}$ that $\left([f]_{a}\right)^{-1}(D) \in \mathcal{T}$. Hence $[f]_{a}$ is a $\mathcal{T}$-measurable function. The same ideas show that $[f]^{b}$ is an $\mathcal{S}$-measurable function for every $b \in Y$. ## Monotone Class Theorem The following standard two-step technique often works to prove that every set in a $\sigma$-algebra has a certain property: 1. show that every set in a collection of sets that generates the $\sigma$-algebra has the property; 2. show that the collection of sets that has the property is a $\sigma$-algebra. For example, the proof of 5.6 used the technique above-first we showed that every measurable rectangle in $\mathcal{S} \otimes \mathcal{T}$ has the desired property, then we showed that the collection of sets that has the desired property is a $\sigma$-algebra (this completed the proof because $\mathcal{S} \otimes \mathcal{T}$ is the smallest $\sigma$-algebra containing the measurable rectangles). The technique outlined above should be used when possible. However, in some situations there seems to be no reasonable way to verify that the collection of sets with the desired property is a $\sigma$-algebra. We will encounter this situation in the next subsection. To deal with it, we need to introduce another technique that involves what are called monotone classes. The following definition will be used in our main theorem about monotone classes. ### 5.10 Definition algebra Suppose $W$ is a set and $\mathcal{A}$ is a set of subsets of $W$. Then $\mathcal{A}$ is called an algebra on $W$ if the following three conditions are satisfied: - $\varnothing \in \mathcal{A}$; - if $E \in \mathcal{A}$, then $W \backslash E \in \mathcal{A}$; - if $E$ and $F$ are elements of $\mathcal{A}$, then $E \cup F \in \mathcal{A}$. Thus an algebra is closed under complementation and under finite unions; a $\sigma$-algebra is closed under complementation and countable unions. ### 5.11 Example collection of finite unions of intervals is an algebra Suppose $\mathcal{A}$ is the collection of all finite unions of intervals of $\mathbf{R}$. Here we are including all intervals - open intervals, closed intervals, bounded intervals, unbounded intervals, sets consisting of only a single point, and intervals that are neither open nor closed because they contain one endpoint but not the other endpoint. Clearly $\mathcal{A}$ is closed under finite unions. You should also verify that $\mathcal{A}$ is closed under complementation. Thus $\mathcal{A}$ is an algebra on $\mathbf{R}$. ### 5.12 Example collection of countable unions of intervals is not an algebra Suppose $\mathcal{A}$ is the collection of all countable unions of intervals of $\mathbf{R}$. Clearly $\mathcal{A}$ is closed under finite unions (and also under countable unions). You should verify that $\mathcal{A}$ is not closed under complementation. Thus $\mathcal{A}$ is neither an algebra nor a $\sigma$-algebra on $\mathbf{R}$. The following result provides an example of an algebra that we will exploit. ### 5.13 the set of finite unions of measurable rectangles is an algebra Suppose $(X, \mathcal{S})$ and $(Y, \mathcal{T})$ are measurable spaces. Then (a) the set of finite unions of measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$ is an algebra on $X \times Y$; (b) every finite union of measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$ can be written as a finite union of disjoint measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$. Proof Let $\mathcal{A}$ denote the set of finite unions of measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$. Obviously $\mathcal{A}$ is closed under finite unions. The collection $\mathcal{A}$ is also closed under finite intersections. To verify this claim, note that if $A_{1}, \ldots, A_{n}, C_{1}, \ldots, C_{m} \in \mathcal{S}$ and $B_{1}, \ldots, B_{n}, D_{1}, \ldots, D_{m} \in \mathcal{T}$, then $$ \begin{aligned} & \left(\left(A_{1} \times B_{1}\right) \cup \cdots \cup\left(A_{n} \times B_{n}\right)\right) \cap\left(\left(C_{1} \times D_{1}\right) \cup \cdots \cup\left(C_{m} \times D_{m}\right)\right) \\ & =\bigcup_{j=1}^{n} \bigcup_{k=1}^{m}\left(\left(A_{j} \times B_{j}\right) \cap\left(C_{k} \times D_{k}\right)\right) \\ & =\bigcup_{j=1}^{n} \bigcup_{k=1}^{m}\left(\left(A_{j} \cap C_{k}\right) \times\left(B_{j} \cap D_{k}\right)\right), \quad A \times B \end{aligned} $$ ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-136.jpg?height=250&width=489&top_left_y=902&top_left_x=683) Intersection of two rectangles is a rectangle. which implies that $\mathcal{A}$ is closed under finite intersections. If $A \in \mathcal{S}$ and $B \in \mathcal{T}$, then $$ (X \times Y) \backslash(A \times B)=((X \backslash A) \times Y) \cup(X \times(Y \backslash B)) $$ Hence the complement of each measurable rectangle in $\mathcal{S} \otimes \mathcal{T}$ is in $\mathcal{A}$. Thus the complement of a finite union of measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$ is in $\mathcal{A}$ (use De Morgan's Laws and the result in the previous paragraph that $\mathcal{A}$ is closed under finite intersections). In other words, $\mathcal{A}$ is closed under complementation, completing the proof of (a). To prove (b), note that if $A \times B$ and $C \times D$ are measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$, then (as can be verified in the figure above) $$ 5.14(A \times B) \cup(C \times D)=(A \times B) \cup(C \times(D \backslash B)) \cup((C \backslash A) \times(B \cap D)) . $$ The equation above writes the union of two measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$ as the union of three disjoint measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$. Now consider any finite union of measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$. If this is not a disjoint union, then choose any nondisjoint pair of measurable rectangles in the union and replace those two measurable rectangles with the union of three disjoint measurable rectangles as in 5.14. Iterate this process until obtaining a disjoint union of measurable rectangles. Now we define a monotone class as a collection of sets that is closed under countable increasing unions and under countable decreasing intersections. ### 5.15 Definition monotone class Suppose $W$ is a set and $\mathcal{M}$ is a set of subsets of $W$. Then $\mathcal{M}$ is called a monotone class on $W$ if the following two conditions are satisfied: - If $E_{1} \subset E_{2} \subset \cdots$ is an increasing sequence of sets in $\mathcal{M}$, then $\bigcup_{k=1}^{\infty} E_{k} \in \mathcal{M}$; - If $E_{1} \supset E_{2} \supset \cdots$ is a decreasing sequence of sets in $\mathcal{M}$, then $\bigcap_{k=1}^{\infty} E_{k} \in \mathcal{M}$. Clearly every $\sigma$-algebra is a monotone class. However, some monotone classes are not closed under even finite unions, as shown by the next example. ### 5.16 Example a monotone class that is not an algebra Suppose $\mathcal{A}$ is the collection of all intervals of $\mathbf{R}$. Then $\mathcal{A}$ is closed under countable increasing unions and countable decreasing intersections. Thus $\mathcal{A}$ is a monotone class on $\mathbf{R}$. However, $\mathcal{A}$ is not closed under finite unions, and $\mathcal{A}$ is not closed under complementation. Thus $\mathcal{A}$ is neither an algebra nor a $\sigma$-algebra on $\mathbf{R}$. If $\mathcal{A}$ is a collection of subsets of some set $W$, then the intersection of all monotone classes on $W$ that contain $\mathcal{A}$ is a monotone class that contains $\mathcal{A}$. Thus this intersection is the smallest monotone class on $W$ that contains $\mathcal{A}$. The next result provides a useful tool when the standard technique for showing that every set in a $\sigma$-algebra has a certain property does not work. ### 5.17 Monotone Class Theorem Suppose $\mathcal{A}$ is an algebra on a set $W$. Then the smallest $\sigma$-algebra containing $\mathcal{A}$ is the smallest monotone class containing $\mathcal{A}$. Proof Let $\mathcal{M}$ denote the smallest monotone class containing $\mathcal{A}$. Because every $\sigma$ algebra is a monotone class, $\mathcal{M}$ is contained in the smallest $\sigma$-algebra containing $\mathcal{A}$. To prove the inclusion in the other direction, first suppose $A \in \mathcal{A}$. Let $$ \mathcal{E}=\{E \in \mathcal{M}: A \cup E \in \mathcal{M}\} $$ Then $\mathcal{A} \subset \mathcal{E}$ (because the union of two sets in $\mathcal{A}$ is in $\mathcal{A}$ ). A moment's thought shows that $\mathcal{E}$ is a monotone class. Thus the smallest monotone class that contains $\mathcal{A}$ is contained in $\mathcal{E}$, meaning that $\mathcal{M} \subset \mathcal{E}$. Hence we have proved that $A \cup E \in \mathcal{M}$ for every $E \in \mathcal{M}$. Now let $$ \mathcal{D}=\{D \in \mathcal{M}: D \cup E \in \mathcal{M} \text { for all } E \in \mathcal{M}\} $$ The previous paragraph shows that $\mathcal{A} \subset \mathcal{D}$. A moment's thought again shows that $\mathcal{D}$ is a monotone class. Thus, as in the previous paragraph, we conclude that $\mathcal{M} \subset \mathcal{D}$. Hence we have proved that $D \cup E \in \mathcal{M}$ for all $D, E \in \mathcal{M}$. The paragraph above shows that the monotone class $\mathcal{M}$ is closed under finite unions. Now if $E_{1}, E_{2}, \ldots \in \mathcal{M}$, then $$ E_{1} \cup E_{2} \cup E_{3} \cup \cdots=E_{1} \cup\left(E_{1} \cup E_{2}\right) \cup\left(E_{1} \cup E_{2} \cup E_{3}\right) \cup \cdots, $$ which is an increasing union of a sequence of sets in $\mathcal{M}$ (by the previous paragraph). We conclude that $\mathcal{M}$ is closed under countable unions. Finally, let $$ \mathcal{M}^{\prime}=\{E \in \mathcal{M}: W \backslash E \in \mathcal{M}\} $$ Then $\mathcal{A} \subset \mathcal{M}^{\prime}$ (because $\mathcal{A}$ is closed under complementation). Once again, you should verify that $\mathcal{M}^{\prime}$ is a monotone class. Thus $\mathcal{M} \subset \mathcal{M}^{\prime}$. We conclude that $\mathcal{M}$ is closed under complementation. The two previous paragraphs show that $\mathcal{M}$ is closed under countable unions and under complementation. Thus $\mathcal{M}$ is a $\sigma$-algebra that contains $\mathcal{A}$. Hence $\mathcal{M}$ contains the smallest $\sigma$-algebra containing $\mathcal{A}$, completing the proof. ## Products of Measures The following definitions will be useful. ### 5.18 Definition finite measure; $\sigma$-finite measure - A measure $\mu$ on a measurable space $(X, \mathcal{S})$ is called finite if $\mu(X)<\infty$. - A measure is called $\sigma$-finite if the whole space can be written as the countable union of sets with finite measure. - More precisely, a measure $\mu$ on a measurable space $(X, \mathcal{S})$ is called $\sigma$-finite if there exists a sequence $X_{1}, X_{2}, \ldots$ of sets in $\mathcal{S}$ such that $$ X=\bigcup_{k=1}^{\infty} X_{k} \quad \text { and } \quad \mu\left(X_{k}\right)<\infty \text { for every } k \in \mathbf{Z}^{+} \text {. } $$ ### 5.19 Example finite and $\sigma$-finite measures - Lebesgue measure on the interval $[0,1]$ is a finite measure. - Lebesgue measure on $\mathbf{R}$ is not a finite measure but is a $\sigma$-finite measure. - Counting measure on $\mathbf{R}$ is not a $\sigma$-finite measure (because the countable union of finite sets is a countable set). The next result will allow us to define the product of two $\sigma$-finite measures. ### 5.20 measure of cross section is a measurable function Suppose $(X, \mathcal{S}, \mu)$ and $(Y, \mathcal{T}, v)$ are $\sigma$-finite measure spaces. If $E \in \mathcal{S} \otimes \mathcal{T}$, then (a) $x \mapsto v\left([E]_{x}\right)$ is an $\mathcal{S}$-measurable function on $X$; (b) $y \mapsto \mu\left([E]^{y}\right)$ is a $\mathcal{T}$-measurable function on $Y$. Proof We will prove (a). If $E \in \mathcal{S} \otimes \mathcal{T}$, then $[E]_{x} \in \mathcal{T}$ for every $x \in X$ (by 5.6); thus the function $x \mapsto v\left([E]_{x}\right)$ is well defined on $X$. We first consider the case where $v$ is a finite measure. Let $$ \mathcal{M}=\left\{E \in \mathcal{S} \otimes \mathcal{T}: x \mapsto v\left([E]_{x}\right) \text { is an } \mathcal{S} \text {-measurable function on } X\right\} $$ We need to prove that $\mathcal{M}=\mathcal{S} \otimes \mathcal{T}$. If $A \in \mathcal{S}$ and $B \in \mathcal{T}$, then $v\left([A \times B]_{x}\right)=v(B) \chi_{A}(x)$ for every $x \in X$ (by Example 5.5). Thus the function $x \mapsto v\left([A \times B]_{x}\right)$ equals the function $v(B) \chi_{A}$ (as a function on $X$ ), which is an $\mathcal{S}$-measurable function on $X$. Hence $\mathcal{M}$ contains all the measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$. Let $\mathcal{A}$ denote the set of finite unions of measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$. Suppose $E \in \mathcal{A}$. Then by 5.13 (b), $E$ is a union of disjoint measurable rectangles $E_{1}, \ldots, E_{n}$. Thus $$ \begin{aligned} v\left([E]_{x}\right) & =v\left(\left[E_{1} \cup \cdots \cup E_{n}\right]_{x}\right) \\ & =v\left(\left[E_{1}\right]_{x} \cup \cdots \cup\left[E_{n}\right]_{x}\right) \\ & =v\left(\left[E_{1}\right]_{x}\right)+\cdots+v\left(\left[E_{n}\right]_{x}\right) \end{aligned} $$ where the last equality holds because $v$ is a measure and $\left[E_{1}\right]_{x}, \ldots,\left[E_{n}\right]_{x}$ are disjoint. The equation above, when combined with the conclusion of the previous paragraph, shows that $x \mapsto v\left([E]_{x}\right)$ is a finite sum of $\mathcal{S}$-measurable functions and thus is an $\mathcal{S}$-measurable function. Hence $E \in \mathcal{M}$. We have now shown that $\mathcal{A} \subset \mathcal{M}$. Our next goal is to show that $\mathcal{M}$ is a monotone class on $X \times Y$. To do this, first suppose $E_{1} \subset E_{2} \subset \cdots$ is an increasing sequence of sets in $\mathcal{M}$. Then $$ \begin{aligned} v\left(\left[\bigcup_{k=1}^{\infty} E_{k}\right]_{x}\right) & =v\left(\bigcup_{k=1}^{\infty}\left(\left[E_{k}\right]_{x}\right)\right) \\ & =\lim _{k \rightarrow \infty} v\left(\left[E_{k}\right]_{x}\right) \end{aligned} $$ where we have used 2.59. Because the pointwise limit of $\mathcal{S}$-measurable functions is $\mathcal{S}$-measurable (by 2.48), the equation above shows that $x \mapsto v\left(\left[\bigcup_{k=1}^{\infty} E_{k}\right]_{x}\right)$ is an $\mathcal{S}$-measurable function. Hence $\bigcup_{k=1}^{\infty} E_{k} \in \mathcal{M}$. We have now shown that $\mathcal{M}$ is closed under countable increasing unions. Now suppose $E_{1} \supset E_{2} \supset \cdots$ is a decreasing sequence of sets in $\mathcal{M}$. Then $$ \begin{aligned} v\left(\left[\bigcap_{k=1}^{\infty} E_{k}\right]_{x}\right) & =v\left(\bigcap_{k=1}^{\infty}\left(\left[E_{k}\right]_{x}\right)\right) \\ & =\lim _{k \rightarrow \infty} v\left(\left[E_{k}\right]_{x}\right) \end{aligned} $$ where we have used 2.60 (this is where we use the assumption that $v$ is a finite measure). Because the pointwise limit of $\mathcal{S}$-measurable functions is $\mathcal{S}$-measurable (by 2.48), the equation above shows that $x \mapsto v\left(\left[\bigcap_{k=1}^{\infty} E_{k}\right]_{x}\right)$ is an $\mathcal{S}$-measurable function. Hence $\bigcap_{k=1}^{\infty} E_{k} \in \mathcal{M}$. We have now shown that $\mathcal{M}$ is closed under countable decreasing intersections. We have shown that $\mathcal{M}$ is a monotone class that contains the algebra $\mathcal{A}$ of all finite unions of measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$ [by 5.13(a), $\mathcal{A}$ is indeed an algebra]. The Monotone Class Theorem (5.17) implies that $\mathcal{M}$ contains the smallest $\sigma$-algebra containing $\mathcal{A}$. In other words, $\mathcal{M}$ contains $\mathcal{S} \otimes \mathcal{T}$. This conclusion completes the proof of (a) in the case where $v$ is a finite measure. Now consider the case where $v$ is a $\sigma$-finite measure. Thus there exists a sequence $Y_{1}, Y_{2}, \ldots$ of sets in $\mathcal{T}$ such that $\bigcup_{k=1}^{\infty} Y_{k}=Y$ and $v\left(Y_{k}\right)<\infty$ for each $k \in \mathbf{Z}^{+}$. Replacing each $Y_{k}$ by $Y_{1} \cup \cdots \cup Y_{k}$, we can assume that $Y_{1} \subset Y_{2} \subset \cdots$. If $E \in \mathcal{S} \otimes \mathcal{T}$, then $$ v\left([E]_{x}\right)=\lim _{k \rightarrow \infty} v\left(\left[E \cap\left(X \times Y_{k}\right)\right]_{x}\right) $$ The function $x \mapsto v\left(\left[E \cap\left(X \times Y_{k}\right)\right]_{x}\right)$ is an $\mathcal{S}$-measurable function on $X$, as follows by considering the finite measure obtained by restricting $v$ to the $\sigma$-algebra on $Y_{k}$ consisting of sets in $\mathcal{T}$ that are contained in $Y_{k}$. The equation above now implies that $x \mapsto v\left([E]_{x}\right)$ is an $\mathcal{S}$-measurable function on $X$, completing the proof of (a). The proof of (b) is similar. ### 5.21 Definition integration notation Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $g: X \rightarrow[-\infty, \infty]$ is a function. The notation $$ \int g(x) d \mu(x) \text { means } \int g d \mu $$ where $d \mu(x)$ indicates that variables other than $x$ should be treated as constants. ### 5.22 Example integrals If $\lambda$ is Lebesgue measure on $[0,4]$, then $$ \int_{[0,4]}\left(x^{2}+y\right) d \lambda(y)=4 x^{2}+8 \quad \text { and } \quad \int_{[0,4]}\left(x^{2}+y\right) d \lambda(x)=\frac{64}{3}+4 y \text {. } $$ The intent in the next definition is that $\int_{X} \int_{Y} f(x, y) d v(y) d \mu(x)$ is defined only when the inner integral and then the outer integral both make sense. ### 5.23 Definition iterated integrals Suppose $(X, \mathcal{S}, \mu)$ and $(Y, \mathcal{T}, v)$ are measure spaces and $f: X \times Y \rightarrow \mathbf{R}$ is a function. Then $$ \int_{X} \int_{Y} f(x, y) d \nu(y) d \mu(x) \quad \text { means } \quad \int_{X}\left(\int_{Y} f(x, y) d \nu(y)\right) d \mu(x) $$ In other words, to compute $\int_{X} \int_{Y} f(x, y) d v(y) d \mu(x)$, first (temporarily) fix $x \in$ $X$ and compute $\int_{Y} f(x, y) d v(y)$ [if this integral makes sense]. Then compute the integral with respect to $\mu$ of the function $x \mapsto \int_{Y} f(x, y) d v(y)$ [if this integral makes sense]. ### 5.24 Example iterated integrals If $\lambda$ is Lebesgue measure on $[0,4]$, then $$ \begin{aligned} \int_{[0,4]} \int_{[0,4]}\left(x^{2}+y\right) d \lambda(y) d \lambda(x) & =\int_{[0,4]}\left(4 x^{2}+8\right) d \lambda(x) \\ & =\frac{352}{3} \end{aligned} $$ and $$ \begin{aligned} \int_{[0,4]} \int_{[0,4]}\left(x^{2}+y\right) d \lambda(x) d \lambda(y) & =\int_{[0,4]}\left(\frac{64}{3}+4 y\right) d \lambda(y) \\ & =\frac{352}{3} . \end{aligned} $$ The two iterated integrals in this example turned out to both equal $\frac{352}{3}$, even though they do not look alike in the intermediate step of the evaluation. As we will see in the next section, this equality of integrals when changing the order of integration is not a coincidence. The definition of $(\mu \times v)(E)$ given below makes sense because the inner integral below equals $v\left([E]_{x}\right)$, which makes sense by 5.6 (or use 5.9), and then the outer integral makes sense by 5.20 (a). The restriction in the definition below to $\sigma$-finite measures is not bothersome because the main results we seek are not valid without this hypothesis (see Example 5.30 in the next section). ### 5.25 Definition product of two measures; $\mu \times v$ Suppose $(X, \mathcal{S}, \mu)$ and $(Y, \mathcal{T}, v)$ are $\sigma$-finite measure spaces. For $E \in \mathcal{S} \otimes \mathcal{T}$, define $(\mu \times v)(E)$ by $$ (\mu \times v)(E)=\int_{X} \int_{Y} \chi_{E}(x, y) d \nu(y) d \mu(x) $$ ### 5.26 Example measure of a rectangle Suppose $(X, \mathcal{S}, \mu)$ and $(Y, \mathcal{T}, v)$ are $\sigma$-finite measure spaces. If $A \in \mathcal{S}$ and $B \in \mathcal{T}$, then $$ \begin{aligned} (\mu \times v)(A \times B) & =\int_{X} \int_{Y} \chi_{A \times B}(x, y) d v(y) d \mu(x) \\ & =\int_{X} v(B) \chi_{A}(x) d \mu(x) \\ & =\mu(A) v(B) . \end{aligned} $$ Thus product measure of a measurable rectangle is the product of the measures of the corresponding sets. For $(X, \mathcal{S}, \mu)$ and $(Y, \mathcal{T}, v) \sigma$-finite measure spaces, we defined the product $\mu \times v$ to be a function from $\mathcal{S} \otimes \mathcal{T}$ to $[0, \infty]$ (see 5.25). Now we show that this function is a measure. ### 5.27 product of two measures is a measure Suppose $(X, \mathcal{S}, \mu)$ and $(Y, \mathcal{T}, v)$ are $\sigma$-finite measure spaces. Then $\mu \times v$ is a measure on $(X \times Y, \mathcal{S} \otimes \mathcal{T})$. Proof Clearly $(\mu \times v)(\varnothing)=0$. Suppose $E_{1}, E_{2}, \ldots$ is a disjoint sequence of sets in $\mathcal{S} \otimes \mathcal{T}$. Then $$ \begin{aligned} (\mu \times v)\left(\bigcup_{k=1}^{\infty} E_{k}\right) & =\int_{X} v\left(\left[\bigcup_{k=1}^{\infty} E_{k}\right]_{x}\right) d \mu(x) \\ & =\int_{X} v\left(\bigcup_{k=1}^{\infty}\left(\left[E_{k}\right]_{x}\right)\right) d \mu(x) \\ & =\int_{X}\left(\sum_{k=1}^{\infty} v\left(\left[E_{k}\right]_{x}\right)\right) d \mu(x) \\ & =\sum_{k=1}^{\infty} \int_{X} v\left(\left[E_{k}\right]_{x}\right) d \mu(x) \\ & =\sum_{k=1}^{\infty}(\mu \times v)\left(E_{k}\right), \end{aligned} $$ where the fourth equality follows from the Monotone Convergence Theorem (3.11; or see Exercise 10 in Section 3A). The equation above shows that $\mu \times v$ satisfies the countable additivity condition required for a measure. ## EXERCISES 5A 1 Suppose $(X, \mathcal{S})$ and $(Y, \mathcal{T})$ are measurable spaces. Prove that if $A$ is a nonempty subset of $X$ and $B$ is a nonempty subset of $Y$ such that $A \times B \in$ $\mathcal{S} \otimes \mathcal{T}$, then $A \in \mathcal{S}$ and $B \in \mathcal{T}$. 2 Suppose $(X, \mathcal{S})$ is a measurable space. Prove that if $E \in \mathcal{S} \otimes \mathcal{S}$, then $$ \{x \in X:(x, x) \in E\} \in \mathcal{S} . $$ 3 Let $\mathcal{B}$ denote the $\sigma$-algebra of Borel subsets of $\mathbf{R}$. Show that there exists a set $E \subset \mathbf{R} \times \mathbf{R}$ such that $[E]_{a} \in \mathcal{B}$ and $[E]^{a} \in \mathcal{B}$ for every $a \in \mathbf{R}$, but $E \notin \mathcal{B} \otimes \mathcal{B}$. 4 Suppose $(X, \mathcal{S})$ and $(Y, \mathcal{T})$ are measurable spaces. Prove that if $f: X \rightarrow \mathbf{R}$ is $\mathcal{S}$-measurable and $g: Y \rightarrow \mathbf{R}$ is $\mathcal{T}$-measurable and $h: X \times Y \rightarrow \mathbf{R}$ is defined by $h(x, y)=f(x) g(y)$, then $h$ is $(\mathcal{S} \otimes \mathcal{T})$-measurable. 5 Verify the assertion in Example 5.11 that the collection of finite unions of intervals of $\mathbf{R}$ is closed under complementation. 6 Verify the assertion in Example 5.12 that the collection of countable unions of intervals of $\mathbf{R}$ is not closed under complementation. 7 Suppose $\mathcal{A}$ is a nonempty collection of subsets of a set $W$. Show that $\mathcal{A}$ is an algebra on $W$ if and only if $\mathcal{A}$ is closed under finite intersections and under complementation. 8 Suppose $\mu$ is a measure on a measurable space $(X, \mathcal{S})$. Prove that the following are equivalent: (a) The measure $\mu$ is $\sigma$-finite. (b) There exists an increasing sequence $X_{1} \subset X_{2} \subset \cdots$ of sets in $\mathcal{S}$ such that $X=\bigcup_{k=1}^{\infty} X_{k}$ and $\mu\left(X_{k}\right)<\infty$ for every $k \in \mathbf{Z}^{+}$. (c) There exists a disjoint sequence $X_{1}, X_{2}, X_{3}, \ldots$ of sets in $\mathcal{S}$ such that $X=\bigcup_{k=1}^{\infty} X_{k}$ and $\mu\left(X_{k}\right)<\infty$ for every $k \in \mathbf{Z}^{+}$. 9 Suppose $\mu$ and $v$ are $\sigma$-finite measures. Prove that $\mu \times v$ is a $\sigma$-finite measure. 10 Suppose $(X, \mathcal{S}, \mu)$ and $(Y, \mathcal{T}, v)$ are $\sigma$-finite measure spaces. Prove that if $\omega$ is a measure on $\mathcal{S} \otimes \mathcal{T}$ such that $\omega(A \times B)=\mu(A) v(B)$ for all $A \in \mathcal{S}$ and all $B \in \mathcal{T}$, then $\omega=\mu \times v$. [The exercise above means that $\mu \times v$ is the unique measure on $\mathcal{S} \otimes \mathcal{T}$ that behaves as we expect on measurable rectangles.] ## 5B Iterated Integrals ## Tonelli's Theorem Relook at Example 5.24 in the previous section and notice that the value of the iterated integral was unchanged when we switched the order of integration, even though switching the order of integration led to different intermediate results. Our next result states that the order of integration can be switched if the function being integrated is nonnegative and the measures are $\sigma$-finite. ### 5.28 Tonelli's Theorem Suppose $(X, \mathcal{S}, \mu)$ and $(Y, \mathcal{T}, v)$ are $\sigma$-finite measure spaces. Suppose $f: X \times Y \rightarrow[0, \infty]$ is $\mathcal{S} \otimes \mathcal{T}$-measurable. Then $$ \begin{aligned} x & \mapsto \int_{Y} f(x, y) d \nu(y) \text { is an } \mathcal{S} \text {-measurable function on } X, \\ y & \mapsto \int_{X} f(x, y) d \mu(x) \text { is a } \mathcal{T} \text {-measurable function on } Y, \end{aligned} $$ and $$ \int_{X \times Y} f d(\mu \times v)=\int_{X} \int_{Y} f(x, y) d \nu(y) d \mu(x)=\int_{Y} \int_{X} f(x, y) d \mu(x) d \nu(y) . $$ Proof We begin by considering the special case where $f=\chi_{E}$ for some $E \in \mathcal{S} \otimes \mathcal{T}$. In this case, $$ \int_{Y} \chi_{E}(x, y) d v(y)=v\left([E]_{x}\right) \text { for every } x \in X $$ and $$ \int_{X} \chi_{E}(x, y) d \mu(x)=\mu\left([E]^{y}\right) \text { for every } y \in Y $$ Thus (a) and (b) hold in this case by 5.20. First assume that $\mu$ and $v$ are finite measures. Let $\mathcal{M}=\left\{E \in \mathcal{S} \otimes \mathcal{T}: \int_{X} \int_{Y} \chi_{E}(x, y) d v(y) d \mu(x)=\int_{Y} \int_{X} \chi_{E}(x, y) d \mu(x) d \nu(y)\right\}$. If $A \in \mathcal{S}$ and $B \in \mathcal{T}$, then $A \times B \in \mathcal{M}$ because both sides of the equation defining $\mathcal{M}$ equal $\mu(A) v(B)$. Let $\mathcal{A}$ denote the set of finite unions of measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$. Then 5.13(b) implies that every element of $\mathcal{A}$ is a disjoint union of measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$. The previous paragraph now implies $\mathcal{A} \subset \mathcal{M}$. The Monotone Convergence Theorem (3.11) implies that $\mathcal{M}$ is closed under countable increasing unions. The Bounded Convergence Theorem (3.26) implies that $\mathcal{M}$ is closed under countable decreasing intersections (this is where we use the assumption that $\mu$ and $v$ are finite measures). We have shown that $\mathcal{M}$ is a monotone class that contains the algebra $\mathcal{A}$ of all finite unions of measurable rectangles in $\mathcal{S} \otimes \mathcal{T}$ [by 5.13(a), $\mathcal{A}$ is indeed an algebra]. The Monotone Class Theorem (5.17) implies that $\mathcal{M}$ contains the smallest $\sigma$-algebra containing $\mathcal{A}$. In other words, $\mathcal{M}$ contains $\mathcal{S} \otimes \mathcal{T}$. Thus $$ \int_{X} \int_{Y} \chi_{E}(x, y) d v(y) d \mu(x)=\int_{Y} \int_{X} \chi_{E}(x, y) d \mu(x) d v(y) $$ for every $E \in \mathcal{S} \otimes \mathcal{T}$. Now relax the assumption that $\mu$ and $v$ are finite measures. Write $X$ as an increasing union of sets $X_{1} \subset X_{2} \subset \cdots$ in $\mathcal{S}$ with finite measure, and write $Y$ as an increasing union of sets $Y_{1} \subset Y_{2} \subset \cdots$ in $\mathcal{T}$ with finite measure. Suppose $E \in \mathcal{S} \otimes \mathcal{T}$. Applying the finite-measure case to the situation where the measures and the $\sigma$-algebras are restricted to $X_{j}$ and $Y_{k}$, we can conclude that 5.29 holds with $E$ replaced by $E \cap\left(X_{j} \times Y_{k}\right)$ for all $j, k \in \mathbf{Z}^{+}$. Fix $k \in \mathbf{Z}^{+}$and use the Monotone Convergence Theorem (3.11) to conclude that 5.29 holds with $E$ replaced by $E \cap\left(X \times Y_{k}\right)$ for all $k \in \mathbf{Z}^{+}$. One more use of the Monotone Convergence Theorem then shows that $$ \int_{X \times Y} \chi_{E} d(\mu \times v)=\int_{X} \int_{Y} \chi_{E}(x, y) d v(y) d \mu(x)=\int_{Y} \int_{X} \chi_{E}(x, y) d \mu(x) d v(y) $$ for all $E \in \mathcal{S} \otimes \mathcal{T}$, where the first equality above comes from the definition of $(\mu \times v)(E)($ see 5.25$)$. Now we turn from characteristic functions to the general case of an $\mathcal{S} \otimes \mathcal{T}$ measurable function $f: X \times Y \rightarrow[0, \infty]$. Define a sequence $f_{1}, f_{2}, \ldots$ of simple $\mathcal{S} \otimes \mathcal{T}$-measurable functions from $X \times Y$ to $[0, \infty)$ by $f_{k}(x, y)= \begin{cases}\frac{m}{2^{k}} & \text { if } f(x, y)t\}) \leq \frac{\int_{X} f d \mu}{t} $$ for all $t>0$. Thus if $\int_{X} f d \mu<\infty$, then the result above should be considered to be somewhat stronger than Markov's inequality (because $\int_{(0, \infty)} \frac{1}{t} d \lambda(t)=\infty$ ). ## EXERCISES 5B 1 (a) Let $\lambda$ denote Lebesgue measure on $[0,1]$. Show that $$ \int_{[0,1]} \int_{[0,1]} \frac{x^{2}-y^{2}}{\left(x^{2}+y^{2}\right)^{2}} d \lambda(y) d \lambda(x)=\frac{\pi}{4} $$ and $$ \int_{[0,1]} \int_{[0,1]} \frac{x^{2}-y^{2}}{\left(x^{2}+y^{2}\right)^{2}} d \lambda(x) d \lambda(y)=-\frac{\pi}{4} $$ (b) Explain why (a) violates neither Tonelli's Theorem nor Fubini's Theorem. 2 (a) Give an example of a doubly indexed collection $\left\{x_{m, n}: m, n \in \mathbf{Z}^{+}\right\}$of real numbers such that $$ \sum_{m=1}^{\infty} \sum_{n=1}^{\infty} x_{m, n}=0 \quad \text { and } \quad \sum_{n=1}^{\infty} \sum_{m=1}^{\infty} x_{m, n}=\infty $$ (b) Explain why (a) violates neither Tonelli's Theorem nor Fubini's Theorem. 3 Suppose $(X, \mathcal{S})$ is a measurable space and $f: X \rightarrow[0, \infty]$ is a function. Let $\mathcal{B}$ denote the $\sigma$-algebra of Borel subsets of $(0, \infty)$. Prove that $U_{f} \in \mathcal{S} \otimes \mathcal{B}$ if and only if $f$ is an $\mathcal{S}$-measurable function. 4 Suppose $(X, \mathcal{S})$ is a measurable space and $f: X \rightarrow \mathbf{R}$ is a function. Let $\operatorname{graph}(f) \subset X \times \mathbf{R}$ denote the graph of $f:$ $$ \operatorname{graph}(f)=\{(x, f(x)): x \in X\} \text {. } $$ Let $\mathcal{B}$ denote the $\sigma$-algebra of Borel subsets of $\mathbf{R}$. Prove that graph $(f) \in \mathcal{S} \otimes \mathcal{B}$ if $f$ is an $\mathcal{S}$-measurable function. ## $5 C$ Lebesgue Integration on $\mathbf{R}^{n}$ Throughout this section, assume that $m$ and $n$ are positive integers. Thus, for example, 5.36 should include the hypothesis that $m$ and $n$ are positive integers, but theorems and definitions become easier to state without explicitly repeating this hypothesis. ## Borel Subsets of $\mathbf{R}^{n}$ We begin with a quick review of notation and key concepts concerning $\mathbf{R}^{n}$. Recall that $\mathbf{R}^{n}$ is the set of all $n$-tuples of real numbers: $$ \mathbf{R}^{n}=\left\{\left(x_{1}, \ldots, x_{n}\right): x_{1}, \ldots, x_{n} \in \mathbf{R}\right\} $$ The function $\|\cdot\|_{\infty}$ from $\mathbf{R}^{n}$ to $[0, \infty)$ is defined by $$ \left\|\left(x_{1}, \ldots, x_{n}\right)\right\|_{\infty}=\max \left\{\left|x_{1}\right|, \ldots,\left|x_{n}\right|\right\} $$ For $x \in \mathbf{R}^{n}$ and $\delta>0$, the open cube $B(x, \delta)$ with side length $2 \delta$ is defined by $$ B(x, \delta)=\left\{y \in \mathbf{R}^{n}:\|y-x\|_{\infty}<\delta\right\} . $$ If $n=1$, then an open cube is simply a bounded open interval. If $n=2$, then an open cube might more appropriately be called an open square. However, using the cube terminology for all dimensions has the advantage of not requiring a different word for different dimensions. A subset $G$ of $\mathbf{R}^{n}$ is called open if for every $x \in G$, there exists $\delta>0$ such that $B(x, \delta) \subset G$. Equivalently, a subset $G$ of $\mathbf{R}^{n}$ is called open if every element of $G$ is contained in an open cube that is contained in $G$. The union of every collection (finite or infinite) of open subsets of $\mathbf{R}^{n}$ is an open subset of $\mathbf{R}^{n}$. Also, the intersection of every finite collection of open subsets of $\mathbf{R}^{n}$ is an open subset of $\mathbf{R}^{n}$. A subset of $\mathbf{R}^{n}$ is called closed if its complement in $\mathbf{R}^{n}$ is open. A set $A \subset \mathbf{R}^{n}$ is called bounded if $\sup \left\{\|a\|_{\infty}: a \in A\right\}<\infty$. We adopt the following common convention: $$ \mathbf{R}^{m} \times \mathbf{R}^{n} \text { is identified with } \mathbf{R}^{m+n} \text {. } $$ To understand the necessity of this convention, note that $\mathbf{R}^{2} \times \mathbf{R} \neq \mathbf{R}^{3}$ because $\mathbf{R}^{2} \times \mathbf{R}$ and $\mathbf{R}^{3}$ contain different kinds of objects. Specifically, an element of $\mathbf{R}^{2} \times \mathbf{R}$ is an ordered pair, the first of which is an element of $\mathbf{R}^{2}$ and the second of which is an element of $\mathbf{R}$; thus an element of $\mathbf{R}^{2} \times \mathbf{R}$ looks like $\left(\left(x_{1}, x_{2}\right), x_{3}\right)$. An element of $\mathbf{R}^{3}$ is an ordered triple of real numbers that looks like $\left(x_{1}, x_{2}, x_{3}\right)$. However, we can identify $\left(\left(x_{1}, x_{2}\right), x_{3}\right)$ with $\left(x_{1}, x_{2}, x_{3}\right)$ in the obvious way. Thus we say that $\mathbf{R}^{2} \times \mathbf{R}$ "equals" $\mathbf{R}^{3}$. More generally, we make the natural identification of $\mathbf{R}^{m} \times \mathbf{R}^{n}$ with $\mathbf{R}^{m+n}$. To check that you understand the identification discussed above, make sure that you see why $B(x, \delta) \times B(y, \delta)=B((x, y), \delta)$ for all $x \in \mathbf{R}^{m}, y \in \mathbf{R}^{n}$, and $\delta>0$. We can now prove that the product of two open sets is an open set. ### 5.36 product of open sets is open Suppose $G_{1}$ is an open subset of $\mathbf{R}^{m}$ and $G_{2}$ is an open subset of $\mathbf{R}^{n}$. Then $G_{1} \times G_{2}$ is an open subset of $\mathbf{R}^{m+n}$. Proof Suppose $(x, y) \in G_{1} \times G_{2}$. Then there exists an open cube $D$ in $\mathbf{R}^{m}$ centered at $x$ and an open cube $E$ in $\mathbf{R}^{n}$ centered at $y$ such that $D \subset G_{1}$ and $E \subset G_{2}$. By reducing the size of either $D$ or $E$, we can assume that the cubes $D$ and $E$ have the same side length. Thus $D \times E$ is an open cube in $\mathbf{R}^{m+n}$ centered at $(x, y)$ that is contained in $G_{1} \times G_{2}$. We have shown that an arbitrary point in $G_{1} \times G_{2}$ is the center of an open cube contained in $G_{1} \times G_{2}$. Hence $G_{1} \times G_{2}$ is an open subset of $\mathbf{R}^{m+n}$. When $n=1$, the definition below of a Borel subset of $\mathbf{R}^{1}$ agrees with our previous definition (2.29) of a Borel subset of $\mathbf{R}$. ### 5.37 Definition Borel set; $\mathcal{B}_{n}$ - A Borel subset of $\mathbf{R}^{n}$ is an element of the smallest $\sigma$-algebra on $\mathbf{R}^{n}$ containing all open subsets of $\mathbf{R}^{n}$. - The $\sigma$-algebra of Borel subsets of $\mathbf{R}^{n}$ is denoted by $\mathcal{B}_{n}$. Recall that a subset of $\mathbf{R}$ is open if and only if it is a countable disjoint union of open intervals. Part (a) in the result below provides a similar result in $\mathbf{R}^{n}$, although we must give up the disjoint aspect. ### 5.38 open sets are countable unions of open cubes (a) A subset of $\mathbf{R}^{n}$ is open in $\mathbf{R}^{n}$ if and only if it is a countable union of open cubes in $\mathbf{R}^{n}$. (b) $\mathcal{B}_{n}$ is the smallest $\sigma$-algebra on $\mathbf{R}^{n}$ containing all the open cubes in $\mathbf{R}^{n}$. Proof We will prove (a), which clearly implies (b). The proof that a countable union of open cubes is open is left as an exercise for the reader (actually, arbitrary unions of open cubes are open). To prove the other direction, suppose $G$ is an open subset of $\mathbf{R}^{n}$. For each $x \in G$, there is an open cube centered at $x$ that is contained in $G$. Thus there is a smaller cube $C_{x}$ such that $x \in C_{x} \subset G$ and all coordinates of the center of $C_{x}$ are rational numbers and the side length of $C_{x}$ is a rational number. Now $$ G=\bigcup_{x \in G} C_{x} . $$ However, there are only countably many distinct cubes whose center has all rational coordinates and whose side length is rational. Thus $G$ is the countable union of open cubes. The next result tells us that the collection of Borel sets from various dimensions fit together nicely. ### 5.39 product of the Borel subsets of $\mathbf{R}^{m}$ and the Borel subsets of $\mathbf{R}^{n}$ $\mathcal{B}_{m} \otimes \mathcal{B}_{n}=\mathcal{B}_{m+n}$ Proof Suppose $E$ is an open cube in $\mathbf{R}^{m+n}$. Thus $E$ is the product of an open cube in $\mathbf{R}^{m}$ and an open cube in $\mathbf{R}^{n}$. Hence $E \in \mathcal{B}_{m} \otimes \mathcal{B}_{n}$. Thus the smallest $\sigma$-algebra containing all the open cubes in $\mathbf{R}^{m+n}$ is contained in $\mathcal{B}_{m} \otimes \mathcal{B}_{n}$. Now 5.38(b) implies that $\mathcal{B}_{m+n} \subset \mathcal{B}_{m} \otimes \mathcal{B}_{n}$. To prove the set inclusion in the other direction, temporarily fix an open set $G$ in $\mathbf{R}^{n}$. Let $$ \mathcal{E}=\left\{A \subset \mathbf{R}^{m}: A \times G \in \mathcal{B}_{m+n}\right\} . $$ Then $\mathcal{E}$ contains every open subset of $\mathbf{R}^{m}$ (as follows from 5.36). Also, $\mathcal{E}$ is closed under countable unions because $$ \left(\bigcup_{k=1}^{\infty} A_{k}\right) \times G=\bigcup_{k=1}^{\infty}\left(A_{k} \times G\right) $$ Furthermore, $\mathcal{E}$ is closed under complementation because $$ \left(\mathbf{R}^{m} \backslash A\right) \times G=\left(\left(\mathbf{R}^{m} \times \mathbf{R}^{n}\right) \backslash(A \times G)\right) \cap\left(\mathbf{R}^{m} \times G\right) $$ Thus $\mathcal{E}$ is a $\sigma$-algebra on $\mathbf{R}^{m}$ that contains all open subsets of $\mathbf{R}^{m}$, which implies that $\mathcal{B}_{m} \subset \mathcal{E}$. In other words, we have proved that if $A \in \mathcal{B}_{m}$ and $G$ is an open subset of $\mathbf{R}^{n}$, then $A \times G \in \mathcal{B}_{m+n}$. Now temporarily fix a Borel subset $A$ of $\mathbf{R}^{m}$. Let $$ \mathcal{F}=\left\{B \subset \mathbf{R}^{n}: A \times B \in \mathcal{B}_{m+n}\right\} $$ The conclusion of the previous paragraph shows that $\mathcal{F}$ contains every open subset of $\mathbf{R}^{n}$. As in the previous paragraph, we also see that $\mathcal{F}$ is a $\sigma$-algebra. Hence $\mathcal{B}_{n} \subset \mathcal{F}$. In other words, we have proved that if $A \in \mathcal{B}_{m}$ and $B \in \mathcal{B}_{n}$, then $A \times B \in \mathcal{B}_{m+n}$. Thus $\mathcal{B}_{m} \otimes \mathcal{B}_{n} \subset \mathcal{B}_{m+n}$, completing the proof. The previous result implies a nice associative property. Specifically, if $m, n$, and $p$ are positive integers, then two applications of 5.39 give $$ \left(\mathcal{B}_{m} \otimes \mathcal{B}_{n}\right) \otimes \mathcal{B}_{p}=\mathcal{B}_{m+n} \otimes \mathcal{B}_{p}=\mathcal{B}_{m+n+p} $$ Similarly, two more applications of 5.39 give $$ \mathcal{B}_{m} \otimes\left(\mathcal{B}_{n} \otimes \mathcal{B}_{p}\right)=\mathcal{B}_{m} \otimes \mathcal{B}_{n+p}=\mathcal{B}_{m+n+p} $$ Thus $\left(\mathcal{B}_{m} \otimes \mathcal{B}_{n}\right) \otimes \mathcal{B}_{p}=\mathcal{B}_{m} \otimes\left(\mathcal{B}_{n} \otimes \mathcal{B}_{p}\right)$; hence we can dispense with parentheses when taking products of more than two Borel $\sigma$-algebras. More generally, we could have defined $\mathcal{B}_{m} \otimes \mathcal{B}_{n} \otimes \mathcal{B}_{p}$ directly as the smallest $\sigma$-algebra on $\mathbf{R}^{m+n+p}$ containing $\left\{A \times B \times C: A \in \mathcal{B}_{m}, B \in \mathcal{B}_{n}, C \in \mathcal{B}_{p}\right\}$ and obtained the same $\sigma$-algebra (see Exercise 3 in this section). ## Lebesgue Measure on $\mathbf{R}^{n}$ ### 5.40 Definition Lebesgue measure; $\lambda_{n}$ Lebesgue measure on $\mathbf{R}^{n}$ is denoted by $\lambda_{n}$ and is defined inductively by $$ \lambda_{n}=\lambda_{n-1} \times \lambda_{1}, $$ where $\lambda_{1}$ is Lebesgue measure on $\left(\mathbf{R}, \mathcal{B}_{1}\right)$. Because $\mathcal{B}_{n}=\mathcal{B}_{n-1} \otimes \mathcal{B}_{1}$ (by 5.39), the measure $\lambda_{n}$ is defined on the Borel subsets of $\mathbf{R}^{n}$. Thinking of a typical point in $\mathbf{R}^{n}$ as $(x, y)$, where $x \in \mathbf{R}^{n-1}$ and $y \in \mathbf{R}$, we can use the definition of the product of two measures (5.25) to write $$ \lambda_{n}(E)=\int_{\mathbf{R}^{n-1}} \int_{\mathbf{R}} \chi_{E}(x, y) d \lambda_{1}(y) d \lambda_{n-1}(x) $$ for $E \in \mathcal{B}_{n}$. Of course, we could use Tonelli's Theorem (5.28) to interchange the order of integration in the equation above. Because Lebesgue measure is the most commonly used measure, mathematicians often dispense with explicitly displaying the measure and just use a variable name. In other words, if no measure is explicitly displayed in an integral and the context indicates no other measure, then you should assume that the measure involved is Lebesgue measure in the appropriate dimension. For example, the result of interchanging the order of integration in the equation above could be written as $$ \lambda_{n}(E)=\int_{\mathbf{R}} \int_{\mathbf{R}^{n-1}} \chi_{E}(x, y) d x d y $$ for $E \in \mathcal{B}_{n}$; here $d x$ means $d \lambda_{n-1}(x)$ and $d y$ means $d \lambda_{1}(y)$. In the equations above giving formulas for $\lambda_{n}(E)$, the integral over $\mathbf{R}^{n-1}$ could be rewritten as an iterated integral over $\mathbf{R}^{n-2}$ and $\mathbf{R}$, and that process could be repeated until reaching iterated integrals only over $\mathbf{R}$. Tonelli's Theorem could then be used repeatedly to swap the order of pairs of those integrated integrals, leading to iterated integrals in any order. Similar comments apply to integrating functions on $\mathbf{R}^{n}$ other than characteristic functions. For example, if $f: \mathbf{R}^{3} \rightarrow \mathbf{R}$ is a $\mathcal{B}_{3}$-measurable function such that either $f \geq 0$ or $\int_{\mathbf{R}^{3}}|f| d \lambda_{3}<\infty$, then by either Tonelli's Theorem or Fubini's Theorem we have $$ \int_{\mathbf{R}^{3}} f d \lambda_{3}=\int_{\mathbf{R}} \int_{\mathbf{R}} \int_{\mathbf{R}} f\left(x_{1}, x_{2}, x_{3}\right) d x_{j} d x_{k} d x_{m}, $$ where $j, k, m$ is any permutation of $1,2,3$. Although we defined $\lambda_{n}$ to be $\lambda_{n-1} \times \lambda_{1}$, we could have defined $\lambda_{n}$ to be $\lambda_{j} \times \lambda_{k}$ for any positive integers $j, k$ with $j+k=n$. This potentially different definition would have led to the same $\sigma$-algebra $\mathcal{B}_{n}$ (by 5.39) and to the same measure $\lambda_{n}$ [because both potential definitions of $\lambda_{n}(E)$ can be written as identical iterations of $n$ integrals with respect to $\lambda_{1}$ ]. ## Volume of Unit Ball in $\mathbf{R}^{n}$ The proof of the next result provides good experience in working with the Lebesgue measure $\lambda_{n}$. Recall that $t E=\{t x: x \in E\}$. ### 5.41 measure of a dilation Suppose $t>0$. If $E \in \mathcal{B}_{n}$, then $t E \in \mathcal{B}_{n}$ and $\lambda_{n}(t E)=t^{n} \lambda_{n}(E)$. Proof Let $$ \mathcal{E}=\left\{E \in \mathcal{B}_{n}: t E \in \mathcal{B}_{n}\right\} $$ Then $\mathcal{E}$ contains every open subset of $\mathbf{R}^{n}$ (because if $E$ is open in $\mathbf{R}^{n}$ then $t E$ is open in $\mathbf{R}^{n}$ ). Also, $\mathcal{E}$ is closed under complementation and countable unions because $$ t\left(\mathbf{R}^{n} \backslash E\right)=\mathbf{R}^{n} \backslash(t E) \text { and } t\left(\bigcup_{k=1}^{\infty} E_{k}\right)=\bigcup_{k=1}^{\infty}\left(t E_{k}\right) $$ Hence $\mathcal{E}$ is a $\sigma$-algebra on $\mathbf{R}^{n}$ containing the open subsets of $\mathbf{R}^{n}$. Thus $\mathcal{E}=\mathcal{B}_{n}$. In other words, $t E \in \mathcal{B}_{n}$ for all $E \in \mathcal{B}_{n}$. To prove $\lambda_{n}(t E)=t^{n} \lambda_{n}(E)$, first consider the case $n=1$. Lebesgue measure on $\mathbf{R}$ is a restriction of outer measure. The outer measure of a set is determined by the sum of the lengths of countable collections of intervals whose union contains the set. Multiplying the set by $t$ corresponds to multiplying each such interval by $t$, which multiplies the length of each such interval by $t$. In other words, $\lambda_{1}(t E)=t \lambda_{1}(E)$. Now assume $n>1$. We will use induction on $n$ and assume that the desired result holds for $n-1$. If $A \in \mathcal{B}_{n-1}$ and $B \in \mathcal{B}_{1}$, then 5.42 $$ \begin{aligned} \lambda_{n}(t(A \times B)) & =\lambda_{n}((t A) \times(t B)) \\ & =\lambda_{n-1}(t A) \cdot \lambda_{1}(t B) \\ & =t^{n-1} \lambda_{n-1}(A) \cdot t \lambda_{1}(B) \\ & =t^{n} \lambda_{n}(A \times B), \end{aligned} $$ giving the desired result for $A \times B$. For $m \in \mathbf{Z}^{+}$, let $C_{m}$ be the open cube in $\mathbf{R}^{n}$ centered at the origin and with side length $m$. Let $$ \mathcal{E}_{m}=\left\{E \in \mathcal{B}_{n}: E \subset C_{m} \text { and } \lambda_{n}(t E)=t^{n} \lambda_{n}(E)\right\} \text {. } $$ From 5.42 and using 5.13(b), we see that finite unions of measurable rectangles contained in $C_{m}$ are in $\mathcal{E}_{m}$. You should verify that $\mathcal{E}_{m}$ is closed under countable increasing unions (use 2.59) and countable decreasing intersections (use 2.60, whose finite measure condition holds because we are working inside $C_{m}$ ). From 5.13 and the Monotone Class Theorem (5.17), we conclude that $\mathcal{E}_{m}$ is the $\sigma$-algebra on $C_{m}$ consisting of Borel subsets of $C_{m}$. Thus $\lambda_{n}(t E)=t^{n} \lambda_{n}(E)$ for all $E \in \mathcal{B}_{n}$ such that $E \subset C_{m}$. Now suppose $E \in \mathcal{B}_{n}$. Then 2.59 implies that $$ \lambda_{n}(t E)=\lim _{m \rightarrow \infty} \lambda_{n}\left(t\left(E \cap C_{m}\right)\right)=t^{n} \lim _{m \rightarrow \infty} \lambda_{n}\left(E \cap C_{m}\right)=t^{n} \lambda_{n}(E) $$ as desired. 5.43 Definition open unit ball in $\mathbf{R}^{n} ; \mathbf{B}_{n}$ The open unit ball in $\mathbf{R}^{n}$ is denoted by $\mathbf{B}_{n}$ and is defined by $$ \mathbf{B}_{n}=\left\{\left(x_{1}, \ldots, x_{n}\right) \in \mathbf{R}^{n}: x_{1}{ }^{2}+\cdots+x_{n}{ }^{2}<1\right\} . $$ The open unit ball $\mathbf{B}_{n}$ is open in $\mathbf{R}^{n}$ (as you should verify) and thus is in the collection $\mathcal{B}_{n}$ of Borel sets. ### 5.44 volume of the unit ball in $\mathbf{R}^{n}$ $$ \lambda_{n}\left(\mathbf{B}_{n}\right)= \begin{cases}\frac{\pi^{n / 2}}{(n / 2) !} & \text { if } n \text { is even, } \\ \frac{2^{(n+1) / 2} \pi^{(n-1) / 2}}{1 \cdot 3 \cdot 5 \cdots \cdot n} & \text { if } n \text { is odd. }\end{cases} $$ Proof Because $\lambda_{1}\left(\mathbf{B}_{1}\right)=2$ and $\lambda_{2}\left(\mathbf{B}_{2}\right)=\pi$, the claimed formula is correct when $n=1$ and when $n=2$. Now assume that $n>2$. We will use induction on $n$, assuming that the claimed formula is true for smaller values of $n$. Think of $\mathbf{R}^{n}=\mathbf{R}^{2} \times \mathbf{R}^{n-2}$ and $\lambda_{n}=\lambda_{2} \times \lambda_{n-2}$. Then $$ \lambda_{n}\left(\mathbf{B}_{n}\right)=\int_{\mathbf{R}^{2}} \int_{\mathbf{R}^{n-2}} \chi_{\mathbf{B}_{n}}(x, y) d y d x $$ Temporarily fix $x=\left(x_{1}, x_{2}\right) \in \mathbf{R}^{2}$. If $x_{1}{ }^{2}+x_{2}{ }^{2} \geq 1$, then $\chi_{\mathbf{B}_{n}}(x, y)=0$ for all $y \in \mathbf{R}^{n-2}$. If $x_{1}{ }^{2}+x_{2}{ }^{2}<1$ and $y \in \mathbf{R}^{n-2}$, then $\chi_{\mathbf{B}_{n}}(x, y)=1$ if and only if $y \in\left(1-x_{1}{ }^{2}-x_{2}{ }^{2}\right)^{1 / 2} \mathbf{B}_{n-2}$. Thus the inner integral in 5.45 equals $$ \lambda_{n-2}\left(\left(1-x_{1}^{2}-x_{2}^{2}\right)^{1 / 2} \mathbf{B}_{n-2}\right) \chi_{\mathbf{B}_{2}}(x), $$ which by 5.41 equals $$ \left(1-x_{1}^{2}-x_{2}^{2}\right)^{(n-2) / 2} \lambda_{n-2}\left(\mathbf{B}_{n-2}\right) \chi_{\mathbf{B}_{2}}(x) . $$ Thus 5.45 becomes the equation $$ \lambda_{n}\left(\mathbf{B}_{n}\right)=\lambda_{n-2}\left(\mathbf{B}_{n-2}\right) \int_{\mathbf{B}_{2}}\left(1-x_{1}^{2}-x_{2}^{2}\right)^{(n-2) / 2} d \lambda_{2}\left(x_{1}, x_{2}\right) $$ To evaluate this integral, switch to the usual polar coordinates that you learned about in calculus $\left(d \lambda_{2}=r d r d \theta\right)$, getting $$ \begin{aligned} \lambda_{n}\left(\mathbf{B}_{n}\right) & =\lambda_{n-2}\left(\mathbf{B}_{n-2}\right) \int_{-\pi}^{\pi} \int_{0}^{1}\left(1-r^{2}\right)^{(n-2) / 2} r d r d \theta \\ & =\frac{2 \pi}{n} \lambda_{n-2}\left(\mathbf{B}_{n-2}\right) . \end{aligned} $$ The last equation and the induction hypothesis give the desired result. This table gives the first five values of $\lambda_{n}\left(\mathbf{B}_{n}\right)$, using 5.44. The last column of this table gives a decimal approximation to $\lambda_{n}\left(\mathbf{B}_{n}\right)$, accurate to two digits after the decimal point. From this table, you might guess that $\lambda_{n}\left(\mathbf{B}_{n}\right)$ is an increasing function of $n$, especially because the smallest cube containing the ball $\mathbf{B}_{n}$ has $n$ dimensional Lebesgue measure $2^{n}$. However, Exercise 12 in this section shows that $\lambda_{n}\left(\mathbf{B}_{n}\right)$ behaves much differently. | $n$ | $\lambda_{n}\left(\mathbf{B}_{n}\right)$ | $\approx \lambda_{n}\left(\mathbf{B}_{n}\right)$ | | :-: | :--------------------------------------: | :----------------------------------------------: | | 1 | 2 | 2.00 | | 2 | $\pi$ | 3.14 | | 3 | $4 \pi / 3$ | 4.19 | | 4 | $\pi^{2} / 2$ | 4.93 | | 5 | $8 \pi^{2} / 15$ | 5.26 | ## Equality of Mixed Partial Derivatives Via Fubini's Theorem 5.46 Definition partial derivatives; $D_{1} f$ and $D_{2} f$ Suppose $G$ is an open subset of $\mathbf{R}^{2}$ and $f: G \rightarrow \mathbf{R}$ is a function. For $(x, y) \in G$, the partial derivatives $\left(D_{1} f\right)(x, y)$ and $\left(D_{2} f\right)(x, y)$ are defined by $$ \left(D_{1} f\right)(x, y)=\lim _{t \rightarrow 0} \frac{f(x+t, y)-f(x, y)}{t} $$ and $$ \left(D_{2} f\right)(x, y)=\lim _{t \rightarrow 0} \frac{f(x, y+t)-f(x, y)}{t} $$ if these limits exist. Using the notation for the cross section of a function (see 5.7), we could write the definitions of $D_{1}$ and $D_{2}$ in the following form: $$ \left(D_{1} f\right)(x, y)=\left([f]^{y}\right)^{\prime}(x) \quad \text { and } \quad\left(D_{2} f\right)(x, y)=\left([f]_{x}\right)^{\prime}(y) $$ ### 5.47 Example partial derivatives of $x^{y}$ Let $G=\left\{(x, y) \in \mathbf{R}^{2}: x>0\right\}$ and define $f: G \rightarrow \mathbf{R}$ by $f(x, y)=x^{y}$. Then $$ \left(D_{1} f\right)(x, y)=y x^{y-1} \quad \text { and } \quad\left(D_{2} f\right)(x, y)=x^{y} \ln x $$ as you should verify. Taking partial derivatives of those partial derivatives, we have $$ \left(D_{2}\left(D_{1} f\right)\right)(x, y)=x^{y-1}+y x^{y-1} \ln x $$ and $$ \left(D_{1}\left(D_{2} f\right)\right)(x, y)=x^{y-1}+y x^{y-1} \ln x $$ as you should also verify. The last two equations show that $D_{1}\left(D_{2} f\right)=D_{2}\left(D_{1} f\right)$ as functions on $G$. In the example above, the two mixed partial derivatives turn out to equal each other, even though the intermediate results look quite different. The next result shows that the behavior in the example above is typical rather than a coincidence. Some proofs of the result below do not use Fubini's Theorem. However, Fubini's Theorem leads to the clean proof below. The integrals that appear in the proof below make sense because continuous real-valued functions on $\mathbf{R}^{2}$ are measurable (because for a continuous function, the inverse image of each open set is open) and because continuous real-valued func- Although the continuity hypotheses in the result below can be slightly weakened, they cannot be eliminated, as shown by Exercise 14 in this section. tions on closed bounded subsets of $\mathbf{R}^{2}$ are bounded. ### 5.48 equality of mixed partial derivatives Suppose $G$ is an open subset of $\mathbf{R}^{2}$ and $f: G \rightarrow \mathbf{R}$ is a function such that $D_{1} f$, $D_{2} f, D_{1}\left(D_{2} f\right)$, and $D_{2}\left(D_{1} f\right)$ all exist and are continuous functions on $G$. Then $$ D_{1}\left(D_{2} f\right)=D_{2}\left(D_{1} f\right) $$ on $G$. Proof Fix $(a, b) \in G$. For $\delta>0$, let $S_{\delta}=[a, a+\delta] \times[b, b+\delta]$. If $S_{\delta} \subset G$, then $$ \begin{aligned} \int_{S_{\delta}} D_{1}\left(D_{2} f\right) d \lambda_{2} & =\int_{b}^{b+\delta} \int_{a}^{a+\delta}\left(D_{1}\left(D_{2} f\right)\right)(x, y) d x d y \\ & =\int_{b}^{b+\delta}\left[\left(D_{2} f\right)(a+\delta, y)-\left(D_{2} f\right)(a, y)\right] d y \\ & =f(a+\delta, b+\delta)-f(a+\delta, b)-f(a, b+\delta)+f(a, b), \end{aligned} $$ where the first equality comes from Fubini's Theorem (5.32) and the second and third equalities come from the Fundamental Theorem of Calculus. A similar calculation of $\int_{S_{\delta}} D_{2}\left(D_{1} f\right) d \lambda_{2}$ yields the same result. Thus $$ \int_{S_{\delta}}\left[D_{1}\left(D_{2} f\right)-D_{2}\left(D_{1} f\right)\right] d \lambda_{2}=0 $$ for all $\delta$ such that $\mathcal{S}_{\delta} \subset G$. If $\left(D_{1}\left(D_{2} f\right)\right)(a, b)>\left(D_{2}\left(D_{1} f\right)\right)(a, b)$, then by the continuity of $D_{1}\left(D_{2} f\right)$ and $D_{2}\left(D_{1} f\right)$, the integrand in the equation above is positive on $S_{\delta}$ for $\delta$ sufficiently small, which contradicts the integral above equaling 0 . Similarly, the inequality $\left(D_{1}\left(D_{2} f\right)\right)(a, b)<\left(D_{2}\left(D_{1} f\right)\right)(a, b)$ also contradicts the equation above for small $\delta$. Thus we conclude that $$ \left(D_{1}\left(D_{2} f\right)\right)(a, b)=\left(D_{2}\left(D_{1} f\right)\right)(a, b), $$ as desired. ## EXERCISES 5C 1 Show that a set $G \subset \mathbf{R}^{n}$ is open in $\mathbf{R}^{n}$ if and only if for each $\left(b_{1}, \ldots, b_{n}\right) \in G$, there exists $r>0$ such that $$ \left\{\left(a_{1}, \ldots, a_{n}\right) \in \mathbf{R}^{n}: \sqrt{\left(a_{1}-b_{1}\right)^{2}+\cdots+\left(a_{n}-b_{n}\right)^{2}}0$. - The open ball centered at $f$ with radius $r$ is denoted $B(f, r)$ and is defined by $$ B(f, r)=\{g \in V: d(f, g)0$ such that $B(f, r) \subset G$. ## 6.5 open balls are open Suppose $V$ is a metric space, $f \in V$, and $r>0$. Then $B(f, r)$ is an open subset of $V$. Proof Suppose $g \in B(f, r)$. We need to show that an open ball centered at $g$ is contained in $B(f, r)$. To do this, note that if $h \in B(g, r-d(f, g))$, then $$ d(f, h) \leq d(f, g)+d(g, h)0\} . $$ Limits in a metric space are defined by reducing to the context of real numbers, where limits have already been defined. ### 6.8 Definition limit in metric space; $\lim _{k \rightarrow \infty} f_{k}$ Suppose $(V, d)$ is a metric space, $f_{1}, f_{2}, \ldots$ is a sequence in $V$, and $f \in V$. Then $$ \lim _{k \rightarrow \infty} f_{k}=f \text { means } \lim _{k \rightarrow \infty} d\left(f_{k}, f\right)=0 \text {. } $$ In other words, a sequence $f_{1}, f_{2}, \ldots$ in $V$ converges to $f \in V$ if for every $\varepsilon>0$, there exists $n \in \mathbf{Z}^{+}$such that $$ d\left(f_{k}, f\right)<\varepsilon \text { for all integers } k \geq n \text {. } $$ The next result states that the closure of a set is the collection of all limits of elements of the set. Also, a set is closed if and only if it equals its closure. The proof of the next result is left as an exercise that provides good practice in using these concepts. ## 6.9 closure Suppose $V$ is a metric space and $E \subset V$. Then (a) $\bar{E}=\left\{g \in V\right.$ : there exist $f_{1}, f_{2}, \ldots$ in $E$ such that $\left.\lim _{k \rightarrow \infty} f_{k}=g\right\}$; (b) $\bar{E}$ is the intersection of all closed subsets of $V$ that contain $E$; (c) $\bar{E}$ is a closed subset of $V$; (d) $E$ is closed if and only if $\bar{E}=E$; (e) $E$ is closed if and only if $E$ contains the limit of every convergent sequence of elements of $E$. The definition of continuity that follows uses the same pattern as the definition for a function from a subset of $\mathbf{R}$ to $\mathbf{R}$. ### 6.10 Definition continuous Suppose $\left(V, d_{V}\right)$ and $\left(W, d_{W}\right)$ are metric spaces and $T: V \rightarrow W$ is a function. - For $f \in V$, the function $T$ is called continuous at $f$ if for every $\varepsilon>0$, there exists $\delta>0$ such that $$ d_{W}(T(f), T(g))<\varepsilon $$ for all $g \in V$ with $d_{V}(f, g)<\delta$. - The function $T$ is called continuous if $T$ is continuous at $f$ for every $f \in V$. The next result gives equivalent conditions for continuity. Recall that $T^{-1}(E)$ is called the inverse image of $E$ and is defined to be $\{f \in V: T(f) \in E\}$. Thus the equivalence of the (a) and (c) below could be restated as saying that a function is continuous if and only if the inverse image of every open set is open. The equivalence of the (a) and (d) below could be restated as saying that a function is continuous if and only if the inverse image of every closed set is closed. ### 6.11 equivalent conditions for continuity Suppose $V$ and $W$ are metric spaces and $T: V \rightarrow W$ is a function. Then the following are equivalent: (a) $T$ is continuous. (b) $\lim _{k \rightarrow \infty} f_{k}=f$ in $V$ implies $\lim _{k \rightarrow \infty} T\left(f_{k}\right)=T(f)$ in $W$. (c) $T^{-1}(G)$ is an open subset of $V$ for every open set $G \subset W$. (d) $T^{-1}(F)$ is a closed subset of $V$ for every closed set $F \subset W$. Proof We first prove that (b) implies (d). Suppose (b) holds. Suppose $F$ is a closed subset of $W$. We need to prove that $T^{-1}(F)$ is closed. To do this, suppose $f_{1}, f_{2}, \ldots$ is a sequence in $T^{-1}(F)$ and $\lim _{k \rightarrow \infty} f_{k}=f$ for some $f \in V$. Because (b) holds, we know that $\lim _{k \rightarrow \infty} T\left(f_{k}\right)=T(f)$. Because $f_{k} \in T^{-1}(F)$ for each $k \in \mathbf{Z}^{+}$, we know that $T\left(f_{k}\right) \in F$ for each $k \in \mathbf{Z}^{+}$. Because $F$ is closed, this implies that $T(f) \in F$. Thus $f \in T^{-1}(F)$, which implies that $T^{-1}(F)$ is closed [by 6.9(e)], completing the proof that (b) implies (d). The proof that (c) and (d) are equivalent follows from the equation $$ T^{-1}(W \backslash E)=V \backslash T^{-1}(E) $$ for every $E \subset W$ and the fact that a set is open if and only if its complement (in the appropriate metric space) is closed. The proof of the remaining parts of this result are left as an exercise that should help strengthen your understanding of these concepts. ## Cauchy Sequences and Completeness The next definition is useful for showing (in some metric spaces) that a sequence has a limit, even when we do not have a good candidate for that limit. ### 6.12 Definition Cauchy sequence A sequence $f_{1}, f_{2}, \ldots$ in a metric space $(V, d)$ is called a Cauchy sequence if for every $\varepsilon>0$, there exists $n \in \mathbf{Z}^{+}$such that $d\left(f_{j}, f_{k}\right)<\varepsilon$ for all integers $j \geq n$ and $k \geq n$. ### 6.13 every convergent sequence is a Cauchy sequence Every convergent sequence in a metric space is a Cauchy sequence. Proof Suppose $\lim _{k \rightarrow \infty} f_{k}=f$ in a metric space $(V, d)$. Suppose $\varepsilon>0$. Then there exists $n \in \mathbf{Z}^{+}$such that $d\left(f_{k}, f\right)<\frac{\varepsilon}{2}$ for all $k \geq n$. If $j, k \in \mathbf{Z}^{+}$are such that $j \geq n$ and $k \geq n$, then $$ d\left(f_{j}, f_{k}\right) \leq d\left(f_{j}, f\right)+d\left(f, f_{k}\right)<\frac{\varepsilon}{2}+\frac{\varepsilon}{2}=\varepsilon . $$ Thus $f_{1}, f_{2}, \ldots$ is a Cauchy sequence, completing the proof. Metric spaces that satisfy the converse of the result above have a special name. ### 6.14 Definition complete metric space A metric space $V$ is called complete if every Cauchy sequence in $V$ converges to some element of $V$. ### 6.15 Example - All five of the metric spaces in Example 6.2 are complete, as you should verify. - The metric space $\mathbf{Q}$, with metric defined by $d(x, y)=|x-y|$, is not complete. To see this, for $k \in \mathbf{Z}^{+}$let $$ x_{k}=\frac{1}{10^{1 !}}+\frac{1}{10^{2 !}}+\cdots+\frac{1}{10^{k !}} \text {. } $$ If $j0$, then $\overline{B(f, r)} \subset \bar{B}(f, r)$. (b) Give an example of a metric space $V, f \in V$, and $r>0$ such that $\overline{B(f, r)} \neq \bar{B}(f, r)$. 7 Show that each sequence in a metric space has at most one limit. 8 Prove 6.9. 9 Prove that each open subset of a metric space $V$ is the union of some sequence of closed subsets of $V$. 10 Prove or give a counterexample: If $V$ is a metric space and $U, W$ are subsets of $V$, then $\bar{U} \cup \bar{W}=\overline{U \cup W}$. 11 Prove or give a counterexample: If $V$ is a metric space and $U, W$ are subsets of $V$, then $\bar{U} \cap \bar{W}=\overline{U \cap W}$. 12 Suppose $\left(U, d_{U}\right),\left(V, d_{V}\right)$, and $\left(W, d_{W}\right)$ are metric spaces. Suppose also that $T: U \rightarrow V$ and $S: V \rightarrow W$ are continuous functions. (a) Using the definition of continuity, show that $S \circ T: U \rightarrow W$ is continuous. (b) Using the equivalence of 6.11(a) and 6.11(b), show that $S \circ T: U \rightarrow W$ is continuous. (c) Using the equivalence of 6.11(a) and 6.11(c), show that $S \circ T: U \rightarrow W$ is continuous. 13 Prove the parts of 6.11 that were not proved in the text. 14 Suppose a Cauchy sequence in a metric space has a convergent subsequence. Prove that the Cauchy sequence converges. 15 Verify that all five of the metric spaces in Example 6.2 are complete metric spaces. 16 Suppose $(U, d)$ is a metric space. Let $W$ denote the set of all Cauchy sequences of elements of $U$. (a) For $\left(f_{1}, f_{2}, \ldots\right)$ and $\left(g_{1}, g_{2}, \ldots\right)$ in $W$, define $\left(f_{1}, f_{2}, \ldots\right) \equiv\left(g_{1}, g_{2}, \ldots\right)$ to mean that $$ \lim _{k \rightarrow \infty} d\left(f_{k}, g_{k}\right)=0 $$ Show that $\equiv$ is an equivalence relation on $W$. (b) Let $V$ denote the set of equivalence classes of elements of $W$ under the equivalence relation above. For $\left(f_{1}, f_{2}, \ldots\right) \in W$, let $\left(f_{1}, f_{2}, \ldots\right)^{\wedge}$ denote the equivalence class of $\left(f_{1}, f_{2}, \ldots\right)$. Define $d_{V}: V \times V \rightarrow[0, \infty)$ by $$ d_{V}\left(\left(f_{1}, f_{2}, \ldots\right)^{\wedge},\left(g_{1}, g_{2}, \ldots\right)^{\wedge}\right)=\lim _{k \rightarrow \infty} d\left(f_{k}, g_{k}\right) . $$ Show that this definition of $d_{V}$ makes sense and that $d_{V}$ is a metric on $V$. (c) Show that $\left(V, d_{V}\right)$ is a complete metric space. (d) Show that the map from $U$ to $V$ that takes $f \in U$ to $(f, f, f, \ldots)$ preserves distances, meaning that $$ d(f, g)=d_{V}\left((f, f, f, \ldots)^{\wedge},(g, g, g, \ldots)^{\wedge}\right) $$ for all $f, g \in U$. (e) Explain why (d) shows that every metric space is a subset of some complete metric space. ## 6B Vector Spaces ## Integration of Complex-Valued Functions Complex numbers were invented so that we can take square roots of negative numbers. The idea is to assume we have a square root of -1 , denoted $i$, that obeys the usual rules of arithmetic. Here are the formal definitions: ### 6.17 Definition complex numbers; C; addition and multiplication in C - A complex number is an ordered pair $(a, b)$, where $a, b \in \mathbf{R}$, but we write this as $a+b i$ or $a+i b$. - The set of all complex numbers is denoted by $\mathbf{C}$ : $$ \mathbf{C}=\{a+b i: a, b \in \mathbf{R}\} . $$ - Addition and multiplication in $\mathbf{C}$ are defined by $$ \begin{gathered} (a+b i)+(c+d i)=(a+c)+(b+d) i \\ (a+b i)(c+d i)=(a c-b d)+(a d+b c) i \end{gathered} $$ here $a, b, c, d \in \mathbf{R}$. If $a \in \mathbf{R}$, then we identify $a+0 i$ with $a$. Thus we think of $\mathbf{R}$ as a subset of C. We also usually write $0+b i$ as $b i$, and we usually write $0+1 i$ as $i$. You should verify that $i^{2}=-1$. With the definitions as above, $\mathbf{C}$ satisfies the usual rules of arithmetic. Specifically, with addition and multiplication defined as above, $\mathbf{C}$ is a field, as you should verify. Thus subtraction and division of complex numbers are defined as in any field. The field $\mathbf{C}$ cannot be made into an ordered field. However, the useful concept of an absolute value can still be defined The symbol $i$ was first used to denote $\sqrt{-1}$ by Leonhard Euler (1707-1783) in 1777. on $\mathbf{C}$. 6.18 Definition real part; $\operatorname{Re} z$; imaginary part; $\operatorname{Im} z ;$ absolute value; limits Suppose $z=a+b i$, where $a$ and $b$ are real numbers. - The real part of $z$, denoted $\operatorname{Re} z$, is defined by $\operatorname{Re} z=a$. - The imaginary part of $z$, denoted $\operatorname{Im} z$, is defined by $\operatorname{Im} z=b$. - The absolute value of $z$, denoted $|z|$, is defined by $|z|=\sqrt{a^{2}+b^{2}}$. - If $z_{1}, z_{2}, \ldots \in \mathbf{C}$ and $L \in \mathbf{C}$, then $\lim _{k \rightarrow \infty} z_{k}=L$ means $\lim _{k \rightarrow \infty}\left|z_{k}-L\right|=0$. For $b$ a real number, the usual definition of $|b|$ as a real number is consistent with the new definition just given of $|b|$ with $b$ thought of as a complex number. Note that if $z_{1}, z_{2}, \ldots$ is a sequence of complex numbers and $L \in \mathbf{C}$, then $$ \lim _{k \rightarrow \infty} z_{k}=L \Longleftrightarrow \lim _{k \rightarrow \infty} \operatorname{Re} z_{k}=\operatorname{Re} L \text { and } \lim _{k \rightarrow \infty} \operatorname{Im} z_{k}=\operatorname{Im} L . $$ We will reduce questions concerning measurability and integration of a complexvalued function to the corresponding questions about the real and imaginary parts of the function. We begin this process with the following definition. ### 6.19 Definition measurable complex-valued function Suppose $(X, \mathcal{S})$ is a measurable space. A function $f: X \rightarrow \mathrm{C}$ is called $\mathcal{S}$-measurable if $\operatorname{Re} f$ and $\operatorname{Im} f$ are both $\mathcal{S}$-measurable functions. See Exercise 5 in this section for two natural conditions that are equivalent to measurability for complex-valued functions. We will make frequent use of the following result. See Exercise 6 in this section for algebraic combinations of complex-valued measurable functions. ## $6.20|f|^{p}$ is measurable if $f$ is measurable Suppose $(X, \mathcal{S})$ is a measurable space, $f: X \rightarrow \mathbf{C}$ is an $\mathcal{S}$-measurable function, and $01$ then $\|\cdot\|_{1 / 2}$ is not a norm on $\mathbf{F}^{n}$ because the triangle inequality is not satisfied (as you should verify). The next result shows that every normed vector space is also a metric space in a natural fashion. ### 6.36 normed vector spaces are metric spaces Suppose $(V,\|\cdot\|)$ is a normed vector space. Define $d: V \times V \rightarrow[0, \infty)$ by $$ d(f, g)=\|f-g\| \text {. } $$ Then $d$ is a metric on $V$. Proof Suppose $f, g, h \in V$. Then $$ \begin{aligned} d(f, h)=\|f-h\| & =\|(f-g)+(g-h)\| \\ & \leq\|f-g\|+\|g-h\| \\ & =d(f, g)+d(g, h) . \end{aligned} $$ Thus the triangle inequality requirement for a metric is satisfied. The verification of the other required properties for a metric are left to the reader. From now on, all metric space notions in the context of a normed vector space should be interpreted with respect to the metric introduced in the previous result. However, usually there is no need to introduce the metric $d$ explicitly—just use the norm of the difference of two elements. For example, suppose $(V,\|\cdot\|)$ is a normed vector space, $f_{1}, f_{2}, \ldots$ is a sequence in $V$, and $f \in V$. Then in the context of a normed vector space, the definition of limit (6.8) becomes the following statement: $$ \lim _{k \rightarrow \infty} f_{k}=f \text { means } \lim _{k \rightarrow \infty}\left\|f_{k}-f\right\|=0 $$ As another example, in the context of a normed vector space, the definition of a Cauchy sequence (6.12) becomes the following statement: A sequence $f_{1}, f_{2}, \ldots$ in a normed vector space $(V,\|\cdot\|)$ is a Cauchy sequence if for every $\varepsilon>0$, there exists $n \in \mathbf{Z}^{+}$such that $\left\|f_{j}-f_{k}\right\|<\varepsilon$ for all integers $j \geq n$ and $k \geq n$. Every sequence in a normed vector space that has a limit is a Cauchy sequence (see 6.13). Normed vector spaces that satisfy the converse have a special name. ### 6.37 Definition Banach space A complete normed vector space is called a Banach space. In other words, a normed vector space $V$ is a Banach space if every Cauchy sequence in $V$ converges to some element of $V$. The verifications of the assertions in Examples 6.38 and 6.39 below are left to the reader as exercises. In a slight abuse of terminology, we often refer to a normed vector space $V$ without mentioning the norm $\|\cdot\|$. When that happens, you should assume that a norm $\|\cdot\|$ lurks nearby, even if it is not explicitly displayed. ### 6.38 Example Banach spaces - The vector space $C([0,1])$ with the norm defined by $\|f\|=\sup |f|$ is a Banach space. - The vector space $\ell^{1}$ with the norm defined by $\left\|\left(a_{1}, a_{2}, \ldots\right)\right\|_{1}=\sum_{k=1}^{\infty}\left|a_{k}\right|$ is a Banach space. ### 6.39 Example not a Banach space - The vector space $C([0,1])$ with the norm defined by $\|f\|=\int_{0}^{1}|f|$ is not a Banach space. - The vector space $\ell^{1}$ with the norm defined by $\left\|\left(a_{1}, a_{2}, \ldots\right)\right\|_{\infty}=\sup \left|a_{k}\right|$ is not a Banach space. ### 6.40 Definition infinite sum in a normed vector space Suppose $g_{1}, g_{2}, \ldots$ is a sequence in a normed vector space $V$. Then $\sum_{k=1}^{\infty} g_{k}$ is defined by $$ \sum_{k=1}^{\infty} g_{k}=\lim _{n \rightarrow \infty} \sum_{k=1}^{n} g_{k} $$ if this limit exists, in which case the infinite series is said to converge. Recall from your calculus course that if $a_{1}, a_{2}, \ldots$ is a sequence of real numbers such that $\sum_{k=1}^{\infty}\left|a_{k}\right|<\infty$, then $\sum_{k=1}^{\infty} a_{k}$ converges. The next result states that the analogous property for normed vector spaces characterizes Banach spaces. $6.41\left(\sum_{k=1}^{\infty}\left\|g_{k}\right\|<\infty \Longrightarrow \sum_{k=1}^{\infty} g_{k}\right.$ converges $) \Longleftrightarrow$ Banach space Suppose $V$ is a normed vector space. Then $V$ is a Banach space if and only if $\sum_{k=1}^{\infty} g_{k}$ converges for every sequence $g_{1}, g_{2}, \ldots$ in $V$ such that $\sum_{k=1}^{\infty}\left\|g_{k}\right\|<\infty$. Proof First suppose $V$ is a Banach space. Suppose $g_{1}, g_{2}, \ldots$ is a sequence in $V$ such that $\sum_{k=1}^{\infty}\left\|g_{k}\right\|<\infty$. Suppose $\varepsilon>0$. Let $n \in \mathbf{Z}^{+}$be such that $\sum_{m=n}^{\infty}\left\|g_{m}\right\|<\varepsilon$. For $j \in \mathbf{Z}^{+}$, let $f_{j}$ denote the partial sum defined by $$ f_{j}=g_{1}+\cdots+g_{j} . $$ If $k>j \geq n$, then $$ \begin{aligned} \left\|f_{k}-f_{j}\right\| & =\left\|g_{j+1}+\cdots+g_{k}\right\| \\ & \leq\left\|g_{j+1}\right\|+\cdots+\left\|g_{k}\right\| \\ & \leq \sum_{m=n}^{\infty}\left\|g_{m}\right\| \\ & <\varepsilon . \end{aligned} $$ Thus $f_{1}, f_{2}, \ldots$ is a Cauchy sequence in $V$. Because $V$ is a Banach space, we conclude that $f_{1}, f_{2}, \ldots$ converges to some element of $V$, which is precisely what it means for $\sum_{k=1}^{\infty} g_{k}$ to converge, completing one direction of the proof. To prove the other direction, suppose $\sum_{k=1}^{\infty} g_{k}$ converges for every sequence $g_{1}, g_{2}, \ldots$ in $V$ such that $\sum_{k=1}^{\infty}\left\|g_{k}\right\|<\infty$. Suppose $f_{1}, f_{2}, \ldots$ is a Cauchy sequence in $V$. We want to prove that $f_{1}, f_{2}, \ldots$ converges to some element of $V$. It suffices to show that some subsequence of $f_{1}, f_{2}, \ldots$ converges (by Exercise 14 in Section 6A). Dropping to a subsequence (but not relabeling) and setting $f_{0}=0$, we can assume that $$ \sum_{k=1}^{\infty}\left\|f_{k}-f_{k-1}\right\|<\infty $$ Hence $\sum_{k=1}^{\infty}\left(f_{k}-f_{k-1}\right)$ converges. The partial sum of this series after $n$ terms is $f_{n}$. Thus $\lim _{n \rightarrow \infty} f_{n}$ exists, completing the proof. ## Bounded Linear Maps When dealing with two or more vector spaces, as in the definition below, assume that the vector spaces are over the same field (either $\mathbf{R}$ or $\mathbf{C}$, but denoted in this book as $\mathbf{F}$ to give us the flexibility to consider both cases). The notation $T f$, in addition to the standard functional notation $T(f)$, is often used when considering linear maps, which we now define. ### 6.42 Definition linear map Suppose $V$ and $W$ are vector spaces. A function $T: V \rightarrow W$ is called linear if - $T(f+g)=T f+T g$ for all $f, g \in V$; - $T(\alpha f)=\alpha T f$ for all $\alpha \in \mathbf{F}$ and $f \in V$. A linear function is often called a linear map. The set of linear maps from a vector space $V$ to a vector space $W$ is itself a vector space, using the usual operations of addition and scalar multiplication of functions. Most attention in analysis focuses on the subset of bounded linear functions, defined below, which we will see is itself a normed vector space. In the next definition, we have two normed vector spaces, $V$ and $W$, which may have different norms. However, we use the same notation $\|\cdot\|$ for both norms (and for the norm of a linear map from $V$ to $W$ ) because the context makes the meaning clear. For example, in the definition below, $f$ is in $V$ and thus $\|f\|$ refers to the norm in $V$. Similarly, $T f \in W$ and thus $\|T f\|$ refers to the norm in $W$. 6.43 Definition bounded linear map; $\|T\| ; \mathcal{B}(V, W)$ Suppose $V$ and $W$ are normed vector spaces and $T: V \rightarrow W$ is a linear map. - The norm of $T$, denoted $\|T\|$, is defined by $$ \|T\|=\sup \{\|T f\|: f \in V \text { and }\|f\| \leq 1\} . $$ - $T$ is called bounded if $\|T\|<\infty$. - The set of bounded linear maps from $V$ to $W$ is denoted $\mathcal{B}(V, W)$. ### 6.44 Example bounded linear map Let $C([0,3])$ be the normed vector space of continuous functions from $[0,3]$ to $\mathbf{F}$, with $\|f\|=\sup |f|$. Define $T: C([0,3]) \rightarrow C([0,3])$ by $$ (T f)(x)=x^{2} f(x) . $$ Then $T$ is a bounded linear map and $\|T\|=9$, as you should verify. ### 6.45 Example linear map that is not bounded Let $V$ be the normed vector space of sequences $\left(a_{1}, a_{2}, \ldots\right)$ of elements of $\mathbf{F}$ such that $a_{k}=0$ for all but finitely many $k \in \mathbf{Z}^{+}$, with $\left\|\left(a_{1}, a_{2}, \ldots\right)\right\|_{\infty}=\max _{k \in \mathbf{Z}^{+}}\left|a_{k}\right|$. Define $T: V \rightarrow V$ by $$ T\left(a_{1}, a_{2}, a_{3}, \ldots\right)=\left(a_{1}, 2 a_{2}, 3 a_{3}, \ldots\right) . $$ Then $T$ is a linear map that is not bounded, as you should verify. The next result shows that if $V$ and $W$ are normed vector spaces, then $\mathcal{B}(V, W)$ is a normed vector space with the norm defined above. ## $6.46\|\cdot\|$ is a norm on $\mathcal{B}(V, W)$ Suppose $V$ and $W$ are normed vector spaces. Then $\|S+T\| \leq\|S\|+\|T\|$ and $\|\alpha T\|=|\alpha|\|T\|$ for all $S, T \in \mathcal{B}(V, W)$ and all $\alpha \in \mathbf{F}$. Furthermore, the function $\|\cdot\|$ is a norm on $\mathcal{B}(V, W)$. Proof Suppose $S, T \in \mathcal{B}(V, W)$. Then $$ \begin{aligned} \|S+T\|= & \sup \{\|(S+T) f\|: f \in V \text { and }\|f\| \leq 1\} \\ \leq & \sup \{\|S f\|+\|T f\|: f \in V \text { and }\|f\| \leq 1\} \\ \leq & \sup \{\|S f\|: f \in V \text { and }\|f\| \leq 1\} \\ & \quad \quad+\sup \{\|T f\|: f \in V \text { and }\|f\| \leq 1\} \\ & =\|S\|+\|T\| . \end{aligned} $$ The inequality above shows that $\|\cdot\|$ satisfies the triangle inequality on $\mathcal{B}(V, W)$. The verification of the other properties required for a normed vector space is left to the reader. Be sure that you are comfortable using all four equivalent formulas for $\|T\|$ shown in Exercise 16. For example, you should often think of $\|T\|$ as the smallest number such that $\|T f\| \leq\|T\|\|f\|$ for all $f$ in the domain of $T$. Note that in the next result, the hypothesis requires $W$ to be a Banach space but there is no requirement for $V$ to be a Banach space. ### 6.47 $\mathcal{B}(V, W)$ is a Banach space if $W$ is a Banach space Suppose $V$ is a normed vector space and $W$ is a Banach space. Then $\mathcal{B}(V, W)$ is a Banach space. Proof Suppose $T_{1}, T_{2}, \ldots$ is a Cauchy sequence in $\mathcal{B}(V, W)$. If $f \in V$, then $$ \left\|T_{j} f-T_{k} f\right\| \leq\left\|T_{j}-T_{k}\right\|\|f\|, $$ which implies that $T_{1} f, T_{2} f, \ldots$ is a Cauchy sequence in $W$. Because $W$ is a Banach space, this implies that $T_{1} f, T_{2} f, \ldots$ has a limit in $W$, which we call $T f$. We have now defined a function $T: V \rightarrow W$. The reader should verify that $T$ is a linear map. Clearly $$ \begin{aligned} \|T f\| & \leq \sup \left\{\left\|T_{k} f\right\|: k \in \mathbf{Z}^{+}\right\} \\ & \leq\left(\sup \left\{\left\|T_{k}\right\|: k \in \mathbf{Z}^{+}\right\}\right)\|f\| \end{aligned} $$ for each $f \in V$. The last supremum above is finite because every Cauchy sequence is bounded (see Exercise 4). Thus $T \in \mathcal{B}(V, W)$. We still need to show that $\lim _{k \rightarrow \infty}\left\|T_{k}-T\right\|=0$. To do this, suppose $\varepsilon>0$. Let $n \in \mathbf{Z}^{+}$be such that $\left\|T_{j}-T_{k}\right\|<\varepsilon$ for all $j \geq n$ and $k \geq n$. Suppose $j \geq n$ and suppose $f \in V$. Then $$ \begin{aligned} \left\|\left(T_{j}-T\right) f\right\| & =\lim _{k \rightarrow \infty}\left\|T_{j} f-T_{k} f\right\| \\ & \leq \varepsilon\|f\| . \end{aligned} $$ Thus $\left\|T_{j}-T\right\| \leq \varepsilon$, completing the proof. The next result shows that the phrase bounded linear map means the same as the phrase continuous linear map. 6.48 continuity is equivalent to boundedness for linear maps A linear map from one normed vector space to another normed vector space is continuous if and only if it is bounded. Proof Suppose $V$ and $W$ are normed vector spaces and $T: V \rightarrow W$ is linear. First suppose $T$ is not bounded. Thus there exists a sequence $f_{1}, f_{2}, \ldots$ in $V$ such that $\left\|f_{k}\right\| \leq 1$ for each $k \in \mathbf{Z}^{+}$and $\left\|T f_{k}\right\| \rightarrow \infty$ as $k \rightarrow \infty$. Hence $$ \lim _{k \rightarrow \infty} \frac{f_{k}}{\left\|T f_{k}\right\|}=0 \quad \text { and } \quad T\left(\frac{f_{k}}{\left\|T f_{k}\right\|}\right)=\frac{T f_{k}}{\left\|T f_{k}\right\|} \not \rightarrow 0 $$ where the nonconvergence to 0 holds because $T f_{k} /\left\|T f_{k}\right\|$ has norm 1 for every $k \in \mathbf{Z}^{+}$. The displayed line above implies that $T$ is not continuous, completing the proof in one direction. To prove the other direction, now suppose $T$ is bounded. Suppose $f \in V$ and $f_{1}, f_{2}, \ldots$ is a sequence in $V$ such that $\lim _{k \rightarrow \infty} f_{k}=f$. Then $$ \begin{aligned} \left\|T f_{k}-T f\right\| & =\left\|T\left(f_{k}-f\right)\right\| \\ & \leq\|T\|\left\|f_{k}-f\right\| . \end{aligned} $$ Thus $\lim _{k \rightarrow \infty} T f_{k}=T f$. Hence $T$ is continuous, completing the proof in the other direction. Exercise 18 gives several additional equivalent conditions for a linear map to be continuous. ## EXERCISES 6C 1 Show that the map $f \mapsto\|f\|$ from a normed vector space $V$ to $\mathbf{F}$ is continuous (where the norm on $\mathbf{F}$ is the usual absolute value). 2 Prove that if $V$ is a normed vector space, $f \in V$, and $r>0$, then $$ \overline{B(f, r)}=\bar{B}(f, r) . $$ 3 Show that the functions defined in the last two bullet points of Example 6.35 are not norms. 4 Prove that each Cauchy sequence in a normed vector space is bounded (meaning that there is a real number that is greater than the norm of every element in the Cauchy sequence). 5 Show that if $n \in \mathbf{Z}^{+}$, then $\mathbf{F}^{n}$ is a Banach space with both the norms used in the first bullet point of Example 6.34. 6 Suppose $X$ is a nonempty set and $b(X)$ is the vector space of bounded functions from $X$ to $\mathbf{F}$. Prove that if $\|\cdot\|$ is defined on $b(X)$ by $\|f\|=\sup _{X}|f|$, then $b(X)$ is a Banach space. 7 Show that $\ell^{1}$ with the norm defined by $\left\|\left(a_{1}, a_{2}, \ldots\right)\right\|_{\infty}=\sup \left|a_{k}\right|$ is not a Banach space. 8 Show that $\ell^{1}$ with the norm defined by $\left\|\left(a_{1}, a_{2}, \ldots\right)\right\|_{1}=\sum_{k=1}^{\infty}\left|a_{k}\right|$ is a Banach space. 9 Show that the vector space $C([0,1])$ of continuous functions from $[0,1]$ to $\mathbf{F}$ with the norm defined by $\|f\|=\int_{0}^{1}|f|$ is not a Banach space. 10 Suppose $U$ is a subspace of a normed vector space $V$ such that some open ball of $V$ is contained in $U$. Prove that $U=V$. 11 Prove that the only subsets of a normed vector space $V$ that are both open and closed are $\varnothing$ and $V$. 12 Suppose $V$ is a normed vector space. Prove that the closure of each subspace of $V$ is a subspace of $V$. 13 Suppose $U$ is a normed vector space. Let $d$ be the metric on $U$ defined by $d(f, g)=\|f-g\|$ for $f, g \in U$. Let $V$ be the complete metric space constructed in Exercise 16 in Section 6A. (a) Show that the set $V$ is a vector space under natural operations of addition and scalar multiplication. (b) Show that there is a natural way to make $V$ into a normed vector space and that with this norm, $V$ is a Banach space. (c) Explain why (b) shows that every normed vector space is a subspace of some Banach space. 14 Suppose $U$ is a subspace of a normed vector space $V$. Suppose also that $W$ is a Banach space and $S: U \rightarrow W$ is a bounded linear map. (a) Prove that there exists a unique continuous function $T: \bar{U} \rightarrow W$ such that $\left.T\right|_{U}=S$. (b) Prove that the function $T$ in part (a) is a bounded linear map from $\bar{U}$ to $W$ and $\|T\|=\|S\|$. (c) Give an example to show that part (a) can fail if the assumption that $W$ is a Banach space is replaced by the assumption that $W$ is a normed vector space. 15 For readers familiar with the quotient of a vector space and a subspace: Suppose $V$ is a normed vector space and $U$ is a subspace of $V$. Define $\|\cdot\|$ on $V / U$ by $$ \|f+U\|=\inf \{\|f+g\|: g \in U\} . $$ (a) Prove that $\|\cdot\|$ is a norm on $V / U$ if and only if $U$ is a closed subspace of $V$. (b) Prove that if $V$ is a Banach space and $U$ is a closed subspace of $V$, then $V / U$ (with the norm defined above) is a Banach space. (c) Prove that if $U$ is a Banach space (with the norm it inherits from $V$ ) and $V / U$ is a Banach space (with the norm defined above), then $V$ is a Banach space. 16 Suppose $V$ and $W$ are normed vector spaces with $V \neq\{0\}$ and $T: V \rightarrow W$ is a linear map. (a) Show that $\|T\|=\sup \{\|T f\|: f \in V$ and $\|f\|<1\}$. (b) Show that $\|T\|=\sup \{\|T f\|: f \in V$ and $\|f\|=1\}$. (c) Show that $\|T\|=\inf \{c \in[0, \infty):\|T f\| \leq c\|f\|$ for all $f \in V\}$. (d) Show that $\|T\|=\sup \left\{\frac{\|T f\|}{\|f\|}: f \in V\right.$ and $\left.f \neq 0\right\}$. 17 Suppose $U, V$, and $W$ are normed vector spaces and $T: U \rightarrow V$ and $S: V \rightarrow W$ are linear. Prove that $\|S \circ T\| \leq\|S\|\|T\|$. 18 Suppose $V$ and $W$ are normed vector spaces and $T: V \rightarrow W$ is a linear map. Prove that the following are equivalent: (a) $T$ is bounded. (b) There exists $f \in V$ such that $T$ is continuous at $f$. (c) $T$ is uniformly continuous (which means that for every $\varepsilon>0$, there exists $\delta>0$ such that $\|T f-T g\|<\varepsilon$ for all $f, g \in V$ with $\|f-g\|<\delta$ ). (d) $T^{-1}(B(0, r))$ is an open subset of $V$ for some $r>0$. ## 6D Linear Functionals ## Bounded Linear Functionals Linear maps into the scalar field $\mathbf{F}$ are so important that they get a special name. ### 6.49 Definition linear functional A linear functional on a vector space $V$ is a linear map from $V$ to $\mathbf{F}$. When we think of the scalar field $\mathbf{F}$ as a normed vector space, as in the next example, the norm $\|z\|$ of a number $z \in \mathbf{F}$ is always intended to be just the usual absolute value $|z|$. This norm makes $\mathbf{F}$ a Banach space. ### 6.50 Example linear functional Let $V$ be the vector space of sequences $\left(a_{1}, a_{2}, \ldots\right)$ of elements of $\mathbf{F}$ such that $a_{k}=0$ for all but finitely many $k \in \mathbf{Z}^{+}$. Define $\varphi: V \rightarrow \mathbf{F}$ by $$ \varphi\left(a_{1}, a_{2}, \ldots\right)=\sum_{k=1}^{\infty} a_{k} $$ Then $\varphi$ is a linear functional on $V$. - If we make $V$ a normed vector space with the norm $\left\|\left(a_{1}, a_{2}, \ldots\right)\right\|_{1}=\sum_{k=1}^{\infty}\left|a_{k}\right|$, then $\varphi$ is a bounded linear functional on $V$, as you should verify. - If we make $V$ a normed vector space with the norm $\left\|\left(a_{1}, a_{2}, \ldots\right)\right\|_{\infty}=\max _{k \in \mathbf{Z}^{+}}\left|a_{k}\right|$, then $\varphi$ is not a bounded linear functional on $V$, as you should verify. ### 6.51 Definition null space; null T Suppose $V$ and $W$ are vector spaces and $T: V \rightarrow W$ is a linear map. Then the null space of $T$ is denoted by null $T$ and is defined by $$ \operatorname{null} T=\{f \in V: T f=0\} $$ If $T$ is a linear map on a vector space $V$, then null $T$ is a subspace of $V$, as you should verify. If $T$ is a continuous linear map from a normed vector space $V$ to a normed vector space $W$, then null $T$ is a closed subspace of $V$ because null $T=$ $T^{-1}(\{0\})$ and the inverse image of the The term kernel is also used in the mathematics literature with the same meaning as null space. This book uses null space instead of kernel because null space better captures the connection with 0 . closed set $\{0\}$ is closed [by 6.11(d)]. The converse of the last sentence fails, because a linear map between normed vector spaces can have a closed null space but not be continuous. For example, the linear map in 6.45 has a closed null space (equal to $\{0\}$ ) but it is not continuous. However, the next result states that for linear functionals, as opposed to more general linear maps, having a closed null space is equivalent to continuity. ### 6.52 bounded linear functionals Suppose $V$ is a normed vector space and $\varphi: V \rightarrow \mathbf{F}$ is a linear functional that is not identically 0 . Then the following are equivalent: (a) $\varphi$ is a bounded linear functional. (b) $\varphi$ is a continuous linear functional. (c) null $\varphi$ is a closed subspace of $V$. (d) $\overline{\text { null } \varphi} \neq V$. Proof The equivalence of (a) and (b) is just a special case of 6.48. To prove that (b) implies (c), suppose $\varphi$ is a continuous linear functional. Then null $\varphi$, which is the inverse image of the closed set $\{0\}$, is a closed subset of $V$ by 6.11(d). Thus (b) implies (c). To prove that (c) implies (a), we will show that the negation of (a) implies the negation of (c). Thus suppose $\varphi$ is not bounded. Thus there is a sequence $f_{1}, f_{2}, \ldots$ in $V$ such that $\left\|f_{k}\right\| \leq 1$ and $\left|\varphi\left(f_{k}\right)\right| \geq k$ for each $k \in \mathbf{Z}^{+}$. Now $$ \frac{f_{1}}{\varphi\left(f_{1}\right)}-\frac{f_{k}}{\varphi\left(f_{k}\right)} \in \operatorname{null} \varphi $$ for each $k \in \mathbf{Z}^{+}$and $$ \lim _{k \rightarrow \infty}\left(\frac{f_{1}}{\varphi\left(f_{1}\right)}-\frac{f_{k}}{\varphi\left(f_{k}\right)}\right)=\frac{f_{1}}{\varphi\left(f_{1}\right)} $$ This proof makes major use of dividing by expressions of the form $\varphi(f)$, which would not make sense for a linear mapping into a vector space other than $\mathbf{F}$. Clearly $$ \varphi\left(\frac{f_{1}}{\varphi\left(f_{1}\right)}\right)=1 \text { and thus } \frac{f_{1}}{\varphi\left(f_{1}\right)} \notin \text { null } \varphi \text {. } $$ The last three displayed items imply that null $\varphi$ is not closed, completing the proof that the negation of (a) implies the negation of (c). Thus (c) implies (a). We now know that (a), (b), and (c) are equivalent to each other. Using the hypothesis that $\varphi$ is not identically 0 , we see that (c) implies (d). To complete the proof, we need only show that (d) implies (c), which we will do by showing that the negation of (c) implies the negation of (d). Thus suppose null $\varphi$ is not a closed subspace of $V$. Because null $\varphi$ is a subspace of $V$, we know that null $\varphi$ is also a subspace of $V$ (see Exercise 12 in Section 6C). Let $f \in \overline{\operatorname{null} \varphi} \backslash$ null $\varphi$. Suppose $g \in V$. Then $$ g=\left(g-\frac{\varphi(g)}{\varphi(f)} f\right)+\frac{\varphi(g)}{\varphi(f)} f $$ The term in large parentheses above is in null $\varphi$ and hence is in $\overline{\text { null } \varphi}$. The term above following the plus sign is a scalar multiple of $f$ and thus is in null $\varphi$. Because the equation above writes $g$ as the sum of two elements of $\overline{\text { null } \varphi}$, we conclude that $g \in \overline{\text { null } \varphi}$. Hence we have shown that $V=\overline{\text { null } \varphi}$, completing the proof that the negation of (c) implies the negation of (d). ## Discontinuous Linear Functionals The second bullet point in Example 6.50 shows that there exists a discontinuous linear functional on a certain normed vector space. Our next major goal is to show that every infinite-dimensional normed vector space has a discontinuous linear functional (see 6.62). Thus infinite-dimensional normed vector spaces behave in this respect much differently from $\mathbf{F}^{n}$, where all linear functionals are continuous (see Exercise 4). We need to extend the notion of a basis of a finite-dimensional vector space to an infinite-dimensional context. In a finite-dimensional vector space, we might consider a basis of the form $e_{1}, \ldots, e_{n}$, where $n \in \mathbf{Z}^{+}$and each $e_{k}$ is an element of our vector space. We can think of the list $e_{1}, \ldots, e_{n}$ as a function from $\{1, \ldots, n\}$ to our vector space, with the value of this function at $k \in\{1, \ldots, n\}$ denoted by $e_{k}$ with a subscript $k$ instead of by the usual functional notation $e(k)$. To generalize, in the next definition we allow $\{1, \ldots, n\}$ to be replaced by an arbitrary set that might not be a finite set. ### 6.53 Definition family A family $\left\{e_{k}\right\}_{k \in \Gamma}$ in a set $V$ is a function $e$ from a set $\Gamma$ to $V$, with the value of the function $e$ at $k \in \Gamma$ denoted by $e_{k}$. Even though a family in $V$ is a function mapping into $V$ and thus is not a subset of $V$, the set terminology and the bracket notation $\left\{e_{k}\right\}_{k \in \Gamma}$ are useful, and the range of a family in $V$ really is a subset of $V$. We now restate some basic linear algebra concepts, but in the context of vector spaces that might be infinite-dimensional. Note that only finite sums appear in the definition below, even though we might be working with an infinite family. ### 6.54 Definition linearly independent; span; finite-dimensional; basis Suppose $\left\{e_{k}\right\}_{k \in \Gamma}$ is a family in a vector space $V$. - $\left\{e_{k}\right\}_{k \in \Gamma}$ is called linearly independent if there does not exist a finite nonempty subset $\Omega$ of $\Gamma$ and a family $\left\{\alpha_{j}\right\}_{j \in \Omega}$ in $\mathbf{F} \backslash\{0\}$ such that $\sum_{j \in \Omega} \alpha_{j} e_{j}=0$. - The span of $\left\{e_{k}\right\}_{k \in \Gamma}$ is denoted by span $\left\{e_{k}\right\}_{k \in \Gamma}$ and is defined to be the set of all sums of the form $$ \sum_{j \in \Omega} \alpha_{j} e_{j} $$ where $\Omega$ is a finite subset of $\Gamma$ and $\left\{\alpha_{j}\right\}_{j \in \Omega}$ is a family in $\mathbf{F}$. - A vector space $V$ is called finite-dimensional if there exists a finite set $\Gamma$ and a family $\left\{e_{k}\right\}_{k \in \Gamma}$ in $V$ such that $\operatorname{span}\left\{e_{k}\right\}_{k \in \Gamma}=V$. - A vector space is called infinite-dimensional if it is not finite-dimensional. - A family in $V$ is called a basis of $V$ if it is linearly independent and its span equals $V$. For example, $\left\{x^{n}\right\}_{n \in\{0,1,2, \ldots\}}$ is a basis of the vector space of polynomials. Our definition of span does not take advantage of the possibility of summing an infinite number of elements in contexts where a notion of limit exists (as is the The term Hamel basis is sometimes used to denote what has been called $a$ basis here. The use of the term Hamel basis emphasizes that only finite sums are under consideration. case in normed vector spaces). When we get to Hilbert spaces in Chapter 8, we consider another kind of basis that does involve infinite sums. As we will soon see, the kind of basis as defined here is just what we need to produce discontinuous linear functionals. Now we introduce terminology that will be needed in our proof that every vector space has a basis. No one has ever produced a concrete example of a basis of an infinite-dimensional Banach space. ### 6.55 Definition maximal element Suppose $\mathcal{A}$ is a collection of subsets of a set $V$. A set $\Gamma \in \mathcal{A}$ is called a maximal element of $\mathcal{A}$ if there does not exist $\Gamma^{\prime} \in \mathcal{A}$ such that $\Gamma \varsubsetneqq \Gamma^{\prime}$. ### 6.56 Example maximal elements For $k \in \mathbf{Z}$, let $k \mathbf{Z}$ denote the set of integer multiples of $k$; thus $k \mathbf{Z}=\{k m: m \in \mathbf{Z}\}$. Let $\mathcal{A}$ be the collection of subsets of $\mathbf{Z}$ defined by $\mathcal{A}=\{k \mathbf{Z}: k=2,3,4, \ldots\}$. Suppose $k \in \mathbf{Z}^{+}$. Then $k \mathbf{Z}$ is a maximal element of $\mathcal{A}$ if and only if $k$ is a prime number, as you should verify. A subset $\Gamma$ of a vector space $V$ can be thought of as a family in $V$ by considering $\left\{e_{f}\right\}_{f \in \Gamma}$, where $e_{f}=f$. With this convention, the next result shows that the bases of $V$ are exactly the maximal elements among the collection of linearly independent subsets of $V$. ### 6.57 bases as maximal elements Suppose $V$ is a vector space. Then a subset of $V$ is a basis of $V$ if and only if it is a maximal element of the collection of linearly independent subsets of $V$. Proof Suppose $\Gamma$ is a linearly independent subset of $V$. First suppose also that $\Gamma$ is a basis of $V$. If $f \in V$ but $f \notin \Gamma$, then $f \in \operatorname{span} \Gamma$, which implies that $\Gamma \cup\{f\}$ is not linearly independent. Thus $\Gamma$ is a maximal element among the collection of linearly independent subsets of $V$, completing one direction of the proof. To prove the other direction, suppose now that $\Gamma$ is a maximal element of the collection of linearly independent subsets of $V$. If $f \in V$ but $f \notin \operatorname{span} \Gamma$, then $\Gamma \cup\{f\}$ is linearly independent, which would contradict the maximality of $\Gamma$ among the collection of linearly independent subsets of $V$. Thus span $\Gamma=V$, which means that $\Gamma$ is a basis of $V$, completing the proof in the other direction. The notion of a chain plays a key role in our next result. ### 6.58 Definition chain A collection $\mathcal{C}$ of subsets of a set $V$ is called a chain if $\Omega, \Gamma \in \mathcal{C}$ implies $\Omega \subset \Gamma$ or $\Gamma \subset \Omega$. ### 6.59 Example chains - The collection $\mathcal{C}=\{4 \mathbf{Z}, 6 \mathbf{Z}\}$ of subsets of $\mathbf{Z}$ is not a chain because neither of the sets $4 \mathbf{Z}$ or $6 \mathbf{Z}$ is a subset of the other. - The collection $\mathcal{C}=\left\{2^{n} \mathbf{Z}: n \in \mathbf{Z}^{+}\right\}$of subsets of $\mathbf{Z}$ is a chain because if $m, n \in \mathbf{Z}^{+}$, then $2^{m} \mathbf{Z} \subset 2^{n} \mathbf{Z}$ or $2^{n} \mathbf{Z} \subset 2^{m} \mathbf{Z}$. The next result follows from the Axiom of Choice, although it is not as intuitively believable as the Axiom of Choice. Because the techniques used to prove the next result are so different from techniques used elsewhere in this book, the Zorn's Lemma is named in honor of Max Zorn (1906-1993), who published a paper containing the result in 1935, when he had a postdoctoral position at Yale. reader is asked either to accept this result without proof or find one of the good proofs available via the internet or in other books. The version of Zorn's Lemma stated here is simpler than the standard more general version, but this version is all that we need. ### 6.60 Zorn's Lemma Suppose $V$ is a set and $\mathcal{A}$ is a collection of subsets of $V$ with the property that the union of all the sets in $\mathcal{C}$ is in $\mathcal{A}$ for every chain $\mathcal{C} \subset \mathcal{A}$. Then $\mathcal{A}$ contains a maximal element. Zorn's Lemma now allows us to prove that every vector space has a basis. The proof does not help us find a concrete basis because Zorn's Lemma is an existence result rather than a constructive technique. ### 6.61 bases exist Every vector space has a basis. Proof Suppose $V$ is a vector space. If $\mathcal{C}$ is a chain of linearly independent subsets of $V$, then the union of all the sets in $\mathcal{C}$ is also a linearly independent subset of $V$ (this holds because linear independence is a condition that is checked by considering finite subsets, and each finite subset of the union is contained in one of the elements of the chain). Thus if $\mathcal{A}$ denotes the collection of linearly independent subsets of $V$, then $\mathcal{A}$ satisfies the hypothesis of Zorn's Lemma (6.60). Hence $\mathcal{A}$ contains a maximal element, which by 6.57 is a basis of $V$. Now we can prove the promised result about the existence of discontinuous linear functionals on every infinite-dimensional normed vector space. ### 6.62 discontinuous linear functionals Every infinite-dimensional normed vector space has a discontinuous linear functional. Proof Suppose $V$ is an infinite-dimensional vector space. By 6.61, $V$ has a basis $\left\{e_{k}\right\}_{k \in \Gamma}$. Because $V$ is infinite-dimensional, $\Gamma$ is not a finite set. Thus we can assume $\mathbf{Z}^{+} \subset \Gamma$ (by relabeling a countable subset of $\Gamma$ ). Define a linear functional $\varphi: V \rightarrow \mathbf{F}$ by setting $\varphi\left(e_{j}\right)$ equal to $j\left\|e_{j}\right\|$ for $j \in \mathbf{Z}^{+}$, setting $\varphi\left(e_{j}\right)$ equal to 0 for $j \in \Gamma \backslash \mathbf{Z}^{+}$, and extending linearly. More precisely, define a linear functional $\varphi: V \rightarrow \mathbf{F}$ by $$ \varphi\left(\sum_{j \in \Omega} \alpha_{j} e_{j}\right)=\sum_{j \in \Omega \cap \mathbf{Z}^{+}} \alpha_{j} j\left\|e_{j}\right\| $$ for every finite subset $\Omega \subset \Gamma$ and every family $\left\{\alpha_{j}\right\}_{j \in \Omega}$ in $\mathbf{F}$. Because $\varphi\left(e_{j}\right)=j\left\|e_{j}\right\|$ for each $j \in \mathbf{Z}^{+}$, the linear functional $\varphi$ is unbounded, completing the proof. ## Hahn-Banach Theorem In the last subsection, we showed that there exists a discontinuous linear functional on each infinite-dimensional normed vector space. Now we turn our attention to the existence of continuous linear functionals. The existence of a nonzero continuous linear functional on each Banach space is not obvious. For example, consider the Banach space $\ell^{\infty} / c_{0}$, where $\ell^{\infty}$ is the Banach space of bounded sequences in $\mathbf{F}$ with $$ \left\|\left(a_{1}, a_{2}, \ldots\right)\right\|_{\infty}=\sup _{k \in \mathbf{Z}^{+}}\left|a_{k}\right| $$ and $c_{0}$ is the subspace of $\ell^{\infty}$ consisting of those sequences in $\mathbf{F}$ that have limit 0 . The quotient space $\ell^{\infty} / c_{0}$ is an infinite-dimensional Banach space (see Exercise 15 in Section 6C). However, no one has ever exhibited a concrete nonzero linear functional on the Banach space $\ell^{\infty} / c_{0}$. In this subsection, we show that infinite-dimensional normed vector spaces have plenty of continuous linear functionals. We do this by showing that a bounded linear functional on a subspace of a normed vector space can be extended to a bounded linear functional on the whole space without increasing its norm-this result is called the Hahn-Banach Theorem (6.69). Completeness plays no role in this topic. Thus this subsection deals with normed vector spaces instead of Banach spaces. We do most of the work needed to prove the Hahn-Banach Theorem in the next lemma, which shows that we can extend a linear functional to a subspace generated by one additional element, without increasing the norm. This one-element-at-a-time approach, when combined with a maximal object produced by Zorn's Lemma, gives us the desired extension to the full normed vector space. If $V$ is a real vector space, $U$ is a subspace of $V$, and $h \in V$, then $U+\mathbf{R} h$ is the subspace of $V$ defined by $$ U+\mathbf{R} h=\{f+\alpha h: f \in U \text { and } \alpha \in \mathbf{R}\} . $$ ### 6.63 Extension Lemma Suppose $V$ is a real normed vector space, $U$ is a subspace of $V$, and $\psi: U \rightarrow \mathbf{R}$ is a bounded linear functional. Suppose $h \in V \backslash U$. Then $\psi$ can be extended to a bounded linear functional $\varphi: U+\mathbf{R} h \rightarrow \mathbf{R}$ such that $\|\varphi\|=\|\psi\|$. Proof Suppose $c \in \mathbf{R}$. Define $\varphi(h)$ to be $c$, and then extend $\varphi$ linearly to $U+\mathbf{R} h$. Specifically, define $\varphi: U+\mathbf{R} h \rightarrow \mathbf{R}$ by $$ \varphi(f+\alpha h)=\psi(f)+\alpha c $$ for $f \in U$ and $\alpha \in \mathbf{R}$. Then $\varphi$ is a linear functional on $U+\mathbf{R} h$. Clearly $\left.\varphi\right|_{U}=\psi$. Thus $\|\varphi\| \geq\|\psi\|$. We need to show that for some choice of $c \in \mathbf{R}$, the linear functional $\varphi$ defined above satisfies the equation $\|\varphi\|=\|\psi\|$. In other words, we want 6.64 $$ |\psi(f)+\alpha c| \leq\|\psi\|\|f+\alpha h\| \quad \text { for all } f \in U \text { and all } \alpha \in \mathbf{R} $$ It would be enough to have $$ |\psi(f)+c| \leq\|\psi\|\|f+h\| \quad \text { for all } f \in U $$ because replacing $f$ by $\frac{f}{\alpha}$ in the last inequality and then multiplying both sides by $|\alpha|$ would give 6.64. Rewriting 6.65, we want to show that there exists $c \in \mathbf{R}$ such that $$ -\|\psi\|\|f+h\| \leq \psi(f)+c \leq\|\psi\|\|f+h\| \quad \text { for all } f \in U $$ Equivalently, we want to show that there exists $c \in \mathbf{R}$ such that $$ -\|\psi\|\|f+h\|-\psi(f) \leq c \leq\|\psi\|\|f+h\|-\psi(f) \quad \text { for all } f \in U $$ The existence of $c \in \mathbf{R}$ satisfying the line above follows from the inequality $$ \sup _{f \in U}(-\|\psi\|\|f+h\|-\psi(f)) \leq \inf _{g \in U}(\|\psi\|\|g+h\|-\psi(g)) $$ To prove the inequality above, suppose $f, g \in U$. Then $$ \begin{aligned} -\|\psi\|\|f+h\|-\psi(f) & \leq\|\psi\|(\|g+h\|-\|g-f\|)-\psi(f) \\ & =\|\psi\|(\|g+h\|-\|g-f\|)+\psi(g-f)-\psi(g) \\ & \leq\|\psi\|\|g+h\|-\psi(g) . \end{aligned} $$ The inequality above proves 6.66 , which completes the proof. Because our simplified form of Zorn's Lemma deals with set inclusions rather than more general orderings, we need to use the notion of the graph of a function. ### 6.67 Definition graph Suppose $T: V \rightarrow W$ is a function from a set $V$ to a set $W$. Then the graph of $T$ is denoted $\operatorname{graph}(T)$ and is the subset of $V \times W$ defined by $$ \operatorname{graph}(T)=\{(f, T(f)) \in V \times W: f \in V\} $$ Formally, a function from a set $V$ to a set $W$ equals its graph as defined above. However, because we usually think of a function more intuitively as a mapping, the separate notion of the graph of a function remains useful. The easy proof of the next result is left to the reader. The first bullet point below uses the vector space structure of $V \times W$, which is a vector space with natural operations of addition and scalar multiplication, as given in Exercise 10 in Section 6B. ### 6.68 function properties in terms of graphs Suppose $V$ and $W$ are normed vector spaces and $T: V \rightarrow W$ is a function. (a) $T$ is a linear map if and only if $\operatorname{graph}(T)$ is a subspace of $V \times W$. (b) Suppose $U \subset V$ and $S: U \rightarrow W$ is a function. Then $T$ is an extension of $S$ if and only if $\operatorname{graph}(S) \subset \operatorname{graph}(T)$. (c) If $T: V \rightarrow W$ is a linear map and $c \in[0, \infty)$, then $\|T\| \leq c$ if and only if $\|g\| \leq c\|f\|$ for all $(f, g) \in \operatorname{graph}(T)$. The proof of the Extension Lemma (6.63) used inequalities that do not make sense when $\mathbf{F}=\mathbf{C}$. Thus the proof of the Hahn-Banach Theorem below requires some extra steps when $\mathbf{F}=\mathbf{C}$. Hans Hahn (1879-1934) was a student and later a faculty member at the University of Vienna, where one of his PhD students was Kurt Gödel (1906-1978). ### 6.69 Hahn-Banach Theorem Suppose $V$ is a normed vector space, $U$ is a subspace of $V$, and $\psi: U \rightarrow \mathbf{F}$ is a bounded linear functional. Then $\psi$ can be extended to a bounded linear functional on $V$ whose norm equals $\|\psi\|$. Proof First we consider the case where $\mathbf{F}=\mathbf{R}$. Let $\mathcal{A}$ be the collection of subsets $E$ of $V \times \mathbf{R}$ that satisfy all the following conditions: - $E=\operatorname{graph}(\varphi)$ for some linear functional $\varphi$ on some subspace of $V$; - $\operatorname{graph}(\psi) \subset E$; - $|\alpha| \leq\|\psi\|\|f\|$ for every $(f, \alpha) \in E$. Then $\mathcal{A}$ satisfies the hypothesis of Zorn's Lemma (6.60). Thus $\mathcal{A}$ has a maximal element. The Extension Lemma (6.63) implies that this maximal element is the graph of a linear functional defined on all of $V$. This linear functional is an extension of $\psi$ to $V$ and it has norm $\|\psi\|$, completing the proof in the case where $\mathbf{F}=\mathbf{R}$. Now consider the case where $\mathbf{F}=\mathbf{C}$. Define $\psi_{1}: U \rightarrow \mathbf{R}$ by $$ \psi_{1}(f)=\operatorname{Re} \psi(f) $$ for $f \in U$. Then $\psi_{1}$ is an $\mathbf{R}$-linear map from $U$ to $\mathbf{R}$ and $\left\|\psi_{1}\right\| \leq\|\psi\|$ (actually $\left\|\psi_{1}\right\|=\|\psi\|$, but we need only the inequality). Also, 6.70 $$ \begin{aligned} \psi(f) & =\operatorname{Re} \psi(f)+i \operatorname{Im} \psi(f) \\ & =\psi_{1}(f)+i \operatorname{Im}(-i \psi(i f)) \\ & =\psi_{1}(f)-i \operatorname{Re}(\psi(i f)) \\ & =\psi_{1}(f)-i \psi_{1}(i f) \end{aligned} $$ for all $f \in U$. Temporarily forget that complex scalar multiplication makes sense on $V$ and temporarily think of $V$ as a real normed vector space. The case of the result that we have already proved then implies that there exists an extension $\varphi_{1}$ of $\psi_{1}$ to an $\mathbf{R}$-linear functional $\varphi_{1}: V \rightarrow \mathbf{R}$ with $\left\|\varphi_{1}\right\|=\left\|\psi_{1}\right\| \leq\|\psi\|$. Motivated by 6.70 , we define $\varphi: V \rightarrow \mathbf{C}$ by $$ \varphi(f)=\varphi_{1}(f)-i \varphi_{1}(i f) $$ for $f \in V$. The equation above and 6.70 imply that $\varphi$ is an extension of $\psi$ to $V$. The equation above also implies that $\varphi(f+g)=\varphi(f)+\varphi(g)$ and $\varphi(\alpha f)=\alpha \varphi(f)$ for all $f, g \in V$ and all $\alpha \in \mathbf{R}$. Also, $\varphi(i f)=\varphi_{1}(i f)-i \varphi_{1}(-f)=\varphi_{1}(i f)+i \varphi_{1}(f)=i\left(\varphi_{1}(f)-i \varphi_{1}(i f)\right)=i \varphi(f)$. The reader should use the equation above to show that $\varphi$ is a $\mathbf{C}$-linear map. The only part of the proof that remains is to show that $\|\varphi\| \leq\|\psi\|$. To do this, note that $$ |\varphi(f)|^{2}=\varphi(\overline{\varphi(f)} f)=\varphi_{1}(\overline{\varphi(f)} f) \leq\|\psi\|\|\overline{\varphi(f)} f\|=\|\psi\||\varphi(f)|\|f\| $$ for all $f \in V$, where the second equality holds because $\varphi(\overline{\varphi(f)} f) \in \mathbf{R}$. Dividing by $|\varphi(f)|$, we see from the line above that $|\varphi(f)| \leq\|\psi\|\|f\|$ for all $f \in V$ (no division necessary if $\varphi(f)=0$ ). This implies that $\|\varphi\| \leq\|\psi\|$, completing the proof. We have given the special name linear functionals to linear maps into the scalar field $\mathbf{F}$. The vector space of bounded linear functionals now also gets a special name and a special notation. ### 6.71 Definition dual space; $V^{\prime}$ Suppose $V$ is a normed vector space. Then the dual space of $V$, denoted $V^{\prime}$, is the normed vector space consisting of the bounded linear functionals on $V$. In other words, $V^{\prime}=\mathcal{B}(V, \mathbf{F})$. By 6.47, the dual space of every normed vector space is a Banach space. $6.72\|f\|=\max \left\{|\varphi(f)|: \varphi \in V^{\prime}\right.$ and $\left.\|\varphi\|=1\right\}$ Suppose $V$ is a normed vector space and $f \in V \backslash\{0\}$. Then there exists $\varphi \in V^{\prime}$ such that $\|\varphi\|=1$ and $\|f\|=\varphi(f)$. Proof Let $U$ be the 1-dimensional subspace of $V$ defined by $$ U=\{\alpha f: \alpha \in \mathbf{F}\} $$ Define $\psi: U \rightarrow \mathbf{F}$ by $$ \psi(\alpha f)=\alpha\|f\| $$ for $\alpha \in \mathbf{F}$. Then $\psi$ is a linear functional on $U$ with $\|\psi\|=1$ and $\psi(f)=\|f\|$. The Hahn-Banach Theorem (6.69) implies that there exists an extension of $\psi$ to a linear functional $\varphi$ on $V$ with $\|\varphi\|=1$, completing the proof. The next result gives another beautiful application of the Hahn-Banach Theorem, with a useful necessary and sufficient condition for an element of a normed vector space to be in the closure of a subspace. ### 6.73 condition to be in the closure of a subspace Suppose $U$ is a subspace of a normed vector space $V$ and $h \in V$. Then $h \in \bar{U}$ if and only if $\varphi(h)=0$ for every $\varphi \in V^{\prime}$ such that $\left.\varphi\right|_{U}=0$. Proof First suppose $h \in \bar{U}$. If $\varphi \in V^{\prime}$ and $\left.\varphi\right|_{U}=0$, then $\varphi(h)=0$ by the continuity of $\varphi$, completing the proof in one direction. To prove the other direction, suppose now that $h \notin \bar{U}$. Define $\psi: U+\mathbf{F} h \rightarrow \mathbf{F}$ by $$ \psi(f+\alpha h)=\alpha $$ for $f \in U$ and $\alpha \in \mathbf{F}$. Then $\psi$ is a linear functional on $U+\mathbf{F} h$ with null $\psi=U$ and $\psi(h)=1$. Because $h \notin \bar{U}$, the closure of the null space of $\psi$ does not equal $U+\mathbf{F} h$. Thus 6.52 implies that $\psi$ is a bounded linear functional on $U+\mathbf{F} h$. The Hahn-Banach Theorem (6.69) implies that $\psi$ can be extended to a bounded linear functional $\varphi$ on $V$. Thus we have found $\varphi \in V^{\prime}$ such that $\left.\varphi\right|_{U}=0$ but $\varphi(h) \neq 0$, completing the proof in the other direction. ## EXERCISES 6D 1 Suppose $V$ is a normed vector space and $\varphi$ is a linear functional on $V$. Suppose $\alpha \in \mathbf{F} \backslash\{0\}$. Prove that the following are equivalent: (a) $\varphi$ is a bounded linear functional. (b) $\varphi^{-1}(\alpha)$ is a closed subset of $V$. (c) $\overline{\varphi^{-1}(\alpha)} \neq V$. 2 Suppose $\varphi$ is a linear functional on a vector space $V$. Prove that if $U$ is a subspace of $V$ such that null $\varphi \subset U$, then $U=$ null $\varphi$ or $U=V$. 3 Suppose $\varphi$ and $\psi$ are linear functionals on the same vector space. Prove that $$ \text { null } \varphi \subset \operatorname{null} \psi $$ if and only if there exists $\alpha \in \mathbf{F}$ such that $\psi=\alpha \varphi$. For the next two exercises, $F^{n}$ should be endowed with the norm $\|\cdot\|_{\infty}$ as defined in Example 6.34. 4 Suppose $n \in \mathbf{Z}^{+}$and $V$ is a normed vector space. Prove that every linear map from $\mathbf{F}^{n}$ to $V$ is continuous. 5 Suppose $n \in \mathbf{Z}^{+}, V$ is a normed vector space, and $T: \mathbf{F}^{n} \rightarrow V$ is a linear map that is one-to-one and onto $V$. (a) Show that $$ \inf \left\{\|T x\|: x \in \mathbf{F}^{n} \text { and }\|x\|_{\infty}=1\right\}>0 $$ (b) Prove that $T^{-1}: V \rightarrow \mathbf{F}^{n}$ is a bounded linear map. 6 Suppose $n \in \mathbf{Z}^{+}$. (a) Prove that all norms on $\mathbf{F}^{n}$ have the same convergent sequences, the same open sets, and the same closed sets. (b) Prove that all norms on $\mathbf{F}^{n}$ make $\mathbf{F}^{n}$ into a Banach space. 7 Suppose $V$ and $W$ are normed vector spaces and $V$ is finite-dimensional. Prove that every linear map from $V$ to $W$ is continuous. 8 Prove that every finite-dimensional normed vector space is a Banach space. 9 Prove that every finite-dimensional subspace of each normed vector space is closed. 10 Give a concrete example of an infinite-dimensional normed vector space and a basis of that normed vector space. 11 Show that the collection $\mathcal{A}=\{k \mathbf{Z}: k=2,3,4, \ldots\}$ of subsets of $\mathbf{Z}$ satisfies the hypothesis of Zorn's Lemma (6.60). 12 Prove that every linearly independent family in a vector space can be extended to a basis of the vector space. 13 Suppose $V$ is a normed vector space, $U$ is a subspace of $V$, and $\psi: U \rightarrow \mathbf{R}$ is a bounded linear functional. Prove that $\psi$ has a unique extension to a bounded linear functional $\varphi$ on $V$ with $\|\varphi\|=\|\psi\|$ if and only if $$ \sup _{f \in U}(-\|\psi\|\|f+h\|-\psi(f))=\inf _{g \in U}(\|\psi\|\|g+h\|-\psi(g)) $$ for every $h \in V \backslash U$. 14 Show that there exists a linear functional $\varphi: \ell^{\infty} \rightarrow \mathbf{F}$ such that $$ \left|\varphi\left(a_{1}, a_{2}, \ldots\right)\right| \leq\left\|\left(a_{1}, a_{2}, \ldots\right)\right\|_{\infty} $$ for all $\left(a_{1}, a_{2}, \ldots\right) \in \ell^{\infty}$ and $$ \varphi\left(a_{1}, a_{2}, \ldots\right)=\lim _{k \rightarrow \infty} a_{k} $$ for all $\left(a_{1}, a_{2}, \ldots\right) \in \ell^{\infty}$ such that the limit above on the right exists. 15 Suppose $B$ is an open ball in a normed vector space $V$ such that $0 \notin B$. Prove that there exists $\varphi \in V^{\prime}$ such that $$ \operatorname{Re} \varphi(f)>0 $$ for all $f \in B$. 16 Show that the dual space of each infinite-dimensional normed vector space is infinite-dimensional. A normed vector space is called separable if it has a countable subset whose closure equals the whole space. 17 Suppose $V$ is a separable normed vector space. Explain how the Hahn-Banach Theorem (6.69) for $V$ can be proved without using any results (such as Zorn's Lemma) that depend upon the Axiom of Choice. 18 Suppose $V$ is a normed vector space such that the dual space $V^{\prime}$ is a separable Banach space. Prove that $V$ is separable. 19 Prove that the dual of the Banach space $C([0,1])$ is not separable; here the norm on $C([0,1])$ is defined by $\|f\|=\sup |f|$. The double dual space of a normed vector space is defined to be the dual space of the dual space. If $V$ is a normed vector space, then the double dual space of $V$ is denoted by $V^{\prime \prime}$; thus $V^{\prime \prime}=\left(V^{\prime}\right)^{\prime}$. The norm on $V^{\prime \prime}$ is defined to be the norm it receives as the dual space of $V^{\prime}$. 20 Define $\Phi: V \rightarrow V^{\prime \prime}$ by $$ (\Phi f)(\varphi)=\varphi(f) $$ for $f \in V$ and $\varphi \in V^{\prime}$. Show that $\|\Phi f\|=\|f\|$ for every $f \in V$. [The map $\Phi$ defined above is called the canonical isometry of $V$ into $V^{\prime \prime}$.] 21 Suppose $V$ is an infinite-dimensional normed vector space. Show that there is a convex subset $U$ of $V$ such that $\bar{U}=V$ and such that the complement $V \backslash U$ is also a convex subset of $V$ with $\overline{V \backslash U}=V$. [See 8.25 for the definition of a convex set. This exercise should stretch your geometric intuition because this behavior cannot happen in finite dimensions.] ## 6E Consequences of Baire's Theorem This section focuses on several important results about Banach spaces that depend upon Baire's Theorem. This result was first proved by René-Louis Baire (18741932) as part of his 1899 doctoral dissertation at École Normale Supérieure (Paris). Even though our interest lies primarily in applications to Banach spaces, the proper setting for Baire's Theorem is the more general context of complete metric spaces. ## Baire's Theorem The result here called Baire's Theorem is often called the Baire Category Theorem. This book uses the shorter name of this result because we do not need the categories introduced by Baire. Furthermore, the use of the word category in this context can be confusing because Baire's categories have no connection with the category theory that developed decades after Baire's work. We begin with some key topological notions. ### 6.74 Definition interior Suppose $U$ is a subset of a metric space $V$. The interior of $U$, denoted int $U$, is the set of $f \in U$ such that some open ball of $V$ centered at $f$ with positive radius is contained in $U$. You should verify the following elementary facts about the interior. - The interior of each subset of a metric space is open. - The interior of a subset $U$ of a metric space $V$ is the largest open subset of $V$ contained in $U$. ### 6.75 Definition dense A subset $U$ of a metric space $V$ is called dense in $V$ if $\bar{U}=V$. For example, $\mathbf{Q}$ and $\mathbf{R} \backslash \mathbf{Q}$ are both dense in $\mathbf{R}$, where $\mathbf{R}$ has its standard metric $d(x, y)=|x-y|$. You should verify the following elementary facts about dense subsets. - A subset $U$ of a metric space $V$ is dense in $V$ if and only if every nonempty open subset of $V$ contains at least one element of $U$. - A subset $U$ of a metric space $V$ has an empty interior if and only if $V \backslash U$ is dense in $V$. The proof of the next result uses the following fact, which you should first prove: If $G$ is an open subset of a metric space $V$ and $f \in G$, then there exists $r>0$ such that $\bar{B}(f, r) \subset G$. ### 6.76 Baire's Theorem (a) A complete metric space is not the countable union of closed subsets with empty interior. (b) The countable intersection of dense open subsets of a complete metric space is nonempty. Proof We will prove (b) and then use (b) to prove (a). To prove (b), suppose $(V, d)$ is a complete metric space and $G_{1}, G_{2}, \ldots$ is a sequence of dense open subsets of $V$. We need to show that $\bigcap_{k=1}^{\infty} G_{k} \neq \varnothing$. Let $f_{1} \in G_{1}$ and let $r_{1} \in(0,1)$ be such that $\bar{B}\left(f_{1}, r_{1}\right) \subset G_{1}$. Now suppose $n \in \mathbf{Z}^{+}$, and $f_{1}, \ldots, f_{n}$ and $r_{1}, \ldots, r_{n}$ have been chosen such that $$ \bar{B}\left(f_{1}, r_{1}\right) \supset \bar{B}\left(f_{2}, r_{2}\right) \supset \cdots \supset \bar{B}\left(f_{n}, r_{n}\right) $$ and 6.78 $$ r_{j} \in\left(0, \frac{1}{j}\right) \quad \text { and } \quad \bar{B}\left(f_{j}, r_{j}\right) \subset G_{j} \text { for } j=1, \ldots, n \text {. } $$ Because $B\left(f_{n}, r_{n}\right)$ is an open subset of $V$ and $G_{n+1}$ is dense in $V$, there exists $f_{n+1} \in B\left(f_{n}, r_{n}\right) \cap G_{n+1}$. Let $r_{n+1} \in\left(0, \frac{1}{n+1}\right)$ be such that $$ \bar{B}\left(f_{n+1}, r_{n+1}\right) \subset \bar{B}\left(f_{n}, r_{n}\right) \cap G_{n+1} . $$ Thus we inductively construct a sequence $f_{1}, f_{2}, \ldots$ that satisfies 6.77 and 6.78 for all $n \in \mathbf{Z}^{+}$. If $j \in \mathbf{Z}^{+}$, then 6.77 and 6.78 imply that 6.79 $$ f_{k} \in \bar{B}\left(f_{j}, r_{j}\right) \quad \text { and } \quad d\left(f_{j}, f_{k}\right) \leq r_{j}<\frac{1}{j} \quad \text { for all } k>j \text {. } $$ Hence $f_{1}, f_{2}, \ldots$ is a Cauchy sequence. Because $(V, d)$ is a complete metric space, there exists $f \in V$ such that $\lim _{k \rightarrow \infty} f_{k}=f$. Now 6.79 and 6.78 imply that for each $j \in \mathbf{Z}^{+}$, we have $f \in \bar{B}\left(f_{j}, r_{j}\right) \subset G_{j}$. Hence $f \in \bigcap_{k=1}^{\infty} G_{k}$, which means that $\bigcap_{k=1}^{\infty} G_{k}$ is not the empty set, completing the proof of (b). To prove (a), suppose $(V, d)$ is a complete metric space and $F_{1}, F_{2}, \ldots$ is a sequence of closed subsets of $V$ with empty interior. Then $V \backslash F_{1}, V \backslash F_{2}, \ldots$ is a sequence of dense open subsets of $V$. Now (b) implies that $$ \varnothing \neq \bigcap_{k=1}^{\infty}\left(V \backslash F_{k}\right) $$ Taking complements of both sides above, we conclude that $$ V \neq \bigcup_{k=1}^{\infty} F_{k} $$ completing the proof of (a). Because $$ \mathbf{R}=\bigcup_{x \in \mathbf{R}}\{x\} $$ and each set $\{x\}$ has empty interior in $\mathbf{R}$, Baire's Theorem implies $\mathbf{R}$ is uncountable. Thus we have yet another proof that $\mathbf{R}$ is uncountable, different than Cantor's original diagonal proof and different from the proof via measure theory (see 2.17). The next result is another nice consequence of Baire's Theorem. ### 6.80 the set of irrational numbers is not a countable union of closed sets There does not exist a countable collection of closed subsets of $\mathbf{R}$ whose union equals $\mathbf{R} \backslash \mathbf{Q}$. Proof This will be a proof by contradiction. Suppose $F_{1}, F_{2}, \ldots$ is a countable collection of closed subsets of $\mathbf{R}$ whose union equals $\mathbf{R} \backslash \mathbf{Q}$. Thus each $F_{k}$ contains no rational numbers, which implies that each $F_{k}$ has empty interior. Now $$ \mathbf{R}=\left(\bigcup_{r \in \mathbf{Q}}\{r\}\right) \cup\left(\bigcup_{k=1}^{\infty} F_{k}\right) $$ The equation above writes the complete metric space $\mathbf{R}$ as a countable union of closed sets with empty interior, which contradicts Baire's Theorem [6.76(a)]. This contradiction completes the proof. ## Open Mapping Theorem and Inverse Mapping Theorem The next result shows that a surjective bounded linear map from one Banach space onto another Banach space maps open sets to open sets. As shown in Exercises 10 and 11, this result can fail if the hypothesis that both spaces are Banach spaces is weakened to allow either of the spaces to be a normed vector space. ### 6.81 Open Mapping Theorem Suppose $V$ and $W$ are Banach spaces and $T$ is a bounded linear map of $V$ onto $W$. Then $T(G)$ is an open subset of $W$ for every open subset $G$ of $V$. Proof Let $B$ denote the open unit ball $B(0,1)=\{f \in V:\|f\|<1\}$ of $V$. For any open ball $B(f, a)$ in $V$, the linearity of $T$ implies that $$ T(B(f, a))=T f+a T(B) . $$ Suppose $G$ is an open subset of $V$. If $f \in G$, then there exists $a>0$ such that $B(f, a) \subset G$. If we can show that $0 \in \operatorname{int} T(B)$, then the equation above shows that $T f \in \operatorname{int} T(B(f, a))$. This would imply that $T(G)$ is an open subset of $W$. Thus to complete the proof we need only show that $T(B)$ contains some open ball centered at 0 . The surjectivity and linearity of $T$ imply that $$ W=\bigcup_{k=1}^{\infty} T(k B)=\bigcup_{k=1}^{\infty} k T(B) $$ Thus $W=\bigcup_{k=1}^{\infty} \overline{k T(B)}$. Baire's Theorem [6.76(a)] now implies that $\overline{k T(B)}$ has a nonempty interior for some $k \in \mathbf{Z}^{+}$. The linearity of $T$ allows us to conclude that $\overline{T(B)}$ has a nonempty interior. Thus there exists $g \in B$ such that $T g \in \operatorname{int} \overline{T(B)}$. Hence $$ 0 \in \operatorname{int} \overline{T(B-g)} \subset \operatorname{int} \overline{T(2 B)}=\operatorname{int} \overline{2 T(B)} . $$ Thus there exists $r>0$ such that $\bar{B}(0,2 r) \subset \overline{2 T(B)}$ [here $\bar{B}(0,2 r)$ is the closed ball in $W$ centered at 0 with radius $2 r]$. Hence $\bar{B}(0, r) \subset \overline{T(B)}$. The definition of what it means to be in the closure of $T(B)$ [see 6.7] now shows that $$ h \in W \text { and }\|h\| \leq r \text { and } \varepsilon>0 \Longrightarrow \exists f \in B \text { such that }\|h-T f\|<\varepsilon \text {. } $$ For arbitrary $h \neq 0$ in $W$, applying the result in the line above to $\frac{r}{\|h\|} h$ shows that $$ h \in W \text { and } \varepsilon>0 \Longrightarrow \exists f \in \frac{\|h\|}{r} B \text { such that }\|h-T f\|<\varepsilon $$ Now suppose $g \in W$ and $\|g\|<1$. Applying 6.82 with $h=g$ and $\varepsilon=\frac{1}{2}$, we see that $$ \text { there exists } f_{1} \in \frac{1}{r} B \text { such that }\left\|g-T f_{1}\right\|<\frac{1}{2} \text {. } $$ Now applying 6.82 with $h=g-T f_{1}$ and $\varepsilon=\frac{1}{4}$, we see that $$ \text { there exists } f_{2} \in \frac{1}{2 r} B \text { such that }\left\|g-T f_{1}-T f_{2}\right\|<\frac{1}{4} \text {. } $$ Applying 6.82 again, this time with $h=g-T f_{1}-T f_{2}$ and $\varepsilon=\frac{1}{8}$, we see that $$ \text { there exists } f_{3} \in \frac{1}{4 r} B \text { such that }\left\|g-T f_{1}-T f_{2}-T f_{3}\right\|<\frac{1}{8} \text {. } $$ Continue in this pattern, constructing a sequence $f_{1}, f_{2}, \ldots$ in $V$. Let $$ f=\sum_{k=1}^{\infty} f_{k} $$ where the infinite sum converges in $V$ because $$ \sum_{k=1}^{\infty}\left\|f_{k}\right\|<\sum_{k=1}^{\infty} \frac{1}{2^{k-1} r}=\frac{2}{r} $$ here we are using 6.41 (this is the place in the proof where we use the hypothesis that $V$ is a Banach space). The inequality displayed above shows that $\|f\|<\frac{2}{r}$. Because $$ \left\|g-T f_{1}-T f_{2}-\cdots-T f_{n}\right\|<\frac{1}{2^{n}} $$ and because $T$ is a continuous linear map, we have $g=T f$. We have now shown that $B(0,1) \subset \frac{2}{r} T(B)$. Thus $\frac{r}{2} B(0,1) \subset T(B)$, completing the proof. The next result provides the useful information that if a bounded linear map from one Banach space to another Banach space has an algebraic inverse (meaning that the linear map is injective and surjec- The Open Mapping Theorem was first proved by Banach and his colleague Juliusz Schauder (1899-1943) in 1929-1930. tive), then the inverse mapping is automatically bounded. ### 6.83 Bounded Inverse Theorem Suppose $V$ and $W$ are Banach spaces and $T$ is a one-to-one bounded linear map from $V$ onto $W$. Then $T^{-1}$ is a bounded linear map from $W$ onto $V$. Proof The verification that $T^{-1}$ is a linear map from $W$ to $V$ is left to the reader. To prove that $T^{-1}$ is bounded, suppose $G$ is an open subset of $V$. Then $$ \left(T^{-1}\right)^{-1}(G)=T(G) . $$ By the Open Mapping Theorem (6.81), $T(G)$ is an open subset of $W$. Thus the equation above shows that the inverse image under the function $T^{-1}$ of every open set is open. By the equivalence of parts (a) and (c) of 6.11, this implies that $T^{-1}$ is continuous. Thus $T^{-1}$ is a bounded linear map (by 6.48). The result above shows that completeness for normed vector spaces sometimes plays a role analogous to compactness for metric spaces (think of the theorem stating that a continuous one-to-one function from a compact metric space onto another compact metric space has an inverse that is also continuous). ## Closed Graph Theorem Suppose $V$ and $W$ are normed vector spaces. Then $V \times W$ is a vector space with the natural operations of addition and scalar multiplication as defined in Exercise 10 in Section 6B. There are several natural norms on $V \times W$ that make $V \times W$ into a normed vector space; the choice used in the next result seems to be the easiest. The proof of the next result is left to the reader as an exercise. ### 6.84 product of Banach spaces Suppose $V$ and $W$ are Banach spaces. Then $V \times W$ is a Banach space if given the norm defined by $$ \|(f, g)\|=\max \{\|f\|,\|g\|\} $$ for $f \in V$ and $g \in W$. With this norm, a sequence $\left(f_{1}, g_{1}\right),\left(f_{2}, g_{2}\right), \ldots$ in $V \times W$ converges to $(f, g)$ if and only if $\lim _{k \rightarrow \infty} f_{k}=f$ and $\lim _{k \rightarrow \infty} g_{k}=g$. The next result gives a terrific way to show that a linear map between Banach spaces is bounded. The proof is remarkably clean because the hard work has been done in the proof of the Open Mapping Theorem (which was used to prove the Bounded Inverse Theorem). ### 6.85 Closed Graph Theorem Suppose $V$ and $W$ are Banach spaces and $T$ is a function from $V$ to $W$. Then $T$ is a bounded linear map if and only if $\operatorname{graph}(T)$ is a closed subspace of $V \times W$. Proof First suppose $T$ is a bounded linear map. Suppose $\left(f_{1}, T f_{1}\right),\left(f_{2}, T f_{2}\right), \ldots$ is a sequence in $\operatorname{graph}(T)$ converging to $(f, g) \in V \times W$. Thus $$ \lim _{k \rightarrow \infty} f_{k}=f \quad \text { and } \quad \lim _{k \rightarrow \infty} T f_{k}=g . $$ Because $T$ is continuous, the first equation above implies that $\lim _{k \rightarrow \infty} T f_{k}=T f$; when combined with the second equation above this implies that $g=T f$. Thus $(f, g)=(f, T f) \in \operatorname{graph}(T)$, which implies that graph $(T)$ is closed, completing the proof in one direction. To prove the other direction, now suppose $\operatorname{graph}(T)$ is a closed subspace of $V \times W$. Thus graph $(T)$ is a Banach space with the norm that it inherits from $V \times W$ [from 6.84 and 6.16(b)]. Consider the linear map $S: \operatorname{graph}(T) \rightarrow V$ defined by $$ S(f, T f)=f \text {. } $$ Then $$ \|S(f, T f)\|=\|f\| \leq \max \{\|f\|,\|T f\|\}=\|(f, T f)\| $$ for all $f \in V$. Thus $S$ is a bounded linear map from graph $(T)$ onto $V$ with $\|S\| \leq 1$. Clearly $S$ is injective. Thus the Bounded Inverse Theorem (6.83) implies that $S^{-1}$ is bounded. Because $S^{-1}: V \rightarrow \operatorname{graph}(T)$ satisfies the equation $S^{-1} f=(f, T f)$, we have $$ \begin{aligned} \|T f\| & \leq \max \{\|f\|,\|T f\|\} \\ & =\|(f, T f)\| \\ & =\left\|S^{-1} f\right\| \\ & \leq\left\|S^{-1}\right\|\|f\| \end{aligned} $$ for all $f \in V$. The inequality above implies that $T$ is a bounded linear map with $\|T\| \leq\left\|S^{-1}\right\|$, completing the proof. ## Principle of Uniform Boundedness The next result states that a family of bounded linear maps on a Banach space that is pointwise bounded is bounded in norm (which means that it is uniformly bounded as a collection of maps on the unit ball). This result is sometimes called the Banach-Steinhaus Theorem. Exercise 17 is also sometimes called the Banach- The Principle of Uniform Boundedness was proved in 1927 by Banach and Hugo Steinhaus (1887-1972). Steinhaus recruited Banach to advanced mathematics after overhearing him discuss Lebesgue integration in a park. Steinhaus Theorem. ### 6.86 Principle of Uniform Boundedness Suppose $V$ is a Banach space, $W$ is a normed vector space, and $\mathcal{A}$ is a family of bounded linear maps from $V$ to $W$ such that $$ \sup \{\|T f\|: T \in \mathcal{A}\}<\infty \text { for every } f \in V \text {. } $$ Then $$ \sup \{\|T\|: T \in \mathcal{A}\}<\infty . $$ Proof Our hypothesis implies that $$ V=\bigcup_{n=1}^{\infty} \underbrace{\{f \in V:\|T f\| \leq n \text { for all } T \in \mathcal{A}\}}_{V_{n}} $$ where $V_{n}$ is defined by the expression above. Because each $T \in \mathcal{A}$ is continuous, $V_{n}$ is a closed subset of $V$ for each $n \in \mathbf{Z}^{+}$. Thus Baire's Theorem [6.76(a)] and the equation above imply that there exist $n \in \mathbf{Z}^{+}$and $h \in V$ and $r>0$ such that $$ B(h, r) \subset V_{n} . $$ Now suppose $g \in V$ and $\|g\|<1$. Thus $r g+h \in B(h, r)$. Hence if $T \in \mathcal{A}$, then 6.87 implies $\|T(r g+h)\| \leq n$, which implies that $$ \|T g\|=\left\|\frac{T(r g+h)}{r}-\frac{T h}{r}\right\| \leq \frac{\|T(r g+h)\|}{r}+\frac{\|T h\|}{r} \leq \frac{n+\|T h\|}{r} . $$ Thus $$ \sup \{\|T\|: T \in \mathcal{A}\} \leq \frac{n+\sup \{\|T h\|: T \in \mathcal{A}\}}{r}<\infty \text {, } $$ completing the proof. ## EXERCISES 6E 1 Suppose $U$ is a subset of a metric space $V$. Show that $U$ is dense in $V$ if and only if every nonempty open subset of $V$ contains at least one element of $U$. 2 Suppose $U$ is a subset of a metric space $V$. Show that $U$ has an empty interior if and only if $V \backslash U$ is dense in $V$. 3 Prove or give a counterexample: If $V$ is a metric space and $U, W$ are subsets of $V$, then $(\operatorname{int} U) \cup(\operatorname{int} W)=\operatorname{int}(U \cup W)$. 4 Prove or give a counterexample: If $V$ is a metric space and $U, W$ are subsets of $V$, then $(\operatorname{int} U) \cap(\operatorname{int} W)=\operatorname{int}(U \cap W)$. Suppose $$ X=\{0\} \cup \bigcup_{k=1}^{\infty}\left\{\frac{1}{k}\right\} $$ and $d(x, y)=|x-y|$ for $x, y \in X$. (a) Show that $(X, d)$ is a complete metric space. (b) Each set of the form $\{x\}$ for $x \in X$ is a closed subset of $\mathbf{R}$ that has an empty interior as a subset of $\mathbf{R}$. Clearly $X$ is a countable union of such sets. Explain why this does not violate the statement of Baire's Theorem that a complete metric space is not the countable union of closed subsets with empty interior. 6 Give an example of a metric space that is the countable union of closed subsets with empty interior. [This exercise shows that the completeness hypothesis in Baire's Theorem cannot be dropped.] 7 (a) Define $f: \mathbf{R} \rightarrow \mathbf{R}$ as follows: $$ f(a)= \begin{cases}0 & \text { if } a \text { is irrational, } \\ \frac{1}{n} & \text { if } a \text { is rational and } n \text { is the smallest positive integer } \\ & \text { such that } a=\frac{m}{n} \text { for some integer } m .\end{cases} $$ At which numbers in $\mathbf{R}$ is $f$ continuous? (b) Show that there does not exist a countable collection of open subsets of $\mathbf{R}$ whose intersection equals $\mathbf{Q}$. (c) Show that there does not exist a function $f: \mathbf{R} \rightarrow \mathbf{R}$ such that $f$ is continuous at each element of $\mathbf{Q}$ and discontinuous at each element of $\mathbf{R} \backslash \mathbf{Q}$. 8 Suppose $(X, d)$ is a complete metric space and $G_{1}, G_{2}, \ldots$ is a sequence of dense open subsets of $X$. Prove that $\bigcap_{k=1}^{\infty} G_{k}$ is a dense subset of $X$. 9 Prove that there does not exist an infinite-dimensional Banach space with a countable basis. [This exercise implies, for example, that there is not a norm that makes the vector space of polynomials with coefficients in $\mathbf{F}$ into a Banach space.] 10 Give an example of a Banach space $V$, a normed vector space $W$, a bounded linear map $T$ of $V$ onto $W$, and an open subset $G$ of $V$ such that $T(G)$ is not an open subset of $W$. [This exercise shows that the hypothesis in the Open Mapping Theorem that $W$ is a Banach space cannot be relaxed to the hypothesis that $W$ is a normed vector space.] 11 Show that there exists a normed vector space $V$, a Banach space $W$, a bounded linear map $T$ of $V$ onto $W$, and an open subset $G$ of $V$ such that $T(G)$ is not an open subset of $W$. [This exercise shows that the hypothesis in the Open Mapping Theorem that $V$ is a Banach space cannot be relaxed to the hypothesis that $V$ is a normed vector space.] ## A linear map $T: V \rightarrow W$ from a normed vector space $V$ to a normed vector space $W$ is called bounded below if there exists $c \in(0, \infty)$ such that $\|f\| \leq c\|T f\|$ for all $f \in V$ 12 Suppose $T: V \rightarrow W$ is a bounded linear map from a Banach space $V$ to a Banach space $W$. Prove that $T$ is bounded below if and only if $T$ is injective and the range of $T$ is a closed subspace of $W$. 13 Give an example of a Banach space $V$, a normed vector space $W$, and a one-toone bounded linear map $T$ of $V$ onto $W$ such that $T^{-1}$ is not a bounded linear map of $W$ onto $V$. [This exercise shows that the hypothesis in the Bounded Inverse Theorem (6.83) that $W$ is a Banach space cannot be relaxed to the hypothesis that $W$ is a normed vector space.] 14 Show that there exists a normed space $V$, a Banach space $W$, and a one-to-one bounded linear map $T$ of $V$ onto $W$ such that $T^{-1}$ is not a bounded linear map of $W$ onto $V$. [This exercise shows that the hypothesis in the Bounded Inverse Theorem (6.83) that $V$ is a Banach space cannot be relaxed to the hypothesis that $V$ is a normed vector space.] 15 Prove 6.84. 16 Suppose $V$ is a Banach space with norm $\|\cdot\|$ and that $\varphi: V \rightarrow \mathbf{F}$ is a linear functional. Define another norm $\|\cdot\|_{\varphi}$ on $V$ by $$ \|f\|_{\varphi}=\|f\|+|\varphi(f)| . $$ Prove that if $V$ is a Banach space with the norm $\|\cdot\|_{\varphi}$, then $\varphi$ is a continuous linear functional on $V$ (with the original norm). 17 Suppose $V$ is a Banach space, $W$ is a normed vector space, and $T_{1}, T_{2}, \ldots$ is a sequence of bounded linear maps from $V$ to $W$ such that $\lim _{k \rightarrow \infty} T_{k} f$ exists for each $f \in V$. Define $T: V \rightarrow W$ by $$ T f=\lim _{k \rightarrow \infty} T_{k} f $$ for $f \in V$. Prove that $T$ is a bounded linear map from $V$ to $W$. [This result states that the pointwise limit of a sequence of bounded linear maps on a Banach space is a bounded linear map.] 18 Suppose $V$ is a normed vector space and $B$ is a subset of $V$ such that $$ \sup _{f \in B}|\varphi(f)|<\infty $$ for every $\varphi \in V^{\prime}$. Prove that sup $\|f\|<\infty$. $$ f \in B $$ 19 Suppose $T: V \rightarrow W$ is a linear map from a Banach space $V$ to a Banach space $W$ such that $$ \varphi \circ T \in V^{\prime} \text { for all } \varphi \in W^{\prime} $$ Prove that $T$ is a bounded linear map. ## Chapter 7 ## $L^{p}$ Spaces Fix a measure space $(X, \mathcal{S}, \mu)$ and a positive number $p$. We begin this chapter by looking at the vector space of measurable functions $f: X \rightarrow \mathbf{F}$ such that $$ \int|f|^{p} d \mu<\infty $$ Important results called Hölder's inequality and Minkowski's inequality help us investigate this vector space. A useful class of Banach spaces appears when we identify functions that differ only on a set of measure 0 and require $p \geq 1$. ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-208.jpg?height=772&width=1145&top_left_y=865&top_left_x=79) The main building of the Swiss Federal Institute of Technology (ETH Zürich). Hermann Minkowski (1864-1909) taught at this university from 1896 to 1902. During this time, Albert Einstein (1879-1955) was a student in several of Minkowski's mathematics classes. Minkowski later created mathematics that helped explain Einstein's special theory of relativity. CC-BY-SA Roland zh ## $7 \mathrm{~A} \quad \mathcal{L}^{p}(\mu)$ ## Hölder's Inequality Our next major goal is to define an important class of vector spaces that generalize the vector spaces $\mathcal{L}^{1}(\mu)$ and $\ell^{1}$ introduced in the last two bullet points of Example 6.32. We begin this process with the definition below. The terminology p-norm introduced below is convenient, even though it is not necessarily a norm. ### 7.1 Definition $\|f\|_{p}$; essential supremum Suppose that $(X, \mathcal{S}, \mu)$ is a measure space, $00: \mu(\{x \in X:|f(x)|>t\})=0\} . $$ The exponent $1 / p$ appears in the definition of the $p$-norm $\|f\|_{p}$ because we want the equation $\|\alpha f\|_{p}=|\alpha|\|f\|_{p}$ to hold for all $\alpha \in \mathbf{F}$. For $00$ and define a function $f:(0, \infty) \rightarrow \mathbf{R}$ by $$ f(a)=\frac{a^{p}}{p}+\frac{b^{p^{\prime}}}{p^{\prime}}-a b $$ William Henry Young (1863-1942) published what is now called Young's inequality in 1912. Thus $f^{\prime}(a)=a^{p-1}-b$. Hence $f$ is decreasing on the interval $\left(0, b^{1 /(p-1)}\right)$ and $f$ is increasing on the interval $\left(b^{1 /(p-1)}, \infty\right)$. Thus $f$ has a global minimum at $b^{1 /(p-1)}$. A tiny bit of arithmetic [use $p /(p-1)=p^{\prime}$ ] shows that $f\left(b^{1 /(p-1)}\right)=0$. Thus $f(a) \geq 0$ for all $a \in(0, \infty)$, which implies the desired inequality. The important result below furnishes a key tool that is used in the proof of Minkowski's inequality (7.14). ### 7.9 Hölder's inequality Suppose $(X, \mathcal{S}, \mu)$ is a measure space, $1 \leq p \leq \infty$, and $f, h: X \rightarrow \mathbf{F}$ are $\mathcal{S}$-measurable. Then $$ \|f h\|_{1} \leq\|f\|_{p}\|h\|_{p^{\prime}} . $$ Proof Suppose $11$. A short calculation shows that $r^{\prime}=\frac{q}{q-p}$. Now Hölder's inequality (7.9) with $p$ replaced by $r$ and $f$ replaced by $|f|^{p}$ and $h$ replaced by the constant function 1 gives $$ \begin{aligned} \int|f|^{p} d \mu & \leq\left(\int\left(|f|^{p}\right)^{r} d \mu\right)^{1 / r}\left(\int 1^{r^{\prime}} d \mu\right)^{1 / r^{\prime}} \\ & =\mu(X)^{(q-p) / q}\left(\int|f|^{q} d \mu\right)^{p / q} . \end{aligned} $$ Now raise both sides of the inequality above to the power $\frac{1}{p}$, getting $$ \left(\int|f|^{p} d \mu\right)^{1 / p} \leq \mu(X)^{(q-p) /(p q)}\left(\int|f|^{q} d \mu\right)^{1 / q} $$ which is the desired inequality. The inequality above shows that $f \in \mathcal{L}^{p}(\mu)$. Thus $\mathcal{L}^{q}(\mu) \subset \mathcal{L}^{p}(\mu)$. ### 7.11 Example $\mathcal{L}^{p}(E)$ We adopt the common convention that if $E$ is a Borel (or Lebesgue measurable) subset of $\mathbf{R}$ and $0

1} \ell^{p} \neq \ell^{1}$. 12 Show that $\bigcap_{p<\infty} \mathcal{L}^{p}([0,1]) \neq \mathcal{L}^{\infty}([0,1])$. 13 Show that $\bigcup_{p>1} \mathcal{L}^{p}([0,1]) \neq \mathcal{L}^{1}([0,1])$. 14 Suppose $p, q \in(0, \infty]$, with $p \neq q$. Prove that neither of the sets $\mathcal{L}^{p}(\mathbf{R})$ and $\mathcal{L}^{q}(\mathbf{R})$ is a subset of the other. 15 Show that there exists $f \in \mathcal{L}^{2}(\mathbf{R})$ such that $f \notin \mathcal{L}^{p}(\mathbf{R})$ for all $p \in(0, \infty] \backslash\{2\}$. 16 Suppose $(X, \mathcal{S}, \mu)$ is a finite measure space. Prove that $$ \lim _{p \rightarrow \infty}\|f\|_{p}=\|f\|_{\infty} $$ for every $\mathcal{S}$-measurable function $f: X \rightarrow \mathbf{F}$. 17 Suppose $\mu$ is a measure, $0

0$, there exists a simple function $g \in \mathcal{L}^{p}(\mu)$ such that $\|f-g\|_{p}<\varepsilon$. [This exercise extends 3.44.] 18 Suppose $00$, there exists a step function $g \in \mathcal{L}^{p}(\mathbf{R})$ such that $\|f-g\|_{p}<\varepsilon$. [This exercise extends 3.47.] 19 Suppose $00$, there exists a continuous function $g: \mathbf{R} \rightarrow \mathbf{F}$ such that $\|f-g\|_{p}<\varepsilon$ and the set $\{x \in \mathbf{R}: g(x) \neq 0\}$ is bounded. [This exercise extends 3.48.] 20 Suppose $(X, \mathcal{S}, \mu)$ is a measure space, $10$, define $f_{t}: \mathbf{R} \rightarrow \mathbf{R}$ by $f_{t}(x)=f(t x)$. Prove that $$ \lim _{t \rightarrow 1}\left\|f-f_{t}\right\|_{p}=0 $$ 24 Suppose $1 \leq p<\infty$ and $f \in \mathcal{L}^{p}(\mathbf{R})$. Prove that $$ \lim _{t \downarrow 0} \frac{1}{2 t} \int_{b-t}^{b+t}|f-f(b)|^{p}=0 $$ for almost every $b \in \mathbf{R}$. ## 7B $L^{p}(\mu)$ ## Definition of $L^{p}(\mu)$ Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $1 \leq p \leq \infty$. If there exists a nonempty set $E \in \mathcal{S}$ such that $\mu(E)=0$, then $\left\|\chi_{E}\right\|_{p}=0$ even though $\chi_{E} \neq 0$; thus $\|\cdot\|_{p}$ is not a norm on $\mathcal{L}^{p}(\mu)$. The standard way to deal with this problem is to identify functions that differ only on a set of $\mu$-measure 0 . To help make this process more rigorous, we introduce the following definitions. 7.15 Definition $\mathcal{Z}(\mu) ; \widetilde{f}$ Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $0

0$, there exists $n \in \mathbf{Z}^{+}$ such that $$ \left\|f_{j}-f_{k}\right\|_{p}<\varepsilon $$ for all $j \geq n$ and $k \geq n$. Then there exists $f \in \mathcal{L}^{p}(\mu)$ such that $$ \lim _{k \rightarrow \infty}\left\|f_{k}-f\right\|_{p}=0 $$ Proof The case $p=\infty$ is left as an exercise for the reader. Thus assume $1 \leq p<\infty$. It suffices to show that $\lim _{m \rightarrow \infty}\left\|f_{k_{m}}-f\right\|_{p}=0$ for some $f \in \mathcal{L}^{p}(\mu)$ and some subsequence $f_{k_{1}}, f_{k_{2}}, \ldots$ (see Exercise 14 of Section $6 \mathrm{~A}$, whose proof does not require the positive definite property of a norm). Thus dropping to a subsequence (but not relabeling) and setting $f_{0}=0$, we can assume that $$ \sum_{k=1}^{\infty}\left\|f_{k}-f_{k-1}\right\|_{p}<\infty $$ Define functions $g_{1}, g_{2}, \ldots$ and $g$ from $X$ to $[0, \infty]$ by $$ g_{m}(x)=\sum_{k=1}^{m}\left|f_{k}(x)-f_{k-1}(x)\right| \quad \text { and } \quad g(x)=\sum_{k=1}^{\infty}\left|f_{k}(x)-f_{k-1}(x)\right| $$ Minkowski's inequality (7.14) implies that $$ \left\|g_{m}\right\|_{p} \leq \sum_{k=1}^{m}\left\|f_{k}-f_{k-1}\right\|_{p} $$ Clearly $\lim _{m \rightarrow \infty} g_{m}(x)=g(x)$ for every $x \in X$. Thus the Monotone Convergence Theorem (3.11) and 7.21 imply 7.22 $$ \int g^{p} d \mu=\lim _{m \rightarrow \infty} \int g_{m}^{p} d \mu \leq\left(\sum_{k=1}^{\infty}\left\|f_{k}-f_{k-1}\right\|_{p}\right)^{p}<\infty $$ Thus $g(x)<\infty$ for almost every $x \in X$. Because every infinite series of real numbers that converges absolutely also converges, for almost every $x \in X$ we can define $f(x)$ by $$ f(x)=\sum_{k=1}^{\infty}\left(f_{k}(x)-f_{k-1}(x)\right)=\lim _{m \rightarrow \infty} \sum_{k=1}^{m}\left(f_{k}(x)-f_{k-1}(x)\right)=\lim _{m \rightarrow \infty} f_{m}(x) . $$ In particular, $\lim _{m \rightarrow \infty} f_{m}(x)$ exists for almost every $x \in X$. Define $f(x)$ to be 0 for those $x \in X$ for which the limit does not exist. We now have a function $f$ that is the pointwise limit (almost everywhere) of $f_{1}, f_{2}, \ldots$. The definition of $f$ shows that $|f(x)| \leq g(x)$ for almost every $x \in X$. Thus 7.22 shows that $f \in \mathcal{L}^{p}(\mu)$. To show that $\lim _{k \rightarrow \infty}\left\|f_{k}-f\right\|_{p}=0$, suppose $\varepsilon>0$ and let $n \in \mathbf{Z}^{+}$be such that $\left\|f_{j}-f_{k}\right\|_{p}<\varepsilon$ for all $j \geq n$ and $k \geq n$. Suppose $k \geq n$. Then $$ \begin{aligned} \left\|f_{k}-f\right\|_{p} & =\left(\int\left|f_{k}-f\right|^{p} d \mu\right)^{1 / p} \\ & \leq \liminf _{j \rightarrow \infty}\left(\int\left|f_{k}-f_{j}\right|^{p} d \mu\right)^{1 / p} \\ & =\liminf _{j \rightarrow \infty}\left\|f_{k}-f_{j}\right\|_{p} \\ & \leq \varepsilon \end{aligned} $$ where the second line above comes from Fatou's Lemma (Exercise 17 in Section 3A). Thus $\lim _{k \rightarrow \infty}\left\|f_{k}-f\right\|_{p}=0$, as desired. The proof that we have just completed contains within it the proof of a useful result that is worth stating separately. A sequence can converge in $p$-norm without converging pointwise anywhere (see, for example, Exercise 12). However, the next result guarantees that some subsequence converges pointwise almost everywhere. ### 7.23 convergent sequences in $\mathcal{L}^{p}$ have pointwise convergent subsequences Suppose $(X, \mathcal{S}, \mu)$ is a measure space and $1 \leq p \leq \infty$. Suppose $f \in \mathcal{L}^{p}(\mu)$ and $f_{1}, f_{2}, \ldots$ is a sequence of functions in $\mathcal{L}^{p}(\mu)$ such that $\lim _{k \rightarrow \infty}\left\|f_{k}-f\right\|_{p}=0$. Then there exists a subsequence $f_{k_{1}}, f_{k_{2}}, \ldots$ such that $$ \lim _{m \rightarrow \infty} f_{k_{m}}(x)=f(x) $$ for almost every $x \in X$. Proof Suppose $f_{k_{1}}, f_{k_{2}}, \ldots$ is a subsequence such that $$ \sum_{m=2}^{\infty}\left\|f_{k_{m}}-f_{k_{m-1}}\right\|_{p}<\infty $$ An examination of the proof of 7.20 shows that $\lim _{m \rightarrow \infty} f_{k_{m}}(x)=f(x)$ for almost every $x \in X$. ### 7.24 $L^{p}(\mu)$ is a Banach space Suppose $\mu$ is a measure and $1 \leq p \leq \infty$. Then $L^{p}(\mu)$ is a Banach space. Proof This result follows immediately from 7.20 and the appropriate definitions. ## Duality Recall that the dual space of a normed vector space $V$ is denoted by $V^{\prime}$ and is defined to be the Banach space of bounded linear functionals on $V$ (see 6.71). In the statement and proof of the next result, an element of an $L^{p}$ space is denoted by a symbol that makes it look like a function rather than like a collection of functions that agree except on a set of measure 0 . However, because integrals and $L^{p}$-norms are unchanged when functions change only on a set of measure 0 , this notational convenience causes no problems. 7.25 natural map of $L^{p^{\prime}}(\mu)$ into $\left(L^{p}(\mu)\right)^{\prime}$ preserves norms Suppose $\mu$ is a measure and $1

1$ and $03$. Thus the angle between two nonzero vectors $a, b \in \mathbf{R}^{n}$ is defined to be $$ \arccos \frac{\langle a, b\rangle}{\|a\|\|b\|} $$ where the motivation for this definition comes from the previous exercise. Explain why the Cauchy-Schwarz inequality is needed to show that this definition makes sense. 10 (a) Suppose $f$ and $g$ are elements of a real inner product space. Prove that $f$ and $g$ have the same norm if and only if $f+g$ is orthogonal to $f-g$. (b) Use part (a) to show that the diagonals of a parallelogram are perpendicular to each other if and only if the parallelogram is a rhombus. 11 Suppose $f$ and $g$ are elements of an inner product space. Prove that $\|f\|=\|g\|$ if and only if $\|s f+t g\|=\|t f+s g\|$ for all $s, t \in \mathbf{R}$. 12 Suppose $f$ and $g$ are elements of an inner product space and $\|f\|=\|g\|=1$ and $\langle f, g\rangle=1$. Prove that $f=g$. 13 Suppose $f$ and $g$ are elements of a real inner product space. Prove that $$ \langle f, g\rangle=\frac{\|f+g\|^{2}-\|f-g\|^{2}}{4} $$ 14 Suppose $f$ and $g$ are elements of a complex inner product space. Prove that $$ \langle f, g\rangle=\frac{\|f+g\|^{2}-\|f-g\|^{2}+\|f+i g\|^{2} i-\|f-i g\|^{2} i}{4} . $$ 15 Suppose $f, g, h$ are elements of an inner product space. Prove that $$ \left\|h-\frac{1}{2}(f+g)\right\|^{2}=\frac{\|h-f\|^{2}+\|h-g\|^{2}}{2}-\frac{\|f-g\|^{2}}{4} \text {. } $$ 16 Prove that a norm satisfying the parallelogram equality comes from an inner product. In other words, show that if $V$ is a normed vector space whose norm $\|\cdot\|$ satisfies the parallelogram equality, then there is an inner product $\langle\cdot, \cdot\rangle$ on $V$ such that $\|f\|=\langle f, f\rangle^{1 / 2}$ for all $f \in V$. 17 Let $\lambda$ denote Lebesgue measure on $[1, \infty)$. (a) Prove that if $f:[1, \infty) \rightarrow[0, \infty)$ is Borel measurable, then $$ \left(\int_{1}^{\infty} f(x) d \lambda(x)\right)^{2} \leq \int_{1}^{\infty} x^{2}(f(x))^{2} d \lambda(x) $$ (b) Describe the set of Borel measurable functions $f:[1, \infty) \rightarrow[0, \infty)$ such that the inequality in part (a) is an equality. 18 Suppose $\mu$ is a measure. For $f, g \in L^{2}(\mu)$, define $\langle f, g\rangle$ by $$ \langle f, g\rangle=\int f \bar{g} d \mu $$ (a) Using the inequality $$ |f(x) \overline{g(x)}| \leq \frac{1}{2}\left(|f(x)|^{2}+|g(x)|^{2}\right) $$ verify that the integral above makes sense and the map sending $f, g$ to $\langle f, g\rangle$ defines an inner product on $L^{2}(\mu)$ (without using Hölder's inequality). (b) Show that the Cauchy-Schwarz inequality implies that $$ \|f g\|_{1} \leq\|f\|_{2}\|g\|_{2} $$ for all $f, g \in L^{2}(\mu)$ (again, without using Hölder's inequality). 19 Suppose $V_{1}, \ldots, V_{m}$ are inner product spaces. Show that the equation $$ \left\langle\left(f_{1}, \ldots, f_{m}\right),\left(g_{1}, \ldots, g_{m}\right)\right\rangle=\left\langle f_{1}, g_{1}\right\rangle+\cdots+\left\langle f_{m}, g_{m}\right\rangle $$ defines an inner product on $V_{1} \times \cdots \times V_{m}$. [Each of the inner product spaces $V_{1}, \ldots, V_{m}$ may have a different inner product, even though the same inner product notation is used on all these spaces.] 20 Suppose $V$ is an inner product space. Make $V \times V$ an inner product space as in the exercise above. Prove that the function that takes an ordered pair $(f, g) \in V \times V$ to the inner product $\langle f, g\rangle \in \mathbf{F}$ is a continuous function from $V \times V$ to $\mathbf{F}$. 21 Suppose $1 \leq p \leq \infty$. (a) Show the norm on $\ell^{p}$ comes from an inner product if and only if $p=2$. (b) Show the norm on $L^{p}(\mathbf{R})$ comes from an inner product if and only if $p=2$. 22 Use inner products to prove Apollonius's identity: In a triangle with sides of length $a, b$, and $c$, let $d$ be the length of the line segment from the midpoint of the side of length $c$ to the opposite vertex. Then $$ a^{2}+b^{2}=\frac{1}{2} c^{2}+2 d^{2} . $$ ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-238.jpg?height=388&width=421&top_left_y=1685&top_left_x=760) ## 8B Orthogonality ## Orthogonal Projections The previous section developed inner product spaces following a standard linear algebra approach. Linear algebra focuses mainly on finite-dimensional vector spaces. Many interesting results about infinite-dimensional inner product spaces require an additional hypothesis, which we now introduce. ### 8.21 Definition Hilbert space A Hilbert space is an inner product space that is a Banach space with the norm determined by the inner product. ### 8.22 Example Hilbert spaces - Suppose $\mu$ is a measure. Then $L^{2}(\mu)$ with its usual inner product is a Hilbert space (by 7.24). - As a special case of the first bullet point, if $n \in \mathbf{Z}^{+}$then taking $\mu$ to be counting measure on $\{1, \ldots, n\}$ shows that $\mathbf{F}^{n}$ with its usual inner product is a Hilbert space. - As another special case of the first bullet point, taking $\mu$ to be counting measure on $\mathbf{Z}^{+}$shows that $\ell^{2}$ with its usual inner product is a Hilbert space. - Every closed subspace of a Hilbert space is a Hilbert space [by 6.16(b)]. ### 8.23 Example not Hilbert spaces - The inner product space $\ell^{1}$, where $\left\langle\left(a_{1}, a_{2}, \ldots\right),\left(b_{1}, b_{2}, \ldots\right)\right\rangle=\sum_{k=1}^{\infty} a_{k} \overline{b_{k}}$, is not a Hilbert space because the associated norm is not complete on $\ell$. - The inner product space $C([0,1])$ of continuous $\mathbf{F}$-valued functions on the interval $[0,1]$, where $\langle f, g\rangle=\int_{0}^{1} f \bar{g}$, is not a Hilbert space because the associated norm is not complete on $C([0,1])$. The next definition makes sense in the context of normed vector spaces. ### 8.24 Definition distance from a point to a set Suppose $U$ is a nonempty subset of a normed vector space $V$ and $f \in V$. The distance from $f$ to $U$, denoted distance $(f, U)$, is defined by $$ \text { distance }(f, U)=\inf \{\|f-g\|: g \in U\} \text {. } $$ Notice that distance $(f, U)=0$ if and only if $f \in \bar{U}$. ### 8.25 Definition convex - A subset of a vector space is called convex if the subset contains the line segment connecting each pair of points in it. - More precisely, suppose $V$ is a vector space and $U \subset V$. Then $U$ is called convex if $$ (1-t) f+t g \in U \text { for all } t \in[0,1] \text { and all } f, g \in U \text {. } $$ ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-240.jpg?height=220&width=419&top_left_y=589&top_left_x=153) Convex subset of $\mathbf{R}^{2}$. ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-240.jpg?height=220&width=416&top_left_y=589&top_left_x=724) Nonconvex subset of $\mathbf{R}^{2}$. ### 8.26 Example convex sets - Every subspace of a vector space is convex, as you should verify. - If $V$ is a normed vector space, $f \in V$, and $r>0$, then the open ball centered at $f$ with radius $r$ is convex, as you should verify. The next example shows that the distance from an element of a Banach space to a closed subspace is not necessarily attained by some element of the closed subspace. After this example, we will prove that this behavior cannot happen in a Hilbert space. ### 8.27 Example no closest element to a closed subspace of a Banach space In the Banach space $C([0,1])$ with norm $\|g\|=\sup |g|$, let $$ U=\left\{g \in C([0,1]): \int_{0}^{1} g=0 \text { and } g(1)=0\right\} $$ Then $U$ is a closed subspace of $C([0,1])$. Let $f \in C([0,1])$ be defined by $f(x)=1-x$. For $k \in \mathbf{Z}^{+}$, let $$ g_{k}(x)=\frac{1}{2}-x+\frac{x^{k}}{2}+\frac{x-1}{k+1} $$ Then $g_{k} \in U$ and $\lim _{k \rightarrow \infty}\left\|f-g_{k}\right\|=\frac{1}{2}$, which implies that distance $(f, U) \leq \frac{1}{2}$. If $g \in U$, then $\int_{0}^{1}(f-g)=\frac{1}{2}$ and $(f-g)(1)=0$. These conditions imply that $\|f-g\|>\frac{1}{2}$. Thus distance $(f, U)=\frac{1}{2}$ but there does not exist $g \in U$ such that $\|f-g\|=\frac{1}{2}$. In the next result, we use for the first time the hypothesis that $V$ is a Hilbert space. ### 8.28 distance to a closed convex set is attained in a Hilbert space - The distance from an element of a Hilbert space to a nonempty closed convex set is attained by a unique element of the nonempty closed convex set. - More specifically, suppose $V$ is a Hilbert space, $f \in V$, and $U$ is a nonempty closed convex subset of $V$. Then there exists a unique $g \in U$ such that $$ \|f-g\|=\operatorname{distance}(f, U) \text {. } $$ Proof First we prove the existence of an element of $U$ that attains the distance to $f$. To do this, suppose $g_{1}, g_{2}, \ldots$ is a sequence of elements of $U$ such that 8.29 $$ \lim _{k \rightarrow \infty}\left\|f-g_{k}\right\|=\operatorname{distance}(f, U) $$ Then for $j, k \in \mathbf{Z}^{+}$we have $$ \begin{aligned} \left\|g_{j}-g_{k}\right\|^{2} & =\left\|\left(f-g_{k}\right)-\left(f-g_{j}\right)\right\|^{2} \\ & =2\left\|f-g_{k}\right\|^{2}+2\left\|f-g_{j}\right\|^{2}-\left\|2 f-\left(g_{k}+g_{j}\right)\right\|^{2} \\ & =2\left\|f-g_{k}\right\|^{2}+2\left\|f-g_{j}\right\|^{2}-4\left\|f-\frac{g_{k}+g_{j}}{2}\right\|^{2} \end{aligned} $$ 8.30 $$ \leq 2\left\|f-g_{k}\right\|^{2}+2\left\|f-g_{j}\right\|^{2}-4(\operatorname{distance}(f, U))^{2}, $$ where the second equality comes from the parallelogram equality (8.20) and the last line holds because the convexity of $U$ implies that $\left(g_{k}+g_{j}\right) / 2 \in U$. Now the inequality above and 8.29 imply that $g_{1}, g_{2}, \ldots$ is a Cauchy sequence. Thus there exists $g \in V$ such that 8.31 $$ \lim _{k \rightarrow \infty}\left\|g_{k}-g\right\|=0 $$ Because $U$ is a closed subset of $V$ and each $g_{k} \in U$, we know that $g \in U$. Now 8.29 and 8.31 imply that $$ \|f-g\|=\operatorname{distance}(f, U) \text {, } $$ which completes the existence proof of the existence part of this result. To prove the uniqueness part of this result, suppose $g$ and $\widetilde{g}$ are elements of $U$ such that $$ \|f-g\|=\|f-\widetilde{g}\|=\operatorname{distance}(f, U) \text {. } $$ Then $$ \begin{aligned} \|g-\widetilde{g}\|^{2} & \leq 2\|f-g\|^{2}+2\|f-\widetilde{g}\|^{2}-4(\text { distance }(f, U))^{2} \\ & =0, \end{aligned} $$ where the first line above follows from 8.30 (with $g_{j}$ replaced by $g$ and $g_{k}$ replaced by $\widetilde{g}$ ) and the last line above follows from 8.32. Now 8.33 implies that $g=\widetilde{g}$, completing the proof of uniqueness. Example 8.27 showed that the existence part of the previous result can fail in a Banach space. Exercise 13 shows that the uniqueness part can also fail in a Banach space. These observations highlight the advantages of working in a Hilbert space. ### 8.34 Definition orthogonal projection; $P_{U}$ Suppose $U$ is a nonempty closed convex subset of a Hilbert space $V$. The orthogonal projection of $V$ onto $U$ is the function $P_{U}: V \rightarrow V$ defined by setting $P_{U}(f)$ equal to the unique element of $U$ that is closest to $f$. The definition above makes sense because of 8.28 . We will often use the notation $P_{U} f$ instead of $P_{U}(f)$. To test your understanding of the definition above, make sure that you can show that if $U$ is a nonempty closed convex subset of a Hilbert space $V$, then - $P_{U} f=f$ if and only if $f \in U$; - $P_{U} \circ P_{U}=P_{U}$. ### 8.35 Example orthogonal projection onto closed unit ball Suppose $U$ is the closed unit ball $\{g \in V:\|g\| \leq 1\}$ in a Hilbert space $V$. Then $$ P_{U} f= \begin{cases}f & \text { if }\|f\| \leq 1 \\ \frac{f}{\|f\|} & \text { if }\|f\|>1\end{cases} $$ as you should verify. ### 8.36 Example orthogonal projection onto a closed subspace Suppose $U$ is the closed subspace of $\ell^{2}$ consisting of the elements of $\ell^{2}$ whose even coordinates are all 0 : $$ U=\left\{\left(a_{1}, 0, a_{3}, 0, a_{5}, 0, \ldots\right): \text { each } a_{k} \in \mathbf{F} \text { and } \sum_{k=1}^{\infty}\left|a_{2 k-1}\right|^{2}<\infty\right\} $$ Then for $b=\left(b_{1}, b_{2}, b_{3}, b_{4}, b_{5}, b_{6}, \ldots\right) \in \ell^{2}$, we have $$ P_{U} b=\left(b_{1}, 0, b_{3}, 0, b_{5}, 0, \ldots\right), $$ as you should verify. Note that in this example the function $P_{U}$ is a linear map from $\ell^{2}$ to $\ell^{2}$ (unlike the behavior in Example 8.35). Also, notice that $b-P_{U} b=\left(0, b_{2}, 0, b_{4}, 0, b_{6}, \ldots\right)$ and thus $b-P_{U} b$ is orthogonal to every element of $U$. The next result shows that the properties stated in the last two paragraphs of the example above hold whenever $U$ is a closed subspace of a Hilbert space. ### 8.37 orthogonal projection onto closed subspace Suppose $U$ is a closed subspace of a Hilbert space $V$ and $f \in V$. Then (a) $f-P_{U} f$ is orthogonal to $g$ for every $g \in U$; (b) if $h \in U$ and $f-h$ is orthogonal to $g$ for every $g \in U$, then $h=P_{U} f$; (c) $P_{U}: V \rightarrow V$ is a linear map; (d) $\left\|P_{U} f\right\| \leq\|f\|$, with equality if and only if $f \in U$. Proof The figure below illustrates (a). To prove (a), suppose $g \in U$. Then for all $\alpha \in \mathbf{F}$ we have $$ \begin{aligned} \left\|f-P_{U} f\right\|^{2} & \leq\left\|f-P_{U} f+\alpha g\right\|^{2} \\ & =\left\langle f-P_{U} f+\alpha g, f-P_{U} f+\alpha g\right\rangle \\ & =\left\|f-P_{U} f\right\|^{2}+|\alpha|^{2}\|g\|^{2}+2 \operatorname{Re} \bar{\alpha}\left\langle f-P_{U} f, g\right\rangle . \end{aligned} $$ Let $\alpha=-t\left\langle f-P_{U} f, g\right\rangle$ for $t>0$. A tiny bit of algebra applied to the inequality above implies $$ 2\left|\left\langle f-P_{U} f, g\right\rangle\right|^{2} \leq t\left|\left\langle f-P_{U} f, g\right\rangle\right|^{2}\|g\|^{2} $$ for all $t>0$. Thus $\left\langle f-P_{U} f, g\right\rangle=0$, completing the proof of (a). To prove (b), suppose $h \in U$ and $f-h$ is orthogonal to $g$ for every $g \in U$. If $g \in U$, then $h-g \in U$ and hence $f-h$ is orthogonal to $h-g$. Thus $$ \begin{aligned} \|f-h\|^{2} & \leq\|f-h\|^{2}+\|h-g\|^{2} \\ & =\|(f-h)+(h-g)\|^{2} \\ & =\|f-g\|^{2}, \end{aligned} $$ ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-243.jpg?height=250&width=574&top_left_y=1167&top_left_x=619) $f-P_{U} f$ is orthogonal to each element of $U$. where the first equality above follows from the Pythagorean Theorem (8.9). Thus $$ \|f-h\| \leq\|f-g\| $$ for all $g \in U$. Hence $h$ is the element of $U$ that minimizes the distance to $f$, which implies that $h=P_{U} f$, completing the proof of (b). To prove (c), suppose $f_{1}, f_{2} \in V$. If $g \in U$, then (a) implies that $\left\langle f_{1}-P_{U} f_{1}, g\right\rangle=$ $\left\langle f_{2}-P_{U} f_{2}, g\right\rangle=0$, and thus $$ \left\langle\left(f_{1}+f_{2}\right)-\left(P_{U} f_{1}+P_{U} f_{2}\right), g\right\rangle=0 . $$ The equation above and (b) now imply that $$ P_{U}\left(f_{1}+f_{2}\right)=P_{U} f_{1}+P_{U} f_{2} . $$ The equation above and the equation $P_{U}(\alpha f)=\alpha P_{U} f$ for $\alpha \in \mathbf{F}$ (whose proof is left to the reader) show that $P_{U}$ is a linear map, proving (c). The proof of (d) is left as an exercise for the reader. ## Orthogonal Complements ### 8.38 Definition orthogonal complement; $U^{\perp}$ Suppose $U$ is a subset of an inner product space $V$. The orthogonal complement of $U$ is denoted by $U^{\perp}$ and is defined by $$ U^{\perp}=\{h \in V:\langle g, h\rangle=0 \text { for all } g \in U\} $$ In other words, the orthogonal complement of a subset $U$ of an inner product space $V$ is the set of elements of $V$ that are orthogonal to every element of $U$. ### 8.39 Example orthogonal complement Suppose $U$ is the set of elements of $\ell^{2}$ whose even coordinates are all 0 : $$ U=\left\{\left(a_{1}, 0, a_{3}, 0, a_{5}, 0, \ldots\right): \text { each } a_{k} \in \mathbf{F} \text { and } \sum_{k=1}^{\infty}\left|a_{2 k-1}\right|^{2}<\infty\right\} $$ Then $U^{\perp}$ is the set of elements of $\ell^{2}$ whose odd coordinates are all 0 : as you should verify. $$ \left.U^{\perp}=\left\{0, a_{2}, 0, a_{4}, 0, a_{6}, \ldots\right): \text { each } a_{k} \in \mathbf{F} \text { and } \sum_{k=1}^{\infty}\left|a_{2 k}\right|^{2}<\infty\right\} $$ ### 8.40 properties of orthogonal complement Suppose $U$ is a subset of an inner product space $V$. Then (a) $U^{\perp}$ is a closed subspace of $V$; (b) $U \cap U^{\perp} \subset\{0\}$; (c) if $W \subset U$, then $U^{\perp} \subset W^{\perp}$; (d) $\bar{U}^{\perp}=U^{\perp}$; (e) $U \subset\left(U^{\perp}\right)^{\perp}$. Proof To prove (a), suppose $h_{1}, h_{2}, \ldots$ is a sequence in $U^{\perp}$ that converges to some $h \in V$. If $g \in U$, then $$ |\langle g, h\rangle|=\left|\left\langle g, h-h_{k}\right\rangle\right| \leq\|g\|\left\|h-h_{k}\right\| \quad \text { for each } k \in \mathbf{Z}^{+} \text {; } $$ hence $\langle g, h\rangle=0$, which implies that $h \in U^{\perp}$. Thus $U^{\perp}$ is closed. The proof of (a) is completed by showing that $U^{\perp}$ is a subspace of $V$, which is left to the reader. To prove (b), suppose $g \in U \cap U^{\perp}$. Then $\langle g, g\rangle=0$, which implies that $g=0$, proving (b). To prove (e), suppose $g \in U$. Thus $\langle g, h\rangle=0$ for all $h \in U^{\perp}$, which implies that $g \in\left(U^{\perp}\right)^{\perp}$. Hence $U \subset\left(U^{\perp}\right)^{\perp}$, proving (e). The proofs of (c) and (d) are left to the reader. The results in the rest of this subsection have as a hypothesis that $V$ is a Hilbert space. These results do not hold when $V$ is only an inner product space. ### 8.41 orthogonal complement of the orthogonal complement Suppose $U$ is a subspace of a Hilbert space $V$. Then $$ \bar{U}=\left(U^{\perp}\right)^{\perp} . $$ Proof Applying 8.40(a) to $U^{\perp}$, we see that $\left(U^{\perp}\right)^{\perp}$ is a closed subspace of $V$. Now taking closures of both sides of the inclusion $U \subset\left(U^{\perp}\right)^{\perp}$ [8.40(e)] shows that $\bar{U} \subset\left(U^{\perp}\right)^{\perp}$. To prove the inclusion in the other direction, suppose $f \in\left(U^{\perp}\right)^{\perp}$. Because $f \in\left(U^{\perp}\right)^{\perp}$ and $P_{\bar{U}} f \in \bar{U} \subset\left(U^{\perp}\right)^{\perp}$ (by the previous paragraph), we see that $$ f-P_{\bar{U}} f \in\left(U^{\perp}\right)^{\perp} $$ Also, $$ f-P_{\bar{U}} f \in U^{\perp} $$ by $8.37(a)$ and $8.40(d)$. Hence $$ f-P_{\bar{U}} f \in U^{\perp} \cap\left(U^{\perp}\right)^{\perp} . $$ Now 8.40(b) (applied to $U^{\perp}$ in place of $U$ ) implies that $f-P_{\bar{U}} f=0$, which implies that $f \in \bar{U}$. Thus $\left(U^{\perp}\right)^{\perp} \subset \bar{U}$, completing the proof. As a special case, the result above implies that if $U$ is a closed subspace of a Hilbert space $V$, then $U=\left(U^{\perp}\right)^{\perp}$. Another special case of the result above is sufficiently useful to deserve stating separately, as we do in the next result. ### 8.42 necessary and sufficient condition for a subspace to be dense Suppose $U$ is a subspace of a Hilbert space $V$. Then $$ \bar{U}=V \text { if and only if } U^{\perp}=\{0\} $$ Proof First suppose $\bar{U}=V$. Then using $8.40(\mathrm{~d})$, we have $$ U^{\perp}=\bar{U}^{\perp}=V^{\perp}=\{0\} . $$ To prove the other direction, now suppose $U^{\perp}=\{0\}$. Then 8.41 implies that $$ \bar{U}=\left(U^{\perp}\right)^{\perp}=\{0\}^{\perp}=V $$ completing the proof. The next result states that if $U$ is a closed subspace of a Hilbert space $V$, then $V$ is the direct sum of $U$ and $U^{\perp}$, often written $V=U \oplus U^{\perp}$, although we do not need to use this terminology or notation further. The key point to keep in mind is that the next result shows that the picture here represents what happens in general for a closed subspace $U$ of a Hilbert space $V$ : every element of $V$ can be uniquely written as an element of $U$ plus an element of $U^{\perp}$. ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-246.jpg?height=527&width=518&top_left_y=155&top_left_x=688) ### 8.43 orthogonal decomposition Suppose $U$ is a closed subspace of a Hilbert space $V$. Then every element $f \in V$ can be uniquely written in the form $$ f=g+h, $$ where $g \in U$ and $h \in U^{\perp}$. Furthermore, $g=P_{U} f$ and $h=f-P_{U} f$. Proof Suppose $f \in V$. Then $$ f=P_{U} f+\left(f-P_{U} f\right) $$ where $P_{U} f \in U$ [by definition of $P_{U} f$ as the element of $U$ that is closest to $f$ ] and $f-P_{U} f \in U^{\perp}$ [by 8.37(a)]. Thus we have the desired decomposition of $f$ as the sum of an element of $U$ and an element of $U^{\perp}$. To prove the uniqueness of this decomposition, suppose $$ f=g_{1}+h_{1}=g_{2}+h_{2} $$ where $g_{1}, g_{2} \in U$ and $h_{1}, h_{2} \in U^{\perp}$. Then $g_{1}-g_{2}=h_{2}-h_{1} \in U \cap U^{\perp}$, which implies that $g_{1}=g_{2}$ and $h_{1}=h_{2}$, as desired. In the next definition, the function $I$ depends upon the vector space $V$. Thus a notation such as $I_{V}$ might be more precise. However, the domain of $I$ should always be clear from the context. ### 8.44 Definition identity map; I Suppose $V$ is a vector space. The identity map $I$ is the linear map from $V$ to $V$ defined by I $f=f$ for $f \in V$. The next result highlights the close relationship between orthogonal projections and orthogonal complements. ### 8.45 range and null space of orthogonal projections Suppose $U$ is a closed subspace of a Hilbert space $V$. Then (a) range $P_{U}=U$ and null $P_{U}=U^{\perp}$; (b) range $P_{U^{\perp}}=U^{\perp}$ and null $P_{U^{\perp}}=U$; (c) $P_{U^{\perp}}=I-P_{U}$. Proof The definition of $P_{U} f$ as the closest point in $U$ to $f$ implies range $P_{U} \subset U$. Because $P_{U} g=g$ for all $g \in U$, we also have $U \subset$ range $P_{U}$. Thus range $P_{U}=U$. If $f \in$ null $P_{U}$, then $f \in U^{\perp}$ [by 8.37(a)]. Thus null $P_{U} \subset U^{\perp}$. Conversely, if $f \in U^{\perp}$, then 8.37(b) (with $h=0$ ) implies that $P_{U} f=0$; hence $U^{\perp} \subset$ null $P_{U}$. Thus null $P_{U}=U^{\perp}$, completing the proof of (a). Replace $U$ by $U^{\perp}$ in (a), getting range $P_{U^{\perp}}=U^{\perp}$ and null $P_{U^{\perp}}=\left(U^{\perp}\right)^{\perp}=U$ (where the last equality comes from 8.41), completing the proof of (b). Finally, if $f \in U$, then $$ P_{U^{\perp}} f=0=f-P_{U} f=\left(I-P_{U}\right) f $$ where the first equality above holds because null $P_{U^{\perp}}=U$ [by (b)]. If $f \in U^{\perp}$, then $$ P_{U \perp} f=f=f-P_{U} f=\left(I-P_{U}\right) f, $$ where the second equality above holds because null $P_{U}=U^{\perp}$ [by (a)]. The last two displayed equations show that $P_{U^{\perp}}$ and $I-P_{U}$ agree on $U$ and agree on $U^{\perp}$. Because $P_{U^{\perp}}$ and $I-P_{U}$ are both linear maps and because each element of $V$ equals some element of $U$ plus some element of $U^{\perp}$ (by 8.43), this implies that $P_{U^{\perp}}=I-P_{U}$, completing the proof of (c). ### 8.46 Example $P_{U^{\perp}}=I-P_{U}$ Suppose $U$ is the closed subspace of $L^{2}(\mathbf{R})$ defined by $$ U=\left\{f \in L^{2}(\mathbf{R}): f(x)=0 \text { for almost every } x<0\right\} \text {. } $$ Then, as you should verify, $$ U^{\perp}=\left\{g \in L^{2}(\mathbf{R}): g(x)=0 \text { for almost every } x \geq 0\right\} \text {. } $$ Furthermore, you should also verify that if $h \in L^{2}(\mathbf{R})$, then $$ P_{U} h=h \chi_{[0, \infty)} \quad \text { and } \quad P_{U^{\perp}} h=h \chi_{(-\infty, 0)} . $$ Thus $P_{U^{\perp}} h=h\left(1-\chi_{[0, \infty)}\right)=\left(I-P_{U}\right) h$ and hence $P_{U^{\perp}}=I-P_{U}$, as asserted in 8.45(c). ## Riesz Representation Theorem Suppose $h$ is an element of a Hilbert space $V$. Define $\varphi: V \rightarrow \mathbf{F}$ by $\varphi(f)=\langle f, h\rangle$ for $f \in V$. The properties of an inner product imply that $\varphi$ is a linear functional. The Cauchy-Schwarz inequality (8.11) implies that $|\varphi(f)| \leq\|f\|\|h\|$ for all $f \in V$, which implies that $\varphi$ is a bounded linear functional on $V$. The next result states that every bounded linear functional on $V$ arises in this fashion. To motivate the proof of the next result, note that if $\varphi$ is as in the paragraph above, then null $\varphi=\{h\}^{\perp}$. Thus $h \in(\text { null } \varphi)^{\perp}$ [by $\left.8.40(\mathrm{e})\right]$. Hence in the proof of the next result, to find $h$ we start with an element of $(\text { null } \varphi)^{\perp}$ and then multiply it by a scalar to make everything come out right. ### 8.47 Riesz Representation Theorem Suppose $\varphi$ is a bounded linear functional on a Hilbert space $V$. Then there exists a unique $h \in V$ such that $$ \varphi(f)=\langle f, h\rangle $$ for all $f \in V$. Furthermore, $\|\varphi\|=\|h\|$. Proof If $\varphi=0$, take $h=0$. Thus we can assume $\varphi \neq 0$. Hence null $\varphi$ is a closed subspace of $V$ not equal to $V$ (see 6.52). The subspace (null $\varphi)^{\perp}$ is not $\{0\}$ (by 8.42). Thus there exists $g \in(\text { null } \varphi)^{\perp}$ with $\|g\|=1$. Let $$ h=\overline{\varphi(g)} g $$ Taking the norm of both sides of the equation above, we get $\|h\|=|\varphi(g)|$. Thus $$ \varphi(h)=|\varphi(g)|^{2}=\|h\|^{2} . $$ Now suppose $f \in V$. Then $$ \begin{aligned} \langle f, h\rangle & =\left\langle f-\frac{\varphi(f)}{\|h\|^{2}} h, h\right\rangle+\left\langle\frac{\varphi(f)}{\|h\|^{2}} h, h\right\rangle \\ & =\left\langle\frac{\varphi(f)}{\|h\|^{2}} h, h\right\rangle \\ & =\varphi(f), \end{aligned} $$ where 8.49 holds because $f-\frac{\varphi(f)}{\|h\|^{2}} h \in$ null $\varphi$ (by 8.48) and $h$ is orthogonal to all elements of null $\varphi$. We have now proved the existence of $h \in V$ such that $\varphi(f)=\langle f, h\rangle$ for all $f \in V$. To prove uniqueness, suppose $\widetilde{h} \in V$ has the same property. Then $$ \langle h-\widetilde{h}, h-\widetilde{h}\rangle=\langle h-\widetilde{h}, h\rangle-\langle h-\widetilde{h}, \widetilde{h}\rangle=\varphi(h-\widetilde{h})-\varphi(h-\widetilde{h})=0, $$ which implies that $h=\widetilde{h}$, which proves uniqueness. The Cauchy-Schwarz inequality implies that $|\varphi(f)|=|\langle f, h\rangle| \leq\|f\|\|h\|$ for all $f \in V$, which implies that $\|\varphi\| \leq\|h\|$. Because $\varphi(h)=\langle h, h\rangle=\|h\|^{2}$, we also have $\|\varphi\| \geq\|h\|$. Thus $\|\varphi\|=\|h\|$, completing the proof. Suppose that $\mu$ is a measure and $1

0$, then the open ball $B(f, r)$ centered at $f$ with radius $r$ is convex. 6 (a) Suppose $V$ is an inner product space and $B$ is the open unit ball in $V$ (thus $B=\{f \in V:\|f\|<1\}$ ). Prove that if $U$ is a subset of $V$ such that $B \subset U \subset \bar{B}$, then $U$ is convex. (b) Give an example to show that the result in part (a) can fail if the phrase inner product space is replaced by Banach space. 7 Suppose $V$ is a normed vector space and $U$ is a closed subset of $V$. Prove that $U$ is convex if and only if $$ \frac{f+g}{2} \in U \text { for all } f, g \in U $$ 8 Prove that if $U$ is a convex subset of a normed vector space, then $\bar{U}$ is also convex. 9 Prove that if $U$ is a convex subset of a normed vector space, then the interior of $U$ is also convex. [The interior of $U$ is the set $\{f \in U: B(f, r) \subset U$ for some $r>0\}$.] 10 Suppose $V$ is a Hilbert space, $U$ is a nonempty closed convex subset of $V$, and $g \in U$ is the unique element of $U$ with smallest norm (obtained by taking $f=0$ in 8.28). Prove that $$ \operatorname{Re}\langle g, h\rangle \geq\|g\|^{2} $$ for all $h \in U$. 11 Suppose $V$ is a Hilbert space. A closed half-space of $V$ is a set of the form $$ \{g \in V: \operatorname{Re}\langle g, h\rangle \geq c\} $$ for some $h \in V$ and some $c \in \mathbf{R}$. Prove that every closed convex subset of $V$ is the intersection of all the closed half-spaces that contain it. 12 Give an example of a nonempty closed subset $U$ of the Hilbert space $\ell^{2}$ and $a \in \ell^{2}$ such that there does not exist $b \in U$ with $\|a-b\|=$ distance $(a, U)$. [By 8.28, $U$ cannot be a convex subset of $\ell^{2}$.] 13 In the real Banach space $\mathbf{R}^{2}$ with norm defined by $\|(x, y)\|_{\infty}=\max \{|x|,|y|\}$, give an example of a closed convex set $U \subset \mathbf{R}^{2}$ and $z \in \mathbf{R}^{2}$ such that there exist infinitely many choices of $w \in U$ with $\|z-w\|_{\infty}=\operatorname{distance}(z, U)$. 14 Suppose $f$ and $g$ are elements of an inner product space. Prove that $\langle f, g\rangle=0$ if and only if $$ \|f\| \leq\|f+\alpha g\| $$ for all $\alpha \in \mathbf{F}$. 15 Suppose $U$ is a closed subspace of a Hilbert space $V$ and $f \in V$. Prove that $\left\|P_{U} f\right\| \leq\|f\|$, with equality if and only if $f \in U$. [This exercise asks you to prove $8.37(d)$.] 16 Suppose $V$ is a Hilbert space and $P: V \rightarrow V$ is a linear map such that $P^{2}=P$ and $\|P f\| \leq\|f\|$ for every $f \in V$. Prove that there exists a closed subspace $U$ of $V$ such that $P=P_{U}$. 17 Suppose $U$ is a subspace of a Hilbert space $V$. Suppose also that $W$ is a Banach space and $S: U \rightarrow W$ is a bounded linear map. Prove that there exists a bounded linear map $T: V \rightarrow W$ such that $\left.T\right|_{U}=S$ and $\|T\|=\|S\|$. [If $W=\mathbf{F}$, then this result is just the Hahn-Banach Theorem (6.69) for Hilbert spaces. The result here is stronger because it allows $W$ to be an arbitrary Banach space instead of requiring $W$ to be $\mathbf{F}$. Also, the proof in this Hilbert space context does not require use of Zorn's Lemma or the Axiom of Choice.] 18 Suppose $U$ and $W$ are subspaces of a Hilbert space $V$. Prove that $\bar{U}=\bar{W}$ if and only if $U^{\perp}=W^{\perp}$. 19 Suppose $U$ and $W$ are closed subspaces of a Hilbert space. Prove that $P_{U} P_{W}=0$ if and only if $\langle f, g\rangle=0$ for all $f \in U$ and all $g \in W$. 20 Verify the assertions in Example 8.46. 21 Show that every inner product space is a subspace of some Hilbert space. Hint: See Exercise 13 in Section 6C. 22 Prove that if $V$ is a Hilbert space and $T: V \rightarrow V$ is a bounded linear map such that the dimension of range $T$ is 1 , then there exist $g, h \in V$ such that $$ T f=\langle f, g\rangle h $$ for all $f \in V$. 23 (a) Give an example of a Banach space $V$ and a bounded linear functional $\varphi$ on $V$ such that $|\varphi(f)|<\|\varphi\|\|f\|$ for all $f \in V \backslash\{0\}$. (b) Show there does not exist an example in part (a) where $V$ is a Hilbert space. 24 (a) Suppose $\varphi$ and $\psi$ are bounded linear functionals on a Hilbert space $V$ such that $\|\varphi+\psi\|=\|\varphi\|+\|\psi\|$. Prove that one of $\varphi, \psi$ is a scalar multiple of the other. (b) Give an example to show that part (a) can fail if the hypothesis that $V$ is a Hilbert space is replaced by the hypothesis that $V$ is a Banach space. 25 (a) Suppose that $\mu$ is a finite measure, $1 \leq p \leq 2$, and $\varphi$ is a bounded linear functional on $L^{p}(\mu)$. Prove that there exists $h \in L^{p^{\prime}}(\mu)$ such that $\varphi(f)=\int f h d \mu$ for every $f \in L^{p}(\mu)$. (b) Same as part (a), but with the hypothesis that $\mu$ is a finite measure replaced by the hypothesis that $\mu$ is a measure, and assume that $1

0 \\ \frac{1}{\sqrt{2 \pi}} & \text { if } k=0 \\ \frac{1}{\sqrt{\pi}} \cos (k t) & \text { if } k<0\end{cases} $$ Then $\left\{e_{k}\right\}_{k \in \mathbf{Z}}$ is an orthonormal family in $L^{2}((-\pi, \pi])$, as you should verify (see Exercise 1 for useful formulas that will help with this verification). This orthonormal family $\left\{e_{k}\right\}_{k \in \mathbf{Z}}$ leads to the classical theory of Fourier series, as we will see in more depth in Chapter 11. - For $k$ a nonnegative integer, define $e_{k}:[0,1) \rightarrow \mathbf{F}$ by $$ e_{k}(x)= \begin{cases}1 & \text { if } x \in\left[\frac{n-1}{2^{k}}, \frac{n}{2^{k}}\right) \text { for some odd integer } n \\ -1 & \text { if } x \in\left[\frac{n-1}{2^{k}}, \frac{n}{2^{k}}\right) \text { for some even integer } n\end{cases} $$ The figure below shows the graphs of $e_{0}, e_{1}, e_{2}$, and $e_{3}$. The pattern of these graphs should convince you that $\left\{e_{k}\right\}_{k \in\{0,1, \ldots\}}$ is an orthonormal fam- This orthonormal family was invented by Hans Rademacher (1892-1969). ily in $L^{2}([0,1))$. ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-253.jpg?height=228&width=1158&top_left_y=662&top_left_x=68) The graph of $e_{0} . \quad$ The graph of $e_{1}$ The graph of $e_{2}$ The graph of $e_{3}$. - Now we modify the example in the previous bullet point by translating the functions in the previous bullet point by arbitrary integers. Specifically, for $k$ a nonnegative integer and $m \in \mathbf{Z}$, define $e_{k, m}: \mathbf{R} \rightarrow \mathbf{F}$ by $e_{k, m}(x)= \begin{cases}1 & \text { if } x \in\left[m+\frac{n-1}{2^{k}}, m+\frac{n}{2^{k}}\right) \text { for some odd integer } n \in\left[1,2^{k}\right], \\ -1 & \text { if } x \in\left[m+\frac{n-1}{2^{k}}, m+\frac{n}{2^{k}}\right) \text { for some even integer } n \in\left[1,2^{k}\right], \\ 0 & \text { if } x \notin[m, m+1) .\end{cases}$ Then $\left\{e_{k, m}\right\}_{(k, m) \in\{0,1, \ldots\} \times \mathbf{Z}}$ is an orthonormal family in $L^{2}(\mathbf{R})$. This example illustrates the usefulness of considering families that are not sequences. Although $\{0,1, \ldots\} \times \mathbf{Z}$ is a countable set and hence we could rewrite $\left\{e_{k, m}\right\}_{(k, m) \in\{0,1, \ldots\}} \times \mathbf{Z}$ as a sequence, doing so would be awkward and would be less clean than the $e_{k, m}$ notation. The next result gives our first indication of why orthonormal families are so useful. ### 8.52 finite orthonormal families Suppose $\Omega$ is a finite set and $\left\{e_{j}\right\}_{j \in \Omega}$ is an orthonormal family in an inner product space. Then $$ \left\|\sum_{j \in \Omega} \alpha_{j} e_{j}\right\|^{2}=\sum_{j \in \Omega}\left|\alpha_{j}\right|^{2} $$ for every family $\left\{\alpha_{j}\right\}_{j \in \Omega}$ in $\mathbf{F}$. Proof Suppose $\left\{\alpha_{j}\right\}_{j \in \Omega}$ is a family in F. Standard properties of inner products show that $$ \begin{aligned} \left\|\sum_{j \in \Omega} \alpha_{j} e_{j}\right\|^{2} & =\left\langle\sum_{j \in \Omega} \alpha_{j} e_{j}, \sum_{k \in \Omega} \alpha_{k} e_{k}\right\rangle \\ & =\sum_{j, k \in \Omega} \alpha_{j} \overline{\alpha_{k}}\left\langle e_{j}, e_{k}\right\rangle \\ & =\sum_{j \in \Omega}\left|\alpha_{j}\right|^{2}, \end{aligned} $$ as desired. Suppose $\Omega$ is a finite set and $\left\{e_{j}\right\}_{j \in \Omega}$ is an orthonormal family in an inner product space. The result above implies that if $\sum_{j \in \Omega} \alpha_{j} e_{j}=0$, then $\alpha_{j}=0$ for every $j \in \Omega$. Linear algebra, and algebra more generally, deals with sums of only finitely many terms. However, in analysis we often want to sum infinitely many terms. For example, earlier we defined the infinite sum of a sequence $g_{1}, g_{2}, \ldots$ in a normed vector space to be the limit as $n \rightarrow \infty$ of the partial sums $\sum_{k=1}^{n} g_{k}$ if that limit exists (see 6.40). The next definition captures a more powerful method of dealing with infinite sums. The sum defined below is called an unordered sum because the set $\Gamma$ is not assumed to come with any ordering. A finite unordered sum is defined in the obvious way. ### 8.53 Definition unordered sum; $\sum_{k \in \Gamma} f_{k}$ Suppose $\left\{f_{k}\right\}_{k \in \Gamma}$ is a family in a normed vector space $V$. The unordered sum $\sum_{k \in \Gamma} f_{k}$ is said to converge if there exists $g \in V$ such that for every $\varepsilon>0$, there exists a finite subset $\Omega$ of $\Gamma$ such that $$ \left\|g-\sum_{j \in \Omega^{\prime}} f_{j}\right\|<\varepsilon $$ for all finite sets $\Omega^{\prime}$ with $\Omega \subset \Omega^{\prime} \subset \Gamma$. If this happens, we set $\sum_{k \in \Gamma} f_{k}=g$. If there is no such $g \in V$, then $\sum_{k \in \Gamma} f_{k}$ is left undefined. Exercises at the end of this section ask you to develop basic properties of unordered sums, including the following: - Suppose $\left\{a_{k}\right\}_{k \in \Gamma}$ is a family in $\mathbf{R}$ and $a_{k} \geq 0$ for each $k \in \Gamma$. Then the unordered sum $\sum_{k \in \Gamma} a_{k}$ converges if and only if $$ \sup \left\{\sum_{j \in \Omega} a_{j}: \Omega \text { is a finite subset of } \Gamma\right\}<\infty $$ Furthermore, if $\sum_{k \in \Gamma} a_{k}$ converges then it equals the supremum above. If $\sum_{k \in \Gamma} a_{k}$ does not converge, then the supremum above is $\infty$ and we write $\sum_{k \in \Gamma} a_{k}=\infty$ (this notation should be used only when $a_{k} \geq 0$ for each $k \in \Gamma$ ). - Suppose $\left\{a_{k}\right\}_{k \in \Gamma}$ is a family in $\mathbf{R}$. Then the unordered sum $\sum_{k \in \Gamma} a_{k}$ converges if and only if $\sum_{k \in \Gamma}\left|a_{k}\right|<\infty$. Thus convergence of an unordered summation in $\mathbf{R}$ is the same as absolute convergence. As we are about to see, the situation in more general Hilbert spaces is quite different. Now we can extend 8.52 to infinite sums. ### 8.54 linear combinations of an orthonormal family Suppose $\left\{e_{k}\right\}_{k \in \Gamma}$ is an orthonormal family in a Hilbert space $V$. Suppose $\left\{\alpha_{k}\right\}_{k \in \Gamma}$ is a family in $\mathbf{F}$. Then (a) the unordered sum $\sum_{k \in \Gamma} \alpha_{k} e_{k}$ converges $\Longleftrightarrow \sum_{k \in \Gamma}\left|\alpha_{k}\right|^{2}<\infty$. Furthermore, if $\sum_{k \in \Gamma} \alpha_{k} e_{k}$ converges, then (b) $$ \left\|\sum_{k \in \Gamma} \alpha_{k} e_{k}\right\|^{2}=\sum_{k \in \Gamma}\left|\alpha_{k}\right|^{2} $$ Proof First suppose $\sum_{k \in \Gamma} \alpha_{k} e_{k}$ converges, with $\sum_{k \in \Gamma} \alpha_{k} e_{k}=g$. Suppose $\varepsilon>0$. Then there exists a finite set $\Omega \subset \Gamma$ such that $$ \left\|g-\sum_{j \in \Omega^{\prime}} \alpha_{j} e_{j}\right\|<\varepsilon $$ for all finite sets $\Omega^{\prime}$ with $\Omega \subset \Omega^{\prime} \subset \Gamma$. If $\Omega^{\prime}$ is a finite set with $\Omega \subset \Omega^{\prime} \subset \Gamma$, then the inequality above implies that $$ \|g\|-\varepsilon<\left\|\sum_{j \in \Omega^{\prime}} \alpha_{j} e_{j}\right\|<\|g\|+\varepsilon $$ which (using 8.52) implies that $$ \|g\|-\varepsilon<\left(\sum_{j \in \Omega^{\prime}}\left|\alpha_{j}\right|^{2}\right)^{1 / 2}<\|g\|+\varepsilon $$ Thus $\|g\|=\left(\sum_{k \in \Gamma}\left|\alpha_{k}\right|^{2}\right)^{1 / 2}$, completing the proof of one direction of (a) and the proof of (b). To prove the other direction of (a), now suppose $\sum_{k \in \Gamma}\left|\alpha_{k}\right|^{2}<\infty$. Thus there exists an increasing sequence $\Omega_{1} \subset \Omega_{2} \subset \cdots$ of finite subsets of $\Gamma$ such that for each $m \in \mathbf{Z}^{+}$, $$ \sum_{j \in \Omega^{\prime} \backslash \Omega_{m}}\left|\alpha_{j}\right|^{2}<\frac{1}{m^{2}} $$ for every finite set $\Omega^{\prime}$ such that $\Omega_{m} \subset \Omega^{\prime} \subset \Gamma$. For each $m \in \mathbf{Z}^{+}$, let $$ g_{m}=\sum_{j \in \Omega_{m}} \alpha_{j} e_{j} . $$ If $n>m$, then 8.52 implies that $$ \left\|g_{n}-g_{m}\right\|^{2}=\sum_{j \in \Omega_{n} \backslash \Omega_{m}}\left|\alpha_{j}\right|^{2}<\frac{1}{m^{2}} $$ Thus $g_{1}, g_{2}, \ldots$ is a Cauchy sequence and hence converges to some element $g$ of $V$. Temporarily fixing $m \in \mathbf{Z}^{+}$and taking the limit of the equation above as $n \rightarrow \infty$, we see that $$ \left\|g-g_{m}\right\| \leq \frac{1}{m} $$ To show that $\sum_{k \in \Gamma} \alpha_{k} e_{k}=g$, suppose $\varepsilon>0$. Let $m \in \mathbf{Z}^{+}$be such that $\frac{2}{m}<\varepsilon$. Suppose $\Omega^{\prime}$ is a finite set with $\Omega_{m} \subset \Omega^{\prime} \subset \Gamma$. Then $$ \begin{aligned} \left\|g-\sum_{j \in \Omega^{\prime}} \alpha_{j} e_{j}\right\| & \leq\left\|g-g_{m}\right\|+\left\|g_{m}-\sum_{j \in \Omega^{\prime}} \alpha_{j} e_{j}\right\| \\ & \leq \frac{1}{m}+\left\|\sum_{j \in \Omega^{\prime} \backslash \Omega_{m}} \alpha_{j} e_{j}\right\| \\ & =\frac{1}{m}+\left(\sum_{j \in \Omega^{\prime} \backslash \Omega_{m}}\left|\alpha_{j}\right|^{2}\right)^{1 / 2} \\ & <\varepsilon, \end{aligned} $$ where the third line comes from 8.52 and the last line comes from 8.55 . Thus $\sum_{k \in \Gamma} \alpha_{k} e_{k}=g$, completing the proof. ### 8.56 Example a convergent unordered sum need not converge absolutely Suppose $\left\{e_{k}\right\}_{k \in \mathbf{Z}^{+}}$is the orthogonal family in $\ell^{2}$ defined by setting $e_{k}$ equal to the sequence that is 0 everywhere except for a 1 in the $k^{\text {th }}$ slot. Then by 8.54 , the unordered sum $$ \sum_{k \in \mathbf{Z}^{+}} \frac{1}{k} e_{k} $$ converges in $\ell^{2}$ (because $\sum_{k \in \mathbf{Z}^{+}} \frac{1}{k^{2}}<\infty$ ) even though $\sum_{k \in \mathbf{Z}^{+}}\left\|\frac{1}{k} e_{k}\right\|=\infty$. Note that $\sum_{k \in \mathbf{Z}^{+}} \frac{1}{k} e_{k}=\left(1, \frac{1}{2}, \frac{1}{3}, \ldots\right) \in \ell^{2}$. Now we prove an important inequality. ### 8.57 Bessel's inequality Suppose $\left\{e_{k}\right\}_{k \in \Gamma}$ is an orthonormal family in an inner product space $V$ and $f \in V$. Then $$ \sum_{k \in \Gamma}\left|\left\langle f, e_{k}\right\rangle\right|^{2} \leq\|f\|^{2} $$ Proof Suppose $\Omega$ is a finite subset of $\Gamma$. Then $f=\sum_{j \in \Omega}\left\langle f, e_{j}\right\rangle e_{j}+\left(f-\sum_{j \in \Omega}\left\langle f, e_{j}\right\rangle e_{j}\right)$, where the first sum above is orthogonal to the term in parentheses above (as you should verify). Applying the Pythagorean Theorem (8.9) to the equation above gives $$ \begin{aligned} \|f\|^{2} & =\left\|\sum_{j \in \Omega}\left\langle f, e_{j}\right\rangle e_{j}\right\|^{2}+\left\|f-\sum_{j \in \Omega}\left\langle f, e_{j}\right\rangle e_{j}\right\|^{2} \\ & \geq\left\|\sum_{j \in \Omega}\left\langle f, e_{j}\right\rangle e_{j}\right\|^{2} \\ & =\sum_{j \in \Omega}\left|\left\langle f, e_{j}\right\rangle\right|^{2}, \end{aligned} $$ where the last equality follows from 8.52. Because the inequality above holds for every finite set $\Omega \subset \Gamma$, we conclude that $\|f\|^{2} \geq \sum_{k \in \Gamma}\left|\left\langle f, e_{k}\right\rangle\right|^{2}$, as desired. Recall that the span of a family $\left\{e_{k}\right\}_{k \in \Gamma}$ in a vector space is the set of finite sums of the form $$ \sum_{j \in \Omega} \alpha_{j} e_{j}, $$ where $\Omega$ is a finite subset of $\Gamma$ and $\left\{\alpha_{j}\right\}_{j \in \Omega}$ is a family in $\mathbf{F}$ (see 6.54). Bessel's inequality now allows us to prove the following beautiful result showing that the closure of the span of an orthonormal family is a set of infinite sums. ### 8.58 closure of the span of an orthonormal family Suppose $\left\{e_{k}\right\}_{k \in \Gamma}$ is an orthonormal family in a Hilbert space $V$. Then (a) $\overline{\operatorname{span}\left\{e_{k}\right\}_{k \in \Gamma}}=\left\{\sum_{k \in \Gamma} \alpha_{k} e_{k}:\left\{\alpha_{k}\right\}_{k \in \Gamma}\right.$ is a family in $\mathbf{F}$ and $\left.\sum_{k \in \Gamma}\left|\alpha_{k}\right|^{2}<\infty\right\}$. Furthermore, $$ f=\sum_{k \in \Gamma}\left\langle f, e_{k}\right\rangle e_{k} $$ for every $f \in \overline{\operatorname{span}\left\{e_{k}\right\}_{k \in \Gamma}}$. Proof The right side of (a) above makes sense because of 8.54(a). Furthermore, the right side of (a) above is a subspace of $V$ because $\ell^{2}(\Gamma)$ [which equals $\mathcal{L}^{2}(\mu)$, where $\mu$ is counting measure on $\Gamma]$ is closed under addition and scalar multiplication by 7.5. Suppose first $\left\{\alpha_{k}\right\}_{k \in \Gamma}$ is a family in $\mathbf{F}$ and $\sum_{k \in \Gamma}\left|\alpha_{k}\right|^{2}<\infty$. Let $\varepsilon>0$. Then there is a finite subset $\Omega$ of $\Gamma$ such that $$ \sum_{j \in \Gamma \backslash \Omega}\left|\alpha_{j}\right|^{2}<\varepsilon^{2} $$ The inequality above and 8.54 (b) imply that $$ \left\|\sum_{k \in \Gamma} \alpha_{k} e_{k}-\sum_{j \in \Omega} \alpha_{j} e_{j}\right\|<\varepsilon $$ The definition of the closure (see 6.7) now implies that $\sum_{k \in \Gamma} \alpha_{k} e_{k} \in \overline{\operatorname{span}\left\{e_{k}\right\}_{k \in \Gamma}}$, showing that the right side of (a) is contained in the left side of (a). To prove the inclusion in the other direction, now suppose $f \in \overline{\operatorname{span}\left\{e_{k}\right\}_{k \in \Gamma}}$. Let $$ g=\sum_{k \in \Gamma}\left\langle f, e_{k}\right\rangle e_{k} $$ where the sum above converges by Bessel's inequality (8.57) and by 8.54(a). The direction of the inclusion that we just proved implies that $g \in \overline{\operatorname{span}\left\{e_{k}\right\}_{k \in \Gamma}}$. Thus $$ g-f \in \overline{\operatorname{span}\left\{e_{k}\right\}_{k \in \Gamma}} . $$ Equation 8.59 implies that $\left\langle g, e_{j}\right\rangle=\left\langle f, e_{j}\right\rangle$ for each $j \in \Gamma$, as you should verify (which will require using the Cauchy-Schwarz inequality if done rigorously). Hence $$ \left\langle g-f, e_{k}\right\rangle=0 \quad \text { for every } k \in \Gamma . $$ This implies that $$ g-f \in\left(\operatorname{span}\left\{e_{j}\right\}_{j \in \Gamma}\right)^{\perp}=\left(\overline{\operatorname{span}\left\{e_{j}\right\}_{j \in \Gamma}}\right)^{\perp} $$ where the equality above comes from 8.40 (d). Now 8.60 and the inclusion above imply that $f=g$ [see $8.40(\mathrm{~b})$ ], which along with 8.59 implies that $f$ is in the right side of (a), completing the proof of (a). The equations $f=g$ and 8.59 also imply (b). ## Parseval's Identity Note that 8.52 implies that every orthonormal family in an inner product space is linearly independent (see 6.54 to review the definition of linearly independent and basis). Linear algebra deals mainly with finite-dimensional vector spaces, but infinitedimensional vector spaces frequently appear in analysis. The notion of a basis is not so useful when doing analysis with infinite-dimensional vector spaces because the definition of span does not take advantage of the possibility of summing an infinite number of elements. However, 8.58 tells us that taking the closure of the span of an orthonormal family can capture the sum of infinitely many elements. Thus we make the following definition. ### 8.61 Definition orthonormal basis An orthonormal family $\left\{e_{k}\right\}_{k \in \Gamma}$ in a Hilbert space $V$ is called an orthonormal basis of $V$ if $$ \overline{\operatorname{span}\left\{e_{k}\right\}_{k \in \Gamma}}=V . $$ In addition to requiring orthonormality (which implies linear independence), the definition above differs from the definition of a basis by considering the closure of the span rather than the span. An important point to keep in mind is that despite the terminology, an orthonormal basis is not necessarily a basis in the sense of 6.54. In fact, if $\Gamma$ is an infinite set and $\left\{e_{k}\right\}_{k \in \Gamma}$ is an orthonormal basis of $V$, then $\left\{e_{k}\right\}_{k \in \Gamma}$ is not a basis of $V$ (see Exercise 9). ### 8.62 Example orthonormal bases - For $n \in \mathbf{Z}^{+}$and $k \in\{1, \ldots, n\}$, let $e_{k}$ be the element of $\mathbf{F}^{n}$ all of whose coordinates are 0 except the $k^{\text {th }}$ coordinate, which is 1 : $$ e_{k}=(0, \ldots, 0,1,0, \ldots, 0) . $$ Then $\left\{e_{k}\right\}_{k \in\{1, \ldots, n\}}$ is an orthonormal basis of $\mathbf{F}^{n}$. - Let $e_{1}=\left(\frac{1}{\sqrt{3}}, \frac{1}{\sqrt{3}}, \frac{1}{\sqrt{3}}\right), e_{2}=\left(-\frac{1}{\sqrt{2}}, \frac{1}{\sqrt{2}}, 0\right)$, and $e_{3}=\left(\frac{1}{\sqrt{6}}, \frac{1}{\sqrt{6}},-\frac{2}{\sqrt{6}}\right)$. Then $\left\{e_{k}\right\}_{k \in\{1,2,3\}}$ is an orthonormal basis of $\mathbf{F}^{3}$, as you should verify. - The first three bullet points in 8.51 are examples of orthonormal families that are orthonormal bases. The exercises ask you to verify that we have an orthonormal basis in the first and second bullet points of 8.51. For the third bullet point (trigonometric functions), see Exercise 11 in Section 10D or see Chapter 11. The next result shows why orthonormal bases are so useful—a Hilbert space with an orthonormal basis $\left\{e_{k}\right\}_{k \in \Gamma}$ behaves like $\ell^{2}(\Gamma)$. ### 8.63 Parseval's identity Suppose $\left\{e_{k}\right\}_{k \in \Gamma}$ is an orthonormal basis of a Hilbert space $V$ and $f, g \in V$. Then (a) $f=\sum_{k \in \Gamma}\left\langle f, e_{k}\right\rangle e_{k}$ (b) $\langle f, g\rangle=\sum_{k \in \Gamma}\left\langle f, e_{k}\right\rangle \overline{\left\langle g, e_{k}\right\rangle}$; (c) $\|f\|^{2}=\sum_{k \in \Gamma}\left|\left\langle f, e_{k}\right\rangle\right|^{2}$. Proof The equation in (a) follows immediately from 8.58(b) and the definition of an orthonormal basis. To prove (b), note that $$ \begin{aligned} \langle f, g\rangle & =\left\langle\sum_{k \in \Gamma}\left\langle f, e_{k}\right\rangle e_{k}, g\right\rangle \\ & =\sum_{k \in \Gamma}\left\langle f, e_{k}\right\rangle\left\langle e_{k}, g\right\rangle \\ & =\sum_{k \in \Gamma}\left\langle f, e_{k}\right\rangle \overline{\left\langle g, e_{k}\right\rangle}, \end{aligned} $$ Equation (c) is called Parseval's identity in honor of Marc-Antoine Parseval (1755-1836), who discovered a special case in 1799. where the first equation follows from (a) and the second equation follows from the definition of an unordered sum and the Cauchy-Schwarz inequality. Equation (c) follows from setting $g=f$ in (b). An alternative proof: equation (c) follows from 8.54(b) and the equation $f=\sum_{k \in \Gamma}\left\langle f, e_{k}\right\rangle e_{k}$ from (a). ## Gram-Schmidt Process and Existence of Orthonormal Bases ### 8.64 Definition separable A normed vector space is called separable if it has a countable subset whose closure equals the whole space. ### 8.65 Example separable normed vector spaces - Suppose $n \in \mathbf{Z}^{+}$. Then $\mathbf{F}^{n}$ with the usual Hilbert space norm is separable because the closure of the countable set $$ \left\{\left(c_{1}, \ldots, c_{n}\right) \in \mathbf{F}^{n}: \text { each } c_{j} \text { is rational }\right\} $$ equals $\mathbf{F}^{n}$ (in case $\mathbf{F}=\mathbf{C}$ : to say that a complex number is rational in this context means that both the real and imaginary parts of the complex number are rational numbers in the usual sense). - The Hilbert space $\ell^{2}$ is separable because the closure of the countable set $$ \bigcup_{n=1}^{\infty}\left\{\left(c_{1}, \ldots, c_{n}, 0,0, \ldots\right) \in \ell^{2}: \text { each } c_{j} \text { is rational }\right\} $$ is $\ell^{2}$. - The Hilbert spaces $L^{2}([0,1])$ and $L^{2}(\mathbf{R})$ are separable, as Exercise 13 asks you to verify [hint: consider finite linear combinations with rational coefficients of functions of the form $\chi_{(c, d)}$, where $c$ and $d$ are rational numbers]. A moment's thought about the definition of closure (see 6.7) shows that a normed vector space $V$ is separable if and only if there exists a countable subset $C$ of $V$ such that every open ball in $V$ contains at least one element of $C$. ### 8.66 Example nonseparable normed vector spaces - Suppose $\Gamma$ is an uncountable set. Then the Hilbert space $\ell^{2}(\Gamma)$ is not separable. To see this, note that $\left\|\chi_{\{j\}}-\chi_{\{k\}}\right\|=\sqrt{2}$ for all $j, k \in \Gamma$ with $j \neq k$. Hence $$ \left\{B\left(\chi_{\{k\}}, \frac{\sqrt{2}}{2}\right): k \in \Gamma\right\} $$ is an uncountable collection of disjoint open balls in $\ell^{2}(\Gamma)$; no countable set can have at least one element in each of these balls. - The Banach space $L^{\infty}([0,1])$ is not separable. Here $\left\|\chi_{[0, s]}-\chi_{[0, t]}\right\|=1$ for all $s, t \in[0,1]$ with $s \neq t$. Thus $$ \left\{B\left(\chi_{[0, t]}, \frac{1}{2}\right): t \in[0,1]\right\} $$ is an uncountable collection of disjoint open balls in $L^{\infty}([0,1])$. We present two proofs of the existence of orthonormal bases of Hilbert spaces. The first proof works only for separable Hilbert spaces, but it gives a useful algorithm, called the Gram-Schmidt process, for constructing orthonormal sequences. The second proof works for all Hilbert spaces, but it uses a result that depends upon the Axiom of Choice. Which proof should you read? In practice, the Hilbert spaces you will encounter will almost certainly be separable. Thus the first proof suffices, and it has the additional benefit of introducing you to a widely used algorithm. The second proof uses an entirely different approach and has the advantage of applying to separable and nonseparable Hilbert spaces. For maximum learning, read both proofs! 8.67 existence of orthonormal bases for separable Hilbert spaces Every separable Hilbert space has an orthonormal basis. Proof Suppose $V$ is a separable Hilbert space and $\left\{f_{1}, f_{2}, \ldots\right\}$ is a countable subset of $V$ whose closure equals $V$. We will inductively define an orthonormal sequence $\left\{e_{k}\right\}_{k \in \mathbf{Z}^{+}}$such that $$ \operatorname{span}\left\{f_{1}, \ldots, f_{n}\right\} \subset \operatorname{span}\left\{e_{1}, \ldots, e_{n}\right\} $$ for each $n \in \mathbf{Z}^{+}$. This will imply that $\overline{\operatorname{span}\left\{e_{k}\right\}_{k \in \mathbf{Z}^{+}}}=V$, which will mean that $\left\{e_{k}\right\}_{k \in \mathbf{Z}^{+}}$is an orthonormal basis of $V$. To get started with the induction, set $e_{1}=f_{1} /\left\|f_{1}\right\|$ (we can assume that $f_{1} \neq 0$ ). Now suppose $n \in \mathbf{Z}^{+}$and $e_{1}, \ldots, e_{n}$ have been chosen so that $\left\{e_{k}\right\}_{k \in\{1, \ldots, n\}}$ is an orthonormal family in $V$ and 8.68 holds. If $f_{k} \in \operatorname{span}\left\{e_{1}, \ldots, e_{n}\right\}$ for every $k \in \mathbf{Z}^{+}$, then $\left\{e_{k}\right\}_{k \in\{1, \ldots, n\}}$ is an orthonormal basis of $V$ (completing the proof) and the process should be stopped. Otherwise, let $m$ be the smallest positive integer such that 8.69 $$ f_{m} \notin \operatorname{span}\left\{e_{1}, \ldots, e_{n}\right\} . $$ Define $e_{n+1}$ by 8.70 $$ e_{n+1}=\frac{f_{m}-\left\langle f_{m}, e_{1}\right\rangle e_{1}-\cdots-\left\langle f_{m}, e_{n}\right\rangle e_{n}}{\left\|f_{m}-\left\langle f_{m}, e_{1}\right\rangle e_{1}-\cdots-\left\langle f_{m}, e_{n}\right\rangle e_{n}\right\|} $$ Clearly $\left\|e_{n+1}\right\|=1$ (8.69 guarantees there is no division by 0 ). If $k \in\{1, \ldots, n\}$, then the equation above implies that $\left\langle e_{n+1}, e_{k}\right\rangle=0$. Thus $\left\{e_{k}\right\}_{k \in\{1, \ldots, n+1\}}$ is an orthonormal fam- Jørgen Gram (1850-1916) and Erhard Schmidt (1876-1959) popularized this process that constructs orthonormal sequences. ily in $V$. Also, 8.68 and the choice of $m$ as the smallest positive integer satisfying 8.69 imply that $$ \operatorname{span}\left\{f_{1}, \ldots, f_{n+1}\right\} \subset \operatorname{span}\left\{e_{1}, \ldots, e_{n+1}\right\} $$ completing the induction and completing the proof. Before considering nonseparable Hilbert spaces, we take a short detour to illustrate how the Gram-Schmidt process used in the previous proof can be used to find closest elements to subspaces. We begin with a result connecting the orthogonal projection onto a closed subspace with an orthonormal basis of that subspace. ### 8.71 orthogonal projection in terms of an orthonormal basis Suppose that $U$ is a closed subspace of a Hilbert space $V$ and $\left\{e_{k}\right\}_{k \in \Gamma}$ is an orthonormal basis of $U$. Then $$ P_{U} f=\sum_{k \in \Gamma}\left\langle f, e_{k}\right\rangle e_{k} $$ for all $f \in V$. Proof Let $f \in V$. If $k \in \Gamma$, then 8.72 $$ \left\langle f, e_{k}\right\rangle=\left\langle f-P_{U} f, e_{k}\right\rangle+\left\langle P_{U} f, e_{k}\right\rangle=\left\langle P_{U} f, e_{k}\right\rangle, $$ where the last equality follows from 8.37 (a). Now $$ P_{U} f=\sum_{k \in \Gamma}\left\langle P_{U} f, e_{k}\right\rangle e_{k}=\sum_{k \in \Gamma}\left\langle f, e_{k}\right\rangle e_{k}, $$ where the first equality follows from Parseval's identity [8.63(a)] as applied to $U$ and its orthonormal basis $\left\{e_{k}\right\}_{k \in \Gamma}$, and the second equality follows from 8.72. ### 8.73 Example best approximation Find the polynomial $g$ of degree at most 10 that minimizes $$ \int_{-1}^{1}|\sqrt{|x|}-g(x)|^{2} d x $$ Solution We will work in the real Hilbert space $L^{2}([-1,1])$ with the usual inner product $\langle g, h\rangle=\int_{-1}^{1} g h$. For $k \in\{0,1, \ldots, 10\}$, let $f_{k} \in L^{2}([-1,1])$ be defined by $f_{k}(x)=x^{k}$. Let $U$ be the subspace of $L^{2}([-1,1])$ defined by $$ U=\operatorname{span}\left\{f_{k}\right\}_{k \in\{0, \ldots, 10\}} $$ Apply the Gram-Schmidt process from the proof of 8.67 to $\left\{f_{k}\right\}_{k \in\{0, \ldots, 10\}}$, producing an orthonormal basis $\left\{e_{k}\right\}_{k \in\{0, \ldots, 10\}}$ of $U$, which is a closed subspace of $L^{2}([-1,1])$ (see Exercise 8). The point here is that $\left\{e_{k}\right\}_{k \in\{0, \ldots, 10\}}$ can be computed explicitly and exactly by using 8.70 and evaluating some integrals (using software that can do exact rational arithmetic will make the process easier), getting $e_{0}(x)=1 / \sqrt{2}$, $e_{1}(x)=\sqrt{6} x / 2, \ldots$ up to $e_{10}(x)=\frac{\sqrt{42}}{512}\left(-63+3465 x^{2}-30030 x^{4}+90090 x^{6}-109395 x^{8}+46189 x^{10}\right)$. Define $f \in L^{2}([-1,1])$ by $f(x)=\sqrt{|x|}$. Because $U$ is the subspace of $L^{2}([-1,1])$ consisting of polynomials of degree at most 10 and $P_{U} f$ equals the element of $U$ closest to $f$ (see 8.34), the formula in 8.71 tells us that the solution $g$ to our minimization problem is given by the formula $$ g=\sum_{k=0}^{10}\left\langle f, e_{k}\right\rangle e_{k} $$ Using the explicit expressions for $e_{0}, \ldots, e_{10}$ and again evaluating some integrals, this gives $$ g(x)=\frac{693+15015 x^{2}-64350 x^{4}+139230 x^{6}-138567 x^{8}+51051 x^{10}}{2944} . $$ The figure here shows the graph of $f(x)=\sqrt{|x|}$ (red) and the graph of its closest polynomial $g$ (blue) of degree at most 10 ; here closest means as measured in the norm of $L^{2}([-1,1])$. The approximation of $f$ by $g$ is pretty good, especially considering that $f$ is not differentiable at 0 and thus a Taylor series expansion for $f$ does not make sense. ![](https://cdn.mathpix.com/cropped/2024_01_20_2970d025e1911bd8290dg-263.jpg?height=418&width=580&top_left_y=1683&top_left_x=633) Recall that a subset $\Gamma$ of a set $V$ can be thought of as a family in $V$ by considering $\left\{e_{f}\right\}_{f \in \Gamma}$, where $e_{f}=f$. With this convention, a subset $\Gamma$ of an inner product space $V$ is an orthonormal subset of $V$ if $\|f\|=1$ for all $f \in \Gamma$ and $\langle f, g\rangle=0$ for all $f, g \in \Gamma$ with $f \neq g$. The next result characterizes the orthonormal bases as the maximal elements among the collection of orthonormal subsets of a Hilbert space. Recall that a set $\Gamma \in \mathcal{A}$ in a collection of subsets of a set $V$ is a maximal element of $\mathcal{A}$ if there does not exist $\Gamma^{\prime} \in \mathcal{A}$ such that $\Gamma \varsubsetneqq \Gamma^{\prime}$ (see 6.55). ### 8.74 orthonormal bases as maximal elements Suppose $V$ is a Hilbert space, $\mathcal{A}$ is the collection of all orthonormal subsets of $V$, and $\Gamma$ is an orthonormal subset of $V$. Then $\Gamma$ is an orthonormal basis of $V$ if and only if $\Gamma$ is a maximal element of $\mathcal{A}$. Proof First suppose $\Gamma$ is an orthonormal basis of $V$. Parseval's identity [8.63(a)] implies that the only element of $V$ that is orthogonal to every element of $\Gamma$ is 0 . Thus there does not exist an orthonormal subset of $V$ that strictly contains $\Gamma$. In other words, $\Gamma$ is a maximal element of $\mathcal{A}$. To prove the other direction, suppose now that $\Gamma$ is a maximal element of $\mathcal{A}$. Let $U$ denote the span of $\Gamma$. Then $$ U^{\perp}=\{0\} $$ because if $f$ is a nonzero element of $U^{\perp}$, then $\Gamma \cup\{f /\|f\|\}$ is an orthonormal subset of $V$ that strictly contains $\Gamma$. Hence $\bar{U}=V$ (by 8.42), which implies that $\Gamma$ is an orthonormal basis of $V$. Now we are ready to prove that every Hilbert space has an orthonormal basis. Before reading the next proof, you may want to review the definition of a chain (6.58), which is a collection of sets such that for each pair of sets in the collection, one of them is contained in the other. You should also review Zorn's Lemma (6.60), which gives a way to show that a collection of sets contains a maximal element. ### 8.75 existence of orthonormal bases for all Hilbert spaces Every Hilbert space has an orthonormal basis. Proof Suppose $V$ is a Hilbert space. Let $\mathcal{A}$ be the collection of all orthonormal subsets of $V$. Suppose $\mathcal{C} \subset \mathcal{A}$ is a chain. Let $L$ be the union of all the sets in $\mathcal{C}$. If $f \in L$, then $\|f\|=1$ because $f$ is an element of some orthonormal subset of $V$ that is contained in $\mathcal{C}$. If $f, g \in L$ with $f \neq g$, then there exist orthonormal subsets $\Omega$ and $\Gamma$ in $\mathcal{C}$ such that $f \in \Omega$ and $g \in \Gamma$. Because $\mathcal{C}$ is a chain, either $\Omega \subset \Gamma$ or $\Gamma \subset \Omega$. Either way, there is an orthonormal subset of $V$ that contains both $f$ and $g$. Thus $\langle f, g\rangle=0$. We have shown that $L$ is an orthonormal subset of $V$; in other words, $L \in \mathcal{A}$. Thus Zorn's Lemma (6.60) implies that $\mathcal{A}$ has a maximal element. Now 8.74 implies that $V$ has an orthonormal basis. ## Riesz Representation Theorem, Revisited Now that we know that every Hilbert space has an orthonormal basis, we can give a completely different proof of the Riesz Representation Theorem (8.47) than the proof we gave earlier. Note that the new proof below of the Riesz Representation Theorem gives the formula 8.77 for $h$ in terms of an orthonormal basis. One interesting feature of this formula is that $h$ is uniquely determined by $\varphi$ and thus $h$ does not depend upon the choice of an orthonormal basis. Hence despite its appearance, the right side of 8.77 is independent of the choice of an orthonormal basis. ### 8.76 Riesz Representation Theorem Suppose $\varphi$ is a bounded linear functional on a Hilbert space $V$ and $\left\{e_{k}\right\}_{k \in \Gamma}$ is an orthonormal basis of $V$. Let 8.77 $$ h=\sum_{k \in \Gamma} \overline{\varphi\left(e_{k}\right)} e_{k} $$ Then 8.78 $$ \varphi(f)=\langle f, h\rangle $$ for all $f \in V$. Furthermore, $\|\varphi\|=\left(\sum_{k \in \Gamma}\left|\varphi\left(e_{k}\right)\right|^{2}\right)^{1 / 2}$. Proof First we must show that the sum defining $h$ makes sense. To do this, suppose $\Omega$ is a finite subset of $\Gamma$. Then $$ \sum_{j \in \Omega}\left|\varphi\left(e_{j}\right)\right|^{2}=\varphi\left(\sum_{j \in \Omega} \overline{\varphi\left(e_{j}\right)} e_{j}\right) \leq\|\varphi\|\left\|\sum_{j \in \Omega} \overline{\varphi\left(e_{j}\right)} e_{j}\right\|=\|\varphi\|\left(\sum_{j \in \Omega}\left|\varphi\left(e_{j}\right)\right|^{2}\right)^{1 / 2}, $$ where the last equality follows from 8.52. Dividing by $\left(\sum_{j \in \Omega}\left|\varphi\left(e_{j}\right)\right|^{2}\right)^{1 / 2}$ gives $$ \left(\sum_{j \in \Omega}\left|\varphi\left(e_{j}\right)\right|^{2}\right)^{1 / 2} \leq\|\varphi\| $$ Because the inequality above holds for every finite subset $\Omega$ of $\Gamma$, we conclude that $$ \sum_{k \in \Gamma}\left|\varphi\left(e_{k}\right)\right|^{2} \leq\|\varphi\|^{2} $$ Thus the sum defining $h$ makes sense (by 8.54) in equation 8.77. Now 8.77 shows that $\left\langle h, e_{j}\right\rangle=\overline{\varphi\left(e_{j}\right)}$ for each $j \in \Gamma$. Thus if $f \in V$ then $$ \varphi(f)=\varphi\left(\sum_{k \in \Gamma}\left\langle f, e_{k}\right\rangle e_{k}\right)=\sum_{k \in \Gamma}\left\langle f, e_{k}\right\rangle \varphi\left(e_{k}\right)=\sum_{k \in \Gamma}\left\langle f, e_{k}\right\rangle \overline{\left\langle h, e_{k}\right\rangle}=\langle f, h\rangle, $$ where the first and last equalities follow from 8.63 and the second equality follows from the boundedness/continuity of $\varphi$. Thus 8.78 holds. Finally, the Cauchy-Schwarz inequality, equation 8.78 , and the equation $\varphi(h)=$ $\langle h, h\rangle$ show that $\|\varphi\|=\|h\|=\left(\sum_{k \in \Gamma}\left|\varphi\left(e_{k}\right)\right|^{2}\right)^{1 / 2}$. ## EXERCISES 8C 1 Verify that the family $\left\{e_{k}\right\}_{k \in \mathbf{Z}}$ as defined in the third bullet point of Example 8.51 is an orthonormal family in $L^{2}((-\pi, \pi])$. The following formulas should help: $$ \begin{aligned} & (\sin x)(\cos y)=\frac{\sin (x-y)+\sin (x+y)}{2}, \\ & (\sin x)(\sin y)=\frac{\cos (x-y)-\cos (x+y)}{2}, \\ & (\cos x)(\cos y)=\frac{\cos (x-y)+\cos (x+y)}{2} . \end{aligned} $$ 2 Suppose $\left\{a_{k}\right\}_{k \in \Gamma}$ is a family in $\mathbf{R}$ and $a_{k} \geq 0$ for each $k \in \Gamma$. Prove the unordered sum $\sum_{k \in \Gamma} a_{k}$ converges if and only if $$ \sup \left\{\sum_{j \in \Omega} a_{j}: \Omega \text { is a finite subset of } \Gamma\right\}<\infty $$ Furthermore, prove that if $\sum_{k \in \Gamma} a_{k}$ converges then it equals the supremum above. 3 Suppose $\left\{e_{k}\right\}_{k \in \Gamma}$ is an orthonormal family in an inner product space $V$. Prove that if $f \in V$, then $\left\{k \in \Gamma:\left\langle f, e_{k}\right\rangle \neq 0\right\}$ is a countable set. 4 Suppose $\left\{f_{k}\right\}_{k \in \Gamma}$ and $\left\{g_{k}\right\}_{k \in \Gamma}$ are families in a normed vector space such that $\sum_{k \in \Gamma} f_{k}$ and $\sum_{k \in \Gamma} g_{k}$ converge. Prove that $\sum_{k \in \Gamma}\left(f_{k}+g_{k}\right)$ converges and $$ \sum_{k \in \Gamma}\left(f_{k}+g_{k}\right)=\sum_{k \in \Gamma} f_{k}+\sum_{k \in \Gamma} g_{k} $$ 5 Suppose $\left\{f_{k}\right\}_{k \in \Gamma}$ is a family in a normed vector space such that $\sum_{k \in \Gamma} f_{k}$ converges. Prove that if $c \in \mathbf{F}$, then $\sum_{k \in \Gamma}\left(c f_{k}\right)$ converges and $$ \sum_{k \in \Gamma}\left(c f_{k}\right)=c \sum_{k \in \Gamma} f_{k} $$ 6 Suppose $\left\{a_{k}\right\}_{k \in \Gamma}$ is a family in $\mathbf{R}$. Prove that the unordered sum $\sum_{k \in \Gamma} a_{k}$ converges if and only if $\sum_{k \in \Gamma}\left|a_{k}\right|<\infty$. 7 Suppose $\left\{f_{k}\right\}_{k \in \mathbf{Z}^{+}}$is a family in a normed vector space $V$ and $f \in V$. Prove that the unordered sum $\sum_{k \in \mathbf{Z}^{+}} f_{k}$ equals $f$ if and only if the usual ordered sum $\sum_{k=1}^{\infty} f_{p(k)}$ equals $f$ for every injective and surjective function $p: \mathbf{Z}^{+} \rightarrow \mathbf{Z}^{+}$. 8 Explain why 8.58 implies that if $\Gamma$ is a finite set and $\left\{e_{k}\right\}_{k \in \Gamma}$ is an orthonormal family in a Hilbert space $V$, then $\operatorname{span}\left\{e_{k}\right\}_{k \in \Gamma}$ is a closed subspace of $V$. 9 Suppose $V$ is an infinite-dimensional Hilbert space. Prove that there does not exist a basis of $V$ that is an orthonormal family. 10 (a) Show that the orthonormal family given in the first bullet point of Example 8.51 is an orthonormal basis of $\ell^{2}$. (b) Show that the orthonormal family given in the second bullet point of Example 8.51 is an orthonormal basis of $\ell^{2}(\Gamma)$. (c) Show that the orthonormal family given in the fourth bullet point of Example 8.51 is not an orthonormal basis of $L^{2}([0,1))$. (d) Show that the orthonormal family given in the fifth bullet point of Example 8.51 is not an orthonormal basis of $L^{2}(\mathbf{R})$. 11 Suppose $\mu$ is a $\sigma$-finite measure on $(X, \mathcal{S})$ and $v$ is a $\sigma$-finite measure on $(Y, \mathcal{T})$. Suppose also that $\left\{e_{j}\right\}_{j \in \Omega}$ is an orthonormal basis of $L^{2}(\mu)$ and $\left\{f_{k}\right\}_{k \in \Gamma}$ is an orthonormal basis of $L^{2}(v)$ for some countable set $\Gamma$. For $j \in \Omega$ and $k \in \Gamma$, define $g_{j, k}: X \times Y \rightarrow \mathbf{F}$ by $$ g_{j, k}(x, y)=e_{j}(x) f_{k}(y) $$ Prove that $\left\{g_{j, k}\right\}_{j \in \Omega, k \in \Gamma}$ is an orthonormal basis of $L^{2}(\mu \times v)$. 12 Prove the converse of Parseval's identity. More specifically, prove that if $\left\{e_{k}\right\}_{k \in \Gamma}$ is an orthonormal family in a Hilbert space $V$ and $$ \|f\|^{2}=\sum_{k \in \Gamma}\left|\left\langle f, e_{k}\right\rangle\right|^{2} $$ for every $f \in V$, then $\left\{e_{k}\right\}_{k \in \Gamma}$ is an orthonormal basis of $V$. 13 (a) Show that the Hilbert space $L^{2}([0,1])$ is separable. (b) Show that the Hilbert space $L^{2}(\mathbf{R})$ is separable. (c) Show that the Banach space $\ell^{\infty}$ is not separable. 14 Prove that every subspace of a separable normed vector space is separable. 15 Suppose $V$ is an infinite-dimensional Hilbert space. Prove that there does not exist a translation invariant measure on the Borel subsets of $V$ that assigns positive but finite measure to each open ball in $V$. [A subset of $V$ is called a Borel set if it is in the smallest $\sigma$-algebra containing all the open subsets of $V$. A measure $\mu$ on the Borel subsets of $V$ is called translation invariant if $\mu(f+E)=\mu(E)$ for every $f \in V$ and every Borel set E of $V$.] 16 Find the polynomial $g$ of degree at most 4 that minimizes $\int_{0}^{1}\left|x^{5}-g(x)\right|^{2} d x$. 17 Prove that each orthonormal family in a Hilbert space can be extended to an orthonormal basis of the Hilbert space. Specifically, suppose $\left\{e_{j}\right\}_{j \in \Omega}$ is an orthonormal family in a Hilbert space $V$. Prove that there exists a set $\Gamma$ containing $\Omega$ and an orthonormal basis $\left\{f_{k}\right\}_{k \in \Gamma}$ of $V$ such that $f_{j}=e_{j}$ for every $j \in \Omega$. 18 Prove that every vector space has a basis. 19 Find the polynomial $g$ of degree at most 4 such that $$ f\left(\frac{1}{2}\right)=\int_{0}^{1} f g $$ for every polynomial $f$ of degree at most 4 . ## Exercises 20-25 are for readers familiar with analytic functions 20 Suppose $G$ is a nonempty open subset of $\mathbf{C}$. The Bergman space $L_{a}^{2}(G)$ is defined to be the set of analytic functions $f: G \rightarrow \mathbf{C}$ such that $$ \int_{G}|f|^{2} d \lambda_{2}<\infty, $$ where $\lambda_{2}$ is the usual Lebesgue measure on $\mathbf{R}^{2}$, which is identified with $\mathbf{C}$. For $f, h \in L_{a}^{2}(G)$, define $\langle f, h\rangle$ to be $\int_{G} f \bar{h} d \lambda_{2}$. (a) Show that $L_{a}^{2}(G)$ is a Hilbert space. (b) Show that if $w \in G$, then $f \mapsto f(w)$ is a bounded linear functional on $L_{a}^{2}(G)$. 21 Let $\mathbf{D}$ denote the open unit disk in $\mathbf{C}$; thus $$ \mathbf{D}=\{z \in \mathbf{C}:|z|<1\} $$ (a) Find an orthonormal basis of $L_{a}^{2}(\mathbf{D})$. (b) Suppose $f \in L_{a}^{2}(\mathbf{D})$ has Taylor series $$ f(z)=\sum_{k=0}^{\infty} a_{k} z^{k} $$ for $z \in \mathbf{D}$. Find a formula for $\|f\|$ in terms of $a_{0}, a_{1}, a_{2}, \ldots$ (c) Suppose $w \in$ D. By the previous exercise and the Riesz Representation Theorem (8.47 and 8.76), there exists $\Gamma_{w} \in L_{a}^{2}(\mathbf{D})$ such that $$ f(w)=\left\langle f, \Gamma_{w}\right\rangle \text { for all } f \in L_{a}^{2}(\mathbf{D}) \text {. } $$ Find an explicit formula for $\Gamma_{w}$. 22 Suppose $G$ is the annulus defined by $$ G=\{z \in \mathbf{C}: 1<|z|<2\} . $$ (a) Find an orthonormal basis of $L_{a}^{2}(G)$. (b) Suppose $f \in L_{a}^{2}(G)$ has Laurent series $$ f(z)=\sum_{k=-\infty}^{\infty} a_{k} z^{k} $$ for $z \in G$. Find a formula for $\|f\|$ in terms of $\ldots, a_{-1}, a_{0}, a_{1}, \ldots$ 23 Prove that if $f \in L_{a}^{2}(\mathbf{D} \backslash\{0\})$, then $f$ has a removable singularity at 0 (meaning that $f$ can be extended to a function that is analytic on $\mathbf{D}$ ). 24 The Dirichlet space $\mathcal{D}$ is defined to be the set of analytic functions $f: \mathbf{D} \rightarrow \mathbf{C}$ such that $$ \int_{\mathbf{D}}\left|f^{\prime}\right|^{2} d \lambda_{2}<\infty $$ For $f, g \in \mathcal{D}$, define $\langle f, g\rangle$ to be $f(0) \overline{g(0)}+\int_{\mathbf{D}} f^{\prime} \overline{g^{\prime}} \mathrm{d} \lambda_{2}$. (a) Show that $\mathcal{D}$ is a Hilbert space. (b) Show that if $w \in \mathbf{D}$, then $f \mapsto f(w)$ is a bounded linear functional on $\mathcal{D}$. (c) Find an orthonormal basis of $\mathcal{D}$. (d) Suppose $f \in \mathcal{D}$ has Taylor series $$ f(z)=\sum_{k=0}^{\infty} a_{k} z^{k} $$ for $z \in \mathbf{D}$. Find a formula for $\|f\|$ in terms of $a_{0}, a_{1}, a_{2}, \ldots$. (e) Suppose $w \in$ D. Find an explicit formula for $\Gamma_{w} \in \mathcal{D}$ such that $$ f(w)=\left\langle f, \Gamma_{w}\right\rangle \text { for all } f \in \mathcal{D} \text {. } $$ 25 (a) Prove that the Dirichlet space $\mathcal{D}$ is contained in the Bergman space $L_{a}^{2}(\mathbf{D})$. (b) Prove that there exists a function $f \in L_{a}^{2}(\mathbf{D})$ such that $f$ is uniformly continuous on $\mathbf{D}$ and $f \notin \mathcal{D}$. ================================================ FILE: tests/image/math.md ================================================ This sentence uses `$` delimiters to show math inline: $\sqrt{3x-1}+(1+x)^2$ This sentence $E = mc^2$ uses delimiters to show math inline: $`\sqrt{3x-1}+(1+x)^2`$ **The Cauchy-Schwarz Inequality** $$\left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)$$ **The Cauchy-Schwarz Inequality** ```math \left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right) ``` ![image](test copy.png) $\int_0^1 x^2 dx$ $ \x \leq 17 $ ```math ``` ================================================ FILE: tests/image/test-mermaid.md ================================================ ```mermaid sequenceDiagram participant dotcom participant iframe participant viewscreen dotcom->>iframe: loads html w/ iframe url iframe->>viewscreen: request template viewscreen->>iframe: html & javascript iframe->>dotcom: iframe ready dotcom->>iframe: set mermaid data on iframe iframe->>iframe: render mermaid ``` ================================================ FILE: tests/image/test.aux ================================================ \relax \@writefile{toc}{\contentsline {section}{\numberline {1}Image Tests}{1}{}\protected@file@percent } \@writefile{lof}{\contentsline {figure}{\numberline {1}{\ignorespaces Test image centered in a figure environment.}}{1}{}\protected@file@percent } \newlabel{fig:test_image}{{1}{1}{}{figure.1}{}} \@writefile{toc}{\contentsline {section}{\numberline {2}Some beautiful mathematical equations}{2}{}\protected@file@percent } \gdef \@abspage@last{4} ================================================ FILE: tests/image/test.css ================================================ .foo { background: lightblue url("./test.png") no-repeat fixed center; content: url(test.png); } ================================================ FILE: tests/image/test.html ================================================ Neovim Latest release Last commit ================================================ FILE: tests/image/test.jsx ================================================ export const Modal = (props) => { return ( ) } ================================================ FILE: tests/image/test.md ================================================ # test ## Inline Base64 Image ![Hello World](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEYAAAAUCAAAAAAVAxSkAAABrUlEQVQ4y+3TPUvDQBgH8OdDOGa+oUMgk2MpdHIIgpSUiqC0OKirgxYX8QVFRQRpBRF8KShqLbgIYkUEteCgFVuqUEVxEIkvJFhae3m8S2KbSkcFBw9yHP88+eXucgH8kQZ/jSm4VDaIy9RKCpKac9NKgU4uEJNwhHhK3qvPBVO8rxRWmFXPF+NSM1KVMbwriAMwhDgVcrxeMZm85GR0PhvGJAAmyozJsbsxgNEir4iEjIK0SYqGd8sOR3rJAGN2BCEkOxhxMhpd8Mk0CXtZacxi1hr20mI/rzgnxayoidevcGuHXTC/q6QuYSMt1jC+gBIiMg12v2vb5NlklChiWnhmFZpwvxDGzuUzV8kOg+N8UUvNBp64vy9q3UN7gDXhwWLY2nMC3zRDibfsY7wjEkY79CdMZhrxSqqzxf4ZRPXwzWJirMicDa5KwiPeARygHXKNMQHEy3rMopDR20XNZGbJzUtrwDC/KshlLDWyqdmhxZzCsdYmf2fWZPoxCEDyfIvdtNQH0PRkH6Q51g8rFO3Qzxh2LbItcDCOpmuOsV7ntNaERe3v/lP/zO8yn4N+yNPrekmPAAAAAElFTkSuQmCC) ## Wikilinks !![[test.png]] !![[test.png|options]] ## Injected HTML png Latest release ## Markdown Links ![small](https://picsum.photos/200/30) ![relative png](./test.png) ![png](test.png) ![jpg](test.jpg) ================================================ FILE: tests/image/test.mmd ================================================ sequenceDiagram participant dotcom participant iframe participant viewscreen dotcom->>iframe: loads html w/ iframe url iframe->>viewscreen: request template viewscreen->>iframe: html & javascript iframe->>dotcom: iframe ready dotcom->>iframe: set mermaid data on iframe iframe->>iframe: render mermaid ================================================ FILE: tests/image/test.norg ================================================ # Test - foo This sentence $E = mc^2$ uses delimiters to show math inline: $`\sqrt{3x-1}+(1+x)^2`$ .image ./test.png .image test.png This sentence uses `$` delimiters to show math inline: $\sqrt{3x-1}+(1+x)^2$ This sentence $E = mc^2$ uses delimiters to show math inline: $`\sqrt{3x-1}+(1+x)^2`$ **The Cauchy-Schwarz Inequality** $$\left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)$$ **The Cauchy-Schwarz Inequality** ```math \left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right) ``` ![image](test copy.png) $\int_0^1 x^2 dx$ $ \x \leq 17 $ ================================================ FILE: tests/image/test.org ================================================ [[test.png]] ================================================ FILE: tests/image/test.scss ================================================ .foo { background: lightblue url("./test.png") no-repeat fixed center; background: lightblue url("./test.png") no-repeat fixed center; content: url(test.png); } ================================================ FILE: tests/image/test.svelte ================================================

{greeting}

test ================================================ FILE: tests/image/test.tex ================================================ \documentclass{article} \usepackage{graphicx, amsmath, amssymb, multirow} \usepackage{geometry} \geometry{ a4paper, total={170mm,257mm}, left=20mm, top=20mm, } \begin{document} \section{Image Tests} Inline image: \includegraphics[width=0.15\textwidth]{test.png} \begin{figure}[h] \centering \includegraphics[width=0.7\textwidth]{test.png} \caption{Test image centered in a figure environment.} \label{fig:test_image} \end{figure} \newpage \section{Some beautiful mathematical equations} Ramanujan's formula: $$\frac{1}{\pi}=\frac{2\sqrt{2}}{9801}\sum_{k=0}^\infty\frac{(4k)! (1103+26390k)}{(k!)^4 396^{4k}}$$ Euler's formula: $e^{i\pi}+1=0$ Area of triangle with sides a,b,c is: $$A=\frac{1}{2} \sqrt{s(s-a)(s-b)(s-c)},\quad s=\frac{a+b+c}{2}$$ The most important formula in calculus: \[ f'(x)=\lim_{h\to 0}\frac{f(x+h)-f(x)}{h} \] Einstain's field equations: \[ R_{\mu\nu}-\frac{1}{2}g_{\mu\nu}R+\Lambda g_{\mu\nu}=\frac{8\pi G}{c^4}T_{\mu\nu} \] Gamma function: \[ \Gamma(z) = \int_0^\infty t^{z-1} e^{-t} dt,\quad\Gamma(z+1) = z \Gamma(z) \] Pythagora's theorem: $$a^2+b^2=c^2$$ Logarithms: $$\log ab=\log a+\log b$$ Navier-Stokes equation: $$\rho\left(\frac{\partial \textbf{v}}{\partial t}+\textbf{v}\cdot\nabla\textbf{v}\right)+\nabla p-\nabla\cdot\textbf{T}=\textbf{f}$$ Law of gravity: $$F=G\frac{m_1m_2}{r^2}$$ Fourier transform: \[ F(\omega) = \int_{-\infty}^\infty f(t) e^{-2\pi i t \omega} dt \] Maxwell's equations: \begin{equation} \begin{aligned} \nabla \times \textbf{E}&=\frac{\rho}{\epsilon_0} \\ \nabla \cdot \textbf{H}&=0 \\ \nabla \times \textbf{E}&=-\frac 1c\frac{\partial \textbf{H}}{\partial t} \\ \nabla \times \textbf{H}&=\frac 1c\frac{\partial \textbf{E}}{\partial t} \end{aligned} \end{equation} Schroedinger equation: \[ i \hbar \frac{\partial \psi}{\partial t} = H\Psi \] Chaos theory: $$x_{t+1}=kx_t(1-x_t)$$ Information theory: \[ H=-\sum p(x)\log p(x) \] Black-Scholes equation: $$\frac12\sigma^2S^2\frac{\partial^2V}{\partial S^2}+rS\frac{\partial V}{\partial S}+\frac{\partial V}{\partial t}-rV=0$$ Second law or thermodynamics: $$dS\ge 0$$ Mass-energy equivalence: $$E=mc^2$$ Basel problem: \[ \frac{\pi^2}{6}=\sum_{n=1}^\infty \frac{1}{n^2} \] Euler-Masceroni constant: \[ \gamma = \lim_{n\to\infty}(\sum_{n=1}^\infty \frac{1}{n}-\log n)\approx 0.5772156649\ldots \] Binomial expansion: \[ (a+b)^n = \sum_{k=0}^n \binom{n}{k} a^k b^{n-k} \] Gauss: $$\int_{-\infty}^\infty e^{-x^2} dx = \sqrt{\pi}$$ The Callan-Symanzik equation: \[ \left[M\frac{\partial}{\partial M}+\beta(g)\frac{\partial}{\partial g}+n\gamma\right]G^n(x_1,x_2,...,x_n;M,g)=0 \] Minimal surface equation: $$\mathcal{A}(u)=\int_\Omega(1+|\nabla u|^2)^{1/2} dx_1 dx_2 ... dx_n$$ Multiline equations: \begin{eqnarray*} \cos{2\theta} & = & \cos^2\theta - \sin^2\theta \\ & = & 2\cos^2\theta - 1 \\ & = & 1 - 2\sin^2\theta \end{eqnarray*} And finally: $$1=0.999999999999999999\ldots$$ Just for fun: $6 + 9 + 6 \cdot 9 = 69$ Quadratic equation: \[ ax^2+bx+c=0 \implies x_{1,2}=\frac{-b\pm\sqrt{b^2-4ac}}{2a} \] Four more ways to calculate pi: \begin{equation*} \pi=\sum_{k=0}^\infty\left[\frac{1}{16^k}\left(\frac{4}{8k+1}-\frac{2}{8k+4}-\frac{1}{8k+5}-\frac{1}{8k+6} \right) \right] \end{equation*} \begin{equation*} \frac{2}{\pi}=\frac{\sqrt{2}}{2}\cdot\frac{\sqrt{2+\sqrt{2}}}{2}\cdot\frac{\sqrt{2+\sqrt{2+\sqrt{2}}}}{2}\cdot\ldots \end{equation*} \begin{equation*} \pi = 3+\textstyle \cfrac{1}{7+\textstyle \cfrac{1}{15+\textstyle \cfrac{1}{1+\textstyle \cfrac{1}{292+\textstyle \cfrac{1}{1+\textstyle \cfrac{1}{1+\textstyle \cfrac{1}{1+\ddots}}}}}}} \end{equation*} Chudnovsky Formula: \[ \frac{1}{\pi} = \frac{\sqrt{10005}}{4270934400} \sum_{k=0}^\infty \frac{(6k)! (13591409 + 545140134k)}{(3k)!\,k!^3 (-640320)^{3k}} \] Cauchy's integral formula: $$f(a)=\frac{1}{2\pi i}\int_{C} \frac{f(z)}{z-a} dz $$ Stirling's factorial approximation: $$n! = \sqrt{2 \pi n} \left( \frac{n}{e} \right)^n \left( 1 + O \left( \frac{1}{n} \right) \right)$$ \end{document} ================================================ FILE: tests/image/test.tsx ================================================ export const Modal = (props) => { return ( ) } ================================================ FILE: tests/image/test.typ ================================================ #figure( image("test.png", width: 80%), caption: [ A step in the molecular testing pipeline of our lab. ], ) #set page(width: auto, height: auto, margin: (x: 2pt, y: 2pt)) #set text(size: 12pt, fill: rgb("#FF0000")) $ 5 + 5 = 10 $ $ E = g c^2 $ $ A = pi r^2 $ $ "area" = pi dot "radius"^2 $ $ cal(A) := { x in RR | x "is natural" } $ #let x = 5 $ x < 17 $ $ (3x + y) / 7 &= 9 && "given" \ 3x + y &= 63 & "multiply by 7" \ 3x &= 63 - y && "subtract y" \ x &= 21 - y/3 & "divide by 3" $ // snacks: header start #let x = 5 // snacks: header end $ #x <= 17 $ ================================================ FILE: tests/image/test.vue ================================================ ================================================ FILE: tests/image/test2.md ================================================ # test ## Wikilinks !![[test.jpg]] ================================================ FILE: tests/minit.lua ================================================ #!/usr/bin/env -S nvim -l vim.env.LAZY_STDPATH = ".tests" vim.env.LAZY_PATH = vim.fs.normalize("~/projects/lazy.nvim") if vim.fn.isdirectory(vim.env.LAZY_PATH) == 1 then loadfile(vim.env.LAZY_PATH .. "/bootstrap.lua")() else load(vim.fn.system("curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua"), "bootstrap.lua")() end -- Setup lazy.nvim require("lazy.minit").setup({ spec = { { dir = vim.uv.cwd() }, }, }) ================================================ FILE: tests/picker/diff_spec.lua ================================================ describe("picker.diff", function() local diff = require("snacks.picker.source.diff") describe("parse", function() it("parses git diff format", function() local lines = { "diff --git a/file.txt b/file.txt", "index abc123..def456 100644", "--- a/file.txt", "+++ b/file.txt", "@@ -1,3 +1,3 @@ context", " unchanged", "-old line", "+new line", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("file.txt", blocks[1].file) assert.equals(4, #blocks[1].header) assert.equals(1, #blocks[1].hunks) assert.equals(1, blocks[1].hunks[1].line) assert.equals(4, #blocks[1].hunks[1].diff) end) it("doesn't parse a filename from deleted lua comment", function() local lines = { "diff --git a/lua/todo-comments/config.lua b/lua/todo-comments/config.lua", "index 0e2d34e..a8e1077 100644", "--- a/lua/todo-comments/config.lua", "+++ b/lua/todo-comments/config.lua", "@@ -11,7 +11,6 @@ M.loaded = false", ' M.ns = vim.api.nvim_create_namespace("todo-comments")', "", " --- @class TodoOptions", "--- TODO: add support for markdown todos", " local defaults = {", " signs = true, -- show icons in the signs column", " sign_priority = 8, -- sign priority", " }", " end)", "", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("lua/todo-comments/config.lua", blocks[1].file) end) it("parses plain diff format (no git header)", function() local lines = { "--- file1.txt\t2024-01-01 12:00:00", "+++ file2.txt\t2024-01-02 12:00:00", "@@ -1,3 +1,3 @@", " unchanged", "-old line", "+new line", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("file2.txt", blocks[1].file) assert.equals(2, #blocks[1].header) assert.equals(1, #blocks[1].hunks) assert.equals(1, blocks[1].hunks[1].line) end) it("parses plain diff format (recursive)", function() local lines = { "diff -Naur old/file1.txt new/file1.txt", "--- old/file1.txt 2025-01-01 13:00:00.000000000 +0100", "+++ new/file1.txt 1970-01-01 01:00:00.000000000 +0100", "@@ -1,3 +0,0 @@", "-context1", "-old content", "-context3", "diff -Naur old/file2.txt new/file2.txt", "--- old/file2.txt 1970-01-01 01:00:00.000000000 +0100", "+++ new/file2.txt 2025-01-01 13:00:00.000000000 +0100", "@@ -0,0 +1,3 @@", "+context1", "+new line", "+context3", } local blocks = diff.parse(lines).blocks assert.equals(2, #blocks) assert.equals(3, #blocks[1].header) assert.equals("file1.txt", blocks[1].file) assert.equals(1, #blocks[1].hunks) assert.equals(0, blocks[1].hunks[1].line) assert.equals(3, #blocks[2].header) assert.equals("file2.txt", blocks[2].file) assert.equals(1, #blocks[2].hunks) assert.equals(1, blocks[2].hunks[1].line) end) it("parses combined diff format (merge commits)", function() local lines = { "diff --cc file.txt", "index abc,def..123", "--- a/file.txt", "+++ b/file.txt", "@@@ -10,5 -12,3 +10,6 @@@ context", " unchanged in all", "--removed from parent 1", " -removed from parent 2", "++added in merge", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("file.txt", blocks[1].file) assert.equals(1, #blocks[1].hunks) assert.equals(10, blocks[1].hunks[1].line) -- third position (+10) end) it("parses multiple files", function() local lines = { "diff --git a/file1.txt b/file1.txt", "--- a/file1.txt", "+++ b/file1.txt", "@@ -1,1 +1,1 @@", "-old1", "+new1", "diff --git a/file2.txt b/file2.txt", "--- a/file2.txt", "+++ b/file2.txt", "@@ -1,1 +1,1 @@", "-old2", "+new2", } local blocks = diff.parse(lines).blocks assert.equals(2, #blocks) assert.equals("file1.txt", blocks[1].file) assert.equals("file2.txt", blocks[2].file) end) it("parses multiple hunks per file", function() local lines = { "diff --git a/file.txt b/file.txt", "--- a/file.txt", "+++ b/file.txt", "@@ -1,1 +1,1 @@", "-old1", "+new1", "@@ -10,1 +10,1 @@", "-old2", "+new2", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals(2, #blocks[1].hunks) assert.equals(1, blocks[1].hunks[1].line) assert.equals(10, blocks[1].hunks[2].line) end) it("sorts hunks by line number", function() local lines = { "diff --git a/file.txt b/file.txt", "--- a/file.txt", "+++ b/file.txt", "@@ -50,1 +50,1 @@", "-old2", "@@ -10,1 +10,1 @@", "-old1", } local blocks = diff.parse(lines).blocks assert.equals(2, #blocks[1].hunks) assert.equals(10, blocks[1].hunks[1].line) -- sorted assert.equals(50, blocks[1].hunks[2].line) end) it("handles binary files", function() local lines = { "diff --git a/image.png b/image.png", "index abc123..def456 100644", "Binary files a/image.png and b/image.png differ", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("image.png", blocks[1].file) assert.equals(3, #blocks[1].header) -- diff line + binary notice assert.equals(0, #blocks[1].hunks) -- no hunks for binary end) it("handles binary files with prefixes in the path", function() local lines = { "diff --git a/ b/image.png b/ b/image.png", "index abc123..def456 100644", "Binary files a/image.png and b/image.png differ", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals(" b/image.png", blocks[1].file) assert.equals(3, #blocks[1].header) -- diff line + binary notice assert.equals(0, #blocks[1].hunks) -- no hunks for binary end) it("handles pure renames", function() local lines = { "diff --git a/old.txt b/new.txt", "similarity index 100%", "rename from old.txt", "rename to new.txt", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("new.txt", blocks[1].file) assert.equals(4, #blocks[1].header) assert.equals(0, #blocks[1].hunks) end) it("handles renames with a diff", function() local lines = { "diff --git a/old.txt b/new.txt", "similarity index 66%", "rename from old.txt", "rename to new.txt", "--- a/old.text", "+++ b/new.txt", "@@ -1,3 +1,3 @@", "-line0", " line1", " line2", "+line3", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("new.txt", blocks[1].file) assert.equals(6, #blocks[1].header) assert.equals(1, #blocks[1].hunks) end) it("handles mode changes", function() local lines = { "diff --git a/script.sh b/script.sh", "old mode 100644", "new mode 100755", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("script.sh", blocks[1].file) assert.equals(3, #blocks[1].header) assert.equals(0, #blocks[1].hunks) end) it("handles deleted files", function() local lines = { "diff --git a/deleted.txt b/deleted.txt", "deleted file mode 100644", "index abc123..0000000", "--- a/deleted.txt", "+++ /dev/null", "@@ -1,3 +0,0 @@", "-line1", "-line2", "-line3", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("deleted.txt", blocks[1].file) assert.equals(1, #blocks[1].hunks) assert.equals(0, blocks[1].hunks[1].line) -- deleted at line 0 end) it("handles new files", function() local lines = { "diff --git a/new.txt b/new.txt", "new file mode 100644", "index 0000000..abc123", "--- /dev/null", "+++ b/new.txt", "@@ -0,0 +1,3 @@", "+line1", "+line2", "+line3", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("new.txt", blocks[1].file) assert.equals(1, #blocks[1].hunks) assert.equals(1, blocks[1].hunks[1].line) end) it("ignores empty lines before diff", function() local lines = { "", " ", "diff --git a/file.txt b/file.txt", "--- a/file.txt", "+++ b/file.txt", "@@ -1,1 +1,1 @@", "-old", "+new", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("file.txt", blocks[1].file) end) it("handles files with spaces in name", function() local lines = { "diff --git a/dir c/my file.txt b/dir c/my file.txt", "--- a/dir c/my file.txt", "+++ b/dir c/my file.txt", "@@ -1,1 +1,1 @@", "-old", "+new", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("dir c/my file.txt", blocks[1].file) end) it("handles quoted filenames", function() local lines = { 'diff --git "a/my file.txt" "b/my file.txt"', '--- "a/my file.txt"', '+++ "b/my file.txt"', "@@ -1,1 +1,1 @@", "-old", "+new", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("my file.txt", blocks[1].file) end) it("handles files in subdirectories", function() local lines = { "diff --git a/path/to/file.txt b/path/to/file.txt", "--- a/path/to/file.txt", "+++ b/path/to/file.txt", "@@ -1,1 +1,1 @@", "-old", "+new", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("path/to/file.txt", blocks[1].file) end) it("handles single-line changes in hunk header", function() local lines = { "diff --git a/file.txt b/file.txt", "--- a/file.txt", "+++ b/file.txt", "@@ -5 +5 @@", "-old", "+new", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals(1, #blocks[1].hunks) assert.equals(5, blocks[1].hunks[1].line) end) it("preserves diff content including - and + prefixes", function() local lines = { "diff --git a/file.txt b/file.txt", "--- a/file.txt", "+++ b/file.txt", "@@ -1,3 +1,3 @@", " context", "-removed", "+added", } local blocks = diff.parse(lines).blocks local hunk_diff = blocks[1].hunks[1].diff assert.equals("@@ -1,3 +1,3 @@", hunk_diff[1]) assert.equals(" context", hunk_diff[2]) assert.equals("-removed", hunk_diff[3]) assert.equals("+added", hunk_diff[4]) end) it("handles empty hunks (just @@ header)", function() local lines = { "diff --git a/file.txt b/file.txt", "--- a/file.txt", "+++ b/file.txt", "@@ -1,0 +1,0 @@", "@@ -10,1 +10,1 @@", "-old", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals(2, #blocks[1].hunks) assert.equals(1, #blocks[1].hunks[1].diff) -- just the @@ line assert.equals(2, #blocks[1].hunks[2].diff) -- @@ + one line end) it("handles context-only hunks (no changes, just context)", function() local lines = { "diff --git a/file.txt b/file.txt", "--- a/file.txt", "+++ b/file.txt", "@@ -1,3 +1,3 @@", " context line 1", " context line 2", " context line 3", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals(1, #blocks[1].hunks) assert.equals(4, #blocks[1].hunks[1].diff) end) it("handles hunk at line 0 (insertion at start)", function() local lines = { "diff --git a/file.txt b/file.txt", "--- a/file.txt", "+++ b/file.txt", "@@ -0,0 +1,2 @@", "+line1", "+line2", } local blocks = diff.parse(lines).blocks assert.equals(1, blocks[1].hunks[1].line) end) it("handles very long filenames", function() local long_path = "very/long/path/with/many/segments/" .. string.rep("a", 200) .. ".txt" local lines = { "diff --git a/" .. long_path .. " b/" .. long_path, "--- a/" .. long_path, "+++ b/" .. long_path, "@@ -1,1 +1,1 @@", "-old", "+new", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals(long_path, blocks[1].file) end) it("handles unicode in filenames", function() local lines = { "diff --git a/文件.txt b/文件.txt", "--- a/文件.txt", "+++ b/文件.txt", "@@ -1,1 +1,1 @@", "-old", "+new", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("文件.txt", blocks[1].file) end) it("handles truncated/incomplete diff gracefully", function() local lines = { "diff --git a/file.txt b/file.txt", "--- a/file.txt", "+++ b/file.txt", "@@ -1,5 +1,5 @@", " context", "-old", -- Missing rest of hunk } -- Should not crash local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals(1, #blocks[1].hunks) end) it("handles multiple git diffs", function() local lines = { "diff --git a/git1.txt b/git1.txt", "--- a/git1.txt", "+++ b/git1.txt", "@@ -1,1 +1,1 @@", "-old", "+new", "diff --git a/git2.txt b/git2.txt", "--- a/git2.txt", "+++ b/git2.txt", "@@ -1,1 +1,1 @@", "-old", "+new", "diff --git a/git3.txt b/git3.txt", "--- a/git3.txt", "+++ b/git3.txt", "@@ -1,1 +1,1 @@", "-old", "+new", } local blocks = diff.parse(lines).blocks assert.equals(3, #blocks) assert.equals("git1.txt", blocks[1].file) assert.equals("git2.txt", blocks[2].file) assert.equals("git3.txt", blocks[3].file) end) it("handles symlink changes", function() local lines = { "diff --git a/link.txt b/link.txt", "deleted file mode 120000", "--- a/link.txt", "+++ /dev/null", "@@ -1 +0,0 @@", "-target.txt", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals("link.txt", blocks[1].file) end) it("handles files with only newline changes", function() local lines = { "diff --git a/file.txt b/file.txt", "--- a/file.txt", "+++ b/file.txt", "@@ -1 +1 @@", "-line", "\\ No newline at end of file", "+line", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals(1, #blocks[1].hunks) -- Should include the "No newline" marker assert.truthy(vim.tbl_contains(blocks[1].hunks[1].diff, "\\ No newline at end of file")) end) it("handles diff with no file changes (same content)", function() local lines = { "diff --git a/file.txt b/file.txt", "index abc123..abc123 100644", "--- a/file.txt", "+++ b/file.txt", } local blocks = diff.parse(lines).blocks assert.equals(1, #blocks) assert.equals(0, #blocks[1].hunks) -- no hunks = no changes end) end) end) ================================================ FILE: tests/picker/git_status_spec.lua ================================================ ---@module 'luassert' local Git = require("snacks.picker.source.git") describe("git status", function() -- git status codes are always 2 characters local tests = { -- Unmerged cases ["AA"] = { xy = "AA", status = "added", unmerged = true }, ["UU"] = { xy = "UU", status = "modified", unmerged = true }, ["AU"] = { xy = "AU", status = "added", unmerged = true }, ["DD"] = { xy = "DD", status = "deleted", unmerged = true }, ["UD"] = { xy = "UD", status = "deleted", unmerged = true }, ["DU"] = { xy = "DU", status = "deleted", unmerged = true }, ["UA"] = { xy = "UA", status = "added", unmerged = true }, -- Regular cases [" M"] = { xy = " M", status = "modified" }, [" D"] = { xy = " D", status = "deleted" }, [" R"] = { xy = " R", status = "renamed" }, ["??"] = { xy = "??", status = "untracked" }, ["!!"] = { xy = "!!", status = "ignored" }, -- Staged cases ["M "] = { xy = "M ", status = "modified", staged = true }, ["T "] = { xy = "T ", status = "modified", staged = true }, ["D "] = { xy = "D ", status = "deleted", staged = true }, ["A "] = { xy = "A ", status = "added", staged = true }, ["AD"] = { xy = "AD", status = "added", staged = true }, ["C "] = { xy = "C ", status = "copied", staged = true }, } for _, test in pairs(tests) do it("should parse `" .. test.xy .. "`", function() local status = Git.git_status(test.xy) status.priority = nil assert.are.same(test, status) end) end end) ================================================ FILE: tests/picker/matcher_spec.lua ================================================ ---@module 'luassert' local M = {} M.files = { "lua/snacks/animate/", "lua/snacks/animate/easing.lua", "lua/snacks/animate/init.lua", "lua/snacks/bigfile.lua", "lua/snacks/bufdelete.lua", "lua/snacks/dashboard.lua", "lua/snacks/debug.lua", "lua/snacks/dim.lua", "lua/snacks/git.lua", "lua/snacks/gitbrowse.lua", "lua/snacks/health.lua", "lua/snacks/indent.lua", "lua/snacks/init.lua", "lua/snacks/input.lua", "lua/snacks/lazygit.lua", "lua/snacks/meta/", "lua/snacks/meta/docs.lua", "lua/snacks/meta/init.lua", "lua/snacks/meta/types.lua", "lua/snacks/notifier.lua", "lua/snacks/notify.lua", "lua/snacks/picker/", "lua/snacks/picker/async.lua", "lua/snacks/picker/init.lua", "lua/snacks/picker/list.lua", "lua/snacks/picker/matcher.lua", "lua/snacks/picker/preview.lua", "lua/snacks/picker/queue.lua", "lua/snacks/picker/sorter.lua", "lua/snacks/picker/topk.lua", "lua/snacks/profiler/", "lua/snacks/profiler/core.lua", "lua/snacks/profiler/init.lua", "lua/snacks/profiler/loc.lua", "lua/snacks/profiler/picker.lua", "lua/snacks/profiler/tracer.lua", "lua/snacks/profiler/ui.lua", "lua/snacks/quickfile.lua", "lua/snacks/rename.lua", "lua/snacks/scope.lua", "lua/snacks/scratch.lua", "lua/snacks/scroll.lua", "lua/snacks/statuscolumn.lua", "lua/snacks/terminal.lua", "lua/snacks/toggle.lua", "lua/snacks/util.lua", "lua/snacks/win.lua", "lua/snacks/words.lua", "lua/snacks/zen.lua", } local function fuzzy(pattern) local chars = vim.split(pattern, "") local pat = table.concat(chars, ".*") return vim.tbl_filter(function(v) return v:find(pat) end, M.files) end describe("fuzzy matching", function() local matcher = require("snacks.picker.core.matcher").new() local tests = { { "mod.md", "md", { 5, 6 } }, } for t, test in ipairs(tests) do it("should find optimal match for " .. t, function() matcher:init(test[2]) local item = { text = test[1], idx = 1, score = 0 } local score = matcher:match(item) assert(score and score > 0, "no match found") local positions = matcher:positions(item).text assert.are.same(test[3], positions) end) end local patterns = { "snacks", "lua", "sgbs", "mark", "dcs", "xxx", "lsw" } local algos = { "fuzzy", "fuzzy_find" } for _, pattern in ipairs(patterns) do local chars = vim.split(pattern, "") local expect = fuzzy(pattern) for _, algo in ipairs(algos) do it(("should find fuzzy matches for %q with %s"):format(pattern, algo), function() local matches = {} ---@type string[] for _, file in ipairs(M.files) do if matcher[algo](matcher, file, file, chars) then table.insert(matches, file) end end assert.are.same(expect, matches) end) end end end) ================================================ FILE: tests/picker/minheap_spec.lua ================================================ ---@module 'luassert' local MinHeap = require("snacks.picker.util.minheap") describe("MinHeap", function() local values = {} ---@type number[] for i = 1, 2000 do values[i] = i end ---@param tbl number[] local function shuffle(tbl) for i = #tbl, 2, -1 do local j = math.random(i) tbl[i], tbl[j] = tbl[j], tbl[i] end return tbl end for _ = 1, 100 do it("should push and pop values correctly", function() local topk = MinHeap.new({ capacity = 10 }) for _, v in ipairs(shuffle(values)) do topk:add(v) end table.sort(values, topk.cmp) local topn = vim.list_slice(values, 1, 10) assert.same(topn, topk:get()) end) end end) ================================================ FILE: tests/picker/util_spec.lua ================================================ ---@module 'luassert' describe("globs", function() local tests = { ["*.lua"] = "%.lua$", ["*/*.lua"] = "/[^/]*%.lua$", ["**/*.lua"] = "/[^/]*%.lua$", ["foo/**/bar/*.lua"] = "foo/.*/bar/[^/]*%.lua$", ["foo/*"] = "foo/", ["foo/**"] = "foo/", ["*.?sx"] = "%.[^/]sx$", } for glob, pattern in pairs(tests) do it("should convert glob to pattern: " .. glob, function() local result = Snacks.picker.util.glob2pattern(glob) assert.are.same(pattern, result) end) end end) ================================================ FILE: tests/scope_spec.lua ================================================ ---@module 'luassert' local M = {} ---@param lines string|string[] ---@param opts? {ft?: string, ts?: boolean} function M.set_lines(lines, opts) opts = opts or {} lines = type(lines) == "string" and vim.split(lines, "\n") or lines vim.api.nvim_buf_set_lines(0, 0, -1, false, lines --[[ @as string[] ]]) vim.bo.filetype = opts.ft or "" vim.treesitter.stop() assert(not vim.b.ts_highlight, "treesitter highlight is still enabled") vim.b.snacks_ts = nil if opts.ts then vim.treesitter.start() assert(vim.b.ts_highlight, "treesitter highlight is not enabled") end end function M.inspect(v) return vim.inspect(v):gsub("%s+", " ") end local test = [[ function foo() while true do if x == 1 then break end local y = 2 end end ]] describe("scope", function() local tests = { [1] = { 1, 8 }, [2] = { 2, 7 }, [3] = { 3, 5 }, [4] = { 3, 5 }, [5] = { 3, 5 }, [6] = { 2, 7 }, [7] = { 2, 7 }, [8] = { 1, 8 }, } Snacks.config.scope = { cursor = false, min_size = 2, treesitter = { blocks = false, }, } for _, ws in ipairs({ false, true }) do local lines = vim.split(vim.trim(test), "\n") local t = vim.deepcopy(tests) if ws == true then local c = #lines -- insert empty lines for i = 1, c do table.insert(lines, i * 2, "") end local test2 = {} -- transform tests for line, s in pairs(t) do test2[line * 2 - 1] = { s[1] * 2 - 1, s[2] * 2 - 1 } end t = test2 end for _, ts in ipairs({ true, false }) do for line, s in pairs(t) do it("should get scope " .. M.inspect({ line = line, scope = s, ts = ts, ws = ws }), function() M.set_lines(lines, { ft = "lua", ts = ts, }) Snacks.scope.get(function(scope) assert(scope) assert((scope.node == nil) == not ts) assert.same(scope.from, s[1]) assert.same(scope.to, s[2]) end, { pos = { line, 0 }, treesitter = { enabled = ts }, }) end) end end end end) local function foo() while true do -- doo if x == 1 and false then break end local y = 2 local y = 2 end end ================================================ FILE: tests/terminal_spec.lua ================================================ ---@module "luassert" local terminal = require("snacks.terminal") local tests = { { "bash", { "bash" } }, { '"bash"', { "bash" } }, { '"C:\\Program Files\\Git\\bin\\bash.exe" -c "echo hello"', { "C:\\Program Files\\Git\\bin\\bash.exe", "-c", "echo hello" }, }, { "pwsh -NoLogo", { "pwsh", "-NoLogo" } }, { 'echo "foo\tbar"', { "echo", "foo\tbar" } }, { "echo\tfoo", { "echo", "foo" } }, { 'this "is \\"a test"', { "this", 'is "a test' } }, } describe("terminal.parse", function() for _, test in ipairs(tests) do it("should parse " .. test[1], function() local result = terminal.parse(test[1]) assert.are.same(test[2], result) end) end end) describe("terminal.open", function() it("should set buffer when position is 'current'", function() -- Create a test buffer with content vim.cmd("enew") local test_buf = vim.api.nvim_get_current_buf() vim.api.nvim_buf_set_lines(test_buf, 0, -1, false, { "test content" }) -- Open terminal with position='current' local term = terminal.open(nil, { win = { position = "current" } }) -- Check that the current window now has the terminal buffer local current_win = vim.api.nvim_get_current_win() local current_buf = vim.api.nvim_win_get_buf(current_win) assert.are.equal(term.buf, current_buf, "Terminal buffer should be set in current window") assert.are.equal("terminal", vim.bo[current_buf].buftype, "Buffer should be a terminal") -- Clean up term:close() end) end) ================================================ FILE: tests/util_spec.lua ================================================ ---@module 'luassert' vim.g.mapleader = " " describe("util.normkey", function() local normkey = require("snacks.util").normkey local tests = { [""] = "", [""] = "", [""] = "", [""] = "", [""] = "", [""] = "", [""] = "A", [""] = "", [""] = "", [""] = "", [""] = "", [""] = "", [""] = "", [""] = "", [""] = "", [""] = "", [""] = "", [""] = "", [""] = "", [" "] = "", [""] = "", ["p"] = "