diff --git a/gitfourchette/assets/icons/diff-whitespace-ignore-all-space.svg b/gitfourchette/assets/icons/diff-whitespace-ignore-all-space.svg new file mode 100644 index 00000000..403cbcca --- /dev/null +++ b/gitfourchette/assets/icons/diff-whitespace-ignore-all-space.svg @@ -0,0 +1,3 @@ + + + diff --git a/gitfourchette/assets/icons/diff-whitespace-ignore-eol.svg b/gitfourchette/assets/icons/diff-whitespace-ignore-eol.svg new file mode 100644 index 00000000..ae477de6 --- /dev/null +++ b/gitfourchette/assets/icons/diff-whitespace-ignore-eol.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/gitfourchette/assets/icons/diff-whitespace-ignore-space-change.svg b/gitfourchette/assets/icons/diff-whitespace-ignore-space-change.svg new file mode 100644 index 00000000..7b0d1e7d --- /dev/null +++ b/gitfourchette/assets/icons/diff-whitespace-ignore-space-change.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/gitfourchette/assets/icons/diff-whitespace-strict.svg b/gitfourchette/assets/icons/diff-whitespace-strict.svg new file mode 100644 index 00000000..530affaa --- /dev/null +++ b/gitfourchette/assets/icons/diff-whitespace-strict.svg @@ -0,0 +1,3 @@ + + + diff --git a/gitfourchette/assets/icons/format-text-wrap.svg b/gitfourchette/assets/icons/format-text-wrap.svg new file mode 100644 index 00000000..3c8faf89 --- /dev/null +++ b/gitfourchette/assets/icons/format-text-wrap.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/gitfourchette/assets/icons/paragraph.svg b/gitfourchette/assets/icons/paragraph.svg new file mode 100644 index 00000000..76c01493 --- /dev/null +++ b/gitfourchette/assets/icons/paragraph.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/gitfourchette/assets/style.qss b/gitfourchette/assets/style.qss index a665b9c7..00449fe4 100644 --- a/gitfourchette/assets/style.qss +++ b/gitfourchette/assets/style.qss @@ -27,4 +27,5 @@ DiffView[dark="true"] { selection-background-color: rgba(255, 255, 255, 26%); } QFaintSeparator { border: none; background: rgba(0, 0, 0, 15%); } /* Remove QToolButton dropdown arrow in Fusion style */ +DiffButtons QToolButton::menu-indicator, GpgButton::menu-indicator { image: none; } diff --git a/gitfourchette/codeview/codehighlighter.py b/gitfourchette/codeview/codehighlighter.py index a28d1312..09ea5ef8 100644 --- a/gitfourchette/codeview/codehighlighter.py +++ b/gitfourchette/codeview/codehighlighter.py @@ -1,5 +1,5 @@ # ----------------------------------------------------------------------------- -# Copyright (C) 2025 Iliyas Jorio. +# Copyright (C) 2026 Iliyas Jorio. # This file is part of GitFourchette, distributed under the GNU GPL v3. # For full terms, see the included LICENSE file. # ----------------------------------------------------------------------------- @@ -7,6 +7,7 @@ import logging from gitfourchette import colors +from gitfourchette import settings from gitfourchette.syntax import ColorScheme, LexJob from gitfourchette.qt import * from gitfourchette.toolbox import benchmark, CallbackAccumulator @@ -18,6 +19,10 @@ class CodeHighlighter(QSyntaxHighlighter): scheme: ColorScheme lexJobs: list[LexJob] + _whitespaceRegex = QRegularExpression("[ \t]+") + _whitespaceRegex.optimize() + assert _whitespaceRegex.isValid() + def __init__(self, parent): super().__init__(parent) @@ -50,8 +55,20 @@ def stopLexJobs(self): self.lexJobs.clear() def highlightBlock(self, text: str): - if self.scheme and self.lexJobs: - self.highlightSyntax(text) + if self.scheme: + # Highlight pygments tokens + if self.lexJobs: + self.highlightSyntax(text) + + # Override highlighting on whitespace with a nicer muted color + if settings.prefs.showFormattingMarks: + charFormat = self.scheme.whitespaceFormat + wsIter = self._whitespaceRegex.globalMatch(text) + while wsIter.hasNext(): + match = wsIter.next() + self.setFormat(match.capturedStart(), match.capturedLength(), charFormat) + + # Override highlighting on search term if self.searchTerm: self.highlightSearch(text) diff --git a/gitfourchette/codeview/codeview.py b/gitfourchette/codeview/codeview.py index b759e4ca..53c862ae 100644 --- a/gitfourchette/codeview/codeview.py +++ b/gitfourchette/codeview/codeview.py @@ -40,6 +40,8 @@ class CodeView(QPlainTextEdit): currentLocator: NavLocator isDetachedWindow: bool + FormattingMarkFlags = QTextOption.Flag.ShowTabsAndSpaces + def __init__(self, gutterClass, highlighterClass=CodeHighlighter, parent=None): super().__init__(parent) @@ -284,6 +286,7 @@ def refreshPrefs(self, changeColorScheme=True): tabWidth = settings.prefs.tabSpaces self.setTabStopDistance(QFontMetricsF(monoFont).horizontalAdvance(' ' * tabWidth)) self.refreshWordWrap() + self.refreshFormattingMarksOption() self.setCursorWidth(2) self.gutter.syncFont(monoFont) @@ -307,7 +310,6 @@ def setColorScheme(self, scheme: ColorScheme): styleSheet = scheme.basicQss(self) self.setStyleSheet(styleSheet) - def refreshWordWrap(self): if settings.prefs.wordWrap: wrapMode = QPlainTextEdit.LineWrapMode.WidgetWidth @@ -327,7 +329,20 @@ def refreshWordWrap(self): def toggleWordWrap(self): settings.prefs.wordWrap = not settings.prefs.wordWrap settings.prefs.write() - self.refreshWordWrap() + GFApplication.instance().prefsChanged.emit(["wordWrap"]) + + def refreshFormattingMarksOption(self): + doc = self.document() + if doc is None: + return + opt = QTextOption(doc.defaultTextOption()) + flags = opt.flags() + if settings.prefs.showFormattingMarks: + flags |= self.FormattingMarkFlags + else: + flags &= ~self.FormattingMarkFlags + opt.setFlags(flags) + doc.setDefaultTextOption(opt) # --------------------------------------------- # Context menu diff --git a/gitfourchette/diffarea.py b/gitfourchette/diffarea.py index f9c5a134..bf5e0c9a 100644 --- a/gitfourchette/diffarea.py +++ b/gitfourchette/diffarea.py @@ -8,6 +8,8 @@ import typing from typing import Literal +from gitfourchette.application import GFApplication +from gitfourchette.diffbuttons import DiffButtons from gitfourchette.diffview.diffview import DiffView from gitfourchette.diffview.specialdiffview import SpecialDiffView from gitfourchette.filelists.committedfiles import CommittedFiles @@ -80,6 +82,9 @@ def __init__(self, repoModel, parent): ): passiveWidget.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) + GFApplication.instance().prefsChanged.connect(self.diffButtons.refreshPrefs) + self.diffButtons.refreshPrefs() + # Ignore height in size policy to keep DiffArea from jumping around when we're showing a banner. self.setSizePolicy(self.sizePolicy().horizontalPolicy(), QSizePolicy.Policy.Ignored) self.setMinimumHeight(175) @@ -253,13 +258,18 @@ def _makeDiffContainer(self, repoModel): header.setObjectName("diffHeader") header.setMinimumHeight(FILEHEADER_HEIGHT) header.setContentsMargins(4, 0, 4, 0) - # Don't let header dictate window width if displaying long filename + header.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + # Absorb horizontal space so the path stays left and toolbar stays right. header.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Minimum) + diffTools = DiffButtons(self) + topContainer = QWidget(self) topLayout = QHBoxLayout(topContainer) topLayout.setContentsMargins(0, 0, 0, 0) - topLayout.addWidget(header) + topLayout.setSpacing(0) + topLayout.addWidget(header, 1) + topLayout.addWidget(diffTools, 0) diff = DiffView(self) @@ -296,17 +306,25 @@ def _makeDiffContainer(self, repoModel): self.conflictView = conflict self.specialDiffView = specialDiff self.diffView = diff + self.diffButtons = diffTools return stackContainer def applyCustomStyling(self): - for smallButton in self.discardButton, self.unstageButton, self.stageButton: + for smallButton in ( + self.discardButton, + self.unstageButton, + self.stageButton, + *self.diffButtons.buttons, + ): smallButton.setMaximumHeight(FILEHEADER_HEIGHT) - smallButton.setEnabled(False) smallButton.setFocusPolicy(Qt.FocusPolicy.NoFocus) smallButton.setAutoRaise(True) smallButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) + for button in self.stageButton, self.unstageButton, self.discardButton: + button.setEnabled(False) + for button in self.stageButton, self.unstageButton: button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) @@ -320,6 +338,7 @@ def applyCustomStyling(self): self.stageButton, self.unstageButton, self.discardButton, + *self.diffButtons.buttons, ): tweakWidgetFont(smallWidget, 90) diff --git a/gitfourchette/diffbuttons.py b/gitfourchette/diffbuttons.py new file mode 100644 index 00000000..9d6cae18 --- /dev/null +++ b/gitfourchette/diffbuttons.py @@ -0,0 +1,131 @@ +# ----------------------------------------------------------------------------- +# Copyright (C) 2026 Iliyas Jorio. +# This file is part of GitFourchette, distributed under the GNU GPL v3. +# For full terms, see the included LICENSE file. +# ----------------------------------------------------------------------------- + +from gitfourchette import settings +from gitfourchette.application import GFApplication +from gitfourchette.qt import * +from gitfourchette.settings import ComparisonMethod +from gitfourchette.toolbox import * +from gitfourchette.trtables import TrTables + +_ComparisonMethodIconTable = { + ComparisonMethod.Strict: "diff-whitespace-strict", + ComparisonMethod.IgnoreCrAtEol: "diff-whitespace-ignore-eol", + ComparisonMethod.IgnoreCrAtEolAndSpaceChange: "diff-whitespace-ignore-space-change", + ComparisonMethod.IgnoreCrAtEolAndAllSpace: "diff-whitespace-ignore-all-space", +} + + +class DiffButtons(QWidget): + def __init__(self, parent): + super().__init__(parent) + + self.diffMethodActions: dict[ComparisonMethod, QAction] = {} + + self.wordWrapButton = self._makeWordWrapButton() + self.marksButton = self._makeMarksButton() + self.diffMethodButton = self._makeDiffMethodButton() + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 2, 0) + layout.setSpacing(2) + for button in self.buttons: + layout.addWidget(button) + + @property + def buttons(self): + return self.wordWrapButton, self.marksButton, self.diffMethodButton + + # ------------------------------------------------------------------------- + # Constructor helpers + + def _makeWordWrapButton(self): + button = QToolButton(self) + button.setCheckable(True) + button.setIcon(stockIcon("format-text-wrap")) + button.setToolTip(TrTables.prefKey("wordWrap")) + button.toggled.connect(self.onWordWrapToggled) + return button + + def _makeMarksButton(self): + button = QToolButton(self) + button.setCheckable(True) + button.setIcon(stockIcon("paragraph")) + button.setToolTip(TrTables.prefKey("showFormattingMarks")) + button.toggled.connect(self.onMarksToggled) + return button + + def _makeDiffMethodButton(self): + menu = QMenu(self) + + actionGroup = QActionGroup(menu) + actionGroup.setExclusive(True) + + for method in ComparisonMethod: + label = escamp(TrTables.enum(method)) + action = QAction(label) + action.setIcon(stockIcon(_ComparisonMethodIconTable[method])) + action.triggered.connect(lambda _dummy, m=method: self.onDiffMethodChosen(m)) + action.setActionGroup(actionGroup) + action.setCheckable(True) + menu.addAction(action) + self.diffMethodActions[method] = action + + button = QToolButton(self) + button.setMenu(menu) + button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) + return button + + # ------------------------------------------------------------------------- + # Sync with preferences + + def refreshPrefs(self): + with QSignalBlockerContext( + self.wordWrapButton, + self.marksButton, + *self.diffMethodActions.values(), + ): + self.wordWrapButton.setChecked(settings.prefs.wordWrap) + self.marksButton.setChecked(settings.prefs.showFormattingMarks) + + method = settings.prefs.comparisonMethod + for m, action in self.diffMethodActions.items(): + action.setChecked(m == method) + action = self.diffMethodActions[method] + + toolTip = stripAccelerators(action.text()) + self.diffMethodButton.setIcon(action.icon()) + self.diffMethodButton.setToolTip(toolTip) + + # ------------------------------------------------------------------------- + # Button callbacks + + def onWordWrapToggled(self, checked: bool): + if settings.prefs.wordWrap == checked: + return + settings.prefs.wordWrap = checked + settings.prefs.write() + GFApplication.instance().prefsChanged.emit(["wordWrap"]) + + def onMarksToggled(self, checked: bool): + if settings.prefs.showFormattingMarks == checked: + return + settings.prefs.showFormattingMarks = checked + settings.prefs.write() + GFApplication.instance().prefsChanged.emit(["showFormattingMarks"]) + + def onDiffMethodChosen(self, method: settings.ComparisonMethod): + if settings.prefs.comparisonMethod == method: + return + settings.prefs.comparisonMethod = method + settings.prefs.write() + + GFApplication.instance().prefsChanged.emit(["comparisonMethod"]) + + # Trigger a reload of the patch + # TODO: This is inelegant, but it does the job for now. Ideally prefsChanged would suffice? + GFApplication.instance().mainWindow.onAcceptPrefsDialog({"comparisonMethod": method}) diff --git a/gitfourchette/diffview/diffhighlighter.py b/gitfourchette/diffview/diffhighlighter.py index 576878a8..7d8833c6 100644 --- a/gitfourchette/diffview/diffhighlighter.py +++ b/gitfourchette/diffview/diffhighlighter.py @@ -44,7 +44,7 @@ def highlightSyntax(self, text: str): if not lineData.origin: # Hunk header, etc. return - if lineData.origin == '+': + if lineData.origin != '-': lexJob = self.newLexJob lineNumber = lineData.newLineNo else: diff --git a/gitfourchette/diffview/diffview.py b/gitfourchette/diffview/diffview.py index 9e89d19e..88c6c67a 100644 --- a/gitfourchette/diffview/diffview.py +++ b/gitfourchette/diffview/diffview.py @@ -61,6 +61,7 @@ def __init__(self, parent=None): self.gutter.lineDoubleClicked.connect(self.selectClumpOfLinesAt) self.gutter.selectionMiddleClicked.connect(self.onMiddleClick) + def _initRubberBandButtons(self): self.stageButton = QToolButton() self.stageButton.setText(_("Stage Selection")) diff --git a/gitfourchette/diffview/specialdiff.py b/gitfourchette/diffview/specialdiff.py index 1484abe1..5f7e9398 100644 --- a/gitfourchette/diffview/specialdiff.py +++ b/gitfourchette/diffview/specialdiff.py @@ -21,6 +21,7 @@ from gitfourchette.nav import NavLocator, NavContext, NavFlags from gitfourchette.porcelain import * from gitfourchette.qt import * +from gitfourchette.settings import ComparisonMethod from gitfourchette.tasks import RepoTask from gitfourchette.toolbox import * from gitfourchette.trtables import TrTables @@ -98,6 +99,14 @@ def noChange(repo: Repo, delta: GitDelta, stderr: str = ""): assert delta.status in "A?" # added or untracked message = _("New empty file.") + if (newFileExists + and oldFileExists + and newFile.id != oldFile.id + and settings.prefs.comparisonMethod != ComparisonMethod.Strict): + message = _("Whitespace changes ignored. Contents otherwise identical.") + detailsLine = "{}{} {}.".format(TrTables.prefKey("comparisonMethod"), _(":"), TrTables.enum(settings.prefs.comparisonMethod)) + details.append(detailsLine) + if oldFile.path != newFile.path: intro = _("Renamed:") details.append(f"{intro} {hquo(oldFile.path)} → {hquo(newFile.path)}.") diff --git a/gitfourchette/forms/prefsdialog.py b/gitfourchette/forms/prefsdialog.py index 92529d7d..0ff3b764 100644 --- a/gitfourchette/forms/prefsdialog.py +++ b/gitfourchette/forms/prefsdialog.py @@ -379,6 +379,8 @@ def makeControlWidget(self, key: str, value, caption: str) -> QWidget: presets[_("Built-in git (sandboxed)")] = builtInGit presets[_("Auto-detected system git")] = ToolPresets.defaultGit(hostOnly=True) return self.strControlWithPresets(key, value, presets) + elif key == "comparisonMethod": + return self.radioEnumControl(key, value, type(value)) elif issubclass(valueType, enum.Enum): return self.enumControl(key, value, type(value)) elif valueType is int: @@ -555,6 +557,35 @@ def enumControl(self, prefKey, prefValue, enumType, previewCallback=None): return control + def radioEnumControl(self, prefKey: str, prefValue, enumType): + container = QWidget(self) + group = QButtonGroup(container) + layout = QVBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + members = [m for m in enumType if TrTables.enum(m) != ""] + + buttons: list[QRadioButton] = [] + for i, enumMember in enumerate(members): + rb = QRadioButton(TrTables.enum(enumMember), container) + group.addButton(rb, i) + layout.addWidget(rb) + buttons.append(rb) + if prefValue == enumMember: + rb.setChecked(True) + + if members and not any(b.isChecked() for b in buttons): + buttons[0].setChecked(True) + + def on_button_clicked(button): + idx = group.id(button) + if 0 <= idx < len(members): + self.assign(prefKey, members[idx]) + + group.buttonClicked.connect(on_button_clicked) + return container + def qtStyleControl(self, prefKey, prefValue): defaultCaption = _p("system default theme setting", "System default") control = QComboBox(self) diff --git a/gitfourchette/gitdriver/gitdriver.py b/gitfourchette/gitdriver/gitdriver.py index 4f4c51c1..07dc4b42 100644 --- a/gitfourchette/gitdriver/gitdriver.py +++ b/gitfourchette/gitdriver/gitdriver.py @@ -17,6 +17,7 @@ from gitfourchette import settings from gitfourchette.exttools.toolcommands import ToolCommands +from gitfourchette.settings import ComparisonMethod from gitfourchette.gitdriver.gitdelta import GitDelta from gitfourchette.gitdriver.lfspointer import LfsObjectCacheMissingError from gitfourchette.gitdriver.parsers import parseGitStatus, parseGitDiffRawZ @@ -307,16 +308,38 @@ def readDiffRawZ(self) -> list[GitDelta]: deltas = list(parseGitDiffRawZ(stdout)) return deltas + @staticmethod + def _gitDiffComparisonArgv(forDisplay: bool) -> list[str]: + """ + Extra `git diff` arguments for line-ending / whitespace handling. + Only applied when generating patches for on-screen display so exports, + revert, and trash backups stay exact. + """ + if not forDisplay: + return [] + m = settings.prefs.comparisonMethod + if m == ComparisonMethod.Strict: + return [] + if m == ComparisonMethod.IgnoreCrAtEol: + return ["--ignore-cr-at-eol"] + if m == ComparisonMethod.IgnoreCrAtEolAndSpaceChange: + return ["--ignore-cr-at-eol", "--ignore-space-change"] + if m == ComparisonMethod.IgnoreCrAtEolAndAllSpace: + return ["--ignore-cr-at-eol", "--ignore-all-space"] + return [] + @classmethod def buildDiffCommand( cls, delta: GitDelta | tuple[Oid | None, Oid | None] | None, - binary=True + binary=True, + forDisplay: bool = False, ) -> list[str]: tokens = [ "-c", "core.abbrev=no", "-c", f"diff.context={settings.prefs.contextLines}", "diff", + *cls._gitDiffComparisonArgv(forDisplay), *argsIf(binary, "--binary"), ] @@ -367,7 +390,9 @@ def buildDiffCommandLFS(cls, delta: GitDelta) -> list[str]: "--git-dir=", "-c", "core.abbrev=no", "-c", f"diff.context={settings.prefs.contextLines}", - "diff", "--no-index", "--", + "diff", + *cls._gitDiffComparisonArgv(True), + "--no-index", "--", delta.old.lfs.objectPath, delta.new.lfs.objectPath, ] diff --git a/gitfourchette/mainwindow.py b/gitfourchette/mainwindow.py index c64ee268..8ea6c99c 100644 --- a/gitfourchette/mainwindow.py +++ b/gitfourchette/mainwindow.py @@ -1175,6 +1175,7 @@ def onAcceptPrefsDialog(self, prefDiff: dict): "largeFileThresholdKB", "imageFileThresholdKB", "contextLines", + "comparisonMethod", "maxCommits", "renderSvg", "lfsAware", diff --git a/gitfourchette/repowidget.py b/gitfourchette/repowidget.py index 5d3736b2..442d1227 100644 --- a/gitfourchette/repowidget.py +++ b/gitfourchette/repowidget.py @@ -559,9 +559,15 @@ def toggleHideRefPattern(self, refPattern: str, allButThis: bool = False): # ------------------------------------------------------------------------- - def refreshRepo(self): + def refreshRepo( + self, + effects: TaskEffects = TaskEffects.DefaultRefresh, + jumpTo: NavLocator = NavLocator.Empty + ): """Refresh the repo as soon as possible.""" - self.taskRunner.pendingEpilog.effects |= TaskEffects.DefaultRefresh + self.taskRunner.pendingEpilog.effects |= effects + if jumpTo: + self.taskRunner.pendingEpilog.jumpTo = jumpTo self.onTaskRunnerReady() def onTaskRunnerReady(self): diff --git a/gitfourchette/settings.py b/gitfourchette/settings.py index 00d45877..8b76b557 100644 --- a/gitfourchette/settings.py +++ b/gitfourchette/settings.py @@ -88,6 +88,15 @@ class TabBarClick(enum.StrEnum): Terminal = "terminal" +class ComparisonMethod(enum.IntEnum): + """How `git diff` treats line endings and whitespace when showing patches.""" + + Strict = 0 + IgnoreCrAtEol = 1 + IgnoreCrAtEolAndSpaceChange = 2 + IgnoreCrAtEolAndAllSpace = 3 + + @dataclasses.dataclass class Prefs(PrefsFile): _filename = "prefs.json" @@ -111,6 +120,8 @@ class Prefs(PrefsFile): largeFileThresholdKB : int = 500 wordWrap : bool = False showStrayCRs : bool = True + showFormattingMarks : bool = False + comparisonMethod : ComparisonMethod = ComparisonMethod.Strict _category_imageDiff : int = 0 imageFileThresholdKB : int = 5000 diff --git a/gitfourchette/syntax/colorscheme.py b/gitfourchette/syntax/colorscheme.py index 0623744f..258766ad 100644 --- a/gitfourchette/syntax/colorscheme.py +++ b/gitfourchette/syntax/colorscheme.py @@ -13,7 +13,9 @@ try: import pygments.styles + import pygments.token hasPygments = True + Token = pygments.token.Token except ImportError: # pragma: no cover hasPygments = False @@ -120,11 +122,24 @@ def resolve(cls, name: str) -> ColorScheme: scheme.scheme[tokenType] = charFormat with suppress(KeyError): - scheme.foregroundColor = scheme.scheme[pygments.token.Token.Text].foreground().color() + scheme.foregroundColor = scheme.scheme[Token.Text].foreground().color() + + # Set consistent whitespace color + wsColor = QColor(scheme.foregroundColor) + wsColor.setAlphaF(.18) + wsFormat = QTextCharFormat() + wsFormat.setForeground(wsColor) + scheme.scheme[Token.Whitespace] = wsFormat cls._cachedScheme = scheme return scheme + @property + def whitespaceFormat(self) -> QTextCharFormat: + assert hasPygments + assert self.scheme + return self.scheme[Token.Whitespace] + @classmethod @benchmark def stylePreviews(cls, withPlugins: bool) -> dict[str, str]: @@ -153,9 +168,9 @@ def extractColor(style, *tokenTypes): for styleName in sorted(allStyles): style = pygments.styles.get_style_by_name(styleName) bgColor = QColor(style.background_color) - accent1 = extractColor(style, pygments.token.Name.Class, pygments.token.Text) - accent2 = extractColor(style, pygments.token.Name.Function, pygments.token.Operator) - accent3 = extractColor(style, pygments.token.Keyword, pygments.token.Comment) + accent1 = extractColor(style, Token.Name.Class, Token.Text) + accent2 = extractColor(style, Token.Name.Function, Token.Operator) + accent3 = extractColor(style, Token.Keyword, Token.Comment) # Little icon to preview the colors in this style (colorscheme-chip.svg) chipColors = f"black={bgColor.name()} white={accent1.name()} red={accent2.name()} blue={accent3.name()}" @@ -177,8 +192,7 @@ def refreshFallbackScheme(cls): @classmethod def fillInFallback(cls, scheme: dict, tokenType) -> QTextCharFormat: - from pygments.token import Token - + assert hasPygments assert Token in scheme assert tokenType not in scheme diff --git a/gitfourchette/tasks/loadtasks.py b/gitfourchette/tasks/loadtasks.py index dbd8c612..4b7692c3 100644 --- a/gitfourchette/tasks/loadtasks.py +++ b/gitfourchette/tasks/loadtasks.py @@ -341,7 +341,7 @@ def _getPatch( if loadLfs: tokens = GitDriver.buildDiffCommandLFS(delta) else: - tokens = GitDriver.buildDiffCommand(delta, binary=False) + tokens = GitDriver.buildDiffCommand(delta, binary=False, forDisplay=True) # Run diff command driver = yield from self.flowCallGit(*tokens, autoFail=False) diff --git a/gitfourchette/trtables.py b/gitfourchette/trtables.py index fb2d00fc..6ebc7e6a 100644 --- a/gitfourchette/trtables.py +++ b/gitfourchette/trtables.py @@ -104,7 +104,15 @@ def _init_enums(): from gitfourchette.porcelain import FileMode, NameValidationError from gitfourchette.toolbox import toLengthVariants from gitfourchette.sidebar.sidebarmodel import SidebarItem - from gitfourchette.settings import GraphRowHeight, QtApiNames, GraphRefBoxWidth, RefSort, TabBarClick, FileListClick + from gitfourchette.settings import ( + ComparisonMethod, + FileListClick, + GraphRefBoxWidth, + GraphRowHeight, + QtApiNames, + RefSort, + TabBarClick, + ) from gitfourchette.toolbox import PatchPurpose, PathDisplayStyle, AuthorDisplayStyle from gitfourchette.repomodel import GpgStatus @@ -207,6 +215,17 @@ def _init_enums(): AuthorDisplayStyle.EmailUserName: _("Abbreviated email"), }, + ComparisonMethod: { + ComparisonMethod.Strict: + _("Recognize line endings and white space differences"), + ComparisonMethod.IgnoreCrAtEol: + _("Ignore line ending differences"), + ComparisonMethod.IgnoreCrAtEolAndSpaceChange: + _("Ignore line ending and white space length differences"), + ComparisonMethod.IgnoreCrAtEolAndAllSpace: + _("Ignore line ending differences and all white space differences"), + }, + GraphRowHeight: { GraphRowHeight.Cramped : _p("row spacing", "Cramped"), GraphRowHeight.Tight : _p("row spacing", "Tight"), @@ -423,6 +442,13 @@ def _init_prefKeys(): "syntaxHighlighting": _("Syntax highlighting"), "wordWrap": _("Word wrap"), "showStrayCRs": _("Display alien line endings (CRLF)"), + "showFormattingMarks": _("Show formatting marks (tabs and spaces)"), + "comparisonMethod": _("Comparison method"), + "comparisonMethod_help": paragraphs( + _("Controls how {app} runs git diff when showing a patch in the diff view. " + "Exporting patches, reverting files, and similar actions still use an exact comparison."), + _("Line-ending handling uses Git’s --ignore-cr-at-eol option."), + ), "colorblind": _("“-/+” colors"), "colorblind_help": _("Background colors for deleted (-) and added (+) lines."), "renderSvg": _("SVG files"), diff --git a/test/test_diff.py b/test/test_diff.py index 14bc3681..7fad848b 100644 --- a/test/test_diff.py +++ b/test/test_diff.py @@ -8,8 +8,10 @@ import re import textwrap +from gitfourchette import settings from gitfourchette.diffview.diffview import DiffView from gitfourchette.nav import NavLocator +from gitfourchette.settings import ComparisonMethod from .util import * @@ -799,9 +801,11 @@ def testToggleWordWrap(tempDir, mainWindow): rw = mainWindow.openRepo(wd) dv = rw.diffView + wordWrapButton = rw.diffArea.diffButtons.wordWrapButton rw.jump(NavLocator.inUnstaged("longfile.txt"), check=True) assert dv.horizontalScrollBar().isVisible() + assert not wordWrapButton.isChecked() # Scroll down a bit and look at the first visible word dv.verticalScrollBar().setValue(5) @@ -812,6 +816,8 @@ def testToggleWordWrap(tempDir, mainWindow): triggerContextMenuAction(dv.viewport(), "word wrap") QTest.qWait(0) + assert wordWrapButton.isChecked() == enableWrap + # Horizontal scroll bar should only be visible without wrap assert dv.horizontalScrollBar().isVisible() == (not enableWrap) @@ -819,6 +825,36 @@ def testToggleWordWrap(tempDir, mainWindow): assert dv.firstVisibleBlock().text().startswith("y4x0") +def testToggleFormattingMarksHeaderButton(tempDir, mainWindow): + markFlags = QTextOption.Flag.ShowTabsAndSpaces + + wd = unpackRepo(tempDir) + writeFile(f"{wd}/marks.txt", "x\t y\n") + + rw = mainWindow.openRepo(wd) + rw.jump(NavLocator.inUnstaged("marks.txt"), check=True) + + dv = rw.diffView + formattingMarksButton = rw.diffArea.diffButtons.marksButton + assert formattingMarksButton is not None + + def formattingMarkBitsSet() -> bool: + f = dv.document().defaultTextOption().flags() + return (f & markFlags) == markFlags + + assert not settings.prefs.showFormattingMarks + assert not formattingMarksButton.isChecked() + assert not formattingMarkBitsSet() + + for enableMarks in [True, False]: + if formattingMarksButton.isChecked() != enableMarks: + formattingMarksButton.click() + + assert formattingMarksButton.isChecked() == enableMarks + assert settings.prefs.showFormattingMarks == enableMarks + assert formattingMarkBitsSet() == enableMarks + + def testRestoreScrollPositionWithWordWrap(tempDir, mainWindow): wd = unpackRepo(tempDir) @@ -1078,3 +1114,51 @@ def testDiffExoticLineEndings(tempDir, mainWindow): expectedLines[1] = expectedLines[1].removesuffix("\r") + "" assert expectedLines == reconstructedText[1:] + + +@requiresPygments +def testDiffTokenizationOnIndentedLineWithIgnoreAllSpace(tempDir, mainWindow): + oldText = textwrap.dedent("""\ + void hello(void) { + \tassignment = 0x12345678; // comment + } + """) + + newText = textwrap.dedent("""\ + void hello(void) { + \tif (indentNextLine) + \t\tassignment = 0x12345678; // comment + } + """) + + wd = unpackRepo(tempDir) + with RepoContext(wd) as repo: + for revision in oldText, newText: + writeFile(f"{wd}/hello.c", revision) + repo.index.add("hello.c") + repo.create_commit_on_head("hello", TEST_SIGNATURE, TEST_SIGNATURE) + + mainWindow.onAcceptPrefsDialog({ + # Ignore whitespace for this diff! + "comparisonMethod": ComparisonMethod.IgnoreCrAtEolAndAllSpace, + + # Pick a scheme that applies non-default color to identifiers + "syntaxHighlighting": "one-dark", + }) + + rw = mainWindow.openRepo(wd) + rw.jump(NavLocator.inCommit(rw.repo.head_commit_id, "hello.c"), check=True) + waitUntilTrue(lambda: rw.diffView.highlighter.newLexJob.lexingComplete) + + doc = rw.diffView.document() + block = doc.findBlockByLineNumber(3) + assert block.text() == "\t\tassignment = 0x12345678; // comment" + + # Ensure syntax highlighting matches up with the actual tokens in the line + colorTokens = [] + for span in block.layout().formats(): + token = block.text()[span.start: span.start + span.length].strip() + if token: + colorTokens.append(token) + + assert colorTokens == ["assignment", "=", "0x12345678", ";", "// comment"] diff --git a/test/test_diffcomparisonmethods.py b/test/test_diffcomparisonmethods.py new file mode 100644 index 00000000..39d08368 --- /dev/null +++ b/test/test_diffcomparisonmethods.py @@ -0,0 +1,173 @@ +# ----------------------------------------------------------------------------- +# Copyright (C) 2026 Iliyas Jorio. +# This file is part of GitFourchette, distributed under the GNU GPL v3. +# For full terms, see the included LICENSE file. +# ----------------------------------------------------------------------------- + +""" +Exercise Settings → Code → Comparison method against real `git diff` output +shown in the diff view (tabs vs spaces, run-length spaces, LF vs CRLF). +""" + +import pytest + +from gitfourchette.nav import NavLocator +from gitfourchette.settings import ComparisonMethod +from .util import * + + +def _commitTextFile(wd: str, relpath: str, contents: str): + with RepoContext(wd) as repo: + writeFile(f"{wd}{relpath}", contents) + repo.index.add_all([relpath]) + repo.create_commit_on_head(f"add {relpath}", TEST_SIGNATURE, TEST_SIGNATURE) + + +def _setWorktreeText(wd: str, relpath: str, contents: str): + writeFile(f"{wd}{relpath}", contents) + + +def _waitUnifiedDiffVisible(rw): + assert rw.diffView.isVisible() + assert rw.diffView.toPlainText().startswith("@@") + + +def _waitNoChangeSpecialVisible(rw): + assert rw.specialDiffView.isVisible() + assert findTextInWidget(rw.specialDiffView, r"whitespace changes ignored") + + +def _applyComparisonMethod(mainWindow, method: ComparisonMethod): + mainWindow.onAcceptPrefsDialog({"comparisonMethod": method}) + + +@pytest.mark.parametrize( + ("method", "expectUnifiedDiff"), + [ + (ComparisonMethod.Strict, True), + (ComparisonMethod.IgnoreCrAtEol, True), + (ComparisonMethod.IgnoreCrAtEolAndSpaceChange, False), + (ComparisonMethod.IgnoreCrAtEolAndAllSpace, False), + ], + ids=["strict", "ignore_eol", "ignore_eol_space_change", "ignore_eol_all_space"], +) +def testComparisonMethodTabsVsSpacesInDiffView( + tempDir, mainWindow, method, expectUnifiedDiff): + wd = unpackRepo(tempDir) + relpath = "comp_tabs_spaces.txt" + _commitTextFile(wd, relpath, "a\tb\n") + _setWorktreeText(wd, relpath, "a b\n") + + rw = mainWindow.openRepo(wd) + rw.jump(NavLocator.inUnstaged(relpath), check=True) + _waitUnifiedDiffVisible(rw) + + _applyComparisonMethod(mainWindow, method) + if expectUnifiedDiff: + _waitUnifiedDiffVisible(rw) + else: + _waitNoChangeSpecialVisible(rw) + + +def testReloadCurrentPatchWhenSwitchingWhitespaceMode(tempDir, mainWindow): + """ + Changing comparison via reloadCurrentPatchForPrefs (Jump.invoke) must refresh + the visible patch without selecting another file. + """ + wd = unpackRepo(tempDir) + relpath = "comp_reload_patch.txt" + _commitTextFile(wd, relpath, "a\tb\n") + _setWorktreeText(wd, relpath, "a b\n") + + rw = mainWindow.openRepo(wd) + rw.jump(NavLocator.inUnstaged(relpath), check=True) + _waitUnifiedDiffVisible(rw) + + _applyComparisonMethod(mainWindow, ComparisonMethod.IgnoreCrAtEolAndAllSpace) + _waitNoChangeSpecialVisible(rw) + + _applyComparisonMethod(mainWindow, ComparisonMethod.Strict) + _waitUnifiedDiffVisible(rw) + + +def testDiffHeaderWhitespaceMenuReloadPatch(tempDir, mainWindow): + """Diff header comparison menu must trigger the same reload as prefs.""" + wd = unpackRepo(tempDir) + relpath = "comp_header_buttons.txt" + _commitTextFile(wd, relpath, "a\tb\n") + _setWorktreeText(wd, relpath, "a b\n") + + rw = mainWindow.openRepo(wd) + rw.jump(NavLocator.inUnstaged(relpath), check=True) + _waitUnifiedDiffVisible(rw) + + menu = rw.diffArea.diffButtons.diffMethodButton.menu() + assert findMenuAction(menu, "recognize line endings and white space").isChecked() + + triggerMenuAction(menu, "ignore line ending.+and all white space") + _waitNoChangeSpecialVisible(rw) + assert findMenuAction(menu, "ignore line ending.+and all white space").isChecked() + + triggerMenuAction(menu, "recognize line endings and white space") + _waitUnifiedDiffVisible(rw) + assert findMenuAction(menu, "recognize line endings and white space").isChecked() + + +@pytest.mark.parametrize( + ("method", "expectUnifiedDiff"), + [ + (ComparisonMethod.Strict, True), + (ComparisonMethod.IgnoreCrAtEol, True), + (ComparisonMethod.IgnoreCrAtEolAndSpaceChange, False), + (ComparisonMethod.IgnoreCrAtEolAndAllSpace, False), + ], + ids=["strict", "ignore_eol", "ignore_eol_space_change", "ignore_eol_all_space"], +) +def testComparisonMethodSpaceRunLengthInDiffview( + tempDir, mainWindow, method, expectUnifiedDiff): + wd = unpackRepo(tempDir) + relpath = "comp_space_runs.txt" + _commitTextFile(wd, relpath, "a b c\n") + _setWorktreeText(wd, relpath, "a b c\n") + + rw = mainWindow.openRepo(wd) + rw.jump(NavLocator.inUnstaged(relpath), check=True) + _waitUnifiedDiffVisible(rw) + + _applyComparisonMethod(mainWindow, method) + if expectUnifiedDiff: + _waitUnifiedDiffVisible(rw) + else: + _waitNoChangeSpecialVisible(rw) + + +@pytest.mark.parametrize( + ("method", "expectUnifiedDiff"), + [ + (ComparisonMethod.Strict, True), + (ComparisonMethod.IgnoreCrAtEol, False), + (ComparisonMethod.IgnoreCrAtEolAndSpaceChange, False), + (ComparisonMethod.IgnoreCrAtEolAndAllSpace, False), + ], + ids=["strict", "ignore_eol", "ignore_eol_space_change", "ignore_eol_all_space"], +) +def testComparisonMethodLfVsCrlfInDiffView( + tempDir, mainWindow, method, expectUnifiedDiff): + wd = unpackRepo(tempDir) + if WINDOWS: + with RepoContext(wd) as repo: + repo.config["core.autocrlf"] = "false" + + relpath = "comp_eol.txt" + _commitTextFile(wd, relpath, "hello\n") + _setWorktreeText(wd, relpath, "hello\r\n") + + rw = mainWindow.openRepo(wd) + rw.jump(NavLocator.inUnstaged(relpath), check=True) + _waitUnifiedDiffVisible(rw) + + _applyComparisonMethod(mainWindow, method) + if expectUnifiedDiff: + _waitUnifiedDiffVisible(rw) + else: + _waitNoChangeSpecialVisible(rw) diff --git a/test/test_prefs.py b/test/test_prefs.py index 97713f59..63da8bba 100644 --- a/test/test_prefs.py +++ b/test/test_prefs.py @@ -6,8 +6,11 @@ import textwrap +from gitfourchette import settings from gitfourchette.forms.prefsdialog import PrefsDialog +from gitfourchette.gitdriver.gitdriver import GitDriver from gitfourchette.nav import NavLocator +from gitfourchette.settings import ComparisonMethod from gitfourchette.toolbox.fontpicker import FontPicker from .util import * @@ -153,6 +156,66 @@ def testPrefsRecreateDiffDocument(tempDir, mainWindow): assert "" not in rw.diffView.toPlainText() +def testPrefsShowFormattingMarks(tempDir, mainWindow): + markFlags = QTextOption.Flag.ShowTabsAndSpaces + + wd = unpackRepo(tempDir) + writeFile(f"{wd}/marks.txt", "x\t y\n") + rw = mainWindow.openRepo(wd) + assert rw.navLocator.isSimilarEnoughTo(NavLocator.inUnstaged("marks.txt")) + + def formattingMarksBitsSet() -> bool: + f = rw.diffView.document().defaultTextOption().flags() + return (f & markFlags) == markFlags + + assert not formattingMarksBitsSet() + + dlg = mainWindow.openPrefsDialog("showFormattingMarks") + checkBox: QCheckBox = dlg.findChild(QCheckBox, "prefctl_showFormattingMarks") + assert checkBox is not None + assert not checkBox.isChecked() + checkBox.setChecked(True) + dlg.accept() + + assert settings.prefs.showFormattingMarks + assert formattingMarksBitsSet() + + dlg = mainWindow.openPrefsDialog("showFormattingMarks") + checkBox: QCheckBox = dlg.findChild(QCheckBox, "prefctl_showFormattingMarks") + checkBox.setChecked(False) + dlg.accept() + + assert not settings.prefs.showFormattingMarks + assert not formattingMarksBitsSet() + + +def testComparisonMethodGitArgv(monkeypatch): + monkeypatch.setattr(settings.prefs, "comparisonMethod", ComparisonMethod.IgnoreCrAtEolAndAllSpace) + assert GitDriver._gitDiffComparisonArgv(True) == ["--ignore-cr-at-eol", "--ignore-all-space"] + + monkeypatch.setattr(settings.prefs, "comparisonMethod", ComparisonMethod.IgnoreCrAtEolAndSpaceChange) + assert GitDriver._gitDiffComparisonArgv(True) == ["--ignore-cr-at-eol", "--ignore-space-change"] + + monkeypatch.setattr(settings.prefs, "comparisonMethod", ComparisonMethod.IgnoreCrAtEol) + assert GitDriver._gitDiffComparisonArgv(True) == ["--ignore-cr-at-eol"] + + monkeypatch.setattr(settings.prefs, "comparisonMethod", ComparisonMethod.Strict) + assert GitDriver._gitDiffComparisonArgv(True) == [] + assert GitDriver._gitDiffComparisonArgv(False) == [] + + +def testPrefsComparisonMethodRadios(tempDir, mainWindow): + wd = unpackRepo(tempDir) + mainWindow.openRepo(wd) + dlg = mainWindow.openPrefsDialog("comparisonMethod") + container: QWidget = dlg.findChild(QWidget, "prefctl_comparisonMethod") + assert container is not None + radios = container.findChildren(QRadioButton) + assert len(radios) == len(ComparisonMethod) + assert sum(rb.isChecked() for rb in radios) == 1 + dlg.reject() + + def testPrefsUserCommandsSyntaxHighlighter(mainWindow): # This is just for code coverage for now. dlg = mainWindow.openPrefsDialog("commands") diff --git a/test/test_syntaxhighlighting.py b/test/test_syntaxhighlighting.py index d613529c..9e7c4954 100644 --- a/test/test_syntaxhighlighting.py +++ b/test/test_syntaxhighlighting.py @@ -8,7 +8,7 @@ from .util import * -from gitfourchette.syntax import syntaxHighlightingAvailable, LexJobCache, LexJob +from gitfourchette.syntax import LexJobCache, LexJob from gitfourchette.nav import NavLocator SAMPLE_CODE = """\ @@ -32,7 +32,7 @@ def digestFormatRange(formatRange: QTextLayout.FormatRange): return (start, length, isStyled) -@pytest.mark.skipif(not syntaxHighlightingAvailable, reason="pygments not available") +@requiresPygments def testDeferredSyntaxHighlighting(tempDir, mainWindow): wd = unpackRepo(tempDir) @@ -67,7 +67,7 @@ def testDeferredSyntaxHighlighting(tempDir, mainWindow): assert digestFormatRange(formatRange) == (0, len("hello multiline comment"), True) -@pytest.mark.skipif(not syntaxHighlightingAvailable, reason="pygments not available") +@requiresPygments def testLexJobCaching(tempDir, mainWindow): mainWindow.onAcceptPrefsDialog({"largeFileThresholdKB": 0}) @@ -112,7 +112,7 @@ def getNewLexJob() -> LexJob: assert job is getNewLexJob() -@pytest.mark.skipif(not syntaxHighlightingAvailable, reason="pygments not available") +@requiresPygments @pytest.mark.skipif(WINDOWS, reason="TODO: flaky on Windows") def testEvictLexJobFromCache(tempDir, mainWindow): mainWindow.onAcceptPrefsDialog({ 'largeFileThresholdKB': 1e6 }) @@ -164,7 +164,7 @@ def getNewLexJob() -> LexJob: # Simple coverage test -@pytest.mark.skipif(not syntaxHighlightingAvailable, reason="pygments not available") +@requiresPygments def testSyntaxHighlightingNullOid(tempDir, mainWindow): wd = unpackRepo(tempDir) @@ -177,7 +177,7 @@ def testSyntaxHighlightingNullOid(tempDir, mainWindow): # Simple coverage test -@pytest.mark.skipif(not syntaxHighlightingAvailable, reason="pygments not available") +@requiresPygments def testSyntaxHighlightingEmptyOid(tempDir, mainWindow): wd = unpackRepo(tempDir) @@ -189,7 +189,7 @@ def testSyntaxHighlightingEmptyOid(tempDir, mainWindow): mainWindow.openRepo(wd) -@pytest.mark.skipif(not syntaxHighlightingAvailable, reason="pygments not available") +@requiresPygments def testSyntaxHighlightingFillInFallbackTokenTypes(tempDir, mainWindow): # YAML has bespoke token types that aren't part of the standard Pygments token set, # e.g. Token.Literal.Scalar.Plain, Token.Punctuation.Indicator. diff --git a/test/test_tasks_blame.py b/test/test_tasks_blame.py index 5e5ca246..20409230 100644 --- a/test/test_tasks_blame.py +++ b/test/test_tasks_blame.py @@ -21,7 +21,6 @@ from gitfourchette.blameview.blamewindow import BlameWindow from gitfourchette.nav import NavLocator, NavContext from gitfourchette.repowidget import RepoWidget -from gitfourchette.syntax import syntaxHighlightingAvailable class BlameFixture: @@ -360,7 +359,7 @@ def testBlameUnborn(tempDir, mainWindow): acceptQMessageBox(mainWindow, "no commits in this repository") -@pytest.mark.skipif(not syntaxHighlightingAvailable, reason="pygments not available") +@requiresPygments def testBlameSyntaxHighlighting(tempDir, mainWindow): wd = unpackRepo(tempDir) diff --git a/test/util.py b/test/util.py index 2078c058..9fcefef1 100644 --- a/test/util.py +++ b/test/util.py @@ -21,6 +21,7 @@ from gitfourchette.exttools.toolcommands import ToolCommands from gitfourchette.porcelain import * from gitfourchette.toolbox import QPoint_zero, stripAccelerators, stripHtml +from gitfourchette.syntax import syntaxHighlightingAvailable from . import * TEST_SIGNATURE = Signature("Test Person", "toto@example.com", 1672600000, 0) @@ -49,6 +50,10 @@ os.environ.get("TESTFUSE", "") in {"0", ""}, reason="Requires FUSE (test.py --with-fuse)") +requiresPygments = pytest.mark.skipif( + not syntaxHighlightingAvailable, + reason="Requires Pygments") + _T = TypeVar("_T") _TInheritsQWidget = TypeVar("_TInheritsQWidget", bound=QWidget) _TInheritsQDialog = TypeVar("_TInheritsQDialog", bound=QDialog)