diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index feac234f786..d7885547302 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -69,7 +69,7 @@ jobs:
     strategy:
       matrix:
         nvim_version: [ stable, nightly ]
-        luals_version: [ 3.10.5 ]
+        luals_version: [ 3.11.0 ]
 
     steps:
       - uses: actions/checkout@v4
diff --git a/lua/nvim-tree.lua b/lua/nvim-tree.lua
index c65b51438de..16f2fa33ac3 100644
--- a/lua/nvim-tree.lua
+++ b/lua/nvim-tree.lua
@@ -125,7 +125,7 @@ function M.place_cursor_on_node()
   if not node or node.name == ".." then
     return
   end
-  node = utils.get_parent_of_group(node)
+  node = node:get_parent_of_group()
 
   local line = vim.api.nvim_get_current_line()
   local cursor = vim.api.nvim_win_get_cursor(0)
@@ -854,7 +854,7 @@ function M.setup(conf)
   require("nvim-tree.keymap").setup(opts)
   require("nvim-tree.appearance").setup()
   require("nvim-tree.diagnostics").setup(opts)
-  require("nvim-tree.explorer").setup(opts)
+  require("nvim-tree.explorer"):setup(opts)
   require("nvim-tree.git").setup(opts)
   require("nvim-tree.git.utils").setup(opts)
   require("nvim-tree.view").setup(opts)
diff --git a/lua/nvim-tree/actions/fs/clipboard.lua b/lua/nvim-tree/actions/fs/clipboard.lua
index c6808eb550c..cc75be4d13f 100644
--- a/lua/nvim-tree/actions/fs/clipboard.lua
+++ b/lua/nvim-tree/actions/fs/clipboard.lua
@@ -217,10 +217,10 @@ end
 ---@param action ACTION
 ---@param action_fn fun(source: string, dest: string)
 function Clipboard:do_paste(node, action, action_fn)
-  node = lib.get_last_group_node(node)
-  local explorer = core.get_explorer()
-  if node.name == ".." and explorer then
-    node = explorer
+  if node.name == ".." then
+    node = self.explorer
+  else
+    node = node:last_group_node()
   end
   local clip = self.data[action]
   if #clip == 0 then
diff --git a/lua/nvim-tree/actions/fs/create-file.lua b/lua/nvim-tree/actions/fs/create-file.lua
index dbd1bc7e99c..588c978fc92 100644
--- a/lua/nvim-tree/actions/fs/create-file.lua
+++ b/lua/nvim-tree/actions/fs/create-file.lua
@@ -1,6 +1,5 @@
 local utils = require("nvim-tree.utils")
 local events = require("nvim-tree.events")
-local lib = require("nvim-tree.lib")
 local core = require("nvim-tree.core")
 local notify = require("nvim-tree.notify")
 
@@ -40,14 +39,13 @@ local function get_containing_folder(node)
   return node.absolute_path:sub(0, -node_name_size - 1)
 end
 
----@param node Node|nil
+---@param node Node?
 function M.fn(node)
   local cwd = core.get_cwd()
   if cwd == nil then
     return
   end
 
-  node = node and lib.get_last_group_node(node)
   if not node or node.name == ".." then
     node = {
       absolute_path = cwd,
@@ -55,6 +53,8 @@ function M.fn(node)
       nodes = core.get_explorer().nodes,
       open = true,
     }
+  else
+    node = node:last_group_node()
   end
 
   local containing_folder = get_containing_folder(node)
diff --git a/lua/nvim-tree/actions/fs/rename-file.lua b/lua/nvim-tree/actions/fs/rename-file.lua
index 9539cd7e730..a06fb201c56 100644
--- a/lua/nvim-tree/actions/fs/rename-file.lua
+++ b/lua/nvim-tree/actions/fs/rename-file.lua
@@ -120,7 +120,7 @@ function M.fn(default_modifier)
       return
     end
 
-    node = lib.get_last_group_node(node)
+    node = node:last_group_node()
     if node.name == ".." then
       return
     end
diff --git a/lua/nvim-tree/actions/moves/item.lua b/lua/nvim-tree/actions/moves/item.lua
index c38618e9c74..ec73d010b5f 100644
--- a/lua/nvim-tree/actions/moves/item.lua
+++ b/lua/nvim-tree/actions/moves/item.lua
@@ -2,7 +2,6 @@ local utils = require("nvim-tree.utils")
 local view = require("nvim-tree.view")
 local core = require("nvim-tree.core")
 local lib = require("nvim-tree.lib")
-local explorer_node = require("nvim-tree.explorer.node")
 local diagnostics = require("nvim-tree.diagnostics")
 
 local M = {}
@@ -16,7 +15,7 @@ local MAX_DEPTH = 100
 ---@return boolean
 local function status_is_valid(node, what, skip_gitignored)
   if what == "git" then
-    local git_status = explorer_node.get_git_status(node)
+    local git_status = node:get_git_status()
     return git_status ~= nil and (not skip_gitignored or git_status[1] ~= "!!")
   elseif what == "diag" then
     local diag_status = diagnostics.get_diag_status(node)
@@ -75,7 +74,7 @@ local function expand_node(node)
   if not node.open then
     -- Expand the node.
     -- Should never collapse since we checked open.
-    lib.expand_or_collapse(node)
+    node:expand_or_collapse()
   end
 end
 
@@ -98,7 +97,7 @@ local function move_next_recursive(what, skip_gitignored)
     valid = status_is_valid(node_init, what, skip_gitignored)
   end
   if node_init.nodes ~= nil and valid and not node_init.open then
-    lib.expand_or_collapse(node_init)
+    node_init:expand_or_collapse()
   end
 
   move("next", what, skip_gitignored)
diff --git a/lua/nvim-tree/actions/moves/parent.lua b/lua/nvim-tree/actions/moves/parent.lua
index e00bc49e7e5..88eca47567a 100644
--- a/lua/nvim-tree/actions/moves/parent.lua
+++ b/lua/nvim-tree/actions/moves/parent.lua
@@ -1,7 +1,6 @@
 local view = require("nvim-tree.view")
 local utils = require("nvim-tree.utils")
 local core = require("nvim-tree.core")
-local lib = require("nvim-tree.lib")
 
 local M = {}
 
@@ -12,7 +11,7 @@ function M.fn(should_close)
 
   return function(node)
     local explorer = core.get_explorer()
-    node = lib.get_last_group_node(node)
+    node = node:last_group_node()
     if should_close and node.open then
       node.open = false
       if explorer then
@@ -21,7 +20,7 @@ function M.fn(should_close)
       return
     end
 
-    local parent = utils.get_parent_of_group(node).parent
+    local parent = node:get_parent_of_group().parent
 
     if not parent or not parent.parent then
       return view.set_cursor({ 1, 0 })
diff --git a/lua/nvim-tree/actions/moves/sibling.lua b/lua/nvim-tree/actions/moves/sibling.lua
index afab9ef3efa..cf5b492d387 100644
--- a/lua/nvim-tree/actions/moves/sibling.lua
+++ b/lua/nvim-tree/actions/moves/sibling.lua
@@ -15,7 +15,7 @@ function M.fn(direction)
     local first, last, next, prev = nil, nil, nil, nil
     local found = false
     local parent = node.parent or core.get_explorer()
-    Iterator.builder(parent.nodes)
+    Iterator.builder(parent and parent.nodes or {})
       :recursor(function()
         return nil
       end)
diff --git a/lua/nvim-tree/actions/tree/modifiers/expand-all.lua b/lua/nvim-tree/actions/tree/modifiers/expand-all.lua
index 25be0b90803..ed898de2a30 100644
--- a/lua/nvim-tree/actions/tree/modifiers/expand-all.lua
+++ b/lua/nvim-tree/actions/tree/modifiers/expand-all.lua
@@ -1,7 +1,6 @@
 local core = require("nvim-tree.core")
 local Iterator = require("nvim-tree.iterators.node-iterator")
 local notify = require("nvim-tree.notify")
-local lib = require("nvim-tree.lib")
 
 local M = {}
 
@@ -18,7 +17,7 @@ end
 
 ---@param node Node
 local function expand(node)
-  node = lib.get_last_group_node(node)
+  node = node:last_group_node()
   node.open = true
   if #node.nodes == 0 then
     core.get_explorer():expand(node)
@@ -62,10 +61,10 @@ local function gen_iterator()
   end
 end
 
----@param base_node table
-function M.fn(base_node)
+---@param node Node
+function M.fn(node)
   local explorer = core.get_explorer()
-  local node = base_node.nodes and base_node or explorer
+  node = node.nodes and node or explorer
   if gen_iterator()(node) then
     notify.warn("expansion iteration was halted after " .. M.MAX_FOLDER_DISCOVERY .. " discovered folders")
   end
diff --git a/lua/nvim-tree/api.lua b/lua/nvim-tree/api.lua
index 27da1d206df..c153c07ad60 100644
--- a/lua/nvim-tree/api.lua
+++ b/lua/nvim-tree/api.lua
@@ -138,7 +138,7 @@ Api.tree.change_root_to_node = wrap_node(function(node)
   if node.name == ".." then
     actions.root.change_dir.fn("..")
   elseif node.nodes ~= nil then
-    actions.root.change_dir.fn(lib.get_last_group_node(node).absolute_path)
+    actions.root.change_dir.fn(node:last_group_node().absolute_path)
   end
 end)
 
