-
Notifications
You must be signed in to change notification settings - Fork 66
/
Copy pathinit.lua
1248 lines (1051 loc) · 42 KB
/
init.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
--------------------------------------------------
-- ____ --
-- / __ \________ ________ ____ ________ --
-- / /_/ / ___/ _ \/ ___/ _ \/ __ \/ ___/ _ \ --
-- / ____/ / / __(__ ) __/ / / / /__/ __/ --
-- /_/ /_/ \___/____/\___/_/ /_/\___/\___/ --
-- --
-- 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",
-- set_at = 1616033523,
-- },
--
-- -- 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_authorizing = false
Presence.is_connected = false
Presence.is_connecting = 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 file_explorers = require("presence.file_explorers")
local default_file_assets = require("presence.file_assets")
local plugin_managers = require("presence.plugin_managers")
local Discord = require("presence.discord")
function Presence:setup(...)
-- Support setup invocation via both dot and colon syntax.
-- To maintain backwards compatibility, colon syntax will still
-- be supported, but dot syntax should be recommended.
local args = {...}
local options = args[1]
if #args == 0 then
options = self
self = Presence
end
options = options or {}
self.options = options
-- Initialize logger
self:set_option("log_level", nil, false)
self.log = log:init({ level = options.log_level })
-- Get operating system information including path separator
-- http://www.lua.org/manual/5.3/manual.html#pdf-package.config
local uname = vim.loop.os_uname()
local separator = package.config:sub(1,1)
local wsl_distro_name = os.getenv("WSL_DISTRO_NAME")
local os_name = self.get_os_name(uname)
self.os = {
name = os_name,
is_wsl = uname.release:lower():find("microsoft") ~= nil,
path_separator = separator,
}
-- Print setup message with OS information
local setup_message_fmt = "Setting up plugin for %s"
if self.os.name then
local setup_message = self.os.is_wsl
and string.format(setup_message_fmt.." in WSL (%s)", self.os.name, vim.inspect(wsl_distro_name))
or string.format(setup_message_fmt, self.os.name)
self.log:debug(setup_message)
else
self.log:error(string.format("Unable to detect operating system: %s", vim.inspect(vim.loop.os_uname())))
end
-- Use the default or user-defined client id if provided
if options.client_id then
self.log:info("Using user-defined Discord client id")
end
-- General options
self:set_option("auto_update", 1)
self:set_option("client_id", "793271441293967371")
self:set_option("debounce_timeout", 10)
self:set_option("main_image", "neovim")
self:set_option("neovim_image_text", "The One True Text Editor")
self:set_option("enable_line_number", false)
-- Status text options
self:set_option("editing_text", "Editing %s")
self:set_option("file_explorer_text", "Browsing %s")
self:set_option("git_commit_text", "Committing changes")
self:set_option("plugin_manager_text", "Managing plugins")
self:set_option("reading_text", "Reading %s")
self:set_option("workspace_text", "Working on %s")
self:set_option("line_number_text", "Line %s out of %s")
self:set_option("blacklist", {})
self:set_option("buttons", true)
self:set_option("show_time", true)
-- File assets options
self:set_option("file_assets", {})
for name, asset in pairs(default_file_assets) do
if not self.options.file_assets[name] then
self.options.file_assets[name] = asset
end
end
-- Get and check discord socket path
local discord_socket_path = self:get_discord_socket_path()
if discord_socket_path then
self.log:debug(string.format("Using Discord IPC socket path: %s", discord_socket_path))
self:check_discord_socket(discord_socket_path)
else
self.log:error("Failed to determine Discord IPC socket path")
end
-- Initialize discord RPC client
self.discord = Discord:init({
logger = self.log,
client_id = options.client_id,
ipc_socket = discord_socket_path,
})
-- Seed instance id using unique socket path
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())
self.log:debug(string.format("Using id %s", self.id))
-- Ensure auto-update config is reflected in its global var setting
vim.api.nvim_set_var("presence_auto_update", options.auto_update)
-- Set autocommands
vim.fn["presence#SetAutoCmds"]()
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
-- Normalize the OS name from uname
function Presence.get_os_name(uname)
if uname.sysname:find("Windows") then
return "windows"
elseif uname.sysname:find("Darwin") then
return "macos"
elseif uname.sysname:find("Linux") then
return "linux"
end
return "unknown"
end
-- To ensure consistent option values, coalesce true and false values to 1 and 0
function Presence.coalesce_option(value)
if type(value) == "boolean" then
return value and 1 or 0
end
return value
end
-- Set option using either vim global or setup table
function Presence:set_option(option, default, validate)
default = self.coalesce_option(default)
validate = validate == nil and true or validate
local g_variable = string.format("presence_%s", option)
self.options[option] = self.coalesce_option(self.options[option])
if validate then
-- Warn on any duplicate user-defined options
self:check_dup_options(option)
end
self.options[option] = self.options[option] or
vim.g[g_variable] or
default
end
-- Check and warn for duplicate user-defined options
function Presence:check_dup_options(option)
local g_variable = string.format("presence_%s", option)
if self.options[option] ~= nil and vim.g[g_variable] ~= nil then
local warning_fmt = "Duplicate options: `g:%s` and setup option `%s`"
local warning_msg = string.format(warning_fmt, g_variable, option)
self.log:warn(warning_msg)
end
end
-- Check the Discord socket at the given path
function Presence:check_discord_socket(path)
self.log:debug(string.format("Checking Discord IPC socket at %s...", path))
-- Asynchronously check socket path via stat
vim.loop.fs_stat(path, function(err, stats)
if err then
local err_msg = "Failed to get socket information"
self.log:error(string.format("%s: %s", err_msg, err))
return
end
if stats.type ~= "socket" then
local warning_msg = "Found unexpected Discord IPC socket type"
self.log:warn(string.format("%s: %s", warning_msg, err))
return
end
self.log:debug("Checked Discord IPC socket, looks good!")
end)
end
-- Send a nil activity to unset the presence
function Presence:cancel()
self.log:debug("Canceling Discord presence...")
if not self.discord:is_connected() then
return
end
self.discord:set_activity(nil, function(err)
if err then
self.log:error(string.format("Failed to cancel activity in Discord: %s", err))
return
end
self.log:info("Canceled Discord presence")
end)
end
-- Call a command on a remote Neovim instance at the provided IPC path
function Presence:call_remote_nvim_instance(socket, command)
local remote_nvim_instance = vim.loop.new_pipe(true)
remote_nvim_instance:connect(socket, function()
self.log:debug(string.format("Connected to remote nvim instance at %s", socket))
local packed = msgpack.pack({ 0, 0, "nvim_command", { command } })
remote_nvim_instance:write(packed, function()
self.log:debug(string.format("Wrote to remote nvim instance: %s", socket))
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...")
self.is_connecting = true
self.discord:connect(function(err)
self.is_connecting = false
-- Handle known connection errors
if err == "EISCONN" then
self.log:info("Already connected to Discord")
elseif err == "ECONNREFUSED" then
self.log:warn("Failed to connect to Discord: "..err.." (is Discord running?)")
return
elseif err then
self.log:error("Failed to connect to Discord: "..err)
return
end
self.log:info("Connected to Discord")
self.is_connected = true
if on_done then on_done() end
end)
end
function Presence:authorize(on_done)
self.log:debug("Authorizing with Discord...")
-- Track authorization state to avoid race conditions
-- (Discord rejects when multiple auth requests are sent at once)
self.is_authorizing = true
self.discord:authorize(function(err, response)
self.is_authorizing = false
if err and err:find(".*already did handshake.*") then
self.log:info("Already authorized with Discord")
self.is_authorized = true
return on_done()
elseif err then
self.log:error("Failed to authorize with Discord: "..err)
self.is_authorized = false
return
end
self.log:info(string.format("Authorized with Discord for %s", response.data.user.username))
self.is_authorized = true
if on_done then on_done() end
end)
end
-- Find the Discord socket from temp runtime directories
function Presence:get_discord_socket_path()
local sock_name = "discord-ipc-0"
local sock_path = nil
if self.os.is_wsl then
-- Use socket created by relay for WSL
sock_path = "/var/run/"..sock_name
elseif self.os.name == "windows" then
-- Use named pipe in NPFS for Windows
sock_path = [[\\.\pipe\]]..sock_name
elseif self.os.name == "macos" then
-- Use $TMPDIR for macOS
local path = os.getenv("TMPDIR")
if path then
sock_path = path:match("/$")
and path..sock_name
or path.."/"..sock_name
end
elseif self.os.name == "linux" then
-- Check various temp directory environment variables
local env_vars = {
"XDG_RUNTIME_DIR",
"TEMP",
"TMP",
"TMPDIR",
}
for i = 1, #env_vars do
local var = env_vars[i]
local path = os.getenv(var)
if path then
self.log:debug(string.format("Using runtime path: %s", path))
sock_path = path:match("/$") and path..sock_name or path.."/"..sock_name
break
end
end
end
return sock_path
end
-- Gets the file path of the current vim buffer
function Presence.get_current_buffer()
local current_buffer = vim.api.nvim_get_current_buf()
return vim.api.nvim_buf_get_name(current_buffer)
end
-- Gets the current project name
function Presence:get_project_name(file_path)
if not file_path then
return nil
end
-- Escape quotes in the file path
file_path = file_path:gsub([["]], [[\"]])
-- TODO: Only checks for a git repository, could add more checks here
-- Might want to run this in a background process depending on performance
local project_path_cmd = "git rev-parse --show-toplevel"
project_path_cmd = file_path
and string.format([[cd "%s" && %s]], file_path, project_path_cmd)
or project_path_cmd
local project_path = vim.fn.system(project_path_cmd)
project_path = vim.trim(project_path)
if project_path:find("fatal.*") then
self.log:info("Not a git repository, skipping...")
return nil
end
if vim.v.shell_error ~= 0 or #project_path == 0 then
local message_fmt = "Failed to get project name (error code %d): %s"
self.log:error(string.format(message_fmt, vim.v.shell_error, project_path))
return nil
end
-- Since git always uses forward slashes, replace with backslash in Windows
if self.os.name == "windows" then
project_path = project_path:gsub("/", [[\]])
end
return self.get_filename(project_path, self.os.path_separator), project_path
end
-- Get the name of the parent directory for the given path
function Presence.get_dir_path(path, path_separator)
return path:match(string.format("^(.+%s.+)%s.*$", path_separator, path_separator))
end
-- Get the name of the file for the given path
function Presence.get_filename(path, path_separator)
return path:match(string.format("^.+%s(.+)$", path_separator))
end
-- Get the file extension for the given filename
function Presence.get_file_extension(path)
return path:match("^.+%.(.+)$")
end
-- Format any status text via options and support custom formatter functions
function Presence:format_status_text(status_type, ...)
local option_name = string.format("%s_text", status_type)
local text_option = self.options[option_name]
if type(text_option) == "function" then
return text_option(...)
else
return string.format(text_option, ...)
end
end
-- Get the status text for the current buffer
function Presence:get_status_text(filename)
local file_explorer = file_explorers[vim.bo.filetype:match "[^%d]+"]
or file_explorers[(filename or ""):match "[^%d]+"]
local plugin_manager = plugin_managers[vim.bo.filetype]
if file_explorer then
return self:format_status_text("file_explorer", file_explorer)
elseif plugin_manager then
return self:format_status_text("plugin_manager", plugin_manager)
end
if not filename or filename == "" then return nil end
if vim.bo.modifiable and not vim.bo.readonly then
if vim.bo.filetype == "gitcommit" then
return self:format_status_text("git_commit", filename)
elseif filename then
return self:format_status_text("editing", filename)
end
elseif filename then
return self:format_status_text("reading", filename)
end
end
-- Get all local nvim socket paths
function Presence:get_nvim_socket_paths(on_done)
self.log:debug("Getting nvim socket paths...")
local sockets = {}
local parser = {}
local cmd
if self.os.is_wsl then
-- TODO: There needs to be a better way of doing this... no support for ss/netstat?
-- (See https://github.com/microsoft/WSL/issues/2249)
local cmd_fmt = "for file in %s/nvim*; do echo $file/0; done"
local shell_cmd = string.format(cmd_fmt, vim.loop.os_tmpdir() or "/tmp")
cmd = {
"sh",
"-c",
shell_cmd,
}
elseif self.os.name == "windows" then
cmd = {
"powershell.exe",
"-Command",
[[(Get-ChildItem \\.\pipe\).FullName | findstr 'nvim']],
}
elseif self.os.name == "macos" then
if vim.fn.executable("netstat") == 0 then
self.log:warn("Unable to get nvim socket paths: `netstat` command unavailable")
return
end
-- Define macOS BSD netstat output parser
function parser.parse(data)
return data:match("%s(/.+)")
end
cmd = table.concat({
"netstat -u",
[[grep --color=never "nvim.*/0"]],
}, "|")
elseif self.os.name == "linux" then
if vim.fn.executable("ss") == 1 then
-- Use `ss` if available
cmd = table.concat({
"ss -lx",
[[grep "nvim.*/0"]],
}, "|")
-- Define ss output parser
function parser.parse(data)
return data:match("%s(/.-)%s")
end
elseif vim.fn.executable("netstat") == 1 then
-- Use `netstat` if available
cmd = table.concat({
"netstat -u",
[[grep --color=never "nvim.*/0"]],
}, "|")
-- Define netstat output parser
function parser.parse(data)
return data:match("%s(/.+)")
end
else
local warning_msg = "Unable to get nvim socket paths: `netstat` and `ss` commands unavailable"
self.log:warn(warning_msg)
return
end
else
local warning_fmt = "Unable to get nvim socket paths: Unexpected OS: %s"
self.log:warn(string.format(warning_fmt, self.os.name))
return
end
local function handle_data(_, data)
if not data then return end
for i = 1, #data do
local socket = parser.parse and parser.parse(vim.trim(data[i])) or vim.trim(data[i])
if socket and socket ~= "" and socket ~= self.socket then
table.insert(sockets, socket)
end
end
end
local function handle_error(_, data)
if not data then return end
if data[1] ~= "" then
self.log:error(string.format("Unable to get nvim socket paths: %s", data[1]))
end
end
local function handle_exit()
self.log:debug(string.format("Got nvim socket paths: %s", vim.inspect(sockets)))
on_done(sockets)
end
local cmd_str = type(cmd) == "table" and table.concat(cmd, ", ") or cmd
self.log:debug(string.format("Executing command: `%s`", cmd_str))
vim.fn.jobstart(cmd, {
on_stdout = handle_data,
on_stderr = handle_error,
on_exit = handle_exit,
})
end
-- Wrap calls to Discord that require prior connection and authorization
function Presence.discord_event(on_ready)
return function(self, ...)
if not self.discord.ipc_socket then
self.log:debug("Discord IPC socket not found, skipping...")
return
end
local args = {...}
local callback = function()
on_ready(self, unpack(args))
end
-- Call Discord if already connected and authorized
if self.is_connected and self.is_authorized then
return callback()
end
-- Schedule event if currently authorizing with Discord
if self.is_connecting or self.is_authorizing then
local action = self.is_connecting and "connecting" or "authorizing"
local message_fmt = "Currently %s with Discord, scheduling callback for later..."
self.log:debug(string.format(message_fmt, action))
return vim.schedule(callback)
end
-- Authorize if connected but not yet authorized yet
if self.is_connected and not self.is_authorized then
return self:authorize(callback)
end
-- Connect and authorize plugin with Discord
self:connect(function()
if self.is_authorized then
return callback()
end
self:authorize(callback)
end)
end
end
-- Check if the current project/parent is in blacklist
function Presence:check_blacklist(buffer, parent_dirpath, project_dirpath)
local parent_dirname = nil
local project_dirname = nil
-- Parse parent/project directory name
if parent_dirpath then
parent_dirname = self.get_filename(parent_dirpath, self.os.path_separator)
end
if project_dirpath then
project_dirname = self.get_filename(project_dirpath, self.os.path_separator)
end
-- Blacklist table
local blacklist_table = self.options["blacklist"]
-- Loop over the values to see if the provided project/path is in the blacklist
for _, val in pairs(blacklist_table) do
-- Matches buffer exactly
if buffer:match(val) == buffer then return true end
-- Match parent either by Lua pattern or by plain string
local is_parent_directory_blacklisted = parent_dirpath and
((parent_dirpath:match(val) == parent_dirpath or
parent_dirname:match(val) == parent_dirname) or
(parent_dirpath:find(val, nil, true) or
parent_dirname:find(val, nil, true)))
if is_parent_directory_blacklisted then
return true
end
-- Match project either by Lua pattern or by plain string
local is_project_directory_blacklisted = project_dirpath and
((project_dirpath:match(val) == project_dirpath or
project_dirname:match(val) == project_dirname) or
(project_dirpath:find(val, nil, true) or
project_dirname:find(val, nil, true)))
if is_project_directory_blacklisted then
return true
end
end
return false
end
-- Get either user-configured buttons or the create default "View Repository" button definition
function Presence:get_buttons(buffer, parent_dirpath)
-- User configured a static buttons table
if type(self.options.buttons) == "table" then
local is_plural = #self.options.buttons > 1
local s = is_plural and "s" or ""
self.log:debug(string.format("Using custom-defined button%s", s))
return self.options.buttons
end
-- Retrieve the git repository URL
local repo_url
if parent_dirpath then
-- Escape quotes in the file path
local path = parent_dirpath:gsub([["]], [[\"]])
local git_url_cmd = "git config --get remote.origin.url"
local cmd = path
and string.format([[cd "%s" && %s]], path, git_url_cmd)
or git_url_cmd
-- Trim and coerce empty string value to null
repo_url = vim.trim(vim.fn.system(cmd))
repo_url = repo_url ~= "" and repo_url or nil
end
-- User configured a function to dynamically create buttons table
if type(self.options.buttons) == "function" then
self.log:debug("Using custom-defined button config function")
return self.options.buttons(buffer, repo_url)
end
-- Default behavior to show a "View Repository" button if the repo URL is valid
if repo_url then
-- Check if repo url uses short ssh syntax
local domain, project = repo_url:match("^git@(.+):(.+)$")
if domain and project then
self.log:debug(string.format("Repository URL uses short ssh syntax: %s", repo_url))
repo_url = string.format("https://%s/%s", domain, project)
end
-- Check if repo url uses a valid protocol
local protocols = {
"ftp",
"git",
"http",
"https",
"ssh",
}
local protocol, relative = repo_url:match("^(.+)://(.+)$")
if not vim.tbl_contains(protocols, protocol) or not relative then
self.log:debug(string.format("Repository URL uses invalid protocol: %s", repo_url))
return nil
end
-- Check if repo url has the user specified
local user, path = relative:match("^(.+)@(.+)$")
if user and path then
self.log:debug(string.format("Repository URL has user specified: %s", repo_url))
repo_url = string.format("https://%s", path)
else
repo_url = string.format("https://%s", relative)
end
self.log:debug(string.format("Adding button with repository URL: %s", repo_url))
return {
{ label = "View Repository", url = repo_url },
}
end
return nil
end
-- Update Rich Presence for the provided vim buffer
function Presence:update_for_buffer(buffer)
-- Avoid unnecessary updates if the previous activity was for the current buffer
-- (allow same-buffer updates when line numbers are enabled)
if self.options.enable_line_number == 0 and self.last_activity.file == buffer then
self.log:debug(string.format("Activity already set for %s, skipping...", buffer))
return
end
-- Parse vim buffer
local filename = self.get_filename(buffer, self.os.path_separator)
local parent_dirpath = self.get_dir_path(buffer, self.os.path_separator)
local extension = filename and self.get_file_extension(filename) or nil
self.log:debug(string.format("Parsed filename %s with %s extension", filename, extension or "no"))
-- Return early if there is no valid activity status text to set
local status_text = self:get_status_text(filename)
if not status_text then
return self.log:debug("No status text for the given buffer, skipping...")
end
-- Get project information
self.log:debug(string.format("Getting project name for %s...", parent_dirpath))
local project_name, project_path = self:get_project_name(parent_dirpath)
-- Check for blacklist
local is_blacklisted = #self.options.blacklist > 0 and self:check_blacklist(buffer, parent_dirpath, project_path)
if is_blacklisted then
self.last_activity.file = buffer
self.log:debug("Either project or directory name is blacklisted, skipping...")
self:cancel()
return
end
local activity_set_at = os.time()
-- Set the relative time if it does not already exist
local relative_activity_set_at = self.last_activity.relative_set_at or os.time()
self.log:debug(string.format("Setting activity for %s...", buffer and #buffer > 0 and buffer or "unnamed buffer"))
-- Determine image text and asset key
local name = filename
local asset_key = "code"
local description = filename
local file_asset = self.options.file_assets[filename] or self.options.file_assets[extension]
if file_asset then
name, asset_key, description = unpack(file_asset)
self.log:debug(string.format("Using file asset: %s", vim.inspect(file_asset)))
end
-- Construct activity asset information
local file_text = description or name
local neovim_image_text = self.options.neovim_image_text
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",
large_text = use_file_as_main_image and file_text or neovim_image_text,
small_image = use_file_as_main_image and "neovim" or asset_key,
small_text = use_file_as_main_image and neovim_image_text or file_text,
}
local activity = {
state = status_text,
assets = assets,
timestamps = self.options.show_time == 1 and {
start = relative_activity_set_at,
} or nil,
}
-- Add button that links to the git workspace remote origin url
if self.options.buttons ~= 0 then
local buttons = self:get_buttons(buffer, parent_dirpath)
if buttons then
self.log:debug(string.format("Attaching buttons to activity: %s", vim.inspect(buttons)))
activity.buttons = buttons
end
end
-- Get the current line number and line count if the user has set the enable_line_number option
if self.options.enable_line_number == 1 then
self.log:debug("Getting line number for current buffer...")
local line_number = vim.api.nvim_win_get_cursor(0)[1]
local line_count = vim.api.nvim_buf_line_count(0)
local line_number_text = self:format_status_text("line_number", line_number, line_count)
activity.details = line_number_text
self.workspace = nil
self.last_activity = {
id = self.id,
file = buffer,
set_at = activity_set_at,
relative_set_at = relative_activity_set_at,
workspace = nil,
}
else
-- Include project details if available and if the user hasn't set the enable_line_number option
if project_name then
self.log:debug(string.format("Detected project: %s", project_name))
activity.details = self:format_status_text("workspace", project_name, buffer)
self.workspace = project_path
self.last_activity = {
id = self.id,
file = buffer,
set_at = activity_set_at,
relative_set_at = relative_activity_set_at,
workspace = project_path,
}
if self.workspaces[project_path] then
self.workspaces[project_path].updated_at = activity_set_at
else
self.workspaces[project_path] = {
started_at = activity_set_at,
updated_at = activity_set_at,
}
end
activity.timestamps = self.options.show_time == 1 and {
start = self.workspaces[project_path].started_at,
} or nil
else
self.log:debug("No project detected")
self.workspace = nil
self.last_activity = {
id = self.id,
file = buffer,
set_at = activity_set_at,
relative_set_at = relative_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
-- (can't use the `format_status_text` method here)
local workspace_text = self.options.workspace_text
if type(workspace_text) == "function" then
local custom_workspace_text = workspace_text(nil, buffer)
if custom_workspace_text then
activity.details = custom_workspace_text
end
elseif not workspace_text:find("%s") then
activity.details = workspace_text
end
end
end
-- Sync activity to all peers
self.log:debug("Sync activity to all peers...")
self:sync_self_activity()
self.log:debug("Setting Discord activity...")
self.discord:set_activity(activity, function(err)
if err then
self.log:error(string.format("Failed to set activity in Discord: %s", err))
return
end
self.log:info(string.format("Set activity in Discord for %s", filename))
end)
end
-- Update Rich Presence for the current or provided vim buffer for an authorized connection
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 10 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)
else
vim.schedule(function()
self:update_for_buffer(self.get_current_buffer())
end)
end
end)
--------------------------------------------------
-- Presence peer-to-peer API
--------------------------------------------------
-- 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