├── .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 | badge 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 | Showcase for renaming files 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 | Buy Me a Coffee at ko-fi.com 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 | --------------------------------------------------------------------------------