From 094cdd84e8a450de3ec7579913024d7dbcfd82b8 Mon Sep 17 00:00:00 2001 From: "zizhou teng (n451)" <2020200706@ruc.edu.cn> Date: Mon, 28 Apr 2025 19:56:41 +0800 Subject: [PATCH 1/4] feat: support for orgmode style cycling --- CHANGELOG.md | 2 +- lua/obsidian/config.lua | 264 +++++++++++++++++++++++ lua/obsidian/mappings.lua | 52 +++++ lua/obsidian/util.lua | 427 +++++++++++++++++++++++++++++++++++++- 4 files changed, 743 insertions(+), 2 deletions(-) create mode 100644 lua/obsidian/mappings.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d73bf8..43798a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added support for obsidian style `%%` comment. - Added `opts.daily_notes.workdays_only` option which, when false, adds support for weekend daily notes. - Added configuration option `completion.match_case`, allowing removal of duplicates when note case does not match search string case. Defaults to true for non-breaking behavior. +- Added support for orgmode style cycling heading state mapping, `` and `` ### Changed @@ -308,7 +309,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 There's a lot of new features and improvements here that I'm really excited about 🥳 They've improved my workflow a ton and I hope they do for you too. To highlight the 3 biggest additions: 1. 🔗 Full support for header anchor links and block links! That means both for following links and completion of links. Various forms of anchor/block links are support. Here are a few examples: - - Typical Obsidian-style wiki links, e.g. `[[My note#Heading 1]]`, `[[My note#Heading 1#Sub heading]]`, `[[My note#^block-123]]`. - Wiki links with a label, e.g. `[[my-note#heading-1|Heading 1 in My Note]]`. - Markdown links, e.g. `[Heading 1 in My Note](my-note.md#heading-1)`. diff --git a/lua/obsidian/config.lua b/lua/obsidian/config.lua index 4c2b53f3..3cbbeeda 100644 --- a/lua/obsidian/config.lua +++ b/lua/obsidian/config.lua @@ -557,4 +557,268 @@ See: https://github.com/obsidian-nvim/obsidian.nvim/wiki/Keymaps]] return opts end +---@enum obsidian.config.OpenStrategy +config.OpenStrategy = { + current = "current", + vsplit = "vsplit", + hsplit = "hsplit", +} + +---@enum obsidian.config.SortBy +config.SortBy = { + path = "path", + modified = "modified", + accessed = "accessed", + created = "created", +} + +---@enum obsidian.config.NewNotesLocation +config.NewNotesLocation = { + current_dir = "current_dir", + notes_subdir = "notes_subdir", +} + +---@enum obsidian.config.LinkStyle +config.LinkStyle = { + wiki = "wiki", + markdown = "markdown", +} + +---@class obsidian.config.CompletionOpts +--- +---@field nvim_cmp boolean +---@field blink boolean +---@field min_chars integer +---@field match_case boolean +config.CompletionOpts = {} + +--- Get defaults. +--- +---@return obsidian.config.CompletionOpts +config.CompletionOpts.default = function() + local has_nvim_cmp, _ = pcall(require, "cmp") + return { + nvim_cmp = has_nvim_cmp, + min_chars = 2, + match_case = true, + } +end + +---@class obsidian.config.MappingOpts +config.MappingOpts = {} + +---Get defaults. +---@return obsidian.config.MappingOpts +config.MappingOpts.default = function() + local mappings = require "obsidian.mappings" + + return { + ["gf"] = mappings.gf_passthrough(), + ["ch"] = mappings.toggle_checkbox(), + [""] = mappings.smart_action(), + [""] = mappings.cycle_global(), + [""] = mappings.cycle(), + } +end + +---@class obsidian.config.PickerNoteMappingOpts +--- +---@field new string|? +---@field insert_link string|? +config.PickerNoteMappingOpts = {} + +---Get defaults. +---@return obsidian.config.PickerNoteMappingOpts +config.PickerNoteMappingOpts.default = function() + return { + new = "", + insert_link = "", + } +end + +---@class obsidian.config.PickerTagMappingOpts +--- +---@field tag_note string|? +---@field insert_tag string|? +config.PickerTagMappingOpts = {} + +---@return obsidian.config.PickerTagMappingOpts +config.PickerTagMappingOpts.default = function() + return { + tag_note = "", + insert_tag = "", + } +end + +---@enum obsidian.config.Picker +config.Picker = { + telescope = "telescope.nvim", + fzf_lua = "fzf-lua", + mini = "mini.pick", + snacks = "snacks.pick", +} + +---@class obsidian.config.PickerOpts +--- +---@field name obsidian.config.Picker|? +---@field note_mappings obsidian.config.PickerNoteMappingOpts +---@field tag_mappings obsidian.config.PickerTagMappingOpts +config.PickerOpts = {} + +--- Get the defaults. +--- +---@return obsidian.config.PickerOpts +config.PickerOpts.default = function() + return { + name = nil, + note_mappings = config.PickerNoteMappingOpts.default(), + tag_mappings = config.PickerTagMappingOpts.default(), + } +end + +---@class obsidian.config.DailyNotesOpts +--- +---@field folder string|? +---@field date_format string|? +---@field alias_format string|? +---@field template string|? +---@field default_tags string[]|? +---@field workdays_only boolean +config.DailyNotesOpts = {} + +--- Get defaults. +--- +---@return obsidian.config.DailyNotesOpts +config.DailyNotesOpts.default = function() + return { + folder = nil, + date_format = nil, + alias_format = nil, + default_tags = { "daily-notes" }, + workdays_only = true, + } +end + +---@class obsidian.config.TemplateOpts +--- +---@field folder string|obsidian.Path|? +---@field date_format string|? +---@field time_format string|? +---@field substitutions table|? +config.TemplateOpts = {} + +--- Get defaults. +--- +---@return obsidian.config.TemplateOpts +config.TemplateOpts.default = function() + return { + folder = nil, + date_format = nil, + time_format = nil, + substitutions = {}, + } +end + +---@class obsidian.config.UIOpts +--- +---@field enable boolean +---@field update_debounce integer +---@field max_file_length integer|? +---@field checkboxes table +---@field bullets obsidian.config.UICharSpec|? +---@field external_link_icon obsidian.config.UICharSpec +---@field reference_text obsidian.config.UIStyleSpec +---@field highlight_text obsidian.config.UIStyleSpec +---@field tags obsidian.config.UIStyleSpec +---@field block_ids obsidian.config.UIStyleSpec +---@field hl_groups table +config.UIOpts = {} + +---@class obsidian.config.UICharSpec +--- +---@field char string +---@field hl_group string + +---@class obsidian.config.CheckboxSpec : obsidian.config.UICharSpec +--- +---@field char string +---@field hl_group string +---@field order integer + +---@class obsidian.config.UIStyleSpec +--- +---@field hl_group string + +---@return obsidian.config.UIOpts +config.UIOpts.default = function() + return { + enable = true, + update_debounce = 200, + max_file_length = 5000, + checkboxes = { + [" "] = { order = 1, char = "󰄱", hl_group = "ObsidianTodo" }, + ["~"] = { order = 2, char = "󰰱", hl_group = "ObsidianTilde" }, + ["!"] = { order = 3, char = "", hl_group = "ObsidianImportant" }, + [">"] = { order = 4, char = "", hl_group = "ObsidianRightArrow" }, + ["x"] = { order = 5, char = "", hl_group = "ObsidianDone" }, + }, + bullets = { char = "•", hl_group = "ObsidianBullet" }, + external_link_icon = { char = "", hl_group = "ObsidianExtLinkIcon" }, + reference_text = { hl_group = "ObsidianRefText" }, + highlight_text = { hl_group = "ObsidianHighlightText" }, + tags = { hl_group = "ObsidianTag" }, + block_ids = { hl_group = "ObsidianBlockID" }, + hl_groups = { + ObsidianTodo = { bold = true, fg = "#f78c6c" }, + ObsidianDone = { bold = true, fg = "#89ddff" }, + ObsidianRightArrow = { bold = true, fg = "#f78c6c" }, + ObsidianTilde = { bold = true, fg = "#ff5370" }, + ObsidianImportant = { bold = true, fg = "#d73128" }, + ObsidianBullet = { bold = true, fg = "#89ddff" }, + ObsidianRefText = { underline = true, fg = "#c792ea" }, + ObsidianExtLinkIcon = { fg = "#c792ea" }, + ObsidianTag = { italic = true, fg = "#89ddff" }, + ObsidianBlockID = { italic = true, fg = "#89ddff" }, + ObsidianHighlightText = { bg = "#75662e" }, + }, + } +end + +---@class obsidian.config.AttachmentsOpts +--- +---@field img_folder string Default folder to save images to, relative to the vault root. +---@field img_name_func (fun(): string)|? +---@field img_text_func fun(client: obsidian.Client, path: obsidian.Path): string +---@field confirm_img_paste boolean Whether to confirm the paste or not. Defaults to true. +config.AttachmentsOpts = {} + +---@return obsidian.config.AttachmentsOpts +config.AttachmentsOpts.default = function() + return { + img_folder = "assets/imgs", + img_text_func = function(client, path) + path = client:vault_relative_path(path) or path + return string.format("![%s](%s)", path.name, util.urlencode(tostring(path))) + end, + img_name_func = function() + return string.format("Pasted image %s", os.date "%Y%m%d%H%M%S") + end, + confirm_img_paste = true, + } +end + +---@class obsidian.config.CallbackConfig +--- +---@field post_setup fun(client: obsidian.Client)|? Runs right after the `obsidian.Client` is initialized. +---@field enter_note fun(client: obsidian.Client, note: obsidian.Note)|? Runs when entering a note buffer. +---@field leave_note fun(client: obsidian.Client, note: obsidian.Note)|? Runs when leaving a note buffer. +---@field pre_write_note fun(client: obsidian.Client, note: obsidian.Note)|? Runs right before writing a note buffer. +---@field post_set_workspace fun(client: obsidian.Client, workspace: obsidian.Workspace)|? Runs anytime the workspace is set/changed. +config.CallbackConfig = {} + +---@return obsidian.config.CallbackConfig +config.CallbackConfig.default = function() + return {} +end + return config diff --git a/lua/obsidian/mappings.lua b/lua/obsidian/mappings.lua new file mode 100644 index 00000000..7462cbf7 --- /dev/null +++ b/lua/obsidian/mappings.lua @@ -0,0 +1,52 @@ +local util = require "obsidian.util" + +local M = {} + +---@class obsidian.mappings.MappingConfig +---@field action function +---@field opts table + +---@return obsidian.mappings.MappingConfig +M.smart_action = function() + return { + action = util.smart_action, + opts = { noremap = false, expr = true, buffer = true, desc = "Obsidian smart action" }, + } +end + +---@return obsidian.mappings.MappingConfig +M.gf_passthrough = function() + return { + action = util.gf_passthrough, + opts = { noremap = false, expr = true, buffer = true, desc = "Go to file" }, + } +end + +---@return obsidian.mappings.MappingConfig +M.toggle_checkbox = function() + return { + action = util.toggle_checkbox, + opts = { buffer = true, desc = "Toggle Checkbox" }, + } +end + +---@return obsidian.mappings.MappingConfig +M.cycle_global = function() + return { + action = util.cycle_global, + opts = { buffer = true, desc = "Cycle file heading state" }, + } +end + +---@return obsidian.mappings.MappingConfig +M.cycle = function() + return { + action = util.cycle, + opts = { buffer = true, desc = "Cycle heading state under the cursor" }, + } +end + +vim.keymap.set("n", "(ObsidianCycle)", util.cycle) +vim.keymap.set("n", "(ObsidianCycleGlobal)", util.cycle_global) + +return M diff --git a/lua/obsidian/util.lua b/lua/obsidian/util.lua index 12590f3a..abd4f0ba 100644 --- a/lua/obsidian/util.lua +++ b/lua/obsidian/util.lua @@ -1,5 +1,6 @@ local compat = require "obsidian.compat" local string, table = string, table +local ts = vim.treesitter local util = {} setmetatable(util, { @@ -511,9 +512,282 @@ util.parse_link = function(link, opts) return link_location, link_name, link_type end +<<<<<<< HEAD ------------------------------------ -- Miscellaneous helper functions -- ------------------------------------ +======= +--- Get the tag under the cursor, if there is one. +--- +---@param line string|? +---@param col integer|? +--- +---@return string|? +util.cursor_tag = function(line, col) + local search = require "obsidian.search" + + local current_line = line and line or vim.api.nvim_get_current_line() + local _, cur_col = unpack(vim.api.nvim_win_get_cursor(0)) + cur_col = col or cur_col + 1 -- nvim_win_get_cursor returns 0-indexed column + + for match in iter(search.find_tags(current_line)) do + local open, close, _ = unpack(match) + if open <= cur_col and cur_col <= close then + return string.sub(current_line, open + 1, close) + end + end + + return nil +end + +--- Get the heading under the cursor, if there is one. +--- +---@param line string|? +--- +---@return string|? +util.cursor_heading = function(line) + local current_line = line and line or vim.api.nvim_get_current_line() + return current_line:match "^(%s*)(#+)%s*(.*)$" +end + +util.gf_passthrough = function() + local legacy = require("obsidian").get_client().opts.legacy_commands + if util.cursor_on_markdown_link(nil, nil, true) then + return legacy and "ObsidianFollowLink" or "Obsidian follow_link" + else + return "gf" + end +end + +util.smart_action = function() + local legacy = require("obsidian").get_client().opts.legacy_commands + -- follow link if possible + if util.cursor_on_markdown_link(nil, nil, true) then + return legacy and "ObsidianFollowLink" or "Obsidian follow_link" + end + + -- show notes with tag if possible + if util.cursor_tag(nil, nil) then + return legacy and "ObsidianTags" or "Obsidian tags" + end + + if util.cursor_heading() then + return "(ObsidianCycle)" + end + + -- toggle task if possible + -- cycles through your custom UI checkboxes, default: [ ] [~] [>] [x] + return legacy and "ObsidianToggleCheckbox" or "Obsidian toggle_checkbox" +end + +---Get the path to where a plugin is installed. +---@param name string|? +---@return string|? +util.get_src_root = function(name) + name = name and name or "obsidian.nvim" + for _, path in ipairs(vim.api.nvim_list_runtime_paths()) do + if vim.endswith(path, name) then + return path + end + end + return nil +end + +--- Get info about a plugin. +--- +---@param name string|? +--- +---@return { commit: string|?, path: string }|? +util.get_plugin_info = function(name) + name = name and name or "obsidian.nvim" + + local src_root = util.get_src_root(name) + if src_root == nil then + return nil + end + + local out = { path = src_root } + + local Job = require "plenary.job" + local output, exit_code = Job:new({ ---@diagnostic disable-line: missing-fields + command = "git", + args = { "rev-parse", "HEAD" }, + cwd = src_root, + enable_recording = true, + }):sync(1000) + + if exit_code == 0 then + out.commit = output[1] + end + + return out +end + +---@param cmd string +---@return string|? +util.get_external_dependency_info = function(cmd) + local Job = require "plenary.job" + local output, exit_code = Job:new({ ---@diagnostic disable-line: missing-fields + command = cmd, + args = { "--version" }, + enable_recording = true, + }):sync(1000) + + if exit_code == 0 then + return output[1] + end +end + +---Get an iterator of (bufnr, bufname) over all named buffers. The buffer names will be absolute paths. +--- +---@return function () -> (integer, string)|? +util.get_named_buffers = function() + local idx = 0 + local buffers = vim.api.nvim_list_bufs() + + ---@return integer|? + ---@return string|? + return function() + while idx < #buffers do + idx = idx + 1 + local bufnr = buffers[idx] + if vim.api.nvim_buf_is_loaded(bufnr) then + return bufnr, vim.api.nvim_buf_get_name(bufnr) + end + end + end +end + +---Insert text at current cursor position. +---@param text string +util.insert_text = function(text) + local curpos = vim.fn.getcurpos() + local line_num, line_col = curpos[2], curpos[3] + local indent = string.rep(" ", line_col) + + -- Convert text to lines table so we can handle multi-line strings. + local lines = {} + for line in text:gmatch "[^\r\n]+" do + lines[#lines + 1] = line + end + + for line_index, line in pairs(lines) do + local current_line_num = line_num + line_index - 1 + local current_line = vim.fn.getline(current_line_num) + assert(type(current_line) == "string") + + -- Since there's no column 0, remove extra space when current line is blank. + if current_line == "" then + indent = indent:sub(1, -2) + end + + local pre_txt = current_line:sub(1, line_col) + local post_txt = current_line:sub(line_col + 1, -1) + local inserted_txt = pre_txt .. line .. post_txt + + vim.fn.setline(current_line_num, inserted_txt) + + -- Create new line so inserted_txt doesn't replace next lines + if line_index ~= #lines then + vim.fn.append(current_line_num, indent) + end + end +end + +---@param bufnr integer +---@return string +util.buf_get_full_text = function(bufnr) + local text = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, true), "\n") + if vim.api.nvim_get_option_value("eol", { buf = bufnr }) then + text = text .. "\n" + end + return text +end + +--- Get the current visual selection of text and exit visual mode. +--- +---@param opts { strict: boolean|? }|? +--- +---@return { lines: string[], selection: string, csrow: integer, cscol: integer, cerow: integer, cecol: integer }|? +util.get_visual_selection = function(opts) + opts = opts or {} + -- Adapted from fzf-lua: + -- https://github.com/ibhagwan/fzf-lua/blob/6ee73fdf2a79bbd74ec56d980262e29993b46f2b/lua/fzf-lua/utils.lua#L434-L466 + -- this will exit visual mode + -- use 'gv' to reselect the text + local _, csrow, cscol, cerow, cecol + local mode = vim.fn.mode() + if opts.strict and not vim.endswith(string.lower(mode), "v") then + return + end + + if mode == "v" or mode == "V" or mode == "" then + -- if we are in visual mode use the live position + _, csrow, cscol, _ = unpack(vim.fn.getpos ".") + _, cerow, cecol, _ = unpack(vim.fn.getpos "v") + if mode == "V" then + -- visual line doesn't provide columns + cscol, cecol = 0, 999 + end + -- exit visual mode + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) + else + -- otherwise, use the last known visual position + _, csrow, cscol, _ = unpack(vim.fn.getpos "'<") + _, cerow, cecol, _ = unpack(vim.fn.getpos "'>") + end + + -- Swap vars if needed + if cerow < csrow then + csrow, cerow = cerow, csrow + cscol, cecol = cecol, cscol + elseif cerow == csrow and cecol < cscol then + cscol, cecol = cecol, cscol + end + + local lines = vim.fn.getline(csrow, cerow) + assert(type(lines) == "table") + if vim.tbl_isempty(lines) then + return + end + + -- When the whole line is selected via visual line mode ("V"), cscol / cecol will be equal to "v:maxcol" + -- for some odd reason. So change that to what they should be here. See ':h getpos' for more info. + local maxcol = vim.api.nvim_get_vvar "maxcol" + if cscol == maxcol then + cscol = string.len(lines[1]) + end + if cecol == maxcol then + cecol = string.len(lines[#lines]) + end + + ---@type string + local selection + local n = #lines + if n <= 0 then + selection = "" + elseif n == 1 then + selection = string.sub(lines[1], cscol, cecol) + elseif n == 2 then + selection = string.sub(lines[1], cscol) .. "\n" .. string.sub(lines[n], 1, cecol) + else + selection = string.sub(lines[1], cscol) + .. "\n" + .. table.concat(lines, "\n", 2, n - 1) + .. "\n" + .. string.sub(lines[n], 1, cecol) + end + + return { + lines = lines, + selection = selection, + csrow = csrow, + cscol = cscol, + cerow = cerow, + cecol = cecol, + } +end + ---@param anchor obsidian.note.HeaderAnchor ---@return string util.format_anchor_label = function(anchor) @@ -678,7 +952,7 @@ util.contains_invalid_characters = function(fname) return string.find(fname, "[" .. invalid_chars .. "]") ~= nil end ----Check if a string is NaN +--- Check if a string is NaN --- ---@param v any ---@return boolean @@ -724,4 +998,155 @@ util.fire_callback = function(event, callback, ...) end end +--- Adapted from `nvim-orgmode/orgmode` +--- Cycle all headings in file between "Show All", "Contents" and "Overview" +--- +util.cycle_global = function() + local mode = vim.g.obsidian_global_cycle_mode or "Show All" + if not vim.wo.foldenable or mode == "Show All" then + mode = "Overview" + vim.cmd [[silent! norm! zMzX]] + elseif mode == "Contents" then + mode = "Show All" + vim.cmd [[silent! norm! zR]] + elseif mode == "Overview" then + mode = "Contents" + vim.wo.foldlevel = 1 + vim.cmd [[silent! norm! zx]] + end + vim.api.nvim_echo({ { "Obsidian: " .. mode } }, false, {}) + vim.g.obsidian_global_cycle_mode = mode +end + +---@param bufnr integer +---@param cursor integer[] +---@return TSNode? +local function closest_section_node(bufnr, cursor) + local parser = ts.get_parser(bufnr, "markdown", {}) + assert(parser) + local cursor_range = { cursor[1] - 1, cursor[2], cursor[1] - 1, cursor[2] + 1 } + local node = parser:named_node_for_range(cursor_range) + + if not node then + return nil + end + + if node:type() == "section" then + return node + end + + while node and node:type() ~= "section" do + node = node:parent() + end + + return node +end + +---@param node TSNode +---@return boolean +local function has_child_headlines(node) + return vim.iter(node:iter_children()):any(function(child) + return child:type() == "atx_heading" + end) +end + +---@param node TSNode +---@return TSNode[]? +local function get_child_headlines(node) + local ret = {} + for child in node:iter_children() do + if child:type() == "section" then + ret[#ret + 1] = child + end + end + return ret +end + +---@return boolean +local function is_one_line(node) + local start_row, _, end_row, end_col = node:parent():range() + -- One line sections have end range on the next line with 0 column + -- Example: If headline is on line 5, range will be (5, 1, 6, 0) + return start_row == end_row or (start_row + 1 == end_row and end_col == 0) +end + +---@param node TSNode +---@return boolean +local function can_section_expand(node) + return not is_one_line(node) or has_child_headlines(node) +end + +--- Cycle heading state under cursor +util.cycle = function() + local current_buffer = vim.api.nvim_get_current_buf() + local cursor_position = vim.api.nvim_win_get_cursor(0) + local current_line = vim.fn.line "." + + -- Ensure fold system is active + if not vim.wo.foldenable then + vim.wo.foldenable = true + vim.cmd [[silent! norm! zx]] -- Refresh folds + end + + -- Check current fold state + local current_fold_level = vim.fn.foldlevel(current_line) + if current_fold_level == 0 then + return + end + + -- Handle closed folds first + local is_fold_closed = vim.fn.foldclosed(current_line) ~= -1 + if is_fold_closed then + return vim.cmd [[silent! norm! zo]] -- Open closed fold + end + + -- Find Markdown section structure + local current_section_node = closest_section_node(current_buffer, cursor_position) + if not current_section_node then + return + end + + -- Ignore non-expandable sections + if not can_section_expand(current_section_node) then + return + end + + -- Fold state management + local child_sections = get_child_headlines(current_section_node) + local should_close_parent = #child_sections == 0 + + if not should_close_parent then + local has_nested_structure = false + + -- Process child fold states + for _, child_node in ipairs(child_sections or {}) do + if can_section_expand(child_node) then + has_nested_structure = true + local child_start_line = child_node:start() + 1 + + -- Close open child folds first + if vim.fn.foldclosed(child_start_line) == -1 then + vim.cmd(string.format("silent! keepjumps norm! %dggzc", child_start_line)) + should_close_parent = true + end + end + end + + -- Return to original cursor position + vim.cmd(string.format("silent! keepjumps norm! %dgg", current_line)) + + -- Close parent if no actual nesting exists + if not should_close_parent and not has_nested_structure then + should_close_parent = true + end + end + + -- Execute final fold action + if should_close_parent then + vim.cmd [[silent! norm! zc]] -- Close parent fold + else + vim.cmd [[silent! norm! zczO]] -- Force fold refresh + end +end + return util From ebe990b43057eb5f9c04b3185553153348ee8669 Mon Sep 17 00:00:00 2001 From: Zizhou Teng <412445606@qq.com> Date: Mon, 30 Jun 2025 16:22:16 +0800 Subject: [PATCH 2/4] fix: rebase to main --- lua/obsidian/api.lua | 153 ++++++++++++++- lua/obsidian/init.lua | 10 + lua/obsidian/util.lua | 424 ------------------------------------------ 3 files changed, 162 insertions(+), 425 deletions(-) diff --git a/lua/obsidian/api.lua b/lua/obsidian/api.lua index 0febd974..3ed94ba7 100644 --- a/lua/obsidian/api.lua +++ b/lua/obsidian/api.lua @@ -1,7 +1,7 @@ local M = {} local log = require "obsidian.log" local util = require "obsidian.util" -local iter, string, table = vim.iter, string, table +local ts, iter, string, table = vim.treesitter, vim.iter, string, table ---builtin functions that are impure, interacts with editor state, like vim.api @@ -497,4 +497,155 @@ M.get_icon = function(path) return nil end +--- Adapted from `nvim-orgmode/orgmode` +--- Cycle all headings in file between "Show All", "Contents" and "Overview" +--- +M.cycle_global = function() + local mode = vim.g.obsidian_global_cycle_mode or "Show All" + if not vim.wo.foldenable or mode == "Show All" then + mode = "Overview" + vim.cmd [[silent! norm! zMzX]] + elseif mode == "Contents" then + mode = "Show All" + vim.cmd [[silent! norm! zR]] + elseif mode == "Overview" then + mode = "Contents" + vim.wo.foldlevel = 1 + vim.cmd [[silent! norm! zx]] + end + vim.api.nvim_echo({ { "Obsidian: " .. mode } }, false, {}) + vim.g.obsidian_global_cycle_mode = mode +end + +---@param bufnr integer +---@param cursor integer[] +---@return TSNode? +local function closest_section_node(bufnr, cursor) + local parser = ts.get_parser(bufnr, "markdown", {}) + assert(parser) + local cursor_range = { cursor[1] - 1, cursor[2], cursor[1] - 1, cursor[2] + 1 } + local node = parser:named_node_for_range(cursor_range) + + if not node then + return nil + end + + if node:type() == "section" then + return node + end + + while node and node:type() ~= "section" do + node = node:parent() + end + + return node +end + +---@param node TSNode +---@return boolean +local function has_child_headlines(node) + return vim.iter(node:iter_children()):any(function(child) + return child:type() == "atx_heading" + end) +end + +---@param node TSNode +---@return TSNode[]? +local function get_child_headlines(node) + local ret = {} + for child in node:iter_children() do + if child:type() == "section" then + ret[#ret + 1] = child + end + end + return ret +end + +---@return boolean +local function is_one_line(node) + local start_row, _, end_row, end_col = node:parent():range() + -- One line sections have end range on the next line with 0 column + -- Example: If headline is on line 5, range will be (5, 1, 6, 0) + return start_row == end_row or (start_row + 1 == end_row and end_col == 0) +end + +---@param node TSNode +---@return boolean +local function can_section_expand(node) + return not is_one_line(node) or has_child_headlines(node) +end + +--- Cycle heading state under cursor +M.cycle = function() + local current_buffer = vim.api.nvim_get_current_buf() + local cursor_position = vim.api.nvim_win_get_cursor(0) + local current_line = vim.fn.line "." + + -- Ensure fold system is active + if not vim.wo.foldenable then + vim.wo.foldenable = true + vim.cmd [[silent! norm! zx]] -- Refresh folds + end + + -- Check current fold state + local current_fold_level = vim.fn.foldlevel(current_line) + if current_fold_level == 0 then + return + end + + -- Handle closed folds first + local is_fold_closed = vim.fn.foldclosed(current_line) ~= -1 + if is_fold_closed then + return vim.cmd [[silent! norm! zo]] -- Open closed fold + end + + -- Find Markdown section structure + local current_section_node = closest_section_node(current_buffer, cursor_position) + if not current_section_node then + return + end + + -- Ignore non-expandable sections + if not can_section_expand(current_section_node) then + return + end + + -- Fold state management + local child_sections = get_child_headlines(current_section_node) + local should_close_parent = #child_sections == 0 + + if not should_close_parent then + local has_nested_structure = false + + -- Process child fold states + for _, child_node in ipairs(child_sections or {}) do + if can_section_expand(child_node) then + has_nested_structure = true + local child_start_line = child_node:start() + 1 + + -- Close open child folds first + if vim.fn.foldclosed(child_start_line) == -1 then + vim.cmd(string.format("silent! keepjumps norm! %dggzc", child_start_line)) + should_close_parent = true + end + end + end + + -- Return to original cursor position + vim.cmd(string.format("silent! keepjumps norm! %dgg", current_line)) + + -- Close parent if no actual nesting exists + if not should_close_parent and not has_nested_structure then + should_close_parent = true + end + end + + -- Execute final fold action + if should_close_parent then + vim.cmd [[silent! norm! zc]] -- Close parent fold + else + vim.cmd [[silent! norm! zczO]] -- Force fold refresh + end +end + return M diff --git a/lua/obsidian/init.lua b/lua/obsidian/init.lua index 5f6d9b5e..caf5e1f1 100644 --- a/lua/obsidian/init.lua +++ b/lua/obsidian/init.lua @@ -152,6 +152,16 @@ obsidian.setup = function(opts) desc = "Obsidian Smart Action", }) + vim.keymap.set("n", "", require("obsidian.api").cycle, { + buffer = true, + desc = "Obsidian Cycle", + }) + + vim.keymap.set("n", "", require("obsidian.api").cycle_global, { + buffer = true, + desc = "Obsidian Global Cyble", + }) + -- Inject completion sources, providers to their plugin configurations if opts.completion.nvim_cmp then require("obsidian.completion.plugin_initializers.nvim_cmp").inject_sources(opts) diff --git a/lua/obsidian/util.lua b/lua/obsidian/util.lua index abd4f0ba..5929bf6d 100644 --- a/lua/obsidian/util.lua +++ b/lua/obsidian/util.lua @@ -1,6 +1,5 @@ local compat = require "obsidian.compat" local string, table = string, table -local ts = vim.treesitter local util = {} setmetatable(util, { @@ -512,281 +511,9 @@ util.parse_link = function(link, opts) return link_location, link_name, link_type end -<<<<<<< HEAD ------------------------------------ -- Miscellaneous helper functions -- ------------------------------------ -======= ---- Get the tag under the cursor, if there is one. ---- ----@param line string|? ----@param col integer|? ---- ----@return string|? -util.cursor_tag = function(line, col) - local search = require "obsidian.search" - - local current_line = line and line or vim.api.nvim_get_current_line() - local _, cur_col = unpack(vim.api.nvim_win_get_cursor(0)) - cur_col = col or cur_col + 1 -- nvim_win_get_cursor returns 0-indexed column - - for match in iter(search.find_tags(current_line)) do - local open, close, _ = unpack(match) - if open <= cur_col and cur_col <= close then - return string.sub(current_line, open + 1, close) - end - end - - return nil -end - ---- Get the heading under the cursor, if there is one. ---- ----@param line string|? ---- ----@return string|? -util.cursor_heading = function(line) - local current_line = line and line or vim.api.nvim_get_current_line() - return current_line:match "^(%s*)(#+)%s*(.*)$" -end - -util.gf_passthrough = function() - local legacy = require("obsidian").get_client().opts.legacy_commands - if util.cursor_on_markdown_link(nil, nil, true) then - return legacy and "ObsidianFollowLink" or "Obsidian follow_link" - else - return "gf" - end -end - -util.smart_action = function() - local legacy = require("obsidian").get_client().opts.legacy_commands - -- follow link if possible - if util.cursor_on_markdown_link(nil, nil, true) then - return legacy and "ObsidianFollowLink" or "Obsidian follow_link" - end - - -- show notes with tag if possible - if util.cursor_tag(nil, nil) then - return legacy and "ObsidianTags" or "Obsidian tags" - end - - if util.cursor_heading() then - return "(ObsidianCycle)" - end - - -- toggle task if possible - -- cycles through your custom UI checkboxes, default: [ ] [~] [>] [x] - return legacy and "ObsidianToggleCheckbox" or "Obsidian toggle_checkbox" -end - ----Get the path to where a plugin is installed. ----@param name string|? ----@return string|? -util.get_src_root = function(name) - name = name and name or "obsidian.nvim" - for _, path in ipairs(vim.api.nvim_list_runtime_paths()) do - if vim.endswith(path, name) then - return path - end - end - return nil -end - ---- Get info about a plugin. ---- ----@param name string|? ---- ----@return { commit: string|?, path: string }|? -util.get_plugin_info = function(name) - name = name and name or "obsidian.nvim" - - local src_root = util.get_src_root(name) - if src_root == nil then - return nil - end - - local out = { path = src_root } - - local Job = require "plenary.job" - local output, exit_code = Job:new({ ---@diagnostic disable-line: missing-fields - command = "git", - args = { "rev-parse", "HEAD" }, - cwd = src_root, - enable_recording = true, - }):sync(1000) - - if exit_code == 0 then - out.commit = output[1] - end - - return out -end - ----@param cmd string ----@return string|? -util.get_external_dependency_info = function(cmd) - local Job = require "plenary.job" - local output, exit_code = Job:new({ ---@diagnostic disable-line: missing-fields - command = cmd, - args = { "--version" }, - enable_recording = true, - }):sync(1000) - - if exit_code == 0 then - return output[1] - end -end - ----Get an iterator of (bufnr, bufname) over all named buffers. The buffer names will be absolute paths. ---- ----@return function () -> (integer, string)|? -util.get_named_buffers = function() - local idx = 0 - local buffers = vim.api.nvim_list_bufs() - - ---@return integer|? - ---@return string|? - return function() - while idx < #buffers do - idx = idx + 1 - local bufnr = buffers[idx] - if vim.api.nvim_buf_is_loaded(bufnr) then - return bufnr, vim.api.nvim_buf_get_name(bufnr) - end - end - end -end - ----Insert text at current cursor position. ----@param text string -util.insert_text = function(text) - local curpos = vim.fn.getcurpos() - local line_num, line_col = curpos[2], curpos[3] - local indent = string.rep(" ", line_col) - - -- Convert text to lines table so we can handle multi-line strings. - local lines = {} - for line in text:gmatch "[^\r\n]+" do - lines[#lines + 1] = line - end - - for line_index, line in pairs(lines) do - local current_line_num = line_num + line_index - 1 - local current_line = vim.fn.getline(current_line_num) - assert(type(current_line) == "string") - - -- Since there's no column 0, remove extra space when current line is blank. - if current_line == "" then - indent = indent:sub(1, -2) - end - - local pre_txt = current_line:sub(1, line_col) - local post_txt = current_line:sub(line_col + 1, -1) - local inserted_txt = pre_txt .. line .. post_txt - - vim.fn.setline(current_line_num, inserted_txt) - - -- Create new line so inserted_txt doesn't replace next lines - if line_index ~= #lines then - vim.fn.append(current_line_num, indent) - end - end -end - ----@param bufnr integer ----@return string -util.buf_get_full_text = function(bufnr) - local text = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, true), "\n") - if vim.api.nvim_get_option_value("eol", { buf = bufnr }) then - text = text .. "\n" - end - return text -end - ---- Get the current visual selection of text and exit visual mode. ---- ----@param opts { strict: boolean|? }|? ---- ----@return { lines: string[], selection: string, csrow: integer, cscol: integer, cerow: integer, cecol: integer }|? -util.get_visual_selection = function(opts) - opts = opts or {} - -- Adapted from fzf-lua: - -- https://github.com/ibhagwan/fzf-lua/blob/6ee73fdf2a79bbd74ec56d980262e29993b46f2b/lua/fzf-lua/utils.lua#L434-L466 - -- this will exit visual mode - -- use 'gv' to reselect the text - local _, csrow, cscol, cerow, cecol - local mode = vim.fn.mode() - if opts.strict and not vim.endswith(string.lower(mode), "v") then - return - end - - if mode == "v" or mode == "V" or mode == "" then - -- if we are in visual mode use the live position - _, csrow, cscol, _ = unpack(vim.fn.getpos ".") - _, cerow, cecol, _ = unpack(vim.fn.getpos "v") - if mode == "V" then - -- visual line doesn't provide columns - cscol, cecol = 0, 999 - end - -- exit visual mode - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) - else - -- otherwise, use the last known visual position - _, csrow, cscol, _ = unpack(vim.fn.getpos "'<") - _, cerow, cecol, _ = unpack(vim.fn.getpos "'>") - end - - -- Swap vars if needed - if cerow < csrow then - csrow, cerow = cerow, csrow - cscol, cecol = cecol, cscol - elseif cerow == csrow and cecol < cscol then - cscol, cecol = cecol, cscol - end - - local lines = vim.fn.getline(csrow, cerow) - assert(type(lines) == "table") - if vim.tbl_isempty(lines) then - return - end - - -- When the whole line is selected via visual line mode ("V"), cscol / cecol will be equal to "v:maxcol" - -- for some odd reason. So change that to what they should be here. See ':h getpos' for more info. - local maxcol = vim.api.nvim_get_vvar "maxcol" - if cscol == maxcol then - cscol = string.len(lines[1]) - end - if cecol == maxcol then - cecol = string.len(lines[#lines]) - end - - ---@type string - local selection - local n = #lines - if n <= 0 then - selection = "" - elseif n == 1 then - selection = string.sub(lines[1], cscol, cecol) - elseif n == 2 then - selection = string.sub(lines[1], cscol) .. "\n" .. string.sub(lines[n], 1, cecol) - else - selection = string.sub(lines[1], cscol) - .. "\n" - .. table.concat(lines, "\n", 2, n - 1) - .. "\n" - .. string.sub(lines[n], 1, cecol) - end - - return { - lines = lines, - selection = selection, - csrow = csrow, - cscol = cscol, - cerow = cerow, - cecol = cecol, - } -end ---@param anchor obsidian.note.HeaderAnchor ---@return string @@ -998,155 +725,4 @@ util.fire_callback = function(event, callback, ...) end end ---- Adapted from `nvim-orgmode/orgmode` ---- Cycle all headings in file between "Show All", "Contents" and "Overview" ---- -util.cycle_global = function() - local mode = vim.g.obsidian_global_cycle_mode or "Show All" - if not vim.wo.foldenable or mode == "Show All" then - mode = "Overview" - vim.cmd [[silent! norm! zMzX]] - elseif mode == "Contents" then - mode = "Show All" - vim.cmd [[silent! norm! zR]] - elseif mode == "Overview" then - mode = "Contents" - vim.wo.foldlevel = 1 - vim.cmd [[silent! norm! zx]] - end - vim.api.nvim_echo({ { "Obsidian: " .. mode } }, false, {}) - vim.g.obsidian_global_cycle_mode = mode -end - ----@param bufnr integer ----@param cursor integer[] ----@return TSNode? -local function closest_section_node(bufnr, cursor) - local parser = ts.get_parser(bufnr, "markdown", {}) - assert(parser) - local cursor_range = { cursor[1] - 1, cursor[2], cursor[1] - 1, cursor[2] + 1 } - local node = parser:named_node_for_range(cursor_range) - - if not node then - return nil - end - - if node:type() == "section" then - return node - end - - while node and node:type() ~= "section" do - node = node:parent() - end - - return node -end - ----@param node TSNode ----@return boolean -local function has_child_headlines(node) - return vim.iter(node:iter_children()):any(function(child) - return child:type() == "atx_heading" - end) -end - ----@param node TSNode ----@return TSNode[]? -local function get_child_headlines(node) - local ret = {} - for child in node:iter_children() do - if child:type() == "section" then - ret[#ret + 1] = child - end - end - return ret -end - ----@return boolean -local function is_one_line(node) - local start_row, _, end_row, end_col = node:parent():range() - -- One line sections have end range on the next line with 0 column - -- Example: If headline is on line 5, range will be (5, 1, 6, 0) - return start_row == end_row or (start_row + 1 == end_row and end_col == 0) -end - ----@param node TSNode ----@return boolean -local function can_section_expand(node) - return not is_one_line(node) or has_child_headlines(node) -end - ---- Cycle heading state under cursor -util.cycle = function() - local current_buffer = vim.api.nvim_get_current_buf() - local cursor_position = vim.api.nvim_win_get_cursor(0) - local current_line = vim.fn.line "." - - -- Ensure fold system is active - if not vim.wo.foldenable then - vim.wo.foldenable = true - vim.cmd [[silent! norm! zx]] -- Refresh folds - end - - -- Check current fold state - local current_fold_level = vim.fn.foldlevel(current_line) - if current_fold_level == 0 then - return - end - - -- Handle closed folds first - local is_fold_closed = vim.fn.foldclosed(current_line) ~= -1 - if is_fold_closed then - return vim.cmd [[silent! norm! zo]] -- Open closed fold - end - - -- Find Markdown section structure - local current_section_node = closest_section_node(current_buffer, cursor_position) - if not current_section_node then - return - end - - -- Ignore non-expandable sections - if not can_section_expand(current_section_node) then - return - end - - -- Fold state management - local child_sections = get_child_headlines(current_section_node) - local should_close_parent = #child_sections == 0 - - if not should_close_parent then - local has_nested_structure = false - - -- Process child fold states - for _, child_node in ipairs(child_sections or {}) do - if can_section_expand(child_node) then - has_nested_structure = true - local child_start_line = child_node:start() + 1 - - -- Close open child folds first - if vim.fn.foldclosed(child_start_line) == -1 then - vim.cmd(string.format("silent! keepjumps norm! %dggzc", child_start_line)) - should_close_parent = true - end - end - end - - -- Return to original cursor position - vim.cmd(string.format("silent! keepjumps norm! %dgg", current_line)) - - -- Close parent if no actual nesting exists - if not should_close_parent and not has_nested_structure then - should_close_parent = true - end - end - - -- Execute final fold action - if should_close_parent then - vim.cmd [[silent! norm! zc]] -- Close parent fold - else - vim.cmd [[silent! norm! zczO]] -- Force fold refresh - end -end - return util From 959df29b9d75eaf79c39f693bde6b61eecc93f79 Mon Sep 17 00:00:00 2001 From: Zizhou Teng <412445606@qq.com> Date: Mon, 30 Jun 2025 16:37:09 +0800 Subject: [PATCH 3/4] fix: remove wrong rebase --- lua/obsidian/config.lua | 264 ---------------------------------------- 1 file changed, 264 deletions(-) diff --git a/lua/obsidian/config.lua b/lua/obsidian/config.lua index 3cbbeeda..4c2b53f3 100644 --- a/lua/obsidian/config.lua +++ b/lua/obsidian/config.lua @@ -557,268 +557,4 @@ See: https://github.com/obsidian-nvim/obsidian.nvim/wiki/Keymaps]] return opts end ----@enum obsidian.config.OpenStrategy -config.OpenStrategy = { - current = "current", - vsplit = "vsplit", - hsplit = "hsplit", -} - ----@enum obsidian.config.SortBy -config.SortBy = { - path = "path", - modified = "modified", - accessed = "accessed", - created = "created", -} - ----@enum obsidian.config.NewNotesLocation -config.NewNotesLocation = { - current_dir = "current_dir", - notes_subdir = "notes_subdir", -} - ----@enum obsidian.config.LinkStyle -config.LinkStyle = { - wiki = "wiki", - markdown = "markdown", -} - ----@class obsidian.config.CompletionOpts ---- ----@field nvim_cmp boolean ----@field blink boolean ----@field min_chars integer ----@field match_case boolean -config.CompletionOpts = {} - ---- Get defaults. ---- ----@return obsidian.config.CompletionOpts -config.CompletionOpts.default = function() - local has_nvim_cmp, _ = pcall(require, "cmp") - return { - nvim_cmp = has_nvim_cmp, - min_chars = 2, - match_case = true, - } -end - ----@class obsidian.config.MappingOpts -config.MappingOpts = {} - ----Get defaults. ----@return obsidian.config.MappingOpts -config.MappingOpts.default = function() - local mappings = require "obsidian.mappings" - - return { - ["gf"] = mappings.gf_passthrough(), - ["ch"] = mappings.toggle_checkbox(), - [""] = mappings.smart_action(), - [""] = mappings.cycle_global(), - [""] = mappings.cycle(), - } -end - ----@class obsidian.config.PickerNoteMappingOpts ---- ----@field new string|? ----@field insert_link string|? -config.PickerNoteMappingOpts = {} - ----Get defaults. ----@return obsidian.config.PickerNoteMappingOpts -config.PickerNoteMappingOpts.default = function() - return { - new = "", - insert_link = "", - } -end - ----@class obsidian.config.PickerTagMappingOpts ---- ----@field tag_note string|? ----@field insert_tag string|? -config.PickerTagMappingOpts = {} - ----@return obsidian.config.PickerTagMappingOpts -config.PickerTagMappingOpts.default = function() - return { - tag_note = "", - insert_tag = "", - } -end - ----@enum obsidian.config.Picker -config.Picker = { - telescope = "telescope.nvim", - fzf_lua = "fzf-lua", - mini = "mini.pick", - snacks = "snacks.pick", -} - ----@class obsidian.config.PickerOpts ---- ----@field name obsidian.config.Picker|? ----@field note_mappings obsidian.config.PickerNoteMappingOpts ----@field tag_mappings obsidian.config.PickerTagMappingOpts -config.PickerOpts = {} - ---- Get the defaults. ---- ----@return obsidian.config.PickerOpts -config.PickerOpts.default = function() - return { - name = nil, - note_mappings = config.PickerNoteMappingOpts.default(), - tag_mappings = config.PickerTagMappingOpts.default(), - } -end - ----@class obsidian.config.DailyNotesOpts ---- ----@field folder string|? ----@field date_format string|? ----@field alias_format string|? ----@field template string|? ----@field default_tags string[]|? ----@field workdays_only boolean -config.DailyNotesOpts = {} - ---- Get defaults. ---- ----@return obsidian.config.DailyNotesOpts -config.DailyNotesOpts.default = function() - return { - folder = nil, - date_format = nil, - alias_format = nil, - default_tags = { "daily-notes" }, - workdays_only = true, - } -end - ----@class obsidian.config.TemplateOpts ---- ----@field folder string|obsidian.Path|? ----@field date_format string|? ----@field time_format string|? ----@field substitutions table|? -config.TemplateOpts = {} - ---- Get defaults. ---- ----@return obsidian.config.TemplateOpts -config.TemplateOpts.default = function() - return { - folder = nil, - date_format = nil, - time_format = nil, - substitutions = {}, - } -end - ----@class obsidian.config.UIOpts ---- ----@field enable boolean ----@field update_debounce integer ----@field max_file_length integer|? ----@field checkboxes table ----@field bullets obsidian.config.UICharSpec|? ----@field external_link_icon obsidian.config.UICharSpec ----@field reference_text obsidian.config.UIStyleSpec ----@field highlight_text obsidian.config.UIStyleSpec ----@field tags obsidian.config.UIStyleSpec ----@field block_ids obsidian.config.UIStyleSpec ----@field hl_groups table -config.UIOpts = {} - ----@class obsidian.config.UICharSpec ---- ----@field char string ----@field hl_group string - ----@class obsidian.config.CheckboxSpec : obsidian.config.UICharSpec ---- ----@field char string ----@field hl_group string ----@field order integer - ----@class obsidian.config.UIStyleSpec ---- ----@field hl_group string - ----@return obsidian.config.UIOpts -config.UIOpts.default = function() - return { - enable = true, - update_debounce = 200, - max_file_length = 5000, - checkboxes = { - [" "] = { order = 1, char = "󰄱", hl_group = "ObsidianTodo" }, - ["~"] = { order = 2, char = "󰰱", hl_group = "ObsidianTilde" }, - ["!"] = { order = 3, char = "", hl_group = "ObsidianImportant" }, - [">"] = { order = 4, char = "", hl_group = "ObsidianRightArrow" }, - ["x"] = { order = 5, char = "", hl_group = "ObsidianDone" }, - }, - bullets = { char = "•", hl_group = "ObsidianBullet" }, - external_link_icon = { char = "", hl_group = "ObsidianExtLinkIcon" }, - reference_text = { hl_group = "ObsidianRefText" }, - highlight_text = { hl_group = "ObsidianHighlightText" }, - tags = { hl_group = "ObsidianTag" }, - block_ids = { hl_group = "ObsidianBlockID" }, - hl_groups = { - ObsidianTodo = { bold = true, fg = "#f78c6c" }, - ObsidianDone = { bold = true, fg = "#89ddff" }, - ObsidianRightArrow = { bold = true, fg = "#f78c6c" }, - ObsidianTilde = { bold = true, fg = "#ff5370" }, - ObsidianImportant = { bold = true, fg = "#d73128" }, - ObsidianBullet = { bold = true, fg = "#89ddff" }, - ObsidianRefText = { underline = true, fg = "#c792ea" }, - ObsidianExtLinkIcon = { fg = "#c792ea" }, - ObsidianTag = { italic = true, fg = "#89ddff" }, - ObsidianBlockID = { italic = true, fg = "#89ddff" }, - ObsidianHighlightText = { bg = "#75662e" }, - }, - } -end - ----@class obsidian.config.AttachmentsOpts ---- ----@field img_folder string Default folder to save images to, relative to the vault root. ----@field img_name_func (fun(): string)|? ----@field img_text_func fun(client: obsidian.Client, path: obsidian.Path): string ----@field confirm_img_paste boolean Whether to confirm the paste or not. Defaults to true. -config.AttachmentsOpts = {} - ----@return obsidian.config.AttachmentsOpts -config.AttachmentsOpts.default = function() - return { - img_folder = "assets/imgs", - img_text_func = function(client, path) - path = client:vault_relative_path(path) or path - return string.format("![%s](%s)", path.name, util.urlencode(tostring(path))) - end, - img_name_func = function() - return string.format("Pasted image %s", os.date "%Y%m%d%H%M%S") - end, - confirm_img_paste = true, - } -end - ----@class obsidian.config.CallbackConfig ---- ----@field post_setup fun(client: obsidian.Client)|? Runs right after the `obsidian.Client` is initialized. ----@field enter_note fun(client: obsidian.Client, note: obsidian.Note)|? Runs when entering a note buffer. ----@field leave_note fun(client: obsidian.Client, note: obsidian.Note)|? Runs when leaving a note buffer. ----@field pre_write_note fun(client: obsidian.Client, note: obsidian.Note)|? Runs right before writing a note buffer. ----@field post_set_workspace fun(client: obsidian.Client, workspace: obsidian.Workspace)|? Runs anytime the workspace is set/changed. -config.CallbackConfig = {} - ----@return obsidian.config.CallbackConfig -config.CallbackConfig.default = function() - return {} -end - return config From a5cfc7da917b9a695d1ba342f1a0a436954e24d5 Mon Sep 17 00:00:00 2001 From: Zizhou Teng <412445606@qq.com> Date: Thu, 3 Jul 2025 12:19:12 +0800 Subject: [PATCH 4/4] fix: remove accident commit --- lua/obsidian/api.lua | 102 +++++++++++++++++++++++++++++--------- lua/obsidian/mappings.lua | 52 ------------------- 2 files changed, 78 insertions(+), 76 deletions(-) delete mode 100644 lua/obsidian/mappings.lua diff --git a/lua/obsidian/api.lua b/lua/obsidian/api.lua index 3ed94ba7..e884db96 100644 --- a/lua/obsidian/api.lua +++ b/lua/obsidian/api.lua @@ -501,10 +501,10 @@ end --- Cycle all headings in file between "Show All", "Contents" and "Overview" --- M.cycle_global = function() - local mode = vim.g.obsidian_global_cycle_mode or "Show All" + local mode = Obsidian.cycle_mode or "Show All" if not vim.wo.foldenable or mode == "Show All" then mode = "Overview" - vim.cmd [[silent! norm! zMzX]] + vim.cmd "norm! zMzX" elseif mode == "Contents" then mode = "Show All" vim.cmd [[silent! norm! zR]] @@ -514,31 +514,78 @@ M.cycle_global = function() vim.cmd [[silent! norm! zx]] end vim.api.nvim_echo({ { "Obsidian: " .. mode } }, false, {}) - vim.g.obsidian_global_cycle_mode = mode + Obsidian.cycle_mode = mode end - ----@param bufnr integer ----@param cursor integer[] +-- +-- ---@param bufnr integer +-- ---@param cursor integer[] +-- ---@return TSNode? +-- local function closest_section_node(bufnr, cursor) +-- local parser = ts.get_parser(bufnr, "markdown", {}) +-- assert(parser) +-- local cursor_range = { cursor[1] - 1, cursor[2], cursor[1] - 1, cursor[2] + 1 } +-- local node = parser:named_node_for_range(cursor_range) +-- +-- if not node then +-- return nil +-- end +-- +-- if node:type() == "section" then +-- return node +-- end +-- +-- while node and node:type() ~= "section" do +-- node = node:parent() +-- end +-- +-- return node +-- end + +-- ---@param buf number +-- ---@param pos { [1]: number, [2]: number } +-- ---@return TSNode? +-- local function closest_section_node(buf, pos) +-- local parser = vim.treesitter.get_parser(buf, "markdown") +-- assert(parser) +-- local tree = parser:parse()[1] +-- local root = tree:root() +-- local node = root:named_descendant_for_range(pos[1] - 1, pos[2], pos[1] - 1, pos[2]) +-- +-- while node do +-- if node:type() == "section" then +-- local first = node:named_child(0) +-- if first and first:type() == "atx_heading" then +-- return node +-- end +-- end +-- node = node:parent() +-- end +-- +-- return nil +-- end +---@param buf number +---@param pos { [1]: number, [2]: number } ---@return TSNode? -local function closest_section_node(bufnr, cursor) - local parser = ts.get_parser(bufnr, "markdown", {}) +local function closest_section_node(buf, pos) + local parser = ts.get_parser(buf, "markdown") assert(parser) - local cursor_range = { cursor[1] - 1, cursor[2], cursor[1] - 1, cursor[2] + 1 } - local node = parser:named_node_for_range(cursor_range) - - if not node then - return nil - end - - if node:type() == "section" then - return node - end - - while node and node:type() ~= "section" do + local tree = parser:parse()[1] + local root = tree:root() + local node = root:named_descendant_for_range(pos[1] - 1, pos[2], pos[1] - 1, pos[2]) + + while node do + if node:type() == "section" then + -- We only return a section if its first child is a heading + local first = node:named_child(0) + if first and first:type():match "_heading$" then -- supports 'atx_heading' and 'setext_heading' + print(node:type()) + return node + end + end node = node:parent() end - return node + return nil end ---@param node TSNode @@ -568,10 +615,17 @@ local function is_one_line(node) -- Example: If headline is on line 5, range will be (5, 1, 6, 0) return start_row == end_row or (start_row + 1 == end_row and end_col == 0) end - ----@param node TSNode ----@return boolean +-- +-- ---@param node TSNode +-- ---@return boolean +-- local function can_section_expand(node) +-- return not is_one_line(node) or has_child_headlines(node) +-- end local function can_section_expand(node) + local first = node:named_child(0) + if not first or first:type() ~= "atx_heading" then + return false + end return not is_one_line(node) or has_child_headlines(node) end diff --git a/lua/obsidian/mappings.lua b/lua/obsidian/mappings.lua deleted file mode 100644 index 7462cbf7..00000000 --- a/lua/obsidian/mappings.lua +++ /dev/null @@ -1,52 +0,0 @@ -local util = require "obsidian.util" - -local M = {} - ----@class obsidian.mappings.MappingConfig ----@field action function ----@field opts table - ----@return obsidian.mappings.MappingConfig -M.smart_action = function() - return { - action = util.smart_action, - opts = { noremap = false, expr = true, buffer = true, desc = "Obsidian smart action" }, - } -end - ----@return obsidian.mappings.MappingConfig -M.gf_passthrough = function() - return { - action = util.gf_passthrough, - opts = { noremap = false, expr = true, buffer = true, desc = "Go to file" }, - } -end - ----@return obsidian.mappings.MappingConfig -M.toggle_checkbox = function() - return { - action = util.toggle_checkbox, - opts = { buffer = true, desc = "Toggle Checkbox" }, - } -end - ----@return obsidian.mappings.MappingConfig -M.cycle_global = function() - return { - action = util.cycle_global, - opts = { buffer = true, desc = "Cycle file heading state" }, - } -end - ----@return obsidian.mappings.MappingConfig -M.cycle = function() - return { - action = util.cycle, - opts = { buffer = true, desc = "Cycle heading state under the cursor" }, - } -end - -vim.keymap.set("n", "(ObsidianCycle)", util.cycle) -vim.keymap.set("n", "(ObsidianCycleGlobal)", util.cycle_global) - -return M