11#!/usr/bin/env python3
22
3+
34# This file is part of Elixir, a source code cross-referencer.
45#
56# Copyright (C) 2017--2020 Mikaël Bouillot <[email protected] > 2829import dataclasses
2930from collections import OrderedDict , namedtuple
3031from re import search , sub
31- from typing import Any , Callable , NamedTuple , Tuple
32+ from typing import Any , Callable , NamedTuple , Optional , Tuple
33+ from difflib import SequenceMatcher
3234from urllib import parse
3335import falcon
3436import jinja2
@@ -109,7 +111,7 @@ def get_project_error_page(req, resp, exception: ElixirProjectError):
109111
110112 versions_raw = get_versions_cached (query , req .context , project )
111113 get_url_with_new_version = lambda v : stringify_source_path (project , v , '/' )
112- versions , current_version_path = get_versions (versions_raw , get_url_with_new_version , version )
114+ versions , current_version_path = get_versions (versions_raw , get_url_with_new_version , None , version )
113115
114116 if current_version_path [2 ] is None :
115117 # If details about current version are not available, make base links
@@ -193,6 +195,10 @@ def validate_project_and_version(ctx, project, version):
193195def get_source_base_url (project : str , version : str ) -> str :
194196 return f'/{ parse .quote (project , safe = "" ) } /{ parse .quote (version , safe = "" ) } /source'
195197
198+ def stringify_diff_path (project : str , version : str , version_other : str , path : str ) -> str :
199+ return f'/{ parse .quote (project , safe = "" ) } /{ parse .quote (version , safe = "" ) } /diff/' + \
200+ f'{ parse .quote (version_other , safe = "" ) } /{ path } '
201+
196202# Converts ParsedSourcePath to a string with corresponding URL path
197203def stringify_source_path (project : str , version : str , path : str ) -> str :
198204 if not path .startswith ('/' ):
@@ -250,14 +256,19 @@ def on_get(self, req, resp, project: str, version: str, path: str):
250256
251257 query .close ()
252258
259+ # Returns base url of diff pages
260+ # project and version are assumed to be unquoted
261+ def get_diff_base_url (project : str , version : str , version_other : str ) -> str :
262+ return f'/{ parse .quote (project , safe = "" ) } /{ parse .quote (version , safe = "" ) } /diff/{ parse .quote (version_other , safe = "" ) } '
263+
253264# Handles source URLs without a path, ex. '/u-boot/v2023.10/source'.
254265# Note lack of trailing slash
255266class SourceWithoutPathResource (SourceResource ):
256267 def on_get (self , req , resp , project : str , version : str ):
257268 return super ().on_get (req , resp , project , version , '' )
258269
259270class DiffResource :
260- def on_get (self , req , resp , project : str , version : str , version_other : str , path : str ):
271+ def on_get (self , req , resp , project : str , version : str , version_other : str , path : str = '' ):
261272 project , version , query = validate_project_and_version (req .context , project , version )
262273 version_other = validate_version (parse .unquote (version_other ))
263274 if version_other is None or version_other == 'latest' :
@@ -431,7 +442,7 @@ def get_projects(basedir: str) -> list[ProjectEntry]:
431442
432443# Tuple of version name and URL to chosen resource with that version
433444# Used to render version list in the sidebar
434- VersionEntry = namedtuple ('VersionEntry' , 'version, url' )
445+ VersionEntry = namedtuple ('VersionEntry' , 'version, url, diff_url ' )
435446
436447# Takes result of Query.get_versions() and prepares it for the sidebar template.
437448# Returns an OrderedDict with version information and optionally a triple with
@@ -444,6 +455,7 @@ def get_projects(basedir: str) -> list[ProjectEntry]:
444455# current_version: string with currently browsed version
445456def get_versions (versions : OrderedDict [str , OrderedDict [str , str ]],
446457 get_url : Callable [[str ], str ],
458+ get_diff_url : Optional [Callable [[str ], str ]],
447459 current_version : str ) -> Tuple [dict [str , dict [str , list [VersionEntry ]]], Tuple [str | None , str | None , str | None ]]:
448460
449461 result = OrderedDict ()
@@ -455,13 +467,14 @@ def get_versions(versions: OrderedDict[str, OrderedDict[str, str]],
455467 result [major ] = OrderedDict ()
456468 if minor not in result [major ]:
457469 result [major ][minor ] = []
458- result [major ][minor ].append (VersionEntry (v , get_url (v )))
470+ result [major ][minor ].append (
471+ VersionEntry (v , get_url (v ), get_diff_url (v ) if get_diff_url is not None else None )
472+ )
459473 if v == current_version :
460474 current_version_path = (major , minor , v )
461475
462476 return result , current_version_path
463477
464- # Caches get_versions result in a context object
465478def get_versions_cached (q , ctx , project ):
466479 with ctx .versions_cache_lock :
467480 if project not in ctx .versions_cache :
@@ -480,9 +493,9 @@ def get_versions_cached(q, ctx, project):
480493# project: name of the project
481494# version: version of the project
482495def get_layout_template_context (q : Query , ctx : RequestContext , get_url_with_new_version : Callable [[str ], str ],
483- project : str , version : str ) -> dict [str , Any ]:
496+ get_diff_url : Optional [ Callable [[ str ], str ]], project : str , version : str ) -> dict [str , Any ]:
484497 versions_raw = get_versions_cached (q , ctx , project )
485- versions , current_version_path = get_versions (versions_raw , get_url_with_new_version , version )
498+ versions , current_version_path = get_versions (versions_raw , get_url_with_new_version , get_diff_url , version )
486499
487500 return {
488501 'projects' : get_projects (ctx .config .project_dir ),
@@ -694,7 +707,7 @@ def get_ident_url(ident, ident_family=None):
694707# path: path of the file, path to the target in case of symlinks
695708# url: absolute URL of the file
696709# size: int, file size in bytes, None for directories and symlinks
697- DirectoryEntry = namedtuple ('DirectoryEntry' , 'type, name, path, url, size' )
710+ DirectoryEntry = namedtuple ('DirectoryEntry' , 'type, name, path, url, size, cls ' )
698711
699712# Returns a list of DirectoryEntry objects with information about files in a directory
700713# base_url: file URLs will be created by appending file path to this URL. It shouldn't end with a slash
@@ -705,21 +718,21 @@ def get_directory_entries(q: Query, base_url, tag: str, path: str) -> list[Direc
705718 lines = q .get_dir_contents (tag , path )
706719
707720 for l in lines :
708- type , name , size , perm = l .split (' ' )
721+ type , name , size , perm , blob_id = l .split (' ' )
709722 file_path = f"{ path } /{ name } "
710723
711724 if type == 'tree' :
712- dir_entries .append (DirectoryEntry ('tree' , name , file_path , f"{ base_url } { file_path } " , None ))
725+ dir_entries .append (DirectoryEntry ('tree' , name , file_path , f"{ base_url } { file_path } " , None , None ))
713726 elif type == 'blob' :
714727 # 120000 permission means it's a symlink
715728 if perm == '120000' :
716729 dir_path = path if path .endswith ('/' ) else path + '/'
717730 link_contents = q .get_file_raw (tag , file_path )
718731 link_target_path = os .path .abspath (dir_path + link_contents )
719732
720- dir_entries .append (DirectoryEntry ('symlink' , name , link_target_path , f"{ base_url } { link_target_path } " , size ))
733+ dir_entries .append (DirectoryEntry ('symlink' , name , link_target_path , f"{ base_url } { link_target_path } " , size , None ))
721734 else :
722- dir_entries .append (DirectoryEntry ('blob' , name , file_path , f"{ base_url } { file_path } " , size ))
735+ dir_entries .append (DirectoryEntry ('blob' , name , file_path , f"{ base_url } { file_path } " , size , None ))
723736
724737 return dir_entries
725738
@@ -774,10 +787,11 @@ def generate_source_page(ctx: RequestContext, q: Query,
774787 title_path = f'{ path_split [- 1 ] } - { "/" .join (path_split ) } - '
775788
776789 get_url_with_new_version = lambda v : stringify_source_path (project , v , path )
790+ get_diff_url = lambda v_other : stringify_diff_path (project , version , v_other , path )
777791
778792 # Create template context
779793 data = {
780- ** get_layout_template_context (q , ctx , get_url_with_new_version , project , version ),
794+ ** get_layout_template_context (q , ctx , get_url_with_new_version , get_diff_url , project , version ),
781795
782796 'title_path' : title_path ,
783797 'path' : path ,
@@ -789,20 +803,84 @@ def generate_source_page(ctx: RequestContext, q: Query,
789803
790804 return (status , template .render (data ))
791805
806+ # Returns a list of DirectoryEntry objects with information about files in a directory
807+ # base_url: file URLs will be created by appending file path to this URL. It shouldn't end with a slash
808+ # tag: requested repository tag
809+ # tag_other: tag to diff with
810+ # path: path to the directory in the repository
811+ def diff_directory_entries (q : Query , base_url , tag : str , tag_other : str , path : str ) -> list [DirectoryEntry ]:
812+ dir_entries = []
813+
814+ names , names_other = {}, {}
815+ for line in q .get_dir_contents (tag , path ):
816+ n = line .split (' ' )
817+ names [n [1 ]] = n
818+ for line in q .get_dir_contents (tag_other , path ):
819+ n = line .split (' ' )
820+ names_other [n [1 ]] = n
821+
822+ def dir_sort (name ):
823+ if name in names and names [name ][0 ] == 'tree' :
824+ return (1 , name )
825+ elif name in names_other and names_other [name ][0 ] == 'tree' :
826+ return (1 , name )
827+ else :
828+ return (2 , name )
829+
830+ all_names = set (names .keys ())
831+ all_names = all_names .union (names_other .keys ())
832+ all_names = sorted (all_names , key = dir_sort )
833+
834+ for name in all_names :
835+ data = names .get (name )
836+ data_other = names_other .get (name )
837+
838+ cls = None
839+ if data is None and data_other is not None :
840+ type , name , size , perm , blob_id = data_other
841+ cls = 'added'
842+ elif data_other is None and data is not None :
843+ type , name , size , perm , blob_id = data
844+ cls = 'removed'
845+ elif data is not None and data_other is not None :
846+ type_old , name , _ , _ , blob_id = data
847+ type , _ , size , perm , blob_id_other = data_other
848+ if blob_id != blob_id_other or type_old != type :
849+ cls = 'changed'
850+ else :
851+ raise Exception ("name does not exist " + name )
852+
853+ file_path = f"{ path } /{ name } "
854+
855+ if type == 'tree' :
856+ dir_entries .append (DirectoryEntry ('tree' , name , file_path , f"{ base_url } { file_path } " , None , cls ))
857+ elif type == 'blob' :
858+ # 120000 permission means it's a symlink
859+ if perm == '120000' :
860+ dir_path = path if path .endswith ('/' ) else path + '/'
861+ link_contents = q .get_file_raw (tag , file_path )
862+ link_target_path = os .path .abspath (dir_path + link_contents )
863+
864+ dir_entries .append (DirectoryEntry ('symlink' , name , link_target_path , f"{ base_url } { link_target_path } " , size , cls ))
865+ else :
866+ dir_entries .append (DirectoryEntry ('blob' , name , file_path , f"{ base_url } { file_path } " , size , cls ))
867+
868+ return dir_entries
869+
792870# Generates response (status code and optionally HTML) of the `diff` route
793871def generate_diff_page (ctx : RequestContext , q : Query ,
794872 project : str , version : str , version_other : str , path : str ) -> tuple [int , str ]:
795873
796874 status = falcon .HTTP_OK
797- source_base_url = get_source_base_url (project , version )
875+ diff_base_url = get_diff_base_url (project , version , version_other )
798876
799877 # Generate breadcrumbs
800878 path_split = path .split ('/' )[1 :]
801879 path_temp = ''
802- breadcrumb_links = []
880+ breadcrumb_urls = []
803881 for p in path_split :
804882 path_temp += '/' + p
805- breadcrumb_links .append ((p , f'{ source_base_url } { path_temp } ' ))
883+ breadcrumb_urls .append ((p , f'{ diff_base_url } { path_temp } ' ))
806884
807885 type = q .get_file_type (version , path )
808886 type_other = q .get_file_type (version_other , path )
@@ -842,25 +920,18 @@ def generate_warning(type, version):
842920 warning = f'Files are the same in { version } and { version_other } .'
843921 else :
844922 missing_version = version_other if type == 'blob' else version
845- warning = f'File does not exist or is not a file { missing_version } .'
923+ warning = f'File does not exist, or is not a file in { missing_version } . ( { version } displayed) '
846924
847925 template_ctx = {
848926 'code' : generate_source (q , project , version if type == 'blob' else version_other , path ),
849- 'warning' : warning
927+ 'warning' : warning ,
850928 }
851929 template = ctx .jinja_env .get_template ('source.html' )
852930 else :
853931 raise ElixirProjectError ('File not found' , f'This file does not exist in { version } nor in { version_other } .' ,
854932 status = falcon .HTTP_NOT_FOUND ,
855933 query = q , project = project , version = version ,
856- extra_template_args = {'breadcrumb_links' : breadcrumb_links })
857-
858- if type_other != 'blob' :
859- raise ElixirProjectError ('File not found' , f'This file is not present in { version_other } .' ,
860- status = falcon .HTTP_NOT_FOUND ,
861- query = q , project = project , version = version ,
862- extra_template_args = {'breadcrumb_links' : breadcrumb_links })
863-
934+ extra_template_args = {'breadcrumb_urls' : breadcrumb_urls })
864935
865936 # Create titles like this:
866937 # root path: "Linux source code (v5.5.6) - Bootlin"
@@ -874,20 +945,21 @@ def generate_warning(type, version):
874945 title_path = f'{ path_split [- 1 ] } - { "/" .join (path_split ) } - '
875946
876947 get_url_with_new_version = lambda v : stringify_source_path (project , v , path )
948+ get_diff_url = lambda v_other : stringify_diff_path (project , version , v_other , path )
877949
878- template = ctx .jinja_env .get_template ('diff.html' )
879-
880- code , code_other = generate_diff (q , project , version , version_other , path )
881950 # Create template context
882951 data = {
883- ** get_layout_template_context (q , ctx , get_url_with_new_version , project , version ),
952+ ** get_layout_template_context (q , ctx , get_url_with_new_version , get_diff_url , project , version ),
884953 ** template_ctx ,
885954
886- 'code' : code ,
887- 'code_other' : code_other ,
955+ 'diff_mode_available' : True ,
956+ 'diff_checked' : True ,
957+ 'diff_exit_url' : stringify_source_path (project , version , path ),
958+
888959 'title_path' : title_path ,
889960 'path' : path ,
890- 'breadcrumb_links' : breadcrumb_links ,
961+ 'breadcrumb_urls' : breadcrumb_urls ,
962+ 'base_url' : diff_base_url ,
891963 }
892964
893965 return (status , template .render (data ))
@@ -970,7 +1042,7 @@ def generate_ident_page(ctx: RequestContext, q: Query,
9701042 get_url_with_new_version = lambda v : stringify_ident_path (project , v , family , ident )
9711043
9721044 data = {
973- ** get_layout_template_context (q , ctx , get_url_with_new_version , project , version ),
1045+ ** get_layout_template_context (q , ctx , get_url_with_new_version , None , project , version ),
9741046
9751047 'searched_ident' : ident ,
9761048 'current_family' : family ,
@@ -1050,6 +1122,7 @@ def get_application():
10501122 app .add_route ('/{project}/{version}/ident/{ident}' , IdentWithoutFamilyResource ())
10511123 app .add_route ('/{project}/{version}/{family}/ident/{ident}' , IdentResource ())
10521124 app .add_route ('/{project}/{version}/diff/{version_other}/{path:path}' , DiffResource ())
1125+ app .add_route ('/{project}/{version}/diff/{version_other}' , DiffResource ())
10531126
10541127 app .add_route ('/acp' , AutocompleteResource ())
10551128 app .add_route ('/api/ident/{project:project}/{ident:ident}' , ApiIdentGetterResource ())
0 commit comments