@@ -198,7 +198,7 @@ Api.fs.copy.basename = wrap_node(wrap_explorer_member("clipboard", "copy_basenam
 Api.fs.copy.relative_path = wrap_node(wrap_explorer_member("clipboard", "copy_path"))
 
 ---@param mode string
----@param node table
+---@param node Node
 local function edit(mode, node)
   local path = node.absolute_path
   if node.link_to and not node.nodes then
@@ -214,7 +214,7 @@ local function open_or_expand_or_dir_up(mode, toggle_group)
     if node.name == ".." then
       actions.root.change_dir.fn("..")
     elseif node.nodes then
-      lib.expand_or_collapse(node, toggle_group)
+      node:expand_or_collapse(toggle_group)
     elseif not toggle_group then
       edit(mode, node)
     end
diff --git a/lua/nvim-tree/buffers.lua b/lua/nvim-tree/buffers.lua
index 51ebe141a93..954c7e4011c 100644
--- a/lua/nvim-tree/buffers.lua
+++ b/lua/nvim-tree/buffers.lua
@@ -21,7 +21,7 @@ function M.reload_modified()
   end
 end
 
----@param node table
+---@param node Node
 ---@return boolean
 function M.is_modified(node)
   return node
@@ -32,7 +32,7 @@ function M.is_modified(node)
 end
 
 ---A buffer exists for the node's absolute path
----@param node table
+---@param node Node
 ---@return boolean
 function M.is_opened(node)
   return node and vim.fn.bufloaded(node.absolute_path) > 0
diff --git a/lua/nvim-tree/core.lua b/lua/nvim-tree/core.lua
index d3e6d20ffff..186b1ed74e1 100644
--- a/lua/nvim-tree/core.lua
+++ b/lua/nvim-tree/core.lua
@@ -15,7 +15,7 @@ function M.init(foldername)
   if TreeExplorer then
     TreeExplorer:destroy()
   end
-  TreeExplorer = require("nvim-tree.explorer"):new(foldername)
+  TreeExplorer = require("nvim-tree.explorer"):create(foldername)
   if not first_init_done then
     events._dispatch_ready()
     first_init_done = true
diff --git a/lua/nvim-tree/enum.lua b/lua/nvim-tree/enum.lua
index 9c50bc27638..a680c2b3fdc 100644
--- a/lua/nvim-tree/enum.lua
+++ b/lua/nvim-tree/enum.lua
@@ -1,5 +1,13 @@
 local M = {}
 
+---Must be synced with uv.fs_stat.result as it is compared with it
+---@enum (key) NODE_TYPE
+M.NODE_TYPE = {
+  directory = 1,
+  file = 2,
+  link = 4,
+}
+
 ---Setup options for "highlight_*"
 ---@enum HL_POSITION
 M.HL_POSITION = {
diff --git a/lua/nvim-tree/explorer/init.lua b/lua/nvim-tree/explorer/init.lua
index 6ae149b6d86..0045ba9b243 100644
--- a/lua/nvim-tree/explorer/init.lua
+++ b/lua/nvim-tree/explorer/init.lua
@@ -1,15 +1,15 @@
-local builders = require("nvim-tree.explorer.node-builders")
 local git = require("nvim-tree.git")
 local log = require("nvim-tree.log")
 local notify = require("nvim-tree.notify")
 local utils = require("nvim-tree.utils")
 local view = require("nvim-tree.view")
-local watch = require("nvim-tree.explorer.watch")
-local explorer_node = require("nvim-tree.explorer.node")
+local node_factory = require("nvim-tree.node.factory")
+
+local RootNode = require("nvim-tree.node.root")
+local Watcher = require("nvim-tree.watcher")
 
 local Iterator = require("nvim-tree.iterators.node-iterator")
 local NodeIterator = require("nvim-tree.iterators.node-iterator")
-local Watcher = require("nvim-tree.watcher")
 
 local Filters = require("nvim-tree.explorer.filters")
 local Marks = require("nvim-tree.marks")
@@ -22,23 +22,20 @@ local FILTER_REASON = require("nvim-tree.enum").FILTER_REASON
 
 local config
 
----@class Explorer
+---@class (exact) Explorer: RootNode
 ---@field opts table user options
----@field absolute_path string
----@field nodes Node[]
----@field open boolean
----@field watcher Watcher|nil
 ---@field renderer Renderer
 ---@field filters Filters
 ---@field live_filter LiveFilter
 ---@field sorters Sorter
 ---@field marks Marks
 ---@field clipboard Clipboard
-local Explorer = {}
+local Explorer = RootNode:new()
 
----@param path string|nil
----@return Explorer|nil
-function Explorer:new(path)
+---Static factory method
+---@param path string?
+---@return Explorer?
+function Explorer:create(path)
   local err
 
   if path then
@@ -48,21 +45,22 @@ function Explorer:new(path)
   end
   if not path then
     notify.error(err)
-    return
+    return nil
   end
 
-  local o = {
-    opts = config,
-    absolute_path = path,
-    nodes = {},
-    open = true,
-    sorters = Sorters:new(config),
-  }
+  ---@type Explorer
+  local explorer_placeholder = nil
+
+  local o = RootNode:create(explorer_placeholder, path, "..", nil)
+
+  o = self:new(o) --[[@as Explorer]]
 
-  setmetatable(o, self)
-  self.__index = self
+  o.explorer = o
 
-  o.watcher = watch.create_watcher(o)
+  o.open = true
+  o.opts = config
+
+  o.sorters = Sorters:new(config)
   o.renderer = Renderer:new(config, o)
   o.filters = Filters:new(config, o)
   o.live_filter = LiveFilter:new(config, o)
@@ -79,18 +77,6 @@ function Explorer:expand(node)
   self:_load(node)
 end
 
-function Explorer:destroy()
-  local function iterate(node)
-    explorer_node.node_destroy(node)
-    if node.nodes then
-      for _, child in pairs(node.nodes) do
-        iterate(child)
-      end
-    end
-  end
-  iterate(self)
-end
-
 ---@param node Node
 ---@param git_status table|nil
 function Explorer:reload(node, git_status)
@@ -111,7 +97,7 @@ function Explorer:reload(node, git_status)
 
   local remain_childs = {}
 
-  local node_ignored = explorer_node.is_git_ignored(node)
+  local node_ignored = node:is_git_ignored()
   ---@type table<string, Node>
   local nodes_by_path = utils.key_by(node.nodes, "absolute_path")
 
@@ -138,32 +124,19 @@ function Explorer:reload(node, git_status)
     if filter_reason == FILTER_REASON.none then
       remain_childs[abs] = true
 
-      -- Type must come from fs_stat and not fs_scandir_next to maintain sshfs compatibility
-      local t = stat and stat.type or nil
-
       -- Recreate node if type changes.
       if nodes_by_path[abs] then
         local n = nodes_by_path[abs]
 
-        if n.type ~= t then
+        if not stat or n.type ~= stat.type then
           utils.array_remove(node.nodes, n)
-          explorer_node.node_destroy(n)
+          n:destroy()
           nodes_by_path[abs] = nil
         end
       end
 
       if not nodes_by_path[abs] then
-        local new_child = nil
-        if t == "directory" and vim.loop.fs_access(abs, "R") and Watcher.is_fs_event_capable(abs) then
-          new_child = builders.folder(node, abs, name, stat)
-        elseif t == "file" then
-          new_child = builders.file(node, abs, name, stat)
-        elseif t == "link" then
-          local link = builders.link(node, abs, name, stat)
-          if link.link_to ~= nil then
-            new_child = link
-          end
-        end
+        local new_child = node_factory.create_node(self, node, abs, stat, name)
         if new_child then
           table.insert(node.nodes, new_child)
           nodes_by_path[abs] = new_child
@@ -171,7 +144,7 @@ function Explorer:reload(node, git_status)
       else
         local n = nodes_by_path[abs]
         if n then
-          n.executable = builders.is_executable(abs) or false
+          n.executable = utils.is_executable(abs) or false
           n.fs_stat = stat
         end
       end
@@ -190,14 +163,14 @@ function Explorer:reload(node, git_status)
       if remain_childs[n.absolute_path] then
         return remain_childs[n.absolute_path]
       else
-        explorer_node.node_destroy(n)
+        n:destroy()
         return false
       end
     end, node.nodes)
   )
 
   local is_root = not node.parent
-  local child_folder_only = explorer_node.has_one_child_folder(node) and node.nodes[1]
+  local child_folder_only = node:has_one_child_folder() and node.nodes[1]
   if config.renderer.group_empty and not is_root and child_folder_only then
     node.group_next = child_folder_only
     local ns = self:reload(child_folder_only, git_status)
@@ -212,26 +185,6 @@ function Explorer:reload(node, git_status)
   return node.nodes
 end
 
----TODO #2837 #2871 move this and similar to node
----Refresh contents and git status for a single node
----@param node Node
----@param callback function
-function Explorer:refresh_node(node, callback)
-  if type(node) ~= "table" then
-    callback()
-  end
-
-  local parent_node = utils.get_parent_of_group(node)
-
-  self:reload_and_get_git_project(node.absolute_path, function(toplevel, project)
-    self:reload(parent_node, project)
-
-    self:update_parent_statuses(parent_node, project, toplevel)
-
-    callback()
-  end)
-end
-
 ---Refresh contents of all nodes to a path: actual directory and links.
 ---Groups will be expanded if needed.
 ---@param path string absolute path
@@ -259,7 +212,7 @@ function Explorer:refresh_parent_nodes_for_path(path)
     local project = git.get_project(toplevel) or {}
 
     self:reload(node, project)
-    self:update_parent_statuses(node, project, toplevel)
+    node:update_parent_statuses(project, toplevel)
   end
 
   log.profile_end(profile)
@@ -274,65 +227,19 @@ function Explorer:_load(node)
 end
 
 ---@private
----@param nodes_by_path table
+---@param nodes_by_path Node[]
 ---@param node_ignored boolean
 ---@param status table|nil
 ---@return fun(node: Node): table
 function Explorer:update_status(nodes_by_path, node_ignored, status)
   return function(node)
     if nodes_by_path[node.absolute_path] then
-      explorer_node.update_git_status(node, node_ignored, status)
+      node:update_git_status(node_ignored, status)
     end
     return node
   end
 end
 
----TODO #2837 #2871 move this and similar to node
----@private
----@param path string
----@param callback fun(toplevel: string|nil, project: table|nil)
-function Explorer:reload_and_get_git_project(path, callback)
-  local toplevel = git.get_toplevel(path)
-
-  git.reload_project(toplevel, path, function()
-    callback(toplevel, git.get_project(toplevel) or {})
-  end)
-end
-
----TODO #2837 #2871 move this and similar to node
----@private
----@param node Node
----@param project table|nil
----@param root string|nil
-function Explorer:update_parent_statuses(node, project, root)
-  while project and node do
-    -- step up to the containing project
-    if node.absolute_path == root then
-      -- stop at the top of the tree
-      if not node.parent then
-        break
-      end
-
-      root = git.get_toplevel(node.parent.absolute_path)
-
-      -- stop when no more projects
-      if not root then
-        break
-      end
-
-      -- update the containing project
-      project = git.get_project(root)
-      git.reload_project(root, node.absolute_path, nil)
-    end
-
-    -- update status
-    explorer_node.update_git_status(node, explorer_node.is_git_ignored(node.parent), project)
-
-    -- maybe parent
-    node = node.parent
-  end
-end
-
 ---@private
 ---@param handle uv.uv_fs_t
 ---@param cwd string
@@ -340,7 +247,7 @@ end
 ---@param git_status table
 ---@param parent Explorer
 function Explorer:populate_children(handle, cwd, node, git_status, parent)
-  local node_ignored = explorer_node.is_git_ignored(node)
+  local node_ignored = node:is_git_ignored()
   local nodes_by_path = utils.bool_record(node.nodes, "absolute_path")
 
   local filter_status = parent.filters:prepare(git_status)
@@ -368,23 +275,11 @@ function Explorer:populate_children(handle, cwd, node, git_status, parent)
       local stat = vim.loop.fs_lstat(abs)
       local filter_reason = parent.filters:should_filter_as_reason(abs, stat, filter_status)
       if filter_reason == FILTER_REASON.none and not nodes_by_path[abs] then
-        -- Type must come from fs_stat and not fs_scandir_next to maintain sshfs compatibility
-        local t = stat and stat.type or nil
-        local child = nil
-        if t == "directory" and vim.loop.fs_access(abs, "R") then
-          child = builders.folder(node, abs, name, stat)
-        elseif t == "file" then
-          child = builders.file(node, abs, name, stat)
-        elseif t == "link" then
-          local link = builders.link(node, abs, name, stat)
-          if link.link_to ~= nil then
-            child = link
-          end
-        end
+        local child = node_factory.create_node(self, node, abs, stat, name)
         if child then
           table.insert(node.nodes, child)
           nodes_by_path[child.absolute_path] = true
-          explorer_node.update_git_status(child, node_ignored, git_status)
+          child:update_git_status(node_ignored, git_status)
         end
       else
         for reason, value in pairs(FILTER_REASON) do
@@ -416,7 +311,7 @@ function Explorer:explore(node, status, parent)
   self:populate_children(handle, cwd, node, status, parent)
 
   local is_root = not node.parent
-  local child_folder_only = explorer_node.has_one_child_folder(node) and node.nodes[1]
+  local child_folder_only = node:has_one_child_folder() and node.nodes[1]
   if config.renderer.group_empty and not is_root and child_folder_only then
     local child_cwd = child_folder_only.link_to or child_folder_only.absolute_path
     local child_status = git.load_project_status(child_cwd)
@@ -473,14 +368,13 @@ function Explorer:reload_git()
   event_running = true
 
   local projects = git.reload()
-  explorer_node.reload_node_status(self, projects)
+  self:reload_node_status(projects)
   self.renderer:draw()
   event_running = false
 end
 
-function Explorer.setup(opts)
+function Explorer:setup(opts)
   config = opts
-  require("nvim-tree.explorer.node").setup(opts)
   require("nvim-tree.explorer.watch").setup(opts)
 end
 
diff --git a/lua/nvim-tree/explorer/live-filter.lua b/lua/nvim-tree/explorer/live-filter.lua
index 7cdfc35dd80..ca772b34b9b 100644
--- a/lua/nvim-tree/explorer/live-filter.lua
+++ b/lua/nvim-tree/explorer/live-filter.lua
@@ -23,7 +23,7 @@ function LiveFilter:new(opts, explorer)
   return o
 end
 
----@param node_ Node|nil
+---@param node_ Node?
 local function reset_filter(self, node_)
   node_ = node_ or self.explorer
 
@@ -85,7 +85,7 @@ local function matches(self, node)
   return vim.regex(self.filter):match_str(name) ~= nil
 end
 
----@param node_ Node|nil
+---@param node_ Node?
 function LiveFilter:apply_filter(node_)
   if not self.filter or self.filter == "" then
     reset_filter(self, node_)
diff --git a/lua/nvim-tree/explorer/node-builders.lua b/lua/nvim-tree/explorer/node-builders.lua
deleted file mode 100644
index 10e958168aa..00000000000
--- a/lua/nvim-tree/explorer/node-builders.lua
+++ /dev/null
@@ -1,107 +0,0 @@
-local utils = require("nvim-tree.utils")
-local watch = require("nvim-tree.explorer.watch")
-
-local M = {}
-
----@param parent Node
----@param absolute_path string
----@param name string
----@param fs_stat uv.fs_stat.result|nil
----@return Node
-function M.folder(parent, absolute_path, name, fs_stat)
-  local handle = vim.loop.fs_scandir(absolute_path)
-  local has_children = handle and vim.loop.fs_scandir_next(handle) ~= nil
-
-  local node = {
-    type = "directory",
-    absolute_path = absolute_path,
-    fs_stat = fs_stat,
-    group_next = nil, -- If node is grouped, this points to the next child dir/link node
-    has_children = has_children,
-    name = name,
-    nodes = {},
-    open = false,
-    parent = parent,
-  }
-
-  node.watcher = watch.create_watcher(node)
-
-  return node
-end
-
---- path is an executable file or directory
----@param absolute_path string
----@return boolean|nil
-function M.is_executable(absolute_path)
-  if utils.is_windows or utils.is_wsl then
-    --- executable detection on windows is buggy and not performant hence it is disabled
-    return false
-  else
-    return vim.loop.fs_access(absolute_path, "X")
-  end
-end
-
----@param parent Node
----@param absolute_path string
----@param name string
----@param fs_stat uv.fs_stat.result|nil
----@return Node
-function M.file(parent, absolute_path, name, fs_stat)
-  local ext = string.match(name, ".?[^.]+%.(.*)") or ""
-
-  return {
-    type = "file",
-    absolute_path = absolute_path,
-    executable = M.is_executable(absolute_path),
-    extension = ext,
-    fs_stat = fs_stat,
-    name = name,
-    parent = parent,
-  }
-end
-
--- TODO-INFO: sometimes fs_realpath returns nil
--- I expect this be a bug in glibc, because it fails to retrieve the path for some
--- links (for instance libr2.so in /usr/lib) and thus even with a C program realpath fails
--- when it has no real reason to. Maybe there is a reason, but errno is definitely wrong.
--- So we need to check for link_to ~= nil when adding new links to the main tree
----@param parent Node
----@param absolute_path string
----@param name string
----@param fs_stat uv.fs_stat.result|nil
----@return Node
-function M.link(parent, absolute_path, name, fs_stat)
-  --- I dont know if this is needed, because in my understanding, there isn't hard links in windows, but just to be sure i changed it.
-  local link_to = vim.loop.fs_realpath(absolute_path)
-  local open, nodes, has_children
-
-  local is_dir_link = (link_to ~= nil) and vim.loop.fs_stat(link_to).type == "directory"
-
-  if is_dir_link and link_to then
-    local handle = vim.loop.fs_scandir(link_to)
-    has_children = handle and vim.loop.fs_scandir_next(handle) ~= nil
-    open = false
-    nodes = {}
-  end
-
-  local node = {
-    type = "link",
-    absolute_path = absolute_path,
-    fs_stat = fs_stat,
-    group_next = nil, -- If node is grouped, this points to the next child dir/link node
-    has_children = has_children,
-    link_to = link_to,
-    name = name,
-    nodes = nodes,
-    open = open,
-    parent = parent,
-  }
-
-  if is_dir_link then
-    node.watcher = watch.create_watcher(node)
-  end
-
-  return node
-end
-
-return M
diff --git a/lua/nvim-tree/explorer/node.lua b/lua/nvim-tree/explorer/node.lua
deleted file mode 100644
index 27e31b131c5..00000000000
--- a/lua/nvim-tree/explorer/node.lua
+++ /dev/null
@@ -1,183 +0,0 @@
-local git = {} -- circular dependencies
-
-local M = {}
-
----@class GitStatus
----@field file string|nil
----@field dir table|nil
-
----@param parent_ignored boolean
----@param status table|nil
----@param absolute_path string
----@return GitStatus|nil
-local function get_dir_git_status(parent_ignored, status, absolute_path)
-  if parent_ignored then
-    return { file = "!!" }
-  end
-
-  if status then
-    return {
-      file = status.files and status.files[absolute_path],
-      dir = status.dirs and {
-        direct = status.dirs.direct[absolute_path],
-        indirect = status.dirs.indirect[absolute_path],
-      },
-    }
-  end
-end
-
----@param parent_ignored boolean
----@param status table
----@param absolute_path string
----@return GitStatus
-local function get_git_status(parent_ignored, status, absolute_path)
-  local file_status = parent_ignored and "!!" or (status and status.files and status.files[absolute_path])
-  return { file = file_status }
-end
-
----@param node Node
----@return boolean
-function M.has_one_child_folder(node)
-  return #node.nodes == 1 and node.nodes[1].nodes and vim.loop.fs_access(node.nodes[1].absolute_path, "R") or false
-end
-
----@param node Node
----@param parent_ignored boolean
----@param status table|nil
-function M.update_git_status(node, parent_ignored, status)
-  local get_status
-  if node.nodes then
-    get_status = get_dir_git_status
-  else
-    get_status = get_git_status
-  end
-
-  -- status of the node's absolute path
-  node.git_status = get_status(parent_ignored, status, node.absolute_path)
-
-  -- status of the link target, if the link itself is not dirty
-  if node.link_to and not node.git_status then
-    node.git_status = get_status(parent_ignored, status, node.link_to)
-  end
-end
-
----@param node Node
----@return GitStatus|nil
-function M.get_git_status(node)
-  local git_status = node and node.git_status
-  if not git_status then
-    -- status doesn't exist
-    return nil
-  end
-
-  if not node.nodes then
-    -- file
-    return git_status.file and { git_status.file }
-  end
-
-  -- dir
-  if not M.config.git.show_on_dirs then
-    return nil
-  end
-
-  local status = {}
-  if not require("nvim-tree.lib").get_last_group_node(node).open or M.config.git.show_on_open_dirs then
-    -- dir is closed or we should show on open_dirs
-    if git_status.file ~= nil then
-      table.insert(status, git_status.file)
-    end
-    if git_status.dir ~= nil then
-      if git_status.dir.direct ~= nil then
-        for _, s in pairs(node.git_status.dir.direct) do
-          table.insert(status, s)
-        end
-      end
-      if git_status.dir.indirect ~= nil then
-        for _, s in pairs(node.git_status.dir.indirect) do
-          table.insert(status, s)
-        end
-      end
-    end
-  else
-    -- dir is open and we shouldn't show on open_dirs
-    if git_status.file ~= nil then
-      table.insert(status, git_status.file)
-    end
-    if git_status.dir ~= nil and git_status.dir.direct ~= nil then
-      local deleted = {
-        [" D"] = true,
-        ["D "] = true,
-        ["RD"] = true,
-        ["DD"] = true,
-      }
-      for _, s in pairs(node.git_status.dir.direct) do
-        if deleted[s] then
-          table.insert(status, s)
-        end
-      end
-    end
-  end
-  if #status == 0 then
-    return nil
-  else
-    return status
-  end
-end
-
----@param parent_node Node|nil
----@param projects table
-function M.reload_node_status(parent_node, projects)
-  if parent_node == nil then
-    return
-  end
-
-  local toplevel = git.get_toplevel(parent_node.absolute_path)
-  local status = projects[toplevel] or {}
-  for _, node in ipairs(parent_node.nodes) do
-    M.update_git_status(node, M.is_git_ignored(parent_node), status)
-    if node.nodes and #node.nodes > 0 then
-      M.reload_node_status(node, projects)
-    end
-  end
-end
-
----@param node Node
----@return boolean
-function M.is_git_ignored(node)
-  return node and node.git_status ~= nil and node.git_status.file == "!!"
-end
-
----@param node Node
----@return boolean
-function M.is_dotfile(node)
-  if node == nil then
-    return false
-  end
-  if node.is_dot or (node.name and (node.name:sub(1, 1) == ".")) or M.is_dotfile(node.parent) then
-    node.is_dot = true
-    return true
-  end
-  return false
-end
-
----@param node Node
-function M.node_destroy(node)
-  if not node then
-    return
-  end
-
-  if node.watcher then
-    node.watcher:destroy()
-    node.watcher = nil
-  end
-end
-
-function M.setup(opts)
-  M.config = {
-    git = opts.git,
-  }
-
-  git = require("nvim-tree.git")
-end
-
-return M
diff --git a/lua/nvim-tree/explorer/sorters.lua b/lua/nvim-tree/explorer/sorters.lua
index 4ec4c3d596e..bcf55900af5 100644
--- a/lua/nvim-tree/explorer/sorters.lua
+++ b/lua/nvim-tree/explorer/sorters.lua
@@ -111,7 +111,7 @@ local function split_merge(t, first, last, comparator)
 end
 
 ---Perform a merge sort using sorter option.
----@param t table nodes
+---@param t Node[]
 function Sorter:sort(t)
   if self.user then
     local t_user = {}
diff --git a/lua/nvim-tree/explorer/watch.lua b/lua/nvim-tree/explorer/watch.lua
index 317f3e54b7a..7fd13f4c3fb 100644
--- a/lua/nvim-tree/explorer/watch.lua
+++ b/lua/nvim-tree/explorer/watch.lua
@@ -76,12 +76,7 @@ function M.create_watcher(node)
       else
         log.line("watcher", "node event executing refresh '%s'", node.absolute_path)
       end
-      local explorer = require("nvim-tree.core").get_explorer()
-      if explorer then
-        explorer:refresh_node(node, function()
-          explorer.renderer:draw()
-        end)
-      end
+      node:refresh()
     end)
   end
 
