diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 000000000..a0722f0ac --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,33 @@ +# .github/workflows/coverage.yml +name: Post coverage comment + +on: + workflow_run: + workflows: ["Test"] + types: + - completed + +jobs: + test: + name: Run tests & display coverage + runs-on: ubuntu-latest + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + permissions: + # Gives the action the necessary permissions for publishing new + # comments in pull requests. + pull-requests: write + # Gives the action the necessary permissions for editing existing + # comments (to avoid publishing multiple comments in the same PR) + contents: write + # Gives the action the necessary permissions for looking up the + # workflow that launched this workflow, and download the related + # artifact that contains the comment to be published + actions: read + steps: + # DO NOT run actions/checkout here, for security reasons + # For details, refer to https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ + - name: Post comment + uses: py-cov-action/python-coverage-comment-action@v3 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 72b40d010..cd5d8dd8b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -66,11 +66,6 @@ jobs: DB_HOST: 127.0.0.1 DB_PORT: 3306 - - name: Upload coverage data - uses: actions/upload-artifact@v4 - with: - name: coverage-data-${{ matrix.python-version }}-${{ matrix.os }}-${{ matrix.arch }}-mysql - path: ".coverage.*" postgres: runs-on: ubuntu-latest @@ -144,12 +139,6 @@ jobs: DB_HOST: localhost DB_PORT: 5432 - - name: Upload coverage data - uses: actions/upload-artifact@v4 - with: - name: coverage-data-${{ matrix.python-version }}-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.database }} - path: ".coverage.*" - sqlite: runs-on: ubuntu-latest strategy: @@ -192,44 +181,6 @@ jobs: DB_BACKEND: sqlite3 DB_NAME: ":memory:" - - name: Upload coverage data - uses: actions/upload-artifact@v4 - with: - name: coverage-data-${{ matrix.python-version }}-${{ matrix.os }}-${{ matrix.arch }}-sqlite - path: ".coverage.*" - - coverage: - name: Check coverage. - runs-on: "ubuntu-latest" - needs: [sqlite, mysql, postgres] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - # Use latest, so it understands all syntax. - python-version: "3.11" - - - run: python -m pip install --upgrade coverage[toml] - - - name: Download coverage data. - uses: actions/download-artifact@v4 - with: - pattern: coverage-data-* - merge-multiple: true - - - name: Combine coverage & check percentage - run: | - python -m coverage combine - python -m coverage html - python -m coverage report - - - name: Upload HTML report if check failed. - uses: actions/upload-artifact@v4 - with: - name: html-report - path: htmlcov - if: ${{ failure() }} - lint: runs-on: ubuntu-latest strategy: diff --git a/.gitignore b/.gitignore index ee3559cc4..988922d50 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ htmlcov .tox geckodriver.log coverage.xml +.direnv/ +.envrc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1eb0a7df1..54a49e4d6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: hooks: - id: doc8 - repo: https://github.com/adamchainz/django-upgrade - rev: 1.16.0 + rev: 1.19.0 hooks: - id: django-upgrade args: [--target-version, "4.2"] @@ -32,7 +32,7 @@ repos: args: - --trailing-comma=es5 - repo: https://github.com/pre-commit/mirrors-eslint - rev: v9.0.0 + rev: v9.6.0 hooks: - id: eslint additional_dependencies: @@ -44,16 +44,16 @@ repos: args: - --fix - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.3.7' + rev: 'v0.5.0' hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/tox-dev/pyproject-fmt - rev: 1.7.0 + rev: 2.1.3 hooks: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.16 + rev: v0.18 hooks: - id: validate-pyproject diff --git a/README.rst b/README.rst index 2408e1b83..4e195a796 100644 --- a/README.rst +++ b/README.rst @@ -44,7 +44,7 @@ Here's a screenshot of the toolbar in action: In addition to the built-in panels, a number of third-party panels are contributed by the community. -The current stable version of the Debug Toolbar is 4.3.0. It works on +The current stable version of the Debug Toolbar is 4.4.5. It works on Django ≥ 4.2.0. The Debug Toolbar does not currently support `Django's asynchronous views diff --git a/debug_toolbar/__init__.py b/debug_toolbar/__init__.py index 9a8c2b24f..a1a09f2a1 100644 --- a/debug_toolbar/__init__.py +++ b/debug_toolbar/__init__.py @@ -4,7 +4,7 @@ # Do not use pkg_resources to find the version but set it here directly! # see issue #1446 -VERSION = "4.3.0" +VERSION = "4.4.5" # Code that discovers files or modules in INSTALLED_APPS imports this module. urls = "debug_toolbar.urls", APP_NAME diff --git a/debug_toolbar/apps.py b/debug_toolbar/apps.py index a2e977d84..a49875bac 100644 --- a/debug_toolbar/apps.py +++ b/debug_toolbar/apps.py @@ -5,10 +5,12 @@ from django.conf import settings from django.core.checks import Error, Warning, register from django.middleware.gzip import GZipMiddleware +from django.urls import NoReverseMatch, reverse from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ -from debug_toolbar import settings as dt_settings +from debug_toolbar import APP_NAME, settings as dt_settings +from debug_toolbar.settings import CONFIG_DEFAULTS class DebugToolbarConfig(AppConfig): @@ -213,7 +215,33 @@ def debug_toolbar_installed_when_running_tests_check(app_configs, **kwargs): """ Check that the toolbar is not being used when tests are running """ - if not settings.DEBUG and dt_settings.get_config()["IS_RUNNING_TESTS"]: + # Check if show toolbar callback has changed + show_toolbar_changed = ( + dt_settings.get_config()["SHOW_TOOLBAR_CALLBACK"] + != CONFIG_DEFAULTS["SHOW_TOOLBAR_CALLBACK"] + ) + try: + # Check if the toolbar's urls are installed + reverse(f"{APP_NAME}:render_panel") + toolbar_urls_installed = True + except NoReverseMatch: + toolbar_urls_installed = False + + # If the user is using the default SHOW_TOOLBAR_CALLBACK, + # then the middleware will respect the change to settings.DEBUG. + # However, if the user has changed the callback to: + # DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: DEBUG} + # where DEBUG is not settings.DEBUG, then it won't pick up that Django' + # test runner has changed the value for settings.DEBUG, and the middleware + # will inject the toolbar, while the URLs aren't configured leading to a + # NoReverseMatch error. + likely_error_setup = show_toolbar_changed and not toolbar_urls_installed + + if ( + not settings.DEBUG + and dt_settings.get_config()["IS_RUNNING_TESTS"] + and likely_error_setup + ): return [ Error( "The Django Debug Toolbar can't be used with tests", diff --git a/debug_toolbar/middleware.py b/debug_toolbar/middleware.py index 38cf92884..8cdffcfc2 100644 --- a/debug_toolbar/middleware.py +++ b/debug_toolbar/middleware.py @@ -13,6 +13,11 @@ from debug_toolbar.toolbar import DebugToolbar from debug_toolbar.utils import clear_stack_trace_caches +try: + from django.core.handlers.asgi import ASGIRequest +except ImportError: + ASGIRequest = None + _HTML_TYPES = ("text/html", "application/xhtml+xml") @@ -20,8 +25,14 @@ def show_toolbar(request): """ Default function to determine whether to show the toolbar on a given page. """ - internal_ips = settings.INTERNAL_IPS.copy() + if not settings.DEBUG: + return False + + # Test: settings + if request.META.get("REMOTE_ADDR") in settings.INTERNAL_IPS: + return True + # Test: Docker try: # This is a hack for docker installations. It attempts to look # up the IP address of the docker host. @@ -31,11 +42,14 @@ def show_toolbar(request): ".".join(socket.gethostbyname("host.docker.internal").rsplit(".")[:-1]) + ".1" ) - internal_ips.append(docker_ip) + if request.META.get("REMOTE_ADDR") == docker_ip: + return True except socket.gaierror: # It's fine if the lookup errored since they may not be using docker pass - return settings.DEBUG and request.META.get("REMOTE_ADDR") in internal_ips + + # No test passed + return False @lru_cache(maxsize=None) @@ -64,6 +78,10 @@ def __call__(self, request): if not show_toolbar(request) or DebugToolbar.is_toolbar_request(request): return self.get_response(request) + if self._check_async_request(request): + self._show_async_request_is_not_supported() + return self.get_response(request) + toolbar = DebugToolbar(request, self.get_response) # Activate instrumentation ie. monkey-patch. @@ -124,3 +142,12 @@ def get_headers(request, panels): else: headers[header] = value return headers + + def _check_async_request(self, request) -> bool: + return type(request) == ASGIRequest + + @staticmethod + def _show_async_request_is_not_supported(): + print("-" * 10) + print("Be caution, django-debug-toolbar does not support async requests!") + print("-" * 10) diff --git a/debug_toolbar/panels/alerts.py b/debug_toolbar/panels/alerts.py new file mode 100644 index 000000000..e640dcdd5 --- /dev/null +++ b/debug_toolbar/panels/alerts.py @@ -0,0 +1,151 @@ +from html.parser import HTMLParser + +from django.utils.translation import gettext_lazy as _ + +from debug_toolbar.panels import Panel + + +class FormParser(HTMLParser): + """ + HTML form parser, used to check for invalid configurations of forms that + take file inputs. + """ + + def __init__(self): + super().__init__() + self.in_form = False + self.current_form = {} + self.forms = [] + self.form_ids = [] + self.referenced_file_inputs = [] + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == "form": + self.in_form = True + form_id = attrs.get("id") + if form_id: + self.form_ids.append(form_id) + self.current_form = { + "file_form": False, + "form_attrs": attrs, + "submit_element_attrs": [], + } + elif ( + self.in_form + and tag == "input" + and attrs.get("type") == "file" + and (not attrs.get("form") or attrs.get("form") == "") + ): + self.current_form["file_form"] = True + elif ( + self.in_form + and ( + (tag == "input" and attrs.get("type") in {"submit", "image"}) + or tag == "button" + ) + and (not attrs.get("form") or attrs.get("form") == "") + ): + self.current_form["submit_element_attrs"].append(attrs) + elif tag == "input" and attrs.get("form"): + self.referenced_file_inputs.append(attrs) + + def handle_endtag(self, tag): + if tag == "form" and self.in_form: + self.forms.append(self.current_form) + self.in_form = False + + +class AlertsPanel(Panel): + """ + A panel to alert users to issues. + """ + + messages = { + "form_id_missing_enctype": _( + 'Form with id "{form_id}" contains file input, but does not have the attribute enctype="multipart/form-data".' + ), + "form_missing_enctype": _( + 'Form contains file input, but does not have the attribute enctype="multipart/form-data".' + ), + "input_refs_form_missing_enctype": _( + 'Input element references form with id "{form_id}", but the form does not have the attribute enctype="multipart/form-data".' + ), + } + + title = _("Alerts") + + template = "debug_toolbar/panels/alerts.html" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.alerts = [] + + @property + def nav_subtitle(self): + if alerts := self.get_stats().get("alerts"): + alert_text = "alert" if len(alerts) == 1 else "alerts" + return f"{len(alerts)} {alert_text}" + else: + return "" + + def add_alert(self, alert): + self.alerts.append(alert) + + def check_invalid_file_form_configuration(self, html_content): + """ + Inspects HTML content for a form that includes a file input but does + not have the encoding type set to multipart/form-data, and warns the + user if so. + """ + parser = FormParser() + parser.feed(html_content) + + # Check for file inputs directly inside a form that do not reference + # any form through the form attribute + for form in parser.forms: + if ( + form["file_form"] + and form["form_attrs"].get("enctype") != "multipart/form-data" + and not any( + elem.get("formenctype") == "multipart/form-data" + for elem in form["submit_element_attrs"] + ) + ): + if form_id := form["form_attrs"].get("id"): + alert = self.messages["form_id_missing_enctype"].format( + form_id=form_id + ) + else: + alert = self.messages["form_missing_enctype"] + self.add_alert({"alert": alert}) + + # Check for file inputs that reference a form + form_attrs_by_id = { + form["form_attrs"].get("id"): form["form_attrs"] for form in parser.forms + } + + for attrs in parser.referenced_file_inputs: + form_id = attrs.get("form") + if form_id and attrs.get("type") == "file": + form_attrs = form_attrs_by_id.get(form_id) + if form_attrs and form_attrs.get("enctype") != "multipart/form-data": + alert = self.messages["input_refs_form_missing_enctype"].format( + form_id=form_id + ) + self.add_alert({"alert": alert}) + + return self.alerts + + def generate_stats(self, request, response): + # check if streaming response + if getattr(response, "streaming", True): + return + + html_content = response.content.decode(response.charset) + self.check_invalid_file_form_configuration(html_content) + + # Further alert checks can go here + + # Write all alerts to record_stats + self.record_stats({"alerts": self.alerts}) diff --git a/debug_toolbar/panels/templates/jinja2.py b/debug_toolbar/panels/templates/jinja2.py new file mode 100644 index 000000000..d343cb140 --- /dev/null +++ b/debug_toolbar/panels/templates/jinja2.py @@ -0,0 +1,23 @@ +import functools + +from django.template.backends.jinja2 import Template as JinjaTemplate +from django.template.context import make_context +from django.test.signals import template_rendered + + +def patch_jinja_render(): + orig_render = JinjaTemplate.render + + @functools.wraps(orig_render) + def wrapped_render(self, context=None, request=None): + # This patching of render only instruments the rendering + # of the immediate template. It won't include the parent template(s). + self.name = self.template.name + template_rendered.send( + sender=self, template=self, context=make_context(context, request) + ) + return orig_render(self, context, request) + + if JinjaTemplate.render != wrapped_render: + JinjaTemplate.original_render = JinjaTemplate.render + JinjaTemplate.render = wrapped_render diff --git a/debug_toolbar/panels/templates/panel.py b/debug_toolbar/panels/templates/panel.py index c0c6246b2..ee2a066c7 100644 --- a/debug_toolbar/panels/templates/panel.py +++ b/debug_toolbar/panels/templates/panel.py @@ -1,4 +1,5 @@ from contextlib import contextmanager +from importlib.util import find_spec from os.path import normpath from pprint import pformat, saferepr @@ -15,6 +16,11 @@ from debug_toolbar.panels.sql.tracking import SQLQueryTriggered, allow_sql from debug_toolbar.panels.templates import views +if find_spec("jinja2"): + from debug_toolbar.panels.templates.jinja2 import patch_jinja_render + + patch_jinja_render() + # Monkey-patch to enable the template_rendered signal. The receiver returns # immediately when the panel is disabled to keep the overhead small. @@ -25,7 +31,6 @@ Template.original_render = Template._render Template._render = instrumented_test_render - # Monkey-patch to store items added by template context processors. The # overhead is sufficiently small to justify enabling it unconditionally. @@ -158,7 +163,7 @@ def process_context_list(self, context_layers): f"<<{value.__class__.__name__.lower()} of {value.model._meta.label}>>" ) else: - token = allow_sql.set(False) # noqa: FBT003 + token = allow_sql.set(False) try: saferepr(value) # this MAY trigger a db query except SQLQueryTriggered: diff --git a/debug_toolbar/settings.py b/debug_toolbar/settings.py index 868d50a90..48483cf40 100644 --- a/debug_toolbar/settings.py +++ b/debug_toolbar/settings.py @@ -45,6 +45,7 @@ "TOOLBAR_LANGUAGE": None, "IS_RUNNING_TESTS": "test" in sys.argv, "UPDATE_ON_FETCH": False, + "DEFAULT_THEME": "auto", } @@ -66,6 +67,7 @@ def get_config(): "debug_toolbar.panels.sql.SQLPanel", "debug_toolbar.panels.staticfiles.StaticFilesPanel", "debug_toolbar.panels.templates.TemplatesPanel", + "debug_toolbar.panels.alerts.AlertsPanel", "debug_toolbar.panels.cache.CachePanel", "debug_toolbar.panels.signals.SignalsPanel", "debug_toolbar.panels.redirects.RedirectsPanel", diff --git a/debug_toolbar/static/debug_toolbar/css/toolbar.css b/debug_toolbar/static/debug_toolbar/css/toolbar.css index a35286a1f..e495eeb0c 100644 --- a/debug_toolbar/static/debug_toolbar/css/toolbar.css +++ b/debug_toolbar/static/debug_toolbar/css/toolbar.css @@ -11,10 +11,77 @@ "Noto Color Emoji"; } +:root, +#djDebug[data-theme="light"] { + --djdt-font-color: black; + --djdt-background-color: white; + --djdt-panel-content-background-color: #eee; + --djdt-panel-content-table-background-color: var(--djdt-background-color); + --djdt-panel-title-background-color: #ffc; + --djdt-djdt-panel-content-table-strip-background-color: #f5f5f5; + --djdt--highlighted-background-color: lightgrey; + --djdt-toggle-template-background-color: #bbb; + + --djdt-sql-font-color: #333; + --djdt-pre-text-color: #555; + --djdt-path-and-locals: #777; + --djdt-stack-span-color: black; + --djdt-template-highlight-color: #333; + + --djdt-table-border-color: #ccc; + --djdt-button-border-color: var(--djdt-table-border-color); + --djdt-pre-border-color: var(--djdt-table-border-color); + --djdt-raw-border-color: var(--djdt-table-border-color); +} + +@media (prefers-color-scheme: dark) { + :root { + --djdt-font-color: #8393a7; + --djdt-background-color: #1e293bff; + --djdt-panel-content-background-color: #0f1729ff; + --djdt-panel-title-background-color: #242432; + --djdt-djdt-panel-content-table-strip-background-color: #324154ff; + --djdt--highlighted-background-color: #2c2a7dff; + --djdt-toggle-template-background-color: #282755; + + --djdt-sql-font-color: var(--djdt-font-color); + --djdt-pre-text-color: var(--djdt-font-color); + --djdt-path-and-locals: #65758cff; + --djdt-stack-span-color: #7c8fa4; + --djdt-template-highlight-color: var(--djdt-stack-span-color); + + --djdt-table-border-color: #324154ff; + --djdt-button-border-color: var(--djdt-table-border-color); + --djdt-pre-border-color: var(--djdt-table-border-color); + --djdt-raw-border-color: var(--djdt-table-border-color); + } +} + +#djDebug[data-theme="dark"] { + --djdt-font-color: #8393a7; + --djdt-background-color: #1e293bff; + --djdt-panel-content-background-color: #0f1729ff; + --djdt-panel-title-background-color: #242432; + --djdt-djdt-panel-content-table-strip-background-color: #324154ff; + --djdt--highlighted-background-color: #2c2a7dff; + --djdt-toggle-template-background-color: #282755; + + --djdt-sql-font-color: var(--djdt-font-color); + --djdt-pre-text-color: var(--djdt-font-color); + --djdt-path-and-locals: #65758cff; + --djdt-stack-span-color: #7c8fa4; + --djdt-template-highlight-color: var(--djdt-stack-span-color); + + --djdt-table-border-color: #324154ff; + --djdt-button-border-color: var(--djdt-table-border-color); + --djdt-pre-border-color: var(--djdt-table-border-color); + --djdt-raw-border-color: var(--djdt-table-border-color); +} + /* Debug Toolbar CSS Reset, adapted from Eric Meyer's CSS Reset */ #djDebug { - color: #000; - background: #fff; + color: var(--djdt-font-color); + background: var(--djdt-background-color); } #djDebug, #djDebug div, @@ -87,7 +154,7 @@ outline: 0; font-size: 12px; line-height: 1.5em; - color: #000; + color: var(--djdt-font-color); vertical-align: baseline; background-color: transparent; font-family: var(--djdt-font-family-primary); @@ -100,7 +167,7 @@ #djDebug button { background-color: #eee; background-image: linear-gradient(to bottom, #eee, #cccccc); - border: 1px solid #ccc; + border: 1px solid var(--djdt-button-border-color); border-bottom: 1px solid #bbb; border-radius: 3px; color: #333; @@ -268,10 +335,10 @@ #djDebug pre { white-space: pre-wrap; - color: #555; - border: 1px solid #ccc; + color: var(--djdt-pre-text-color); + border: 1px solid var(--djdt-pre-border-color); border-collapse: collapse; - background-color: #fff; + background-color: var(--djdt-background-color); padding: 2px 3px; margin-bottom: 3px; } @@ -283,7 +350,7 @@ right: 220px; bottom: 0; left: 0px; - background-color: #eee; + background-color: var(--djdt-panel-content-background-color); color: #666; z-index: 100000000; } @@ -294,7 +361,7 @@ #djDebug .djDebugPanelTitle { position: absolute; - background-color: #ffc; + background-color: var(--djdt-panel-title-background-color); color: #666; padding-left: 20px; top: 0; @@ -357,16 +424,16 @@ } #djDebug .djdt-panelContent table { - border: 1px solid #ccc; + border: 1px solid var(--djdt-table-border-color); border-collapse: collapse; width: 100%; - background-color: #fff; + background-color: var(--djdt-panel-content-table-background-color); display: table; margin-top: 0.8em; overflow: auto; } #djDebug .djdt-panelContent tbody > tr:nth-child(odd):not(.djdt-highlighted) { - background-color: #f5f5f5; + background-color: var(--djdt-panel-content-table-strip-background-color); } #djDebug .djdt-panelContent tbody td, #djDebug .djdt-panelContent tbody th { @@ -392,7 +459,7 @@ } #djDebug .djTemplateContext { - background-color: #fff; + background-color: var(--djdt-background-color); } #djDebug .djdt-panelContent .djDebugClose { @@ -433,7 +500,7 @@ #djDebug a.toggleTemplate { padding: 4px; - background-color: #bbb; + background-color: var(--djdt-toggle-template-background-color); border-radius: 3px; } @@ -445,11 +512,11 @@ } #djDebug .djDebugCollapsed { - color: #333; + color: var(--djdt-sql-font-color); } #djDebug .djDebugUncollapsed { - color: #333; + color: var(--djdt-sql-font-color); } #djDebug .djUnselected { @@ -483,66 +550,66 @@ } #djDebug .highlight { - color: #000; + color: var(--djdt-font-color); } #djDebug .highlight .err { - color: #000; + color: var(--djdt-font-color); } /* Error */ #djDebug .highlight .g { - color: #000; + color: var(--djdt-font-color); } /* Generic */ #djDebug .highlight .k { - color: #000; + color: var(--djdt-font-color); font-weight: bold; } /* Keyword */ #djDebug .highlight .o { - color: #000; + color: var(--djdt-font-color); } /* Operator */ #djDebug .highlight .n { - color: #000; + color: var(--djdt-font-color); } /* Name */ #djDebug .highlight .mi { - color: #000; + color: var(--djdt-font-color); font-weight: bold; } /* Literal.Number.Integer */ #djDebug .highlight .l { - color: #000; + color: var(--djdt-font-color); } /* Literal */ #djDebug .highlight .x { - color: #000; + color: var(--djdt-font-color); } /* Other */ #djDebug .highlight .p { - color: #000; + color: var(--djdt-font-color); } /* Punctuation */ #djDebug .highlight .m { - color: #000; + color: var(--djdt-font-color); font-weight: bold; } /* Literal.Number */ #djDebug .highlight .s { - color: #333; + color: var(--djdt-template-highlight-color); } /* Literal.String */ #djDebug .highlight .w { color: #888888; } /* Text.Whitespace */ #djDebug .highlight .il { - color: #000; + color: var(--djdt-font-color); font-weight: bold; } /* Literal.Number.Integer.Long */ #djDebug .highlight .na { - color: #333; + color: var(--djdt-template-highlight-color); } /* Name.Attribute */ #djDebug .highlight .nt { - color: #000; + color: var(--djdt-font-color); font-weight: bold; } /* Name.Tag */ #djDebug .highlight .nv { - color: #333; + color: var(--djdt-template-highlight-color); } /* Name.Variable */ #djDebug .highlight .s2 { - color: #333; + color: var(--djdt-template-highlight-color); } /* Literal.String.Double */ #djDebug .highlight .cp { - color: #333; + color: var(--djdt-template-highlight-color); } /* Comment.Preproc */ #djDebug svg.djDebugLineChart { @@ -595,13 +662,13 @@ } #djDebug .djdt-stack span { - color: #000; + color: var(--djdt-stack-span-color); font-weight: bold; } #djDebug .djdt-stack span.djdt-path, #djDebug .djdt-stack pre.djdt-locals, #djDebug .djdt-stack pre.djdt-locals span { - color: #777; + color: var(--djdt-path-and-locals); font-weight: normal; } #djDebug .djdt-stack span.djdt-code { @@ -612,7 +679,7 @@ } #djDebug .djdt-raw { background-color: #fff; - border: 1px solid #ccc; + border: 1px solid var(--djdt-raw-border-color); margin-top: 0.8em; padding: 5px; white-space: pre-wrap; @@ -631,7 +698,7 @@ max-height: 100%; } #djDebug .djdt-highlighted { - background-color: lightgrey; + background-color: var(--djdt--highlighted-background-color); } #djDebug tr.djdt-highlighted.djdt-profile-row { background-color: #ffc; @@ -654,3 +721,19 @@ .djdt-hidden { display: none; } + +#djDebug #djDebugToolbar a#djToggleThemeButton { + display: flex; + align-items: center; + cursor: pointer; +} +#djToggleThemeButton > svg { + margin-left: auto; +} +#djDebug[data-theme="light"] #djToggleThemeButton svg.theme-light, +#djDebug[data-theme="dark"] #djToggleThemeButton svg.theme-dark, +#djDebug[data-theme="auto"] #djToggleThemeButton svg.theme-auto { + display: block; + height: 1rem; + width: 1rem; +} diff --git a/debug_toolbar/static/debug_toolbar/js/toolbar.js b/debug_toolbar/static/debug_toolbar/js/toolbar.js index 199616336..067b5a312 100644 --- a/debug_toolbar/static/debug_toolbar/js/toolbar.js +++ b/debug_toolbar/static/debug_toolbar/js/toolbar.js @@ -1,4 +1,4 @@ -import { $$, ajax, replaceToolbarState, debounce } from "./utils.js"; +import { $$, ajax, debounce, replaceToolbarState } from "./utils.js"; function onKeyDown(event) { if (event.keyCode === 27) { @@ -213,6 +213,29 @@ const djdt = { if (djDebug.dataset.sidebarUrl !== undefined) { djdt.updateOnAjax(); } + + // Updates the theme using user settings + const userTheme = localStorage.getItem("djdt.user-theme"); + if (userTheme !== null) { + djDebug.setAttribute("data-theme", userTheme); + } + // Adds the listener to the Theme Toggle Button + $$.on(djDebug, "click", "#djToggleThemeButton", function () { + switch (djDebug.getAttribute("data-theme")) { + case "auto": + djDebug.setAttribute("data-theme", "light"); + localStorage.setItem("djdt.user-theme", "light"); + break; + case "light": + djDebug.setAttribute("data-theme", "dark"); + localStorage.setItem("djdt.user-theme", "dark"); + break; + default: /* dark is the default */ + djDebug.setAttribute("data-theme", "auto"); + localStorage.setItem("djdt.user-theme", "auto"); + break; + } + }); }, hidePanels() { const djDebug = getDebugElement(); @@ -276,7 +299,7 @@ const djdt = { storeId = encodeURIComponent(storeId); const dest = `${sidebarUrl}?store_id=${storeId}`; slowjax(dest).then(function (data) { - if (djdt.needUpdateOnFetch){ + if (djdt.needUpdateOnFetch) { replaceToolbarState(storeId, data); } }); diff --git a/debug_toolbar/templates/debug_toolbar/base.html b/debug_toolbar/templates/debug_toolbar/base.html index 6f4967f21..4867a834e 100644 --- a/debug_toolbar/templates/debug_toolbar/base.html +++ b/debug_toolbar/templates/debug_toolbar/base.html @@ -16,10 +16,16 @@ data-sidebar-url="{{ history_url }}" {% endif %} data-default-show="{% if toolbar.config.SHOW_COLLAPSED %}false{% else %}true{% endif %}" - {{ toolbar.config.ROOT_TAG_EXTRA_ATTRS|safe }} data-update-on-fetch="{{ toolbar.config.UPDATE_ON_FETCH }}"> + {{ toolbar.config.ROOT_TAG_EXTRA_ATTRS|safe }} data-update-on-fetch="{{ toolbar.config.UPDATE_ON_FETCH }}" + data-theme="{{ toolbar.config.DEFAULT_THEME }}">