diff --git a/.gitignore b/.gitignore index 35c6a7a..bd0b1e6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ __pycache__/ # headless test harness scratch tests/work/ + +# pinned third-party checkouts for the sidebar -- vendor.sh recreates them +vendor/ diff --git a/KEYS.md b/KEYS.md index 4f31e30..dbdd6b5 100644 --- a/KEYS.md +++ b/KEYS.md @@ -20,6 +20,7 @@ Syntax highlighting only. No completion, no LSP, no snippets, no AI. | `Ctrl+Shift+Z` | redo (needs a terminal that speaks CSI-u) | normal, insert, visual | | `Tab` | indent the selected lines | **selection only** | | `Shift+Tab` | unindent the selected lines — or, in insert, the line you are on | selection, insert | +| `Ctrl+B` | **show / hide the file sidebar** (see its own section below) | normal, insert | | `Ctrl+Q` | visual block (was `Ctrl+V`) | normal, visual | | `Ctrl+Shift+Q` | **quit, with a confirmation** | normal, insert, **and inside the REPL** — except on a terminal with no CSI-u, where **normal mode is not covered**; see below | | **Shift+arrows** | **select, like any other editor** | normal, insert | @@ -242,6 +243,32 @@ Costs: `Ctrl+R` was normal-mode redo (use `Ctrl+Y`), `Ctrl+E` was scroll-down. To scroll back through output, `Ctrl+\` `Ctrl+N` first — otherwise the wheel goes to python. +## The file sidebar + +`Ctrl+B` shows and hides it (`Cmd+B` too on a forwarding terminal — including +from inside the REPL, where `Ctrl+B` is deliberately left to readline). It +opens without stealing your caret: the tree appears on the left and you are +still typing in your code — **rooted at the folder of the file you are in**, +not at whatever directory your shell was sitting in. The mouse is how you +use it: + +| Gesture | Does | +|---|---| +| click a file | open it — in the file window, never the interpreter | +| click a folder | fold / unfold | +| `[+ File]` / `[+ Folder]` | clickable buttons in the tree's top bar; type the name at the prompt | +| right-click | menu: new file/folder, open, open in split, rename, cut, copy, paste, delete | +| Ctrl+click | select several (marked with `*`) | +| drag onto a folder | **move it there** — drag a marked row and the whole selection moves | + +A drag released back on its own row is treated as a click that jittered, not +a move — same trackpad rule as everywhere else in drill. `Ctrl+B` is *not* +bound at the `/` prompt (there it stays cursor-to-start) or in the REPL +(readline's backward-char; tmux users keep their prefix too) — that is what +`Cmd+B` is for. The sidebar is drill's one plugin-powered feature; it loads +on first press and never before, and `Ctrl+S`/`Ctrl+E`/`Ctrl+R` pressed in +the tree do nothing, exactly as in the directory listing below. + ## The directory listing `d` / `dt` / `ds` with no argument opens the listing instead of a file. It is diff --git a/README.md b/README.md index 73135ba..10153f4 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,11 @@ And underneath it is still real Neovim: `hjkl`, `dd`, `ciw`, macros and ## Fast enough to keep a thought -Two files. No plugins, no plugin manager, no LSP, no startup spinner. From any +No plugin manager, no LSP, no startup spinner. One deliberate exception to +"no plugins": the optional `Ctrl+B` file sidebar is three checkouts pinned to +exact commits, fetched once by `vendor.sh`, and **loaded only the first time +you press the key** — never toggle it and nothing is even added to the +runtime path. From any terminal, mid-thought, you're in the editor before the thought fades — and everything **auto-saves** about 0.7s after you stop typing, plus instantly when you switch away. Quit whenever; `d two-sum` brings it all back. You never lose @@ -93,7 +97,7 @@ work, because there is no unsaved state to lose. ## Nothing helps you type No autocomplete. No LSP, no Copilot, no snippets, no signature hints, no -auto-import. Not "turned off" — **absent, at every source, and a 558-case test +auto-import. Not "turned off" — **absent, at every source, and a 729-case test suite asserts it**. Syntax highlighting is the only thing on screen besides your own keystrokes. Your `~/.config/nvim` and `~/.vimrc` are never touched; the config loads with `nvim -u` and exists only inside drill. @@ -109,6 +113,22 @@ t -k # stop Non-blocking, with a sound and a desktop notification at zero. +## `Ctrl+B` — a file sidebar, when you want one + +The one optional extra: press `Ctrl+B` (or `Cmd+B` on a Mac that forwards it, +REPL included) and a file tree slides in on the left — press again and it is +gone, with your caret still in your code either way. It is built for the +mouse: **one click** opens a file or folds a folder, **`[+ File]` / +`[+ Folder]` are real clickable buttons**, **right-click** gets a menu (new, +rename, cut/copy/paste, delete, open in split), **Ctrl+click** multi-selects, +and **dragging a file onto a folder moves it** — drag a selected one and the +whole selection moves. Details: [docs/explorer.md](docs/explorer.md). + +This is drill's only plugin-powered feature, and it stays out of the way: the +three checkouts are pinned to exact commits and load lazily on the first +press, so an editor that never opens the sidebar runs exactly the config it +always has. + ## The quality-of-life layer - **Quit that can't misfire** — `Ctrl+Shift+Q` asks first, and Cancel is the @@ -161,7 +181,7 @@ so `git pull` stays clean forever. | [KEYS.md](KEYS.md) | every keybinding, every conflict, and how each is resolved | | [docs/design.md](docs/design.md) | why the editor behaves the way it does — the mouse, autosave, search, quitting, and the five deliberate trade-offs | | [docs/verify.md](docs/verify.md) | prove nothing is helping you, and that your own nvim is untouched | -| [docs/testing.md](docs/testing.md) | the 558-case gate that drives the real config with real keycodes | +| [docs/testing.md](docs/testing.md) | the 729-case gate that drives the real config with real keycodes | | [docs/recording.md](docs/recording.md) | `demo.sh` — the self-driving tour that recorded the video above | | [docs/wsl.md](docs/wsl.md) | WSL: the clipboard, the timer's sound and notification, and the one thing you have to check by hand | diff --git a/docs/explorer.md b/docs/explorer.md new file mode 100644 index 0000000..c9f6622 --- /dev/null +++ b/docs/explorer.md @@ -0,0 +1,75 @@ +# The file sidebar + +`Ctrl+B` shows it, `Ctrl+B` hides it, and in both directions your caret stays +in your code — the tree is furniture, not a destination. On a Mac whose +terminal forwards Cmd ([docs/macos-cmd.md](macos-cmd.md)), `Cmd+B` does the +same thing and also works from inside the REPL, where `Ctrl+B` is +deliberately left alone (readline's backward-char, and tmux's prefix). + +**It roots at the folder of the file you are in**, freshly on every open — +`d lld-prac main` shows the `lld-prac/` folder with `main.py` highlighted, +no matter which directory your shell happened to be in when you typed it. +(drill launches nvim from wherever you were; the shell's directory is a tree +of everything *except* your project.) Switch to a file elsewhere and the next +`Ctrl+B` roots there instead. + +## The gestures + +Everything is a mouse gesture; the keyboard is only for typing names. + +- **Click a file** — opens it, in the file window. Never in the interpreter: + the open logic refuses terminal windows outright. +- **Click a folder** — folds or unfolds it. One click. There is no + double-click folklore anywhere in the tree. +- **`[+ File]` / `[+ Folder]`** — real buttons in the tree's top bar. Click, + type a name at the prompt, Enter. The location is taken from the row your + cursor is on: a folder row means *in here*, a file row means *next to me*. +- **Right-click** — a menu on the row you clicked: New file, New folder, + Open, Open in split, Rename, Cut, Copy, Paste, Delete. Plain text labels + on purpose — no nerd font required, anywhere in the sidebar. +- **Ctrl+click** — select several files (marked with `*`). +- **Drag a file onto a folder** — moves it into that folder. Drop it on a + file instead and it moves next to that file. If the row you dragged was + marked, the **whole marked selection moves** in one gesture. + +A press that drifts a cell before the button comes up is a click that +jittered, not a drag — the same trackpad rule the rest of drill's mouse +handling lives by, so nothing ever moves by accident. + +Inside the tree, drill's file keys say no the same way they do in the +directory listing: `Ctrl+S`, `Ctrl+E`, `Ctrl+R` do nothing there. + +## What it costs when you don't use it + +Nothing. This is drill's one exception to "no plugins", and it is built to +stay an exception: + +- The three checkouts (nvim-tree, and volt+menu for the right-click menu) + are **pinned to exact commits** by `vendor.sh` — a table of three SHAs and + a loop, not a plugin manager. The same commits on every machine, every + install, until a pin is bumped on purpose. +- **Nothing loads at startup.** The checkouts are not even on the runtime + path until the first `Ctrl+B` of the session. Never press it and you run + the config drill always had, byte for byte. +- The same change **sealed the accidental plugin door**: plugins in your + `~/.local/share/nvim/site` used to load inside drill through `packpath` + even though the README said "no plugins". They no longer do. Your own + nvim setup outside drill is untouched either way. + +## When it says no + +- *"sidebar plugins are not fetched"* — run `./vendor.sh` in your drill + directory (`~/drill` unless you moved it). `install.sh` does this for you; + a `git pull` upgrade does not. +- *"the sidebar needs nvim 0.10+"* — everything else in drill still works; + the pinned plugins are what need 0.10. +- `Cmd+B` does nothing — your terminal is not forwarding Cmd. One setting: + [docs/macos-cmd.md](macos-cmd.md). +- **Right-click opens your terminal's own menu instead of drill's** — the + terminal is eating the click before nvim sees it. In iTerm2 that is a + Pointer binding, and it wins over mouse reporting: **Settings → Pointer → + Bindings**, select the *Right button · single click → Open Context Menu* + row, press **−** to remove it. With the binding gone, the click reaches + drill whenever the editor is running, and iTerm2 still shows its own menu + at the shell prompt, where nothing is listening for the mouse. (Ctrl-click + in drill is multi-select, not the menu — that one is drill's on purpose.) diff --git a/docs/macos-cmd.md b/docs/macos-cmd.md index 9072750..510047e 100644 --- a/docs/macos-cmd.md +++ b/docs/macos-cmd.md @@ -42,6 +42,7 @@ So drill binds every editor chord on Cmd too, same modes, same handlers: | `Cmd+Z` | undo | | `Cmd+Shift+Z` | redo (the mac redo; `Ctrl+Y` still works everywhere) | | `Cmd+/` | comment / uncomment | +| `Cmd+B` | the file sidebar — including from the REPL, where `Ctrl+B` is left to readline | | `Cmd+Q` | quit, with the same confirmation as `Ctrl+Shift+Q` | | `Cmd+←` / `Cmd+→` | start / end of line | | `Cmd+↑` / `Cmd+↓` | start / end of file | @@ -56,7 +57,7 @@ like Shift+arrows and typing over that selection replaces it. an *unmapped* forwarded chord does not die quietly in nvim — measured, a bare `` typed the literal text `` into the buffer in insert, replaced the selection with it in Select mode, and ate a character in normal mode. So -a reflexive Cmd+W or Cmd+B from a mac hand would spray key notation into +a reflexive Cmd+W or Cmd+N from a mac hand would spray key notation into your file. drill floors every printable Cmd chord to a no-op — in the REPL too, where the junk would have gone to python — and binds the useful ones on top. diff --git a/docs/testing.md b/docs/testing.md index 78e3a7a..39945ab 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -5,20 +5,21 @@ and diffing what comes out. Nothing is mocked: if a case passes, that keystroke does that thing in this config. ```sh -./tests/run.sh # 558 cases; exit 0 only if all pass +./tests/run.sh # 729 cases; exit 0 only if all pass ./tests/run.sh sel_ # just the select-mode cases ``` | Suite | Cases | What it drives | |---|---|---| -| `suite_options.sh` | 86 | config invariants no key-driven test can see: completion off at every source, zero LSP clients, no swap/backup/undo files, the cursor shape in both panes, and every mapping registered in the modes it claims — including that `Ctrl+/` is *not* bound in terminal mode and `Ctrl+C` is *not* bound in normal or terminal, so SIGINT still reaches a running program | -| `suite_config.sh` | 30 | headless nvim: shift+arrow selection, `Tab`/`Shift+Tab`, cut/paste/select-all, undo/redo, `Ctrl+C` copying without losing the selection | -| `suite_mouse.sh` | 58 | pty: click to caret from every mode, into empty space and past EOF, jitter in both axes, real drags, double-click, Option/Ctrl+click, and clicks between the file and the interpreter | +| `suite_options.sh` | 172 | config invariants no key-driven test can see: completion off at every source, zero LSP clients, no swap/backup/undo files, no third-party site dir on `runtimepath` OR `packpath`, the cursor shape in both panes, and every mapping registered in the modes it claims — the whole Cmd layer and the sidebar toggle included, and that `Ctrl+/` is *not* bound in terminal mode and `Ctrl+C` is *not* bound in normal or terminal, so SIGINT still reaches a running program | +| `suite_config.sh` | 52 | headless nvim: shift+arrow selection, `Tab`/`Shift+Tab`, cut/paste/select-all, undo/redo, `Ctrl+C` copying without losing the selection, and the Cmd chords with their `` floor | +| `suite_mouse.sh` | 66 | pty: click to caret from every mode, into empty space and past EOF, jitter in both axes, real drags, double-click, Option/Ctrl+click, clicks between the file and the interpreter, and backspacing out of the ghost space | | `suite_search.sh` | 36 | `Ctrl+F`, exactly when the highlight and hints appear and go, and the Esc chain — including that it resumes typing at the exact column. Headless **and** pty: the Esc chain cannot be seen headlessly, because feedkeys force-ends Insert as the typeahead drains | | `suite_autosave.sh` | 8 | what is on **disk**, read back from the shell | | `suite_quit.sh` | 21 | pty: the confirmation is drawn, Cancel and Esc both return you to typing, Quit writes the pending edit, and all of it again from inside the REPL | | `suite_runwin.sh` | 12 | pty: the `Ctrl+R` output window — that it closes when you leave it, that any key still closes it from inside, that `Ctrl+R` then `Ctrl+E` is two windows and not three, and that a program still running is left alone | | `suite_netrw.sh` | 10 | pty: the directory listing is not a file — `Ctrl+S` does not wedge it, `Ctrl+E`/`Ctrl+R` start nothing. Headless is useless here: it does not produce a netrw buffer at all | +| `suite_tree.sh` | 55 | pty: the `Ctrl+B` sidebar, gesture by gesture — both toggle chords (Cmd+B as raw CSI-u bytes), focus that never leaves your code, single-click open and fold, the winbar `[+ File]`/`[+ Folder]` buttons all the way to files on disk, the right-click menu drawn and an item clicked by its float coordinates, ctrl-click marks, drag-and-drop single and marked-pair moves, a jitter that must not move anything, the three-window layout with the REPL, and the root landing on the file's folder on every open | | `suite_timer.sh` | 36 | not nvim at all — 18 assertions against `drill.sh` run under **bash and zsh**, which disagree about word splitting | | `suite_projects.sh` | 40 | also not nvim — nested `d ` and `d search` against stubbed `nvim`/`fzf` binaries in a scratch `DRILL_HOME`, under **bash and zsh** — including that `..` cannot leave the bucket and bracketed names resolve literally | | `suite_preload.sh` | 26 | the LeetCode desk: import-free files using the toolkit under `r`/`ri` in **bash and zsh** — shadowing, argv, `__name__`, exit codes, sibling imports in project folders, names still live at the prompt after a raise, a typo'd name getting python's own error, the plain-python3 fallback — plus a pty pass proving `Ctrl+R` and `Ctrl+E` route through the shim | diff --git a/docs/verify.md b/docs/verify.md index d0c64ac..6a0b68c 100644 --- a/docs/verify.md +++ b/docs/verify.md @@ -18,7 +18,10 @@ And confirm it left your own setup alone: run plain `nvim` and check - No autocomplete, no LSP, no Copilot, no AI, no snippet expansion - No linter suggestions, no auto-import, no signature hints - Syntax highlighting only — nothing else appears on screen while you type -- No plugin manager, no distro +- No plugin manager, no distro. The one exception to "no plugins" is the + optional `Ctrl+B` sidebar: three checkouts pinned to exact commits by + `vendor.sh`, loaded only when you first press the key, and none of them + touches what appears while you type - No swapfile, no backup, no undo file — the directory holds the `.py` you wrote and nothing else - Stock Python 3, Neovim and fzf — and fzf only powers `d search`, never the diff --git a/docs/wsl.md b/docs/wsl.md index 2894d1e..74aacd5 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -142,7 +142,7 @@ Ctrl+click are both just clicks, not vim surprises. ## Running the tests ```sh -./tests/run.sh # 558 cases +./tests/run.sh # 729 cases ``` Needs a clipboard provider on PATH; `run.sh` warns up front if there is none, diff --git a/explorer.lua b/explorer.lua new file mode 100644 index 0000000..30508f5 --- /dev/null +++ b/explorer.lua @@ -0,0 +1,343 @@ +-- ============================================================================ +-- explorer.lua -- the optional file-explorer sidebar behind Ctrl+B / Cmd+B. +-- +-- drill's one deliberate exception to "no plugins": nvim-tree draws the tree, +-- volt+menu will draw the right-click menu, all vendored at pinned SHAs by +-- vendor.sh. Nothing in this file runs -- the checkouts are not even on +-- 'runtimepath' -- until the first toggle, so an editor that never presses +-- Ctrl+B is byte-for-byte the editor drill has always been. +-- +-- Loaded with dofile() from nvimrc.lua, not require(): drill's own code is +-- found by path, never by 'runtimepath', same as preload.py. +-- ============================================================================ + +local M = {} + +local HERE = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h") +local VENDOR = HERE .. "/vendor" + +-- The checks live here, not in the toggle: dofile() runs once, and a missing +-- checkout or an old nvim is a property of the install, not of the keypress. +-- M.error is the single source of truth; the toggle just repeats it. +if vim.fn.has("nvim-0.10") == 0 then + M.error = "drill: the sidebar needs nvim 0.10+ -- everything else still works" +end +for _, d in ipairs({ "nvim-tree.lua", "volt", "menu" }) do + if not M.error and vim.fn.isdirectory(VENDOR .. "/" .. d .. "/lua") == 0 then + M.error = "drill: sidebar plugins are not fetched -- run " .. HERE .. "/vendor.sh" + end +end + +local host = { type_here = function() end } +local ready = false +local tree_menu -- defined below the toolbar; setup's right-click map closes over it + +function M.setup(h) + if M.error or ready then return end + host = h + for _, d in ipairs({ "nvim-tree.lua", "volt", "menu" }) do + vim.opt.runtimepath:prepend(VENDOR .. "/" .. d) + end + require("nvim-tree").setup({ + disable_netrw = false, -- bare `d` keeps its netrw listing + hijack_netrw = false, + git = { enable = false }, -- solves/ is scratch; no gutter ceremony + diagnostics = { enable = false }, + -- No filesystem watchers, and this is a bug fix, not a taste: nvim-tree + -- registers one watcher per directory, and a tree rooted somewhere large + -- (the pre-rooting-fix sidebar rooted at the shell's cwd -- often ~) + -- exhausted the fd limit and sprayed "[NvimTree] File system watcher + -- failed (EMFILE)" over everything, including the quit prompt, until + -- Enter fed the message pager instead of the dialog. drill's files + -- change from inside the editor; the tree refreshes on its own actions + -- and the glue reloads after every drop/mkdir. + filesystem_watchers = { enable = false }, + update_focused_file = { enable = true }, + view = { width = 32, preserve_window_proportions = true }, + renderer = { + special_files = {}, + icons = { + show = { file = false, folder = false, folder_arrow = true, + git = false, modified = false, bookmarks = true }, + -- ASCII on purpose: drill promises zero setup, and nerd-font glyphs + -- are tofu boxes on a stock terminal font. + glyphs = { folder = { arrow_closed = "+", arrow_open = "-" }, + bookmark = "*" }, + }, + }, + actions = { + open_file = { + window_picker = { + -- the default exclude already refuses terminal/nofile/help, so a + -- file opened from the tree can never land in the interpreter; + -- netrw is drill's own listing and must not eat an open either + exclude = { + buftype = { "nofile", "terminal", "help" }, + filetype = { "netrw", "notify", "qf", "diff" }, + }, + }, + }, + }, + on_attach = function(bufnr) M.attach(bufnr) end, + }) + + -- drill lives in insert mode, and a mouse press from insert FOCUSES the + -- tree while STAYING in insert -- measured: mode "i", filetype NvimTree -- + -- so the release arrived in a mode none of the tree's mappings own and + -- every click ate itself. There is nothing to type in a tree: entering it, + -- by any route, lands you in Normal, and every gesture below can assume it. + vim.api.nvim_create_autocmd("WinEnter", { + group = vim.api.nvim_create_augroup("DrillTree", { clear = true }), + callback = function() + if vim.bo.filetype == "NvimTree" then vim.cmd("stopinsert") end + end, + }) + + -- Right-click, global but late: this mapping exists only once the sidebar + -- has been toggled at all, and everywhere outside the tree it re-feeds the + -- unmapped key, so the file buffer keeps whatever stock right-click this + -- nvim came with ('mousemodel' popup_setpos and its built-in menu today). + -- Global is forced by how mouse events route: a right-click ON the tree + -- while you are typing in the file is delivered to the FILE buffer's maps, + -- so a tree-buffer-local mapping would simply never fire. + vim.keymap.set({ "n", "i" }, "", function() + local mp = vim.fn.getmousepos() + local b = mp.winid ~= 0 and vim.api.nvim_win_get_buf(mp.winid) + if b and vim.bo[b].filetype == "NvimTree" then + tree_menu() + else + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("", true, true, true), "n", false) + end + end, { silent = true }) + + -- Ctrl-click multi-select, same shape as right-click and global for the + -- same routing reason. Outside the tree it re-feeds a PLAIN press, which + -- is exactly what the mouse section's Ctrl-stripping maps did before this + -- override -- nvim's stock is a tag jump that wedges a + -- tagless editor behind a modal E426. On a tree row it toggles the mark + -- (rendered as *), and a later drag of any marked row moves the whole set. + vim.keymap.set({ "n", "i" }, "", function() + local mp = vim.fn.getmousepos() + local b = mp.winid ~= 0 and vim.api.nvim_win_get_buf(mp.winid) + if b and vim.bo[b].filetype == "NvimTree" and mp.line > 0 then + vim.api.nvim_set_current_win(mp.winid) + pcall(vim.api.nvim_win_set_cursor, mp.winid, { mp.line, 0 }) + require("nvim-tree.api").marks.toggle() + else + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("", true, true, true), "n", false) + end + end, { silent = true }) + ready = true +end + +-- ---------------------------------------------------------------------------- +-- The toolbar: real clickable [+ File] / [+ Folder] buttons, because "press a" +-- is not a button. The winbar takes %@handler@label%X click regions (core +-- since 0.8), and the handlers must be globals for v:lua to reach them -- +-- namespaced DrillTree* so they collide with nothing. +-- +-- Both prompt for the name with vim.ui.input, seeded with the directory the +-- tree cursor is on (a folder row means "in here", a file row means "next to +-- me") -- click a folder, click the button, type a name. The folder button +-- does NOT ride api.fs.create's trailing-slash convention: that is the same +-- keyboard folklore the button exists to retire. mkdir -p, reload, done. + +local function cursor_dir() + local api = require("nvim-tree.api") + local node = api.tree.get_node_under_cursor() + if node and node.nodes then return node.absolute_path end -- a folder + if node and node.parent then return node.parent.absolute_path end + return vim.fn.getcwd() +end + +function _G.DrillTreeNewFile() + require("nvim-tree.api").fs.create() +end + +function _G.DrillTreeNewFolder() + local base = cursor_dir() + vim.ui.input({ prompt = "New folder: ", default = base .. "/" }, function(path) + if not path or path == "" or path:sub(-1) == "/" then return end + vim.fn.mkdir(path, "p") + require("nvim-tree.api").tree.reload() + end) +end + +local TOOLBAR = table.concat({ + " %@v:lua.DrillTreeNewFile@[+ File]%X", + "%@v:lua.DrillTreeNewFolder@[+ Folder]%X", +}, " ") + +-- ---------------------------------------------------------------------------- +-- The right-click menu. menu/volt do the drawing and the item clicks; the +-- items are drill's own -- plain ASCII labels, not the upstream set, whose +-- names are nerd-font glyphs (tofu on a stock terminal font) and whose "New +-- folder" entry is the trailing-slash convention wearing a menu costume. +-- Every cmd acts on the node under the tree cursor, which the right-click +-- parked there first. + +local function menu_items() + local api = require("nvim-tree.api") + return { + { name = "New file", cmd = function() api.fs.create() end, rtxt = "a" }, + { name = "New folder", cmd = _G.DrillTreeNewFolder, rtxt = "A" }, + { name = "separator" }, + { name = "Open", cmd = function() api.node.open.edit() end, rtxt = "" }, + { name = "Open in split", cmd = function() api.node.open.vertical() end, rtxt = "v" }, + { name = "separator" }, + { name = "Rename", cmd = function() api.fs.rename() end, rtxt = "r" }, + { name = "Cut", cmd = function() api.fs.cut() end, rtxt = "x" }, + { name = "Copy", cmd = function() api.fs.copy.node() end, rtxt = "c" }, + { name = "Paste", cmd = function() api.fs.paste() end, rtxt = "p" }, + { name = "separator" }, + { name = "Delete", cmd = function() api.fs.remove() end, rtxt = "d" }, + } +end + +function tree_menu() + local mp = vim.fn.getmousepos() + if mp.winid == 0 then return end + -- Focus the tree and park its cursor on the clicked row FIRST: the item + -- cmds all mean "the node under the cursor", and the menu float itself + -- opens unfocused, so this is what "right-clicked that file" resolves to. + vim.api.nvim_set_current_win(mp.winid) + if mp.line > 0 then + pcall(vim.api.nvim_win_set_cursor, mp.winid, { mp.line, 0 }) + end + require("menu.utils").delete_old_menus() + -- menu leaves its auto-close map behind when a menu is closed + -- by an item click rather than an outside click; clear the stale one so + -- it cannot stack + pcall(vim.keymap.del, "n", "") + require("menu").open(menu_items(), { mouse = true, border = true }) +end + +-- ---------------------------------------------------------------------------- +-- Drag a file onto a folder to move it. No plugin ships this; the events do: +-- the press parks the cursor on the source row (built-in), the first +-- report snapshots that node, and the release resolves the row +-- under the mouse into the destination. Dropping on a folder moves INTO it, +-- on a file moves NEXT TO it (paste targets the node's directory), and a +-- release back on the source row is not a drag at all -- that is a trackpad +-- click that jittered, the exact gesture mouse_drive.py's jitter cases exist +-- for, and it falls through to the ordinary click below. +-- +-- If the dragged node is one of the marked ones (ctrl-click, below), the +-- whole marked set rides along: cut accumulates, one paste moves them all. + +local drag_src = nil + +local function node_at(row) + local api = require("nvim-tree.api") + local win = vim.fn.bufwinid(vim.api.nvim_get_current_buf()) + pcall(vim.api.nvim_win_set_cursor, win, { row, 0 }) + return api.tree.get_node_under_cursor() +end + +local function drop(src, target) + local api = require("nvim-tree.api") + local moving = { src } + local marks = api.marks.list() + for _, n in ipairs(marks) do + if n.absolute_path == src.absolute_path then + moving = marks -- dragged one of the marked: all go + break + end + end + for _, n in ipairs(moving) do api.fs.cut(n) end + api.fs.paste(target) + api.fs.clear_clipboard() + if moving == marks then api.marks.clear() end + api.tree.reload() +end + +-- A left-click, decomposed. The press already moved the cursor to the row +-- (built-in), so the release only has to act on the node under it. Guard on +-- getmousepos().line == 0: that is the winbar, whose clicks belong to the +-- %@ handlers above, not to the tree. +local function tree_drag() + if drag_src == nil then + -- first motion report of this gesture: the press has already parked the + -- cursor on the source row, so this IS the source + drag_src = require("nvim-tree.api").tree.get_node_under_cursor() or false + end +end + +local function tree_click() + local mp = vim.fn.getmousepos() + local src = drag_src + drag_src = nil + if mp.line == 0 then return end + if src and mp.line > 0 then + local target = node_at(mp.line) + if target and src.absolute_path and target.absolute_path + and target.absolute_path ~= src.absolute_path then + return drop(src, target) + end + end + local api = require("nvim-tree.api") + local node = api.tree.get_node_under_cursor() + if not node then return end + -- open.edit on a folder toggles it, on a file opens it through the + -- window_picker excludes -- one click for both, no double-click folklore + api.node.open.edit(node) +end + +-- Buffer-local extras on top of nvim-tree's stock keymap. Buffer-local is +-- load-bearing: suite_options asserts the GLOBAL mouse maps stay exactly as +-- the mouse section left them, and nothing here may show up there. +function M.attach(bufnr) + require("nvim-tree.api").config.mappings.default_on_attach(bufnr) + vim.keymap.set("n", "", tree_click, { buffer = bufnr, silent = true }) + -- deliberately NOT re-fed to the built-in: the default drag grows a + -- character-wise selection across tree rows, which is noise here + vim.keymap.set("n", "", tree_drag, { buffer = bufnr, silent = true }) + + -- Window-local dressing, scheduled: on_attach runs while nvim-tree is + -- still assembling the window, and bufwinid needs the finished layout. + vim.schedule(function() + local win = vim.fn.bufwinid(bufnr) + if win == -1 then return end + -- global virtualedit=all is the click-anywhere ghost space; in a tree it + -- would let the caret float past the end of a filename + vim.wo[win].virtualedit = "none" + vim.wo[win].winbar = TOOLBAR + end) +end + +function M.toggle() + if M.error then + vim.notify(M.error, vim.log.levels.WARN) + return + end + -- The tree roots at the folder of the file you are IN, not at the shell's + -- working directory. drill launches nvim from wherever the shell happened + -- to be -- `d lld-prac main` in your home directory used to open a tree of + -- ~, which is a tree of everything except your project. The file's folder + -- is the project; that is what the sidebar is for. + -- + -- Re-root ONLY when the current buffer knows better: a real file (its + -- folder), or the netrw listing (the directory it is showing). From the + -- REPL or any nameless buffer, pass no path at all -- nvim-tree then keeps + -- the root it already had, instead of yanking it back to the shell's cwd, + -- which is how a Cmd+B from the interpreter used to hand you a tree of ~. + -- + -- focus=false: the sidebar appears, the caret stays in your code -- the + -- mouse is how you enter the tree. type_here() on both directions, so the + -- toggle keeps drill's one promise: wherever you land, you are typing. + local opts = { focus = false } + local f = vim.fn.expand("%:p") + if vim.bo.buftype == "" and f ~= "" and vim.fn.filereadable(f) == 1 then + opts.path = vim.fn.fnamemodify(f, ":h") + opts.find_file = true -- ...and land highlighted on the file + elseif vim.bo.filetype == "netrw" and type(vim.b.netrw_curdir) == "string" + and vim.fn.isdirectory(vim.b.netrw_curdir) == 1 then + opts.path = vim.b.netrw_curdir + end + require("nvim-tree.api").tree.toggle(opts) + host.type_here() +end + +return M diff --git a/install.sh b/install.sh index 374ed15..5d618bd 100755 --- a/install.sh +++ b/install.sh @@ -91,25 +91,25 @@ fi # is worse than printing none. Empty = this manager has no name we know for it. pkg_for() { case "$PM" in - brew) case "$1" in python3) echo python ;; fzf) echo fzf ;; + brew) case "$1" in python3) echo python ;; fzf) echo fzf ;; git) echo git ;; clipboard) echo xclip ;; esac ;; - apt-get) case "$1" in python3) echo python3 ;; fzf) echo fzf ;; + apt-get) case "$1" in python3) echo python3 ;; fzf) echo fzf ;; git) echo git ;; clipboard) echo "wl-clipboard xclip" ;; sound) echo pulseaudio-utils ;; notify) echo libnotify-bin ;; pgrep) echo procps ;; esac ;; - dnf) case "$1" in python3) echo python3 ;; fzf) echo fzf ;; + dnf) case "$1" in python3) echo python3 ;; fzf) echo fzf ;; git) echo git ;; clipboard) echo "wl-clipboard xclip" ;; sound) echo pulseaudio-utils ;; notify) echo libnotify ;; pgrep) echo procps-ng ;; esac ;; - zypper) case "$1" in python3) echo python3 ;; fzf) echo fzf ;; + zypper) case "$1" in python3) echo python3 ;; fzf) echo fzf ;; git) echo git ;; clipboard) echo "wl-clipboard xclip" ;; sound) echo pulseaudio-utils ;; notify) echo libnotify-tools ;; pgrep) echo procps ;; esac ;; - pacman) case "$1" in python3) echo python ;; fzf) echo fzf ;; + pacman) case "$1" in python3) echo python ;; fzf) echo fzf ;; git) echo git ;; clipboard) echo "wl-clipboard xclip" ;; sound) echo libpulse ;; notify) echo libnotify ;; pgrep) echo procps-ng ;; esac ;; - apk) case "$1" in python3) echo python3 ;; fzf) echo fzf ;; + apk) case "$1" in python3) echo python3 ;; fzf) echo fzf ;; git) echo git ;; clipboard) echo xclip ;; sound) echo pulseaudio-utils ;; notify) echo libnotify ;; pgrep) echo procps ;; esac ;; esac @@ -142,7 +142,7 @@ nvim_pkg() { *) echo neovim ;; esac } -NVIM_BY_HAND="install neovim 0.9+ by hand: 'snap install nvim --classic', +NVIM_BY_HAND="install neovim 0.10+ by hand: 'snap install nvim --classic', or the AppImage from https://github.com/neovim/neovim/releases" MISSING="" # packages we can name -- offered as one install command @@ -184,10 +184,10 @@ check_deps() { local maj min rest maj="$(printf '%s' "${cand%%.*}" | tr -cd '0-9')" rest="${cand#*.}"; min="$(printf '%s' "${rest%%.*}" | tr -cd '0-9')" - if [ "${maj:-0}" -eq 0 ] && [ "${min:-0}" -lt 9 ]; then + if [ "${maj:-0}" -eq 0 ] && [ "${min:-0}" -lt 10 ]; then NOTES="$NOTES - - neovim $cand is too old, need 0.9+ (the config is Lua and uses nvim_win_hide). - $NVIM_BY_HAND" + - neovim $cand is too old, need 0.10+ (the config is Lua, and the Ctrl+B + sidebar's pinned plugins need 0.10). $NVIM_BY_HAND" HARD=1 fi fi @@ -206,6 +206,9 @@ check_deps() { command -v fzf >/dev/null 2>&1 || need feature fzf "fzf -- 'd search' has nothing to fuzzy-pick with" + command -v git >/dev/null 2>&1 || + need feature git "git -- vendor.sh fetches the Ctrl+B sidebar's pinned plugins with it" + if [ "$OS" != "Darwin" ]; then # macOS has pbcopy/pbpaste built in and nvim always finds them. Everywhere # else the provider is a separate package. On WSL clip.exe + powershell.exe @@ -313,14 +316,14 @@ DRILL_HOME_REAL="$(cd "$DRILL_HOME" && pwd -P)" # would let an incomplete clone install "successfully" from its own directory # and then add a `source .../drill.sh` line for a drill.sh that does not exist, # or leave d/dt/ds pointing at a missing nvimrc.lua. -for f in nvimrc.lua drill.sh preload.py KEYS.md; do +for f in nvimrc.lua drill.sh preload.py KEYS.md explorer.lua vendor.sh; do [ -f "$SRC/$f" ] || bail "missing $f next to install.sh" done if [ "$SRC" = "$DRILL_HOME_REAL" ]; then say "running from $DRILL_HOME itself -- the files are already in place" else - for f in nvimrc.lua drill.sh preload.py KEYS.md; do + for f in nvimrc.lua drill.sh preload.py KEYS.md explorer.lua vendor.sh; do if [ -f "$DRILL_HOME/$f" ] && ! cmp -s "$SRC/$f" "$DRILL_HOME/$f"; then cp "$DRILL_HOME/$f" "$DRILL_HOME/$f.bak" say "kept your old $f as $f.bak" @@ -333,6 +336,17 @@ fi [ -e "$DRILL_HOME/cheatsheet.py" ] || : > "$DRILL_HOME/cheatsheet.py" [ -e "$DRILL_HOME/log.md" ] || : > "$DRILL_HOME/log.md" +# The Ctrl+B sidebar's pinned plugin checkouts. FEATURE-level on purpose: a +# failed fetch (an offline install) costs the sidebar and nothing else, and +# vendor.sh can be rerun by hand any time. Already at the pins, it is an +# offline no-op, so re-installs cost nothing here. +if command -v git >/dev/null 2>&1; then + "$DRILL_HOME/vendor.sh" || + say "could not fetch the sidebar plugins -- Ctrl+B will say so; rerun $DRILL_HOME/vendor.sh when online" +else + say "no git -- skipped the Ctrl+B sidebar plugins; install git, then run $DRILL_HOME/vendor.sh" +fi + # ---- shell rc --------------------------------------------------------- RC="$(rc_file)" if [ -z "$RC" ]; then diff --git a/nvimrc.lua b/nvimrc.lua index e2bb1fa..07253cd 100644 --- a/nvimrc.lua +++ b/nvimrc.lua @@ -1,6 +1,18 @@ -- ~/drill/nvimrc.lua -- isolated drill editor. Load ONLY via: nvim -u ~/drill/nvimrc.lua -- Syntax highlighting only. No completion, no LSP, no snippets, no plugins, no AI. -vim.opt.runtimepath:remove(vim.fn.stdpath("data") .. "/site") +-- (One exception, opted into per keypress: the Ctrl+B sidebar -- see +-- explorer.lua -- whose pinned checkouts are the ONLY third-party code that +-- may load here.) +-- +-- Removing the site dir from 'runtimepath' was never the whole seal: startup +-- packages load from 'packpath', which still held it -- measured, a plugin in +-- ~/.local/share/nvim/site/pack/x/start/ loaded inside drill. All three +-- doors close here, so "no plugins" means the machine's plugins too. +for _, p in ipairs({ vim.fn.stdpath("data") .. "/site", + vim.fn.stdpath("data") .. "/site/after" }) do + vim.opt.runtimepath:remove(p) + vim.opt.packpath:remove(p) +end vim.g.mapleader = " " vim.cmd("syntax on") vim.cmd("filetype plugin indent on") @@ -1215,6 +1227,22 @@ local function repl_hide() if shows(repl_win, repl_buf) then remember_h(repl_win) vim.api.nvim_win_hide(repl_win) -- window gone, python untouched + -- Hiding a window hands focus to a NEIGHBOR, and with the sidebar open + -- that neighbor is the tree -- measured: out of the REPL parked + -- the caret in the sidebar in Normal mode. "Back to the code" has to + -- mean the code: walk to the first window holding a typing buffer. + -- None anywhere (a bare listing plus the tree) leaves focus where it + -- fell, which type_here() below already treats as "nothing to do". + if not typing_buffer() then + for _, w in ipairs(vim.api.nvim_list_wins()) do + local b = vim.api.nvim_win_get_buf(w) + if vim.bo[b].buftype == "" and vim.bo[b].modifiable + and vim.bo[b].filetype ~= "netrw" then + vim.api.nvim_set_current_win(w) + break + end + end + end end type_here() -- back in the editor, typing end @@ -1313,6 +1341,35 @@ for _, m in ipairs({ map(m[1], m[2], m[3], S) end +-- --------------------------------------------------------------------------- +-- The file-explorer sidebar -- Ctrl+B (and Cmd+B, bound in the mac section) +-- +-- The one deliberate exception to "no plugins". All of it lives in +-- explorer.lua and the pinned checkouts under vendor/, and none of it is +-- loaded -- not even added to 'runtimepath' -- until the first toggle, so an +-- editor that never presses Ctrl+B runs exactly the config it always has. +-- Ctrl+B is bound in n/i only, deliberately: in cmdline it is +-- cursor-to-start at the / prompt this config leans on, and in the +-- interpreter it is readline's backward-char (and tmux's prefix). Cmd+B +-- covers the interpreter instead. +local explorer = nil +local function tree_toggle() + vim.cmd("stopinsert") + if explorer == nil then + local here = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h") + local ok, mod = pcall(dofile, here .. "/explorer.lua") + explorer = (ok and mod) or false + if explorer then explorer.setup({ type_here = type_here }) end + end + if not explorer then + vim.notify("drill: explorer.lua failed to load -- reinstall or git pull", + vim.log.levels.WARN) + return + end + explorer.toggle() +end +map({ "n", "i" }, "", tree_toggle, S) + -- --------------------------------------------------------------------------- -- Quit -- Ctrl+Shift+Q -- @@ -1504,6 +1561,11 @@ end map({ "n", "v", "i" }, "", save, S) +-- Cmd+B mirrors Ctrl+B, plus terminal mode: ^B belongs to readline in the +-- interpreter, but Cmd+B arrives as CSI-u and python never sees it, so the +-- sidebar is reachable from the REPL too. +map({ "n", "i", "t" }, "", tree_toggle, S) + map({ "x", "s" }, "", copy_selection, S) map({ "n", "i" }, "", copy_line, S) diff --git a/tests/run.sh b/tests/run.sh index dfbd68c..35023cd 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -40,6 +40,13 @@ if [ ! -f "$DRILL_CONFIG" ]; then fi command -v nvim >/dev/null || { echo "run.sh: nvim not on PATH" >&2; exit 2; } +# The sidebar suite drives the vendored plugins, so bring them to their pins +# first. Already there, this is an offline no-op; genuinely unfetchable (no +# network, no checkouts) it warns here and suite_tree fails loudly below -- +# a gate that silently skipped a feature would be reporting coverage it +# does not have. +"$(cd "$DIR/.." && pwd)/vendor.sh" || echo "run.sh: WARNING -- vendor.sh failed; the sidebar suite will fail" + # The config sets clipboard=unnamedplus, and several cases in suite_config.sh # assert the REAL '+' register. With no provider those fail for a reason that # has nothing to do with the keybinding under test, and read as a regression in @@ -177,6 +184,7 @@ run "autosave" suite_autosave.sh run "quit confirmation" suite_quit.sh run "run-window lifecycle" suite_runwin.sh run "directory listing" suite_netrw.sh +run "file explorer sidebar" suite_tree.sh run "shell: timer" suite_timer.sh run "shell: projects + search" suite_projects.sh run "preload: the LeetCode desk" suite_preload.sh diff --git a/tests/suite_config.sh b/tests/suite_config.sh index 7cc463a..9473df2 100755 --- a/tests/suite_config.sh +++ b/tests/suite_config.sh @@ -218,7 +218,7 @@ t cmd_bs_deletes_to_line_start \ # normal -- so one reflexive Cmd+W from a mac hand would spray notation # into the file. t cmd_floor_swallows_unbound_chords \ - --content 'alpha' --keys 'Z' --expect 'Zalpha' + --content 'alpha' --keys 'Z' --expect 'Zalpha' t cmd_floor_keeps_selection_intact \ --content 'alpha bravo' --keys "${SEL5}Z" --expect 'Z bravo' diff --git a/tests/suite_options.sh b/tests/suite_options.sh index 13f1809..117e334 100755 --- a/tests/suite_options.sh +++ b/tests/suite_options.sh @@ -45,6 +45,17 @@ end ok("completion_infercase_off", vim.o.infercase == false, vim.o.infercase) ok("completion_no_lsp_clients", #vim.lsp.get_clients() == 0, #vim.lsp.get_clients()) +-- ---- no third-party code leaks in ---------------------------------------- +-- the site dir must be off BOTH paths: rtp alone was never the seal, because +-- startup packages (pack/*/start) load from packpath +local site = vim.fn.stdpath("data") .. "/site" +for _, opt in ipairs({ "runtimepath", "packpath" }) do + local v = "," .. vim.o[opt] .. "," + ok("no_user_site_in_" .. opt, + not v:find("," .. site .. ",", 1, true) and + not v:find("," .. site .. "/after,", 1, true), vim.o[opt]) +end + -- ---- no files left lying around ------------------------------------------ ok("no_swapfile", vim.o.swapfile == false, vim.o.swapfile) ok("no_backup", vim.o.backup == false, vim.o.backup) @@ -167,6 +178,23 @@ ok("cmdkey_c_floored_in_terminal", vim.fn.maparg("", "t"):lower() == " -- insert Shift+Tab dedent (i_CTRL-D), the pair of selection Tab/S-Tab ok("stab_insert_bound", vim.fn.maparg("", "i"):lower() == "", vim.fn.maparg("", "i")) +-- the sidebar toggle. Ctrl+B in normal and insert only: in cmdline it is +-- cursor-to-start at the / prompt, and in the interpreter it is readline +-- backward-char (and the tmux prefix) -- Cmd+B covers the interpreter, where +-- CSI-u keeps it off the python wire. +for _, m in ipairs({"n", "i"}) do + ok("tree_ctrl_b_bound_" .. m, vim.fn.maparg("", m) ~= "" or + vim.fn.maparg("", m, false, true).callback ~= nil, vim.fn.maparg("", m)) +end +for _, m in ipairs({"c", "t"}) do + ok("tree_ctrl_b_free_" .. m, vim.fn.maparg("", m) == "" and + vim.fn.maparg("", m, false, true).callback == nil, tostring(vim.fn.maparg("", m))) +end +for _, m in ipairs({"n", "i", "t"}) do + local d = vim.fn.maparg("", m, false, true) + ok("tree_cmd_b_bound_" .. m, d.callback ~= nil and (d.rhs or ""):lower() ~= "", + tostring(d.rhs)) +end -- quit. Bound in terminal too, so you can leave from inside the interpreter -- -- python has no use for . must stay VISUAL BLOCK: the two are only -- separate keys because CSI-u keeps Shift on a control chord. diff --git a/tests/suite_tree.sh b/tests/suite_tree.sh new file mode 100755 index 0000000..72d54f2 --- /dev/null +++ b/tests/suite_tree.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# ============================================================================ +# suite_tree.sh -- the Ctrl+B sidebar, driven by real mouse gestures. +# +# Everything the sidebar promises is a gesture -- click to open, click to +# expand, toolbar buttons, right-click menu, ctrl-click multi-select, drag a +# file onto a folder -- and none of it exists in headless nvim (no screen +# grid, no winbar, no mouse). So tree_drive.py runs the real editor on a pty +# and writes real SGR mouse sequences, exactly like suite_mouse.sh. +# +# Needs the pinned checkouts under vendor/ -- run.sh fetches them as a +# preflight; by hand it is one command: ./vendor.sh +# +# ./suite_tree.sh run everything +# ./suite_tree.sh click only cases whose name contains "click" +# ============================================================================ +set -uo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PATH="$DIR/bin:$PATH"; export PATH +CONFIG="${DRILL_CONFIG:-$(cd "$DIR/.." && pwd)/nvimrc.lua}" +FILTER="${1:-}" + +command -v python3 >/dev/null || { echo "suite_tree.sh: python3 not on PATH" >&2; exit 2; } +if [ ! -d "$DIR/../vendor/nvim-tree.lua/lua" ]; then + echo "suite_tree.sh: vendor/ is missing -- run ./vendor.sh first" >&2 + exit 1 +fi + +# Unique socket per run so two checkouts under test cannot collide. +DRILL_SOCK="${TMPDIR:-/tmp}/drill-tree-$$.sock" +export DRILL_CONFIG="$CONFIG" DRILL_SOCK + +OUT="$(timeout 300 python3 "$DIR/tree_drive.py")" +RC=$? +rm -f "$DRILL_SOCK" + +if [ $RC -ne 0 ] || [ -z "$OUT" ]; then + echo "suite_tree.sh: driver failed (rc=$RC)" >&2 + [ -n "$OUT" ] && echo "$OUT" >&2 + exit 2 +fi + +PASS=0; FAIL=0 +declare -a BAD=() +while IFS=$'\t' read -r verdict name got; do + case "$verdict" in PASS|FAIL) ;; *) continue ;; esac + if [ -n "$FILTER" ] && [[ "$name" != *"$FILTER"* ]]; then continue; fi + if [ "$verdict" = PASS ]; then + PASS=$((PASS+1)); printf '\033[32mPASS\033[0m %s\n' "$name" + else + FAIL=$((FAIL+1)); BAD+=("$name"); printf '\033[31mFAIL\033[0m %s -- got: %s\n' "$name" "$got" + fi +done <<< "$OUT" + +echo +echo "==========================================================" +echo "CONFIG=$CONFIG PASS=$PASS FAIL=$FAIL" +[ $FAIL -gt 0 ] && { echo "failing: ${BAD[*]}"; exit 1; } +exit 0 diff --git a/tests/tree_drive.py b/tests/tree_drive.py new file mode 100755 index 0000000..6f869a3 --- /dev/null +++ b/tests/tree_drive.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +# ============================================================================ +# tree_drive.py -- the sidebar is a mouse feature, so it is tested with one. +# +# Ctrl+B / Cmd+B toggle an nvim-tree sidebar (explorer.lua), and everything +# the feature promises is a gesture: click a file to open it, click a folder +# to expand it, click [+ File] / [+ Folder] in the winbar toolbar, right-click +# for a menu, ctrl-click to multi-select, drag a file onto a folder to move +# it. None of that exists in headless nvim -- no screen grid, no winbar, no +# mouse -- so this drives the REAL editor on a pty and writes real SGR mouse +# sequences, exactly like mouse_drive.py. +# +# Emits "PASS\tname\tgot" / "FAIL\tname\tgot" on stdout for suite_tree.sh. +# ============================================================================ +import fcntl +import os +import re +import select +import shutil +import signal +import struct +import subprocess +import sys +import termios +import time + +CONFIG = os.environ.get("DRILL_CONFIG") +SOCK = os.environ.get("DRILL_SOCK", "/tmp/drill-tree-test.sock") + +results = [] + + +def ok(name, cond, got): + results.append("%s\t%s\t%s" % ("PASS" if cond else "FAIL", name, got)) + + +def main(): + if not CONFIG or not os.path.isfile(CONFIG): + sys.stderr.write("tree_drive.py: DRILL_CONFIG not a file: %r\n" % CONFIG) + return 2 + + work = os.path.join(os.path.dirname(os.path.abspath(__file__)), "work") + root = os.path.join(work, "tree_%d" % os.getpid()) + os.makedirs(os.path.join(root, "sub"), exist_ok=True) + for f in ("a.py", "b.py", "d.py", "e.py"): + with open(os.path.join(root, f), "w") as fh: + fh.write("print(1)\n") + with open(os.path.join(root, "sub", "c.py"), "w") as fh: + fh.write("print(2)\n") + + try: + os.unlink(SOCK) + except OSError: + pass + pid, fd = os.forkpty() + if pid == 0: + os.environ["TERM"] = "xterm-256color" + os.environ.pop("NVIM", None) + # cwd is the fixture: nvim-tree roots the tree at the working directory + os.chdir(root) + os.execvp("nvim", ["nvim", "--listen", SOCK, "-i", "NONE", "-u", CONFIG, "a.py"]) + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 100, 0, 0)) + for _ in range(60): + if os.path.exists(SOCK): + break + time.sleep(0.15) + time.sleep(1.5) + + # Everything nvim paints, kept: the right-click menu is a floating window + # with no API to ask "what does it say", so those cases read the pty + # stream itself, escape-stripped -- same technique as quit_drive.py. + painted = [] + + def drain(): + while True: + r, _, _ = select.select([fd], [], [], 0.05) + if not r: + return + try: + data = os.read(fd, 65536) + if not data: + return + painted.append(data.decode("utf-8", "replace")) + except OSError: + return + + def screen_since_mark(): + raw = "".join(painted) + txt = re.sub(r"\x1b\[[0-9;:?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[>=]", "", raw) + return re.sub(r"\s+", " ", txt) + + def q(expr, t=4): + """The short timeout IS an assertion: a gesture that wedges the editor + behind a modal makes --remote-expr block, and this reports + instead of hanging the whole suite.""" + try: + r = subprocess.run(["nvim", "--server", SOCK, "--remote-expr", expr], + capture_output=True, text=True, timeout=t, + stdin=subprocess.DEVNULL) + return (r.stdout or r.stderr).rstrip("\n") + except subprocess.TimeoutExpired: + return "" + + def press(seq, w=0.8): + os.write(fd, seq.encode()) + time.sleep(w) + drain() + + # SGR 1006 mouse reports, 1-based col/row. M = press, m = release, + # button+32 = motion with the button held. Same wire format as + # mouse_drive.py; modifiers ride the button bits (Ctrl adds 16). + def click(col, row, button=0, w=0.8): + press("\x1b[<%d;%d;%dM" % (button, col, row), 0.12) + press("\x1b[<%d;%d;%dm" % (button, col, row), w) + + def drag(col1, row1, col2, row2, w=0.8): + press("\x1b[<0;%d;%dM" % (col1, row1), 0.12) + press("\x1b[<32;%d;%dM" % ((col1 + col2) // 2, (row1 + row2) // 2), 0.12) + press("\x1b[<32;%d;%dM" % (col2, row2), 0.12) + press("\x1b[<0;%d;%dm" % (col2, row2), w) + + def tree_win(): + return q('luaeval("(function() for _, w in ipairs(vim.api.nvim_list_wins()) do ' + 'if vim.bo[vim.api.nvim_win_get_buf(w)].filetype == \\"NvimTree\\" ' + 'then return w end end return -1 end)()")') + + def tree_rows(): + w = tree_win() + if w == "-1": + return [] + return q('join(getbufline(winbufnr(win_id2win(%s)), 1, "$"), "\\n")' % w).split("\n") + + def row_of(name): + """Screen row of the tree entry containing `name`: buffer row + 1, + because the winbar toolbar occupies the window's first screen line.""" + for i, line in enumerate(tree_rows(), start=1): + if name in line: + return i + 1 + return -1 + + def poll(path, want=True, t=6.0): + deadline = time.time() + t + while time.time() < deadline: + if os.path.exists(path) == want: + return True + time.sleep(0.2) + return os.path.exists(path) == want + + drain() + try: + # ---- the toggle --------------------------------------------------- + ok("starts_typing_in_the_file", q("mode(1)") == "i", q("mode(1)")) + + press("\x02") # Ctrl+B, from insert + ok("ctrl_b_opens_sidebar", q("winnr('$')") == "2", q("winnr('$')")) + ok("sidebar_is_nvimtree", tree_win() != "-1", tree_win()) + ok("focus_stays_in_file", q("&filetype") == "python", q("&filetype")) + ok("still_typing_after_open", q("mode(1)") == "i", q("mode(1)")) + + tw = tree_win() + ok("tree_virtualedit_off", + q("getwinvar(win_id2win(%s), '&virtualedit')" % tw) == "none", + q("getwinvar(win_id2win(%s), '&virtualedit')" % tw)) + ok("file_virtualedit_untouched", q("&virtualedit") == "all", q("&virtualedit")) + wb = q("getwinvar(win_id2win(%s), '&winbar')" % tw) + ok("toolbar_in_winbar", "[+ File]" in wb and "[+ Folder]" in wb, wb) + + press("\x1b[98;9u") # Cmd+B as CSI-u + ok("cmd_b_closes_sidebar", q("winnr('$')") == "1", q("winnr('$')")) + ok("typing_after_close", q("mode(1)") == "i", q("mode(1)")) + press("\x1b[98;9u") + ok("cmd_b_reopens_sidebar", q("winnr('$')") == "2", q("winnr('$')")) + + # ---- single click opens / expands --------------------------------- + r = row_of("b.py") + ok("tree_lists_fixture", r > 0, "row=%d rows=%r" % (r, tree_rows())) + click(3, r) + ok("click_opens_file", q("expand('%:t')") == "b.py", q("expand('%:t')")) + ok("click_lands_typing", q("mode(1)") == "i", q("mode(1)")) + + r = row_of("sub") + click(3, r) + ok("click_expands_folder", row_of("c.py") > 0, "\n".join(tree_rows())) + click(3, r) # collapse it again + ok("click_collapses_folder", row_of("c.py") == -1, "\n".join(tree_rows())) + + # ---- the toolbar buttons ------------------------------------------ + # park the tree cursor on a file at the root first -- the buttons + # create "in here" for a folder row, "next to me" for a file row -- + # then click the button and type only a name + click(3, row_of("b.py")) + click(4, 1, w=0.6) # [+ File] in the winbar + press("zz.py\r", 1.0) + ok("toolbar_new_file_on_disk", poll(os.path.join(root, "zz.py")), + str(os.path.exists(os.path.join(root, "zz.py")))) + + click(15, 1, w=0.6) # [+ Folder] + press("newdir\r", 1.0) + ok("toolbar_new_folder_on_disk", + poll(os.path.join(root, "newdir")), + str(os.path.isdir(os.path.join(root, "newdir")))) + ok("no_modal_after_toolbar", q("mode(1)") != "", q("mode(1)")) + + # ---- the right-click menu ----------------------------------------- + def floats(): + return q('len(filter(map(getwininfo(), ' + '"nvim_win_get_config(v:val.winid).relative"), "v:val != \'\'"))') + + r = row_of("d.py") + del painted[:] + click(3, r, button=2, w=1.0) # right-click d.py + ok("right_click_draws_menu", floats() != "0", floats()) + seen = screen_since_mark() + ok("menu_lists_drill_items", + "New file" in seen and "Rename" in seen and "Delete" in seen, + seen[-300:]) + + # the float sits at the click: border corner one row below the mouse, + # text one more in. "Open" is the 4th text line (file, folder, + # separator, Open). + click(7, r + 5, w=1.2) + ok("menu_open_item_opens_file", q("expand('%:t')") == "d.py", q("expand('%:t')")) + ok("menu_gone_after_item_click", floats() == "0", floats()) + ok("typing_after_menu_open", q("mode(1)") == "i", q("mode(1)")) + + # outside the tree the stock right-click survives: the built-in + # popup_setpos menu, not drill's -- then Esc dismisses it + del painted[:] + click(60, 5, button=2, w=1.0) + seen = screen_since_mark() + ok("file_right_click_stays_stock", "New file" not in seen, seen[-200:]) + press("\x1b", 0.5) + ok("no_wedge_after_stock_menu", q("mode(1)") != "", q("mode(1)")) + + # ---- ctrl-click multi-select -------------------------------------- + def mark_count(): + return q('luaeval("#require(\\"nvim-tree.api\\").marks.list()")') + + click(3, row_of("b.py"), button=16, w=0.6) # Ctrl = +16 in SGR + ok("ctrl_click_marks_one", mark_count() == "1", mark_count()) + click(3, row_of("d.py"), button=16, w=0.6) + ok("ctrl_click_marks_two", mark_count() == "2", mark_count()) + + # ---- drag and drop ------------------------------------------------ + # e.py is NOT marked: dragging it onto sub/ moves it alone, and the + # marked pair stays marked + drag(3, row_of("e.py"), 3, row_of("sub"), w=1.5) + ok("drag_moves_file_into_folder", + poll(os.path.join(root, "sub", "e.py")) and + poll(os.path.join(root, "e.py"), want=False), + "sub/e.py=%s root/e.py=%s" % (os.path.exists(os.path.join(root, "sub", "e.py")), + os.path.exists(os.path.join(root, "e.py")))) + ok("marks_survive_unmarked_drag", mark_count() == "2", mark_count()) + + # b.py IS marked: dragging it takes d.py along + drag(3, row_of("b.py"), 3, row_of("sub"), w=1.5) + ok("drag_marked_pair_moves_both", + poll(os.path.join(root, "sub", "b.py")) and + poll(os.path.join(root, "sub", "d.py")) and + poll(os.path.join(root, "b.py"), want=False) and + poll(os.path.join(root, "d.py"), want=False), + str(sorted(os.listdir(os.path.join(root, "sub"))))) + ok("marks_clear_after_marked_drag", mark_count() == "0", mark_count()) + + # a press that drifts one cell before the button comes up is a CLICK + # that jittered, not a drag -- same contract as mouse_drive.py + ra = row_of("a.py") + press("\x1b[<0;3;%dM" % ra, 0.12) + press("\x1b[<32;4;%dM" % ra, 0.12) + press("\x1b[<0;4;%dm" % ra, 1.0) + ok("jitter_drag_is_a_click", q("expand('%:t')") == "a.py", q("expand('%:t')")) + ok("jitter_moved_nothing", os.path.exists(os.path.join(root, "a.py")), + str(os.listdir(root))) + + # ---- three windows: tree + file + interpreter --------------------- + # opens the REPL botright, spanning the full width UNDER the + # tree -- the IDE layout. The tree must survive it. + press("\x05", 3.5) + ok("repl_opens_under_tree", q("winnr('$')") == "3", q("winnr('$')")) + ok("repl_lands_at_prompt", q("mode(1)") == "t", q("mode(1)")) + ok("tree_survives_repl", tree_win() != "-1", tree_win()) + + # a file opened from the tree must land in the FILE window -- the + # window_picker excludes terminals -- never in the interpreter + click(3, row_of("zz.py"), w=1.2) + ok("tree_open_lands_in_file_window", + q("expand('%:t')") == "zz.py" and q("&buftype") == "", + "%s buftype=%r" % (q("expand('%:t')"), q("&buftype"))) + ok("repl_survives_tree_open", q("winnr('$')") == "3", q("winnr('$')")) + + # drill's own keys pressed IN the tree: never a traceback behind a + # modal, never a stray window -- the netrw contract, kept here too + click(3, row_of("sub"), w=0.8) # focus the tree + press("\x13", 1.0) # Ctrl+S + ok("ctrl_s_in_tree_no_wedge", q("mode(1)") != "", q("mode(1)")) + ok("ctrl_s_in_tree_no_window", q("winnr('$')") == "3", q("winnr('$')")) + press("\x05", 2.0) # Ctrl+E from the tree + ok("ctrl_e_in_tree_no_wedge", q("mode(1)") != "", q("mode(1)")) + ok("ctrl_e_in_tree_no_extra_window", q("winnr('$')") == "3", q("winnr('$')")) + + # Ctrl+B from inside the tree still closes it, and you land typing + press("\x02", 1.0) + ok("ctrl_b_in_tree_closes_it", q("winnr('$')") == "2", q("winnr('$')")) + ok("typing_after_tree_close_with_repl", + q("mode(1)") in ("i", "t"), q("mode(1)")) + + # ---- the tree roots where your file lives ------------------------- + # `d lld-prac main` runs nvim from wherever the shell was, so rooting + # at the CWD showed a tree of everything EXCEPT the project. The root + # must be the folder of the file under the caret, each time it opens. + def tree_root(): + return q('luaeval("require(\\"nvim-tree.api\\")' + '.tree.get_nodes().absolute_path")') + + subprocess.run(["nvim", "--server", SOCK, "--remote-send", + "k:e sub/c.py"], + capture_output=True, stdin=subprocess.DEVNULL) + time.sleep(1.0) + press("\x02", 1.2) + ok("tree_roots_at_files_folder", + tree_root().endswith("/sub"), tree_root()) + ok("file_listed_under_new_root", row_of("c.py") > 0, + "\n".join(tree_rows())) + + press("\x02", 0.8) # close it again + subprocess.run(["nvim", "--server", SOCK, "--remote-send", + "k:e zz.py"], + capture_output=True, stdin=subprocess.DEVNULL) + time.sleep(1.0) + press("\x02", 1.2) + ok("tree_root_follows_reopen", + tree_root().rstrip("/").endswith(os.path.basename(root)), + tree_root()) + + # ---- the EMFILE regression ---------------------------------------- + # nvim-tree registers one filesystem watcher per directory; rooted + # somewhere large that exhausted the fd limit and sprayed "[NvimTree] + # File system watcher failed (EMFILE)" over the quit prompt until + # Enter fed the message pager instead of the dialog. Watchers must + # stay off -- drill's files change from inside the editor. + w = q('luaeval("tostring(require(\\"nvim-tree.config\\")' + '.g.filesystem_watchers.enable)")') + ok("fs_watchers_disabled", w == "false", w) + + # the netrw listing roots the tree at the directory it is showing + press("\x02", 0.8) # close + subprocess.run(["nvim", "--server", SOCK, "--remote-send", + "k:e sub"], + capture_output=True, stdin=subprocess.DEVNULL) + time.sleep(1.0) + press("\x02", 1.2) + ok("netrw_roots_tree_at_its_dir", + tree_root().rstrip("/").endswith("/sub"), tree_root()) + + # ...and a toggle from the REPL keeps that root instead of yanking it + # back to the shell's cwd (which is how Cmd+B from the interpreter + # used to hand you a tree of ~) + press("\x02", 0.8) # close + subprocess.run(["nvim", "--server", SOCK, "--remote-send", + "jj"], + capture_output=True, stdin=subprocess.DEVNULL) + time.sleep(0.6) + ok("parked_in_the_repl", q("&buftype") == "terminal", q("&buftype")) + press("\x1b[98;9u", 1.2) # Cmd+B, terminal mode + ok("repl_toggle_keeps_root", + tree_root().rstrip("/").endswith("/sub"), tree_root()) + + # ---- Ctrl+E out of the REPL lands in the FILE, not the sidebar ---- + # Hiding a split hands focus to a neighboring window, and with the + # tree open that neighbor is the tree -- a window you cannot type in. + # "hide it and go back to the code" has to mean the code. + subprocess.run(["nvim", "--server", SOCK, "--remote-send", + "k:e zz.pyj"], + capture_output=True, stdin=subprocess.DEVNULL) + time.sleep(1.0) # a real file up top + press("\x05", 2.0) # Ctrl+E in the REPL + ok("ctrl_e_hides_repl_tree_open", q("winnr('$')") == "2", q("winnr('$')")) + ok("ctrl_e_lands_in_editor", q("&filetype") == "python", q("&filetype")) + ok("ctrl_e_lands_typing", q("mode(1)") == "i", q("mode(1)")) + finally: + subprocess.run(["nvim", "--server", SOCK, "--remote-send", ":qa!"], + capture_output=True, stdin=subprocess.DEVNULL) + time.sleep(0.4) + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + shutil.rmtree(root, ignore_errors=True) + + sys.stdout.write("\n".join(results) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vendor.sh b/vendor.sh new file mode 100755 index 0000000..011fa1f --- /dev/null +++ b/vendor.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# ============================================================================ +# vendor.sh -- fetch the sidebar's plugins, at pinned commits, into vendor/. +# +# drill has no plugin manager, and this is not one: it is a table of three +# (name, url, sha) rows and a loop. Each checkout is shallow, detached, and +# byte-reproducible -- the same SHA on every machine, today and in a year. +# Nothing here runs at editor startup; explorer.lua only reads the result. +# +# Called by install.sh after copying files, by tests/run.sh as a preflight, +# or by hand after a git pull. Already at the pin? It is an offline no-op. +# ============================================================================ +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENDOR="$DIR/vendor" + +# Pinned 2026-08-16. Bump deliberately: update the SHA, run ./vendor.sh, +# then run ./tests/run.sh -- suite_tree exercises every API call the glue +# makes, so a pin bump that breaks one fails loudly before it ships. +PINS=" +nvim-tree.lua https://github.com/nvim-tree/nvim-tree.lua.git b2aadda94b107480c48e548d6db51c6840b7b33c +volt https://github.com/nvzone/volt.git 620de1321f275ec9d80028c68d1b88b409c0c8b1 +menu https://github.com/nvzone/menu.git 7a0a4a2896b715c066cfbe320bdc048091874cc6 +" + +command -v git >/dev/null 2>&1 || { + echo "vendor.sh: git is required to fetch the sidebar plugins" >&2 + exit 1 +} + +echo "$PINS" | while read -r name url sha; do + [ -z "$name" ] && continue + dest="$VENDOR/$name" + have="$(git -C "$dest" rev-parse HEAD 2>/dev/null || true)" + if [ "$have" = "$sha" ]; then + echo "vendor.sh: $name already at ${sha:0:12}" + continue + fi + echo "vendor.sh: fetching $name @ ${sha:0:12}" + rm -rf "$dest" + mkdir -p "$dest" + git -C "$dest" init -q + # GitHub serves fetches of a bare SHA (allow-any-sha1-in-want), so this + # needs no branch name and survives upstream force-pushes. + git -C "$dest" fetch -q --depth 1 "$url" "$sha" + git -C "$dest" checkout -q FETCH_HEAD +done + +echo "vendor.sh: vendor/ ready"