diff --git a/lua/nvim-tree/git/init.lua b/lua/nvim-tree/git/init.lua
index caea8517a5e..b963cfd7d3e 100644
--- a/lua/nvim-tree/git/init.lua
+++ b/lua/nvim-tree/git/init.lua
@@ -4,7 +4,10 @@ local git_utils = require("nvim-tree.git.utils")
 local Runner = require("nvim-tree.git.runner")
 local Watcher = require("nvim-tree.watcher").Watcher
 local Iterator = require("nvim-tree.iterators.node-iterator")
-local explorer_node = require("nvim-tree.explorer.node")
+
+---@class GitStatus
+---@field file string|nil
+---@field dir table|nil
 
 local M = {
   config = {},
@@ -208,18 +211,15 @@ local function reload_tree_at(toplevel)
     Iterator.builder(root_node.nodes)
       :hidden()
       :applier(function(node)
-        local parent_ignored = explorer_node.is_git_ignored(node.parent)
-        explorer_node.update_git_status(node, parent_ignored, git_status)
+        local parent_ignored = node.parent and node.parent:is_git_ignored() or false
+        node:update_git_status(parent_ignored, git_status)
       end)
       :recursor(function(node)
         return node.nodes and #node.nodes > 0 and node.nodes
       end)
       :iterate()
 
-    local explorer = require("nvim-tree.core").get_explorer()
-    if explorer then
-      explorer.renderer:draw()
-    end
+    root_node.explorer.renderer:draw()
   end)
 end
 
