Skip to content

Commit 4704cbb

Browse files
committed
diff: Implement diff for navigation
1 parent eea2fb7 commit 4704cbb

File tree

9 files changed

+231
-43
lines changed

9 files changed

+231
-43
lines changed

elixir/diff.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import os
12
from typing import Generator, Optional, Tuple, List
23
from pygments.formatters import HtmlFormatter
4+
from .query import Query
5+
from .web_utils import DirectoryEntry
36

47
from elixir.query import DiffEntry
58

@@ -152,3 +155,78 @@ def format_diff(filename: str, diff, code: str, code_other: str) -> Tuple[str, s
152155

153156
return pygments.highlight(code, lexer, formatter), pygments.highlight(code_other, lexer, formatter_other)
154157

158+
# Returns a list of DirectoryEntry objects with information about changes between version.
159+
# base_url: file URLs will be created by appending file path to this URL. It shouldn't end with a slash
160+
# tag: requested repository tag
161+
# tag_other: tag to diff with
162+
# path: path to the directory in the repository
163+
def diff_directory_entries(q: Query, base_url, tag: str, tag_other: str, path: str) -> list[DirectoryEntry]:
164+
dir_entries = []
165+
166+
# Fetch list of names in both directories
167+
names, names_other = {}, {}
168+
for line in q.get_dir_contents(tag, path):
169+
n = line.split(' ')
170+
names[n[1]] = n
171+
for line in q.get_dir_contents(tag_other, path):
172+
n = line.split(' ')
173+
names_other[n[1]] = n
174+
175+
# Used to sort names - directories first, files second
176+
def dir_sort(name):
177+
if name in names and names[name][0] == 'tree':
178+
return (1, name)
179+
elif name in names_other and names_other[name][0] == 'tree':
180+
return (1, name)
181+
else:
182+
return (2, name)
183+
184+
# Create a sorted list of all unique filenames from both versions
185+
all_names = set(names.keys())
186+
all_names = all_names.union(names_other.keys())
187+
all_names = sorted(all_names, key=dir_sort)
188+
189+
for name in all_names:
190+
data = names.get(name)
191+
data_other = names_other.get(name)
192+
193+
diff_cls = None
194+
195+
# Added if file only in right version
196+
if data is None and data_other is not None:
197+
type, name, size, perm, blob_id = data_other
198+
diff_cls = 'added'
199+
# Removed if file only in left version
200+
elif data_other is None and data is not None:
201+
type, name, size, perm, blob_id = data
202+
diff_cls = 'removed'
203+
# If file in both versions
204+
elif data is not None and data_other is not None:
205+
type_old, name, _, _, blob_id = data
206+
type, _, size, perm, blob_id_other = data_other
207+
# changed only if blob id is different
208+
if blob_id != blob_id_other or type_old != type:
209+
diff_cls = 'changed'
210+
else:
211+
raise Exception("name does not exist " + name)
212+
213+
file_path = f"{ path }/{ name }"
214+
215+
if type == 'tree':
216+
dir_entries.append(DirectoryEntry('tree', name, file_path,
217+
f"{ base_url }{ file_path }", None, diff_cls))
218+
elif type == 'blob':
219+
# 120000 permission means it's a symlink
220+
if perm == '120000':
221+
dir_path = path if path.endswith('/') else path + '/'
222+
link_contents = q.get_file_raw(tag, file_path)
223+
link_target_path = os.path.abspath(dir_path + link_contents)
224+
225+
dir_entries.append(DirectoryEntry('symlink', name, link_target_path,
226+
f"{ base_url }{ link_target_path }", size, diff_cls))
227+
else:
228+
dir_entries.append(DirectoryEntry('blob', name, file_path,
229+
f"{ base_url }{ file_path }", size, diff_cls))
230+
231+
return dir_entries
232+

elixir/web.py

Lines changed: 44 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
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 <mikael.bouillot@bootlin.com>
@@ -28,7 +29,8 @@
2829
import dataclasses
2930
from collections import OrderedDict, namedtuple
3031
from 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
3234
from urllib import parse
3335
import falcon
3436
import jinja2
@@ -42,7 +44,7 @@
4244
from .query import get_query
4345
from .web_utils import ProjectConverter, IdentConverter, validate_version, validate_project, validate_ident, \
4446
get_elixir_version_string, get_elixir_repo_url, RequestContext, Config, DirectoryEntry
45-
from .diff import format_diff
47+
from .diff import format_diff, diff_directory_entries
4648

4749
VERSION_CACHE_DURATION_SECONDS = 2 * 60 # 2 minutes
4850
ADD_ISSUE_LINK = "https://github.com/bootlin/elixir/issues/new"
@@ -110,7 +112,7 @@ def get_project_error_page(req, resp, exception: ElixirProjectError):
110112

111113
versions_raw = get_versions_cached(query, req.context, project)
112114
get_url_with_new_version = lambda v: stringify_source_path(project, v, '/')
113-
versions, current_version_path = get_versions(versions_raw, get_url_with_new_version, version)
115+
versions, current_version_path = get_versions(versions_raw, get_url_with_new_version, None, version)
114116

115117
if current_version_path[2] is None:
116118
# If details about current version are not available, make base links
@@ -194,6 +196,10 @@ def validate_project_and_version(ctx, project, version):
194196
def get_source_base_url(project: str, version: str) -> str:
195197
return f'/{ parse.quote(project, safe="") }/{ parse.quote(version, safe="") }/source'
196198

199+
def stringify_diff_path(project: str, version: str, version_other: str, path: str) -> str:
200+
return f'/{ parse.quote(project, safe="") }/{ parse.quote(version, safe="") }/diff/' + \
201+
f'{ parse.quote(version_other, safe="") }/{ path }'
202+
197203
# Converts ParsedSourcePath to a string with corresponding URL path
198204
def stringify_source_path(project: str, version: str, path: str) -> str:
199205
if not path.startswith('/'):
@@ -251,14 +257,19 @@ def on_get(self, req, resp, project: str, version: str, path: str):
251257

252258
query.close()
253259

260+
# Returns base url of diff pages
261+
# project and version are assumed to be unquoted
262+
def get_diff_base_url(project: str, version: str, version_other: str) -> str:
263+
return f'/{ parse.quote(project, safe="") }/{ parse.quote(version, safe="") }/diff/{ parse.quote(version_other, safe="") }'
264+
254265
# Handles source URLs without a path, ex. '/u-boot/v2023.10/source'.
255266
# Note lack of trailing slash
256267
class SourceWithoutPathResource(SourceResource):
257268
def on_get(self, req, resp, project: str, version: str):
258269
return super().on_get(req, resp, project, version, '')
259270

260271
class DiffResource:
261-
def on_get(self, req, resp, project: str, version: str, version_other: str, path: str):
272+
def on_get(self, req, resp, project: str, version: str, version_other: str, path: str = ''):
262273
project, version, query = validate_project_and_version(req.context, project, version)
263274
version_other = validate_version(parse.unquote(version_other))
264275
if version_other is None or version_other == 'latest':
@@ -432,7 +443,7 @@ def get_projects(basedir: str) -> list[ProjectEntry]:
432443

433444
# Tuple of version name and URL to chosen resource with that version
434445
# Used to render version list in the sidebar
435-
VersionEntry = namedtuple('VersionEntry', 'version, url')
446+
VersionEntry = namedtuple('VersionEntry', 'version, url, diff_url')
436447

437448
# Takes result of Query.get_versions() and prepares it for the sidebar template.
438449
# Returns an OrderedDict with version information and optionally a triple with
@@ -445,6 +456,7 @@ def get_projects(basedir: str) -> list[ProjectEntry]:
445456
# current_version: string with currently browsed version
446457
def get_versions(versions: OrderedDict[str, OrderedDict[str, str]],
447458
get_url: Callable[[str], str],
459+
get_diff_url: Optional[Callable[[str], str]],
448460
current_version: str) -> Tuple[dict[str, dict[str, list[VersionEntry]]], Tuple[str|None, str|None, str|None]]:
449461

450462
result = OrderedDict()
@@ -456,13 +468,14 @@ def get_versions(versions: OrderedDict[str, OrderedDict[str, str]],
456468
result[major] = OrderedDict()
457469
if minor not in result[major]:
458470
result[major][minor] = []
459-
result[major][minor].append(VersionEntry(v, get_url(v)))
471+
result[major][minor].append(
472+
VersionEntry(v, get_url(v), get_diff_url(v) if get_diff_url is not None else None)
473+
)
460474
if v == current_version:
461475
current_version_path = (major, minor, v)
462476

463477
return result, current_version_path
464478

465-
# Caches get_versions result in a context object
466479
def get_versions_cached(q, ctx, project):
467480
with ctx.versions_cache_lock:
468481
if project not in ctx.versions_cache:
@@ -481,9 +494,9 @@ def get_versions_cached(q, ctx, project):
481494
# project: name of the project
482495
# version: version of the project
483496
def get_layout_template_context(q: Query, ctx: RequestContext, get_url_with_new_version: Callable[[str], str],
484-
project: str, version: str) -> dict[str, Any]:
497+
get_diff_url: Optional[Callable[[str], str]], project: str, version: str) -> dict[str, Any]:
485498
versions_raw = get_versions_cached(q, ctx, project)
486-
versions, current_version_path = get_versions(versions_raw, get_url_with_new_version, version)
499+
versions, current_version_path = get_versions(versions_raw, get_url_with_new_version, get_diff_url, version)
487500

488501
return {
489502
'projects': get_projects(ctx.config.project_dir),
@@ -658,21 +671,21 @@ def get_directory_entries(q: Query, base_url, tag: str, path: str) -> list[Direc
658671
lines = q.get_dir_contents(tag, path)
659672

660673
for l in lines:
661-
type, name, size, perm = l.split(' ')
674+
type, name, size, perm, blob_id = l.split(' ')
662675
file_path = f"{ path }/{ name }"
663676

664677
if type == 'tree':
665-
dir_entries.append(DirectoryEntry('tree', name, file_path, f"{ base_url }{ file_path }", None))
678+
dir_entries.append(DirectoryEntry('tree', name, file_path, f"{ base_url }{ file_path }", None, None))
666679
elif type == 'blob':
667680
# 120000 permission means it's a symlink
668681
if perm == '120000':
669682
dir_path = path if path.endswith('/') else path + '/'
670683
link_contents = q.get_file_raw(tag, file_path)
671684
link_target_path = os.path.abspath(dir_path + link_contents)
672685

673-
dir_entries.append(DirectoryEntry('symlink', name, link_target_path, f"{ base_url }{ link_target_path }", size))
686+
dir_entries.append(DirectoryEntry('symlink', name, link_target_path, f"{ base_url }{ link_target_path }", size, None))
674687
else:
675-
dir_entries.append(DirectoryEntry('blob', name, file_path, f"{ base_url }{ file_path }", size))
688+
dir_entries.append(DirectoryEntry('blob', name, file_path, f"{ base_url }{ file_path }", size, None))
676689

677690
return dir_entries
678691

@@ -727,10 +740,11 @@ def generate_source_page(ctx: RequestContext, q: Query,
727740
title_path = f'{ path_split[-1] } - { "/".join(path_split) } - '
728741

729742
get_url_with_new_version = lambda v: stringify_source_path(project, v, path)
743+
get_diff_url = lambda v_other: stringify_diff_path(project, version, v_other, path)
730744

731745
# Create template context
732746
data = {
733-
**get_layout_template_context(q, ctx, get_url_with_new_version, project, version),
747+
**get_layout_template_context(q, ctx, get_url_with_new_version, get_diff_url, project, version),
734748

735749
'title_path': title_path,
736750
'path': path,
@@ -747,15 +761,15 @@ def generate_diff_page(ctx: RequestContext, q: Query,
747761
project: str, version: str, version_other: str, path: str) -> tuple[int, str]:
748762

749763
status = falcon.HTTP_OK
750-
source_base_url = get_source_base_url(project, version)
764+
diff_base_url = get_diff_base_url(project, version, version_other)
751765

752766
# Generate breadcrumbs
753767
path_split = path.split('/')[1:]
754768
path_temp = ''
755-
breadcrumb_links = []
769+
breadcrumb_urls = []
756770
for p in path_split:
757771
path_temp += '/'+p
758-
breadcrumb_links.append((p, f'{ source_base_url }{ path_temp }'))
772+
breadcrumb_urls.append((p, f'{ diff_base_url }{ path_temp }'))
759773

760774
type = q.get_file_type(version, path)
761775
type_other = q.get_file_type(version_other, path)
@@ -795,25 +809,18 @@ def generate_warning(type, version):
795809
warning = f'Files are the same in {version} and {version_other}.'
796810
else:
797811
missing_version = version_other if type == 'blob' else version
798-
warning = f'File does not exist or is not a file {missing_version}.'
812+
warning = f'File does not exist, or is not a file in {missing_version}. ({version} displayed)'
799813

800814
template_ctx = {
801815
'code': generate_source(q, project, version if type == 'blob' else version_other, path),
802-
'warning': warning
816+
'warning': warning,
803817
}
804818
template = ctx.jinja_env.get_template('source.html')
805819
else:
806820
raise ElixirProjectError('File not found', f'This file does not exist in {version} nor in {version_other}.',
807821
status=falcon.HTTP_NOT_FOUND,
808822
query=q, project=project, version=version,
809-
extra_template_args={'breadcrumb_links': breadcrumb_links})
810-
811-
if type_other != 'blob':
812-
raise ElixirProjectError('File not found', f'This file is not present in {version_other}.',
813-
status=falcon.HTTP_NOT_FOUND,
814-
query=q, project=project, version=version,
815-
extra_template_args={'breadcrumb_links': breadcrumb_links})
816-
823+
extra_template_args={'breadcrumb_urls': breadcrumb_urls})
817824

818825
# Create titles like this:
819826
# root path: "Linux source code (v5.5.6) - Bootlin"
@@ -827,20 +834,21 @@ def generate_warning(type, version):
827834
title_path = f'{ path_split[-1] } - { "/".join(path_split) } - '
828835

829836
get_url_with_new_version = lambda v: stringify_source_path(project, v, path)
837+
get_diff_url = lambda v_other: stringify_diff_path(project, version, v_other, path)
830838

831-
template = ctx.jinja_env.get_template('diff.html')
832-
833-
code, code_other = generate_diff(q, project, version, version_other, path)
834839
# Create template context
835840
data = {
836-
**get_layout_template_context(q, ctx, get_url_with_new_version, project, version),
841+
**get_layout_template_context(q, ctx, get_url_with_new_version, get_diff_url, project, version),
837842
**template_ctx,
838843

839-
'code': code,
840-
'code_other': code_other,
844+
'diff_mode_available': True,
845+
'diff_checked': True,
846+
'diff_exit_url': stringify_source_path(project, version, path),
847+
841848
'title_path': title_path,
842849
'path': path,
843-
'breadcrumb_links': breadcrumb_links,
850+
'breadcrumb_urls': breadcrumb_urls,
851+
'base_url': diff_base_url,
844852
}
845853

846854
return (status, template.render(data))
@@ -923,7 +931,7 @@ def generate_ident_page(ctx: RequestContext, q: Query,
923931
get_url_with_new_version = lambda v: stringify_ident_path(project, v, family, ident)
924932

925933
data = {
926-
**get_layout_template_context(q, ctx, get_url_with_new_version, project, version),
934+
**get_layout_template_context(q, ctx, get_url_with_new_version, None, project, version),
927935

928936
'searched_ident': ident,
929937
'current_family': family,
@@ -1003,6 +1011,7 @@ def get_application():
10031011
app.add_route('/{project}/{version}/ident/{ident}', IdentWithoutFamilyResource())
10041012
app.add_route('/{project}/{version}/{family}/ident/{ident}', IdentResource())
10051013
app.add_route('/{project}/{version}/diff/{version_other}/{path:path}', DiffResource())
1014+
app.add_route('/{project}/{version}/diff/{version_other}', DiffResource())
10061015

10071016
app.add_route('/acp', AutocompleteResource())
10081017
app.add_route('/api/ident/{project:project}/{ident:ident}', ApiIdentGetterResource())

elixir/web_utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,5 +88,6 @@ def convert(self, value: str) -> str|None:
8888
# path: path of the file, path to the target in case of symlinks
8989
# url: absolute URL of the file
9090
# size: int, file size in bytes, None for directories and symlinks
91-
DirectoryEntry = namedtuple('DirectoryEntry', 'type, name, path, url, size')
91+
# diff: file state in a diff - "added", "removed", "changed" or None
92+
DirectoryEntry = namedtuple('DirectoryEntry', 'type, name, path, url, size, diff')
9293

script.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ get_dir()
9595
{
9696
v=`echo $opt1 | version_rev`
9797
git ls-tree -l "$v:`denormalize $opt2`" 2>/dev/null |
98-
awk '{print $2" "$5" "$4" "$1}' |
98+
awk '{print $2" "$5" "$4" "$1" "$3}' |
9999
grep -v ' \.' |
100100
sort -t ' ' -k 1,1r -k 2,2
101101
}

0 commit comments

Comments
 (0)