11import difflib
2- import os
32import time
3+ from pathlib import Path
4+ from typing import Iterable , Iterator , List , Optional
45
56import sublime
67import sublime_plugin
78
89
9- def splitlines_keep_ends (text ):
10+ def splitlines_keep_ends (text : str ) -> List [str ]:
11+ """
12+ Split text into lines but preserve newline endings.
13+ Required for difflib to work correctly.
14+ """
1015 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-
16+ for i in range (len (lines ) - 1 ):
17+ lines [i ] += '\n '
1818 return lines
1919
2020
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
21+ def read_file_lines (fname : str | Path ) -> List [str ]:
22+ """Read a UTF-8 file and return its lines with newline endings preserved."""
23+ path = Path (fname )
24+ text = path .read_text (encoding = "utf-8" )
25+ lines = splitlines_keep_ends (text )
2826 add_no_eol_warning_if_applicable (lines )
29-
3027 return lines
3128
3229
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
30+ def add_no_eol_warning_if_applicable (lines : List [str ]) -> None :
31+ """
32+ Append a note if file doesn't end with newline.
33+ difflib misbehaves otherwise.
34+ """
35+ if lines and lines [- 1 ] and not lines [- 1 ].endswith ('\n ' ):
3736 lines [- 1 ] += '\n \\ No newline at end of file\n '
3837
3938
4039class DiffFilesCommand (sublime_plugin .WindowCommand ):
41-
42- def run (self , files ):
40+ def run (self , files : List [str ]) -> None :
4341 if len (files ) != 2 :
4442 return
4543
@@ -50,23 +48,30 @@ def run(self, files):
5048 sublime .status_message ("Diff only works with UTF-8 files" )
5149 return
5250
53- adate = time .ctime (os .stat (files [1 ]).st_mtime )
54- bdate = time .ctime (os .stat (files [0 ]).st_mtime )
51+ a_path , b_path = Path (files [1 ]), Path (files [0 ])
52+ adate = time .ctime (a_path .stat ().st_mtime )
53+ bdate = time .ctime (b_path .stat ().st_mtime )
5554
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' )
55+ diff = difflib .unified_diff (
56+ a , b , files [1 ], files [0 ], adate , bdate , lineterm = ""
57+ )
58+ show_diff_output (
59+ diff ,
60+ None ,
61+ self .window ,
62+ f"{ a_path .name } -> { b_path .name } " ,
63+ "diff_files" ,
64+ "diff_files_to_buffer" ,
65+ )
5866
59- def is_visible (self , files ) :
67+ def is_visible (self , files : List [ str ]) -> bool :
6068 return len (files ) == 2
6169
6270
6371class DiffChangesCommand (sublime_plugin .TextCommand ):
64-
65- def run (self , edit ):
66-
72+ def run (self , edit : sublime .Edit ) -> None :
6773 fname = self .view .file_name ()
68-
69- if not fname or not os .path .exists (fname ):
74+ if not fname or not Path (fname ).exists ():
7075 sublime .status_message ("Unable to diff changes because the file does not exist" )
7176 return
7277
@@ -77,24 +82,31 @@ def run(self, edit):
7782 return
7883
7984 b = get_lines_for_view (self .view )
80-
8185 add_no_eol_warning_if_applicable (b )
8286
83- adate = time .ctime (os .stat (fname ).st_mtime )
87+ adate = time .ctime (Path ( fname ) .stat ().st_mtime )
8488 bdate = time .ctime ()
8589
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' )
90+ diff = difflib .unified_diff (a , b , fname , fname , adate , bdate , lineterm = "" )
91+ name = f "Unsaved Changes: { Path (fname ). name } "
92+ show_diff_output (diff , self .view , self .view .window (), name , " unsaved_changes" , " diff_changes_to_buffer" )
8993
90- def is_enabled (self ):
94+ def is_enabled (self ) -> bool :
9195 return self .view .is_dirty () and self .view .file_name () is not None
9296
9397
94- def show_diff_output (diff , view , win , name , panel_name , buffer_setting_name ):
95- difftxt = u"" .join (line for line in diff )
98+ def show_diff_output (
99+ diff : Iterable [str ],
100+ view : Optional [sublime .View ],
101+ win : sublime .Window ,
102+ name : str ,
103+ panel_name : str ,
104+ buffer_setting_name : str ,
105+ ) -> None :
106+ """Display the unified diff either in a scratch buffer or an output panel."""
107+ difftxt = "" .join (diff )
96108
97- if difftxt == "" :
109+ if not difftxt :
98110 sublime .status_message ("No changes" )
99111 return
100112
@@ -107,88 +119,88 @@ def show_diff_output(diff, view, win, name, panel_name, buffer_setting_name):
107119 else :
108120 v = win .create_output_panel (panel_name )
109121 if view :
110- v .settings ().set (' word_wrap' , view .settings ().get (' word_wrap' ))
122+ v .settings ().set (" word_wrap" , view .settings ().get (" word_wrap" ))
111123
112- v .assign_syntax (' Packages/Diff/Diff.sublime-syntax' )
113- v .run_command (' append' , {' characters' : difftxt , ' disable_tab_translation' : True })
124+ v .assign_syntax (" Packages/Diff/Diff.sublime-syntax" )
125+ v .run_command (" append" , {" characters" : difftxt , " disable_tab_translation" : True })
114126
115127 if not use_buffer :
116- win .run_command (' show_panel' , {' panel' : f' output.{ panel_name } ' })
128+ win .run_command (" show_panel" , {" panel" : f" output.{ panel_name } " })
117129
118130
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
131+ def get_view_from_tab_context (active_view : sublime . View , ** kwargs ) -> sublime . View :
132+ """Return the view associated with the clicked tab."""
133+ if " group" in kwargs and " index" in kwargs :
134+ return active_view .window ().views_in_group (kwargs [" group" ])[kwargs [" index" ]]
135+ return active_view
124136
125137
126- def get_views_from_tab_context (active_view , ** kwargs ):
138+ def get_views_from_tab_context (active_view : sublime .View , ** kwargs ) -> List [sublime .View ]:
139+ """Return selected views, preserving right-click order."""
127140 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 ]
141+ if "group" in kwargs and "index" in kwargs :
142+ tab_view = get_view_from_tab_context (active_view , ** kwargs )
143+ return [v for v in selected_views if v .id () != tab_view .id ()] + [tab_view ]
133144 return selected_views
134145
135146
136- def get_selected_views (window ):
137- return filter (lambda view : view , map (lambda sheet : sheet .view (), window .selected_sheets ()))
147+ def get_selected_views (window : sublime .Window ) -> Iterator [sublime .View ]:
148+ """Yield selected views from the given window."""
149+ return filter (None , (sheet .view () for sheet in window .selected_sheets ()))
138150
139151
140- def get_name_for_view (view ) :
141- return view .file_name () or view .name () or "Unsaved view ({})" . format ( view .id ())
152+ def get_name_for_view (view : sublime . View ) -> str :
153+ return view .file_name () or view .name () or f "Unsaved view ({ view .id ()} )"
142154
143155
144- def get_lines_for_view (view ):
156+ def get_lines_for_view (view : sublime .View ) -> List [str ]:
157+ """Return the full text of a view split into lines."""
145158 return splitlines_keep_ends (view .substr (sublime .Region (0 , view .size ())))
146159
147160
148161class DiffViewsCommand (sublime_plugin .TextCommand ):
149- def run (self , edit , ** kwargs ):
162+ def run (self , edit : sublime . Edit , ** kwargs ) -> None :
150163 views = get_views_from_tab_context (self .view , ** kwargs )
151164 if len (views ) != 2 :
152165 return
153166
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 ])
167+ view_names = [get_name_for_view (v ) for v in views ]
168+ from_lines , to_lines = map (get_lines_for_view , views )
161169 add_no_eol_warning_if_applicable (from_lines )
162170 add_no_eol_warning_if_applicable (to_lines )
163171
164172 diff = difflib .unified_diff (
165173 from_lines ,
166174 to_lines ,
167175 fromfile = view_names [0 ],
168- tofile = view_names [1 ]
176+ tofile = view_names [1 ],
177+ lineterm = "" ,
169178 )
170179
180+ # Try to shorten common path prefix
171181 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 ):
182+ common_path = Path (* Path (view_names [0 ]).parts ).parent
183+ common_prefix = str (common_path )
184+ if common_prefix and all (name .startswith (common_prefix ) for name in view_names ):
185+ view_names = [name [len (common_prefix ) + 1 :] for name in view_names ]
186+ except Exception :
187+ pass
188+
189+ show_diff_output (
190+ diff ,
191+ views [0 ],
192+ views [0 ].window (),
193+ f"{ view_names [0 ]} -> { view_names [1 ]} " ,
194+ "diff_views" ,
195+ "diff_tabs_to_buffer" ,
196+ )
197+
198+ def is_enabled (self , ** kwargs ) -> bool :
183199 return self .is_visible (** kwargs )
184200
185- def is_visible (self , ** kwargs ):
186- views = get_views_from_tab_context (self .view , ** kwargs )
187- return len (views ) == 2
201+ def is_visible (self , ** kwargs ) -> bool :
202+ return len (get_views_from_tab_context (self .view , ** kwargs )) == 2
188203
189- def description (self , ** kwargs ):
204+ def description (self , ** kwargs ) -> str :
190205 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...'
206+ return "Diff Selected Tabs..." if len (selected_views ) == 2 else "Diff With Current Tab..."
0 commit comments