@@ -283,6 +283,35 @@ function M.load_project_status(path)
   end
 end
 
+---@param parent_ignored boolean
+---@param status table|nil
+---@param absolute_path string
+---@return GitStatus|nil
+function M.git_status_dir(parent_ignored, status, absolute_path)
+  if parent_ignored then
+    return { file = "!!" }
+  end
+
+  if status then
+    return {
+      file = status.files and status.files[absolute_path],
+      dir = status.dirs and {
+        direct = status.dirs.direct[absolute_path],
+        indirect = status.dirs.indirect[absolute_path],
+      },
+    }
+  end
+end
+
+---@param parent_ignored boolean
+---@param status table|nil
+---@param absolute_path string
+---@return GitStatus
+function M.git_status_file(parent_ignored, status, absolute_path)
+  local file_status = parent_ignored and "!!" or (status and status.files and status.files[absolute_path])
+  return { file = file_status }
+end
+
 function M.purge_state()
   log.line("git", "purge_state")
 
diff --git a/lua/nvim-tree/lib.lua b/lua/nvim-tree/lib.lua
index 0ad23687da8..15ccb337aa1 100644
--- a/lua/nvim-tree/lib.lua
+++ b/lua/nvim-tree/lib.lua
@@ -3,7 +3,6 @@ local core = require("nvim-tree.core")
 local utils = require("nvim-tree.utils")
 local events = require("nvim-tree.events")
 local notify = require("nvim-tree.notify")
-local explorer_node = require("nvim-tree.explorer.node")
 
 ---@class LibOpenOpts
 ---@field path string|nil path
@@ -15,6 +14,7 @@ local M = {
 }
 
 ---Cursor position as per vim.api.nvim_win_get_cursor
+---nil on no explorer or invalid view win
 ---@return integer[]|nil
 function M.get_cursor_position()
   if not core.get_explorer() then
@@ -31,154 +31,28 @@ end
 
 ---@return Node|nil
 function M.get_node_at_cursor()
+  local explorer = core.get_explorer()
+  if not explorer then
+    return
+  end
+
   local cursor = M.get_cursor_position()
   if not cursor then
     return
   end
 
   if cursor[1] == 1 and view.is_root_folder_visible(core.get_cwd()) then
-    return { name = ".." }
-  end
-
-  return utils.get_nodes_by_line(core.get_explorer().nodes, core.get_nodes_starting_line())[cursor[1]]
-end
-
----Create a sanitized partial copy of a node, populating children recursively.
----@param node Node|nil
----@return Node|nil cloned node
-local function clone_node(node)
-  if not node then
-    node = core.get_explorer()
-    if not node then
-      return nil
-    end
-  end
-
-  local n = {
-    absolute_path = node.absolute_path,
-    executable = node.executable,
-    extension = node.extension,
-    git_status = node.git_status,
-    has_children = node.has_children,
-    hidden = node.hidden,
-    link_to = node.link_to,
-    name = node.name,
-    open = node.open,
-    type = node.type,
-    fs_stat = node.fs_stat,
-  }
-
-  if type(node.nodes) == "table" then
-    n.nodes = {}
-    for _, child in ipairs(node.nodes) do
-      table.insert(n.nodes, clone_node(child))
-    end
+    return explorer
   end
 
-  return n
+  return utils.get_nodes_by_line(explorer.nodes, core.get_nodes_starting_line())[cursor[1]]
 end
 
 ---Api.tree.get_nodes
----@return Node[]|nil
+---@return Node[]?
 function M.get_nodes()
-  return clone_node(core.get_explorer())
-end
-
--- If node is grouped, return the last node in the group. Otherwise, return the given node.
----@param node Node
----@return Node
-function M.get_last_group_node(node)
-  while node and node.group_next do
-    node = node.group_next
-  end
-
-  return node ---@diagnostic disable-line: return-type-mismatch -- it can't be nil
-end
-
----Group empty folders
--- Recursively group nodes
----@param node Node
----@return Node[]
-function M.group_empty_folders(node)
-  local is_root = not node.parent
-  local child_folder_only = explorer_node.has_one_child_folder(node) and node.nodes[1]
-  if M.group_empty and not is_root and child_folder_only then
-    node.group_next = child_folder_only
-    local ns = M.group_empty_folders(child_folder_only)
-    node.nodes = ns or {}
-    return ns
-  end
-  return node.nodes
-end
-
----Ungroup empty folders
--- If a node is grouped, ungroup it: put node.group_next to the node.nodes and set node.group_next to nil
----@param node Node
-function M.ungroup_empty_folders(node)
-  local cur = node
-  while cur and cur.group_next do
-    cur.nodes = { cur.group_next }
-    cur.group_next = nil
-    cur = cur.nodes[1]
-  end
-end
-
----@param node Node
----@return Node[]
-function M.get_all_nodes_in_group(node)
-  local next_node = utils.get_parent_of_group(node)
-  local nodes = {}
-  while next_node do
-    table.insert(nodes, next_node)
-    next_node = next_node.group_next
-  end
-  return nodes
-end
-
--- Toggle group empty folders
----@param head_node Node
-local function toggle_group_folders(head_node)
-  local is_grouped = head_node.group_next ~= nil
-
-  if is_grouped then
-    M.ungroup_empty_folders(head_node)
-  else
-    M.group_empty_folders(head_node)
-  end
-end
-
----@param node Node
-function M.expand_or_collapse(node, toggle_group)
   local explorer = core.get_explorer()
-
-  toggle_group = toggle_group or false
-  if node.has_children then
-    node.has_children = false
-  end
-
-  if #node.nodes == 0 and explorer then
-    explorer:expand(node)
-  end
-
-  local head_node = utils.get_parent_of_group(node)
-  if toggle_group then
-    toggle_group_folders(head_node)
-  end
-
-  local open = M.get_last_group_node(node).open
-  local next_open
-  if toggle_group then
-    next_open = open
-  else
-    next_open = not open
-  end
-  for _, n in ipairs(M.get_all_nodes_in_group(head_node)) do
-    n.open = next_open
-  end
-
-  if explorer then
-    explorer.renderer:draw()
-  end
+  return explorer and explorer:clone()
 end
 
 function M.set_target_win()
diff --git a/lua/nvim-tree/log.lua b/lua/nvim-tree/log.lua
index ad07a8562ef..8e796b9e6c8 100644
--- a/lua/nvim-tree/log.lua
+++ b/lua/nvim-tree/log.lua
@@ -71,7 +71,7 @@ end
 --- Write to log file the inspection of a node
 --- defaults to the node under cursor if none is provided
 ---@param typ string as per log.types config
----@param node table|nil node to be inspected
+---@param node Node? node to be inspected
 ---@param fmt string for string.format
 ---@vararg any arguments for string.format
 function M.node(typ, node, fmt, ...)
