From bd576536084d99a95bb23d3d11ae62bb399ab6b0 Mon Sep 17 00:00:00 2001
From: Mika Vilpas <mika.vilpas@gmail.com>
Date: Thu, 4 Apr 2024 17:51:28 +0300
Subject: [PATCH] feat!: files renamed in yazi are kept in sync in nvim

BREAKING CHANGE: this plugin now requires yazi 0.2.4 or newer.
---
 README.md                       |   6 +-
 lua/yazi.lua                    |  19 +++++-
 lua/yazi/health.lua             |   4 +-
 lua/yazi/types.lua              |  14 +++++
 lua/yazi/utils.lua              |  96 +++++++++++++++++++++++++++++
 tests/yazi/example_spec.lua     |   9 +--
 tests/yazi/open_dir_spec.lua    |   7 ++-
 tests/yazi/read_events_spec.lua |  25 ++++++++
 tests/yazi/rename_spec.lua      | 104 ++++++++++++++++++++++++++++++++
 9 files changed, 271 insertions(+), 13 deletions(-)
 create mode 100644 tests/yazi/read_events_spec.lua
 create mode 100644 tests/yazi/rename_spec.lua

diff --git a/README.md b/README.md
index 8d7c2cab..8cbac21a 100644
--- a/README.md
+++ b/README.md
@@ -11,11 +11,15 @@ So far I have done some maintenance work:
 - test: add simple testing setup for future development
 - feat: can optionally open yazi instead of netrw for directories
 - feat: health check for yazi
+- feat: files renamed in yazi are kept in sync with open buffers
 
 If you'd like to collaborate, contact me via GitHub issues.
 
 ## Installation
 
+> **Note:** This plugin requires a recent version of yazi.
+> You can run `:checkhealth yazi` to see if a compatible version is installed and working.
+
 Using lazy.nvim:
 
 ```lua
@@ -42,5 +46,3 @@ Using lazy.nvim:
   },
 }
 ```
-
-You can run `:checkhealth yazi` to see if the plugin is installed and working.
diff --git a/lua/yazi.lua b/lua/yazi.lua
index 2797e806..160637b2 100644
--- a/lua/yazi.lua
+++ b/lua/yazi.lua
@@ -8,6 +8,7 @@ local M = {}
 M.yazi_loaded = false
 
 local output_path = '/tmp/yazi_filechosen'
+local yazi_nvim_events_path = '/tmp/yazi.nvim.events.txt'
 
 --- :Yazi entry point
 ---@param path string? defaults to the current file or the working directory
@@ -24,7 +25,11 @@ function M.yazi(path)
   local win, buffer = window.open_floating_window()
 
   os.remove(output_path)
-  local cmd = string.format('yazi "%s" --chooser-file "%s"', path, output_path)
+  local cmd = string.format(
+    'yazi "%s" --local-events "rename" --chooser-file "%s" > /tmp/yazi.nvim.events.txt',
+    path,
+    output_path
+  )
 
   if M.yazi_loaded == false then
     -- ensure that the buffer is closed on exit
@@ -38,6 +43,7 @@ function M.yazi(path)
         M.yazi_loaded = false
         vim.cmd('silent! :checktime')
 
+        -- open the file that was chosen
         if vim.api.nvim_win_is_valid(prev_win) then
           -- NOTE the types for nvim_ apis are inaccurate so we need to typecast
           ---@cast win integer
@@ -50,14 +56,23 @@ function M.yazi(path)
             end
           end
 
+          ---@cast buffer integer
           if
-            ---@cast buffer integer
             vim.api.nvim_buf_is_valid(buffer)
             and vim.api.nvim_buf_is_loaded(buffer)
           then
             vim.api.nvim_buf_delete(buffer, { force = true })
           end
         end
+
+        -- process events emitted from yazi
+        local rename_events = utils.read_events_file(yazi_nvim_events_path)
+        local renames =
+          utils.get_buffers_that_need_renaming_after_yazi_exited(rename_events)
+
+        for _, event in ipairs(renames) do
+          vim.api.nvim_buf_set_name(event.buffer, event.to)
+        end
       end,
     })
   end
