├── .stylua.toml ├── LICENSE ├── README.md ├── doc └── neovim-project.txt └── lua ├── neovim-project ├── config.lua ├── init.lua ├── payload.lua ├── picker.lua ├── preview.lua ├── project.lua └── utils │ ├── cmd.lua │ ├── history.lua │ ├── neo-tree.lua │ ├── path.lua │ └── showkeys.lua └── telescope └── _extensions └── neovim-project.lua /.stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 120 2 | line_endings = "Unix" 3 | indent_type = "Spaces" 4 | indent_width = 2 5 | quote_style = "AutoPreferDouble" 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🗃️ Neovim project manager plugin 2 | 3 | **Neovim project** plugin simplifies project management by maintaining project history and providing quick access to saved sessions via [Telescope](https://github.com/nvim-telescope/telescope.nvim) or [fzf-lua](https://github.com/ibhagwan/fzf-lua). It runs on top of the [Neovim Session Manager](https://github.com/Shatur/neovim-session-manager), which is needed to store all open tabs and buffers for each project. 4 | 5 | - ✅ Start where you left off last time. 6 | - ✅ Switch from project to project in second. 7 | - ✅ Sessions and history can be synced across your devices (rsync, Syncthing, Nextcloud, Dropbox, etc.) 8 | - ✅ Find all your projects by glob patterns defined in config. 9 | - ✅ Autosave **neo-tree.nvim** expanded directories and buffers order in **barbar.nvim**. 10 | - ✅ Choose your favorite picker. 11 | 12 | ![Neovim project manager plugin dracula theme](https://github.com/coffebar/neovim-project/assets/3100053/b75e9373-d694-48e4-abbf-3abfe98ae46f) 13 | 14 | ![Neovim project manager plugin onedark theme](https://github.com/coffebar/neovim-project/assets/3100053/2bc9b472-071c-4975-97b0-545bd1390053) 15 | 16 | ![Neovim project preview](https://github.com/user-attachments/assets/7a4aa83d-c57d-4e11-9702-88d4d174fe77) 17 | 18 | 🙏 **Neovim project manager** plugin is heavily inspired by [project.vim](https://github.com/ahmedkhalf/project.nvim) 19 | 20 | ## Usage 21 | 22 | 1. Set patterns in the [configuration](#%EF%B8%8F-configuration) to discover your projects. 23 | 2. Use [commands](#commands) to open your project. Or open Neovim in the project directory. Both methods will create a session. 24 | 3. Open files inside the project and work. 25 | 4. The session will be saved before closing Neovim or switching to another project via [commands](#commands). 26 | 5. Open Neovim in any non-project directory and the latest session will be loaded. 27 | 28 | ## 📦 Installation 29 | 30 | You can install the plugin using your preferred package manager. 31 | 32 |
Lazy.nvim 33 | 34 | ```lua 35 | { 36 | "coffebar/neovim-project", 37 | opts = { 38 | projects = { -- define project roots 39 | "~/projects/*", 40 | "~/.config/*", 41 | }, 42 | picker = { 43 | type = "telescope", -- or "fzf-lua" 44 | } 45 | }, 46 | init = function() 47 | -- enable saving the state of plugins in the session 48 | vim.opt.sessionoptions:append("globals") -- save global variables that start with an uppercase letter and contain at least one lowercase letter. 49 | end, 50 | dependencies = { 51 | { "nvim-lua/plenary.nvim" }, 52 | -- optional picker 53 | { "nvim-telescope/telescope.nvim", tag = "0.1.4" }, 54 | -- optional picker 55 | { "ibhagwan/fzf-lua" }, 56 | { "Shatur/neovim-session-manager" }, 57 | }, 58 | lazy = false, 59 | priority = 100, 60 | }, 61 | ``` 62 | 63 |
64 | 65 |
packer.nvim 66 | 67 | ```lua 68 | use({ 69 | "coffebar/neovim-project", 70 | config = function() 71 | -- enable saving the state of plugins in the session 72 | vim.opt.sessionoptions:append("globals") -- save global variables that start with an uppercase letter and contain at least one lowercase letter. 73 | -- setup neovim-project plugin 74 | require("neovim-project").setup { 75 | projects = { -- define project roots 76 | "~/projects/*", 77 | "~/.config/*", 78 | }, 79 | picker = { 80 | type = "telescope", -- or "fzf-lua" 81 | } 82 | } 83 | end, 84 | requires = { 85 | { "nvim-lua/plenary.nvim" }, 86 | -- optional picker 87 | { "nvim-telescope/telescope.nvim", tag = "0.1.4" }, 88 | -- optional picker 89 | { "ibhagwan/fzf-lua" }, 90 | { "Shatur/neovim-session-manager" }, 91 | } 92 | }) 93 | ``` 94 | 95 |
96 | 97 |
pckr.nvim 98 | 99 | ```lua 100 | { 101 | "coffebar/neovim-project", 102 | config = function() 103 | -- enable saving the state of plugins in the session 104 | vim.opt.sessionoptions:append("globals") -- save global variables that start with an uppercase letter and contain at least one lowercase letter. 105 | -- setup neovim-project plugin 106 | require("neovim-project").setup { 107 | projects = { -- define project roots 108 | "~/projects/*", 109 | "~/.config/*", 110 | }, 111 | picker = { 112 | type = "telescope", -- or "fzf-lua" 113 | } 114 | } 115 | end, 116 | requires = { 117 | { "nvim-lua/plenary.nvim" }, 118 | -- optional picker 119 | { "nvim-telescope/telescope.nvim", tag = "0.1.4" }, 120 | -- optional picker 121 | { "ibhagwan/fzf-lua" }, 122 | { "Shatur/neovim-session-manager" }, 123 | } 124 | }; 125 | ``` 126 | 127 |
128 | 129 | ## ⚙️ Configuration 130 | 131 | ### Default options: 132 | 133 | ```lua 134 | { 135 | -- Project directories 136 | projects = { 137 | "~/projects/*", 138 | "~/p*cts/*", -- glob pattern is supported 139 | "~/projects/repos/*", 140 | "~/.config/*", 141 | "~/work/*", 142 | }, 143 | -- Path to store history and sessions 144 | datapath = vim.fn.stdpath("data"), -- ~/.local/share/nvim/ 145 | -- Load the most recent session on startup if not in the project directory 146 | last_session_on_startup = true, 147 | -- Dashboard mode prevent session autoload on startup 148 | dashboard_mode = false, 149 | -- Timeout in milliseconds before trigger FileType autocmd after session load 150 | -- to make sure lsp servers are attached to the current buffer. 151 | -- Set to 0 to disable triggering FileType autocmd 152 | filetype_autocmd_timeout = 200, 153 | -- Keymap to delete project from history in Telescope picker 154 | forget_project_keys = { 155 | -- insert mode: Ctrl+d 156 | i = "", 157 | -- normal mode: d 158 | n = "d" 159 | }, 160 | -- Follow symbolic links in glob patterns (affects startup speed) 161 | -- "full" or true - follow symlinks in all matched directories 162 | -- "partial" - follow symlinks before any matching operators (*, ?, []) 163 | -- "none" or false or nil - do not follow symlinks 164 | follow_symlinks = "full", 165 | 166 | -- Overwrite some of Session Manager options 167 | session_manager_opts = { 168 | autosave_ignore_dirs = { 169 | vim.fn.expand("~"), -- don't create a session for $HOME/ 170 | "/tmp", 171 | }, 172 | autosave_ignore_filetypes = { 173 | -- All buffers of these file types will be closed before the session is saved 174 | "ccc-ui", 175 | "gitcommit", 176 | "gitrebase", 177 | "qf", 178 | "toggleterm", 179 | }, 180 | }, 181 | -- Picker to use for project selection 182 | -- Options: "telescope", "fzf-lua" 183 | -- Fallback to builtin select ui if the specified picker is not available 184 | picker = { 185 | type = "telescope", -- or "fzf-lua" 186 | preview = { 187 | enabled = true, -- show directory structure in Telescope preview 188 | git_status = true, -- show branch name, an ahead/behind counter, and the git status of each file/folder 189 | git_fetch = false, -- fetch from remote, used to display the number of commits ahead/behind, requires git authorization 190 | show_hidden = true, -- show hidden files/folders 191 | }, 192 | opts = { 193 | -- picker-specific options 194 | }, 195 | }, 196 | 197 | } 198 | ``` 199 | 200 | ## Commands 201 | 202 | Neovim project manager will add these commands: 203 | 204 | - `:NeovimProjectDiscover [sort]` - find a project based on patterns, with optional sorting arguments: 205 | - `default` (or no argument) - uses the order specified in the config. 206 | - `history` - prioritises the most recently accessed projects. 207 | - `alphabetical_name` - sorts projects alphabetically by project name. 208 | - `alphabetical_path` - sorts projects alphabetically by their full path. 209 | 210 | - `:NeovimProjectHistory` - select a project from your recent history. 211 | 212 | - `:NeovimProjectLoadRecent` - open the previous session. 213 | 214 | - `:NeovimProjectLoadHist` - opens the project from the history providing a project dir. 215 | 216 | - `:NeovimProjectLoad` - opens the project from all your projects providing a project dir. 217 | 218 | History is sorted by access time. "Discover" keeps order as you have in the config, but can be overridden using the sorting options. 219 | 220 | #### Mappings 221 | 222 | Use `Ctrl+d` in Telescope / fzf-lua to delete the project's session and remove it from the history. 223 | 224 | ## ⚡ Requirements 225 | 226 | - Neovim >= 0.10.0 227 | - Optional: Telescope.nvim for the Telescope picker 228 | - Optional: fzf-lua for the fzf-lua picker 229 | 230 | ## Demo video 231 | 232 | https://github.com/coffebar/neovim-project/assets/3100053/e88ae41a-5606-46c4-a287-4c476ed97ccc 233 | 234 | ## How to manage dotfiles repo 235 | 236 | If you have a repository for your dotfiles, you will find it convenient to access them through projects. 237 | 238 | Project pattern `~/.config/*` matches many programs config folders, including Neovim. 239 | So when you need to edit Neovim config, you open project `~/.config/nvim` by typing "nv..". When you need to edit alacritty config - you start typing "ala.." 240 | 241 | Of course, you want to use vim-fugitive and gitsigns in these projects. And it should be a single git repo for dotfiles. By default, Neovim will know nothing about your dotfiles repo. 242 | 243 | Create autocommands to update env variables to tell Neovim where is your dotfiles bare repo. Here is an example from my dotfiles: 244 | 245 | ```lua 246 | local augroup = vim.api.nvim_create_augroup("user_cmds", { clear = true }) 247 | 248 | local function update_git_env_for_dotfiles() 249 | -- Auto change ENV variables to enable 250 | -- bare git repository for dotfiles after 251 | -- loading saved session 252 | local home = vim.fn.expand("~") 253 | local git_dir = home .. "/dotfiles" 254 | 255 | if vim.env.GIT_DIR ~= nil and vim.env.GIT_DIR ~= git_dir then 256 | return 257 | end 258 | 259 | -- check dotfiles dir exists on current machine 260 | if vim.fn.isdirectory(git_dir) ~= 1 then 261 | vim.env.GIT_DIR = nil 262 | vim.env.GIT_WORK_TREE = nil 263 | return 264 | end 265 | 266 | -- check if the current working directory should belong to dotfiles 267 | local cwd = vim.loop.cwd() 268 | if vim.startswith(cwd, home .. "/.config/") or cwd == home or cwd == home .. "/.local/bin" then 269 | if vim.env.GIT_DIR == nil then 270 | -- export git location into ENV 271 | vim.env.GIT_DIR = git_dir 272 | vim.env.GIT_WORK_TREE = home 273 | end 274 | else 275 | if vim.env.GIT_DIR == git_dir then 276 | -- unset variables 277 | vim.env.GIT_DIR = nil 278 | vim.env.GIT_WORK_TREE = nil 279 | end 280 | end 281 | end 282 | 283 | vim.api.nvim_create_autocmd("DirChanged", { 284 | pattern = { "*" }, 285 | group = augroup, 286 | desc = "Update git env for dotfiles after changing directory", 287 | callback = function() 288 | update_git_env_for_dotfiles() 289 | end, 290 | }) 291 | 292 | vim.api.nvim_create_autocmd("User", { 293 | pattern = { "SessionLoadPost" }, 294 | group = augroup, 295 | desc = "Update git env for dotfiles after loading session", 296 | callback = function() 297 | update_git_env_for_dotfiles() 298 | end, 299 | }) 300 | ``` 301 | 302 | This code should be required from your `init.lua` before plugins. 303 | 304 | ## 🤝 Contributing 305 | 306 | - Open a ticket if you want integration with another plugin, or if you want to request a new feature. 307 | - If you encounter bugs please open an issue. 308 | - Pull requests are welcome. Follow the Conventional Commits specification for commit naming. 309 | -------------------------------------------------------------------------------- /doc/neovim-project.txt: -------------------------------------------------------------------------------- 1 | *neovim-project.txt* For Last change: 2025 March 30 2 | 3 | ============================================================================== 4 | Table of Contents *neovim-project-table-of-contents* 5 | 6 | 1. 🗃️ Neovim project manager plugin|neovim-project-🗃️-neovim-project-manager-plugin| 7 | - Usage |neovim-project-🗃️-neovim-project-manager-plugin-usage| 8 | - 📦 Installation|neovim-project-🗃️-neovim-project-manager-plugin-📦-installation| 9 | - ⚙️ Configuration|neovim-project-🗃️-neovim-project-manager-plugin-⚙️-configuration| 10 | - Commands |neovim-project-🗃️-neovim-project-manager-plugin-commands| 11 | - ⚡ Requirements|neovim-project-🗃️-neovim-project-manager-plugin-⚡-requirements| 12 | - How to manage dotfiles repo|neovim-project-🗃️-neovim-project-manager-plugin-how-to-manage-dotfiles-repo| 13 | - 🤝 Contributing|neovim-project-🗃️-neovim-project-manager-plugin-🤝-contributing| 14 | 15 | ============================================================================== 16 | 1. 🗃️ Neovim project manager plugin *neovim-project-🗃️-neovim-project-manager-plugin* 17 | 18 | **Neovim project** plugin simplifies project management by maintaining project 19 | history and providing quick access to saved sessions via Telescope 20 | or fzf-lua 21 | . It runs on top of the 22 | Neovim Session Manager , 23 | which is needed to store all open tabs and buffers for each project. 24 | 25 | - ✅ Start where you left off last time. 26 | - ✅ Switch from project to project in second. 27 | - ✅ Sessions and history can be synced across your devices (rsync, Syncthing, Nextcloud, Dropbox, etc.) 28 | - ✅ Find all your projects by glob patterns defined in config. 29 | - ✅ Autosave **neo-tree.nvim** expanded directories and buffers order in **barbar.nvim**. 30 | 31 | 🙏 **Neovim project manager** plugin is heavily inspired by project.vim 32 | 33 | 34 | 35 | USAGE *neovim-project-🗃️-neovim-project-manager-plugin-usage* 36 | 37 | 1. Set patterns in the |neovim-project-configuration| to discover your projects. 38 | 2. Use |neovim-project-commands| to open your project. Or open Neovim in the project directory. Both methods will create a session. 39 | 3. Open files inside the project and work. 40 | 4. The session will be saved before closing Neovim or switching to another project via |neovim-project-commands|. 41 | 5. Open Neovim in any non-project directory and the latest session will be loaded. 42 | 43 | 44 | 📦 INSTALLATION *neovim-project-🗃️-neovim-project-manager-plugin-📦-installation* 45 | 46 | You can install the plugin using your preferred package manager. 47 | 48 | Lazy.nvim ~ 49 | 50 | >lua 51 | { 52 | "coffebar/neovim-project", 53 | opts = { 54 | projects = { -- define project roots 55 | "~/projects/*", 56 | "~/.config/*", 57 | }, 58 | picker = { 59 | type = "telescope", -- or "fzf-lua" 60 | } 61 | }, 62 | init = function() 63 | -- enable saving the state of plugins in the session 64 | vim.opt.sessionoptions:append("globals") -- save global variables that start with an uppercase letter and contain at least one lowercase letter. 65 | end, 66 | dependencies = { 67 | { "nvim-lua/plenary.nvim" }, 68 | -- optional picker 69 | { "nvim-telescope/telescope.nvim", tag = "0.1.4" }, 70 | -- optional picker 71 | { "ibhagwan/fzf-lua" }, 72 | { "Shatur/neovim-session-manager" }, 73 | }, 74 | lazy = false, 75 | priority = 100, 76 | }, 77 | < 78 | 79 | packer.nvim ~ 80 | 81 | >lua 82 | use({ 83 | "coffebar/neovim-project", 84 | config = function() 85 | -- enable saving the state of plugins in the session 86 | vim.opt.sessionoptions:append("globals") -- save global variables that start with an uppercase letter and contain at least one lowercase letter. 87 | -- setup neovim-project plugin 88 | require("neovim-project").setup { 89 | projects = { -- define project roots 90 | "~/projects/*", 91 | "~/.config/*", 92 | }, 93 | picker = { 94 | type = "telescope", -- or "fzf-lua" 95 | } 96 | } 97 | end, 98 | requires = { 99 | { "nvim-lua/plenary.nvim" }, 100 | -- optional picker 101 | { "nvim-telescope/telescope.nvim", tag = "0.1.4" }, 102 | -- optional picker 103 | { "ibhagwan/fzf-lua" }, 104 | { "Shatur/neovim-session-manager" }, 105 | } 106 | }) 107 | < 108 | 109 | pckr.nvim ~ 110 | 111 | >lua 112 | { 113 | "coffebar/neovim-project", 114 | config = function() 115 | -- enable saving the state of plugins in the session 116 | vim.opt.sessionoptions:append("globals") -- save global variables that start with an uppercase letter and contain at least one lowercase letter. 117 | -- setup neovim-project plugin 118 | require("neovim-project").setup { 119 | projects = { -- define project roots 120 | "~/projects/*", 121 | "~/.config/*", 122 | }, 123 | picker = { 124 | type = "telescope", -- or "fzf-lua" 125 | } 126 | } 127 | end, 128 | requires = { 129 | { "nvim-lua/plenary.nvim" }, 130 | -- optional picker 131 | { "nvim-telescope/telescope.nvim", tag = "0.1.4" }, 132 | -- optional picker 133 | { "ibhagwan/fzf-lua" }, 134 | { "Shatur/neovim-session-manager" }, 135 | } 136 | }; 137 | < 138 | 139 | 140 | ⚙️ CONFIGURATION *neovim-project-🗃️-neovim-project-manager-plugin-⚙️-configuration* 141 | 142 | 143 | DEFAULT OPTIONS: ~ 144 | 145 | >lua 146 | { 147 | -- Project directories 148 | projects = { 149 | "~/projects/*", 150 | "~/p*cts/*", -- glob pattern is supported 151 | "~/projects/repos/*", 152 | "~/.config/*", 153 | "~/work/*", 154 | }, 155 | -- Path to store history and sessions 156 | datapath = vim.fn.stdpath("data"), -- ~/.local/share/nvim/ 157 | -- Load the most recent session on startup if not in the project directory 158 | last_session_on_startup = true, 159 | -- Dashboard mode prevent session autoload on startup 160 | dashboard_mode = false, 161 | -- Timeout in milliseconds before trigger FileType autocmd after session load 162 | -- to make sure lsp servers are attached to the current buffer 163 | -- Set to 0 to disable triggering FileType autocmd 164 | filetype_autocmd_timeout = 200, 165 | -- Keymap to delete project from history in Telescope picker 166 | forget_project_keys = { 167 | -- insert mode: Ctrl+d 168 | i = "", 169 | -- normal mode: d 170 | n = "d" 171 | }, 172 | -- Follow symbolic links in glob patterns (affects startup speed) 173 | -- "full" or true - follow symlinks in all matched directories 174 | -- "partial" - follow symlinks before any matching operators (*, ?, []) 175 | -- "none" or false or nil - do not follow symlinks 176 | follow_symlinks = "full", 177 | 178 | -- Overwrite some of Session Manager options 179 | session_manager_opts = { 180 | autosave_ignore_dirs = { 181 | vim.fn.expand("~"), -- don't create a session for $HOME/ 182 | "/tmp", 183 | }, 184 | autosave_ignore_filetypes = { 185 | -- All buffers of these file types will be closed before the session is saved 186 | "ccc-ui", 187 | "gitcommit", 188 | "gitrebase", 189 | "qf", 190 | "toggleterm", 191 | }, 192 | }, 193 | -- Picker to use for project selection 194 | -- Options: "telescope", "fzf-lua" 195 | -- Fallback to builtin select ui if the specified picker is not available 196 | picker = { 197 | type = "telescope", -- or "fzf-lua" 198 | preview = { 199 | enabled = true, -- show directory structure in Telescope preview 200 | git_status = true, -- show branch name and the git status of each file/folder 201 | git_fetch = false, -- fetch from remote, used to display the number of commits ahead/behind, requires git authorization 202 | show_hidden = true, -- show hidden files/folders 203 | }, 204 | opts = { 205 | -- picker-specific options 206 | }, 207 | }, 208 | } 209 | < 210 | 211 | 212 | COMMANDS *neovim-project-🗃️-neovim-project-manager-plugin-commands* 213 | 214 | Neovim project manager will add these commands: 215 | 216 | - `:NeovimProjectDiscover [sort]` - find a project based on patterns, with optional sorting arguments: 217 | - `default` (or no argument) - uses the order specified in the config. 218 | - `history` - prioritises the most recently accessed projects. 219 | - `alphabetical_name` - sorts projects alphabetically by project name. 220 | - `alphabetical_path` - sorts projects alphabetically by their full path. 221 | - `:NeovimProjectHistory` - select a project from your recent history using the configured picker. 222 | - `:NeovimProjectLoadRecent` - open the previous session. 223 | - `:NeovimProjectLoadHist` - opens the project from the history using the configured picker. 224 | - `:NeovimProjectLoad` - opens the project from all your projects using the configured picker. 225 | 226 | History is sorted by access time. "Discover" keeps order as you have in the 227 | config, but can be overridden using the sorting options. 228 | 229 | MAPPINGS 230 | 231 | Use `Ctrl+d` in Telescope / fzf-lua to delete the project's session and remove it from the history. 232 | 233 | ⚡ REQUIREMENTS*neovim-project-🗃️-neovim-project-manager-plugin-⚡-requirements* 234 | 235 | - Neovim >= 0.10.0 236 | - Optional: Telescope.nvim for the Telescope picker 237 | - Optional: fzf-lua for the fzf-lua picker 238 | 239 | 240 | HOW TO MANAGE DOTFILES REPO *neovim-project-🗃️-neovim-project-manager-plugin-how-to-manage-dotfiles-repo* 241 | 242 | If you have a repository for your dotfiles, you will find it convenient to 243 | access them through projects. 244 | 245 | Project pattern `~/.config/*` matches many programs config folders, including 246 | Neovim. So when you need to edit Neovim config, you open project 247 | `~/.config/nvim` by typing "nv..". When you need to edit alacritty config - you 248 | start typing "ala.." 249 | 250 | Of course, you want to use vim-fugitive and gitsigns in these projects. And it 251 | should be a single git repo for dotfiles. By default, Neovim will know nothing 252 | about your dotfiles repo. 253 | 254 | Create autocommands to update env variables to tell Neovim where is your 255 | dotfiles bare repo. Here is an example from my dotfiles: 256 | 257 | >lua 258 | local augroup = vim.api.nvim_create_augroup("user_cmds", { clear = true }) 259 | 260 | local function update_git_env_for_dotfiles() 261 | -- Auto change ENV variables to enable 262 | -- bare git repository for dotfiles after 263 | -- loading saved session 264 | local home = vim.fn.expand("~") 265 | local git_dir = home .. "/dotfiles" 266 | 267 | if vim.env.GIT_DIR ~= nil and vim.env.GIT_DIR ~= git_dir then 268 | return 269 | end 270 | 271 | -- check dotfiles dir exists on current machine 272 | if vim.fn.isdirectory(git_dir) ~= 1 then 273 | vim.env.GIT_DIR = nil 274 | vim.env.GIT_WORK_TREE = nil 275 | return 276 | end 277 | 278 | -- check if the current working directory should belong to dotfiles 279 | local cwd = vim.loop.cwd() 280 | if vim.startswith(cwd, home .. "/.config/") or cwd == home or cwd == home .. "/.local/bin" then 281 | if vim.env.GIT_DIR == nil then 282 | -- export git location into ENV 283 | vim.env.GIT_DIR = git_dir 284 | vim.env.GIT_WORK_TREE = home 285 | end 286 | else 287 | if vim.env.GIT_DIR == git_dir then 288 | -- unset variables 289 | vim.env.GIT_DIR = nil 290 | vim.env.GIT_WORK_TREE = nil 291 | end 292 | end 293 | end 294 | 295 | vim.api.nvim_create_autocmd("DirChanged", { 296 | pattern = { "*" }, 297 | group = augroup, 298 | desc = "Update git env for dotfiles after changing directory", 299 | callback = function() 300 | update_git_env_for_dotfiles() 301 | end, 302 | }) 303 | 304 | vim.api.nvim_create_autocmd("User", { 305 | pattern = { "SessionLoadPost" }, 306 | group = augroup, 307 | desc = "Update git env for dotfiles after loading session", 308 | callback = function() 309 | update_git_env_for_dotfiles() 310 | end, 311 | }) 312 | < 313 | 314 | This code should be required from your `init.lua` before plugins. 315 | 316 | 317 | 🤝 CONTRIBUTING *neovim-project-🗃️-neovim-project-manager-plugin-🤝-contributing* 318 | 319 | - Open a ticket if you want integration with another plugin, or if you want to request a new feature. 320 | - If you encounter bugs please open an issue. 321 | - Pull requests are welcome. Follow the Conventional Commits specification for commit naming. 322 | 323 | ============================================================================== 324 | 325 | Generated by panvimdoc 326 | 327 | vim:tw=78:ts=8:noet:ft=help:norl: 328 | -------------------------------------------------------------------------------- /lua/neovim-project/config.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | ---@class ProjectOptions 4 | M.defaults = { 5 | -- Project directories 6 | projects = { 7 | "~/projects/*", 8 | "~/p*cts/*", -- glob pattern is supported 9 | "~/projects/repos/*", 10 | "~/.config/*", 11 | "~/work/*", 12 | }, 13 | -- Path to store history and sessions 14 | datapath = vim.fn.stdpath("data"), -- ~/.local/share/nvim/ 15 | -- Load the most recent session on startup if not in the project directory 16 | last_session_on_startup = true, 17 | -- Dashboard mode prevent session autoload on startup 18 | dashboard_mode = false, 19 | -- Timeout in milliseconds before trigger FileType autocmd after session load 20 | -- to make sure lsp servers are attached to the current buffer. 21 | -- Set to 0 to disable triggering FileType autocmd 22 | filetype_autocmd_timeout = 200, 23 | -- Keymap to delete project from history in Telescope picker 24 | forget_project_keys = { 25 | -- insert mode 26 | i = "", 27 | -- normal mode 28 | n = "d", 29 | }, 30 | -- Follow symbolic links in glob patterns (affects startup speed) 31 | -- "full" or true - follow symlinks in all matched directories 32 | -- "partial" - follow symlinks before any matching operators (*, ?, []) 33 | -- "none" or false or nil - do not follow symlinks 34 | follow_symlinks = "full", 35 | 36 | -- Overwrite some of Session Manager options 37 | session_manager_opts = { 38 | autosave_ignore_dirs = { 39 | vim.fn.expand("~"), -- don't create a session for $HOME/ 40 | "/tmp", 41 | }, 42 | autosave_ignore_filetypes = { 43 | -- All buffers of these file types will be closed before the session is saved 44 | "ccc-ui", 45 | "gitcommit", 46 | "gitrebase", 47 | "qf", 48 | "toggleterm", 49 | }, 50 | -- keep these as is 51 | autosave_last_session = true, 52 | autosave_only_in_session = true, 53 | autosave_ignore_not_normal = false, 54 | }, 55 | 56 | -- Picker to use for project selection 57 | -- Options: "telescope", "fzf-lua" 58 | -- Fallback to builtin select ui if the specified picker is not available 59 | picker = { 60 | type = "telescope", -- or "fzf-lua" 61 | preview = { 62 | enabled = true, -- show directory structure in Telescope preview 63 | git_status = true, -- show branch name and the git status of each file/folder 64 | git_fetch = false, -- fetch from remote, used to display the number of commits ahead/behind, requires git authorization 65 | show_hidden = true, -- show hidden files/folders 66 | }, 67 | opts = { 68 | -- picker-specific options 69 | }, 70 | }, 71 | } 72 | 73 | ---@type ProjectOptions 74 | M.options = {} 75 | 76 | M.setup = function(options) 77 | M.options = vim.tbl_deep_extend("force", M.defaults, options or {}) 78 | 79 | vim.opt.autochdir = false -- implicitly unset autochdir 80 | 81 | local path = require("neovim-project.utils.path") 82 | path.init() 83 | local project = require("neovim-project.project") 84 | project.init() 85 | 86 | local start_session_here = false -- open or create session in current dir 87 | 88 | local session_manager_config = require("session_manager.config") 89 | local AutoLoadMode = session_manager_config.AutoloadMode 90 | -- Disable session autoload by default 91 | M.options.session_manager_opts.autoload_mode = AutoLoadMode.Disabled 92 | 93 | -- Don't load a session if nvim started with args, open just given files 94 | if vim.fn.argc() == 0 and not M.options.dashboard_mode then 95 | local cmd = require("neovim-project.utils.cmd") 96 | local is_man = cmd.check_open_cmd("+Man!") 97 | 98 | if 99 | not is_man and (path.chdir_closest_parent_project() or path.chdir_closest_parent_project(path.resolve("%:p"))) 100 | then 101 | -- nvim started in the project dir or sub project , open current dir session 102 | start_session_here = true 103 | else 104 | -- Open the recent session if not disabled from config 105 | if M.options.last_session_on_startup then 106 | M.options.session_manager_opts.autoload_mode = AutoLoadMode.LastSession 107 | end 108 | end 109 | end 110 | 111 | M.options.session_manager_opts.sessions_dir = path.sessionspath 112 | 113 | -- Session Manager setup 114 | require("session_manager").setup(M.options.session_manager_opts) 115 | 116 | -- unset session_manager_opts 117 | ---@diagnostic disable-next-line: inject-field 118 | M.options.session_manager_opts = nil 119 | 120 | if start_session_here then 121 | project.start_session_here() 122 | end 123 | 124 | -- unregister SessionManager command 125 | if vim.fn.exists(":SessionManager") == 2 then 126 | vim.api.nvim_del_user_command("SessionManager") 127 | else 128 | -- defer is needed on Packer 129 | vim.defer_fn(function() 130 | if vim.fn.exists(":SessionManager") == 2 then 131 | vim.api.nvim_del_user_command("SessionManager") 132 | end 133 | end, 100) 134 | end 135 | end 136 | 137 | return M 138 | 139 | -- 1. If nvim started with args, disable autoload. On project switch - close all buffers and load session 140 | -- 2. If nvim started in project dir, open project's session. If session does not exist - create it 141 | -- 3. Else open last session. If no sessions: not create and close all buffers prior to project switch. 142 | -------------------------------------------------------------------------------- /lua/neovim-project/init.lua: -------------------------------------------------------------------------------- 1 | local config = require("neovim-project.config") 2 | local M = {} 3 | M.setup = config.setup 4 | return M 5 | -------------------------------------------------------------------------------- /lua/neovim-project/payload.lua: -------------------------------------------------------------------------------- 1 | -- Module uses global variable to store additional data inside session file 2 | -- It's required to add this line to config: 3 | -- vim.opt.sessionoptions:append("globals") 4 | -- 5 | local neotree_util = require("neovim-project.utils.neo-tree") 6 | local M = {} 7 | 8 | --- @class Payload 9 | local Payload = { 10 | -- @type "string" 11 | neotree_opened_directories = "nil", -- store neo-tree explicitly opened directories 12 | -- 13 | -- add more plugins here 14 | } 15 | 16 | function M.store( 17 | payload --[[Payload]] 18 | ) 19 | -- Must be called prior to session save 20 | 21 | -- convert table to lua code string 22 | vim.g.NeovimProjectPayload__session_restore = 23 | string.format("return { neotree_opened_directories = %s, }", payload.neotree_opened_directories) 24 | end 25 | 26 | function M.restore( 27 | payload --[[Payload]] 28 | ) 29 | if payload == nil then 30 | return 31 | end 32 | if payload.neotree_opened_directories ~= nil then 33 | neotree_util.restore_expanded(payload.neotree_opened_directories) 34 | end 35 | end 36 | 37 | function M.load_post() 38 | -- Must be called after session load 39 | if vim.g.NeovimProjectPayload__session_restore ~= nil then 40 | -- convert lua code string to table 41 | local load_func 42 | if vim.fn.has("nvim-0.5") == 1 then 43 | load_func = load(vim.g.NeovimProjectPayload__session_restore) 44 | else 45 | load_func = loadstring(vim.g.NeovimProjectPayload__session_restore) 46 | end 47 | -- local load_func = loadstring(vim.g.NeovimProjectPayload__session_restore) 48 | if load_func ~= nil then 49 | M.restore(load_func()) 50 | end 51 | vim.g.NeovimProjectPayload__session_restore = nil -- clear global variable 52 | end 53 | end 54 | 55 | function M.pre_save() 56 | -- Must be called prior to session save 57 | M.store({ 58 | -- expanded neo-tree directories 59 | neotree_opened_directories = neotree_util.get_state_as_lua_string(), 60 | }) 61 | end 62 | 63 | return M 64 | -------------------------------------------------------------------------------- /lua/neovim-project/picker.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local config = require("neovim-project.config") 3 | local path = require("neovim-project.utils.path") 4 | local history = require("neovim-project.utils.history") 5 | 6 | function M.create_picker(opts, discover, callback, delete_session_func) 7 | local picker = config.options.picker.type 8 | local picker_opts = vim.tbl_deep_extend("force", config.options.picker.opts or {}, opts or {}) 9 | 10 | if picker == "telescope" and pcall(require, "telescope") then 11 | return M.create_telescope_picker(picker_opts, discover) 12 | elseif picker == "fzf-lua" and pcall(require, "fzf-lua") then 13 | return M.create_fzf_lua_picker(picker_opts, discover, callback, delete_session_func) 14 | else 15 | return M.create_builtin_picker(picker_opts, discover, callback, delete_session_func) 16 | end 17 | end 18 | 19 | function M.create_telescope_picker(opts, discover) 20 | local telescope = require("telescope") 21 | if discover then 22 | return telescope.extensions["neovim-project"].discover(opts) 23 | else 24 | return telescope.extensions["neovim-project"].history(opts) 25 | end 26 | end 27 | 28 | function M.create_fzf_lua_picker(opts, discover, callback, delete_session_func) 29 | local fzf = require("fzf-lua") 30 | 31 | local results 32 | if discover then 33 | results = path.get_all_projects_with_sorting() 34 | else 35 | results = history.get_recent_projects() 36 | results = path.fix_symlinks_for_history(results) 37 | -- Reverse results 38 | for i = 1, math.floor(#results / 2) do 39 | results[i], results[#results - i + 1] = results[#results - i + 1], results[i] 40 | end 41 | end 42 | 43 | local function format_entry(entry) 44 | local name = vim.fn.fnamemodify(entry, ":t") 45 | return string.format("%s\t%s", name, entry) 46 | end 47 | 48 | local formatted_results = vim.tbl_map(format_entry, results) 49 | 50 | -- Default options 51 | local default_opts = { 52 | prompt = discover and "Discover Projects> " or "Recent Projects> ", 53 | actions = { 54 | ["default"] = function(selected) 55 | if selected and #selected > 0 then 56 | local dir = selected[1]:match("\t(.+)$") 57 | callback(dir) 58 | end 59 | end, 60 | ["ctrl-d"] = function(selected) 61 | if selected and #selected > 0 then 62 | local dir = selected[1]:match("\t(.+)$") 63 | local choice = vim.fn.confirm("Delete '" .. dir .. "' from project list?", "&Yes\n&No", 2) 64 | if choice == 1 then 65 | history.delete_project(dir) 66 | delete_session_func(dir) 67 | -- Refresh the picker 68 | M.create_fzf_lua_picker(opts, discover, callback, delete_session_func) 69 | end 70 | end 71 | end, 72 | }, 73 | fzf_opts = { 74 | ["--delimiter"] = "\t", 75 | ["--with-nth"] = "1", 76 | ["--preview"] = "echo {}", 77 | ["--preview-window"] = "hidden:right:0", 78 | }, 79 | } 80 | 81 | local merged_opts = vim.tbl_deep_extend("force", default_opts, opts or {}) 82 | 83 | fzf.fzf_exec(formatted_results, merged_opts) 84 | end 85 | 86 | function M.create_builtin_picker(opts, discover, callback, delete_session_func) 87 | local results 88 | if discover then 89 | results = path.get_all_projects_with_sorting() 90 | else 91 | results = history.get_recent_projects() 92 | results = path.fix_symlinks_for_history(results) 93 | -- Reverse results 94 | for i = 1, math.floor(#results / 2) do 95 | results[i], results[#results - i + 1] = results[#results - i + 1], results[i] 96 | end 97 | end 98 | 99 | local default_opts = { 100 | prompt = discover and "Discover Projects" or "Recent Projects", 101 | format_item = function(item) 102 | return vim.fn.fnamemodify(item, ":t") .. " (" .. item .. ")" 103 | end, 104 | } 105 | 106 | local merged_opts = vim.tbl_deep_extend("force", default_opts, opts or {}) 107 | 108 | local function select_project() 109 | vim.ui.select(results, merged_opts, function(choice) 110 | if choice then 111 | callback(choice) 112 | end 113 | end) 114 | end 115 | 116 | local function delete_project() 117 | vim.ui.select(results, { 118 | prompt = "Select project to delete", 119 | format_item = merged_opts.format_item, 120 | }, function(choice) 121 | if choice then 122 | local confirm = vim.fn.confirm("Delete '" .. choice .. "' from project list?", "&Yes\n&No", 2) 123 | if confirm == 1 then 124 | history.delete_project(choice) 125 | delete_session_func(choice) 126 | -- Refresh the picker 127 | M.create_builtin_picker(opts, discover, callback, delete_session_func) 128 | else 129 | -- Go back to project selection 130 | select_project() 131 | end 132 | end 133 | end) 134 | end 135 | 136 | -- Add an option to delete projects 137 | vim.ui.select({ "Select Project", "Delete Project" }, { 138 | prompt = "Choose an action", 139 | }, function(choice) 140 | if choice == "Select Project" then 141 | select_project() 142 | elseif choice == "Delete Project" then 143 | delete_project() 144 | end 145 | end) 146 | end 147 | 148 | return M 149 | -------------------------------------------------------------------------------- /lua/neovim-project/preview.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local previewers = require("telescope.previewers") 4 | local config = require("neovim-project.config") 5 | local history = require("neovim-project.utils.history") 6 | 7 | local initialized = false 8 | local preview_cache = {} 9 | local fetched = {} 10 | 11 | local function expand_path(project_path) 12 | project_path = vim.fn.expand(project_path) 13 | project_path = vim.fn.fnamemodify(project_path, ":p") 14 | project_path = project_path:gsub("[/\\]$", "") 15 | return project_path 16 | end 17 | 18 | local function clear_caches() 19 | local current_project = expand_path(history.get_current_project()) 20 | preview_cache[current_project] = nil 21 | fetched[current_project] = nil 22 | end 23 | 24 | function M.define_preview_highlighting() 25 | local normal_bg = vim.fn.synIDattr(vim.fn.hlID("Normal"), "bg#") 26 | local branch_bg = vim.fn.synIDattr(vim.fn.hlID("Function"), "fg#") 27 | local title_bg = vim.fn.synIDattr(vim.fn.hlID("Constant"), "fg#") 28 | local added_fg = vim.fn.synIDattr(vim.fn.hlID("Added"), "fg#") 29 | local changed_fg = vim.fn.synIDattr(vim.fn.hlID("Changed"), "fg#") 30 | local removed_fg = vim.fn.synIDattr(vim.fn.hlID("Removed"), "fg#") 31 | -- fallback, not all themes have Function 32 | if not branch_bg or branch_bg == "" then 33 | branch_bg = vim.fn.synIDattr(vim.fn.hlID("Statement"), "fg#") 34 | end 35 | 36 | vim.api.nvim_set_hl(0, "NeovimProjectTitle", { 37 | bg = title_bg, 38 | fg = normal_bg, 39 | bold = true, 40 | }) 41 | 42 | vim.api.nvim_set_hl(0, "NeovimProjectBranch", { 43 | bg = branch_bg, 44 | fg = normal_bg, 45 | bold = true, 46 | }) 47 | 48 | vim.api.nvim_set_hl(0, "NeovimProjectAdded", { 49 | bg = normal_bg, 50 | fg = added_fg, 51 | }) 52 | 53 | vim.api.nvim_set_hl(0, "NeovimProjectChanged", { 54 | bg = normal_bg, 55 | fg = changed_fg, 56 | }) 57 | 58 | vim.api.nvim_set_hl(0, "NeovimProjectRemoved", { 59 | bg = normal_bg, 60 | fg = removed_fg, 61 | }) 62 | end 63 | 64 | M.init = function() 65 | M.define_preview_highlighting() 66 | clear_caches() 67 | 68 | -- autocmd to enforce proper highlighting when changing colorschemes 69 | vim.api.nvim_create_autocmd("ColorScheme", { 70 | callback = function() 71 | local preview = require("neovim-project.preview") 72 | preview.define_preview_highlighting() 73 | end, 74 | group = vim.api.nvim_create_augroup("NeovimProjectHighlights", { clear = true }), 75 | }) 76 | 77 | -- Set up an autocmd to clear current project cache when opening the picker, this ensures that recent changes are visible 78 | vim.api.nvim_create_autocmd({ "FocusGained", "BufEnter" }, { 79 | callback = function() 80 | clear_caches() 81 | end, 82 | group = vim.api.nvim_create_augroup("NeovimProjectCacheClear", { clear = true }), 83 | }) 84 | end 85 | 86 | --- Stolen from oil.nvim 87 | --- Check for an icon provider and return a common icon provider API 88 | local function get_icon_provider() 89 | -- prefer mini.icons 90 | local _, mini_icons = pcall(require, "mini.icons") 91 | ---@diagnostic disable-next-line: undefined-field 92 | if _G.MiniIcons then 93 | return function(type, name) 94 | return mini_icons.get(type, name) 95 | end 96 | end 97 | 98 | -- fallback to `nvim-web-devicons` 99 | local has_devicons, devicons = pcall(require, "nvim-web-devicons") 100 | if has_devicons then 101 | return function(type, name, conf) 102 | if type == "directory" then 103 | return conf and conf.directory or "", "OilDirIcon" 104 | else 105 | local icon, hl = devicons.get_icon(name) 106 | icon = icon or (conf and conf.default_file or "") 107 | return icon, hl 108 | end 109 | end 110 | end 111 | end 112 | 113 | local icon_provider = get_icon_provider() or function(_, _, _) 114 | return "", "" 115 | end 116 | 117 | -- Custom previewer that shows the contents of the project directory 118 | M.project_previewer = previewers.new_buffer_previewer({ 119 | title = "Project Preview", 120 | get_buffer_by_name = function(_, entry) 121 | return entry.value 122 | end, 123 | define_preview = function(self, entry) 124 | if not initialized then 125 | M.init() 126 | initialized = true 127 | end 128 | 129 | local project_path = entry.value 130 | 131 | -- Process path to make it usable 132 | project_path = expand_path(project_path) 133 | -- Create a timer for debouncing preview generation 134 | if not self._preview_timer then 135 | self._preview_timer = vim.loop.new_timer() 136 | else 137 | -- Cancel any pending preview generation 138 | self._preview_timer:stop() 139 | end 140 | 141 | local function render_preview() 142 | -- Check if the buffer still exists 143 | if vim.api.nvim_buf_is_valid(self.state.bufnr) then 144 | if not preview_cache[project_path] then 145 | preview_cache[project_path] = M.generate_project_preview(project_path) 146 | end 147 | -- Display the output 148 | vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, preview_cache[project_path].lines) 149 | 150 | -- Apply highlights 151 | local ns_id = vim.api.nvim_create_namespace("neovim_project_preview") 152 | vim.api.nvim_buf_clear_namespace(self.state.bufnr, ns_id, 0, -1) 153 | 154 | for _, hl_info in ipairs(preview_cache[project_path].highlights) do 155 | vim.api.nvim_buf_add_highlight( 156 | self.state.bufnr, 157 | ns_id, 158 | hl_info.hl, 159 | hl_info.line, 160 | hl_info.start_col, 161 | hl_info.end_col 162 | ) 163 | end 164 | end 165 | end 166 | if preview_cache[project_path] then 167 | render_preview() 168 | else 169 | self._preview_timer:start(50, 0, vim.schedule_wrap(render_preview)) 170 | end 171 | end, 172 | }) 173 | 174 | -- Get the current git branch and status for a project path 175 | local function get_git_info(project_path) 176 | local result = { 177 | is_repo = false, 178 | branch = "", 179 | ahead = 0, 180 | behind = 0, 181 | status = "", 182 | } 183 | 184 | -- Get branch name 185 | local is_git_repo = 186 | vim.fn.system("git -C " .. vim.fn.shellescape(project_path) .. " rev-parse --is-inside-work-tree"):match("true") 187 | if not is_git_repo then 188 | return result 189 | end 190 | 191 | result.is_repo = true 192 | 193 | local branch_name = 194 | vim.fn.system("git -C " .. vim.fn.shellescape(project_path) .. " branch --show-current"):gsub("\n", "") 195 | 196 | -- Only proceed if we have a valid branch 197 | if branch_name ~= "" then 198 | result.branch = branch_name 199 | 200 | -- Fetch remote information for accurate ahead/behind counters 201 | -- Done asynchronously to prevent freezing UI 202 | -- This will fetch once per project during a particular nvim session 203 | -- This wipes the cache for a project, so it will force a regeneration of the preview when it is viewed again 204 | if config.options.picker.preview.git_fetch and not fetched[project_path] then 205 | local fetch_job_id = vim.fn.jobstart("git fetch --quiet", { 206 | cwd = project_path, 207 | detach = false, 208 | on_exit = function(_, _, _) 209 | -- Clear preview cache to refresh the ahead/behind counts 210 | preview_cache[project_path] = nil 211 | fetched[project_path] = true 212 | end, 213 | }) 214 | end 215 | 216 | -- Get ahead/behind counts 217 | local status_output = vim.fn.system( 218 | "git -C " 219 | .. vim.fn.shellescape(project_path) 220 | .. " rev-list --left-right --count origin/" 221 | .. branch_name 222 | .. "..." 223 | .. branch_name 224 | ) 225 | 226 | local behind, ahead = status_output:match("(%d+)%s+(%d+)") 227 | if tonumber(behind) then 228 | result.behind = tonumber(behind) 229 | end 230 | if tonumber(ahead) then 231 | result.ahead = tonumber(ahead) 232 | end 233 | end 234 | 235 | result.status = vim.fn.system("git -C " .. vim.fn.shellescape(project_path) .. " status --porcelain=v1") 236 | 237 | return result 238 | end 239 | 240 | -- Generate header for project preview 241 | local function generate_preview_header(project_path) 242 | local header = {} 243 | local header_highlights = {} 244 | local project_title = vim.fn.fnamemodify(project_path, ":t") 245 | -- Add padding spaces for better appearance with background color 246 | 247 | local title_string = " " .. project_title .. " " 248 | local formatted_header = title_string 249 | 250 | local title_width = #title_string 251 | local title_start = 0 252 | local title_end = title_start + title_width 253 | 254 | table.insert(header_highlights, { 255 | line = 0, 256 | hl = "NeovimProjectTitle", 257 | start_col = title_start, 258 | end_col = title_end, 259 | }) 260 | local git_info = {} 261 | if config.options.picker.preview.git_status then 262 | git_info = get_git_info(project_path) 263 | if git_info.is_repo then 264 | local branch_icon = icon_provider("filetype", "git", { 265 | default_file = "", 266 | }) 267 | local branch_string = " " .. branch_icon .. " " .. git_info.branch .. " " 268 | 269 | local ahead = "" 270 | local behind = "" 271 | if git_info.ahead > 0 then 272 | ahead = "↑" .. git_info.ahead 273 | end 274 | if git_info.behind > 0 then 275 | behind = "↓" .. git_info.behind 276 | end 277 | local sync_string = " " .. behind .. ahead .. " " 278 | formatted_header = formatted_header .. branch_string .. sync_string 279 | 280 | local branch_width = #branch_string 281 | local branch_start = title_end 282 | local branch_end = branch_start + branch_width 283 | 284 | local sync_width = #sync_string 285 | local sync_start = branch_end 286 | local sync_end = sync_start + sync_width 287 | table.insert(header_highlights, { 288 | line = 0, 289 | hl = "NeovimProjectBranch", 290 | start_col = branch_start, 291 | end_col = branch_end, 292 | }) 293 | 294 | table.insert(header_highlights, { 295 | line = 0, 296 | hl = "NeovimProjectChanged", 297 | start_col = sync_start, 298 | end_col = sync_end, 299 | }) 300 | end 301 | end 302 | 303 | table.insert(header, formatted_header) 304 | 305 | return { lines = header, highlights = header_highlights }, git_info 306 | end 307 | 308 | local function prep_items(project_path, items, git_status) 309 | local result = {} 310 | for _, item in ipairs(items) do 311 | result[item] = { 312 | is_dir = vim.fn.isdirectory(project_path .. "/" .. item) == 1, 313 | git_status = "", 314 | deleted = false, 315 | } 316 | end 317 | 318 | if not git_status or git_status == "" then 319 | return result 320 | end 321 | 322 | local status_map = { 323 | ["??"] = "A", -- Untracked files are treated as Added 324 | ["A"] = "A", -- Added 325 | ["M"] = "M", -- Modified 326 | ["R"] = "M", -- Renamed (treat as modified) 327 | ["D"] = "D", -- Deleted 328 | } 329 | 330 | local function git_status_display(status_code, deleted, dir) 331 | if not status_code or status_code == "" then 332 | return "" 333 | end 334 | 335 | if deleted then 336 | return "D" 337 | end 338 | 339 | status_code = status_code:gsub("%?", "A") 340 | -- Remove duplicate characters 341 | local seen = {} 342 | local result = "" 343 | for i = 1, #status_code do 344 | local char = status_code:sub(i, i) 345 | if not seen[char] then 346 | seen[char] = true 347 | result = result .. char 348 | end 349 | end 350 | status_code = result 351 | 352 | if #status_code == 1 then 353 | return status_map[status_code] or "M" 354 | end 355 | 356 | -- For multiple status codes, prioritize A > M > D 357 | if dir then 358 | return "M" 359 | elseif status_code:match("A") or status_code:match("?") then 360 | return "A" 361 | elseif status_code:match("M") then 362 | return "M" 363 | else 364 | return "D" 365 | end 366 | end 367 | 368 | -- Parse git status output line by line 369 | for line in git_status:gmatch("[^\r\n]+") do 370 | if #line >= 3 then 371 | local status_code = line:sub(1, 2) 372 | local path = line:sub(4) 373 | 374 | local first_slash = path:find("/") 375 | local top_level_item = first_slash and path:sub(1, first_slash - 1) or path 376 | 377 | if status_code:match("[D]") and top_level_item and not result[top_level_item] then 378 | local is_directory = first_slash ~= nil 379 | result[top_level_item] = { 380 | is_dir = is_directory, 381 | git_status = "D", 382 | deleted = true, 383 | } 384 | elseif result[top_level_item] then 385 | result[top_level_item].git_status = result[top_level_item].git_status .. status_code 386 | end 387 | end 388 | end 389 | 390 | for item_name, item_data in pairs(result) do 391 | item_data.git_status = git_status_display(item_data.git_status, item_data.deleted, item_data.is_dir) 392 | end 393 | 394 | return result 395 | end 396 | 397 | local function generate_preview_body(project_path, git_info) 398 | local body = {} 399 | local highlights = {} 400 | 401 | local items = prep_items(project_path, vim.fn.readdir(project_path), git_info.status) 402 | local directories = {} 403 | local files = {} 404 | for name, properties in pairs(items) do 405 | if config.options.picker.preview.show_hidden or not name:match("^%.") then 406 | if properties.is_dir then 407 | table.insert(directories, name) 408 | else 409 | table.insert(files, name) 410 | end 411 | end 412 | end 413 | 414 | -- Sort directories and files alphabetically 415 | table.sort(directories) 416 | table.sort(files) 417 | 418 | local function status_to_hl_group(status) 419 | if status == "A" then 420 | return "NeovimProjectAdded" 421 | elseif status == "D" then 422 | return "NeovimProjectRemoved" 423 | else 424 | return "NeovimProjectChanged" 425 | end 426 | end 427 | 428 | -- Helper function to format a file/folder and add it to the output 429 | local function process_item(name, properties) 430 | local field_type = properties.is_dir and "directory" or "file" 431 | 432 | -- Get icon from provider 433 | local icon, hl = icon_provider(field_type, name, { 434 | -- fallback icons 435 | directory = "📁", 436 | default_file = "📄", 437 | }) 438 | 439 | -- Add trailing slash for directories 440 | local display_name = name 441 | if properties.is_dir then 442 | display_name = name .. "/" 443 | end 444 | 445 | -- Add status letter and insert line 446 | local status_display = properties.git_status .. " " 447 | if #status_display == 1 then 448 | status_display = " " 449 | end 450 | local line = status_display .. icon .. " " .. display_name 451 | local line_idx = #body + 2 452 | table.insert(body, line) 453 | 454 | -- Icon highlighting 455 | if hl and hl ~= "" then 456 | local icon_start = 2 457 | local icon_end = icon_start + vim.fn.strwidth(icon) 458 | 459 | table.insert(highlights, { 460 | line = line_idx - 1, 461 | hl = hl, 462 | start_col = icon_start, 463 | end_col = icon_end, 464 | }) 465 | end 466 | 467 | -- Highlight for files with a git_status 468 | if properties.git_status ~= "" then 469 | table.insert(highlights, { 470 | line = line_idx - 1, 471 | hl = status_to_hl_group(properties.git_status), 472 | start_col = 0, 473 | end_col = #line, 474 | }) 475 | -- Highlight for hidden files 476 | elseif name:match("^%.") then 477 | local text_start = 4 478 | local text_end = #line 479 | 480 | table.insert(highlights, { 481 | line = line_idx - 1, 482 | hl = "Comment", 483 | start_col = text_start, 484 | end_col = text_end, 485 | }) 486 | end 487 | end 488 | 489 | -- Process directories first 490 | for _, name in ipairs(directories) do 491 | process_item(name, items[name]) 492 | end 493 | 494 | -- Then process files 495 | for _, name in ipairs(files) do 496 | process_item(name, items[name]) 497 | end 498 | 499 | return { lines = body, highlights = highlights } 500 | end 501 | 502 | -- Generate project preview content 503 | function M.generate_project_preview(project_path) 504 | local output = {} 505 | local highlights = {} 506 | 507 | -- Get header content 508 | local header, git_info = generate_preview_header(project_path) 509 | 510 | -- Add header lines to output 511 | for _, line in ipairs(header.lines) do 512 | table.insert(output, line) 513 | end 514 | 515 | -- Add header highlights to highlights 516 | for _, hl in ipairs(header.highlights) do 517 | table.insert(highlights, hl) 518 | end 519 | 520 | local body = generate_preview_body(project_path, git_info) 521 | 522 | -- Add body lines to output 523 | for _, line in ipairs(body.lines) do 524 | table.insert(output, line) 525 | end 526 | 527 | -- Add body highlights to highlights 528 | for _, hl in ipairs(body.highlights) do 529 | table.insert(highlights, hl) 530 | end 531 | return { lines = output, highlights = highlights } 532 | end 533 | 534 | return M 535 | -------------------------------------------------------------------------------- /lua/neovim-project/project.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local history = require("neovim-project.utils.history") 4 | local manager = require("session_manager") 5 | local path = require("neovim-project.utils.path") 6 | local payload = require("neovim-project.payload") 7 | local utils = require("session_manager.utils") 8 | local config = require("neovim-project.config") 9 | local picker = require("neovim-project.picker") 10 | local showkeys = require("neovim-project.utils.showkeys") 11 | 12 | M.save_project_waiting = false 13 | 14 | M.setup_autocmds = function() 15 | local augroup = vim.api.nvim_create_augroup("neovim-project", { clear = true }) 16 | 17 | -- setup events for neo-tree when it's loaded 18 | vim.api.nvim_create_autocmd({ "FileType" }, { 19 | pattern = "neo-tree", 20 | group = augroup, 21 | once = true, 22 | callback = require("neovim-project.utils.neo-tree").setup_events_for_neotree, 23 | }) 24 | -- save history to file when exit nvim 25 | vim.api.nvim_create_autocmd({ "VimLeavePre" }, { 26 | pattern = "*", 27 | group = augroup, 28 | callback = function() 29 | history.write_projects_to_history() 30 | end, 31 | }) 32 | -- add project to history when open nvim in project's directory 33 | vim.api.nvim_create_autocmd({ "User" }, { 34 | pattern = "SessionLoadPost", 35 | group = augroup, 36 | once = true, 37 | callback = function() 38 | if path.dir_pretty ~= nil then 39 | history.add_session_project(path.dir_pretty) 40 | end 41 | end, 42 | }) 43 | -- switch project after save previous session 44 | vim.api.nvim_create_autocmd({ "User" }, { 45 | pattern = "SessionSavePost", 46 | group = augroup, 47 | callback = function() 48 | M.save_project_waiting = true 49 | end, 50 | }) 51 | -- 1. Add state data to the session file via global variable 52 | -- 2. Workaround for showkeys plugin: close the buffer and save it's state 53 | vim.api.nvim_create_autocmd({ "User" }, { 54 | pattern = "SessionSavePre", 55 | group = augroup, 56 | callback = function() 57 | payload.pre_save() 58 | showkeys.pre_save() 59 | end, 60 | }) 61 | -- 1. Trigger FileType autocmd to attach lsp server to the active buffer 62 | -- 2. Restore saved state data from the global var in the session file 63 | -- 3. Workaround for showkeys plugin: reopen the it's buffer 64 | vim.api.nvim_create_autocmd({ "User" }, { 65 | pattern = "SessionLoadPost", 66 | group = augroup, 67 | callback = function() 68 | if config.options.filetype_autocmd_timeout > 0 then 69 | vim.defer_fn(function() 70 | vim.api.nvim_command("silent! doautocmd FileType") 71 | end, config.options.filetype_autocmd_timeout) 72 | end 73 | payload.load_post() 74 | if path.dir_pretty == nil then 75 | path.dir_pretty = path.cwd() 76 | end 77 | showkeys.post_load() 78 | end, 79 | }) 80 | -- Exit from session when directory changed from outside 81 | vim.api.nvim_create_autocmd({ "DirChangedPre" }, { 82 | pattern = "global", 83 | group = augroup, 84 | callback = function(event) 85 | if path.dir_pretty == nil then 86 | return 87 | end 88 | if path.dir_pretty ~= path.short_path(event.file) then 89 | -- directory changed from outside 90 | history.write_projects_to_history() 91 | local dir = path.dir_pretty 92 | vim.notify("CWD Changed! Exit from session " .. dir, vim.log.levels.INFO, { title = "Neovim Project" }) 93 | path.dir_pretty = nil 94 | utils.is_session = false 95 | -- touch session file to update mtime and auto load it on next start 96 | local sessions = utils.get_sessions() 97 | for idx, session in ipairs(sessions) do 98 | if path.short_path(session.dir.filename) == dir then 99 | local Path = require("plenary.path") 100 | return Path:new(sessions[idx].filename):touch() 101 | end 102 | end 103 | end 104 | end, 105 | }) 106 | end 107 | 108 | local function switch_project_callback(dir) 109 | M.switch_project(dir) 110 | end 111 | 112 | function M.show_history() 113 | picker.create_picker({}, false, switch_project_callback, M.delete_session) 114 | end 115 | 116 | function M.discover_projects() 117 | picker.create_picker({}, true, switch_project_callback, M.delete_session) 118 | end 119 | 120 | M.delete_session = function(dir) 121 | if utils.is_session and dir == path.cwd() then 122 | utils.is_session = false 123 | end 124 | local sessions = utils.get_sessions() 125 | for idx, session in ipairs(sessions) do 126 | if path.short_path(session.dir.filename) == dir then 127 | local Path = require("plenary.path") 128 | return Path:new(sessions[idx].filename):rm() 129 | end 130 | end 131 | end 132 | 133 | M.in_session = function() 134 | return utils.exists_in_session() 135 | end 136 | 137 | M.switch_after_save_session = function(dir) 138 | -- Switch project after saving current session 139 | -- 140 | -- save current session 141 | -- before switch project 142 | M.save_project_waiting = true 143 | manager.save_current_session() 144 | -- wait for SessionSavePost autocmd or timeout 2 sec 145 | vim.wait(2000, function() 146 | return not M.switing_project 147 | end, 1) 148 | M.load_session(dir) 149 | end 150 | 151 | M.load_session = function(dir) 152 | if not dir then 153 | return 154 | end 155 | if path.cwd() ~= dir then 156 | path.dir_pretty = path.short_path(dir) 157 | vim.api.nvim_set_current_dir(dir) 158 | end 159 | 160 | M.start_session_here() 161 | end 162 | 163 | M.start_session_here = function() 164 | -- load session or create new one if not exists 165 | local cwd = path.cwd() 166 | if not cwd then 167 | return 168 | end 169 | local fullpath = vim.fn.expand(cwd) 170 | local session = require("session_manager.config").dir_to_session_filename(fullpath) 171 | if session:exists() then 172 | manager.load_current_dir_session(false) 173 | else 174 | vim.cmd("silent! %bd") -- close all buffers from previous session 175 | -- create empty session 176 | manager.save_current_session() 177 | end 178 | -- add to history 179 | if path.dir_pretty ~= nil then 180 | history.add_session_project(path.dir_pretty) 181 | else 182 | history.add_session_project(cwd) 183 | end 184 | end 185 | 186 | M.create_commands = function() 187 | -- Create user commands 188 | 189 | -- Open the previous session 190 | vim.api.nvim_create_user_command("NeovimProjectLoadRecent", function(args) 191 | local cnt = args.count 192 | if cnt < 1 then 193 | -- cnt is an offset from the last session in history 194 | if M.in_session() then 195 | cnt = 1 -- skip current session 196 | else 197 | cnt = 0 198 | end 199 | end 200 | local recent = history.get_recent_projects() 201 | local index = #recent - cnt 202 | if index < 1 then 203 | index = 1 204 | end 205 | if #recent > 0 then 206 | M.switch_project(recent[index]) 207 | else 208 | vim.notify("No recent projects") 209 | end 210 | end, { nargs = 0, count = true }) 211 | 212 | -- Open the project from the history by name 213 | vim.api.nvim_create_user_command("NeovimProjectLoadHist", function(args) 214 | local arg = args.args 215 | local recentprojects = history.get_recent_projects() 216 | local recent = {} 217 | for _, v in ipairs(recentprojects) do 218 | local val = string.gsub(v, "\\", "/") 219 | table.insert(recent, val) 220 | end 221 | 222 | if vim.tbl_contains(recent, arg) then 223 | M.switch_project(arg) 224 | else 225 | vim.notify("Project not found") 226 | end 227 | end, { 228 | nargs = 1, 229 | complete = function() 230 | local recentprojects = history.get_recent_projects() 231 | local recent = {} 232 | for _, v in ipairs(recentprojects) do 233 | local val = string.gsub(v, "\\", "/") 234 | table.insert(recent, val) 235 | end 236 | return recent 237 | end, 238 | }) 239 | 240 | -- Open the project from all projects by name 241 | vim.api.nvim_create_user_command("NeovimProjectLoad", function(args) 242 | local arg = args.args 243 | local allprojects = path.get_all_projects_with_sorting() 244 | local projects = {} 245 | for _, v in ipairs(allprojects) do 246 | local val = string.gsub(v, "\\", "/") 247 | table.insert(projects, val) 248 | end 249 | 250 | if vim.tbl_contains(projects, arg) then 251 | M.switch_project(arg) 252 | else 253 | vim.notify("Project not found") 254 | end 255 | end, { 256 | nargs = 1, 257 | complete = function() 258 | local projects = {} 259 | local allprojects = path.get_all_projects_with_sorting() 260 | for _, v in ipairs(allprojects) do 261 | local val = string.gsub(v, "\\", "/") 262 | table.insert(projects, val) 263 | end 264 | return projects 265 | end, 266 | }) 267 | 268 | vim.api.nvim_create_user_command("NeovimProjectHistory", function(args) 269 | picker.create_picker(args, false, M.switch_project) 270 | end, {}) 271 | 272 | vim.api.nvim_create_user_command("NeovimProjectDiscover", function(args) 273 | -- Default sorting based on patterns 274 | config.options.picker.opts.sorting = args.args or "default" 275 | picker.create_picker(args, true, M.switch_project) 276 | end, { 277 | nargs = "?", 278 | complete = function() 279 | return { "default", "history", "alphabetical_name", "alphabetical_path" } 280 | end, 281 | }) 282 | end 283 | 284 | M.switch_project = function(dir) 285 | if M.in_session() then 286 | M.switch_after_save_session(dir) 287 | else 288 | M.load_session(dir) 289 | end 290 | end 291 | 292 | M.init = function() 293 | M.setup_autocmds() 294 | M.create_commands() 295 | end 296 | 297 | return M 298 | -------------------------------------------------------------------------------- /lua/neovim-project/utils/cmd.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | M.check_open_cmd = function(cmd) 4 | local open_cmd = vim.fn.split(vim.fn.systemlist("ps -o command -p " .. vim.fn.getpid())[2], " ", 1) 5 | local found = false 6 | 7 | for _, arg in ipairs(open_cmd) do 8 | if arg == cmd then 9 | found = true 10 | end 11 | end 12 | return found 13 | end 14 | 15 | return M 16 | -------------------------------------------------------------------------------- /lua/neovim-project/utils/history.lua: -------------------------------------------------------------------------------- 1 | local path = require("neovim-project.utils.path") 2 | local uv = vim.loop 3 | local M = {} 4 | 5 | M.recent_projects = nil -- projects from previous neovim sessions 6 | M.session_projects = {} -- projects from current neovim session 7 | M.has_watch_setup = false -- file change watch has been setup 8 | M.history_read = false -- history has been read at least once 9 | 10 | local function open_history(mode) 11 | path.create_scaffolding() 12 | return uv.fs_open(path.historyfile, mode, 438) 13 | end 14 | 15 | local function dir_exists(dir) 16 | dir = dir:gsub("^~", path.homedir) 17 | local stat = uv.fs_stat(dir) 18 | if stat ~= nil and stat.type == "directory" then 19 | return true 20 | end 21 | return false 22 | end 23 | 24 | M.add_session_project = function(dir) 25 | table.insert(M.session_projects, dir) 26 | end 27 | 28 | M.delete_project = function(dir) 29 | for k, v in pairs(M.recent_projects) do 30 | if v == dir then 31 | M.recent_projects[k] = nil 32 | end 33 | end 34 | for k, v in pairs(M.session_projects) do 35 | if v == dir then 36 | M.session_projects[k] = nil 37 | end 38 | end 39 | end 40 | 41 | local function deserialize_history(history_data) 42 | -- split data to table 43 | local projects = {} 44 | for s in history_data:gmatch("[^\r\n]+") do 45 | if dir_exists(s) then 46 | table.insert(projects, s) 47 | end 48 | end 49 | 50 | M.recent_projects = path.delete_duplicates(projects) 51 | end 52 | 53 | local function setup_watch() 54 | -- Only runs once 55 | if M.has_watch_setup == false then 56 | M.has_watch_setup = true 57 | local event = uv.new_fs_event() 58 | if event == nil then 59 | return 60 | end 61 | event:start(path.projectpath, {}, function(err, _, events) 62 | if err ~= nil then 63 | return 64 | end 65 | if events["change"] then 66 | M.recent_projects = nil 67 | M.read_projects_from_history() 68 | end 69 | end) 70 | end 71 | end 72 | 73 | M.read_projects_from_history = function() 74 | local file = open_history("r") 75 | setup_watch() 76 | if file == nil then 77 | M.history_read = true 78 | return 79 | end 80 | uv.fs_fstat(file, function(_, stat) 81 | if stat == nil then 82 | M.history_read = true 83 | return 84 | end 85 | uv.fs_read(file, stat.size, -1, function(_, data) 86 | uv.fs_close(file, function(_, _) end) 87 | deserialize_history(data) 88 | M.history_read = true 89 | end) 90 | end) 91 | end 92 | 93 | local function sanitize_projects() 94 | local tbl = {} 95 | if M.recent_projects ~= nil then 96 | vim.list_extend(tbl, M.recent_projects) 97 | vim.list_extend(tbl, M.session_projects) 98 | else 99 | tbl = M.session_projects 100 | end 101 | 102 | tbl = path.delete_duplicates(tbl) 103 | 104 | local real_tbl = {} 105 | for _, dir in ipairs(tbl) do 106 | if dir_exists(dir) then 107 | table.insert(real_tbl, dir) 108 | end 109 | end 110 | 111 | return real_tbl 112 | end 113 | 114 | function M.get_recent_projects() 115 | M.make_sure_read_projects_from_history() 116 | return sanitize_projects() 117 | end 118 | 119 | function M.get_current_project() 120 | local projects = M.get_recent_projects() 121 | 122 | if #M.session_projects > 0 then 123 | return M.session_projects[#M.session_projects] 124 | end 125 | 126 | if projects and #projects > 0 then 127 | return projects[#projects] 128 | end 129 | 130 | return "" 131 | end 132 | 133 | function M.make_sure_read_projects_from_history() 134 | if M.history_read == false then 135 | M.read_projects_from_history() 136 | vim.wait(200, function() 137 | return M.history_read 138 | end) 139 | end 140 | end 141 | 142 | M.write_projects_to_history = function() 143 | -- Write projects is synchronous 144 | -- because it runs when vim ends 145 | M.make_sure_read_projects_from_history() 146 | local mode = "w" 147 | if M.recent_projects == nil then 148 | mode = "a" 149 | end 150 | local file = open_history(mode) 151 | 152 | if file ~= nil then 153 | local res = sanitize_projects() 154 | 155 | -- Trim table to last 100 entries 156 | local len_res = #res 157 | local tbl_out 158 | if #res > 100 then 159 | tbl_out = vim.list_slice(res, len_res - 100, len_res) 160 | else 161 | tbl_out = res 162 | end 163 | 164 | -- Transform table to string 165 | local out = "" 166 | for _, v in ipairs(tbl_out) do 167 | out = out .. v .. "\n" 168 | end 169 | 170 | -- Write string out to file and close 171 | uv.fs_write(file, out, -1) 172 | uv.fs_close(file) 173 | end 174 | end 175 | 176 | return M 177 | -------------------------------------------------------------------------------- /lua/neovim-project/utils/neo-tree.lua: -------------------------------------------------------------------------------- 1 | -- Hacking neo-tree to restore expanded dirs after session load 2 | 3 | local M = {} 4 | 5 | local path_util = require("neovim-project.utils.path") 6 | 7 | M.dirs_to_restore = nil 8 | 9 | local function filesystem_state() 10 | -- Returns a table with filesystem source state of neo-tree 11 | local installed, sm = pcall(require, "neo-tree.sources.manager") 12 | if not installed or sm == nil then 13 | return nil 14 | end 15 | local ok, state = pcall(sm.get_state, "filesystem") 16 | if ok then 17 | return state 18 | else 19 | return nil 20 | end 21 | end 22 | 23 | local function after_render() 24 | if M.dirs_to_restore ~= nil and #M.dirs_to_restore > 0 then 25 | local state = filesystem_state() 26 | if state == nil then 27 | return 28 | end 29 | 30 | local nui_tree = state.tree 31 | if nui_tree == nil then 32 | -- filesystem source is not ready. 33 | -- probably, neo-tree is opened with another source 34 | return 35 | end 36 | if state.explicitly_opened_nodes == nil then 37 | state.explicitly_opened_nodes = {} 38 | end 39 | local dir = table.remove(M.dirs_to_restore, 1) 40 | state.explicitly_opened_nodes[dir] = true 41 | local node = nui_tree:get_node(dir) 42 | if node ~= nil then 43 | node:expand() 44 | end 45 | -- refresh tree to load children 46 | state.commands["refresh"](state) 47 | end 48 | end 49 | 50 | M.get_state_as_lua_string = function() 51 | -- Returns a string that can be used in lua code as value (table or nil) 52 | -- Value is a table of paths that were explicitly opened in neo-tree 53 | local state = filesystem_state() 54 | -- create table dirs_to_restore from state.explicitly_opened_nodes and M.dirs_to_restore 55 | local restore = {} 56 | 57 | if state ~= nil and state.explicitly_opened_nodes ~= nil then 58 | for path, opened in pairs(state.explicitly_opened_nodes) do 59 | if opened then 60 | restore[path] = true 61 | end 62 | end 63 | else 64 | if M.dirs_to_restore ~= nil then 65 | for _, path in ipairs(M.dirs_to_restore) do 66 | restore[path] = true 67 | end 68 | end 69 | end 70 | 71 | if vim.tbl_count(restore) == 0 then 72 | return "nil" 73 | end 74 | -- join all keys with a comma 75 | local cwd = vim.loop.cwd() 76 | local data = {} 77 | for dir, _ in pairs(restore) do 78 | if vim.startswith(dir, cwd) then -- path belongs to current project directory 79 | dir = path_util.short_path(dir) -- short path for syncing 80 | if vim.fn.isdirectory(vim.fn.expand(dir)) == 1 then 81 | -- add only existing directories 82 | table.insert(data, dir) 83 | end 84 | end 85 | end 86 | if vim.tbl_count(data) == 0 then 87 | return "nil" 88 | end 89 | -- clear table from values that are hidden in closed nodes 90 | cwd = path_util.short_path(cwd) 91 | local filtered_data = {} 92 | for _, path in ipairs(data) do 93 | local parent = path_util.short_path(vim.fn.fnamemodify(vim.fn.expand(path), ":h")) 94 | local has_parent = parent == cwd 95 | if not has_parent then 96 | for _, p in ipairs(data) do 97 | if p == parent then 98 | has_parent = true 99 | break 100 | end 101 | end 102 | end 103 | if has_parent then 104 | path = vim.inspect(path) -- wrap in quotes and escape special characters 105 | table.insert(filtered_data, path) 106 | end 107 | end 108 | if vim.tbl_count(filtered_data) == 0 then 109 | return "nil" 110 | end 111 | return "{" .. table.concat(filtered_data, ",") .. "}" 112 | -- output current state in command mode: 113 | -- lua print(require("neovim-project.utils.neo-tree").get_state_as_lua_string()) 114 | end 115 | 116 | M.restore_expanded = function(dirs_relative) 117 | -- Call this function after session load 118 | if #dirs_relative == 0 then 119 | return 120 | end 121 | local dirs_absolute = {} 122 | for _, path in ipairs(dirs_relative) do 123 | path = vim.fn.expand(path) 124 | table.insert(dirs_absolute, path) 125 | end 126 | 127 | -- sort dirs by depths before expanding 128 | -- nodes with bigger depths are not in the tree until parent is expanded 129 | table.sort(dirs_absolute, function(a, b) 130 | local _, depth_a = string.gsub(a, "/", "") 131 | local _, depth_b = string.gsub(b, "/", "") 132 | return depth_a < depth_b 133 | end) 134 | 135 | -- Impossible to restore state until user opens neo-tree because tree is not built yet 136 | M.dirs_to_restore = dirs_absolute -- save dirs to restore later in autocmd 137 | end 138 | 139 | M.setup_events_for_neotree = function() 140 | local installed, events = pcall(require, "neo-tree.events") 141 | if not installed then 142 | vim.notify("Neovim-project: neo-tree.events is not found", vim.log.levels.WARN) 143 | return 144 | end 145 | events.subscribe({ 146 | event = events.AFTER_RENDER, 147 | handler = after_render, 148 | }) 149 | end 150 | 151 | return M 152 | -------------------------------------------------------------------------------- /lua/neovim-project/utils/path.lua: -------------------------------------------------------------------------------- 1 | local uv = vim.loop 2 | local M = {} 3 | 4 | M.datapath = vim.fn.stdpath("data") -- directory 5 | M.projectpath = M.datapath .. "/neovim-project" -- directory 6 | M.historyfile = M.projectpath .. "/history" -- file 7 | M.sessionspath = M.datapath .. "/neovim-sessions" --directory 8 | M.homedir = nil 9 | M.dir_pretty = nil -- directory of current project (respects user defined symlinks in config) 10 | 11 | ---Convert glob-style wildcards to Lua pattern 12 | ---@param wildcard string wildcard string 13 | ---@param resolve boolean whether or not to resolve symlinks for the "prefix" 14 | ---@param eol boolean? whether or not to add `$` at the end of the pattern, default is true 15 | ---@return string converted Lua pattern 16 | local function wildcard_to_pattern(wildcard, resolve, eol) 17 | if not wildcard or wildcard == "" then 18 | return "" 19 | end 20 | if eol == nil then 21 | eol = true 22 | end 23 | 24 | -- Expand to absolute path, fnamemodify can work with wildcards 25 | local pattern = vim.fn.fnamemodify(wildcard, ":p") 26 | local pattern_end = pattern:sub(#pattern, #pattern) 27 | if pattern_end == "/" or pattern_end == "\\" then 28 | pattern = pattern:sub(1, #pattern - 1) 29 | end 30 | 31 | if resolve then 32 | -- It turns out that `vim.fn.resolve` can actually resolve the "prefix" even if it is a wildcard 33 | pattern = vim.fn.resolve(pattern) 34 | end 35 | 36 | -- Escape special characters for Lua patterns (except wildcards we need to handle specially) 37 | pattern = pattern:gsub("([%%%.%+%-%$%^%(%)%]])", "%%%1") 38 | 39 | -- Handle the beginning of the pattern 40 | local start_pattern = "^" 41 | 42 | -- Keep track of current position 43 | local i = 1 44 | local len = #pattern 45 | local result = start_pattern 46 | 47 | while i <= len do 48 | local c = pattern:sub(i, i) 49 | 50 | if c == "?" then 51 | -- ? matches one character, but not path separators 52 | result = result .. "[^/\\]" 53 | i = i + 1 54 | elseif c == "*" then 55 | if i < len and pattern:sub(i + 1, i + 1) == "*" then 56 | -- ** recursively matches all directories 57 | if i + 2 <= len and (pattern:sub(i + 2, i + 2) == "/" or pattern:sub(i + 2, i + 2) == "\\") then 58 | -- Handle **/ or **\ patterns, match any level of directories 59 | result = result .. ".*" 60 | i = i + 3 61 | else 62 | -- Standalone ** treated as * 63 | if i == 1 or pattern:sub(i - 1, i - 1) == "." then 64 | -- Patterns starting with .* can match hidden files 65 | result = result .. ".*" 66 | else 67 | -- * doesn't match files starting with a dot (nosuf=true) 68 | result = result .. "([^.][^/\\]*)" 69 | end 70 | i = i + 2 71 | end 72 | else 73 | -- Single * case 74 | if i == 1 or pattern:sub(i - 1, i - 1) == "." then 75 | -- Patterns starting with .* can match hidden files 76 | result = result .. "[^/\\]*" 77 | else 78 | -- * doesn't match files starting with a dot (nosuf=true) 79 | result = result .. "([^.][^/\\]*)" 80 | end 81 | i = i + 1 82 | end 83 | elseif c == "[" then 84 | -- Handle [abc] character classes 85 | local closing = pattern:find("]", i + 1) 86 | if closing then 87 | if pattern:sub(i + 1, i + 1) == "!" then 88 | -- Handle [!abc] character classes 89 | result = result .. "[^" 90 | i = i + 2 91 | end 92 | result = result .. pattern:sub(i, closing) 93 | i = closing + 1 94 | else 95 | -- No closing bracket found, treat as a normal character 96 | result = result .. "%[" 97 | i = i + 1 98 | end 99 | elseif c == "/" or c == "\\" then 100 | -- Handle path separators uniformly 101 | result = result .. "[/\\]" 102 | i = i + 1 103 | else 104 | -- Normal characters 105 | result = result .. c 106 | i = i + 1 107 | end 108 | end 109 | 110 | if eol then 111 | result = result .. "$" -- Add ending anchor 112 | end 113 | 114 | return result 115 | end 116 | 117 | local function is_subdirectory(parent, sub) 118 | return sub:sub(1, #parent) == parent 119 | end 120 | 121 | local function find_closest_parent(directories, subdirectory) 122 | local closest_parent = nil 123 | local closest_length = 0 124 | subdirectory = M.short_path(subdirectory) 125 | for _, dir in ipairs(directories) do 126 | dir = M.short_path(dir) 127 | if is_subdirectory(dir, subdirectory) then 128 | local length = #dir 129 | if length > closest_length then 130 | closest_length = length 131 | closest_parent = dir 132 | end 133 | end 134 | end 135 | return closest_parent 136 | end 137 | 138 | M.get_all_projects = function(patterns) 139 | -- Get all existing projects from patterns 140 | local projects = {} 141 | if patterns == nil then 142 | patterns = require("neovim-project.config").options.projects 143 | end 144 | for _, pattern in ipairs(patterns) do 145 | local tbl = vim.fn.glob(pattern, true, true, true) 146 | for _, path in ipairs(tbl) do 147 | if vim.fn.isdirectory(path) == 1 then 148 | local short = M.short_path(path) 149 | if not vim.tbl_contains(projects, short) then 150 | table.insert(projects, short) 151 | end 152 | end 153 | end 154 | end 155 | return projects 156 | end 157 | 158 | function M.init() 159 | M.datapath = vim.fn.expand(require("neovim-project.config").options.datapath) 160 | M.projectpath = M.datapath .. "/neovim-project" -- directory 161 | M.historyfile = M.projectpath .. "/history" -- file 162 | M.sessionspath = M.datapath .. "/neovim-sessions" --directory 163 | M.homedir = vim.fn.expand("~") 164 | end 165 | 166 | M.get_all_projects_with_sorting = function() 167 | -- Get all projects but with specific sorting 168 | local sorting = require("neovim-project.config").options.picker.opts.sorting 169 | local all_projects = M.get_all_projects() 170 | 171 | -- Sort by most recent projects first 172 | if sorting == "history" then 173 | local recent = require("neovim-project.utils.history").get_recent_projects() 174 | recent = M.fix_symlinks_for_history(recent) 175 | 176 | -- Reverse projects 177 | for i = 1, math.floor(#recent / 2) do 178 | recent[i], recent[#recent - i + 1] = recent[#recent - i + 1], recent[i] 179 | end 180 | 181 | -- Add all projects and prioritise history 182 | local seen, projects = {}, {} 183 | for _, project in ipairs(vim.list_extend(recent, all_projects)) do 184 | if not seen[project] then 185 | table.insert(projects, project) 186 | seen[project] = true 187 | end 188 | end 189 | return projects 190 | 191 | -- Sort alphabetically ascending by project name 192 | elseif sorting == "alphabetical_name" then 193 | table.sort(all_projects, function(a, b) 194 | local name_a = a:match(".*/([^/]+)$") or a 195 | local name_b = b:match(".*/([^/]+)$") or b 196 | return name_a:lower() < name_b:lower() 197 | end) 198 | return all_projects 199 | 200 | -- Sort alphabetically ascending by project path 201 | elseif sorting == "alphabetical_path" then 202 | table.sort(all_projects) 203 | return all_projects 204 | 205 | -- Default sort based on patterns 206 | else 207 | return all_projects 208 | end 209 | end 210 | 211 | M.short_path = function(path) 212 | -- Reduce file name to be relative to the home directory, if possible. 213 | path = M.resolve(path) 214 | return vim.fn.fnamemodify(path, ":~") 215 | end 216 | 217 | M.cwd = function() 218 | -- Get current working directory in short form 219 | return M.short_path(uv.cwd()) 220 | end 221 | 222 | M.create_scaffolding = function(callback) 223 | -- Create directories 224 | if callback ~= nil then -- async 225 | uv.fs_mkdir(M.projectpath, 448, callback) 226 | else -- sync 227 | uv.fs_mkdir(M.projectpath, 448) 228 | end 229 | end 230 | 231 | M.resolve = function(filename) 232 | -- Replace symlink with real path 233 | filename = vim.fn.expand(filename) 234 | return vim.fn.resolve(filename) 235 | end 236 | 237 | M.delete_duplicates = function(tbl) 238 | -- Remove duplicates from table, preserving order 239 | local cache_dict = {} 240 | for _, v in ipairs(tbl) do 241 | if cache_dict[v] == nil then 242 | cache_dict[v] = 1 243 | else 244 | cache_dict[v] = cache_dict[v] + 1 245 | end 246 | end 247 | 248 | local res = {} 249 | for _, v in ipairs(tbl) do 250 | if cache_dict[v] == 1 then 251 | table.insert(res, v) 252 | else 253 | cache_dict[v] = cache_dict[v] - 1 254 | end 255 | end 256 | return res 257 | end 258 | 259 | local find_longest_matched_pattern = function(patterns, dir, resolve) 260 | local longest_pattern = nil 261 | local longest_length = 0 262 | for _, pattern in ipairs(patterns) do 263 | local lua_pattern = wildcard_to_pattern(pattern, resolve, false) 264 | local startindex, endindex = dir:find(lua_pattern) 265 | if startindex ~= nil and endindex ~= nil then 266 | local len = endindex - startindex + 1 267 | if len > longest_length then 268 | longest_length = len 269 | longest_pattern = pattern 270 | end 271 | end 272 | end 273 | 274 | return longest_pattern 275 | end 276 | 277 | M.fix_symlinks_for_history = function(dirs) 278 | -- Replace paths with paths from `projects` option 279 | local patterns = require("neovim-project.config").options.projects 280 | local follow_symlinks = require("neovim-project.config").options.follow_symlinks 281 | 282 | if follow_symlinks == true or follow_symlinks == "full" then 283 | local projects = M.get_all_projects() 284 | for i, dir in ipairs(dirs) do 285 | local dir_resolved 286 | for _, path in ipairs(projects) do 287 | local path_resolved 288 | if dir_resolved == nil then 289 | if path_resolved == nil then 290 | if path == dir then 291 | dirs[i] = path 292 | break 293 | end 294 | path_resolved = M.resolve(path) 295 | end 296 | if path_resolved == dir then 297 | dirs[i] = path 298 | break 299 | end 300 | dir_resolved = M.resolve(dir) 301 | end 302 | if path_resolved == nil then 303 | if path == dir_resolved then 304 | dirs[i] = path 305 | break 306 | end 307 | path_resolved = M.resolve(path) 308 | end 309 | if path_resolved == dir_resolved then 310 | dirs[i] = path 311 | break 312 | end 313 | end 314 | end 315 | else 316 | local resolve 317 | if follow_symlinks == "partial" then 318 | resolve = true 319 | elseif not follow_symlinks or follow_symlinks == "none" then 320 | resolve = false 321 | end 322 | for i, dir in ipairs(dirs) do 323 | local dir_resolved 324 | for _, pattern in ipairs(patterns) do 325 | local lua_pattern = wildcard_to_pattern(pattern, resolve, true) 326 | local startindex, endindex 327 | if dir_resolved == nil then 328 | startindex, endindex = dir:find(lua_pattern) 329 | if startindex == nil or endindex == nil then 330 | dir_resolved = M.resolve(dir) 331 | startindex, endindex = dir_resolved:find(lua_pattern) 332 | end 333 | else 334 | startindex, endindex = dir_resolved:find(lua_pattern) 335 | end 336 | if startindex ~= nil and endindex ~= nil then 337 | local projects = M.get_all_projects({ pattern }) 338 | for _, path in ipairs(projects) do 339 | if path == dir_resolved or M.resolve(path) == dir_resolved then 340 | dirs[i] = path 341 | break 342 | end 343 | end 344 | break 345 | end 346 | end 347 | end 348 | end 349 | -- remove duplicates 350 | return M.delete_duplicates(dirs) 351 | end 352 | 353 | M.chdir_closest_parent_project = function(dir) 354 | local patterns = require("neovim-project.config").options.projects 355 | local follow_symlinks = require("neovim-project.config").options.follow_symlinks 356 | 357 | -- returns the parent project and chdir to that parent 358 | -- if no parent project returns nil 359 | -- if dir is a project return dir 360 | local dir_resolved = dir or M.resolve(M.cwd()) 361 | 362 | local parent 363 | if follow_symlinks == true or follow_symlinks == "full" then 364 | parent = find_closest_parent(M.get_all_projects(), dir_resolved) 365 | else 366 | local resolve 367 | if follow_symlinks == "partial" then 368 | resolve = true 369 | elseif not follow_symlinks or follow_symlinks == "none" then 370 | resolve = false 371 | end 372 | local pattern = find_longest_matched_pattern(patterns, dir_resolved, resolve) 373 | if pattern then 374 | parent = find_closest_parent(M.get_all_projects({ pattern }), dir_resolved) 375 | end 376 | end 377 | 378 | if parent then 379 | M.dir_pretty = M.short_path(parent) -- store path with user defined symlinks 380 | vim.api.nvim_set_current_dir(parent) 381 | end 382 | return parent 383 | end 384 | 385 | return M 386 | -------------------------------------------------------------------------------- /lua/neovim-project/utils/showkeys.lua: -------------------------------------------------------------------------------- 1 | --- Workaround for plugin https://github.com/nvzone/showkeys 2 | --- 3 | --- Close the plugin window before switching projects 4 | --- and reopen it after 5 | --- 6 | 7 | local M = {} 8 | 9 | local showkeys_visible = false 10 | 11 | M.post_load = function() 12 | if not showkeys_visible then 13 | return 14 | end 15 | local has_showkeys, showkeys = pcall(require, "showkeys") 16 | if has_showkeys and type(showkeys) == "table" and type(showkeys.open) == "function" then 17 | showkeys.open() 18 | end 19 | end 20 | 21 | M.pre_save = function() 22 | local has_state, state = pcall(require, "showkeys.state") 23 | if not has_state then 24 | return 25 | end 26 | local has_showkeys, showkeys = pcall(require, "showkeys") 27 | if not has_showkeys then 28 | return 29 | end 30 | if type(state) == "table" and type(showkeys.close) == "function" then 31 | showkeys_visible = state.visible 32 | if showkeys_visible then 33 | showkeys.close() 34 | end 35 | end 36 | end 37 | 38 | return M 39 | -------------------------------------------------------------------------------- /lua/telescope/_extensions/neovim-project.lua: -------------------------------------------------------------------------------- 1 | local has_telescope, telescope = pcall(require, "telescope") 2 | 3 | if not has_telescope then 4 | return 5 | end 6 | 7 | local finders = require("telescope.finders") 8 | local pickers = require("telescope.pickers") 9 | local telescope_config = require("telescope.config").values 10 | local actions = require("telescope.actions") 11 | local state = require("telescope.actions.state") 12 | local entry_display = require("telescope.pickers.entry_display") 13 | 14 | local path = require("neovim-project.utils.path") 15 | local history = require("neovim-project.utils.history") 16 | local preview = require("neovim-project.preview") 17 | local project = require("neovim-project.project") 18 | 19 | local show_preview = require("neovim-project.config").options.picker.preview.enabled 20 | 21 | ---------- 22 | -- Actions 23 | ---------- 24 | 25 | local function create_finder(discover) 26 | local results 27 | if discover then 28 | results = path.get_all_projects_with_sorting() 29 | else 30 | results = history.get_recent_projects() 31 | results = path.fix_symlinks_for_history(results) 32 | -- Reverse results 33 | for i = 1, math.floor(#results / 2) do 34 | results[i], results[#results - i + 1] = results[#results - i + 1], results[i] 35 | end 36 | end 37 | 38 | local displayer = entry_display.create({ 39 | separator = " ", 40 | items = { 41 | { 42 | width = 30, 43 | }, 44 | { 45 | remaining = true, 46 | }, 47 | }, 48 | }) 49 | 50 | local function make_display(entry) 51 | return displayer({ entry.name, { entry.value, "Comment" } }) 52 | end 53 | 54 | return finders.new_table({ 55 | results = results, 56 | entry_maker = function(entry) 57 | local name = vim.fn.fnamemodify(entry, ":t") 58 | return { 59 | display = make_display, 60 | name = name, 61 | value = entry, 62 | ordinal = name .. " " .. entry, 63 | } 64 | end, 65 | }) 66 | end 67 | 68 | local function change_working_directory(prompt_bufnr) 69 | local selected_entry = state.get_selected_entry() 70 | if selected_entry == nil then 71 | actions.close(prompt_bufnr) 72 | return 73 | end 74 | local dir = selected_entry.value 75 | actions.close(prompt_bufnr) 76 | -- session_manager will change session 77 | project.switch_project(dir) 78 | end 79 | 80 | local function delete_project(prompt_bufnr) 81 | local selectedEntry = state.get_selected_entry() 82 | if selectedEntry == nil then 83 | actions.close(prompt_bufnr) 84 | return 85 | end 86 | local dir = selectedEntry.value 87 | local choice = vim.fn.confirm("Delete '" .. dir .. "' from project list?", "&Yes\n&No", 2) 88 | 89 | if choice == 1 then 90 | history.delete_project(dir) 91 | project.delete_session(dir) 92 | 93 | local finder = create_finder(false) 94 | state.get_current_picker(prompt_bufnr):refresh(finder, { 95 | reset_prompt = true, 96 | }) 97 | end 98 | end 99 | 100 | ---Main entrypoint for Telescope. 101 | ---@param opts table 102 | local function project_history(opts) 103 | opts = opts or {} 104 | 105 | pickers 106 | .new(opts, { 107 | prompt_title = "Recent Projects", 108 | finder = create_finder(false), 109 | previewer = show_preview and preview.project_previewer, 110 | sorter = telescope_config.generic_sorter(opts), 111 | attach_mappings = function(prompt_bufnr, map) 112 | local config = require("neovim-project.config") 113 | local forget_project_keys = config.options.forget_project_keys 114 | if forget_project_keys then 115 | for mode, key in pairs(forget_project_keys) do 116 | map(mode, key, delete_project) 117 | end 118 | end 119 | 120 | local on_project_selected = function() 121 | change_working_directory(prompt_bufnr) 122 | end 123 | actions.select_default:replace(on_project_selected) 124 | return true 125 | end, 126 | }) 127 | :find() 128 | end 129 | 130 | ---@param opts table 131 | local function project_discover(opts) 132 | opts = opts or {} 133 | 134 | pickers 135 | .new(opts, { 136 | prompt_title = "Discover Projects", 137 | finder = create_finder(true), 138 | previewer = show_preview and preview.project_previewer, 139 | sorter = telescope_config.generic_sorter(opts), 140 | attach_mappings = function(prompt_bufnr) 141 | local on_project_selected = function() 142 | change_working_directory(prompt_bufnr) 143 | end 144 | actions.select_default:replace(on_project_selected) 145 | return true 146 | end, 147 | }) 148 | :find() 149 | end 150 | return telescope.register_extension({ 151 | exports = { 152 | ["neovim-project"] = project_history, 153 | history = project_history, 154 | discover = project_discover, 155 | }, 156 | }) 157 | --------------------------------------------------------------------------------