Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<Tab>` and `<S-Tab>`

### Changed

Expand Down Expand Up @@ -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)`.
Expand Down
207 changes: 206 additions & 1 deletion lua/obsidian/api.lua
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -497,4 +497,209 @@ 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 = Obsidian.cycle_mode or "Show All"
if not vim.wo.foldenable or mode == "Show All" then
mode = "Overview"
vim.cmd "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, {})
Obsidian.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 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(buf, pos)
local parser = ts.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
-- 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 nil
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
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

--- 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
10 changes: 10 additions & 0 deletions lua/obsidian/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,16 @@ obsidian.setup = function(opts)
desc = "Obsidian Smart Action",
})

vim.keymap.set("n", "<Tab>", require("obsidian.api").cycle, {
buffer = true,
desc = "Obsidian Cycle",
})

vim.keymap.set("n", "<S-Tab>", 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)
Expand Down
3 changes: 2 additions & 1 deletion lua/obsidian/util.lua
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,7 @@ end
------------------------------------
-- Miscellaneous helper functions --
------------------------------------

---@param anchor obsidian.note.HeaderAnchor
---@return string
util.format_anchor_label = function(anchor)
Expand Down Expand Up @@ -678,7 +679,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
Expand Down