diff --git a/lua/yazi/health.lua b/lua/yazi/health.lua
index 190b83fe..bb4d4c4c 100644
--- a/lua/yazi/health.lua
+++ b/lua/yazi/health.lua
@@ -21,9 +21,9 @@ return {
     end
 
     local checker = require('vim.version')
-    if not checker.ge(semver, '0.1.5') then
+    if not checker.ge(semver, '0.2.4') then
       return vim.health.warn(
-        'yazi version is too old, please upgrade to 0.1.5 or newer'
+        'yazi version is too old, please upgrade to 0.2.4 or newer'
       )
     end
 
diff --git a/lua/yazi/types.lua b/lua/yazi/types.lua
index 564cdc8d..d17043e1 100644
--- a/lua/yazi/types.lua
+++ b/lua/yazi/types.lua
@@ -1,2 +1,16 @@
 ---@class YaziConfig
 ---@field public open_for_directories boolean
+
+---@class YaziRenameEvent
+---@field public type "rename"
+---@field public timestamp string
+---@field public id string
+---@field public data YaziEventDataRename
+
+---@class YaziEventDataRename
+---@field public from string
+---@field public to string
+
+---@class YaziBufferRenameInstruction
+---@field buffer integer the existing buffer number that needs renaming
+---@field to string the new file name that the buffer should point to
diff --git a/lua/yazi/utils.lua b/lua/yazi/utils.lua
index 206b6b36..7fa61831 100644
--- a/lua/yazi/utils.lua
+++ b/lua/yazi/utils.lua
@@ -1,4 +1,5 @@
 local fn = vim.fn
+local iterators = require('plenary.iterators')
 
 local M = {}
 
@@ -33,4 +34,99 @@ function M.selected_file_path(path)
   return path
 end
 
+-- Returns parsed events from the yazi events file
+---@param events_file_lines string[]
+---@return YaziRenameEvent[]
+function M.parse_events(events_file_lines)
+  ---@type string[]
+  local events = {}
+
+  for _, line in ipairs(events_file_lines) do
+    local parts = vim.split(line, ',')
+    local type = parts[1]
+
+    if type == 'rename' then
+      -- example of a rename event:
+
+      -- rename,1712242143209837,1712242143209837,{"tab":0,"from":"/Users/mikavilpas/git/yazi/LICENSE","to":"/Users/mikavilpas/git/yazi/LICENSE2"}
+      local timestamp = parts[2]
+      local id = parts[3]
+      local data_string = table.concat(parts, ',', 4, #parts)
+
+      ---@type YaziRenameEvent
+      local event = {
+        type = type,
+        timestamp = timestamp,
+        id = id,
+        data = vim.fn.json_decode(data_string),
+      }
+      table.insert(events, event)
+    end
+  end
+
+  return events
+end
+
+---@param path string
+---@return YaziRenameEvent[]
+function M.read_events_file(path)
+  local success, events_file_lines = pcall(vim.fn.readfile, path)
+  os.remove(path)
+  if not success then
+    return {}
+  end
+
+  -- selene: allow(shadowing)
+  ---@diagnostic disable-next-line: redefined-local
+  local success, events = pcall(M.parse_events, events_file_lines)
+  if not success then
+    return {}
+  end
+
+  return events
+end
+
+---@param rename_events YaziRenameEvent[]
+---@return YaziBufferRenameInstruction[]
+function M.get_buffers_that_need_renaming_after_yazi_exited(rename_events)
+  local buffers = iterators
+    .iter(vim.api.nvim_list_bufs())
+    :filter(function(buffer)
+      if vim.api.nvim_buf_get_name(buffer) == '' then
+        return false
+      end
+
+      return true
+    end)
+    :map(function(buffer)
+      -- the buffer is found if
+      -- * the buffer name matches the original name
+      -- * or the buffer's file is under a directory that was renamed (also nested directories)
+      for _, event in ipairs(rename_events) do
+        local buffer_name = vim.api.nvim_buf_get_name(buffer)
+
+        if event.data.from == buffer_name then
+          ---@type YaziBufferRenameInstruction
+          return {
+            buffer = buffer,
+            to = event.data.to,
+          }
+        end
+
+        local starts_with = buffer_name:sub(1, #event.data.from)
+          == event.data.from
+        if starts_with then
+          ---@type YaziBufferRenameInstruction
+          return {
+            buffer = buffer,
+            to = event.data.to .. buffer_name:sub(#event.data.from + 1),
+          }
+        end
+      end
+    end)
+    :tolist()
+
+  return buffers
+end
+
 return M
diff --git a/tests/yazi/example_spec.lua b/tests/yazi/example_spec.lua
index ddc70d15..dade2e1d 100644
--- a/tests/yazi/example_spec.lua
+++ b/tests/yazi/example_spec.lua
@@ -23,7 +23,7 @@ describe('opening a file', function()
     plugin.yazi()
 
     assert.stub(api_mock.termopen).was_called_with(
-      'yazi "/tmp/test-file.txt" --chooser-file "/tmp/yazi_filechosen"',
+      'yazi "/tmp/test-file.txt" --local-events "rename" --chooser-file "/tmp/yazi_filechosen" > /tmp/yazi.nvim.events.txt',
       match.is_table()
     )
   end)
@@ -33,9 +33,10 @@ describe('opening a file', function()
 
     plugin.yazi()
 
-    assert
-      .stub(api_mock.termopen)
-      .was_called_with('yazi "/tmp/" --chooser-file "/tmp/yazi_filechosen"', match.is_table())
+    assert.stub(api_mock.termopen).was_called_with(
+      'yazi "/tmp/" --local-events "rename" --chooser-file "/tmp/yazi_filechosen" > /tmp/yazi.nvim.events.txt',
+      match.is_table()
+    )
   end)
 
   describe("when a file is selected in yazi's chooser", function()
diff --git a/tests/yazi/open_dir_spec.lua b/tests/yazi/open_dir_spec.lua
index ecf6fd6c..0c95b892 100644
--- a/tests/yazi/open_dir_spec.lua
+++ b/tests/yazi/open_dir_spec.lua
@@ -23,8 +23,9 @@ describe('when the user set open_for_directories = true', function()
     -- instead of netrw opening, yazi should open
     vim.api.nvim_command('edit /')
 
-    assert
-      .stub(api_mock.termopen)
-      .was_called_with('yazi "/" --chooser-file "/tmp/yazi_filechosen"', match.is_table())
+    assert.stub(api_mock.termopen).was_called_with(
+      'yazi "/" --local-events "rename" --chooser-file "/tmp/yazi_filechosen" > /tmp/yazi.nvim.events.txt',
+      match.is_table()
+    )
   end)
 end)
diff --git a/tests/yazi/read_events_spec.lua b/tests/yazi/read_events_spec.lua
new file mode 100644
index 00000000..bd3f3fef
--- /dev/null
+++ b/tests/yazi/read_events_spec.lua
@@ -0,0 +1,25 @@
+local assert = require('luassert')
+local utils = require('yazi.utils')
+
+describe('parsing yazi event file events', function()
+  it('can parse rename events', function()
+    local data = {
+      'rename,1712242143209837,1712242143209837,{"tab":0,"from":"/Users/mikavilpas/git/yazi/file","to":"/Users/mikavilpas/git/yazi/file2"}',
+    }
+
+    local events = utils.parse_events(data)
+
+    assert.are.same(events, {
+      {
+        type = 'rename',
+        timestamp = '1712242143209837',
+        id = '1712242143209837',
+        data = {
+          tab = 0,
+          from = '/Users/mikavilpas/git/yazi/file',
+          to = '/Users/mikavilpas/git/yazi/file2',
+        },
+      },
+    })
+  end)
+end)
diff --git a/tests/yazi/rename_spec.lua b/tests/yazi/rename_spec.lua
new file mode 100644
index 00000000..69e2c70c
--- /dev/null
+++ b/tests/yazi/rename_spec.lua
@@ -0,0 +1,104 @@
+local assert = require('luassert')
+local utils = require('yazi.utils')
+
+describe('get_buffers_that_need_renaming_after_yazi_exited', function()
+  before_each(function()
+    -- clear all buffers
+    for _, buf in ipairs(vim.api.nvim_list_bufs()) do
+      vim.api.nvim_buf_delete(buf, { force = true })
+    end
+  end)
+
+  it('can detect renames to files whose names match exactly', function()
+    ---@type YaziRenameEvent[]
+    local rename_events = {
+      {
+        type = 'rename',
+        timestamp = '1712242143209837',
+        id = '1712242143209837',
+        data = {
+          from = '/my-tmp/file1',
+          to = '/my-tmp/file2',
+        },
+      },
+      {
+        type = 'rename',
+        timestamp = '1712242143209837',
+        id = '1712242143209837',
+        data = {
+          from = '/my-tmp/file3',
+          to = '/my-tmp/file4',
+        },
+      },
+    }
+
+    -- simulate the buffers being opened
+    vim.fn.bufadd('/my-tmp/file1')
+    vim.fn.bufadd('/my-tmp/file3')
+
+    local renames =
+      utils.get_buffers_that_need_renaming_after_yazi_exited(rename_events)
+
+    assert.is_equal(#renames, 2)
+
+    local result1 = renames[1]
+    assert.is_equal('/my-tmp/file2', result1.to)
+    assert.is_number(result1.buffer)
+
+    local result2 = renames[2]
+    assert.is_equal('/my-tmp/file4', result2.to)
+    assert.is_number(result2.buffer)
+  end)
+
+  it(
+    'can detect renames to buffers open in a directory that was renamed',
+    function()
+      ---@type YaziRenameEvent[]
+      local rename_events = {
+        {
+          type = 'rename',
+          timestamp = '1712242143209837',
+          id = '1712242143209837',
+          data = {
+            from = '/my-tmp/dir1',
+            to = '/my-tmp/dir2',
+          },
+        },
+      }
+
+      -- simulate the buffer being opened
+      vim.fn.bufadd('/my-tmp/dir1/file')
+
+      local renames =
+        utils.get_buffers_that_need_renaming_after_yazi_exited(rename_events)
+
+      assert.is_equal(#renames, 1)
+
+      local result1 = renames[1]
+      assert.is_equal('/my-tmp/dir2/file', result1.to)
+    end
+  )
+
+  it("doesn't rename a buffer that was not renamed in yazi", function()
+    ---@type YaziRenameEvent[]
+    local rename_events = {
+      {
+        type = 'rename',
+        timestamp = '1712242143209837',
+        id = '1712242143209837',
+        data = {
+          from = '/my-tmp/not-opened-file',
+          to = '/my-tmp/not-opened-file-renamed',
+        },
+      },
+    }
+
+    -- simulate the buffer being opened
+    vim.fn.bufadd('/my-tmp/dir1/file')
+
+    local renames =
+      utils.get_buffers_that_need_renaming_after_yazi_exited(rename_events)
+
+    assert.is_equal(#renames, 0)
+  end)
+end)