diff --git a/lua/nvim-tree/marks/init.lua b/lua/nvim-tree/marks/init.lua
index 8fec6c663fc..c2da9009ad8 100644
--- a/lua/nvim-tree/marks/init.lua
+++ b/lua/nvim-tree/marks/init.lua
@@ -8,6 +8,8 @@ local rename_file = require("nvim-tree.actions.fs.rename-file")
 local trash = require("nvim-tree.actions.fs.trash")
 local utils = require("nvim-tree.utils")
 
+local DirectoryNode = require("nvim-tree.node.directory")
+
 ---@class Marks
 ---@field config table hydrated user opts.filters
 ---@field private explorer Explorer
@@ -152,7 +154,7 @@ function Marks:bulk_move()
   local node_at_cursor = lib.get_node_at_cursor()
   local default_path = core.get_cwd()
 
-  if node_at_cursor and node_at_cursor.type == "directory" then
+  if node_at_cursor and node_at_cursor:is(DirectoryNode) then
     default_path = node_at_cursor.absolute_path
   elseif node_at_cursor and node_at_cursor.parent then
     default_path = node_at_cursor.parent.absolute_path
diff --git a/lua/nvim-tree/node.lua b/lua/nvim-tree/node.lua
deleted file mode 100644
index cf8aa671c39..00000000000
--- a/lua/nvim-tree/node.lua
+++ /dev/null
@@ -1,36 +0,0 @@
----@meta
-
----@class ParentNode
----@field name string
-
----@class BaseNode
----@field absolute_path string
----@field executable boolean
----@field fs_stat uv.fs_stat.result|nil
----@field git_status GitStatus|nil
----@field hidden boolean
----@field is_dot boolean
----@field name string
----@field parent DirNode
----@field type string
----@field watcher function|nil
----@field diag_status DiagStatus|nil
-
----@class DirNode: BaseNode
----@field has_children boolean
----@field group_next Node|nil
----@field nodes Node[]
----@field open boolean
----@field hidden_stats table -- Each field of this table is a key for source and value for count
-
----@class FileNode: BaseNode
----@field extension string
-
----@class SymlinkDirNode: DirNode
----@field link_to string
-
----@class SymlinkFileNode: FileNode
----@field link_to string
-
----@alias SymlinkNode SymlinkDirNode|SymlinkFileNode
----@alias Node ParentNode|DirNode|FileNode|SymlinkNode|Explorer
diff --git a/lua/nvim-tree/node/directory.lua b/lua/nvim-tree/node/directory.lua
new file mode 100644
index 00000000000..050d3e35b8f
--- /dev/null
+++ b/lua/nvim-tree/node/directory.lua
@@ -0,0 +1,79 @@
+local watch = require("nvim-tree.explorer.watch")
+
+local BaseNode = require("nvim-tree.node")
+
+---@class (exact) DirectoryNode: BaseNode
+---@field has_children boolean
+---@field group_next Node? -- If node is grouped, this points to the next child dir/link node
+---@field nodes Node[]
+---@field open boolean
+---@field hidden_stats table? -- Each field of this table is a key for source and value for count
+local DirectoryNode = BaseNode:new()
+
+---Static factory method
+---@param explorer Explorer
+---@param parent Node?
+---@param absolute_path string
+---@param name string
+---@param fs_stat uv.fs_stat.result|nil
+---@return DirectoryNode
+function DirectoryNode:create(explorer, parent, absolute_path, name, fs_stat)
+  local handle = vim.loop.fs_scandir(absolute_path)
+  local has_children = handle and vim.loop.fs_scandir_next(handle) ~= nil or false
+
+  ---@type DirectoryNode
+  local o = {
+    type = "directory",
+    explorer = explorer,
+    absolute_path = absolute_path,
+    executable = false,
+    fs_stat = fs_stat,
+    git_status = nil,
+    hidden = false,
+    is_dot = false,
+    name = name,
+    parent = parent,
+    watcher = nil,
+    diag_status = nil,
+
+    has_children = has_children,
+    group_next = nil,
+    nodes = {},
+    open = false,
+    hidden_stats = nil,
+  }
+  o = self:new(o) --[[@as DirectoryNode]]
+
+  o.watcher = watch.create_watcher(o)
+
+  return o
+end
+
+function DirectoryNode:destroy()
+  BaseNode.destroy(self)
+  if self.nodes then
+    for _, node in pairs(self.nodes) do
+      node:destroy()
+    end
+  end
+end
+
+---Create a sanitized partial copy of a node, populating children recursively.
+---@return DirectoryNode cloned
+function DirectoryNode:clone()
+  local clone = BaseNode.clone(self) --[[@as DirectoryNode]]
+
+  clone.has_children = self.has_children
+  clone.group_next = nil
+  clone.nodes = {}
+  clone.open = self.open
+  clone.hidden_stats = nil
+
+  for _, child in ipairs(self.nodes) do
+    table.insert(clone.nodes, child:clone())
+  end
+
+  return clone
+end
+
+return DirectoryNode
diff --git a/lua/nvim-tree/node/factory.lua b/lua/nvim-tree/node/factory.lua
new file mode 100644
index 00000000000..a46057da111
--- /dev/null
+++ b/lua/nvim-tree/node/factory.lua
@@ -0,0 +1,31 @@
+local DirectoryNode = require("nvim-tree.node.directory")
+local LinkNode = require("nvim-tree.node.link")
+local FileNode = require("nvim-tree.node.file")
+local Watcher = require("nvim-tree.watcher")
+
+local M = {}
+
+---Factory function to create the appropriate Node
+---@param explorer Explorer
+---@param parent Node
+---@param abs string
+---@param stat uv.fs_stat.result? -- on nil stat return nil Node
+---@param name string
+---@return Node?
+function M.create_node(explorer, parent, abs, stat, name)
+  if not stat then
+    return nil
+  end
+
+  if stat.type == "directory" and vim.loop.fs_access(abs, "R") and Watcher.is_fs_event_capable(abs) then
+    return DirectoryNode:create(explorer, parent, abs, name, stat)
+  elseif stat.type == "file" then
+    return FileNode:create(explorer, parent, abs, name, stat)
+  elseif stat.type == "link" then
+    return LinkNode:create(explorer, parent, abs, name, stat)
+  end
+
+  return nil
+end
+
+return M
diff --git a/lua/nvim-tree/node/file.lua b/lua/nvim-tree/node/file.lua
new file mode 100644
index 00000000000..f504631b7fb
--- /dev/null
+++ b/lua/nvim-tree/node/file.lua
@@ -0,0 +1,49 @@
+local utils = require("nvim-tree.utils")
+
+local BaseNode = require("nvim-tree.node")
+
+---@class (exact) FileNode: BaseNode
+---@field extension string
+local FileNode = BaseNode:new()
+
+---Static factory method
+---@param explorer Explorer
+---@param parent Node
+---@param absolute_path string
+---@param name string
+---@param fs_stat uv.fs_stat.result?
+---@return FileNode
+function FileNode:create(explorer, parent, absolute_path, name, fs_stat)
+  ---@type FileNode
+  local o = {
+    type = "file",
+    explorer = explorer,
+    absolute_path = absolute_path,
+    executable = utils.is_executable(absolute_path),
+    fs_stat = fs_stat,
+    git_status = nil,
+    hidden = false,
+    is_dot = false,
+    name = name,
+    parent = parent,
+    watcher = nil,
+    diag_status = nil,
+
+    extension = string.match(name, ".?[^.]+%.(.*)") or "",
+  }
+  o = self:new(o) --[[@as FileNode]]
+
+  return o
+end
+
+---Create a sanitized partial copy of a node, populating children recursively.
+---@return FileNode cloned
+function FileNode:clone()
+  local clone = BaseNode.clone(self) --[[@as FileNode]]
+
+  clone.extension = self.extension
+
+  return clone
+end
+
+return FileNode
diff --git a/lua/nvim-tree/node/init.lua b/lua/nvim-tree/node/init.lua
new file mode 100644
index 00000000000..5a1f5e2c728
--- /dev/null
+++ b/lua/nvim-tree/node/init.lua
@@ -0,0 +1,347 @@
+local git = require("nvim-tree.git")
+
+---Abstract Node class.
+---Uses the abstract factory pattern to instantiate child instances.
+---@class (exact) BaseNode
+---@field private __index? table
+---@field type NODE_TYPE
+---@field explorer Explorer
+---@field absolute_path string
+---@field executable boolean
+---@field fs_stat uv.fs_stat.result?
+---@field git_status GitStatus?
+---@field hidden boolean
+---@field is_dot boolean
+---@field name string
+---@field parent Node?
+---@field watcher Watcher?
+---@field diag_status DiagStatus?
+local BaseNode = {}
+
+---@alias Node RootNode|BaseNode|DirectoryNode|FileNode|LinkNode
+
+---@param o BaseNode?
+---@return BaseNode
+function BaseNode:new(o)
+  o = o or {}
+
+  setmetatable(o, self)
+  self.__index = self
+
+  return o
+end
+
+function BaseNode:destroy()
+  if self.watcher then
+    self.watcher:destroy()
+    self.watcher = nil
+  end
+end
+
+---From plenary
+---Checks if the object is an instance
+---This will start with the lowest class and loop over all the superclasses.
+---@param self BaseNode
+---@param T BaseNode
+---@return boolean
+function BaseNode:is(T)
+  local mt = getmetatable(self)
+  while mt do
+    if mt == T then
+      return true
+    end
+    mt = getmetatable(mt)
+  end
+  return false
+end
+
+---@return boolean
+function BaseNode:has_one_child_folder()
+  return #self.nodes == 1 and self.nodes[1].nodes and vim.loop.fs_access(self.nodes[1].absolute_path, "R") or false
+end
+
+---@param parent_ignored boolean
+---@param status table|nil
+function BaseNode:update_git_status(parent_ignored, status)
+  local get_status
+  if self.nodes then
+    get_status = git.git_status_dir
+  else
+    get_status = git.git_status_file
+  end
+
+  -- status of the node's absolute path
+  self.git_status = get_status(parent_ignored, status, self.absolute_path)
+
+  -- status of the link target, if the link itself is not dirty
+  if self.link_to and not self.git_status then
+    self.git_status = get_status(parent_ignored, status, self.link_to)
+  end
+end
+
+---@return GitStatus|nil
+function BaseNode:get_git_status()
+  if not self.git_status then
+    -- status doesn't exist
+    return nil
+  end
+
+  if not self.nodes then
+    -- file
+    return self.git_status.file and { self.git_status.file }
+  end
+
+  -- dir
+  if not self.explorer.opts.git.show_on_dirs then
+    return nil
+  end
+
+  local status = {}
+  if not self:last_group_node().open or self.explorer.opts.git.show_on_open_dirs then
+    -- dir is closed or we should show on open_dirs
+    if self.git_status.file ~= nil then
+      table.insert(status, self.git_status.file)
+    end
+    if self.git_status.dir ~= nil then
+      if self.git_status.dir.direct ~= nil then
+        for _, s in pairs(self.git_status.dir.direct) do
+          table.insert(status, s)
+        end
+      end
+      if self.git_status.dir.indirect ~= nil then
+        for _, s in pairs(self.git_status.dir.indirect) do
+          table.insert(status, s)
+        end
+      end
+    end
+  else
+    -- dir is open and we shouldn't show on open_dirs
+    if self.git_status.file ~= nil then
+      table.insert(status, self.git_status.file)
+    end
+    if self.git_status.dir ~= nil and self.git_status.dir.direct ~= nil then
+      local deleted = {
+        [" D"] = true,
+        ["D "] = true,
+        ["RD"] = true,
+        ["DD"] = true,
+      }
+      for _, s in pairs(self.git_status.dir.direct) do
+        if deleted[s] then
+          table.insert(status, s)
+        end
+      end
+    end
+  end
+  if #status == 0 then
+    return nil
+  else
+    return status
+  end
+end
+
+---@param projects table
+function BaseNode:reload_node_status(projects)
+  local toplevel = git.get_toplevel(self.absolute_path)
+  local status = projects[toplevel] or {}
+  for _, node in ipairs(self.nodes) do
+    node:update_git_status(self:is_git_ignored(), status)
+    if node.nodes and #node.nodes > 0 then
+      node:reload_node_status(projects)
+    end
+  end
+end
+
+---@return boolean
+function BaseNode:is_git_ignored()
+  return self.git_status ~= nil and self.git_status.file == "!!"
+end
+
+---@return boolean
+function BaseNode:is_dotfile()
+  if
+    self.is_dot                                     --
+    or (self.name and (self.name:sub(1, 1) == ".")) --
+    or (self.parent and self.parent:is_dotfile())
+  then
+    self.is_dot = true
+    return true
+  end
+  return false
+end
+
+-- If node is grouped, return the last node in the group. Otherwise, return the given node.
+---@return Node
+function BaseNode:last_group_node()
+  local node = self --[[@as BaseNode]]
+
+  while node.group_next do
+    node = node.group_next
+  end
+
+  return node
+end
+
+---@param project table|nil
+---@param root string|nil
+function BaseNode:update_parent_statuses(project, root)
+  local node = self
+  while project and node do
+    -- step up to the containing project
+    if node.absolute_path == root then
+      -- stop at the top of the tree
+      if not node.parent then
+        break
+      end
+
+      root = git.get_toplevel(node.parent.absolute_path)
+
+      -- stop when no more projects
+      if not root then
+        break
+      end
+
+      -- update the containing project
+      project = git.get_project(root)
+      git.reload_project(root, node.absolute_path, nil)
+    end
+
+    -- update status
+    node:update_git_status(node.parent and node.parent:is_git_ignored() or false, project)
+
+    -- maybe parent
+    node = node.parent
+  end
+end
+
+---Refresh contents and git status for a single node
+function BaseNode:refresh()
+  local parent_node = self:get_parent_of_group()
+  local toplevel = git.get_toplevel(self.absolute_path)
+
+  git.reload_project(toplevel, self.absolute_path, function()
+    local project = git.get_project(toplevel) or {}
+
+    self.explorer:reload(parent_node, project)
+
+    parent_node:update_parent_statuses(project, toplevel)
+
+    self.explorer.renderer:draw()
+  end)
+end
+
+---Get the highest parent of grouped nodes
+---@return Node node or parent
+function BaseNode:get_parent_of_group()
+  local node = self
+  while node and node.parent and node.parent.group_next do
+    node = node.parent or node
+  end
+  return node
+end
+
+---@return Node[]
+function BaseNode:get_all_nodes_in_group()
+  local next_node = self:get_parent_of_group()
+  local nodes = {}
+  while next_node do
+    table.insert(nodes, next_node)
+    next_node = next_node.group_next
+  end
+  return nodes
+end
+
+-- Toggle group empty folders
+function BaseNode:toggle_group_folders()
+  local is_grouped = self.group_next ~= nil
+
+  if is_grouped then
+    self:ungroup_empty_folders()
+  else
+    self:group_empty_folders()
+  end
+end
+
+---Group empty folders
+-- Recursively group nodes
+---@return Node[]
+function BaseNode:group_empty_folders()
+  local is_root = not self.parent
+  local child_folder_only = self:has_one_child_folder() and self.nodes[1]
+  if self.explorer.opts.renderer.group_empty and not is_root and child_folder_only then
+    ---@cast self DirectoryNode -- TODO move this to the class
+    self.group_next = child_folder_only
+    local ns = child_folder_only:group_empty_folders()
+    self.nodes = ns or {}
+    return ns
+  end
+  return self.nodes
+end
+
+---Ungroup empty folders
+-- If a node is grouped, ungroup it: put node.group_next to the node.nodes and set node.group_next to nil
+function BaseNode:ungroup_empty_folders()
+  local cur = self
+  while cur and cur.group_next do
+    cur.nodes = { cur.group_next }
+    cur.group_next = nil
+    cur = cur.nodes[1]
+  end
+end
+
+function BaseNode:expand_or_collapse(toggle_group)
+  toggle_group = toggle_group or false
+  if self.has_children then
+    ---@cast self DirectoryNode -- TODO move this to the class
+    self.has_children = false
+  end
+
+  if #self.nodes == 0 then
+    self.explorer:expand(self)
+  end
+
+  local head_node = self:get_parent_of_group()
+  if toggle_group then
+    head_node:toggle_group_folders()
+  end
+
+  local open = self:last_group_node().open
+  local next_open
+  if toggle_group then
+    next_open = open
+  else
+    next_open = not open
+  end
+  for _, n in ipairs(head_node:get_all_nodes_in_group()) do
+    n.open = next_open
+  end
+
+  self.explorer.renderer:draw()
+end
+
+---Create a sanitized partial copy of a node, populating children recursively.
+---@return BaseNode cloned
+function BaseNode:clone()
+  ---@type Explorer
+  local explorer_placeholder = nil
+
+  ---@type BaseNode
+  local clone = {
+    type = self.type,
+    explorer = explorer_placeholder,
+    absolute_path = self.absolute_path,
+    executable = self.executable,
+    fs_stat = self.fs_stat,
+    git_status = self.git_status,
+    hidden = self.hidden,
+    is_dot = self.is_dot,
+    name = self.name,
+    parent = nil,
+    watcher = nil,
+    diag_status = nil,
+  }
+
+  return clone
+end
+
+return BaseNode
diff --git a/lua/nvim-tree/node/link.lua b/lua/nvim-tree/node/link.lua
new file mode 100644
index 00000000000..2df9d920092
--- /dev/null
+++ b/lua/nvim-tree/node/link.lua
@@ -0,0 +1,89 @@
+local watch = require("nvim-tree.explorer.watch")
+
+local BaseNode = require("nvim-tree.node")
+
+---@class (exact) LinkNode: BaseNode
+---@field has_children boolean
+---@field group_next Node? -- If node is grouped, this points to the next child dir/link node
+---@field link_to string absolute path
+---@field nodes Node[]
+---@field open boolean
+local LinkNode = BaseNode:new()
+
+---Static factory method
+---@param explorer Explorer
+---@param parent Node
+---@param absolute_path string
+---@param name string
+---@param fs_stat uv.fs_stat.result?
+---@return LinkNode? nil on vim.loop.fs_realpath failure
+function LinkNode:create(explorer, parent, absolute_path, name, fs_stat)
+  -- INFO: sometimes fs_realpath returns nil
+  -- I expect this be a bug in glibc, because it fails to retrieve the path for some
+  -- links (for instance libr2.so in /usr/lib) and thus even with a C program realpath fails
+  -- when it has no real reason to. Maybe there is a reason, but errno is definitely wrong.
+  local link_to = vim.loop.fs_realpath(absolute_path)
+  if not link_to then
+    return nil
+  end
+
+  local open, nodes, has_children
+  local is_dir_link = (link_to ~= nil) and vim.loop.fs_stat(link_to).type == "directory"
+
+  if is_dir_link and link_to then
+    local handle = vim.loop.fs_scandir(link_to)
+    has_children = handle and vim.loop.fs_scandir_next(handle) ~= nil or false
+    open = false
+    nodes = {}
+  end
+
+  ---@type LinkNode
+  local o = {
+    type = "link",
+    explorer = explorer,
+    absolute_path = absolute_path,
+    executable = false,
+    fs_stat = fs_stat,
+    hidden = false,
+    is_dot = false,
+    name = name,
+    parent = parent,
+    watcher = nil,
+    diag_status = nil,
+
+    has_children = has_children,
+    group_next = nil,
+    link_to = link_to,
+    nodes = nodes,
+    open = open,
+  }
+  o = self:new(o) --[[@as LinkNode]]
+
+  if is_dir_link then
+    o.watcher = watch.create_watcher(o)
+  end
+
+  return o
+end
+
+---Create a sanitized partial copy of a node, populating children recursively.
+---@return LinkNode cloned
+function LinkNode:clone()
+  local clone = BaseNode.clone(self) --[[@as LinkNode]]
+
+  clone.has_children = self.has_children
+  clone.group_next = nil
+  clone.link_to = self.link_to
+  clone.nodes = {}
+  clone.open = self.open
+
+  if self.nodes then
+    for _, child in ipairs(self.nodes) do
+      table.insert(clone.nodes, child:clone())
+    end
+  end
+
+  return clone
+end
+
+return LinkNode
diff --git a/lua/nvim-tree/node/root.lua b/lua/nvim-tree/node/root.lua
new file mode 100644
index 00000000000..4265d49d479
--- /dev/null
+++ b/lua/nvim-tree/node/root.lua
@@ -0,0 +1,20 @@
+local DirectoryNode = require("nvim-tree.node.directory")
+
+---@class (exact) RootNode: DirectoryNode
+local RootNode = DirectoryNode:new()
+
+---Static factory method
+---@param explorer Explorer
+---@param absolute_path string
+---@param name string
+---@param fs_stat uv.fs_stat.result|nil
+---@return RootNode
+function RootNode:create(explorer, absolute_path, name, fs_stat)
+  local o = DirectoryNode:create(explorer, nil, absolute_path, name, fs_stat)
+
+  o = self:new(o) --[[@as RootNode]]
+
+  return o
+end
+
+return RootNode
diff --git a/lua/nvim-tree/renderer/builder.lua b/lua/nvim-tree/renderer/builder.lua
index 119efc26b22..2071bdabcac 100644
--- a/lua/nvim-tree/renderer/builder.lua
+++ b/lua/nvim-tree/renderer/builder.lua
@@ -68,14 +68,14 @@ function Builder:new(opts, explorer)
     virtual_lines = {},
     decorators = {
       -- priority order
-      DecoratorCut:new(opts, explorer),
-      DecoratorCopied:new(opts, explorer),
-      DecoratorDiagnostics:new(opts, explorer),
-      DecoratorBookmarks:new(opts, explorer),
-      DecoratorModified:new(opts, explorer),
-      DecoratorHidden:new(opts, explorer),
-      DecoratorOpened:new(opts, explorer),
-      DecoratorGit:new(opts, explorer),
+      DecoratorCut:create(opts, explorer),
+      DecoratorCopied:create(opts, explorer),
+      DecoratorDiagnostics:create(opts, explorer),
+      DecoratorBookmarks:create(opts, explorer),
+      DecoratorModified:create(opts, explorer),
+      DecoratorHidden:create(opts, explorer),
+      DecoratorOpened:create(opts, explorer),
+      DecoratorGit:create(opts, explorer),
     },
     hidden_display = Builder:setup_hidden_display_function(opts),
   }
