Repository: chrisgrieser/nvim-genghis Branch: main Commit: 12d62b0aaeb0 Files: 32 Total size: 46.7 KB Directory structure: gitextract_lchxmgi1/ ├── .editorconfig ├── .emmyrc.json ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── dependabot.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── nvim-type-check.yml │ ├── panvimdoc.yml │ ├── pr-title.yml │ ├── rumdl-lint.yml │ ├── stale-bot.yml │ └── stylua.yml ├── .gitignore ├── .harper-dictionary.txt ├── .ignore ├── .luarc.jsonc ├── .rumdl.toml ├── .stylua.toml ├── LICENSE ├── README.md ├── doc/ │ └── nvim-genghis.txt ├── lua/ │ └── genghis/ │ ├── config.lua │ ├── init.lua │ ├── operations/ │ │ ├── copy.lua │ │ ├── file.lua │ │ └── navigation.lua │ └── support/ │ ├── lsp-rename.lua │ ├── move-considering-partition.lua │ └── notify.lua └── plugin/ └── ex-commands.lua ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] max_line_length = 100 end_of_line = lf charset = utf-8 insert_final_newline = true indent_style = tab indent_size = 3 tab_width = 3 trim_trailing_whitespace = true [*.{yml,yaml,scm,cff}] indent_style = space indent_size = 2 tab_width = 2 [*.py] indent_style = space indent_size = 4 tab_width = 4 [*.md] indent_style = space indent_size = 4 trim_trailing_whitespace = false ================================================ FILE: .emmyrc.json ================================================ { "runtime": { "version": "LuaJIT", "requirePattern": ["lua/?.lua", "lua/?/init.lua"] }, "workspace": { "library": ["$VIMRUNTIME"] }, "$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json" } ================================================ FILE: .github/FUNDING.yml ================================================ # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository github: chrisgrieser ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: File a bug report title: "[Bug]: " labels: ["bug"] body: - type: textarea id: bug-description attributes: label: Bug Description description: A clear and concise description of the bug. validations: required: true - type: textarea id: screenshot attributes: label: Relevant Screenshot description: If applicable, add screenshots or a screen recording to help explain your problem. - type: textarea id: reproduction-steps attributes: label: To Reproduce description: Steps to reproduce the problem placeholder: | For example: 1. Go to '...' 2. Click on '...' 3. Scroll down to '...' - type: textarea id: version-info attributes: label: neovim version render: Text validations: required: true - type: checkboxes id: checklist attributes: label: Make sure you have done the following options: - label: I have updated to the latest version of the plugin. required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature request description: Suggest an idea title: "Feature Request: " labels: ["enhancement"] body: - type: textarea id: feature-requested attributes: label: Feature Requested description: A clear and concise description of the feature. validations: required: true - type: textarea id: screenshot attributes: label: Relevant Screenshot description: If applicable, add screenshots or a screen recording to help explain the request. - type: checkboxes id: checklist attributes: label: Checklist options: - label: The feature would be useful to more users than just me. required: true ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" commit-message: prefix: "chore(dependabot): " ================================================ FILE: .github/pull_request_template.md ================================================ ## Problem statement ## Proposed solution ## AI usage disclosure ## Checklist - [ ] Variable names follow `camelCase` convention. - [ ] All AI-generated code has been reviewed by a human. - [ ] The `README.md` has been updated for any new or modified functionality (the `.txt` file is auto-generated and does not need to be modified). ================================================ FILE: .github/workflows/nvim-type-check.yml ================================================ name: nvim type check on: push: branches: [main] paths: ["**.lua"] pull_request: paths: ["**.lua"] jobs: build: name: nvim type check runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: stevearc/nvim-typecheck-action@v2 ================================================ FILE: .github/workflows/panvimdoc.yml ================================================ name: panvimdoc on: push: branches: [main] paths: - README.md - .github/workflows/panvimdoc.yml workflow_dispatch: {} # allows manual execution permissions: contents: write #─────────────────────────────────────────────────────────────────────────────── jobs: docs: runs-on: ubuntu-latest name: README.md to vimdoc steps: - uses: actions/checkout@v6 - run: git pull # fix failure when multiple commits are pushed in succession - run: mkdir -p doc - name: panvimdoc uses: kdheepak/panvimdoc@main with: vimdoc: ${{ github.event.repository.name }} version: "Neovim" demojify: true treesitter: true - run: git pull - name: push changes uses: stefanzweifel/git-auto-commit-action@v7 with: commit_message: "chore: auto-generate vimdocs" branch: ${{ github.head_ref }} ================================================ FILE: .github/workflows/pr-title.yml ================================================ name: PR title on: pull_request_target: types: - opened - edited - synchronize - reopened - ready_for_review permissions: pull-requests: read jobs: semantic-pull-request: name: Check PR title runs-on: ubuntu-latest steps: - uses: amannn/action-semantic-pull-request@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: requireScope: false subjectPattern: ^(?![A-Z]).+$ # disallow title starting with capital types: | # add `improv` to the list of allowed types improv fix feat refactor build ci style test chore perf docs break revert ================================================ FILE: .github/workflows/rumdl-lint.yml ================================================ name: Markdown linting via rumdl on: push: branches: [main] paths: - "**/*.md" - ".github/workflows/rumdl-lint.yml" - ".rumdl.toml" pull_request: paths: - "**/*.md" jobs: rumdl: name: rumdl runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: rvben/rumdl@v0 with: report-type: annotations ================================================ FILE: .github/workflows/stale-bot.yml ================================================ name: Stale bot on: schedule: - cron: "18 04 * * 3" permissions: issues: write pull-requests: write jobs: stale: runs-on: ubuntu-latest steps: - name: Close stale issues uses: actions/stale@v10 with: repo-token: ${{ secrets.GITHUB_TOKEN }} # DOCS https://github.com/actions/stale#all-options days-before-stale: 180 days-before-close: 7 stale-issue-label: "Stale" stale-issue-message: | This issue has been automatically marked as stale. **If this issue is still affecting you, please leave any comment**, for example "bump", and it will be kept open. close-issue-message: | This issue has been closed due to inactivity, and will not be monitored. ================================================ FILE: .github/workflows/stylua.yml ================================================ name: Stylua check on: push: branches: [main] paths: ["**.lua"] pull_request: paths: ["**.lua"] jobs: stylua: name: Stylua runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: JohnnyMorganz/stylua-action@v5 with: token: ${{ secrets.GITHUB_TOKEN }} version: latest args: --check . ================================================ FILE: .gitignore ================================================ # help-tags auto-generated by lazy.nvim doc/tags ================================================ FILE: .harper-dictionary.txt ================================================ genghis vimscript ================================================ FILE: .ignore ================================================ # auto-generated by panvimdoc doc ================================================ FILE: .luarc.jsonc ================================================ { "runtime.version": "LuaJIT", "workspace.library": ["$VIMRUNTIME/lua"], // nvim-lua runtime "diagnostics": { "unusedLocalExclude": ["_*"], // allow `_varname` for unused variables "groupFileStatus": { "luadoc": "Any", // require stricter annotations "conventions": "Any" // disallow global variables } }, "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json" } ================================================ FILE: .rumdl.toml ================================================ # DOCS https://github.com/rvben/rumdl/blob/main/docs/global-settings.md [global] line-length = 80 disable = ["blanks-around-lists"] # rule of proximity # ------------------------------------------------------------------------------ [ul-style] style = "dash" # GitHub default & quicker to type [ul-indent] indent = 4 # consistent with .editorconfig [line-length] code-blocks = false reflow = true # enable auto-formatting [blanks-around-headings] lines-below = 0 # rule of proximity [ol-prefix] style = "ordered" [no-inline-html] allowed-elements = ["a", "img", "kbd"] # badges [emphasis-style] style = "asterisk" # better than underscore, since not considered a word-char [strong-style] style = "asterisk" [table-format] enabled = true # opt-in rule [heading-capitalization] enabled = true # opt-in rule style = "sentence_case" ignore-words = ["nvim", "Obsidian", "Alfred"] [toc-validation] enabled = true # opt-in rule # ------------------------------------------------------------------------------ [per-file-ignores] ".github/pull_request_template.md" = ["first-line-h1"] ================================================ FILE: .stylua.toml ================================================ # https://github.com/JohnnyMorganz/StyLua#options #─────────────────────────────────────────────────────────────────────────────── syntax = "LuaJIT" # needed to support `::labels::` column_width = 100 line_endings = "Unix" indent_type = "Tabs" indent_width = 3 quote_style = "AutoPreferDouble" call_parentheses = "NoSingleTable" collapse_simple_statement = "Always" sort_requires.enabled = true ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2022-2023 Christopher Grieser Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Nvim-genghis ⚔️ badge Lightweight and quick file operations without being a full-blown file manager. For when you prefer a fuzzy finder over a file tree, but still want some convenient file operations inside nvim. Showcase for renaming files ## Table of contents - [Features](#features) - [Installation](#installation) - [Configuration](#configuration) - [UI plugin](#ui-plugin) - [Usage](#usage) - [File operations](#file-operations) - [Copy operations](#copy-operations) - [File navigation](#file-navigation) - [Why the name "Genghis"?](#why-the-name-genghis) - [About the author](#about-the-author) ## Features **Commands** - Perform **common file operations**: moving, renaming, creating, deleting, or duplicating files. - **Copy** the path or name of the current file in various formats. - **Navigate** to the next or previous file in the current folder. **Quality-of-life** - All movement and renaming commands **update `import` statements** to the renamed file (if the LSP supports `workspace/willRenameFiles`). - Automatically keep the extension when no extension is given. - Use vim motions in the input field. ## Installation **Requirements** - nvim 0.10+ - *For the trash command*: an OS-specific trash CLI like `trash` or `gio trash`. (Since macOS 14+, there is a `trash` CLI already built-in, so there is no need to install anything.) - **Recommended:** A provider for `vim.ui.input` and `vim.ui.select` such as [snacks.nvim](http://github.com/folke/snacks.nvim). This enables vim motions in the input field and looks nicer. ```lua -- lazy.nvim { "chrisgrieser/nvim-genghis" } -- packer use { "chrisgrieser/nvim-genghis" } ``` ## Configuration The `.setup()` call is optional. ```lua -- default config require("genghis").setup { fileOperations = { -- automatically keep the extension when no file extension is given -- (everything after the first non-leading dot is treated as the extension) autoAddExt = true, trashCmd = function() ---@type fun(): string|string[] if jit.os == "OSX" then return "trash" end -- builtin since macOS 14 if jit.os == "Windows" then return "trash" end if jit.os == "Linux" then return { "gio", "trash" } end return "trash-cli" end, ignoreInFolderSelection = { -- using lua pattern matching (e.g., escape `-` as `%-`) "/node_modules/", -- nodejs "/typings/", -- python "/doc/", -- vim help files folders "%.app/", -- macOS pseudo-folders "/%.", -- hidden folders }, }, navigation = { onlySameExtAsCurrentFile = false, ignoreDotfiles = true, ignoreExt = { "png", "svg", "webp", "jpg", "jpeg", "gif", "pdf", "zip" }, ignoreFilesWithName = { ".DS_Store" }, }, successNotifications = true, icons = { -- set an icon to empty string to disable it chmodx = "󰒃", copyFile = "󱉥", copyPath = "󰅍", duplicate = "", file = "󰈔", move = "󰪹", new = "󰝒", nextFile = "󰖽", prevFile = "󰖿", rename = "󰑕", trash = "󰩹", }, } ``` ### UI plugin A UI plugin for `vim.ui.input` and `vim.ui.select`, such as [snacks.nvim](http://github.com/folke/snacks.nvim), is recommended since it enables for vim motions in the input field. (It also looks much nicer.) ```lua -- minimal snacks.nvim config to use it for `vim.ui.input` and `vim.ui.select` require("snacks").setup({ input = { enabled = true }, picker = { enabled = true }, }), ``` ## Usage You can access a command as Lua function: ```lua require("genghis").createNewFile() ``` Or you can use the ex command `:Genghis` with the respective subcommand: ```vim :Genghis createNewFile ``` ### File operations - `createNewFile`: Create a new file in the same directory as the current file. - `createNewFileInFolder`: Create a new file in a folder in the current working directory. - `duplicateFile`: Duplicate the current file. - `moveSelectionToNewFile`: Create a new file and move the current selection to it. (Visual Line command, the selection is moved linewise.) - `renameFile`: Rename the current file. - `moveToFolderInCwd`: Move the current file to an existing folder in the current working directory. - `moveAndRenameFile`: Move and rename the current file. Keeps the old name if the new path ends with `/`. Works like the UNIX `mv` command. - `chmodx`: Makes current file executable. Equivalent to `chmod +x`. - `trashFile`: Move the current file to the trash. (Defaults to `gio trash` on *Linux*, and `trash` on *macOS* or *Windows*.) - `showInSystemExplorer`: Reveals the current file in the system explorer, such as macOS Finder. (Currently only on macOS, PRs welcome.) The following applies to all commands above: 1. If no extension has been provided, uses the extension of the original file. (Everything after the first non-leading dot is treated as the extension; this behavior can be disabled with the config `fileOperations.autoAddExt = false`.) 2. If the new filename includes a `/`, the new file is placed in the respective subdirectory, creating any non-existing intermediate folders. 3. All movement and renaming commands update `import` statements, if the LSP supports `workspace/willRenameFiles`. ### Copy operations - `copyFilename`: Copy the filename. - `copyFilepath`: Copy the absolute filepath. - `copyFilepathWithTilde`: Copy the absolute filepath, replacing the home directory with `~`. - `copyRelativePath`: Copy the relative filepath. - `copyDirectoryPath`: Copy the absolute directory path. - `copyRelativeDirectoryPath`: Copy the relative directory path. - `copyFileItself`: Copies the file itself. This means you can paste it into the browser or file manager. (Currently only on macOS, PRs welcome.) All commands use the system clipboard. ### File navigation `require("genghis").navigateToFileInFolder("next"|"prev")`: Move to the next/previous file in the current folder of the current file, in alphabetical order. If `snacks.nvim` is installed, displays a cycling notification. ## Why the name "Genghis"? A nod to [vim.eunuch](https://github.com/tpope/vim-eunuch), an older vimscript plugin with a similar goal. As opposed to childless eunuchs, it is said that Genghis Khan [has fathered thousands of children](https://allthatsinteresting.com/genghis-khan-children). ## About the author In my day job, I am a sociologist studying the social mechanisms underlying the digital economy. For my PhD project, I investigate the governance of the app economy and how software ecosystems manage the tension between innovation and compatibility. If you are interested in this subject, feel free to get in touch. - [Website](https://chris-grieser.de/) - [Mastodon](https://pkm.social/@pseudometa) - [ResearchGate](https://www.researchgate.net/profile/Christopher-Grieser) - [LinkedIn](https://www.linkedin.com/in/christopher-grieser-ba693b17a/) If you find this project helpful, you can support me via [🩷 GitHub Sponsors](https://github.com/sponsors/chrisgrieser?frequency=one-time). ================================================ FILE: doc/nvim-genghis.txt ================================================ *nvim-genghis.txt* For Neovim Last change: 2026 February 06 ============================================================================== Table of Contents *nvim-genghis-table-of-contents* 1. Nvim-genghis |nvim-genghis-nvim-genghis-| - Table of contents |nvim-genghis-nvim-genghis--table-of-contents| - Features |nvim-genghis-nvim-genghis--features| - Installation |nvim-genghis-nvim-genghis--installation| - Configuration |nvim-genghis-nvim-genghis--configuration| - Usage |nvim-genghis-nvim-genghis--usage| - Why the name “Genghis”?|nvim-genghis-nvim-genghis--why-the-name-“genghis”?| - About the author |nvim-genghis-nvim-genghis--about-the-author| ============================================================================== 1. Nvim-genghis *nvim-genghis-nvim-genghis-* Lightweight and quick file operations without being a full-blown file manager. For when you prefer a fuzzy finder over a file tree, but still want some convenient file operations inside nvim. TABLE OF CONTENTS *nvim-genghis-nvim-genghis--table-of-contents* - |nvim-genghis-features| - |nvim-genghis-installation| - |nvim-genghis-configuration| - |nvim-genghis-ui-plugin| - |nvim-genghis-usage| - |nvim-genghis-file-operations| - |nvim-genghis-copy-operations| - |nvim-genghis-file-navigation| - |nvim-genghis-why-the-name-"genghis"?| - |nvim-genghis-about-the-author| FEATURES *nvim-genghis-nvim-genghis--features* **Commands** - Perform **common file operations**: moving, renaming, creating, deleting, or duplicating files. - **Copy** the path or name of the current file in various formats. - **Navigate** to the next or previous file in the current folder. **Quality-of-life** - All movement and renaming commands **update import statements** to the renamed file (if the LSP supports `workspace/willRenameFiles`). - Automatically keep the extension when no extension is given. - Use vim motions in the input field. INSTALLATION *nvim-genghis-nvim-genghis--installation* **Requirements** - nvim 0.10+ - _For the trash command_: an OS-specific trash CLI like `trash` or `gio trash`. (Since macOS 14+, there is a `trash` CLI already built-in, so there is no need to install anything.) - **Recommended:** A provider for `vim.ui.input` and `vim.ui.select` such as snacks.nvim . This enables vim motions in the input field and looks nicer. >lua -- lazy.nvim { "chrisgrieser/nvim-genghis" } -- packer use { "chrisgrieser/nvim-genghis" } < CONFIGURATION *nvim-genghis-nvim-genghis--configuration* The `.setup()` call is optional. >lua -- default config require("genghis").setup { fileOperations = { -- automatically keep the extension when no file extension is given -- (everything after the first non-leading dot is treated as the extension) autoAddExt = true, trashCmd = function() ---@type fun(): string|string[] if jit.os == "OSX" then return "trash" end -- builtin since macOS 14 if jit.os == "Windows" then return "trash" end if jit.os == "Linux" then return { "gio", "trash" } end return "trash-cli" end, ignoreInFolderSelection = { -- using lua pattern matching (e.g., escape `-` as `%-`) "/node_modules/", -- nodejs "/typings/", -- python "/doc/", -- vim help files folders "%.app/", -- macOS pseudo-folders "/%.", -- hidden folders }, }, navigation = { onlySameExtAsCurrentFile = false, ignoreDotfiles = true, ignoreExt = { "png", "svg", "webp", "jpg", "jpeg", "gif", "pdf", "zip" }, ignoreFilesWithName = { ".DS_Store" }, }, successNotifications = true, icons = { -- set an icon to empty string to disable it chmodx = "󰒃", copyFile = "󱉥", copyPath = "󰅍", duplicate = "", file = "󰈔", move = "󰪹", new = "󰝒", nextFile = "󰖽", prevFile = "󰖿", rename = "󰑕", trash = "󰩹", }, } < UI PLUGIN ~ A UI plugin for `vim.ui.input` and `vim.ui.select`, such as snacks.nvim , is recommended since it enables for vim motions in the input field. (It also looks much nicer.) >lua -- minimal snacks.nvim config to use it for `vim.ui.input` and `vim.ui.select` require("snacks").setup({ input = { enabled = true }, picker = { enabled = true }, }), < USAGE *nvim-genghis-nvim-genghis--usage* You can access a command as Lua function: >lua require("genghis").createNewFile() < Or you can use the ex command `:Genghis` with the respective subcommand: >vim :Genghis createNewFile < FILE OPERATIONS ~ - `createNewFile`: Create a new file in the same directory as the current file. - `createNewFileInFolder`: Create a new file in a folder in the current working directory. - `duplicateFile`: Duplicate the current file. - `moveSelectionToNewFile`: Create a new file and move the current selection to it. (Visual Line command, the selection is moved linewise.) - `renameFile`: Rename the current file. - `moveToFolderInCwd`: Move the current file to an existing folder in the current working directory. - `moveAndRenameFile`: Move and rename the current file. Keeps the old name if the new path ends with `/`. Works like the UNIX `mv` command. - `chmodx`: Makes current file executable. Equivalent to `chmod +x`. - `trashFile`: Move the current file to the trash. (Defaults to `gio trash` on _Linux_, and `trash` on _macOS_ or _Windows_.) - `showInSystemExplorer`: Reveals the current file in the system explorer, such as macOS Finder. (Currently only on macOS, PRs welcome.) The following applies to all commands above: 1. If no extension has been provided, uses the extension of the original file. (Everything after the first non-leading dot is treated as the extension; this behavior can be disabled with the config `fileOperations.autoAddExt = false`.) 2. If the new filename includes a `/`, the new file is placed in the respective subdirectory, creating any non-existing intermediate folders. 3. All movement and renaming commands update `import` statements, if the LSP supports `workspace/willRenameFiles`. COPY OPERATIONS ~ - `copyFilename`: Copy the filename. - `copyFilepath`: Copy the absolute filepath. - `copyFilepathWithTilde`: Copy the absolute filepath, replacing the home directory with `~`. - `copyRelativePath`: Copy the relative filepath. - `copyDirectoryPath`: Copy the absolute directory path. - `copyRelativeDirectoryPath`: Copy the relative directory path. - `copyFileItself`: Copies the file itself. This means you can paste it into the browser or file manager. (Currently only on macOS, PRs welcome.) All commands use the system clipboard. FILE NAVIGATION ~ `require("genghis").navigateToFileInFolder("next"|"prev")`: Move to the next/previous file in the current folder of the current file, in alphabetical order. If `snacks.nvim` is installed, displays a cycling notification. WHY THE NAME “GENGHIS”?*nvim-genghis-nvim-genghis--why-the-name-“genghis”?* A nod to vim.eunuch , an older vimscript plugin with a similar goal. As opposed to childless eunuchs, it is said that Genghis Khan has fathered thousands of children . ABOUT THE AUTHOR *nvim-genghis-nvim-genghis--about-the-author* In my day job, I am a sociologist studying the social mechanisms underlying the digital economy. For my PhD project, I investigate the governance of the app economy and how software ecosystems manage the tension between innovation and compatibility. If you are interested in this subject, feel free to get in touch. - Website - Mastodon - ResearchGate - LinkedIn If you find this project helpful, you can support me via GitHub Sponsors . Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: ================================================ FILE: lua/genghis/config.lua ================================================ local M = {} -------------------------------------------------------------------------------- ---@class Genghis.config local defaultConfig = { fileOperations = { -- automatically keep the extension when no file extension is given -- (everything after the first non-leading dot is treated as the extension) autoAddExt = true, trashCmd = function() ---@type fun(): string|string[] if jit.os == "OSX" then return "trash" end -- builtin since macOS 14 if jit.os == "Windows" then return "trash" end if jit.os == "Linux" then return { "gio", "trash" } end return "trash-cli" end, ignoreInFolderSelection = { -- using lua pattern matching (e.g., escape `-` as `%-`) "/node_modules/", -- nodejs "/typings/", -- python "/doc/", -- vim help files folders "%.app/", -- macOS pseudo-folders "/%.", -- hidden folders }, }, navigation = { onlySameExtAsCurrentFile = false, ignoreDotfiles = true, ignoreExt = { "png", "svg", "webp", "jpg", "jpeg", "gif", "pdf", "zip" }, ignoreFilesWithName = { ".DS_Store" }, }, successNotifications = true, icons = { -- set an icon to empty string to disable it chmodx = "󰒃", copyFile = "󱉥", copyPath = "󰅍", duplicate = "", file = "󰈔", move = "󰪹", new = "󰝒", nextFile = "󰖽", prevFile = "󰖿", rename = "󰑕", trash = "󰩹", }, } M.config = defaultConfig ---@param userConfig? Genghis.config function M.setup(userConfig) M.config = vim.tbl_deep_extend("force", defaultConfig, userConfig or {}) -- DEPRECATION (2025-11-24) ---@diagnostic disable: undefined-field if M.config.trashCmd then M.config.fileOperations.trashCmd = M.config.trashCmd local notify = require("genghis.support.notify") notify("config `.trashCmd` is deprecated, use `.fileOperations.trashCmd` instead.", "warn") end ---@diagnostic enable: undefined-field end -------------------------------------------------------------------------------- return M ================================================ FILE: lua/genghis/init.lua ================================================ local version = vim.version() if version.major == 0 and version.minor < 10 then vim.notify("nvim-genghis requires at least nvim 0.10.", vim.log.levels.WARN) return end -------------------------------------------------------------------------------- local M = {} ---@param userConfig? Genghis.config function M.setup(userConfig) require("genghis.config").setup(userConfig) end ---@param direction? "next"|"prev" function M.navigateToFileInFolder(direction) require("genghis.operations.navigation").fileInFolder(direction) end -- redirect calls to this module to the respective submodules setmetatable(M, { __index = function(_, key) return function(...) local fileOps = vim.tbl_keys(require("genghis.operations.file")) local copyOps = vim.tbl_keys(require("genghis.operations.copy")) local module if vim.tbl_contains(fileOps, key) then module = "file" end if vim.tbl_contains(copyOps, key) then module = "copy" end if module then require("genghis.operations." .. module)[key](...) else local notify = require("genghis.support.notify") local msg = ("There is no operation called `%s`.\n\n"):format(key) .. "Make sure it exists in the list of operations, and that you haven't misspelled it." notify(msg, "error", { ft = "markdown" }) end end end, }) -------------------------------------------------------------------------------- return M ================================================ FILE: lua/genghis/operations/copy.lua ================================================ local M = {} -------------------------------------------------------------------------------- ---@param expandOperation string local function copyOp(expandOperation) local icon = require("genghis.config").config.icons.copyPath local register = "+" local toCopy = vim.fn.expand(expandOperation) vim.fn.setreg(register, toCopy) local notify = require("genghis.support.notify") notify(toCopy, "info", { title = "Copied", icon = icon }) end -- DOCS for the modifiers -- https://neovim.io/doc/user/builtin.html#expand() -- https://neovim.io/doc/user/cmdline.html#filename-modifiers function M.copyFilepath() copyOp("%:p") end function M.copyFilepathWithTilde() copyOp("%:~") end function M.copyFilename() copyOp("%:t") end function M.copyRelativePath() copyOp("%:.") end function M.copyDirectoryPath() copyOp("%:p:h") end function M.copyRelativeDirectoryPath() copyOp("%:.:h") end function M.copyFileItself() local notify = require("genghis.support.notify") if jit.os ~= "OSX" then notify("Currently only available on macOS.", "warn") return end local icon = require("genghis.config").config.icons.copyFile local path = vim.api.nvim_buf_get_name(0) local applescript = 'tell application "Finder" to set the clipboard to ' .. ("POSIX file %q"):format(path) vim.system({ "osascript", "-e", applescript }, {}, function(out) if out.code ~= 0 then notify("Failed to copy file: " .. out.stderr, "error", { title = "Copy file" }) else notify(vim.fs.basename(path), "info", { title = "Copied file", icon = icon }) end end) end -------------------------------------------------------------------------------- return M ================================================ FILE: lua/genghis/operations/file.lua ================================================ local M = {} -------------------------------------------------------------------------------- ---@param op "rename"|"duplicate"|"new"|"new-from-selection"|"move-rename" ---@param targetDir? string local function fileOp(op, targetDir) local moveConsideringPartition = require("genghis.support.move-considering-partition") local notify = require("genghis.support.notify") local lspRename = require("genghis.support.lsp-rename") -- PARAMETERS local origBufNr = vim.api.nvim_get_current_buf() local oldFilePath = vim.api.nvim_buf_get_name(0) local oldName = vim.fs.basename(oldFilePath) local pathSep = package.config:sub(1, 1) if not targetDir then targetDir = vim.fs.dirname(oldFilePath) end -- * non-greedy 1st capture, so 2nd capture matches double-extensions (see #60) -- * 1st capture requires at least one char, to not match empty string for dotfiles local oldNameNoExt, oldExt = oldName:match("(..-)(%.[%w.]*)") -- handle files without extension if not oldNameNoExt then oldNameNoExt = oldName end if not oldExt then oldExt = "" end local autoAddExt = require("genghis.config").config.fileOperations.autoAddExt local icons = require("genghis.config").config.icons local lspSupportsRenaming = lspRename.supported() -- PREPARE local prompt, prefill if op == "duplicate" then prompt = icons.duplicate .. " Duplicate file as: " prefill = (autoAddExt and oldNameNoExt or oldName) .. "-1" elseif op == "rename" then local text = lspSupportsRenaming and "Rename file & update imports:" or "Rename file to:" prompt = icons.rename .. " " .. text prefill = autoAddExt and oldNameNoExt or oldName elseif op == "move-rename" then local text = lspSupportsRenaming and " Move and rename file & update imports:" or " Move & rename file to:" prompt = icons.rename .. " " .. text prefill = targetDir .. pathSep elseif op == "new" or op == "new-from-selection" then prompt = icons.new .. " Name for new file: " prefill = "" end -- INPUT vim.ui.input({ prompt = vim.trim(prompt), default = prefill, }, function(newName) vim.cmd.redraw() -- clear message area from vim.ui.input prompt if not newName then return end -- input has been canceled if op == "move-rename" and vim.endswith(newName, pathSep) then -- user just provided a folder newName = newName .. oldName elseif (op == "new" or op == "new-from-selection") and newName == "" then newName = "Untitled" end -- GUARD validate filename local invalidName = newName:find("^%s+$") or newName:find(":") or (vim.startswith(newName, pathSep) and op ~= "move-rename") local sameName = newName == oldName local emptyInput = newName == "" if invalidName or sameName or emptyInput then if invalidName or emptyInput then notify("Invalid filename.", "error") elseif sameName then notify("Cannot use the same filename.", "warn") end return end -- DETERMINE PATH AND EXTENSION if newName:find(pathSep) then local newFolder = vim.fs.dirname(newName) local absFolder = op == "move-rename" and newFolder or vim.fs.joinpath(targetDir, newFolder) vim.fn.mkdir(absFolder, "p") end local userProvidedNoExt = newName:find(".%.[^/]*$") == nil -- non-leading dot to not include dotfiles without extension if userProvidedNoExt and autoAddExt then newName = newName .. oldExt end local newFilePath = op == "move-rename" and newName or vim.fs.joinpath(targetDir, newName) if vim.uv.fs_stat(newFilePath) ~= nil then notify(("File with name %q already exists."):format(newFilePath), "error") return end -- EXECUTE FILE OPERATION vim.cmd("silent! update") if op == "duplicate" then local success = vim.uv.fs_copyfile(oldFilePath, newFilePath) if not success then return end vim.cmd.edit(newFilePath) vim.cmd("silent! write") local msg = ("Duplicated %q as %q."):format(oldName, newName) notify(msg, "info", { icon = icons.duplicate }) elseif op == "rename" or op == "move-rename" then lspRename.willRename(oldFilePath, newFilePath) local success = moveConsideringPartition(oldFilePath, newFilePath) if not success then return end vim.cmd.edit(newFilePath) vim.api.nvim_buf_delete(origBufNr, { force = true }) local msg = ("Renamed %q to %q."):format(oldName, newName) notify(msg, "info", { icon = icons.rename }) vim.cmd(lspSupportsRenaming and "wall" or "silent! write") elseif op == "new" then vim.cmd.edit(newFilePath) vim.cmd.write(newFilePath) elseif op == "new-from-selection" then local prevReg = vim.fn.getreg("z") vim.cmd([['<,'>delete z]]) -- will have already left visual for input, so '<,'> are set vim.cmd.edit(newFilePath) vim.cmd("put z") -- `vim.cmd.put("z")` does not work vim.fn.setreg("z", prevReg) vim.cmd.write(newFilePath) end end) end function M.renameFile() fileOp("rename") end function M.moveAndRenameFile() fileOp("move-rename") end function M.duplicateFile() fileOp("duplicate") end function M.createNewFile() fileOp("new") end function M.moveSelectionToNewFile() fileOp("new-from-selection") end -------------------------------------------------------------------------------- ---@param op "move-file"|"new-in-folder" local function folderSelection(op) local moveConsideringPartition = require("genghis.support.move-considering-partition") local notify = require("genghis.support.notify") local lspRenaming = require("genghis.support.lsp-rename") local ignoreFolders = require("genghis.config").config.fileOperations.ignoreInFolderSelection local icons = require("genghis.config").config.icons -- PARAMETERS local oldAbsPath = vim.api.nvim_buf_get_name(0) local oldAbsParent = vim.fs.dirname(oldAbsPath) local filename = vim.fs.basename(oldAbsPath) local lspSupportsRenaming = lspRenaming.supported() local cwd = assert(vim.uv.cwd(), "Could not get current working directory.") local origBufNr = vim.api.nvim_get_current_buf() -- GET OTHER FOLDERS IN CWD local foldersInCwd = vim.fs.find(function(name, path) local absPath = vim.fs.joinpath(path, name) local relPath = absPath:sub(#cwd + 1) .. "/" -- not pathSep, since `joinpath` uses `/` local sameFolder = absPath == oldAbsParent local ignoredDir = vim.iter(ignoreFolders) :any(function(dir) return relPath:find(dir) ~= nil end) return not (ignoredDir or sameFolder) end, { type = "directory", limit = math.huge }) -- ORDER OF FOLDERS table.sort(foldersInCwd, function(a, b) local aMtime = vim.uv.fs_stat(a).mtime.sec local bMtime = vim.uv.fs_stat(b).mtime.sec return aMtime > bMtime end) -- insert cwd at bottom, since moving to it unlikely if cwd ~= oldAbsParent then table.insert(foldersInCwd, cwd) end -- insert current dir at top, since moving to it likely if op == "new-in-folder" then table.insert(foldersInCwd, 1, oldAbsParent) end -- PROMPT & MOVE local prompt if op == "move-file" then prompt = icons.move .. " Move file to" if lspSupportsRenaming then prompt = prompt .. " (with updated imports)" end prompt = prompt .. ":" elseif op == "new-in-folder" then prompt = icons.new .. " Folder for new file:" end vim.ui.select(foldersInCwd, { prompt = prompt, kind = "genghis.select-folder", format_item = function(path) local relPath = path:sub(#cwd + 1) return (relPath == "" and "/" or relPath) end, }, function(newAbsParent) if not newAbsParent then return end local newRelParent = newAbsParent:sub(#cwd + 1) newRelParent = newRelParent == "" and "/" or newRelParent if op == "new-in-folder" then fileOp("new", newAbsParent) elseif op == "move-file" then local newAbsPath = vim.fs.joinpath(newAbsParent, filename) if vim.uv.fs_stat(newAbsPath) ~= nil then notify(("File %q already exists at %q."):format(filename, newRelParent), "error") return end vim.cmd("silent! update") lspRenaming.willRename(oldAbsPath, newAbsPath) local success = moveConsideringPartition(oldAbsPath, newAbsPath) if not success then return end vim.cmd.edit(newAbsPath) vim.api.nvim_buf_delete(origBufNr, { force = true }) local msg = ("Moved %q to %q"):format(filename, newRelParent) local append = lspSupportsRenaming and " and updated imports." or "." notify(msg .. append, "info", { icon = icons.move }) vim.cmd(lspSupportsRenaming and "wall" or "silent! write") end end) end function M.moveToFolderInCwd() folderSelection("move-file") end function M.createNewFileInFolder() folderSelection("new-in-folder") end -------------------------------------------------------------------------------- function M.chmodx() local icons = require("genghis.config").config.icons local filepath = vim.api.nvim_buf_get_name(0) local perm = vim.fn.getfperm(filepath) perm = perm:gsub("r(.)%-", "r%1x") -- add x to every group that has r vim.fn.setfperm(filepath, perm) local notify = require("genghis.support.notify") notify("Permission +x granted.", "info", { icon = icons.chmodx }) vim.cmd.edit() -- reload the file end function M.trashFile() vim.cmd("silent! update") local filepath = vim.api.nvim_buf_get_name(0) local filename = vim.fs.basename(filepath) local icon = require("genghis.config").config.icons.trash local trashCmd = require("genghis.config").config.fileOperations.trashCmd -- execute the trash command local cmd = trashCmd() if type(cmd) ~= "table" then cmd = { cmd } end table.insert(cmd, filepath) local out = vim.system(cmd):wait() -- handle the result local notify = require("genghis.support.notify") if out.code == 0 then vim.api.nvim_buf_delete(0, { force = true }) notify(("%q moved to trash."):format(filename), "info", { icon = icon }) else local outmsg = (out.stdout or "") .. (out.stderr or "") notify(("Trashing %q failed: %s"):format(filename, outmsg), "error") end end function M.showInSystemExplorer() local notify = require("genghis.support.notify") if jit.os ~= "OSX" then notify("Currently only available on macOS.", "warn") return end local out = vim.system({ "open", "-R", vim.api.nvim_buf_get_name(0) }):wait() if out.code ~= 0 then local icon = require("genghis.config").config.icons.file notify("Failed: " .. out.stderr, "error", { icon = icon }) end end -------------------------------------------------------------------------------- return M ================================================ FILE: lua/genghis/operations/navigation.lua ================================================ local M = {} -------------------------------------------------------------------------------- ---Cycles files in folder in alphabetical order. ---If snacks.nvim is installed, adds cycling notification. ---@param direction? "next"|"prev" function M.fileInFolder(direction) local notify = require("genghis.support.notify") if not direction then direction = "next" end if direction ~= "next" and direction ~= "prev" then notify('Invalid direction. Only "next" and "prev" are allowed.', "warn") return end local config = require("genghis.config").config local curPath = vim.api.nvim_buf_get_name(0) local curFile = vim.fs.basename(curPath) local curFolder = vim.fs.dirname(curPath) local icon = direction == "next" and config.icons.nextFile or config.icons.prevFile -- get list of files local itemsInFolder = vim.fs.dir(curFolder) -- INFO `fs.dir` already returns them sorted local filesInFolder = vim.iter(itemsInFolder):fold({}, function(acc, name, type) local ext = name:match("%.(%w+)$") local curExt = curFile:match("%.(%w+)$") local ignored = (config.navigation.onlySameExtAsCurrentFile and ext ~= curExt) or vim.tbl_contains(config.navigation.ignoreExt, ext) or (config.navigation.ignoreDotfiles and vim.startswith(name, ".")) or vim.tbl_contains(config.navigation.ignoreFilesWithName, name) if type == "file" and not ignored then table.insert(acc, name) -- select only name end return acc end) -- GUARD no files to navigate to if #filesInFolder == 0 then -- if currently at a hidden file and there are only hidden files in the dir notify("No valid files found in folder.", "warn", { icon = icon }) return elseif #filesInFolder == 1 then notify("Already at the only valid file.", "warn", { icon = icon }) return end -- determine next index local curIdx for idx = 1, #filesInFolder do if filesInFolder[idx] == curFile then curIdx = idx break end end if not curIdx then local msg = "Cannot determine next file, current file itself is excluded." notify(msg, "warn", { icon = icon }) return end local nextIdx = curIdx + (direction == "next" and 1 or -1) if nextIdx < 1 then nextIdx = #filesInFolder end if nextIdx > #filesInFolder then nextIdx = 1 end -- goto file local nextFile = curFolder .. "/" .. filesInFolder[nextIdx] vim.cmd.edit(nextFile) -- notification if package.loaded["snacks"] then local msg = vim .iter(filesInFolder) :map(function(file) -- mark current, using markdown h1 local prefix = file == filesInFolder[nextIdx] and "#" or "-" return prefix .. " " .. file end) :slice(nextIdx - 5, nextIdx + 5) -- display ~5 files before/after :join("\n") local title = direction:sub(1, 1):upper() .. direction:sub(2) .. " file" .. (" (%d/%d)"):format(nextIdx, #filesInFolder) vim.notify(msg, nil, { title = title, icon = icon, history = false, id = "next-in-folder", -- replace notifications when quickly cycling ft = "markdown", -- so `h1` is highlighted }) end end -------------------------------------------------------------------------------- return M ================================================ FILE: lua/genghis/support/lsp-rename.lua ================================================ local M = {} -------------------------------------------------------------------------------- ---Requests a 'workspace/willRenameFiles' on any running LSP client, that supports it ---SOURCE https://github.com/LazyVim/LazyVim/blob/ac092289f506052cfdd1879f462be05075fe3081/lua/lazyvim/util/lsp.lua#L99-L119 ---@param fromName string ---@param toName string function M.willRename(fromName, toName) local clients = vim.lsp.get_clients { bufnr = 0 } for _, client in ipairs(clients) do if client:supports_method("workspace/willRenameFiles") then local response = client:request_sync("workspace/willRenameFiles", { files = { { oldUri = vim.uri_from_fname(fromName), newUri = vim.uri_from_fname(toName) }, }, }, 1000, 0) if response and response.result ~= nil then vim.lsp.util.apply_workspace_edit(response.result, client.offset_encoding) end end end end ---@nodiscard ---@return boolean function M.supported() local clients = vim.lsp.get_clients { bufnr = 0 } for _, client in ipairs(clients) do if client:supports_method("workspace/willRenameFiles") then return true end end return false end -------------------------------------------------------------------------------- return M ================================================ FILE: lua/genghis/support/move-considering-partition.lua ================================================ ---@param oldFilePath string ---@param newFilePath string ---@return boolean success return function(oldFilePath, newFilePath) local renamed, _ = vim.uv.fs_rename(oldFilePath, newFilePath) if renamed then return true end local notify = require("genghis.support.notify") -- try `fs_copyfile` to support moving across partitions local copied, copiedError = vim.uv.fs_copyfile(oldFilePath, newFilePath) if copied then local deleted, deletedError = vim.uv.fs_unlink(oldFilePath) if deleted then return true else notify(("Failed to delete %q: %q"):format(oldFilePath, deletedError), "error") return false end else local msg = ("Failed to copy %q to %q: %q"):format(oldFilePath, newFilePath, copiedError) notify(msg, "error") return false end end ================================================ FILE: lua/genghis/support/notify.lua ================================================ ---@param msg string ---@param level? "info"|"warn"|"error" ---@param opts? table return function(msg, level, opts) local successNotify = require("genghis.config").config.successNotifications if not level then level = "info" end if level == "info" and not successNotify then return end if not opts then opts = {} end opts.title = opts.title and "Genghis: " .. opts.title or "Genghis" if not opts.ft then opts.ft = "text" end -- prevent `~` from creating strikethroughs in `snacks.notifier` vim.notify(msg, vim.log.levels[level:upper()], opts) end ================================================ FILE: plugin/ex-commands.lua ================================================ vim.api.nvim_create_user_command("Genghis", function(ctx) require("genghis")[ctx.args]() end, { nargs = 1, complete = function(query) local allOps = {} vim.list_extend(allOps, vim.tbl_keys(require("genghis.operations.file"))) vim.list_extend(allOps, vim.tbl_keys(require("genghis.operations.copy"))) vim.list_extend(allOps, vim.tbl_keys(require("genghis.operations.navigation"))) return vim.tbl_filter(function(op) return op:lower():find(query, nil, true) end, allOps) end, })