1
0
Fork 0
mirror of https://github.com/jiriks74/presence.nvim synced 2025-06-26 05:58:57 +02:00

Support peer-to-peer workspace state management

- Add sync logic to manage multi-client runtime state
- Debounce and set activity on TextChanged events
- Update timestamp to be workspace-specific
- Set activity on an open nvim instance upon a VimLeave event
This commit is contained in:
Andrew Kwon 2021-03-18 18:32:06 -07:00
parent a2f6550177
commit a325d154fc
5 changed files with 490 additions and 47 deletions
lua/presence

View file

@ -1,7 +1,63 @@
--------------------------------------------------
-- ____ --
-- / __ \________ ________ ____ ________ --
-- / /_/ / ___/ _ \/ ___/ _ \/ __ \/ ___/ _ \ --
-- / ____/ / / __(__ ) __/ / / / /__/ __/ --
-- /_/ /_/ \___/____/\___/_/ /_/\___/\___/ --
-- --
-- Discord Rich Presence plugin for Neovim. --
--------------------------------------------------
-- Nvim peer-to-peer runtime state shape example:
--
-- Presence = {
-- id = "ee1fc18f-2c81-4b88-b92e-cb801fbe8d85",
-- workspace = "/Users/user/Code/presence.nvim",
-- socket = "/var/folders/mm/8qfxwcdn29s8d_rzmj7bqxb40000gn/T/nvim9pEtTD/0",
--
-- -- Last activity set by current client or any peer instance
-- last_activity = {
-- file = "/Users/user/Code/presence.nvim/README.md",
-- workspace = "/Users/user/Code/presence.nvim",
-- },
--
-- -- Other remote Neovim instances (peers)
-- peers = {
-- ["dd5eeafe-8d0d-44d7-9850-45d3884be1a0"] = {
-- workspace = "/Users/user/Code/presence.nvim",
-- socket = "/var/folders/mm/8qfxwcdn29s8d_rzmj7bqxb40000gn/T/nvim9pEtTD/0",
-- },
-- ["346750e6-c416-44ff-98f3-eb44ea2ef15d"] = {
-- workspace = "/Users/user/Code/presence.nvim",
-- socket = "/var/folders/mm/8qfxwcdn29s8d_rzmj7bqxb40000gn/T/nvim09n664/0",
-- }
-- },
--
-- -- Workspace states across all peers
-- workspaces = {
-- ["/Users/user/Code/dotfiles"] = {
-- started_at = 1616033505,
-- updated_at = 1616033505
-- },
-- ["/Users/user/Code/presence.nvim"] = {
-- started_at = 1616033442,
-- updated_at = 1616033523
-- },
-- },
--
-- ... other methods and member variables
-- }
local Presence = {}
Presence.is_authorized = false
Presence.is_connected = false
Presence.last_activity = {}
Presence.peers = {}
Presence.socket = vim.v.servername
Presence.workspace = nil
Presence.workspaces = {}
local log = require("lib.log")
local msgpack = require("deps.msgpack")
local serpent = require("deps.serpent")
local Discord = require("presence.discord")
local file_assets = require("presence.file_assets")
@ -12,6 +68,7 @@ function Presence:setup(options)
-- Initialize logger
self:set_option("log_level", nil, false)
self.log = log:init({ level = options.log_level })
self.log:debug("Setting up plugin...")
-- Use the default or user-defined client id if provided
if options.client_id then
@ -24,8 +81,19 @@ function Presence:setup(options)
self:set_option("workspace_text", "Working on %s")
self:set_option("neovim_image_text", "The One True Text Editor")
self:set_option("client_id", "793271441293967371")
self:set_option("debounce_timeout", 15)
self.log:debug("Setting up plugin...")
-- Initialize discord RPC client
self.discord = Discord:init({
logger = self.log,
client_id = options.client_id,
ipc_path = self.get_ipc_path(),
})
-- Seed instance id using unique socket address
local seed_nums = {}
self.socket:gsub(".", function(c) table.insert(seed_nums, c:byte()) end)
self.id = self.discord.generate_uuid(tonumber(table.concat(seed_nums)) / os.clock())
-- Ensure auto-update config is reflected in its global var setting
vim.api.nvim_set_var("presence_auto_update", options.auto_update)
@ -33,21 +101,14 @@ function Presence:setup(options)
-- Set autocommands
vim.fn["presence#SetAutoCmds"]()
-- Internal state
self.is_connected = false
self.is_authorized = false
self.discord = Discord:init({
logger = self.log,
client_id = options.client_id,
ipc_path = self.get_ipc_path(),
})
self.log:info("Completed plugin setup")
-- Set global variable to indicate plugin has been set up
vim.api.nvim_set_var("presence_has_setup", 1)
-- Register self to any remote Neovim instances
self:register_self()
return self
end
@ -102,21 +163,6 @@ function Presence:cancel()
end)
end
-- Send command to cancel the presence for all other remote Neovim instances
function Presence:cancel_all_remote_instances()
self:get_nvim_socket_addrs(function(sockets)
for i = 1, #sockets do
local nvim_socket = sockets[i]
-- Skip if the nvim socket is the current instance
if nvim_socket ~= vim.v.servername then
local command = "lua package.loaded.presence:cancel()"
self:call_remote_nvim_instance(nvim_socket, command)
end
end
end)
end
-- Call a command on a remote Neovim instance at the provided IPC path
function Presence:call_remote_nvim_instance(ipc_path, command)
local remote_nvim_instance = vim.loop.new_pipe(true)
@ -128,13 +174,32 @@ function Presence:call_remote_nvim_instance(ipc_path, command)
remote_nvim_instance:write(packed, function()
self.log:debug(string.format("Wrote to remote nvim instance: %s", ipc_path))
remote_nvim_instance:shutdown()
remote_nvim_instance:close()
end)
end)
end
-- Call a Presence method on a remote instance with a given list of arguments
function Presence:call_remote_method(socket, name, args)
local command_fmt = "lua package.loaded.presence:%s(%s)"
-- Stringify the list of args
for i = 1, #args do
local arg = args[i]
if type(arg) == "string" then
args[i] = string.format([["%s"]], arg)
elseif type(arg) == "boolean" then
args[i] = string.format([["%s"]], tostring(arg))
elseif type(arg) == "table" then
-- Wrap serpent dump with function invocation to pass in the table value
args[i] = string.format("(function() %s end)()", serpent.dump(arg))
end
end
local arglist = table.concat(args or {}, ",")
local command = string.format(command_fmt, name, arglist)
self:call_remote_nvim_instance(socket, command)
end
function Presence:connect(on_done)
self.log:debug("Connecting to Discord...")
@ -224,7 +289,7 @@ function Presence:get_project_name(file_path)
return nil
end
return self.get_filename(project_path)
return self.get_filename(project_path), project_path
end
-- Get the name of the parent directory for the given path
@ -245,7 +310,13 @@ end
-- Get all active local nvim unix domain socket addresses
function Presence:get_nvim_socket_addrs(on_done)
-- TODO: Find a better way to get paths of remote Neovim sockets lol
local cmd = [[netstat -u | grep --color=never "nvim.*/0" | awk -F "[ :]+" '{print $9}' | uniq]]
local cmd = table.concat({
"netstat -u",
[[grep --color=never "nvim.*/0"]],
[[awk -F "[ :]+" '{print $9}']],
"sort",
"uniq",
}, "|")
local sockets = {}
local function handle_data(_, data)
@ -253,7 +324,7 @@ function Presence:get_nvim_socket_addrs(on_done)
for i = 1, #data do
local socket = data[i]
if socket ~= "" and socket ~= vim.v.servername then
if socket ~= "" and socket ~= self.socket then
table.insert(sockets, socket)
end
end
@ -303,11 +374,15 @@ function Presence.discord_event(on_ready)
end
-- Update Rich Presence for the provided vim buffer
function Presence:update_for_buffer(buffer)
self.log:debug(string.format("Setting activity for %s...", buffer))
function Presence:update_for_buffer(buffer, should_debounce)
if should_debounce and self.last_activity.file == buffer then
self.log:debug(string.format("Activity already set for %s, skipping...", buffer))
return
end
-- Send command to cancel presence for all remote Neovim instances
self:cancel_all_remote_instances()
local activity_set_at = os.time()
self.log:debug(string.format("Setting activity for %s...", buffer))
-- Parse vim buffer
local filename = self.get_filename(buffer)
@ -326,9 +401,6 @@ function Presence:update_for_buffer(buffer)
local file_text = description or name
local neovim_image_text = self.options.neovim_image_text
-- TODO: Update timestamp to be workspace-specific
local started_at = os.time()
local use_file_as_main_image = self.options.main_image == "file"
local assets = {
large_image = use_file_as_main_image and asset_key or "neovim",
@ -346,12 +418,12 @@ function Presence:update_for_buffer(buffer)
state = editing_text,
assets = assets,
timestamps = {
start = started_at
start = activity_set_at,
},
}
local workspace_text = self.options.workspace_text
local project_name = self:get_project_name(parent_dirpath)
local project_name, project_path = self:get_project_name(parent_dirpath)
-- Include project details if available
if project_name then
@ -360,9 +432,35 @@ function Presence:update_for_buffer(buffer)
activity.details = type(workspace_text) == "function"
and workspace_text(project_name, buffer)
or string.format(workspace_text, project_name)
self.workspace = project_path
self.last_activity = {
file = buffer,
set_at = activity_set_at,
workspace = project_path,
}
if self.workspaces[project_path] then
self.workspaces[project_path].updated_at = activity_set_at
activity.timestamps = {
start = self.workspaces[project_path].started_at,
}
else
self.workspaces[project_path] = {
started_at = activity_set_at,
updated_at = activity_set_at,
}
end
else
self.log:debug("No project detected")
self.workspace = nil
self.last_activity = {
file = buffer,
set_at = activity_set_at,
workspace = nil,
}
-- When no project is detected, set custom workspace text if:
-- * The custom function returns custom workspace text
-- * The configured workspace text does not contain a directive
@ -376,6 +474,9 @@ function Presence:update_for_buffer(buffer)
end
end
-- Sync activity to all peers
self:sync_self_activity()
self.discord:set_activity(activity, function(err)
if err then
self.log:error("Failed to set activity in Discord: "..err)
@ -387,16 +488,205 @@ function Presence:update_for_buffer(buffer)
end
-- Update Rich Presence for the current or provided vim buffer for an authorized connection
Presence.update = Presence.discord_event(function(self, buffer)
Presence.update = Presence.discord_event(function(self, buffer, should_debounce)
-- Default update to not debounce by default
if should_debounce == nil then should_debounce = false end
-- Debounce Rich Presence updates (default to 15 seconds):
-- https://discord.com/developers/docs/rich-presence/how-to#updating-presence
local last_updated_at = self.last_activity.set_at
local debounce_timeout = self.options.debounce_timeout
local should_skip = should_debounce and debounce_timeout and
last_updated_at and os.time() - last_updated_at <= debounce_timeout
if should_skip then
local message_fmt = "Last activity sent was within %d seconds ago, skipping..."
self.log:debug(string.format(message_fmt, debounce_timeout))
return
end
if buffer then
self:update_for_buffer(buffer)
self:update_for_buffer(buffer, should_debounce)
else
self.get_current_buffer(function(current_buffer)
self:update_for_buffer(current_buffer)
self:update_for_buffer(current_buffer, should_debounce)
end)
end
end)
-- Register some remote peer
function Presence:register_peer(id, socket)
self.log:debug(string.format("Registering peer %s...", id))
self.peers[id] = {
socket = socket,
workspace = nil,
}
self.log:info(string.format("Registered peer %s", id))
end
-- Unregister some remote peer
function Presence:unregister_peer(id, peer)
self.log:debug(string.format("Unregistering peer %s... %s", id, vim.inspect(peer)))
-- Remove workspace if no other peers share the same workspace
-- Initialize to remove if the workspace differs from the local workspace, check peers below
local should_remove_workspace = peer.workspace ~= self.workspace
local peers = {}
for peer_id, peer_data in pairs(self.peers) do
-- Omit peer from peers list
if peer_id ~= id then
peers[peer_id] = peer_data
-- Should not remove workspace if another peer shares the workspace
if should_remove_workspace and peer.workspace == peer_data.workspace then
should_remove_workspace = false
end
end
end
self.peers = peers
-- Update workspaces if necessary
local workspaces = {}
if should_remove_workspace then
self.log:debug(string.format("Should remove workspace %s", peer.workspace))
for workspace, data in pairs(self.workspaces) do
if workspace ~= peer.workspace then
workspaces[workspace] = data
end
end
self.workspaces = workspaces
end
self.log:info(string.format("Unregistered peer %s", id))
end
-- Unregister some remote peer and set activity
function Presence:unregister_peer_and_set_activity(id, peer)
self:unregister_peer(id, peer)
self:update()
end
-- Register a remote peer and sync its data
function Presence:register_and_sync_peer(id, socket)
self:register_peer(id, socket)
self.log:debug("Syncing data with newly registered peer...")
-- Initialize the remote peer's list including self
local peers = {
[self.id] = {
socket = self.socket,
workspace = self.workspace,
}
}
for peer_id, peer in pairs(self.peers) do
if peer_id ~= id then
peers[peer_id] = peer
end
end
self:call_remote_method(socket, "sync_self", {{
last_activity = self.last_activity,
peers = peers,
workspaces = self.workspaces,
}})
end
-- Register self to any remote Neovim instances
-- Simply emits to all nvim socket addresses as we have not yet been synced with peer list
function Presence:register_self()
self:get_nvim_socket_addrs(function(sockets)
if #sockets == 0 then
self.log:debug("No other remote nvim instances")
return
end
self.log:debug(string.format("Registering as a new peer to %d instance(s)...", #sockets))
-- Register and sync state with one of the sockets
self:call_remote_method(sockets[1], "register_and_sync_peer", { self.id, self.socket })
if #sockets == 1 then
return
end
for i = 2, #sockets do
self:call_remote_method(sockets[i], "register_peer", { self.id, self.socket })
end
end)
end
-- Unregister self to all peers
function Presence:unregister_self()
local self_as_peer = {
socket = self.socket,
workspace = self.workspace,
}
local i = 1
for id, peer in pairs(self.peers) do
if self.options.auto_update and i == 1 then
self.log:debug(string.format("Unregistering self and setting activity for peer %s...", id))
self:call_remote_method(peer.socket, "unregister_peer_and_set_activity", { self.id, self_as_peer })
else
self.log:debug(string.format("Unregistering self to peer %s...", id))
self:call_remote_method(peer.socket, "unregister_peer", { self.id, self_as_peer })
end
i = i + 1
end
end
-- Sync self with data from a remote peer
function Presence:sync_self(data)
self.log:debug(string.format("Syncing data from remote peer...", vim.inspect(data)))
for key, value in pairs(data) do
self[key] = value
end
self.log:info("Synced runtime data from remote peer")
end
-- Sync activity set by self to all peers
function Presence:sync_self_activity()
local self_as_peer = {
socket = self.socket,
workspace = self.workspace,
}
for id, peer in pairs(self.peers) do
self.log:debug(string.format("Syncing activity to peer %s...", id))
local peers = { [self.id] = self_as_peer }
for peer_id, peer_data in pairs(self.peers) do
if peer_id ~= id then
peers[peer_id] = {
socket = peer_data.socket,
workspace = peer_data.workspace,
}
end
end
self:call_remote_method(peer.socket, "sync_peer_activity", {{
last_activity = self.last_activity,
peers = peers,
workspaces = self.workspaces,
}})
end
end
-- Sync activity set by peer
function Presence:sync_peer_activity(data)
self.log:debug(string.format("Syncing peer activity %s...", vim.inspect(data)))
self:cancel()
self:sync_self(data)
end
function Presence:stop()
self.log:debug("Disconnecting from Discord...")
self.discord:disconnect(function()