@@ -137,7 +137,7 @@ function Builder:unwrap_highlighted_strings(highlighted_strings)
 end
 
 ---@private
----@param node table
+---@param node Node
 ---@return HighlightedString icon
 ---@return HighlightedString name
 function Builder:build_folder(node)
@@ -189,7 +189,7 @@ function Builder:build_symlink(node)
 end
 
 ---@private
----@param node table
+---@param node Node
 ---@return HighlightedString icon
 ---@return HighlightedString name
 function Builder:build_file(node)
@@ -369,7 +369,7 @@ function Builder:build_line(node, idx, num_children)
 
   self.index = self.index + 1
 
-  node = require("nvim-tree.lib").get_last_group_node(node)
+  node = node:last_group_node()
   if node.open then
     self.depth = self.depth + 1
     self:build_lines(node)
@@ -487,7 +487,7 @@ function Builder:build()
   return self
 end
 
----TODO refactor back to function; this was left here to reduce PR noise
+---@private
 ---@param opts table
 ---@return fun(node: Node): string|nil
 function Builder:setup_hidden_display_function(opts)
diff --git a/lua/nvim-tree/renderer/components/diagnostics.lua b/lua/nvim-tree/renderer/components/diagnostics.lua
index 8f749343fef..e51712e7746 100644
--- a/lua/nvim-tree/renderer/components/diagnostics.lua
+++ b/lua/nvim-tree/renderer/components/diagnostics.lua
@@ -14,7 +14,7 @@ local M = {
 }
 
 ---Diagnostics highlight group and position when highlight_diagnostics.
