1+ from __future__ import annotations
2+
13import difflib
2- import os
34import time
5+ from pathlib import Path
6+ from typing import Iterable , Iterator , List , Optional
47
58import sublime
69import sublime_plugin
710
811
9- def splitlines_keep_ends (text ):
12+ def splitlines_keep_ends (text : str ) -> List [str ]:
13+ """
14+ Split text into lines but preserve newline endings.
15+ Required for difflib to work correctly.
16+ """
1017 lines = text .split ('\n ' )
11-
12- # Need to insert back the newline characters between lines, difflib
13- # requires this.
14- if len (lines ) > 0 :
15- for i in range (len (lines ) - 1 ):
16- lines [i ] += '\n '
17-
18+ for i in range (len (lines ) - 1 ):
19+ lines [i ] += '\n '
1820 return lines
1921
2022
21- def read_file_lines (fname ):
22- with open (fname , mode = 'rt' , encoding = 'utf-8' ) as f :
23- lines = splitlines_keep_ends (f .read ())
24-
25- # as `difflib` doesn't work properly when the file does not end
26- # with a new line character (https://bugs.python.org/issue2142),
27- # we add a warning ourselves to fix it
23+ def read_file_lines (fname : str | Path ) -> List [str ]:
24+ """Read a UTF-8 file and return its lines with newline endings preserved."""
25+ path = Path (fname )
26+ text = path .read_text (encoding = "utf-8" )
27+ lines = splitlines_keep_ends (text )
2828 add_no_eol_warning_if_applicable (lines )
29-
3029 return lines
3130
3231
33- def add_no_eol_warning_if_applicable (lines ):
34- if len (lines ) > 0 and lines [- 1 ]:
35- # note we update the last line rather than adding a new one
36- # so that the diff will show the warning with the last line
32+ def add_no_eol_warning_if_applicable (lines : List [str ]) -> None :
33+ """
34+ Append a note if file doesn't end with newline.
35+ difflib misbehaves otherwise.
36+ """
37+ if lines and lines [- 1 ] and not lines [- 1 ].endswith ('\n ' ):
3738 lines [- 1 ] += '\n \\ No newline at end of file\n '
3839
3940
4041class DiffFilesCommand (sublime_plugin .WindowCommand ):
41-
42- def run (self , files ):
42+ def run (self , files : List [str ]) -> None :
4343 if len (files ) != 2 :
4444 return
4545
@@ -50,23 +50,30 @@ def run(self, files):
5050 sublime .status_message ("Diff only works with UTF-8 files" )
5151 return
5252
53- adate = time .ctime (os .stat (files [1 ]).st_mtime )
54- bdate = time .ctime (os .stat (files [0 ]).st_mtime )
53+ a_path , b_path = Path (files [1 ]), Path (files [0 ])
54+ adate = time .ctime (a_path .stat ().st_mtime )
55+ bdate = time .ctime (b_path .stat ().st_mtime )
5556
56- diff = difflib .unified_diff (a , b , files [1 ], files [0 ], adate , bdate )
57- show_diff_output (diff , None , self .window , f"{ os .path .basename (files [1 ])} -> { os .path .basename (files [0 ])} " , 'diff_files' , 'diff_files_to_buffer' )
57+ diff = difflib .unified_diff (
58+ a , b , files [1 ], files [0 ], adate , bdate , lineterm = ""
59+ )
60+ show_diff_output (
61+ diff ,
62+ None ,
63+ self .window ,
64+ f"{ a_path .name } -> { b_path .name } " ,
65+ "diff_files" ,
66+ "diff_files_to_buffer" ,
67+ )
5868
59- def is_visible (self , files ) :
69+ def is_visible (self , files : List [ str ]) -> bool :
6070 return len (files ) == 2
6171
6272
6373class DiffChangesCommand (sublime_plugin .TextCommand ):
64-
65- def run (self , edit ):
66-
74+ def run (self , edit : sublime .Edit ) -> None :
6775 fname = self .view .file_name ()
68-
69- if not fname or not os .path .exists (fname ):
76+ if not fname or not Path (fname ).exists ():
7077 sublime .status_message ("Unable to diff changes because the file does not exist" )
7178 return
7279
@@ -77,24 +84,31 @@ def run(self, edit):
7784 return
7885
7986 b = get_lines_for_view (self .view )
80-
8187 add_no_eol_warning_if_applicable (b )
8288
83- adate = time .ctime (os .stat (fname ).st_mtime )
89+ adate = time .ctime (Path ( fname ) .stat ().st_mtime )
8490 bdate = time .ctime ()
8591
86- diff = difflib .unified_diff (a , b , fname , fname , adate , bdate )
87- name = "Unsaved Changes: " + os . path . basename (fname )
88- show_diff_output (diff , self .view , self .view .window (), name , ' unsaved_changes' , ' diff_changes_to_buffer' )
92+ diff = difflib .unified_diff (a , b , fname , fname , adate , bdate , lineterm = "" )
93+ name = f "Unsaved Changes: { Path (fname ). name } "
94+ show_diff_output (diff , self .view , self .view .window (), name , " unsaved_changes" , " diff_changes_to_buffer" )
8995
90- def is_enabled (self ):
96+ def is_enabled (self ) -> bool :
9197 return self .view .is_dirty () and self .view .file_name () is not None
9298
9399
94- def show_diff_output (diff , view , win , name , panel_name , buffer_setting_name ):
95- difftxt = u"" .join (line for line in diff )
100+ def show_diff_output (
101+ diff : Iterable [str ],
102+ view : Optional [sublime .View ],
103+ win : sublime .Window ,
104+ name : str ,
105+ panel_name : str ,
106+ buffer_setting_name : str ,
107+ ) -> None :
108+ """Display the unified diff either in a scratch buffer or an output panel."""
109+ difftxt = "" .join (diff )
96110
97- if difftxt == "" :
111+ if not difftxt :
98112 sublime .status_message ("No changes" )
99113 return
100114
@@ -107,88 +121,88 @@ def show_diff_output(diff, view, win, name, panel_name, buffer_setting_name):
107121 else :
108122 v = win .create_output_panel (panel_name )
109123 if view :
110- v .settings ().set (' word_wrap' , view .settings ().get (' word_wrap' ))
124+ v .settings ().set (" word_wrap" , view .settings ().get (" word_wrap" ))
111125
112- v .assign_syntax (' Packages/Diff/Diff.sublime-syntax' )
113- v .run_command (' append' , {' characters' : difftxt , ' disable_tab_translation' : True })
126+ v .assign_syntax (" Packages/Diff/Diff.sublime-syntax" )
127+ v .run_command (" append" , {" characters" : difftxt , " disable_tab_translation" : True })
114128
115129 if not use_buffer :
116- win .run_command (' show_panel' , {' panel' : f' output.{ panel_name } ' })
130+ win .run_command (" show_panel" , {" panel" : f" output.{ panel_name } " })
117131
118132
119- def get_view_from_tab_context (active_view , ** kwargs ):
120- view = active_view
121- if ' group' in kwargs and ' index' in kwargs :
122- view = view .window ().views_in_group (kwargs [' group' ])[kwargs [' index' ]]
123- return view
133+ def get_view_from_tab_context (active_view : sublime . View , ** kwargs ) -> sublime . View :
134+ """Return the view associated with the clicked tab."""
135+ if " group" in kwargs and " index" in kwargs :
136+ return active_view .window ().views_in_group (kwargs [" group" ])[kwargs [" index" ]]
137+ return active_view
124138
125139
126- def get_views_from_tab_context (active_view , ** kwargs ):
140+ def get_views_from_tab_context (active_view : sublime .View , ** kwargs ) -> List [sublime .View ]:
141+ """Return selected views, preserving right-click order."""
127142 selected_views = list (get_selected_views (active_view .window ()))
128- if 'group' in kwargs and 'index' in kwargs :
129- tab_context_view = get_view_from_tab_context (active_view , ** kwargs )
130- # if the tab which was right clicked on is selected, exclude it from the selected views and re-add it afterwards
131- # so that the order of the diff will be determined by which tab was right-clicked on
132- return [view for view in selected_views if view .id () != tab_context_view .id ()] + [tab_context_view ]
143+ if "group" in kwargs and "index" in kwargs :
144+ tab_view = get_view_from_tab_context (active_view , ** kwargs )
145+ return [v for v in selected_views if v .id () != tab_view .id ()] + [tab_view ]
133146 return selected_views
134147
135148
136- def get_selected_views (window ):
137- return filter (lambda view : view , map (lambda sheet : sheet .view (), window .selected_sheets ()))
149+ def get_selected_views (window : sublime .Window ) -> Iterator [sublime .View ]:
150+ """Yield selected views from the given window."""
151+ return filter (None , (sheet .view () for sheet in window .selected_sheets ()))
138152
139153
140- def get_name_for_view (view ) :
141- return view .file_name () or view .name () or "Unsaved view ({})" . format ( view .id ())
154+ def get_name_for_view (view : sublime . View ) -> str :
155+ return view .file_name () or view .name () or f "Unsaved view ({ view .id ()} )"
142156
143157
144- def get_lines_for_view (view ):
158+ def get_lines_for_view (view : sublime .View ) -> List [str ]:
159+ """Return the full text of a view split into lines."""
145160 return splitlines_keep_ends (view .substr (sublime .Region (0 , view .size ())))
146161
147162
148163class DiffViewsCommand (sublime_plugin .TextCommand ):
149- def run (self , edit , ** kwargs ):
164+ def run (self , edit : sublime . Edit , ** kwargs ) -> None :
150165 views = get_views_from_tab_context (self .view , ** kwargs )
151166 if len (views ) != 2 :
152167 return
153168
154- view_names = (
155- get_name_for_view (views [0 ]),
156- get_name_for_view (views [1 ])
157- )
158-
159- from_lines = get_lines_for_view (views [0 ])
160- to_lines = get_lines_for_view (views [1 ])
169+ view_names = [get_name_for_view (v ) for v in views ]
170+ from_lines , to_lines = map (get_lines_for_view , views )
161171 add_no_eol_warning_if_applicable (from_lines )
162172 add_no_eol_warning_if_applicable (to_lines )
163173
164174 diff = difflib .unified_diff (
165175 from_lines ,
166176 to_lines ,
167177 fromfile = view_names [0 ],
168- tofile = view_names [1 ]
178+ tofile = view_names [1 ],
179+ lineterm = "" ,
169180 )
170181
182+ # Try to shorten common path prefix
171183 try :
172- common_path_length = len (os .path .commonpath (view_names ))
173- if common_path_length <= 1 :
174- common_path_length = 0
175- else :
176- common_path_length += 1
177- except ValueError :
178- common_path_length = 0
179- view_names = list (map (lambda name : name [common_path_length :], view_names ))
180- show_diff_output (diff , views [0 ], views [0 ].window (), f'{ view_names [0 ]} -> { view_names [1 ]} ' , 'diff_views' , 'diff_tabs_to_buffer' )
181-
182- def is_enabled (self , ** kwargs ):
184+ common_path = Path (* Path (view_names [0 ]).parts ).parent
185+ common_prefix = str (common_path )
186+ if common_prefix and all (name .startswith (common_prefix ) for name in view_names ):
187+ view_names = [name [len (common_prefix ) + 1 :] for name in view_names ]
188+ except Exception :
189+ pass
190+
191+ show_diff_output (
192+ diff ,
193+ views [0 ],
194+ views [0 ].window (),
195+ f"{ view_names [0 ]} -> { view_names [1 ]} " ,
196+ "diff_views" ,
197+ "diff_tabs_to_buffer" ,
198+ )
199+
200+ def is_enabled (self , ** kwargs ) -> bool :
183201 return self .is_visible (** kwargs )
184202
185- def is_visible (self , ** kwargs ):
186- views = get_views_from_tab_context (self .view , ** kwargs )
187- return len (views ) == 2
203+ def is_visible (self , ** kwargs ) -> bool :
204+ return len (get_views_from_tab_context (self .view , ** kwargs )) == 2
188205
189- def description (self , ** kwargs ):
206+ def description (self , ** kwargs ) -> str :
190207 selected_views = list (get_selected_views (self .view .window ()))
191- if len (selected_views ) == 2 :
192- return 'Diff Selected Tabs...'
193-
194- return 'Diff With Current Tab...'
208+ return "Diff Selected Tabs..." if len (selected_views ) == 2 else "Diff With Current Tab..."
0 commit comments