-
Notifications
You must be signed in to change notification settings - Fork 62
Expand file tree
/
Copy pathcommands.lua
More file actions
909 lines (801 loc) · 28.8 KB
/
commands.lua
File metadata and controls
909 lines (801 loc) · 28.8 KB
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
-- Command implementations for vscode-diff
local M = {}
-- Subcommands available for :CodeDiff
M.SUBCOMMANDS = { "merge", "file", "dir", "history", "comments", "install" }
local git = require("codediff.core.git")
local lifecycle = require("codediff.ui.lifecycle")
local config = require("codediff.config")
local view = require("codediff.ui.view")
--- Parse triple-dot syntax for merge-base comparisons.
-- @param arg string: The argument to parse
-- @return string|nil, string|nil: base_rev, target_rev (nil if not triple-dot syntax)
local function parse_triple_dot(arg)
if not arg then
return nil, nil
end
local base, target = arg:match("^(.+)%.%.%.(.*)$")
if base then
return base, target ~= "" and target or nil
end
return nil, nil
end
--- Handles diffing the current buffer against a given git revision.
-- @param revision string: The git revision (e.g., "HEAD", commit hash, branch name) to compare the current file against.
-- @param revision2 string?: Optional second revision. If provided, compares revision vs revision2.
-- @param global_opts table?: Global options (e.g., { layout = "inline" })
-- This function chains async git operations to get git root, resolve revision to hash, and get file content.
local function handle_git_diff(revision, revision2, global_opts)
local current_file = vim.api.nvim_buf_get_name(0)
if current_file == "" then
vim.notify("Current buffer is not a file", vim.log.levels.ERROR)
return
end
-- Determine filetype from current buffer (sync operation, no git involved)
local filetype = vim.bo[0].filetype
if not filetype or filetype == "" then
filetype = vim.filetype.match({ filename = current_file }) or ""
end
-- Async chain: get_git_root -> resolve_revision -> get_file_content -> render_diff
git.get_git_root(current_file, function(err_root, git_root)
if err_root then
vim.schedule(function()
vim.notify(err_root, vim.log.levels.ERROR)
end)
return
end
local relative_path = git.get_relative_path(current_file, git_root)
git.resolve_revision(revision, git_root, function(err_resolve, commit_hash)
if err_resolve then
vim.schedule(function()
vim.notify(err_resolve, vim.log.levels.ERROR)
end)
return
end
-- Resolve the file's path at the original revision (handles renames/copies)
git.resolve_path_at_revision(commit_hash, git_root, relative_path, function(_, original_path)
if revision2 then
-- Compare two revisions
git.resolve_revision(revision2, git_root, function(err_resolve2, commit_hash2)
if err_resolve2 then
vim.schedule(function()
vim.notify(err_resolve2, vim.log.levels.ERROR)
end)
return
end
-- Resolve path at modified revision too
git.resolve_path_at_revision(commit_hash2, git_root, relative_path, function(_, modified_path)
vim.schedule(function()
---@type SessionConfig
local session_config = {
mode = "standalone",
git_root = git_root,
original_path = original_path,
modified_path = modified_path,
original_revision = commit_hash,
modified_revision = commit_hash2,
layout = global_opts.layout,
}
view.create(session_config, filetype)
end)
end)
end)
else
-- Compare revision vs working tree
vim.schedule(function()
---@type SessionConfig
local session_config = {
mode = "standalone",
git_root = git_root,
original_path = original_path,
modified_path = relative_path,
original_revision = commit_hash,
modified_revision = "WORKING",
layout = global_opts.layout,
}
view.create(session_config, filetype)
end)
end
end)
end)
end)
end
local function handle_file_diff(file_a, file_b, global_opts)
-- Determine filetype from first file
local filetype = vim.filetype.match({ filename = file_a }) or ""
-- Snapshot state before creating diff tab (for argv cleanup below)
local prev_tab = vim.api.nvim_get_current_tabpage()
local prev_tab_bufs = vim.api.nvim_tabpage_list_wins(prev_tab)
local is_single_win_tab = #prev_tab_bufs == 1
-- Create diff view (no pre-reading needed, :edit will load content)
---@type SessionConfig
local session_config = {
mode = "standalone",
git_root = nil,
original_path = file_a,
modified_path = file_b,
original_revision = nil,
modified_revision = nil,
layout = global_opts.layout,
}
view.create(session_config, filetype)
-- Clean up leftover tab from command-line args (git difftool scenario).
-- When invoked as `nvim "$LOCAL" "$REMOTE" +"CodeDiff file ..."`, neovim
-- creates a tab with the first argv file. Now that the diff tab exists,
-- that original tab is redundant. Close it remotely (without switching to
-- it) and defer to avoid interfering with startup autocmds / persistence.
-- Guard: only trigger when argv files match the diff files (so running
-- `:CodeDiff file a b` from an existing session won't close unrelated tabs).
local argc = vim.fn.argc()
if argc == 2 and is_single_win_tab then
local argv0 = vim.fn.fnamemodify(vim.fn.argv(0), ":p")
local argv1 = vim.fn.fnamemodify(vim.fn.argv(1), ":p")
local abs_a = vim.fn.fnamemodify(file_a, ":p")
local abs_b = vim.fn.fnamemodify(file_b, ":p")
local argv_matches = (argv0 == abs_a and argv1 == abs_b) or (argv0 == abs_b and argv1 == abs_a)
if argv_matches then
vim.schedule(function()
if vim.api.nvim_tabpage_is_valid(prev_tab) and prev_tab ~= vim.api.nvim_get_current_tabpage() then
local tab_nr = vim.api.nvim_tabpage_get_number(prev_tab)
vim.cmd(tab_nr .. "tabclose")
end
pcall(vim.cmd, "%argdelete")
end)
end
end
end
local function handle_dir_diff(dir1, dir2, global_opts)
local dir_mod = require("codediff.core.dir")
-- Expand ~ and environment variables in paths
dir1 = vim.fn.expand(dir1)
dir2 = vim.fn.expand(dir2)
if vim.fn.isdirectory(dir1) == 0 then
vim.notify("Not a directory: " .. dir1, vim.log.levels.ERROR)
return
end
if vim.fn.isdirectory(dir2) == 0 then
vim.notify("Not a directory: " .. dir2, vim.log.levels.ERROR)
return
end
local diff = dir_mod.diff_directories(dir1, dir2)
local status_result = diff.status_result
if #status_result.unstaged == 0 and #status_result.staged == 0 then
vim.notify("No differences between directories", vim.log.levels.INFO)
return
end
---@type SessionConfig
local session_config = {
mode = "explorer",
git_root = nil, -- nil signals non-git (directory) mode
original_path = diff.root1,
modified_path = diff.root2,
original_revision = nil,
modified_revision = nil,
layout = global_opts.layout,
explorer_data = {
status_result = status_result,
},
}
view.create(session_config, "")
end
-- Handle file history command
-- range: git range (e.g., "origin/main..HEAD", "HEAD~10")
-- file_path: optional file path to filter history
-- line_range: optional {start, end} for line-range history (git log -L)
local function handle_history(range, file_path, flags, line_range, global_opts)
flags = flags or {} -- Default to empty table for backward compat
local current_buf = vim.api.nvim_get_current_buf()
local current_file = vim.api.nvim_buf_get_name(current_buf)
local cwd = vim.fn.getcwd()
-- Expand file_path before async context (vim.fn.expand can't be called in fast event)
local expanded_file_path = nil
if file_path then
expanded_file_path = vim.fn.expand(file_path)
if vim.fn.filereadable(expanded_file_path) ~= 1 then
expanded_file_path = file_path
end
end
local function open_history(git_root)
-- Build options for commit list
local history_opts = {
no_merges = true,
}
-- Apply reverse flag if present
if flags.reverse then
history_opts.reverse = true
end
-- Only apply default limit when no range specified
if not range or range == "" then
history_opts.limit = 100
end
-- If file_path specified, filter by that file
if expanded_file_path then
history_opts.path = git.get_relative_path(expanded_file_path, git_root)
end
-- If line range specified, set up for git log -L
if line_range and history_opts.path then
history_opts.line_range = line_range
end
git.get_commit_list(range or "", git_root, history_opts, function(err, commits)
if err then
vim.schedule(function()
vim.notify("Failed to get commit history: " .. err, vim.log.levels.ERROR)
end)
return
end
if #commits == 0 then
vim.schedule(function()
vim.notify("No commits found in range", vim.log.levels.INFO)
end)
return
end
vim.schedule(function()
---@type SessionConfig
local session_config = {
mode = "history",
git_root = git_root,
original_path = "",
modified_path = "",
original_revision = nil,
modified_revision = nil,
layout = global_opts.layout,
history_data = {
commits = commits,
range = range,
file_path = history_opts.path,
base_revision = flags.base,
line_range = line_range,
},
}
view.create(session_config, "")
end)
end)
end
-- Try buffer path first if available
if current_file ~= "" then
git.get_git_root(current_file, function(err_file, git_root_file)
if not err_file then
open_history(git_root_file)
return
end
git.get_git_root(cwd, function(err_cwd, git_root_cwd)
if not err_cwd then
open_history(git_root_cwd)
return
end
vim.schedule(function()
vim.notify("Not in a git repository", vim.log.levels.ERROR)
end)
end)
end)
else
git.get_git_root(cwd, function(err_cwd, git_root)
if err_cwd then
vim.schedule(function()
vim.notify(err_cwd, vim.log.levels.ERROR)
end)
return
end
open_history(git_root)
end)
end
end
local function handle_explorer(revision, revision2, global_opts)
-- Try buffer path first (consistent with original behavior), fallback to cwd
local current_buf = vim.api.nvim_get_current_buf()
local current_file = vim.api.nvim_buf_get_name(current_buf)
local cwd = vim.fn.getcwd()
local function open_explorer(git_root)
-- Compute focus_file (relative path to current buffer) for focusing in explorer
local focus_file = nil
if current_file ~= "" then
focus_file = git.get_relative_path(current_file, git_root)
end
local function process_status(err_status, status_result, original_rev, modified_rev)
vim.schedule(function()
if err_status then
vim.notify(err_status, vim.log.levels.ERROR)
return
end
-- Check if there are any changes (including conflicts)
local has_conflicts = status_result.conflicts and #status_result.conflicts > 0
if #status_result.unstaged == 0 and #status_result.staged == 0 and not has_conflicts then
vim.notify("No changes to show", vim.log.levels.INFO)
return
end
-- Create explorer view with empty diff panes initially
---@type SessionConfig
local session_config = {
mode = "explorer",
git_root = git_root,
original_path = "", -- Empty indicates explorer mode placeholder
modified_path = "",
original_revision = original_rev,
modified_revision = modified_rev,
layout = global_opts.layout,
explorer_data = {
status_result = status_result,
focus_file = focus_file, -- Focus on current file if changed
},
}
-- view.create handles everything: tab, windows, explorer, and lifecycle
-- Empty lines and paths - explorer will populate via first file selection
view.create(session_config, "")
end)
end
if revision and revision2 then
-- Compare two revisions
git.resolve_revision(revision, git_root, function(err_resolve, commit_hash)
if err_resolve then
vim.schedule(function()
vim.notify(err_resolve, vim.log.levels.ERROR)
end)
return
end
git.resolve_revision(revision2, git_root, function(err_resolve2, commit_hash2)
if err_resolve2 then
vim.schedule(function()
vim.notify(err_resolve2, vim.log.levels.ERROR)
end)
return
end
git.get_diff_revisions(commit_hash, commit_hash2, git_root, function(err_status, status_result)
process_status(err_status, status_result, commit_hash, commit_hash2)
end)
end)
end)
elseif revision then
-- Resolve revision first, then get diff
git.resolve_revision(revision, git_root, function(err_resolve, commit_hash)
if err_resolve then
vim.schedule(function()
vim.notify(err_resolve, vim.log.levels.ERROR)
end)
return
end
-- Get diff between revision and working tree
git.get_diff_revision(commit_hash, git_root, function(err_status, status_result)
process_status(err_status, status_result, commit_hash, "WORKING")
end)
end)
else
-- Get git status (current changes)
git.get_status(git_root, function(err_status, status_result)
-- Pass nil for revisions to enable "Status Mode" in explorer (separate Staged/Unstaged groups)
process_status(err_status, status_result, nil, nil)
end)
end
end
-- Try buffer path first if available
if current_file ~= "" then
git.get_git_root(current_file, function(err_file, git_root_file)
if not err_file then
open_explorer(git_root_file)
return
end
-- Buffer path failed, try cwd as fallback
git.get_git_root(cwd, function(err_cwd, git_root_cwd)
if not err_cwd then
open_explorer(git_root_cwd)
return
end
-- Both failed
vim.schedule(function()
vim.notify("Not in a git repository", vim.log.levels.ERROR)
end)
end)
end)
else
-- No buffer, try cwd directly
git.get_git_root(cwd, function(err_cwd, git_root)
if err_cwd then
vim.schedule(function()
vim.notify(err_cwd, vim.log.levels.ERROR)
end)
return
end
open_explorer(git_root)
end)
end
end
-- Wrapper for merge-base explorer mode: computes merge-base first, then opens explorer
local function handle_explorer_merge_base(base_rev, target_rev, global_opts)
local current_buf = vim.api.nvim_get_current_buf()
local current_file = vim.api.nvim_buf_get_name(current_buf)
local cwd = vim.fn.getcwd()
local buftype = vim.api.nvim_get_option_value("buftype", { buf = current_buf })
-- A file usually has a buftype of "" so filter out `nofile` or dashboards etc
local path_for_root = buftype == "" and current_file ~= "" and current_file or cwd
git.get_git_root(path_for_root, function(err_root, git_root)
if err_root then
vim.schedule(function()
vim.notify(err_root, vim.log.levels.ERROR)
end)
return
end
local actual_target = target_rev or "HEAD"
git.get_merge_base(base_rev, actual_target, git_root, function(err_mb, merge_base_hash)
if err_mb then
vim.schedule(function()
vim.notify(err_mb, vim.log.levels.ERROR)
end)
return
end
-- Schedule the explorer call to run in main context (handle_explorer uses nvim_get_current_buf)
vim.schedule(function()
if target_rev then
handle_explorer(merge_base_hash, target_rev, global_opts)
else
handle_explorer(merge_base_hash, nil, global_opts)
end
end)
end)
end)
end
-- Wrapper for merge-base single-file diff: computes merge-base first, then opens diff
local function handle_git_diff_merge_base(base_rev, target_rev, global_opts)
local current_file = vim.api.nvim_buf_get_name(0)
if current_file == "" then
vim.notify("Current buffer is not a file", vim.log.levels.ERROR)
return
end
git.get_git_root(current_file, function(err_root, git_root)
if err_root then
vim.schedule(function()
vim.notify(err_root, vim.log.levels.ERROR)
end)
return
end
local actual_target = target_rev or "HEAD"
git.get_merge_base(base_rev, actual_target, git_root, function(err_mb, merge_base_hash)
if err_mb then
vim.schedule(function()
vim.notify(err_mb, vim.log.levels.ERROR)
end)
return
end
-- Schedule the diff call to run in main context (handle_git_diff uses nvim_buf_get_name)
vim.schedule(function()
handle_git_diff(merge_base_hash, target_rev, global_opts)
end)
end)
end)
end
local function parse_comment_id(raw)
local id = tonumber(raw)
if not id then
return nil
end
if id < 1 or id ~= math.floor(id) then
return nil
end
return id
end
local function handle_comments(args, opts)
local comments = require("codediff.ui.comments")
local action = args[1]
-- exit early
if not action or action == "" then
return
end
if action == "add" then
local range_line1, range_line2
if opts and opts.range == 2 then
range_line1 = opts.line1
range_line2 = opts.line2
end
local text = table.concat(vim.list_slice(args, 2), " ")
if vim.trim(text) ~= "" then
comments.add_comment(text, range_line1, range_line2)
else
comments.open_add_editor({ range_line1 = range_line1, range_line2 = range_line2 })
end
return
end
if action == "edit" then
local rest = vim.list_slice(args, 2)
if #rest == 0 then
comments.open_edit_editor()
return
end
local id = parse_comment_id(rest[1])
if id then
if #rest == 1 then
comments.open_edit_editor(id)
else
comments.edit_comment(id, table.concat(rest, " ", 2))
end
return
end
comments.edit_comment(nil, table.concat(rest, " "))
return
end
if action == "remove" then
local rest = vim.list_slice(args, 2)
if #rest > 1 then
vim.notify("Usage: :CodeDiff comments remove [comment_id]", vim.log.levels.ERROR)
return
end
if #rest == 1 then
local id = parse_comment_id(rest[1])
if not id then
vim.notify("Usage: :CodeDiff comments remove [comment_id]", vim.log.levels.ERROR)
return
end
comments.remove_comment(id)
return
end
comments.remove_comment()
return
end
if action == "list" then
if #args > 1 then
vim.notify("Usage: :CodeDiff comments list", vim.log.levels.ERROR)
return
end
comments.list_comments()
return
end
if action == "submit" then
comments.submit_comments()
return
end
if action == "clear" then
comments.clear_comments()
return
end
end
function M.vscode_merge(opts)
local args = opts.fargs
if #args == 0 then
vim.notify("Usage: :CodeDiff merge <filename>", vim.log.levels.ERROR)
return
end
local filename = args[1]
-- Strip surrounding quotes if present (from shell escaping in git mergetool)
filename = filename:gsub('^"(.*)"$', "%1"):gsub("^'(.*)'$", "%1")
-- Resolve to absolute path
local full_path = vim.fn.fnamemodify(filename, ":p")
if vim.fn.filereadable(full_path) == 0 then
vim.notify("File not found: " .. filename, vim.log.levels.ERROR)
return
end
-- Ensure all required modules are loaded before we start vim.wait
-- This prevents issues with lazy-loading during the wait loop
-- For synchronous execution (required by git mergetool), we need to block
-- until the view is ready. Use vim.wait which processes the event loop.
local view_ready = false
local error_msg = nil
git.get_git_root(full_path, function(err_root, git_root)
if err_root then
error_msg = "Not a git repository: " .. err_root
view_ready = true
return
end
local relative_path = git.get_relative_path(full_path, git_root)
-- Schedule everything that needs main thread (vim.filetype.match, view.create)
vim.schedule(function()
local filetype = vim.filetype.match({ filename = full_path }) or ""
-- Determine conflict buffer positions based on config
-- conflict_ours_position controls where :2 (OURS) appears on screen
local ours_position = config.options.diff.conflict_ours_position or "right"
-- After conflict_window.lua's win_splitmove(rightbelow=false):
-- - original_win is on LEFT
-- - modified_win is on RIGHT
local original_rev, modified_rev
if ours_position == "right" then
original_rev = ":3" -- THEIRS in original_win (LEFT)
modified_rev = ":2" -- OURS in modified_win (RIGHT)
else
original_rev = ":2" -- OURS in original_win (LEFT)
modified_rev = ":3" -- THEIRS in modified_win (RIGHT)
end
---@type SessionConfig
local session_config = {
mode = "standalone",
git_root = git_root,
original_path = relative_path,
modified_path = relative_path,
original_revision = original_rev,
modified_revision = modified_rev,
conflict = true,
}
view.create(session_config, filetype, function()
view_ready = true
end)
end)
end)
-- Block until view is ready - this allows event loop to process callbacks
vim.wait(10000, function()
return view_ready
end, 10)
-- Force screen redraw after vim.wait to ensure all windows are visible
vim.cmd("redraw!")
if error_msg then
vim.notify(error_msg, vim.log.levels.ERROR)
end
end
function M.vscode_diff(opts)
-- Pre-parse global flags -> strip them so subcommand
-- dispatch sees clean args
local global_opts = {}
local args = {}
for _, arg in ipairs(opts.fargs) do
if arg == "--inline" then
global_opts.layout = "inline"
elseif arg == "--side-by-side" then
global_opts.layout = "side-by-side"
else
table.insert(args, arg)
end
end
-- Check if current tab is a diff view and toggle (close) it if so.
-- Keep comments subcommands available while in an active diff session.
local current_tab = vim.api.nvim_get_current_tabpage()
local active_session = lifecycle.get_session(current_tab)
local subcommand = args[1]
local is_comments_command = subcommand == "comments" or subcommand == "comment"
if active_session and not is_comments_command then
-- Check for unsaved conflict files before closing
if not lifecycle.confirm_close_with_unsaved(current_tab) then
return -- User cancelled
end
if #vim.api.nvim_list_tabpages() == 1 then
lifecycle.cleanup_for_quit(current_tab)
vim.cmd("qall")
else
vim.cmd("tabclose")
end
return
end
if #args == 0 then
-- :CodeDiff without arguments opens explorer mode
handle_explorer(nil, nil, global_opts)
return
end
-- Auto-detect two directory arguments: :CodeDiff dir1 dir2
if #args == 2 then
local expanded1 = vim.fn.expand(args[1])
local expanded2 = vim.fn.expand(args[2])
if vim.fn.isdirectory(expanded1) == 1 and vim.fn.isdirectory(expanded2) == 1 then
handle_dir_diff(expanded1, expanded2, global_opts)
return
end
end
if subcommand == "merge" then
-- :CodeDiff merge <filename> - Merge Tool Mode
if #args ~= 2 then
vim.notify("Usage: :CodeDiff merge <filename>", vim.log.levels.ERROR)
return
end
M.vscode_merge({ fargs = { args[2] } })
elseif subcommand == "file" then
if #args == 2 then
-- Check for triple-dot syntax: :CodeDiff file main...
local base, target = parse_triple_dot(args[2])
if base then
handle_git_diff_merge_base(base, target, global_opts)
else
-- :CodeDiff file HEAD
handle_git_diff(args[2], nil, global_opts)
end
elseif #args == 3 then
-- Check if arguments are files or revisions
local arg1 = args[2]
local arg2 = args[3]
-- If both are readable files, treat as file diff
if vim.fn.filereadable(arg1) == 1 and vim.fn.filereadable(arg2) == 1 then
-- :CodeDiff file file_a.txt file_b.txt
handle_file_diff(arg1, arg2, global_opts)
else
-- Assume revisions: :CodeDiff file main HEAD
handle_git_diff(arg1, arg2, global_opts)
end
else
vim.notify("Usage: :CodeDiff file <revision> [revision2] OR :CodeDiff file <file_a> <file_b>", vim.log.levels.ERROR)
end
elseif subcommand == "dir" then
-- :CodeDiff dir dir1 dir2
if #args ~= 3 then
vim.notify("Usage: :CodeDiff dir <dir1> <dir2>", vim.log.levels.ERROR)
return
end
handle_dir_diff(args[2], args[3], global_opts)
elseif subcommand == "history" then
-- :CodeDiff history [range] [file] [--reverse|-r]
-- :'<,'>CodeDiff history - line-range history for selection
-- Examples:
-- :CodeDiff history - last 100 commits
-- :CodeDiff history HEAD~10 - last 10 commits
-- :CodeDiff history origin/main..HEAD - commits in range
-- :CodeDiff history HEAD~10 % - last 10 commits for current file
-- :CodeDiff history % - history for current file
-- :CodeDiff history path/to/file.lua - history for specific file
-- :CodeDiff history --reverse - last 100 commits (oldest first)
-- :CodeDiff history HEAD~10 -r - last 10 commits (oldest first)
-- Import flag parser
local args_parser = require("codediff.core.args")
-- Define flag spec for history command
local flag_spec = {
["--reverse"] = { short = "-r", type = "boolean" },
["--base"] = { short = "-b", type = "string" },
}
-- Parse args: separate positional from flags
local remaining_args = vim.list_slice(args, 2) -- Skip "history" subcommand
local positional, flags, parse_err = args_parser.parse_args(remaining_args, flag_spec)
if parse_err then
vim.notify("Error: " .. parse_err, vim.log.levels.ERROR)
return
end
-- Use positional[1], positional[2] instead of args[2], args[3]
local arg1 = positional[1]
local arg2 = positional[2]
local range = nil
local file_path = nil
-- Helper to expand path (handles % and normal paths)
local function expand_path(p)
if p == "%" then
return vim.api.nvim_buf_get_name(0)
else
return vim.fn.expand(p)
end
end
if arg1 and arg2 then
-- Two params: first is range, second is file_path
range = arg1
file_path = expand_path(arg2)
elseif arg1 then
-- One param: try as file_path first, otherwise treat as range
local expanded = expand_path(arg1)
if vim.fn.filereadable(expanded) == 1 then
file_path = expanded
else
range = arg1
end
end
-- Detect visual range: opts.range == 2 means a range was explicitly given
-- (e.g., :'<,'>CodeDiff history)
local line_range = nil
if opts.range == 2 then
line_range = { opts.line1, opts.line2 }
-- Visual range implies current file
if not file_path then
local buf_name = vim.api.nvim_buf_get_name(0)
if buf_name ~= "" then
file_path = buf_name
else
vim.notify("Line-range history requires a file buffer", vim.log.levels.ERROR)
return
end
end
end
handle_history(range, file_path, flags, line_range, global_opts)
elseif subcommand == "comments" or subcommand == "comment" then
handle_comments(vim.list_slice(args, 2), opts)
elseif subcommand == "install" or subcommand == "install!" then
-- :CodeDiff install or :CodeDiff install!
-- Handle both :CodeDiff! install and :CodeDiff install!
local force = opts.bang or subcommand == "install!"
local installer = require("codediff.core.installer")
if force then
vim.notify("Reinstalling libvscode-diff...", vim.log.levels.INFO)
end
local success, err = installer.install({ force = force, silent = false })
if success then
vim.notify("libvscode-diff installation successful!", vim.log.levels.INFO)
else
vim.notify("Installation failed: " .. (err or "unknown error"), vim.log.levels.ERROR)
end
else
-- :CodeDiff <revision> [revision2] - opens explorer mode
-- Check for triple-dot syntax: :CodeDiff main...
local base, target = parse_triple_dot(subcommand)
if base then
handle_explorer_merge_base(base, target, global_opts)
elseif #args == 2 then
handle_explorer(args[1], args[2], global_opts)
else
handle_explorer(subcommand, nil, global_opts)
end
end
end
return M