----@param node table
+---@param node Node
 ---@return HL_POSITION position none when no status
 ---@return string|nil group only when status
 function M.get_highlight(node)
@@ -38,7 +38,7 @@ function M.get_highlight(node)
 end
 
 ---diagnostics icon if there is a status
----@param node table
+---@param node Node
 ---@return HighlightedString|nil modified icon
 function M.get_icon(node)
   if node and M.config.diagnostics.enable and M.config.renderer.icons.show.diagnostics then
diff --git a/lua/nvim-tree/renderer/components/padding.lua b/lua/nvim-tree/renderer/components/padding.lua
index 2d808cc671a..8ca25e8a6cf 100644
--- a/lua/nvim-tree/renderer/components/padding.lua
+++ b/lua/nvim-tree/renderer/components/padding.lua
@@ -59,7 +59,7 @@ end
 ---@param depth integer
 ---@param idx integer
 ---@param nodes_number integer
----@param node table
+---@param node Node
 ---@param markers table
 ---@return HighlightedString[]
 function M.get_indent_markers(depth, idx, nodes_number, node, markers, early_stop)
@@ -79,7 +79,7 @@ function M.get_indent_markers(depth, idx, nodes_number, node, markers, early_sto
   return { str = str, hl = { "NvimTreeIndentMarker" } }
 end
 
----@param node table
+---@param node Node
 ---@return HighlightedString[]|nil
 function M.get_arrows(node)
   if not M.config.icons.show.folder_arrow then
diff --git a/lua/nvim-tree/renderer/decorator/bookmarks.lua b/lua/nvim-tree/renderer/decorator/bookmarks.lua
index 63138e002c6..6b33970fe90 100644
--- a/lua/nvim-tree/renderer/decorator/bookmarks.lua
+++ b/lua/nvim-tree/renderer/decorator/bookmarks.lua
@@ -4,20 +4,22 @@ local ICON_PLACEMENT = require("nvim-tree.enum").ICON_PLACEMENT
 local Decorator = require("nvim-tree.renderer.decorator")
 
 ---@class (exact) DecoratorBookmarks: Decorator
----@field icon HighlightedString
+---@field icon HighlightedString?
 local DecoratorBookmarks = Decorator:new()
 
+---Static factory method
 ---@param opts table
 ---@param explorer Explorer
 ---@return DecoratorBookmarks
-function DecoratorBookmarks:new(opts, explorer)
-  local o = Decorator.new(self, {
+function DecoratorBookmarks:create(opts, explorer)
+  ---@type DecoratorBookmarks
+  local o = {
     explorer = explorer,
     enabled = true,
     hl_pos = HL_POSITION[opts.renderer.highlight_bookmarks] or HL_POSITION.none,
     icon_placement = ICON_PLACEMENT[opts.renderer.icons.bookmarks_placement] or ICON_PLACEMENT.none,
-  })
-  ---@cast o DecoratorBookmarks
+  }
+  o = self:new(o) --[[@as DecoratorBookmarks]]
 
   if opts.renderer.icons.show.bookmarks then
     o.icon = {
diff --git a/lua/nvim-tree/renderer/decorator/copied.lua b/lua/nvim-tree/renderer/decorator/copied.lua
index b6c4cf5e9ee..0debcc632bb 100644
--- a/lua/nvim-tree/renderer/decorator/copied.lua
+++ b/lua/nvim-tree/renderer/decorator/copied.lua
@@ -4,21 +4,22 @@ local ICON_PLACEMENT = require("nvim-tree.enum").ICON_PLACEMENT
 local Decorator = require("nvim-tree.renderer.decorator")
 
 ---@class (exact) DecoratorCopied: Decorator
----@field enabled boolean
----@field icon HighlightedString|nil
+---@field icon HighlightedString?
 local DecoratorCopied = Decorator:new()
 
+---Static factory method
 ---@param opts table
 ---@param explorer Explorer
 ---@return DecoratorCopied
-function DecoratorCopied:new(opts, explorer)
-  local o = Decorator.new(self, {
+function DecoratorCopied:create(opts, explorer)
+  ---@type DecoratorCopied
+  local o = {
     explorer = explorer,
     enabled = true,
     hl_pos = HL_POSITION[opts.renderer.highlight_clipboard] or HL_POSITION.none,
     icon_placement = ICON_PLACEMENT.none,
-  })
-  ---@cast o DecoratorCopied
+  }
+  o = self:new(o) --[[@as DecoratorCopied]]
 
   return o
 end
diff --git a/lua/nvim-tree/renderer/decorator/cut.lua b/lua/nvim-tree/renderer/decorator/cut.lua
index 17c69a7fa73..b81642f6e79 100644
--- a/lua/nvim-tree/renderer/decorator/cut.lua
+++ b/lua/nvim-tree/renderer/decorator/cut.lua
@@ -4,21 +4,21 @@ local ICON_PLACEMENT = require("nvim-tree.enum").ICON_PLACEMENT
 local Decorator = require("nvim-tree.renderer.decorator")
 
 ---@class (exact) DecoratorCut: Decorator
----@field enabled boolean
----@field icon HighlightedString|nil
 local DecoratorCut = Decorator:new()
 
+---Static factory method
 ---@param opts table
 ---@param explorer Explorer
 ---@return DecoratorCut
-function DecoratorCut:new(opts, explorer)
-  local o = Decorator.new(self, {
+function DecoratorCut:create(opts, explorer)
+  ---@type DecoratorCut
+  local o = {
     explorer = explorer,
     enabled = true,
     hl_pos = HL_POSITION[opts.renderer.highlight_clipboard] or HL_POSITION.none,
     icon_placement = ICON_PLACEMENT.none,
-  })
-  ---@cast o DecoratorCut
+  }
+  o = self:new(o) --[[@as DecoratorCut]]
 
   return o
 end
diff --git a/lua/nvim-tree/renderer/decorator/diagnostics.lua b/lua/nvim-tree/renderer/decorator/diagnostics.lua
index bf01533eb9e..3daee7bc03a 100644
--- a/lua/nvim-tree/renderer/decorator/diagnostics.lua
+++ b/lua/nvim-tree/renderer/decorator/diagnostics.lua
@@ -33,20 +33,22 @@ local ICON_KEYS = {
 }
 
 ---@class (exact) DecoratorDiagnostics: Decorator
----@field icons HighlightedString[]
+---@field icons HighlightedString[]?
 local DecoratorDiagnostics = Decorator:new()
 
+---Static factory method
 ---@param opts table
 ---@param explorer Explorer
 ---@return DecoratorDiagnostics
-function DecoratorDiagnostics:new(opts, explorer)
-  local o = Decorator.new(self, {
+function DecoratorDiagnostics:create(opts, explorer)
+  ---@type DecoratorDiagnostics
+  local o = {
     explorer = explorer,
     enabled = opts.diagnostics.enable,
     hl_pos = HL_POSITION[opts.renderer.highlight_diagnostics] or HL_POSITION.none,
     icon_placement = ICON_PLACEMENT[opts.renderer.icons.diagnostics_placement] or ICON_PLACEMENT.none,
-  })
-  ---@cast o DecoratorDiagnostics
+  }
+  o = self:new(o) --[[@as DecoratorDiagnostics]]
 
   if not o.enabled then
     return o
diff --git a/lua/nvim-tree/renderer/decorator/git.lua b/lua/nvim-tree/renderer/decorator/git.lua
index cd3f9bb8969..af2c8ccaa94 100644
--- a/lua/nvim-tree/renderer/decorator/git.lua
+++ b/lua/nvim-tree/renderer/decorator/git.lua
@@ -1,5 +1,4 @@
 local notify = require("nvim-tree.notify")
-local explorer_node = require("nvim-tree.explorer.node")
 
 local HL_POSITION = require("nvim-tree.enum").HL_POSITION
 local ICON_PLACEMENT = require("nvim-tree.enum").ICON_PLACEMENT
@@ -10,23 +9,25 @@ local Decorator = require("nvim-tree.renderer.decorator")
 ---@field ord number decreasing priority
 
 ---@class (exact) DecoratorGit: Decorator
----@field file_hl table<string, string> by porcelain status e.g. "AM"
----@field folder_hl table<string, string> by porcelain status
----@field icons_by_status HighlightedStringGit[] by human status
----@field icons_by_xy table<string, HighlightedStringGit[]> by porcelain status
+---@field file_hl table<string, string>? by porcelain status e.g. "AM"
+---@field folder_hl table<string, string>? by porcelain status
+---@field icons_by_status HighlightedStringGit[]? by human status
+---@field icons_by_xy table<string, HighlightedStringGit[]>? by porcelain status
 local DecoratorGit = Decorator:new()
 
+---Static factory method
 ---@param opts table
 ---@param explorer Explorer
 ---@return DecoratorGit
-function DecoratorGit:new(opts, explorer)
-  local o = Decorator.new(self, {
+function DecoratorGit:create(opts, explorer)
+  ---@type DecoratorGit
+  local o = {
     explorer = explorer,
     enabled = opts.git.enable,
     hl_pos = HL_POSITION[opts.renderer.highlight_git] or HL_POSITION.none,
     icon_placement = ICON_PLACEMENT[opts.renderer.icons.git_placement] or ICON_PLACEMENT.none,
-  })
-  ---@cast o DecoratorGit
+  }
+  o = self:new(o) --[[@as DecoratorGit]]
 
   if not o.enabled then
     return o
@@ -147,7 +148,7 @@ function DecoratorGit:calculate_icons(node)
     return nil
   end
 
-  local git_status = explorer_node.get_git_status(node)
+  local git_status = node:get_git_status()
   if git_status == nil then
     return nil
   end
@@ -208,7 +209,7 @@ function DecoratorGit:calculate_highlight(node)
     return nil
   end
 
-  local git_status = explorer_node.get_git_status(node)
+  local git_status = node:get_git_status()
   if not git_status then
     return nil
   end
diff --git a/lua/nvim-tree/renderer/decorator/hidden.lua b/lua/nvim-tree/renderer/decorator/hidden.lua
index d6c125ae117..1df68c48295 100644
--- a/lua/nvim-tree/renderer/decorator/hidden.lua
+++ b/lua/nvim-tree/renderer/decorator/hidden.lua
@@ -1,23 +1,24 @@
 local HL_POSITION = require("nvim-tree.enum").HL_POSITION
 local ICON_PLACEMENT = require("nvim-tree.enum").ICON_PLACEMENT
-local explorer_node = require("nvim-tree.explorer.node")
 local Decorator = require("nvim-tree.renderer.decorator")
 
 ---@class (exact) DecoratorHidden: Decorator
----@field icon HighlightedString|nil
+---@field icon HighlightedString?
 local DecoratorHidden = Decorator:new()
 
+---Static factory method
 ---@param opts table
 ---@param explorer Explorer
 ---@return DecoratorHidden
-function DecoratorHidden:new(opts, explorer)
-  local o = Decorator.new(self, {
+function DecoratorHidden:create(opts, explorer)
+  ---@type DecoratorHidden
+  local o = {
     explorer = explorer,
     enabled = true,
     hl_pos = HL_POSITION[opts.renderer.highlight_hidden] or HL_POSITION.none,
     icon_placement = ICON_PLACEMENT[opts.renderer.icons.hidden_placement] or ICON_PLACEMENT.none,
-  })
-  ---@cast o DecoratorHidden
+  }
+  o = self:new(o) --[[@as DecoratorHidden]]
 
   if opts.renderer.icons.show.hidden then
     o.icon = {
@@ -34,7 +35,7 @@ end
 ---@param node Node
 ---@return HighlightedString[]|nil icons
 function DecoratorHidden:calculate_icons(node)
-  if self.enabled and explorer_node.is_dotfile(node) then
+  if self.enabled and node:is_dotfile() then
     return { self.icon }
   end
 end
@@ -43,7 +44,7 @@ end
 ---@param node Node
 ---@return string|nil group
 function DecoratorHidden:calculate_highlight(node)
-  if not self.enabled or self.hl_pos == HL_POSITION.none or (not explorer_node.is_dotfile(node)) then
+  if not self.enabled or self.hl_pos == HL_POSITION.none or not node:is_dotfile() then
     return nil
   end
 
diff --git a/lua/nvim-tree/renderer/decorator/init.lua b/lua/nvim-tree/renderer/decorator/init.lua
index 92fcc579b38..a80ce615a05 100644
--- a/lua/nvim-tree/renderer/decorator/init.lua
+++ b/lua/nvim-tree/renderer/decorator/init.lua
@@ -1,6 +1,8 @@
 local HL_POSITION = require("nvim-tree.enum").HL_POSITION
 local ICON_PLACEMENT = require("nvim-tree.enum").ICON_PLACEMENT
 
+---Abstract Decorator
+---Uses the factory pattern to instantiate child instances.
 ---@class (exact) Decorator
 ---@field private __index? table
 ---@field protected explorer Explorer
diff --git a/lua/nvim-tree/renderer/decorator/modified.lua b/lua/nvim-tree/renderer/decorator/modified.lua
index 75bb59c8446..4665343f0ee 100644
--- a/lua/nvim-tree/renderer/decorator/modified.lua
+++ b/lua/nvim-tree/renderer/decorator/modified.lua
@@ -9,17 +9,19 @@ local Decorator = require("nvim-tree.renderer.decorator")
 ---@field icon HighlightedString|nil
 local DecoratorModified = Decorator:new()
 
+---Static factory method
 ---@param opts table
 ---@param explorer Explorer
 ---@return DecoratorModified
-function DecoratorModified:new(opts, explorer)
-  local o = Decorator.new(self, {
+function DecoratorModified:create(opts, explorer)
+  ---@type DecoratorModified
+  local o = {
     explorer = explorer,
     enabled = opts.modified.enable,
     hl_pos = HL_POSITION[opts.renderer.highlight_modified] or HL_POSITION.none,
     icon_placement = ICON_PLACEMENT[opts.renderer.icons.modified_placement] or ICON_PLACEMENT.none,
-  })
-  ---@cast o DecoratorModified
+  }
+  o = self:new(o) --[[@as DecoratorModified]]
 
   if not o.enabled then
     return o
diff --git a/lua/nvim-tree/renderer/decorator/opened.lua b/lua/nvim-tree/renderer/decorator/opened.lua
index 5a17c2da6bf..6f2ad58bbfc 100644
--- a/lua/nvim-tree/renderer/decorator/opened.lua
+++ b/lua/nvim-tree/renderer/decorator/opened.lua
@@ -6,21 +6,22 @@ local ICON_PLACEMENT = require("nvim-tree.enum").ICON_PLACEMENT
 local Decorator = require("nvim-tree.renderer.decorator")
 
 ---@class (exact) DecoratorOpened: Decorator
----@field enabled boolean
 ---@field icon HighlightedString|nil
 local DecoratorOpened = Decorator:new()
 
+---Static factory method
 ---@param opts table
 ---@param explorer Explorer
 ---@return DecoratorOpened
-function DecoratorOpened:new(opts, explorer)
-  local o = Decorator.new(self, {
+function DecoratorOpened:create(opts, explorer)
+  ---@type DecoratorOpened
+  local o = {
     explorer = explorer,
     enabled = true,
     hl_pos = HL_POSITION[opts.renderer.highlight_opened_files] or HL_POSITION.none,
     icon_placement = ICON_PLACEMENT.none,
-  })
-  ---@cast o DecoratorOpened
+  }
+  o = self:new(o) --[[@as DecoratorOpened]]
 
   return o
 end
diff --git a/lua/nvim-tree/utils.lua b/lua/nvim-tree/utils.lua
index 0c341c9ea14..495e6679f14 100644
--- a/lua/nvim-tree/utils.lua
+++ b/lua/nvim-tree/utils.lua
@@ -112,8 +112,7 @@ function M.find_node(nodes, fn)
     end)
     :iterate()
   i = require("nvim-tree.view").is_root_folder_visible() and i or i - 1
-  local explorer = require("nvim-tree.core").get_explorer()
-  if explorer and explorer.live_filter.filter then
+  if node and node.explorer.live_filter.filter then
     i = i + 1
   end
   return node, i
@@ -121,7 +120,7 @@ end
 
 -- Find the line number of a node.
 -- Return -1 is node is nil or not found.
----@param node Node|nil
+---@param node Node?
 ---@return integer
 function M.find_node_line(node)
   if not node then
@@ -174,16 +173,6 @@ function M.get_node_from_path(path)
     :iterate()
 end
 
----Get the highest parent of grouped nodes
----@param node Node
----@return Node node or parent
-function M.get_parent_of_group(node)
-  while node and node.parent and node.parent.group_next do
-    node = node.parent
-  end
-  return node
-end
-
 M.default_format_hidden_count = function(hidden_count, simple)
   local parts = {}
   local total_count = 0
@@ -473,7 +462,7 @@ end
 ---Focus node passed as parameter if visible, otherwise focus first visible parent.
 ---If none of the parents is visible focus root.
 ---If node is nil do nothing.
----@param node Node|nil node to focus
+---@param node Node? node to focus
 function M.focus_node_or_parent(node)
   local explorer = require("nvim-tree.core").get_explorer()
 
@@ -549,14 +538,6 @@ function M.array_remove_nils(array)
   end, array)
 end
 
----@param f fun(node: Node|nil)
----@return function
-function M.inject_node(f)
-  return function()
-    f(require("nvim-tree.lib").get_node_at_cursor())
-  end
-end
-
 --- Is the buffer named NvimTree_[0-9]+ a tree? filetype is "NvimTree" or not readable file.
 --- This is cheap, as the readable test should only ever be needed when resuming a vim session.
 ---@param bufnr number|nil may be 0 or nil for current
@@ -578,4 +559,16 @@ function M.is_nvim_tree_buf(bufnr)
   return false
 end
 
+--- path is an executable file or directory
+---@param absolute_path string
+---@return boolean
+function M.is_executable(absolute_path)
+  if M.is_windows or M.is_wsl then
+    --- executable detection on windows is buggy and not performant hence it is disabled
+    return false
+  else
+    return vim.loop.fs_access(absolute_path, "X") or false
+  end
+end
+
 return M