diff --git a/.github/scripts/setup-link-preview-tests.sh b/.github/scripts/setup-link-preview-tests.sh index f56f589c..fc77e3bb 100644 --- a/.github/scripts/setup-link-preview-tests.sh +++ b/.github/scripts/setup-link-preview-tests.sh @@ -3,9 +3,7 @@ set -euo pipefail install_dir="${1:?Usage: setup-link-preview-tests.sh INSTALL_DIR}" script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -snacks_revision=$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))["snacks.nvim"]["commit"])' \ - "$script_dir/../../nvim/.config/nvim/lazy-lock.json") -neovim_version=v0.11.5 +bash "$script_dir/setup-neovim-tests.sh" "$install_dir" html_revision=73a3947324f6efddf9e17c0ea58d454843590cc02 markdown_revision=f969cd3ae3f9fbd4e43205431d0ae286014c05b5 @@ -27,9 +25,6 @@ build_parser() { -o "$parser_dir/$language.so" } -download_archive \ - "https://github.com/neovim/neovim/releases/download/$neovim_version/nvim-linux-x86_64.tar.gz" \ - "$install_dir/nvim" download_archive \ "https://github.com/tree-sitter/tree-sitter-html/archive/$html_revision.tar.gz" \ "$install_dir/html" @@ -40,7 +35,3 @@ download_archive \ build_parser html "$install_dir/html/src" build_parser markdown "$install_dir/markdown/tree-sitter-markdown/src" build_parser markdown_inline "$install_dir/markdown/tree-sitter-markdown-inline/src" - -download_archive \ - "https://github.com/folke/snacks.nvim/archive/$snacks_revision.tar.gz" \ - "$install_dir/snacks.nvim" diff --git a/.github/scripts/setup-neovim-tests.sh b/.github/scripts/setup-neovim-tests.sh new file mode 100644 index 00000000..5ea02711 --- /dev/null +++ b/.github/scripts/setup-neovim-tests.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +install_dir="${1:?Usage: setup-neovim-tests.sh INSTALL_DIR}" +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +snacks_revision=$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))["snacks.nvim"]["commit"])' \ + "$script_dir/../../nvim/.config/nvim/lazy-lock.json") +neovim_version=v0.11.5 + +download_archive() { + local url="$1" + local destination="$2" + mkdir -p "$destination" + curl --fail --silent --show-error --location --retry 2 "$url" \ + | tar -xz --strip-components=1 -C "$destination" +} + +download_archive \ + "https://github.com/neovim/neovim/releases/download/$neovim_version/nvim-linux-x86_64.tar.gz" \ + "$install_dir/nvim" + +download_archive \ + "https://github.com/folke/snacks.nvim/archive/$snacks_revision.tar.gz" \ + "$install_dir/snacks.nvim" diff --git a/.github/workflows/test-link-preview.yml b/.github/workflows/test-link-preview.yml index 51bfed7d..b1a01c5d 100644 --- a/.github/workflows/test-link-preview.yml +++ b/.github/workflows/test-link-preview.yml @@ -9,6 +9,7 @@ on: - 'nvim/.config/nvim/lazy-lock.json' - '.github/workflows/test-link-preview.yml' - '.github/scripts/setup-link-preview-tests.sh' + - '.github/scripts/setup-neovim-tests.sh' push: branches: [main] paths: @@ -18,6 +19,7 @@ on: - 'nvim/.config/nvim/lazy-lock.json' - '.github/workflows/test-link-preview.yml' - '.github/scripts/setup-link-preview-tests.sh' + - '.github/scripts/setup-neovim-tests.sh' permissions: contents: read diff --git a/.github/workflows/test-neovim.yml b/.github/workflows/test-neovim.yml new file mode 100644 index 00000000..b585fbd6 --- /dev/null +++ b/.github/workflows/test-neovim.yml @@ -0,0 +1,40 @@ +name: Test Neovim configuration + +on: + pull_request: + paths: + - 'nvim/.config/nvim/init.lua' + - 'nvim/.config/nvim/lua/config/**' + - 'nvim/.config/nvim/lazy-lock.json' + - 'tests/daily-note.lua' + - '.github/workflows/test-neovim.yml' + - '.github/scripts/setup-neovim-tests.sh' + push: + branches: [main] + paths: + - 'nvim/.config/nvim/init.lua' + - 'nvim/.config/nvim/lua/config/**' + - 'nvim/.config/nvim/lazy-lock.json' + - 'tests/daily-note.lua' + - '.github/workflows/test-neovim.yml' + - '.github/scripts/setup-neovim-tests.sh' + +permissions: + contents: read + +concurrency: + group: neovim-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - name: Install test dependencies + run: | + bash .github/scripts/setup-neovim-tests.sh "$RUNNER_TEMP/neovim" + echo "$RUNNER_TEMP/neovim/nvim/bin" >> "$GITHUB_PATH" + - name: Test daily note editor state + run: SNACKS_TEST_DIR="$RUNNER_TEMP/neovim/snacks.nvim" nvim --headless -u NONE -l tests/daily-note.lua diff --git a/fish/.config/fish/functions/__daily_notes_dir.fish b/fish/.config/fish/functions/__daily_notes_dir.fish new file mode 100644 index 00000000..bbc9e2fa --- /dev/null +++ b/fish/.config/fish/functions/__daily_notes_dir.fish @@ -0,0 +1,26 @@ +function __daily_notes_dir --description 'Find the daily notes repository' + if set -q DAILY_NOTES_DIR; and test -n "$DAILY_NOTES_DIR" + if not test -d "$DAILY_NOTES_DIR"; or not test -f "$DAILY_NOTES_DIR/justfile" + echo 'DAILY_NOTES_DIR must point to a directory containing a justfile.' >&2 + return 1 + end + path resolve -- "$DAILY_NOTES_DIR" + return + end + + set -l source_file (path resolve -- (status filename)) + set -l parent (path resolve -- (path dirname -- "$source_file")/../../../../..) + set -l matches + for justfile in "$parent"/*/justfile + set -l recipes (command just --justfile "$justfile" --summary 2>/dev/null | string split ' ') + if contains -- daily $recipes + set -a matches (path dirname -- "$justfile") + end + end + + if test (count $matches) -ne 1 + echo 'Expected one sibling directory with a daily recipe. Set DAILY_NOTES_DIR to choose explicitly.' >&2 + return 1 + end + printf '%s\n' "$matches[1]" +end diff --git a/fish/.config/fish/functions/daily.fish b/fish/.config/fish/functions/daily.fish new file mode 100644 index 00000000..534e2865 --- /dev/null +++ b/fish/.config/fish/functions/daily.fish @@ -0,0 +1,4 @@ +function daily --description 'Open today’s note using the configured daily recipe' + set -l directory (__daily_notes_dir); or return 1 + DAILY_NOTE_PROSE=1 command just --justfile "$directory/justfile" --working-directory "$directory" daily $argv +end diff --git a/nvim/.config/nvim/init.lua b/nvim/.config/nvim/init.lua index 85be7a7f..47dd8b9d 100644 --- a/nvim/.config/nvim/init.lua +++ b/nvim/.config/nvim/init.lua @@ -4,3 +4,13 @@ require("config.lazy") vim.filetype.add({ extension = { pkl = "pkl" }, }) + +if vim.env.DAILY_NOTE_PROSE == "1" then + vim.env.DAILY_NOTE_PROSE = nil + vim.api.nvim_create_autocmd("VimEnter", { + once = true, + callback = function() + require("config.prose").enable() + end, + }) +end diff --git a/nvim/.config/nvim/lua/config/keymaps.lua b/nvim/.config/nvim/lua/config/keymaps.lua index 3fd200f4..81bbd8df 100644 --- a/nvim/.config/nvim/lua/config/keymaps.lua +++ b/nvim/.config/nvim/lua/config/keymaps.lua @@ -3,6 +3,30 @@ local map = vim.keymap.set +map("n", "nd", function() + local result = vim.system({ + "fish", "-c", + '__daily_notes_dir; and printf "%s\\n" "$DAILY_NOTES_FORMAT"', + }, { text = true }):wait() + if result.code ~= 0 then + vim.notify(vim.trim(result.stderr), vim.log.levels.ERROR) + return + end + local settings = vim.split(result.stdout, "\n", { plain = true }) + local directory, format = settings[1], settings[2] + if not format or format == "" then + vim.notify("Set the Fish universal variable DAILY_NOTES_FORMAT to the relative note date format.", vim.log.levels.WARN) + return + end + + local path = vim.fs.joinpath(directory, os.date(format)) + vim.fn.mkdir(vim.fs.dirname(path), "p") + if vim.fn.bufnr(path) ~= vim.api.nvim_get_current_buf() then + vim.api.nvim_cmd({ cmd = "edit", args = { path }, magic = { file = false, bar = false } }, {}) + end + require("config.prose").enable() +end, { desc = "Open Daily Note" }) + map("n", "gd", function() Snacks.terminal({ "gh", "dash" }, { win = { position = "float" } }) end, { desc = "GitHub Dashboard" }) @@ -12,23 +36,5 @@ map("n", "bo", "BufOnly", { desc = "Delete all other buffers" } map("n", "yp", "let @+ = expand('%:p')", { desc = "Copy absolute path to clipboard" }) map("n", "p", function() - Snacks.zen({ - toggles = { dim = false, git_signs = false, mini_diff_signs = false }, - win = { - width = 90, - wo = { - wrap = true, - linebreak = true, - breakindent = true, - spell = true, - number = false, - relativenumber = false, - signcolumn = "no", - foldcolumn = "0", - cursorline = false, - colorcolumn = "", - list = false, - }, - }, - }) + require("config.prose").toggle() end, { desc = "Toggle Prose Mode" }) diff --git a/nvim/.config/nvim/lua/config/prose.lua b/nvim/.config/nvim/lua/config/prose.lua new file mode 100644 index 00000000..f1840ff5 --- /dev/null +++ b/nvim/.config/nvim/lua/config/prose.lua @@ -0,0 +1,37 @@ +local M = {} +local prose_win + +function M.toggle() + prose_win = Snacks.zen({ + toggles = { dim = false, git_signs = false, mini_diff_signs = false }, + win = { + width = 90, + wo = { + wrap = true, + linebreak = true, + breakindent = true, + spell = true, + number = false, + relativenumber = false, + signcolumn = "no", + foldcolumn = "0", + cursorline = false, + colorcolumn = "", + list = false, + }, + }, + }) +end + +function M.enable() + local win = Snacks.zen.win + if win and win:valid() then + if win == prose_win then + return + end + win:close() + end + M.toggle() +end + +return M diff --git a/tests/daily-note.lua b/tests/daily-note.lua new file mode 100644 index 00000000..988dbead --- /dev/null +++ b/tests/daily-note.lua @@ -0,0 +1,69 @@ +local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h") +vim.opt.rtp:prepend(root .. "/nvim/.config/nvim") +vim.opt.rtp:prepend(vim.env.SNACKS_TEST_DIR or vim.fn.stdpath("data") .. "/lazy/snacks.nvim") +require("snacks").setup({}) +vim.o.hidden = true +vim.o.columns = 140 + +local directory = vim.fn.tempname() +vim.fn.mkdir(directory, "p") + +-- Keep repository discovery outside these editor-state regression tests. +vim.system = function() + return { + wait = function() + return { code = 0, stdout = directory .. "\n%Y-%m-%d.md\n" } + end, + } +end +require("config.keymaps") +local daily +for _, map in ipairs(vim.api.nvim_get_keymap("n")) do + if map.desc == "Open Daily Note" then + daily = map.callback + end +end +assert(daily, "daily note mapping was not registered") + +local function assert_prose() + assert(Snacks.zen.win and Snacks.zen.win:valid(), "prose window is missing") + assert(vim.wo.wrap and vim.wo.linebreak and vim.wo.spell, "prose options were not applied") + assert(not vim.wo.number and not vim.wo.relativenumber, "line numbers are still enabled") + assert(vim.api.nvim_win_get_width(0) == 90, "prose window has the wrong width") +end + +local function run() + vim.api.nvim_cmd({ cmd = "edit", args = { directory .. "/" .. os.date("%Y-%m-%d.md") } }, {}) + local note = vim.api.nvim_get_current_buf() + vim.api.nvim_buf_set_lines(note, 0, -1, false, { "unsaved daily note" }) + daily() + assert(vim.api.nvim_get_current_buf() == note, "daily shortcut replaced the note buffer") + assert(vim.bo.modified and vim.api.nvim_get_current_line() == "unsaved daily note", "unsaved text was lost") + assert_prose() + local prose_window = vim.api.nvim_get_current_win() + daily() + assert(vim.api.nvim_get_current_win() == prose_window, "repeated shortcut recreated prose mode") + Snacks.zen.win:close() + + for _, mode in ipairs({ "zoom", "zen" }) do + vim.cmd.enew() + vim.wo.wrap = false + vim.wo.spell = false + Snacks.zen[mode]() + daily() + assert(vim.api.nvim_get_current_buf() == note, mode .. " did not open the existing note") + assert(vim.bo.modified and vim.api.nvim_get_current_line() == "unsaved daily note", "hidden note edits were lost") + assert_prose() + require("config.prose").toggle() + assert(not (Snacks.zen.win and Snacks.zen.win:valid()), "prose toggle did not close the window") + end +end + +local ok, err = xpcall(run, debug.traceback) +vim.fn.delete(directory, "rf") +if not ok then + io.stderr:write(err .. "\n") + vim.cmd("cquit 1") +end +print("Daily note regression tests passed") +vim.cmd("qa!")