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 ⚔️
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
- [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,
})