├── .harper-dictionary.txt
├── .ignore
├── .github
├── ISSUE_TEMPLATE
│ ├── config.yml
│ ├── feature_request.yml
│ └── bug_report.yml
├── dependabot.yml
├── FUNDING.yml
├── workflows
│ ├── nvim-type-check.yml
│ ├── stylua.yml
│ ├── rumdl-lint.yml
│ ├── stale-bot.yml
│ ├── pr-title.yml
│ └── panvimdoc.yml
└── pull_request_template.md
├── .gitignore
├── .stylua.toml
├── .emmyrc.json
├── .editorconfig
├── .luarc.jsonc
├── plugin
└── ex-commands.lua
├── lua
└── genghis
│ ├── support
│ ├── notify.lua
│ ├── move-considering-partition.lua
│ └── lsp-rename.lua
│ ├── init.lua
│ ├── operations
│ ├── copy.lua
│ ├── navigation.lua
│ └── file.lua
│ └── config.lua
├── LICENSE
├── .rumdl.toml
├── README.md
└── doc
└── nvim-genghis.txt
/.harper-dictionary.txt:
--------------------------------------------------------------------------------
1 | genghis
2 | vimscript
3 |
--------------------------------------------------------------------------------
/.ignore:
--------------------------------------------------------------------------------
1 | # auto-generated by panvimdoc
2 | doc
3 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # help-tags auto-generated by lazy.nvim
2 | doc/tags
3 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "github-actions"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 | commit-message:
8 | prefix: "chore(dependabot): "
9 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # https://docs.github.com/en/github/administering-a-repository/managing-repository-settings/displaying-a-sponsor-button-in-your-repository
2 |
3 | custom: https://www.paypal.me/ChrisGrieser
4 | ko_fi: pseudometa
5 |
--------------------------------------------------------------------------------
/.stylua.toml:
--------------------------------------------------------------------------------
1 | # https://github.com/JohnnyMorganz/StyLua#options
2 | column_width = 100
3 | indent_type = "Tabs"
4 | indent_width = 3
5 | quote_style = "AutoPreferDouble"
6 | call_parentheses = "NoSingleTable"
7 | collapse_simple_statement = "Always"
8 |
9 | [sort_requires]
10 | enabled = true
11 |
--------------------------------------------------------------------------------
/.github/workflows/nvim-type-check.yml:
--------------------------------------------------------------------------------
1 | name: nvim type check
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | paths: ["**.lua"]
7 | pull_request:
8 | paths: ["**.lua"]
9 |
10 | jobs:
11 | build:
12 | name: nvim type check
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v6
16 | - uses: stevearc/nvim-typecheck-action@v2
17 |
--------------------------------------------------------------------------------
/.emmyrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "runtime": {
3 | "version": "LuaJIT",
4 | "requirePattern": ["lua/?.lua", "lua/?/init.lua"]
5 | },
6 | "workspace": {
7 | "library": [
8 | "$VIMRUNTIME",
9 | "$HOME/.local/share/nvim/lazy/luvit-meta/library/uv.lua"
10 | ]
11 | },
12 | "$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json"
13 | }
14 |
--------------------------------------------------------------------------------
/.github/workflows/stylua.yml:
--------------------------------------------------------------------------------
1 | name: Stylua check
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | paths: ["**.lua"]
7 | pull_request:
8 | paths: ["**.lua"]
9 |
10 | jobs:
11 | stylua:
12 | name: Stylua
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v6
16 | - uses: JohnnyMorganz/stylua-action@v4
17 | with:
18 | token: ${{ secrets.GITHUB_TOKEN }}
19 | version: latest
20 | args: --check .
21 |
--------------------------------------------------------------------------------
/.github/workflows/rumdl-lint.yml:
--------------------------------------------------------------------------------
1 | name: Markdown linting via rumdl
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | paths:
7 | - "**/*.md"
8 | - ".github/workflows/rumdl-lint.yml"
9 | - ".rumdl.toml"
10 | pull_request:
11 | paths:
12 | - "**/*.md"
13 |
14 | jobs:
15 | rumdl:
16 | name: rumdl
17 | runs-on: ubuntu-latest
18 | steps:
19 | - uses: actions/checkout@v6
20 | - uses: rvben/rumdl@v0
21 | with:
22 | report-type: annotations
23 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | max_line_length = 100
5 | end_of_line = lf
6 | charset = utf-8
7 | insert_final_newline = true
8 | indent_style = tab
9 | indent_size = 3
10 | tab_width = 3
11 | trim_trailing_whitespace = true
12 |
13 | [*.{yml,yaml,scm,cff}]
14 | indent_style = space
15 | indent_size = 2
16 | tab_width = 2
17 |
18 | [*.py]
19 | indent_style = space
20 | indent_size = 4
21 | tab_width = 4
22 |
23 | [*.md]
24 | indent_style = space
25 | indent_size = 4
26 | trim_trailing_whitespace = false
27 |
--------------------------------------------------------------------------------
/.luarc.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "runtime.version": "LuaJIT",
3 |
4 | "workspace.library": [
5 | "$VIMRUNTIME/lua", // nvim-lua runtime
6 | "${3rd}/luv/library" // vim.uv
7 | ],
8 |
9 | "diagnostics": {
10 | "unusedLocalExclude": ["_*"], // allow `_varname` for unused variables
11 | "groupFileStatus": {
12 | "luadoc": "Any", // require stricter annotations
13 | "conventions": "Any" // disallow global variables
14 | }
15 | },
16 |
17 | "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json"
18 | }
19 |
--------------------------------------------------------------------------------
/plugin/ex-commands.lua:
--------------------------------------------------------------------------------
1 | vim.api.nvim_create_user_command("Genghis", function(ctx) require("genghis")[ctx.args]() end, {
2 | nargs = 1,
3 | complete = function(query)
4 | local allOps = {}
5 | vim.list_extend(allOps, vim.tbl_keys(require("genghis.operations.file")))
6 | vim.list_extend(allOps, vim.tbl_keys(require("genghis.operations.copy")))
7 | vim.list_extend(allOps, vim.tbl_keys(require("genghis.operations.navigation")))
8 | return vim.tbl_filter(function(op) return op:lower():find(query, nil, true) end, allOps)
9 | end,
10 | })
11 |
--------------------------------------------------------------------------------
/lua/genghis/support/notify.lua:
--------------------------------------------------------------------------------
1 | ---@param msg string
2 | ---@param level? "info"|"warn"|"error"
3 | ---@param opts? table
4 | return function(msg, level, opts)
5 | local successNotify = require("genghis.config").config.successNotifications
6 | if not level then level = "info" end
7 | if level == "info" and not successNotify then return end
8 | if not opts then opts = {} end
9 |
10 | opts.title = opts.title and "Genghis: " .. opts.title or "Genghis"
11 | if not opts.ft then opts.ft = "text" end -- prevent `~` from creating strikethroughs in `snacks.notifier`
12 | vim.notify(msg, vim.log.levels[level:upper()], opts)
13 | end
14 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Problem statement
2 |
3 |
4 | ## Proposed solution
5 |
6 |
7 | ## AI usage disclosure
8 |
11 |
12 | ## Checklist
13 | - [ ] Variable names follow `camelCase` convention.
14 | - [ ] All AI-generated code has been reviewed by a human.
15 | - [ ] The `README.md` has been updated for any new or modified functionality
16 | (the `.txt` file is auto-generated and does not need to be modified).
17 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: Feature request
2 | description: Suggest an idea
3 | title: "Feature Request: "
4 | labels: ["enhancement"]
5 | body:
6 | - type: textarea
7 | id: feature-requested
8 | attributes:
9 | label: Feature Requested
10 | description: A clear and concise description of the feature.
11 | validations:
12 | required: true
13 | - type: textarea
14 | id: screenshot
15 | attributes:
16 | label: Relevant Screenshot
17 | description: If applicable, add screenshots or a screen recording to help explain the request.
18 | - type: checkboxes
19 | id: checklist
20 | attributes:
21 | label: Checklist
22 | options:
23 | - label: The feature would be useful to more users than just me.
24 | required: true
25 |
--------------------------------------------------------------------------------
/lua/genghis/support/move-considering-partition.lua:
--------------------------------------------------------------------------------
1 | ---@param oldFilePath string
2 | ---@param newFilePath string
3 | ---@return boolean success
4 | return function(oldFilePath, newFilePath)
5 | local renamed, _ = vim.uv.fs_rename(oldFilePath, newFilePath)
6 | if renamed then return true end
7 |
8 | local notify = require("genghis.support.notify")
9 |
10 | -- try `fs_copyfile` to support moving across partitions
11 | local copied, copiedError = vim.uv.fs_copyfile(oldFilePath, newFilePath)
12 | if copied then
13 | local deleted, deletedError = vim.uv.fs_unlink(oldFilePath)
14 | if deleted then
15 | return true
16 | else
17 | notify(("Failed to delete %q: %q"):format(oldFilePath, deletedError), "error")
18 | return false
19 | end
20 | else
21 | local msg = ("Failed to copy %q to %q: %q"):format(oldFilePath, newFilePath, copiedError)
22 | notify(msg, "error")
23 | return false
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/.github/workflows/stale-bot.yml:
--------------------------------------------------------------------------------
1 | name: Stale bot
2 | on:
3 | schedule:
4 | - cron: "18 04 * * 3"
5 |
6 | permissions:
7 | issues: write
8 | pull-requests: write
9 |
10 | jobs:
11 | stale:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Close stale issues
15 | uses: actions/stale@v10
16 | with:
17 | repo-token: ${{ secrets.GITHUB_TOKEN }}
18 |
19 | # DOCS https://github.com/actions/stale#all-options
20 | days-before-stale: 180
21 | days-before-close: 7
22 | stale-issue-label: "Stale"
23 | stale-issue-message: |
24 | This issue has been automatically marked as stale.
25 | **If this issue is still affecting you, please leave any comment**, for example "bump", and it will be kept open.
26 | close-issue-message: |
27 | This issue has been closed due to inactivity, and will not be monitored.
28 |
--------------------------------------------------------------------------------
/.github/workflows/pr-title.yml:
--------------------------------------------------------------------------------
1 | name: PR title
2 |
3 | on:
4 | pull_request_target:
5 | types:
6 | - opened
7 | - edited
8 | - synchronize
9 | - reopened
10 | - ready_for_review
11 |
12 | permissions:
13 | pull-requests: read
14 |
15 | jobs:
16 | semantic-pull-request:
17 | name: Check PR title
18 | runs-on: ubuntu-latest
19 | steps:
20 | - uses: amannn/action-semantic-pull-request@v6
21 | env:
22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
23 | with:
24 | requireScope: false
25 | subjectPattern: ^(?![A-Z]).+$ # disallow title starting with capital
26 | types: | # add `improv` to the list of allowed types
27 | improv
28 | fix
29 | feat
30 | refactor
31 | build
32 | ci
33 | style
34 | test
35 | chore
36 | perf
37 | docs
38 | break
39 | revert
40 |
--------------------------------------------------------------------------------
/.github/workflows/panvimdoc.yml:
--------------------------------------------------------------------------------
1 | name: panvimdoc
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | paths:
7 | - README.md
8 | - .github/workflows/panvimdoc.yml
9 | workflow_dispatch: {} # allows manual execution
10 |
11 | permissions:
12 | contents: write
13 |
14 | #───────────────────────────────────────────────────────────────────────────────
15 |
16 | jobs:
17 | docs:
18 | runs-on: ubuntu-latest
19 | name: README.md to vimdoc
20 | steps:
21 | - uses: actions/checkout@v6
22 | - run: git pull # fix failure when multiple commits are pushed in succession
23 | - run: mkdir -p doc
24 |
25 | - name: panvimdoc
26 | uses: kdheepak/panvimdoc@main
27 | with:
28 | vimdoc: ${{ github.event.repository.name }}
29 | version: "Neovim"
30 | demojify: true
31 | treesitter: true
32 |
33 | - run: git pull
34 | - name: push changes
35 | uses: stefanzweifel/git-auto-commit-action@v7
36 | with:
37 | commit_message: "chore: auto-generate vimdocs"
38 | branch: ${{ github.head_ref }}
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022-2023 Christopher Grieser
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: File a bug report
3 | title: "[Bug]: "
4 | labels: ["bug"]
5 | body:
6 | - type: textarea
7 | id: bug-description
8 | attributes:
9 | label: Bug Description
10 | description: A clear and concise description of the bug.
11 | validations:
12 | required: true
13 | - type: textarea
14 | id: screenshot
15 | attributes:
16 | label: Relevant Screenshot
17 | description: If applicable, add screenshots or a screen recording to help explain your problem.
18 | - type: textarea
19 | id: reproduction-steps
20 | attributes:
21 | label: To Reproduce
22 | description: Steps to reproduce the problem
23 | placeholder: |
24 | For example:
25 | 1. Go to '...'
26 | 2. Click on '...'
27 | 3. Scroll down to '...'
28 | - type: textarea
29 | id: version-info
30 | attributes:
31 | label: neovim version
32 | render: Text
33 | validations:
34 | required: true
35 | - type: checkboxes
36 | id: checklist
37 | attributes:
38 | label: Make sure you have done the following
39 | options:
40 | - label: I have updated to the latest version of the plugin.
41 | required: true
42 |
--------------------------------------------------------------------------------
/lua/genghis/support/lsp-rename.lua:
--------------------------------------------------------------------------------
1 | local M = {}
2 |
3 | --------------------------------------------------------------------------------
4 |
5 | ---Requests a 'workspace/willRenameFiles' on any running LSP client, that supports it
6 | ---SOURCE https://github.com/LazyVim/LazyVim/blob/ac092289f506052cfdd1879f462be05075fe3081/lua/lazyvim/util/lsp.lua#L99-L119
7 | ---@param fromName string
8 | ---@param toName string
9 | function M.willRename(fromName, toName)
10 | local clients = vim.lsp.get_clients { bufnr = 0 }
11 | for _, client in ipairs(clients) do
12 | if client:supports_method("workspace/willRenameFiles") then
13 | local response = client:request_sync("workspace/willRenameFiles", {
14 | files = {
15 | { oldUri = vim.uri_from_fname(fromName), newUri = vim.uri_from_fname(toName) },
16 | },
17 | }, 1000, 0)
18 | if response and response.result ~= nil then
19 | vim.lsp.util.apply_workspace_edit(response.result, client.offset_encoding)
20 | end
21 | end
22 | end
23 | end
24 |
25 | ---@nodiscard
26 | ---@return boolean
27 | function M.supported()
28 | local clients = vim.lsp.get_clients { bufnr = 0 }
29 | for _, client in ipairs(clients) do
30 | if client:supports_method("workspace/willRenameFiles") then return true end
31 | end
32 | return false
33 | end
34 |
35 | --------------------------------------------------------------------------------
36 | return M
37 |
--------------------------------------------------------------------------------
/.rumdl.toml:
--------------------------------------------------------------------------------
1 | # DOCS https://github.com/rvben/rumdl/blob/main/docs/global-settings.md
2 |
3 | [global]
4 | line-length = 80
5 | disable = [
6 | "MD032", # blanks-around-lists: space waster
7 | ]
8 |
9 | # ------------------------------------------------------------------------------
10 |
11 | [MD004] # ul-style
12 | style = "dash" # GitHub default & quicker to type
13 |
14 | [MD007] # ul-indent
15 | indent = 4 # consistent with .editorconfig
16 |
17 | [MD013] # line-length
18 | code-blocks = false
19 | reflow = true # enable auto-formatting
20 |
21 | [MD022] # blanks-around-headings
22 | lines-below = 0 # rule of proximity
23 |
24 | [MD029] # ol-prefix
25 | style = "ordered"
26 |
27 | [MD033] # inline-html
28 | allowed-elements = ["a", "img"] # badges
29 |
30 | [MD049] # emphasis-style
31 | style = "asterisk" # better than underscore, since it's not considered a word-char
32 |
33 | [MD050] # strong-style
34 | style = "asterisk" # better than underscore, since it's not considered a word-char
35 |
36 | [MD060] # auto-format tables
37 | enabled = true # opt-in, since disabled by default
38 |
39 | [MD063] # heading-capitalization
40 | enabled = true # opt-in, since disabled by default
41 | style = "sentence_case"
42 | ignore-words = ["nvim"]
43 |
44 | # ------------------------------------------------------------------------------
45 |
46 | [per-file-ignores]
47 | # does not need to start with h1
48 | ".github/pull_request_template.md" = ["MD041"]
49 |
--------------------------------------------------------------------------------
/lua/genghis/init.lua:
--------------------------------------------------------------------------------
1 | local version = vim.version()
2 | if version.major == 0 and version.minor < 10 then
3 | vim.notify("nvim-genghis requires at least nvim 0.10.", vim.log.levels.WARN)
4 | return
5 | end
6 | --------------------------------------------------------------------------------
7 |
8 | local M = {}
9 |
10 | ---@param userConfig? Genghis.config
11 | function M.setup(userConfig) require("genghis.config").setup(userConfig) end
12 |
13 | ---@param direction? "next"|"prev"
14 | function M.navigateToFileInFolder(direction)
15 | require("genghis.operations.navigation").fileInFolder(direction)
16 | end
17 |
18 | -- redirect calls to this module to the respective submodules
19 | setmetatable(M, {
20 | __index = function(_, key)
21 | return function(...)
22 | local fileOps = vim.tbl_keys(require("genghis.operations.file"))
23 | local copyOps = vim.tbl_keys(require("genghis.operations.copy"))
24 |
25 | local module
26 | if vim.tbl_contains(fileOps, key) then module = "file" end
27 | if vim.tbl_contains(copyOps, key) then module = "copy" end
28 |
29 | if module then
30 | require("genghis.operations." .. module)[key](...)
31 | else
32 | local notify = require("genghis.support.notify")
33 | local msg = ("There is no operation called `%s`.\n\n"):format(key)
34 | .. "Make sure it exists in the list of operations, and that you haven't misspelled it."
35 | notify(msg, "error", { ft = "markdown" })
36 | end
37 | end
38 | end,
39 | })
40 |
41 | --------------------------------------------------------------------------------
42 | return M
43 |
--------------------------------------------------------------------------------
/lua/genghis/operations/copy.lua:
--------------------------------------------------------------------------------
1 | local M = {}
2 | --------------------------------------------------------------------------------
3 |
4 | ---@param expandOperation string
5 | local function copyOp(expandOperation)
6 | local icon = require("genghis.config").config.icons.copyPath
7 |
8 | local register = "+"
9 | local toCopy = vim.fn.expand(expandOperation)
10 | vim.fn.setreg(register, toCopy)
11 |
12 | local notify = require("genghis.support.notify")
13 | notify(toCopy, "info", { title = "Copied", icon = icon })
14 | end
15 |
16 | -- DOCS for the modifiers
17 | -- https://neovim.io/doc/user/builtin.html#expand()
18 | -- https://neovim.io/doc/user/cmdline.html#filename-modifiers
19 | function M.copyFilepath() copyOp("%:p") end
20 | function M.copyFilepathWithTilde() copyOp("%:~") end
21 | function M.copyFilename() copyOp("%:t") end
22 | function M.copyRelativePath() copyOp("%:.") end
23 | function M.copyDirectoryPath() copyOp("%:p:h") end
24 | function M.copyRelativeDirectoryPath() copyOp("%:.:h") end
25 |
26 | function M.copyFileItself()
27 | local notify = require("genghis.support.notify")
28 | if jit.os ~= "OSX" then
29 | notify("Currently only available on macOS.", "warn")
30 | return
31 | end
32 |
33 | local icon = require("genghis.config").config.icons.copyFile
34 | local path = vim.api.nvim_buf_get_name(0)
35 | local applescript = 'tell application "Finder" to set the clipboard to '
36 | .. ("POSIX file %q"):format(path)
37 |
38 | vim.system({ "osascript", "-e", applescript }, {}, function(out)
39 | if out.code ~= 0 then
40 | notify("Failed to copy file: " .. out.stderr, "error", { title = "Copy file" })
41 | else
42 | notify(vim.fs.basename(path), "info", { title = "Copied file", icon = icon })
43 | end
44 | end)
45 | end
46 |
47 | --------------------------------------------------------------------------------
48 | return M
49 |
--------------------------------------------------------------------------------
/lua/genghis/config.lua:
--------------------------------------------------------------------------------
1 | local M = {}
2 | --------------------------------------------------------------------------------
3 |
4 | ---@class Genghis.config
5 | local defaultConfig = {
6 | fileOperations = {
7 | -- automatically keep the extension when no file extension is given
8 | -- (everything after the first non-leading dot is treated as the extension)
9 | autoAddExt = true,
10 |
11 | trashCmd = function() ---@type fun(): string|string[]
12 | if jit.os == "OSX" then return "trash" end -- builtin since macOS 14
13 | if jit.os == "Windows" then return "trash" end
14 | if jit.os == "Linux" then return { "gio", "trash" } end
15 | return "trash-cli"
16 | end,
17 |
18 | ignoreInFolderSelection = { -- using lua pattern matching (e.g., escape `-` as `%-`)
19 | "/node_modules/", -- nodejs
20 | "/typings/", -- python
21 | "/doc/", -- vim help files folders
22 | "%.app/", -- macOS pseudo-folders
23 | "/%.", -- hidden folders
24 | },
25 | },
26 |
27 | navigation = {
28 | onlySameExtAsCurrentFile = false,
29 | ignoreDotfiles = true,
30 | ignoreExt = { "png", "svg", "webp", "jpg", "jpeg", "gif", "pdf", "zip" },
31 | ignoreFilesWithName = { ".DS_Store" },
32 | },
33 |
34 | successNotifications = true,
35 |
36 | icons = { -- set an icon to empty string to disable it
37 | chmodx = "",
38 | copyFile = "",
39 | copyPath = "",
40 | duplicate = "",
41 | file = "",
42 | move = "",
43 | new = "",
44 | nextFile = "",
45 | prevFile = "",
46 | rename = "",
47 | trash = "",
48 | },
49 | }
50 | M.config = defaultConfig
51 |
52 | ---@param userConfig? Genghis.config
53 | function M.setup(userConfig)
54 | M.config = vim.tbl_deep_extend("force", defaultConfig, userConfig or {})
55 |
56 | -- DEPRECATION (2025-11-24)
57 | ---@diagnostic disable: undefined-field
58 | if M.config.trashCmd then
59 | M.config.fileOperations.trashCmd = M.config.trashCmd
60 | local notify = require("genghis.support.notify")
61 | notify("config `.trashCmd` is deprecated, use `.fileOperations.trashCmd` instead.", "warn")
62 | end
63 | ---@diagnostic enable: undefined-field
64 | end
65 |
66 | --------------------------------------------------------------------------------
67 | return M
68 |
--------------------------------------------------------------------------------
/lua/genghis/operations/navigation.lua:
--------------------------------------------------------------------------------
1 | local M = {}
2 | --------------------------------------------------------------------------------
3 |
4 | ---Cycles files in folder in alphabetical order.
5 | ---If snacks.nvim is installed, adds cycling notification.
6 | ---@param direction? "next"|"prev"
7 | function M.fileInFolder(direction)
8 | local notify = require("genghis.support.notify")
9 |
10 | if not direction then direction = "next" end
11 | if direction ~= "next" and direction ~= "prev" then
12 | notify('Invalid direction. Only "next" and "prev" are allowed.', "warn")
13 | return
14 | end
15 |
16 | local config = require("genghis.config").config
17 | local curPath = vim.api.nvim_buf_get_name(0)
18 | local curFile = vim.fs.basename(curPath)
19 | local curFolder = vim.fs.dirname(curPath)
20 | local icon = direction == "next" and config.icons.nextFile or config.icons.prevFile
21 |
22 | -- get list of files
23 | local itemsInFolder = vim.fs.dir(curFolder) -- INFO `fs.dir` already returns them sorted
24 | local filesInFolder = vim.iter(itemsInFolder):fold({}, function(acc, name, type)
25 | local ext = name:match("%.(%w+)$")
26 | local curExt = curFile:match("%.(%w+)$")
27 |
28 | local ignored = (config.navigation.onlySameExtAsCurrentFile and ext ~= curExt)
29 | or vim.tbl_contains(config.navigation.ignoreExt, ext)
30 | or (config.navigation.ignoreDotfiles and vim.startswith(name, "."))
31 | or vim.tbl_contains(config.navigation.ignoreFilesWithName, name)
32 |
33 | if type == "file" and not ignored then
34 | table.insert(acc, name) -- select only name
35 | end
36 | return acc
37 | end)
38 |
39 | -- GUARD no files to navigate to
40 | if #filesInFolder == 0 then -- if currently at a hidden file and there are only hidden files in the dir
41 | notify("No valid files found in folder.", "warn", { icon = icon })
42 | return
43 | elseif #filesInFolder == 1 then
44 | notify("Already at the only valid file.", "warn", { icon = icon })
45 | return
46 | end
47 |
48 | -- determine next index
49 | local curIdx
50 | for idx = 1, #filesInFolder do
51 | if filesInFolder[idx] == curFile then
52 | curIdx = idx
53 | break
54 | end
55 | end
56 | if not curIdx then
57 | local msg = "Cannot determine next file, current file itself is excluded."
58 | notify(msg, "warn", { icon = icon })
59 | return
60 | end
61 | local nextIdx = curIdx + (direction == "next" and 1 or -1)
62 | if nextIdx < 1 then nextIdx = #filesInFolder end
63 | if nextIdx > #filesInFolder then nextIdx = 1 end
64 |
65 | -- goto file
66 | local nextFile = curFolder .. "/" .. filesInFolder[nextIdx]
67 | vim.cmd.edit(nextFile)
68 |
69 | -- notification
70 | if package.loaded["snacks"] then
71 | local msg = vim
72 | .iter(filesInFolder)
73 | :map(function(file)
74 | -- mark current, using markdown h1
75 | local prefix = file == filesInFolder[nextIdx] and "#" or "-"
76 | return prefix .. " " .. file
77 | end)
78 | :slice(nextIdx - 5, nextIdx + 5) -- display ~5 files before/after
79 | :join("\n")
80 | local title = direction:sub(1, 1):upper()
81 | .. direction:sub(2)
82 | .. " file"
83 | .. (" (%d/%d)"):format(nextIdx, #filesInFolder)
84 | vim.notify(msg, nil, {
85 | title = title,
86 | icon = icon,
87 | history = false,
88 | id = "next-in-folder", -- replace notifications when quickly cycling
89 | ft = "markdown", -- so `h1` is highlighted
90 | })
91 | end
92 | end
93 |
94 | --------------------------------------------------------------------------------
95 | return M
96 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # nvim-genghis ⚔️
2 |
3 |
4 |
5 | Lightweight and quick file operations without being a full-blown file manager.
6 | For when you prefer a fuzzy finder over a file tree, but still want some
7 | convenient file operations inside nvim.
8 |
9 |
10 |
11 | ## Table of contents
12 |
13 |
14 |
15 | - [Features](#features)
16 | - [Installation](#installation)
17 | - [Configuration](#configuration)
18 | - [UI Plugin](#ui-plugin)
19 | - [Usage](#usage)
20 | - [File operations](#file-operations)
21 | - [Copy operations](#copy-operations)
22 | - [File navigation](#file-navigation)
23 | - [Why the name "Genghis"?](#why-the-name-genghis)
24 | - [About the author](#about-the-author)
25 |
26 |
27 |
28 | ## Features
29 | **Commands**
30 | - Perform **common file operations**: moving, renaming, creating, deleting, or
31 | duplicating files.
32 | - **Copy** the path or name of the current file in various formats.
33 | - **Navigate** to the next or previous file in the current folder.
34 |
35 | **Quality-of-life**
36 | - All movement and renaming commands **update `import` statements** to the
37 | renamed file (if the LSP supports `workspace/willRenameFiles`).
38 | - Automatically keep the extension when no extension is given.
39 | - Use vim motions in the input field.
40 |
41 | ## Installation
42 | **Requirements**
43 | - nvim 0.10+
44 | - *For the trash command*: an OS-specific trash CLI like `trash` or `gio trash`.
45 | (Since macOS 14+, there is a `trash` CLI already built-in, so there is no need
46 | to install anything.)
47 | - **Recommended:** A provider for `vim.ui.input` and `vim.ui.select` such as
48 | [snacks.nvim](http://github.com/folke/snacks.nvim). This enables vim motions
49 | in the input field and looks nicer.
50 |
51 | ```lua
52 | -- lazy.nvim
53 | { "chrisgrieser/nvim-genghis" }
54 |
55 | -- packer
56 | use { "chrisgrieser/nvim-genghis" }
57 | ```
58 |
59 | ## Configuration
60 | The `.setup()` call is optional.
61 |
62 | ```lua
63 | -- default config
64 | require("genghis").setup {
65 | fileOperations = {
66 | -- automatically keep the extension when no file extension is given
67 | -- (everything after the first non-leading dot is treated as the extension)
68 | autoAddExt = true,
69 |
70 | trashCmd = function() ---@type fun(): string|string[]
71 | if jit.os == "OSX" then return "trash" end -- builtin since macOS 14
72 | if jit.os == "Windows" then return "trash" end
73 | if jit.os == "Linux" then return { "gio", "trash" } end
74 | return "trash-cli"
75 | end,
76 |
77 | ignoreInFolderSelection = { -- using lua pattern matching (e.g., escape `-` as `%-`)
78 | "/node_modules/", -- nodejs
79 | "/typings/", -- python
80 | "/doc/", -- vim help files folders
81 | "%.app/", -- macOS pseudo-folders
82 | "/%.", -- hidden folders
83 | },
84 | },
85 |
86 | navigation = {
87 | onlySameExtAsCurrentFile = false,
88 | ignoreDotfiles = true,
89 | ignoreExt = { "png", "svg", "webp", "jpg", "jpeg", "gif", "pdf", "zip" },
90 | ignoreFilesWithName = { ".DS_Store" },
91 | },
92 |
93 | successNotifications = true,
94 |
95 | icons = { -- set an icon to empty string to disable it
96 | chmodx = "",
97 | copyFile = "",
98 | copyPath = "",
99 | duplicate = "",
100 | file = "",
101 | move = "",
102 | new = "",
103 | nextFile = "",
104 | prevFile = "",
105 | rename = "",
106 | trash = "",
107 | },
108 | }
109 | ```
110 |
111 | ### UI plugin
112 | A UI plugin for `vim.ui.input` and `vim.ui.select`, such as
113 | [snacks.nvim](http://github.com/folke/snacks.nvim), is recommended since it
114 | enables for vim motions in the input field. (It also looks much nicer.)
115 |
116 | ```lua
117 | -- minimal snacks.nvim config to use it for `vim.ui.input` and `vim.ui.select`
118 | require("snacks").setup({
119 | input = { enabled = true },
120 | picker = { enabled = true },
121 | }),
122 | ```
123 |
124 | ## Usage
125 | You can access a command as Lua function:
126 |
127 | ```lua
128 | require("genghis").createNewFile()
129 | ```
130 |
131 | Or you can use the ex command `:Genghis` with the respective subcommand:
132 |
133 | ```vim
134 | :Genghis createNewFile
135 | ```
136 |
137 | ### File operations
138 | - `createNewFile`: Create a new file in the same directory as the current file.
139 | - `createNewFileInFolder`: Create a new file in a folder in the current working
140 | directory.
141 | - `duplicateFile`: Duplicate the current file.
142 | - `moveSelectionToNewFile`: Create a new file and move the current selection
143 | to it. (Visual Line command, the selection is moved linewise.)
144 | - `renameFile`: Rename the current file.
145 | - `moveToFolderInCwd`: Move the current file to an existing folder in the
146 | current working directory.
147 | - `moveAndRenameFile`: Move and rename the current file. Keeps the
148 | old name if the new path ends with `/`. Works like the UNIX `mv` command.
149 | - `chmodx`: Makes current file executable. Equivalent to `chmod +x`.
150 | - `trashFile`: Move the current file to the trash. (Defaults to `gio trash` on
151 | *Linux*, and `trash` on *macOS* or *Windows*.)
152 | - `showInSystemExplorer`: Reveals the current file in the system explorer, such
153 | as macOS Finder. (Currently only on macOS, PRs welcome.)
154 |
155 | The following applies to all commands above:
156 | 1. If no extension has been provided, uses the extension of the original file.
157 | (Everything after the first non-leading dot is treated as the extension; this
158 | behavior can be disabled with the config `fileOperations.autoAddExt =
159 | false`.)
160 | 2. If the new filename includes a `/`, the new file is placed in the respective
161 | subdirectory, creating any non-existing intermediate folders.
162 | 3. All movement and renaming commands update `import` statements, if the LSP
163 | supports `workspace/willRenameFiles`.
164 |
165 | ### Copy operations
166 | - `copyFilename`: Copy the filename.
167 | - `copyFilepath`: Copy the absolute filepath.
168 | - `copyFilepathWithTilde`: Copy the absolute filepath, replacing the home
169 | directory with `~`.
170 | - `copyRelativePath`: Copy the relative filepath.
171 | - `copyDirectoryPath`: Copy the absolute directory path.
172 | - `copyRelativeDirectoryPath`: Copy the relative directory path.
173 | - `copyFileItself`: Copies the file itself. This means you can paste it into
174 | the browser or file manager. (Currently only on macOS, PRs welcome.)
175 |
176 | All commands use the system clipboard.
177 |
178 | ### File navigation
179 | `require("genghis").navigateToFileInFolder("next"|"prev")`: Move to the
180 | next/previous file in the current folder of the current file, in alphabetical
181 | order.
182 |
183 | If `snacks.nvim` is installed, displays a cycling notification.
184 |
185 | ## Why the name "Genghis"?
186 | A nod to [vim.eunuch](https://github.com/tpope/vim-eunuch), an older vimscript
187 | plugin with a similar goal. As opposed to childless eunuchs, it is said that
188 | Genghis Khan [has fathered thousands of
189 | children](https://allthatsinteresting.com/genghis-khan-children).
190 |
191 | ## About the author
192 | In my day job, I am a sociologist studying the social mechanisms underlying the
193 | digital economy. For my PhD project, I investigate the governance of the app
194 | economy and how software ecosystems manage the tension between innovation and
195 | compatibility. If you are interested in this subject, feel free to get in touch.
196 |
197 | - [Website](https://chris-grieser.de/)
198 | - [Mastodon](https://pkm.social/@pseudometa)
199 | - [ResearchGate](https://www.researchgate.net/profile/Christopher-Grieser)
200 | - [LinkedIn](https://www.linkedin.com/in/christopher-grieser-ba693b17a/)
201 |
202 |
205 |
--------------------------------------------------------------------------------
/doc/nvim-genghis.txt:
--------------------------------------------------------------------------------
1 | *nvim-genghis.txt* For Neovim Last change: 2025 December 19
2 |
3 | ==============================================================================
4 | Table of Contents *nvim-genghis-table-of-contents*
5 |
6 | 1. nvim-genghis |nvim-genghis-nvim-genghis-|
7 | - Table of contents |nvim-genghis-nvim-genghis--table-of-contents|
8 | - Features |nvim-genghis-nvim-genghis--features|
9 | - Installation |nvim-genghis-nvim-genghis--installation|
10 | - Configuration |nvim-genghis-nvim-genghis--configuration|
11 | - Usage |nvim-genghis-nvim-genghis--usage|
12 | - Why the name “Genghis”?|nvim-genghis-nvim-genghis--why-the-name-“genghis”?|
13 | - About the author |nvim-genghis-nvim-genghis--about-the-author|
14 |
15 | ==============================================================================
16 | 1. nvim-genghis *nvim-genghis-nvim-genghis-*
17 |
18 |
19 |
20 | Lightweight and quick file operations without being a full-blown file manager.
21 | For when you prefer a fuzzy finder over a file tree, but still want some
22 | convenient file operations inside nvim.
23 |
24 |
25 |
26 |
27 | TABLE OF CONTENTS *nvim-genghis-nvim-genghis--table-of-contents*
28 |
29 | - |nvim-genghis-features|
30 | - |nvim-genghis-installation|
31 | - |nvim-genghis-configuration|
32 | - |nvim-genghis-ui-plugin|
33 | - |nvim-genghis-usage|
34 | - |nvim-genghis-file-operations|
35 | - |nvim-genghis-copy-operations|
36 | - |nvim-genghis-file-navigation|
37 | - |nvim-genghis-why-the-name-"genghis"?|
38 | - |nvim-genghis-about-the-author|
39 |
40 |
41 | FEATURES *nvim-genghis-nvim-genghis--features*
42 |
43 | **Commands** - Perform **common file operations**: moving, renaming, creating,
44 | deleting, or duplicating files. - **Copy** the path or name of the current file
45 | in various formats. - **Navigate** to the next or previous file in the current
46 | folder.
47 |
48 | **Quality-of-life** - All movement and renaming commands **update import
49 | statements** to the renamed file (if the LSP supports
50 | `workspace/willRenameFiles`). - Automatically keep the extension when no
51 | extension is given. - Use vim motions in the input field.
52 |
53 |
54 | INSTALLATION *nvim-genghis-nvim-genghis--installation*
55 |
56 | **Requirements** - nvim 0.10+ - _For the trash command_: an OS-specific trash
57 | CLI like `trash` or `gio trash`. (Since macOS 14+, there is a `trash` CLI
58 | already built-in, so there is no need to install anything.) - **Recommended:**
59 | A provider for `vim.ui.input` and `vim.ui.select` such as snacks.nvim
60 | . This enables vim motions in the input
61 | field and looks nicer.
62 |
63 | >lua
64 | -- lazy.nvim
65 | { "chrisgrieser/nvim-genghis" }
66 |
67 | -- packer
68 | use { "chrisgrieser/nvim-genghis" }
69 | <
70 |
71 |
72 | CONFIGURATION *nvim-genghis-nvim-genghis--configuration*
73 |
74 | The `.setup()` call is optional.
75 |
76 | >lua
77 | -- default config
78 | require("genghis").setup {
79 | fileOperations = {
80 | -- automatically keep the extension when no file extension is given
81 | -- (everything after the first non-leading dot is treated as the extension)
82 | autoAddExt = true,
83 |
84 | trashCmd = function() ---@type fun(): string|string[]
85 | if jit.os == "OSX" then return "trash" end -- builtin since macOS 14
86 | if jit.os == "Windows" then return "trash" end
87 | if jit.os == "Linux" then return { "gio", "trash" } end
88 | return "trash-cli"
89 | end,
90 |
91 | ignoreInFolderSelection = { -- using lua pattern matching (e.g., escape `-` as `%-`)
92 | "/node_modules/", -- nodejs
93 | "/typings/", -- python
94 | "/doc/", -- vim help files folders
95 | "%.app/", -- macOS pseudo-folders
96 | "/%.", -- hidden folders
97 | },
98 | },
99 |
100 | navigation = {
101 | onlySameExtAsCurrentFile = false,
102 | ignoreDotfiles = true,
103 | ignoreExt = { "png", "svg", "webp", "jpg", "jpeg", "gif", "pdf", "zip" },
104 | ignoreFilesWithName = { ".DS_Store" },
105 | },
106 |
107 | successNotifications = true,
108 |
109 | icons = { -- set an icon to empty string to disable it
110 | chmodx = "",
111 | copyFile = "",
112 | copyPath = "",
113 | duplicate = "",
114 | file = "",
115 | move = "",
116 | new = "",
117 | nextFile = "",
118 | prevFile = "",
119 | rename = "",
120 | trash = "",
121 | },
122 | }
123 | <
124 |
125 |
126 | UI PLUGIN ~
127 |
128 | A UI plugin for `vim.ui.input` and `vim.ui.select`, such as snacks.nvim
129 | , is recommended since it enables for vim
130 | motions in the input field. (It also looks much nicer.)
131 |
132 | >lua
133 | -- minimal snacks.nvim config to use it for `vim.ui.input` and `vim.ui.select`
134 | require("snacks").setup({
135 | input = { enabled = true },
136 | picker = { enabled = true },
137 | }),
138 | <
139 |
140 |
141 | USAGE *nvim-genghis-nvim-genghis--usage*
142 |
143 | You can access a command as Lua function:
144 |
145 | >lua
146 | require("genghis").createNewFile()
147 | <
148 |
149 | Or you can use the ex command `:Genghis` with the respective subcommand:
150 |
151 | >vim
152 | :Genghis createNewFile
153 | <
154 |
155 |
156 | FILE OPERATIONS ~
157 |
158 | - `createNewFile`: Create a new file in the same directory as the current file.
159 | - `createNewFileInFolder`: Create a new file in a folder in the current working
160 | directory.
161 | - `duplicateFile`: Duplicate the current file.
162 | - `moveSelectionToNewFile`: Create a new file and move the current selection
163 | to it. (Visual Line command, the selection is moved linewise.)
164 | - `renameFile`: Rename the current file.
165 | - `moveToFolderInCwd`: Move the current file to an existing folder in the
166 | current working directory.
167 | - `moveAndRenameFile`: Move and rename the current file. Keeps the
168 | old name if the new path ends with `/`. Works like the UNIX `mv` command.
169 | - `chmodx`: Makes current file executable. Equivalent to `chmod +x`.
170 | - `trashFile`: Move the current file to the trash. (Defaults to `gio trash` on
171 | _Linux_, and `trash` on _macOS_ or _Windows_.)
172 | - `showInSystemExplorer`: Reveals the current file in the system explorer, such
173 | as macOS Finder. (Currently only on macOS, PRs welcome.)
174 |
175 | The following applies to all commands above: 1. If no extension has been
176 | provided, uses the extension of the original file. (Everything after the first
177 | non-leading dot is treated as the extension; this behavior can be disabled with
178 | the config `fileOperations.autoAddExt = false`.) 2. If the new filename
179 | includes a `/`, the new file is placed in the respective subdirectory, creating
180 | any non-existing intermediate folders. 3. All movement and renaming commands
181 | update `import` statements, if the LSP supports `workspace/willRenameFiles`.
182 |
183 |
184 | COPY OPERATIONS ~
185 |
186 | - `copyFilename`: Copy the filename.
187 | - `copyFilepath`: Copy the absolute filepath.
188 | - `copyFilepathWithTilde`: Copy the absolute filepath, replacing the home
189 | directory with `~`.
190 | - `copyRelativePath`: Copy the relative filepath.
191 | - `copyDirectoryPath`: Copy the absolute directory path.
192 | - `copyRelativeDirectoryPath`: Copy the relative directory path.
193 | - `copyFileItself`: Copies the file itself. This means you can paste it into
194 | the browser or file manager. (Currently only on macOS, PRs welcome.)
195 |
196 | All commands use the system clipboard.
197 |
198 |
199 | FILE NAVIGATION ~
200 |
201 | `require("genghis").navigateToFileInFolder("next"|"prev")`: Move to the
202 | next/previous file in the current folder of the current file, in alphabetical
203 | order.
204 |
205 | If `snacks.nvim` is installed, displays a cycling notification.
206 |
207 |
208 | WHY THE NAME “GENGHIS”?*nvim-genghis-nvim-genghis--why-the-name-“genghis”?*
209 |
210 | A nod to vim.eunuch , an older vimscript
211 | plugin with a similar goal. As opposed to childless eunuchs, it is said that
212 | Genghis Khan has fathered thousands of children
213 | .
214 |
215 |
216 | ABOUT THE AUTHOR *nvim-genghis-nvim-genghis--about-the-author*
217 |
218 | In my day job, I am a sociologist studying the social mechanisms underlying the
219 | digital economy. For my PhD project, I investigate the governance of the app
220 | economy and how software ecosystems manage the tension between innovation and
221 | compatibility. If you are interested in this subject, feel free to get in
222 | touch.
223 |
224 | - Website
225 | - Mastodon
226 | - ResearchGate
227 | - LinkedIn
228 |
229 |
230 |
231 | Generated by panvimdoc
232 |
233 | vim:tw=78:ts=8:noet:ft=help:norl:
234 |
--------------------------------------------------------------------------------
/lua/genghis/operations/file.lua:
--------------------------------------------------------------------------------
1 | local M = {}
2 | --------------------------------------------------------------------------------
3 |
4 | ---@param op "rename"|"duplicate"|"new"|"new-from-selection"|"move-rename"
5 | ---@param targetDir? string
6 | local function fileOp(op, targetDir)
7 | local moveConsideringPartition = require("genghis.support.move-considering-partition")
8 | local notify = require("genghis.support.notify")
9 | local lspRename = require("genghis.support.lsp-rename")
10 |
11 | -- PARAMETERS
12 | local origBufNr = vim.api.nvim_get_current_buf()
13 | local oldFilePath = vim.api.nvim_buf_get_name(0)
14 | local oldName = vim.fs.basename(oldFilePath)
15 | local pathSep = package.config:sub(1, 1)
16 | if not targetDir then targetDir = vim.fs.dirname(oldFilePath) end
17 |
18 | -- * non-greedy 1st capture, so 2nd capture matches double-extensions (see #60)
19 | -- * 1st capture requires at least one char, to not match empty string for dotfiles
20 | local oldNameNoExt, oldExt = oldName:match("(..-)(%.[%w.]*)")
21 | -- handle files without extension
22 | if not oldNameNoExt then oldNameNoExt = oldName end
23 | if not oldExt then oldExt = "" end
24 |
25 | local autoAddExt = require("genghis.config").config.fileOperations.autoAddExt
26 | local icons = require("genghis.config").config.icons
27 | local lspSupportsRenaming = lspRename.supported()
28 |
29 | -- PREPARE
30 | local prompt, prefill
31 | if op == "duplicate" then
32 | prompt = icons.duplicate .. " Duplicate file as: "
33 | prefill = (autoAddExt and oldNameNoExt or oldName) .. "-1"
34 | elseif op == "rename" then
35 | local text = lspSupportsRenaming and "Rename file & update imports:" or "Rename file to:"
36 | prompt = icons.rename .. " " .. text
37 | prefill = autoAddExt and oldNameNoExt or oldName
38 | elseif op == "move-rename" then
39 | local text = lspSupportsRenaming and " Move and rename file & update imports:"
40 | or " Move & rename file to:"
41 | prompt = icons.rename .. " " .. text
42 | prefill = targetDir .. pathSep
43 | elseif op == "new" or op == "new-from-selection" then
44 | prompt = icons.new .. " Name for new file: "
45 | prefill = ""
46 | end
47 |
48 | -- INPUT
49 | vim.ui.input({
50 | prompt = vim.trim(prompt),
51 | default = prefill,
52 | }, function(newName)
53 | vim.cmd.redraw() -- clear message area from vim.ui.input prompt
54 | if not newName then return end -- input has been canceled
55 |
56 | if op == "move-rename" and vim.endswith(newName, pathSep) then -- user just provided a folder
57 | newName = newName .. oldName
58 | elseif (op == "new" or op == "new-from-selection") and newName == "" then
59 | newName = "Untitled"
60 | end
61 |
62 | -- GUARD validate filename
63 | local invalidName = newName:find("^%s+$")
64 | or newName:find(":")
65 | or (vim.startswith(newName, pathSep) and op ~= "move-rename")
66 | local sameName = newName == oldName
67 | local emptyInput = newName == ""
68 | if invalidName or sameName or emptyInput then
69 | if invalidName or emptyInput then
70 | notify("Invalid filename.", "error")
71 | elseif sameName then
72 | notify("Cannot use the same filename.", "warn")
73 | end
74 | return
75 | end
76 |
77 | -- DETERMINE PATH AND EXTENSION
78 | if newName:find(pathSep) then
79 | local newFolder = vim.fs.dirname(newName)
80 | local absFolder = op == "move-rename" and newFolder
81 | or vim.fs.joinpath(targetDir, newFolder)
82 | vim.fn.mkdir(absFolder, "p")
83 | end
84 |
85 | local userProvidedNoExt = newName:find(".%.[^/]*$") == nil -- non-leading dot to not include dotfiles without extension
86 | if userProvidedNoExt and autoAddExt then newName = newName .. oldExt end
87 |
88 | local newFilePath = op == "move-rename" and newName or vim.fs.joinpath(targetDir, newName)
89 | if vim.uv.fs_stat(newFilePath) ~= nil then
90 | notify(("File with name %q already exists."):format(newFilePath), "error")
91 | return
92 | end
93 |
94 | -- EXECUTE FILE OPERATION
95 | vim.cmd("silent! update")
96 | if op == "duplicate" then
97 | local success = vim.uv.fs_copyfile(oldFilePath, newFilePath)
98 | if success then
99 | vim.cmd.edit(newFilePath)
100 | local msg = ("Duplicated %q as %q."):format(oldName, newName)
101 | notify(msg, "info", { icon = icons.duplicate })
102 | end
103 | elseif op == "rename" or op == "move-rename" then
104 | lspRename.willRename(oldFilePath, newFilePath)
105 | local success = moveConsideringPartition(oldFilePath, newFilePath)
106 | if success then
107 | vim.cmd.edit(newFilePath)
108 | vim.api.nvim_buf_delete(origBufNr, { force = true })
109 | local msg = ("Renamed %q to %q."):format(oldName, newName)
110 | notify(msg, "info", { icon = icons.rename })
111 | if lspSupportsRenaming then vim.cmd.wall() end
112 | end
113 | elseif op == "new" then
114 | vim.cmd.edit(newFilePath)
115 | vim.cmd.write(newFilePath)
116 | elseif op == "new-from-selection" then
117 | local prevReg = vim.fn.getreg("z")
118 | vim.cmd.normal { vim.fn.mode(), bang = true } -- leave visual mode, so '<,'> are set
119 | vim.cmd([['<,'>delete z]])
120 |
121 | vim.cmd.edit(newFilePath)
122 | vim.cmd("put z") -- `vim.cmd.put("z")` does not work
123 | vim.fn.setreg("z", prevReg)
124 | vim.cmd.write(newFilePath)
125 | end
126 | end)
127 | end
128 |
129 | function M.renameFile() fileOp("rename") end
130 | function M.moveAndRenameFile() fileOp("move-rename") end
131 | function M.duplicateFile() fileOp("duplicate") end
132 | function M.createNewFile() fileOp("new") end
133 | function M.moveSelectionToNewFile() fileOp("new-from-selection") end
134 |
135 | --------------------------------------------------------------------------------
136 |
137 | ---@param op "move-file"|"new-in-folder"
138 | local function folderSelection(op)
139 | local moveConsideringPartition = require("genghis.support.move-considering-partition")
140 | local notify = require("genghis.support.notify")
141 | local lspRenaming = require("genghis.support.lsp-rename")
142 | local ignoreFolders = require("genghis.config").config.fileOperations.ignoreInFolderSelection
143 | local icons = require("genghis.config").config.icons
144 |
145 | -- PARAMETERS
146 | local oldAbsPath = vim.api.nvim_buf_get_name(0)
147 | local oldAbsParent = vim.fs.dirname(oldAbsPath)
148 | local filename = vim.fs.basename(oldAbsPath)
149 | local lspSupportsRenaming = lspRenaming.supported()
150 | local cwd = assert(vim.uv.cwd(), "Could not get current working directory.")
151 | local origBufNr = vim.api.nvim_get_current_buf()
152 |
153 | -- GET OTHER FOLDERS IN CWD
154 | local foldersInCwd = vim.fs.find(function(name, path)
155 | local absPath = vim.fs.joinpath(path, name)
156 | local relPath = absPath:sub(#cwd + 1) .. "/" -- not pathSep, since `joinpath` uses `/`
157 |
158 | local sameFolder = absPath == oldAbsParent
159 | local ignoredDir = vim.iter(ignoreFolders)
160 | :any(function(dir) return relPath:find(dir) ~= nil end)
161 |
162 | return not (ignoredDir or sameFolder)
163 | end, { type = "directory", limit = math.huge })
164 |
165 | -- ORDER OF FOLDERS
166 | table.sort(foldersInCwd, function(a, b)
167 | local aMtime = vim.uv.fs_stat(a).mtime.sec
168 | local bMtime = vim.uv.fs_stat(b).mtime.sec
169 | return aMtime > bMtime
170 | end)
171 | -- insert cwd at bottom, since moving to it unlikely
172 | if cwd ~= oldAbsParent then table.insert(foldersInCwd, cwd) end
173 | -- insert current dir at top, since moving to it likely
174 | if op == "new-in-folder" then table.insert(foldersInCwd, 1, oldAbsParent) end
175 |
176 | -- PROMPT & MOVE
177 | local prompt
178 | if op == "move-file" then
179 | prompt = icons.move .. " Move file to"
180 | if lspSupportsRenaming then prompt = prompt .. " (with updated imports)" end
181 | prompt = prompt .. ":"
182 | elseif op == "new-in-folder" then
183 | prompt = icons.new .. " Folder for new file:"
184 | end
185 | vim.ui.select(foldersInCwd, {
186 | prompt = prompt,
187 | kind = "genghis.select-folder",
188 | format_item = function(path)
189 | local relPath = path:sub(#cwd + 1)
190 | return (relPath == "" and "/" or relPath)
191 | end,
192 | }, function(newAbsParent)
193 | if not newAbsParent then return end
194 | local newRelParent = newAbsParent:sub(#cwd + 1)
195 | newRelParent = newRelParent == "" and "/" or newRelParent
196 |
197 | if op == "new-in-folder" then
198 | fileOp("new", newAbsParent)
199 | elseif op == "move-file" then
200 | local newAbsPath = vim.fs.joinpath(newAbsParent, filename)
201 | if vim.uv.fs_stat(newAbsPath) ~= nil then
202 | notify(("File %q already exists at %q."):format(filename, newRelParent), "error")
203 | return
204 | end
205 |
206 | vim.cmd("silent! update")
207 | lspRenaming.willRename(oldAbsPath, newAbsPath)
208 | local success = moveConsideringPartition(oldAbsPath, newAbsPath)
209 | if not success then return end
210 |
211 | vim.cmd.edit(newAbsPath)
212 | vim.api.nvim_buf_delete(origBufNr, { force = true })
213 | local msg = ("Moved %q to %q"):format(filename, newRelParent)
214 | local append = lspSupportsRenaming and " and updated imports." or "."
215 | notify(msg .. append, "info", { icon = icons.move })
216 | if lspSupportsRenaming then vim.cmd.wall() end
217 | end
218 | end)
219 | end
220 |
221 | function M.moveToFolderInCwd() folderSelection("move-file") end
222 | function M.createNewFileInFolder() folderSelection("new-in-folder") end
223 |
224 | --------------------------------------------------------------------------------
225 |
226 | function M.chmodx()
227 | local icons = require("genghis.config").config.icons
228 |
229 | local filepath = vim.api.nvim_buf_get_name(0)
230 | local perm = vim.fn.getfperm(filepath)
231 | perm = perm:gsub("r(.)%-", "r%1x") -- add x to every group that has r
232 | vim.fn.setfperm(filepath, perm)
233 |
234 | local notify = require("genghis.support.notify")
235 | notify("Permission +x granted.", "info", { icon = icons.chmodx })
236 | vim.cmd.edit() -- reload the file
237 | end
238 |
239 | function M.trashFile()
240 | vim.cmd("silent! update")
241 | local filepath = vim.api.nvim_buf_get_name(0)
242 | local filename = vim.fs.basename(filepath)
243 | local icon = require("genghis.config").config.icons.trash
244 | local trashCmd = require("genghis.config").config.fileOperations.trashCmd
245 |
246 | -- execute the trash command
247 | local cmd = trashCmd()
248 | if type(cmd) ~= "table" then cmd = { cmd } end
249 | table.insert(cmd, filepath)
250 | local out = vim.system(cmd):wait()
251 |
252 | -- handle the result
253 | local notify = require("genghis.support.notify")
254 | if out.code == 0 then
255 | vim.api.nvim_buf_delete(0, { force = true })
256 | notify(("%q moved to trash."):format(filename), "info", { icon = icon })
257 | else
258 | local outmsg = (out.stdout or "") .. (out.stderr or "")
259 | notify(("Trashing %q failed: %s"):format(filename, outmsg), "error")
260 | end
261 | end
262 |
263 | function M.showInSystemExplorer()
264 | local notify = require("genghis.support.notify")
265 | if jit.os ~= "OSX" then
266 | notify("Currently only available on macOS.", "warn")
267 | return
268 | end
269 |
270 | local out = vim.system({ "open", "-R", vim.api.nvim_buf_get_name(0) }):wait()
271 | if out.code ~= 0 then
272 | local icon = require("genghis.config").config.icons.file
273 | notify("Failed: " .. out.stderr, "error", { icon = icon })
274 | end
275 | end
276 | --------------------------------------------------------------------------------
277 | return M
278 |
--------------------------------------------------------------------------------