FossilRepo
Add global search bar, file blame, keyboard shortcut Global search: - Search icon in top nav bar — click to expand search field - Keyboard shortcut: press / to open search (like GitHub) - Searches against current project's checkins, tickets, wiki File blame: - New /fossil/code/blame/<path> view using `fossil blame` CLI - Shows commit hash, user, date for each line - Hashes and usernames clickable - "Blame" button on code file viewer alongside History and Raw - Uses temporary checkout (created/cleaned per request) Code file viewer: - Now has Source | Rendered | Blame | History | Raw buttons
Commit
711e261845fb8c8eeff9d23d69e6019da97ac6f700dd208647ff01164e7b6662
Parent
6140f5bde8f7375…
7 files changed
+51
+1
+32
+8
+12
+1
+15
+51
| --- fossil/cli.py | ||
| +++ fossil/cli.py | ||
| @@ -48,10 +48,61 @@ | ||
| 48 | 48 | if result.returncode == 0: |
| 49 | 49 | return result.stdout |
| 50 | 50 | except (FileNotFoundError, subprocess.TimeoutExpired): |
| 51 | 51 | pass |
| 52 | 52 | return "" |
| 53 | + | |
| 54 | + def blame(self, repo_path: Path, filename: str) -> list[dict]: | |
| 55 | + """Run fossil blame on a file. Returns [{user, uuid, line_num, text}]. | |
| 56 | + | |
| 57 | + Requires creating a temp checkout since blame needs an open checkout. | |
| 58 | + """ | |
| 59 | + import tempfile | |
| 60 | + | |
| 61 | + lines = [] | |
| 62 | + tmpdir = tempfile.mkdtemp(prefix="fossilrepo-blame-") | |
| 63 | + try: | |
| 64 | + # Open a checkout in the temp dir | |
| 65 | + subprocess.run( | |
| 66 | + [self.binary, "open", str(repo_path), "--workdir", tmpdir], | |
| 67 | + capture_output=True, | |
| 68 | + text=True, | |
| 69 | + timeout=30, | |
| 70 | + cwd=tmpdir, | |
| 71 | + ) | |
| 72 | + # Run blame | |
| 73 | + result = subprocess.run( | |
| 74 | + [self.binary, "blame", filename], | |
| 75 | + capture_output=True, | |
| 76 | + text=True, | |
| 77 | + timeout=30, | |
| 78 | + cwd=tmpdir, | |
| 79 | + ) | |
| 80 | + if result.returncode == 0: | |
| 81 | + import re | |
| 82 | + | |
| 83 | + for line in result.stdout.splitlines(): | |
| 84 | + # Format: "hash date user: code" | |
| 85 | + m = re.match(r"([0-9a-f]+)\s+(\S+)\s+([^:]+):\s?(.*)", line) | |
| 86 | + if m: | |
| 87 | + lines.append( | |
| 88 | + { | |
| 89 | + "uuid": m.group(1), | |
| 90 | + "date": m.group(2), | |
| 91 | + "user": m.group(3).strip(), | |
| 92 | + "text": m.group(4), | |
| 93 | + } | |
| 94 | + ) | |
| 95 | + # Close checkout | |
| 96 | + subprocess.run([self.binary, "close", "--force"], capture_output=True, cwd=tmpdir, timeout=10) | |
| 97 | + except Exception: | |
| 98 | + pass | |
| 99 | + finally: | |
| 100 | + import shutil | |
| 101 | + | |
| 102 | + shutil.rmtree(tmpdir, ignore_errors=True) | |
| 103 | + return lines | |
| 53 | 104 | |
| 54 | 105 | def wiki_commit(self, repo_path: Path, page_name: str, content: str, user: str = "") -> bool: |
| 55 | 106 | """Create or update a wiki page. Pipes content to fossil wiki commit.""" |
| 56 | 107 | cmd = [self.binary, "wiki", "commit", page_name, "-R", str(repo_path)] |
| 57 | 108 | if user: |
| 58 | 109 |
| --- fossil/cli.py | |
| +++ fossil/cli.py | |
| @@ -48,10 +48,61 @@ | |
| 48 | if result.returncode == 0: |
| 49 | return result.stdout |
| 50 | except (FileNotFoundError, subprocess.TimeoutExpired): |
| 51 | pass |
| 52 | return "" |
| 53 | |
| 54 | def wiki_commit(self, repo_path: Path, page_name: str, content: str, user: str = "") -> bool: |
| 55 | """Create or update a wiki page. Pipes content to fossil wiki commit.""" |
| 56 | cmd = [self.binary, "wiki", "commit", page_name, "-R", str(repo_path)] |
| 57 | if user: |
| 58 |
| --- fossil/cli.py | |
| +++ fossil/cli.py | |
| @@ -48,10 +48,61 @@ | |
| 48 | if result.returncode == 0: |
| 49 | return result.stdout |
| 50 | except (FileNotFoundError, subprocess.TimeoutExpired): |
| 51 | pass |
| 52 | return "" |
| 53 | |
| 54 | def blame(self, repo_path: Path, filename: str) -> list[dict]: |
| 55 | """Run fossil blame on a file. Returns [{user, uuid, line_num, text}]. |
| 56 | |
| 57 | Requires creating a temp checkout since blame needs an open checkout. |
| 58 | """ |
| 59 | import tempfile |
| 60 | |
| 61 | lines = [] |
| 62 | tmpdir = tempfile.mkdtemp(prefix="fossilrepo-blame-") |
| 63 | try: |
| 64 | # Open a checkout in the temp dir |
| 65 | subprocess.run( |
| 66 | [self.binary, "open", str(repo_path), "--workdir", tmpdir], |
| 67 | capture_output=True, |
| 68 | text=True, |
| 69 | timeout=30, |
| 70 | cwd=tmpdir, |
| 71 | ) |
| 72 | # Run blame |
| 73 | result = subprocess.run( |
| 74 | [self.binary, "blame", filename], |
| 75 | capture_output=True, |
| 76 | text=True, |
| 77 | timeout=30, |
| 78 | cwd=tmpdir, |
| 79 | ) |
| 80 | if result.returncode == 0: |
| 81 | import re |
| 82 | |
| 83 | for line in result.stdout.splitlines(): |
| 84 | # Format: "hash date user: code" |
| 85 | m = re.match(r"([0-9a-f]+)\s+(\S+)\s+([^:]+):\s?(.*)", line) |
| 86 | if m: |
| 87 | lines.append( |
| 88 | { |
| 89 | "uuid": m.group(1), |
| 90 | "date": m.group(2), |
| 91 | "user": m.group(3).strip(), |
| 92 | "text": m.group(4), |
| 93 | } |
| 94 | ) |
| 95 | # Close checkout |
| 96 | subprocess.run([self.binary, "close", "--force"], capture_output=True, cwd=tmpdir, timeout=10) |
| 97 | except Exception: |
| 98 | pass |
| 99 | finally: |
| 100 | import shutil |
| 101 | |
| 102 | shutil.rmtree(tmpdir, ignore_errors=True) |
| 103 | return lines |
| 104 | |
| 105 | def wiki_commit(self, repo_path: Path, page_name: str, content: str, user: str = "") -> bool: |
| 106 | """Create or update a wiki page. Pipes content to fossil wiki commit.""" |
| 107 | cmd = [self.binary, "wiki", "commit", page_name, "-R", str(repo_path)] |
| 108 | if user: |
| 109 |
+1
| --- fossil/urls.py | ||
| +++ fossil/urls.py | ||
| @@ -23,9 +23,10 @@ | ||
| 23 | 23 | path("branches/", views.branch_list, name="branches"), |
| 24 | 24 | path("tags/", views.tag_list, name="tags"), |
| 25 | 25 | path("search/", views.search, name="search"), |
| 26 | 26 | path("stats/", views.repo_stats, name="stats"), |
| 27 | 27 | path("code/raw/<path:filepath>", views.code_raw, name="code_raw"), |
| 28 | + path("code/blame/<path:filepath>", views.code_blame, name="code_blame"), | |
| 28 | 29 | path("code/history/<path:filepath>", views.file_history, name="file_history"), |
| 29 | 30 | path("docs/", views.fossil_docs, name="docs"), |
| 30 | 31 | path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"), |
| 31 | 32 | ] |
| 32 | 33 |
| --- fossil/urls.py | |
| +++ fossil/urls.py | |
| @@ -23,9 +23,10 @@ | |
| 23 | path("branches/", views.branch_list, name="branches"), |
| 24 | path("tags/", views.tag_list, name="tags"), |
| 25 | path("search/", views.search, name="search"), |
| 26 | path("stats/", views.repo_stats, name="stats"), |
| 27 | path("code/raw/<path:filepath>", views.code_raw, name="code_raw"), |
| 28 | path("code/history/<path:filepath>", views.file_history, name="file_history"), |
| 29 | path("docs/", views.fossil_docs, name="docs"), |
| 30 | path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"), |
| 31 | ] |
| 32 |
| --- fossil/urls.py | |
| +++ fossil/urls.py | |
| @@ -23,9 +23,10 @@ | |
| 23 | path("branches/", views.branch_list, name="branches"), |
| 24 | path("tags/", views.tag_list, name="tags"), |
| 25 | path("search/", views.search, name="search"), |
| 26 | path("stats/", views.repo_stats, name="stats"), |
| 27 | path("code/raw/<path:filepath>", views.code_raw, name="code_raw"), |
| 28 | path("code/blame/<path:filepath>", views.code_blame, name="code_blame"), |
| 29 | path("code/history/<path:filepath>", views.file_history, name="file_history"), |
| 30 | path("docs/", views.fossil_docs, name="docs"), |
| 31 | path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"), |
| 32 | ] |
| 33 |
+32
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -980,10 +980,42 @@ | ||
| 980 | 980 | filename = filepath.split("/")[-1] |
| 981 | 981 | response = DjHttpResponse(content_bytes, content_type="application/octet-stream") |
| 982 | 982 | response["Content-Disposition"] = f'attachment; filename="{filename}"' |
| 983 | 983 | return response |
| 984 | 984 | |
| 985 | + | |
| 986 | +# --- File Blame --- | |
| 987 | + | |
| 988 | + | |
| 989 | +@login_required | |
| 990 | +def code_blame(request, slug, filepath): | |
| 991 | + P.PROJECT_VIEW.check(request.user) | |
| 992 | + project, fossil_repo, reader = _get_repo_and_reader(slug) | |
| 993 | + | |
| 994 | + from fossil.cli import FossilCLI | |
| 995 | + | |
| 996 | + cli = FossilCLI() | |
| 997 | + blame_lines = [] | |
| 998 | + if cli.is_available(): | |
| 999 | + blame_lines = cli.blame(fossil_repo.full_path, filepath) | |
| 1000 | + | |
| 1001 | + parts = filepath.split("/") | |
| 1002 | + file_breadcrumbs = [{"name": p, "path": "/".join(parts[: i + 1])} for i, p in enumerate(parts)] | |
| 1003 | + | |
| 1004 | + return render( | |
| 1005 | + request, | |
| 1006 | + "fossil/code_blame.html", | |
| 1007 | + { | |
| 1008 | + "project": project, | |
| 1009 | + "filepath": filepath, | |
| 1010 | + "file_breadcrumbs": file_breadcrumbs, | |
| 1011 | + "blame_lines": blame_lines, | |
| 1012 | + "line_count": len(blame_lines), | |
| 1013 | + "active_tab": "code", | |
| 1014 | + }, | |
| 1015 | + ) | |
| 1016 | + | |
| 985 | 1017 | |
| 986 | 1018 | # --- Repository Statistics --- |
| 987 | 1019 | |
| 988 | 1020 | |
| 989 | 1021 | @login_required |
| 990 | 1022 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -980,10 +980,42 @@ | |
| 980 | filename = filepath.split("/")[-1] |
| 981 | response = DjHttpResponse(content_bytes, content_type="application/octet-stream") |
| 982 | response["Content-Disposition"] = f'attachment; filename="{filename}"' |
| 983 | return response |
| 984 | |
| 985 | |
| 986 | # --- Repository Statistics --- |
| 987 | |
| 988 | |
| 989 | @login_required |
| 990 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -980,10 +980,42 @@ | |
| 980 | filename = filepath.split("/")[-1] |
| 981 | response = DjHttpResponse(content_bytes, content_type="application/octet-stream") |
| 982 | response["Content-Disposition"] = f'attachment; filename="{filename}"' |
| 983 | return response |
| 984 | |
| 985 | |
| 986 | # --- File Blame --- |
| 987 | |
| 988 | |
| 989 | @login_required |
| 990 | def code_blame(request, slug, filepath): |
| 991 | P.PROJECT_VIEW.check(request.user) |
| 992 | project, fossil_repo, reader = _get_repo_and_reader(slug) |
| 993 | |
| 994 | from fossil.cli import FossilCLI |
| 995 | |
| 996 | cli = FossilCLI() |
| 997 | blame_lines = [] |
| 998 | if cli.is_available(): |
| 999 | blame_lines = cli.blame(fossil_repo.full_path, filepath) |
| 1000 | |
| 1001 | parts = filepath.split("/") |
| 1002 | file_breadcrumbs = [{"name": p, "path": "/".join(parts[: i + 1])} for i, p in enumerate(parts)] |
| 1003 | |
| 1004 | return render( |
| 1005 | request, |
| 1006 | "fossil/code_blame.html", |
| 1007 | { |
| 1008 | "project": project, |
| 1009 | "filepath": filepath, |
| 1010 | "file_breadcrumbs": file_breadcrumbs, |
| 1011 | "blame_lines": blame_lines, |
| 1012 | "line_count": len(blame_lines), |
| 1013 | "active_tab": "code", |
| 1014 | }, |
| 1015 | ) |
| 1016 | |
| 1017 | |
| 1018 | # --- Repository Statistics --- |
| 1019 | |
| 1020 | |
| 1021 | @login_required |
| 1022 |
+8
| --- templates/base.html | ||
| +++ templates/base.html | ||
| @@ -110,10 +110,18 @@ | ||
| 110 | 110 | <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script> |
| 111 | 111 | <script> |
| 112 | 112 | document.body.addEventListener('htmx:configRequest', function(event) { |
| 113 | 113 | var token = document.querySelector('meta[name="csrf-token"]'); |
| 114 | 114 | if (token) { event.detail.headers['X-CSRFToken'] = token.content; } |
| 115 | + }); | |
| 116 | + // Keyboard shortcut: / to open search | |
| 117 | + document.addEventListener('keydown', function(e) { | |
| 118 | + if (e.key === '/' && !e.ctrlKey && !e.metaKey && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') { | |
| 119 | + e.preventDefault(); | |
| 120 | + document.querySelector('[x-ref="searchInput"]')?.closest('[x-data]')?.__x?.$data && (document.querySelector('[x-ref="searchInput"]').closest('[x-data]').__x.$data.open = true); | |
| 121 | + setTimeout(() => document.querySelector('[x-ref="searchInput"]')?.focus(), 100); | |
| 122 | + } | |
| 115 | 123 | }); |
| 116 | 124 | </script> |
| 117 | 125 | </head> |
| 118 | 126 | <body class="h-full bg-gray-950 text-gray-100" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'> |
| 119 | 127 | <div class="min-h-full flex flex-col"> |
| 120 | 128 | |
| 121 | 129 | ADDED templates/fossil/code_blame.html |
| --- templates/base.html | |
| +++ templates/base.html | |
| @@ -110,10 +110,18 @@ | |
| 110 | <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script> |
| 111 | <script> |
| 112 | document.body.addEventListener('htmx:configRequest', function(event) { |
| 113 | var token = document.querySelector('meta[name="csrf-token"]'); |
| 114 | if (token) { event.detail.headers['X-CSRFToken'] = token.content; } |
| 115 | }); |
| 116 | </script> |
| 117 | </head> |
| 118 | <body class="h-full bg-gray-950 text-gray-100" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'> |
| 119 | <div class="min-h-full flex flex-col"> |
| 120 | |
| 121 | DDED templates/fossil/code_blame.html |
| --- templates/base.html | |
| +++ templates/base.html | |
| @@ -110,10 +110,18 @@ | |
| 110 | <script defer src="https://unpkg.com/[email protected]/dist/cdn.min.js"></script> |
| 111 | <script> |
| 112 | document.body.addEventListener('htmx:configRequest', function(event) { |
| 113 | var token = document.querySelector('meta[name="csrf-token"]'); |
| 114 | if (token) { event.detail.headers['X-CSRFToken'] = token.content; } |
| 115 | }); |
| 116 | // Keyboard shortcut: / to open search |
| 117 | document.addEventListener('keydown', function(e) { |
| 118 | if (e.key === '/' && !e.ctrlKey && !e.metaKey && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') { |
| 119 | e.preventDefault(); |
| 120 | document.querySelector('[x-ref="searchInput"]')?.closest('[x-data]')?.__x?.$data && (document.querySelector('[x-ref="searchInput"]').closest('[x-data]').__x.$data.open = true); |
| 121 | setTimeout(() => document.querySelector('[x-ref="searchInput"]')?.focus(), 100); |
| 122 | } |
| 123 | }); |
| 124 | </script> |
| 125 | </head> |
| 126 | <body class="h-full bg-gray-950 text-gray-100" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'> |
| 127 | <div class="min-h-full flex flex-col"> |
| 128 | |
| 129 | DDED templates/fossil/code_blame.html |
| --- a/templates/fossil/code_blame.html | ||
| +++ b/templates/fossil/code_blame.html | ||
| @@ -0,0 +1,12 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% ay-100 mb-2">{{ project.name }}</h1> | |
| 3 | +{% include "fossil/_project_nav.html" %} | |
| 4 | + | |
| 5 | +<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> | |
| 6 | + <div class="px-4 py-3 border-b border-gray-700 flex er-gray-700 flex flex-wrap i" {% endfoass="flex items-centeay-100 mb-2">{{il:code' slug=project.slug %}" class="text-brand-light hover:text-brand">{{ project.sluggray-700"> | |
| 7 | + <div class=> | |
| 8 | + {% if forloop.lacolor: inherit;ast %} | |
| 9 | + <span class="text-gray-200">{{ crumb.name }}</span>lse %} | |
| 10 | + <a href="{% url 'fossil:code_dir' slug=project.slug dirpath=crumb.path %}" class="text-brand-light hover:text-brand">{{ crumb.name }}</a> | |
| 11 | + {% endif %} | |
| 12 | + |
| --- a/templates/fossil/code_blame.html | |
| +++ b/templates/fossil/code_blame.html | |
| @@ -0,0 +1,12 @@ | |
| --- a/templates/fossil/code_blame.html | |
| +++ b/templates/fossil/code_blame.html | |
| @@ -0,0 +1,12 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% ay-100 mb-2">{{ project.name }}</h1> |
| 3 | {% include "fossil/_project_nav.html" %} |
| 4 | |
| 5 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 6 | <div class="px-4 py-3 border-b border-gray-700 flex er-gray-700 flex flex-wrap i" {% endfoass="flex items-centeay-100 mb-2">{{il:code' slug=project.slug %}" class="text-brand-light hover:text-brand">{{ project.sluggray-700"> |
| 7 | <div class=> |
| 8 | {% if forloop.lacolor: inherit;ast %} |
| 9 | <span class="text-gray-200">{{ crumb.name }}</span>lse %} |
| 10 | <a href="{% url 'fossil:code_dir' slug=project.slug dirpath=crumb.path %}" class="text-brand-light hover:text-brand">{{ crumb.name }}</a> |
| 11 | {% endif %} |
| 12 |
| --- templates/fossil/code_file.html | ||
| +++ templates/fossil/code_file.html | ||
| @@ -62,10 +62,11 @@ | ||
| 62 | 62 | <div class="flex items-center gap-1 text-xs"> |
| 63 | 63 | <a href="?mode=source" class="px-2 py-1 rounded {% if view_mode == 'source' %}bg-brand text-white{% else %}text-gray-500 hover:text-white{% endif %}">Source</a> |
| 64 | 64 | <a href="?mode=rendered" class="px-2 py-1 rounded {% if view_mode == 'rendered' %}bg-brand text-white{% else %}text-gray-500 hover:text-white{% endif %}">Rendered</a> |
| 65 | 65 | </div> |
| 66 | 66 | {% endif %} |
| 67 | + <a href="{% url 'fossil:code_blame' slug=project.slug filepath=filepath %}" class="px-2 py-1 text-xs text-gray-500 hover:text-brand-light rounded hover:bg-gray-700">Blame</a> | |
| 67 | 68 | <a href="{% url 'fossil:file_history' slug=project.slug filepath=filepath %}" class="px-2 py-1 text-xs text-gray-500 hover:text-brand-light rounded hover:bg-gray-700">History</a> |
| 68 | 69 | <a href="{% url 'fossil:code_raw' slug=project.slug filepath=filepath %}" class="px-2 py-1 text-xs text-gray-500 hover:text-brand-light rounded hover:bg-gray-700">Raw</a> |
| 69 | 70 | {% if not is_binary %} |
| 70 | 71 | <span class="text-xs text-gray-500">{{ line_count }} line{{ line_count|pluralize }}</span> |
| 71 | 72 | {% endif %} |
| 72 | 73 |
| --- templates/fossil/code_file.html | |
| +++ templates/fossil/code_file.html | |
| @@ -62,10 +62,11 @@ | |
| 62 | <div class="flex items-center gap-1 text-xs"> |
| 63 | <a href="?mode=source" class="px-2 py-1 rounded {% if view_mode == 'source' %}bg-brand text-white{% else %}text-gray-500 hover:text-white{% endif %}">Source</a> |
| 64 | <a href="?mode=rendered" class="px-2 py-1 rounded {% if view_mode == 'rendered' %}bg-brand text-white{% else %}text-gray-500 hover:text-white{% endif %}">Rendered</a> |
| 65 | </div> |
| 66 | {% endif %} |
| 67 | <a href="{% url 'fossil:file_history' slug=project.slug filepath=filepath %}" class="px-2 py-1 text-xs text-gray-500 hover:text-brand-light rounded hover:bg-gray-700">History</a> |
| 68 | <a href="{% url 'fossil:code_raw' slug=project.slug filepath=filepath %}" class="px-2 py-1 text-xs text-gray-500 hover:text-brand-light rounded hover:bg-gray-700">Raw</a> |
| 69 | {% if not is_binary %} |
| 70 | <span class="text-xs text-gray-500">{{ line_count }} line{{ line_count|pluralize }}</span> |
| 71 | {% endif %} |
| 72 |
| --- templates/fossil/code_file.html | |
| +++ templates/fossil/code_file.html | |
| @@ -62,10 +62,11 @@ | |
| 62 | <div class="flex items-center gap-1 text-xs"> |
| 63 | <a href="?mode=source" class="px-2 py-1 rounded {% if view_mode == 'source' %}bg-brand text-white{% else %}text-gray-500 hover:text-white{% endif %}">Source</a> |
| 64 | <a href="?mode=rendered" class="px-2 py-1 rounded {% if view_mode == 'rendered' %}bg-brand text-white{% else %}text-gray-500 hover:text-white{% endif %}">Rendered</a> |
| 65 | </div> |
| 66 | {% endif %} |
| 67 | <a href="{% url 'fossil:code_blame' slug=project.slug filepath=filepath %}" class="px-2 py-1 text-xs text-gray-500 hover:text-brand-light rounded hover:bg-gray-700">Blame</a> |
| 68 | <a href="{% url 'fossil:file_history' slug=project.slug filepath=filepath %}" class="px-2 py-1 text-xs text-gray-500 hover:text-brand-light rounded hover:bg-gray-700">History</a> |
| 69 | <a href="{% url 'fossil:code_raw' slug=project.slug filepath=filepath %}" class="px-2 py-1 text-xs text-gray-500 hover:text-brand-light rounded hover:bg-gray-700">Raw</a> |
| 70 | {% if not is_binary %} |
| 71 | <span class="text-xs text-gray-500">{{ line_count }} line{{ line_count|pluralize }}</span> |
| 72 | {% endif %} |
| 73 |
| --- templates/includes/nav.html | ||
| +++ templates/includes/nav.html | ||
| @@ -6,10 +6,25 @@ | ||
| 6 | 6 | <a href="{% url 'dashboard' %}" class="flex-shrink-0"> |
| 7 | 7 | <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-7 w-auto"> |
| 8 | 8 | </a> |
| 9 | 9 | </div> |
| 10 | 10 | <div class="flex items-center gap-3"> |
| 11 | + <!-- Quick search --> | |
| 12 | + <div x-data="{ open: false }" class="relative"> | |
| 13 | + <button @click="open = !open; $nextTick(() => $refs.searchInput?.focus())" class="rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800" title="Search (/)"> | |
| 14 | + <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> | |
| 15 | + <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" /> | |
| 16 | + </svg> | |
| 17 | + </button> | |
| 18 | + <div x-show="open" @click.outside="open = false" @keydown.escape.window="open = false" x-transition | |
| 19 | + class="absolute right-0 z-20 mt-2 w-80 rounded-lg bg-gray-800 shadow-lg ring-1 ring-gray-700 p-3"> | |
| 20 | + <form method="get" action="{% if request.resolver_match.kwargs.slug %}/projects/{{ request.resolver_match.kwargs.slug }}/fossil/search/{% else %}/projects/fossil-scm/fossil/search/{% endif %}"> | |
| 21 | + <input type="text" name="q" x-ref="searchInput" placeholder="Search checkins, tickets, wiki..." | |
| 22 | + class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand"> | |
| 23 | + </form> | |
| 24 | + </div> | |
| 25 | + </div> | |
| 11 | 26 | <!-- Theme toggle --> |
| 12 | 27 | <button x-data="{ dark: document.documentElement.classList.contains('dark') }" |
| 13 | 28 | @click="dark = !dark; document.documentElement.classList.toggle('dark'); localStorage.setItem('theme', dark ? 'dark' : 'light')" |
| 14 | 29 | class="rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800" |
| 15 | 30 | title="Toggle theme"> |
| 16 | 31 |
| --- templates/includes/nav.html | |
| +++ templates/includes/nav.html | |
| @@ -6,10 +6,25 @@ | |
| 6 | <a href="{% url 'dashboard' %}" class="flex-shrink-0"> |
| 7 | <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-7 w-auto"> |
| 8 | </a> |
| 9 | </div> |
| 10 | <div class="flex items-center gap-3"> |
| 11 | <!-- Theme toggle --> |
| 12 | <button x-data="{ dark: document.documentElement.classList.contains('dark') }" |
| 13 | @click="dark = !dark; document.documentElement.classList.toggle('dark'); localStorage.setItem('theme', dark ? 'dark' : 'light')" |
| 14 | class="rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800" |
| 15 | title="Toggle theme"> |
| 16 |
| --- templates/includes/nav.html | |
| +++ templates/includes/nav.html | |
| @@ -6,10 +6,25 @@ | |
| 6 | <a href="{% url 'dashboard' %}" class="flex-shrink-0"> |
| 7 | <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-7 w-auto"> |
| 8 | </a> |
| 9 | </div> |
| 10 | <div class="flex items-center gap-3"> |
| 11 | <!-- Quick search --> |
| 12 | <div x-data="{ open: false }" class="relative"> |
| 13 | <button @click="open = !open; $nextTick(() => $refs.searchInput?.focus())" class="rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800" title="Search (/)"> |
| 14 | <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 15 | <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" /> |
| 16 | </svg> |
| 17 | </button> |
| 18 | <div x-show="open" @click.outside="open = false" @keydown.escape.window="open = false" x-transition |
| 19 | class="absolute right-0 z-20 mt-2 w-80 rounded-lg bg-gray-800 shadow-lg ring-1 ring-gray-700 p-3"> |
| 20 | <form method="get" action="{% if request.resolver_match.kwargs.slug %}/projects/{{ request.resolver_match.kwargs.slug }}/fossil/search/{% else %}/projects/fossil-scm/fossil/search/{% endif %}"> |
| 21 | <input type="text" name="q" x-ref="searchInput" placeholder="Search checkins, tickets, wiki..." |
| 22 | class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand"> |
| 23 | </form> |
| 24 | </div> |
| 25 | </div> |
| 26 | <!-- Theme toggle --> |
| 27 | <button x-data="{ dark: document.documentElement.classList.contains('dark') }" |
| 28 | @click="dark = !dark; document.documentElement.classList.toggle('dark'); localStorage.setItem('theme', dark ? 'dark' : 'light')" |
| 29 | class="rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800" |
| 30 | title="Toggle theme"> |
| 31 |