├── .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 | 
13 |
14 | 
15 |
16 | 
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 |
--------------------------------------------------------------------------------