From 9069d4dc1d6ad29bc3c9a78cf7715bb8dc8ff7e9 Mon Sep 17 00:00:00 2001 From: Zach Rammell Date: Mon, 2 Feb 2026 23:43:06 -0800 Subject: [PATCH 1/4] Force renaming support --- gitfourchette/tasks/jumptasks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gitfourchette/tasks/jumptasks.py b/gitfourchette/tasks/jumptasks.py index 5d2da97c..6b5a19b4 100644 --- a/gitfourchette/tasks/jumptasks.py +++ b/gitfourchette/tasks/jumptasks.py @@ -41,6 +41,7 @@ def loadWorkdir(task: RepoTask, allowWriteIndex: bool): """ gitStatus = yield from task.flowCallGit( *argsIf(not allowWriteIndex, "--no-optional-locks"), + "-c", "status.renames=true", "status", "--porcelain=v2", "-z", From b677c1a73365889daecb428059272b5fc35347ba Mon Sep 17 00:00:00 2001 From: Zach Rammell Date: Mon, 2 Feb 2026 23:43:41 -0800 Subject: [PATCH 2/4] Unstage both addition and deletion when unstaging rename --- gitfourchette/tasks/indextasks.py | 7 ++++++- test/test_filelist.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/gitfourchette/tasks/indextasks.py b/gitfourchette/tasks/indextasks.py index 49b314fe..e446531b 100644 --- a/gitfourchette/tasks/indextasks.py +++ b/gitfourchette/tasks/indextasks.py @@ -234,7 +234,12 @@ def flow(self, deltas: list[GitDelta]): QApplication.beep() raise AbortTask() - paths = [delta.new.path for delta in deltas] + paths = [] + for delta in deltas: + paths.append(delta.new.path) + if delta.status == "R": + paths.append(delta.old.path) + self.effects |= TaskEffects.Workdir # Not using 'restore --staged' because it doesn't work in an empty repo yield from self.flowCallGit("reset", "--", *paths) diff --git a/test/test_filelist.py b/test/test_filelist.py index e19ea369..efadfce6 100644 --- a/test/test_filelist.py +++ b/test/test_filelist.py @@ -660,3 +660,33 @@ def testFileListNaturalSort(tempDir, mainWindow): rw = mainWindow.openRepo(wd) assert qlvGetRowData(rw.dirtyFiles) == names + + +def testUnstageRenamedFile(tempDir, mainWindow): + wd = unpackRepo(tempDir) + writeFile(f"{wd}/a.txt", "content") + + with RepoContext(wd) as repo: + repo.index.add("a.txt") + repo.create_commit_on_head("initial", TEST_SIGNATURE, TEST_SIGNATURE) + + os.rename(f"{wd}/a.txt", f"{wd}/b.txt") + + with RepoContext(wd) as repo: + repo.index.add_all() + repo.index.write() + + rw = mainWindow.openRepo(wd) + + staged = list(qlvGetRowData(rw.stagedFiles)) + assert staged == ["b.txt"] + + rw.diffArea.stagedFiles.selectAll() + triggerContextMenuAction(rw.diffArea.stagedFiles.viewport(), "unstage") + + rw.refreshRepo() + + status = rw.repo.status() + + assert status['a.txt'] == FileStatus.WT_DELETED + assert status['b.txt'] == FileStatus.WT_NEW From 39791555e2e39e5355a3c66872b9c1b283dd80c1 Mon Sep 17 00:00:00 2001 From: Zach Rammell Date: Tue, 3 Feb 2026 11:40:04 -0800 Subject: [PATCH 3/4] Add "Filename first" path display style --- gitfourchette/filelists/filelist.py | 111 +++++++++++++++++++--------- gitfourchette/toolbox/pathutils.py | 6 ++ gitfourchette/trtables.py | 1 + test/test_filelist.py | 3 + 4 files changed, 86 insertions(+), 35 deletions(-) diff --git a/gitfourchette/filelists/filelist.py b/gitfourchette/filelists/filelist.py index 9c4f2e71..c8201bf4 100644 --- a/gitfourchette/filelists/filelist.py +++ b/gitfourchette/filelists/filelist.py @@ -72,46 +72,84 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIn if font: painter.setFont(font) fullText = index.data(Qt.ItemDataRole.DisplayRole) - text = painter.fontMetrics().elidedText(fullText, option.textElideMode, textRect.width()) + elideMode = Qt.TextElideMode.ElideRight if settings.prefs.pathDisplayStyle == PathDisplayStyle.FileNameFirst else option.textElideMode + text = painter.fontMetrics().elidedText(fullText, elideMode, textRect.width()) # Split path into directory and filename for better readability - dirPortion = None - filePortion = None + firstPortion = None + secondPortion = None + firstColor = QPalette.ColorRole.PlaceholderText + secondColor = QPalette.ColorRole.WindowText + + # Determine split based on style + isFileNameFirst = settings.prefs.pathDisplayStyle == PathDisplayStyle.FileNameFirst + + if isFileNameFirst: + # Rely on the raw path to identify the filename, as filenames can contain spaces + fullPath = index.data(FileListModel.Role.FilePath) + fName = os.path.basename(fullPath) + + prefix = fName + " " + if not text.startswith(prefix) and '\u2026' in text: + ellipsisPos = text.find('\u2026') + if fName.startswith(text[:ellipsisPos]): + prefix = text[:ellipsisPos + 1] - if '/' in fullText: - slashesInFull = fullText.count('/') - slashesInElided = text.count('/') + if text.startswith(prefix): + firstPortion = prefix + secondPortion = text[len(prefix):] + else: + firstPortion = text + secondPortion = "" + + firstColor = QPalette.ColorRole.WindowText + secondColor = QPalette.ColorRole.PlaceholderText - if slashesInFull > slashesInElided: - # A slash was elided - gray everything up to the ellipsis - ellipsisPos = text.find('\u2026') - dirPortion = text[:ellipsisPos + 1] - filePortion = text[ellipsisPos + 1:] - elif slashesInElided > 0: - # No slash elided - gray up to the last slash - lastSlash = text.rfind('/') - dirPortion = text[:lastSlash + 1] - filePortion = text[lastSlash + 1:] - - if dirPortion is not None: - textColor = QPalette.ColorRole.WindowText if not isSelected else QPalette.ColorRole.HighlightedText - dirColor = QPalette.ColorRole.PlaceholderText if not isSelected else textColor - - # Draw directory with muted color - mutedColor = option.palette.color(colorGroup, dirColor) - if isSelected: - mutedColor.setAlphaF(.7) - painter.setPen(mutedColor) - painter.drawText(textRect, option.displayAlignment, dirPortion) - - # Draw filename with normal color - painter.setPen(option.palette.color(colorGroup, textColor)) - dirWidth = painter.fontMetrics().horizontalAdvance(dirPortion) - fileRect = QRect(textRect) - fileRect.setLeft(textRect.left() + dirWidth) - painter.drawText(fileRect, option.displayAlignment, filePortion) else: - painter.drawText(textRect, option.displayAlignment, text) + if '/' in fullText: + slashesInFull = fullText.count('/') + slashesInElided = text.count('/') + + if slashesInFull > slashesInElided: + # A slash was elided - gray everything up to the ellipsis + ellipsisPos = text.find('\u2026') + firstPortion = text[:ellipsisPos + 1] + secondPortion = text[ellipsisPos + 1:] + elif slashesInElided > 0: + # No slash elided - gray up to the last slash + lastSlash = text.rfind('/') + firstPortion = text[:lastSlash + 1] + secondPortion = text[lastSlash + 1:] + + if firstPortion is None: + firstPortion = "" + secondPortion = text + + firstColor = QPalette.ColorRole.PlaceholderText + secondColor = QPalette.ColorRole.WindowText + + # Draw the parts + if firstPortion: + fg1 = option.palette.color(colorGroup, firstColor if not isSelected else QPalette.ColorRole.HighlightedText) + if firstColor == QPalette.ColorRole.PlaceholderText and isSelected: + fg1 = QColor(option.palette.color(colorGroup, QPalette.ColorRole.HighlightedText)) + fg1.setAlphaF(0.7) + + painter.setPen(fg1) + painter.drawText(textRect, option.displayAlignment, firstPortion) + + # Prepare rect for second part + part1Width = painter.fontMetrics().horizontalAdvance(firstPortion) + textRect.setLeft(textRect.left() + part1Width) + + if secondPortion: + fg2 = option.palette.color(colorGroup, secondColor if not isSelected else QPalette.ColorRole.HighlightedText) + if secondColor == QPalette.ColorRole.PlaceholderText and isSelected: + fg2 = QColor(option.palette.color(colorGroup, QPalette.ColorRole.HighlightedText)) + fg2.setAlphaF(0.7) + + painter.setPen(fg2) + painter.drawText(textRect, option.displayAlignment, secondPortion) # Highlight search term if searchTerm and searchTerm in fullText.lower(): @@ -122,8 +160,11 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIn else: needleLen = len(searchTerm) + # Use original textRect (before we advanced it for the second part) + textRect.setLeft(textRect.left() - part1Width if firstPortion else 0) SearchBar.highlightNeedle(painter, textRect, text, needlePos, needleLen) + painter.restore() diff --git a/gitfourchette/toolbox/pathutils.py b/gitfourchette/toolbox/pathutils.py index 12b0d1d4..d83b6114 100644 --- a/gitfourchette/toolbox/pathutils.py +++ b/gitfourchette/toolbox/pathutils.py @@ -14,6 +14,7 @@ class PathDisplayStyle(enum.IntEnum): FullPaths = 1 AbbreviateDirs = 2 FileNameOnly = 3 + FileNameFirst = 4 def compactPath(path: str) -> str: @@ -33,6 +34,11 @@ def abbreviatePath(path: str, style: PathDisplayStyle = PathDisplayStyle.FullPat else: splitLong[i] = splitLong[i][0] return '/'.join(splitLong) + elif style == PathDisplayStyle.FileNameFirst: + split = path.rsplit('/', 1) + if len(split) == 1: + return path + return split[-1] + ' ' + split[0] elif style == PathDisplayStyle.FileNameOnly: return path.rsplit('/', 1)[-1] else: diff --git a/gitfourchette/trtables.py b/gitfourchette/trtables.py index fc6eb016..c0cbe0c7 100644 --- a/gitfourchette/trtables.py +++ b/gitfourchette/trtables.py @@ -195,6 +195,7 @@ def _init_enums(): PathDisplayStyle.FullPaths : _("Full paths"), PathDisplayStyle.AbbreviateDirs : _("Abbreviate directories"), PathDisplayStyle.FileNameOnly : _("Show filename only"), + PathDisplayStyle.FileNameFirst : _("Filename first"), }, AuthorDisplayStyle: { diff --git a/test/test_filelist.py b/test/test_filelist.py index efadfce6..f2d12fde 100644 --- a/test/test_filelist.py +++ b/test/test_filelist.py @@ -498,6 +498,9 @@ def testFileListChangePathDisplayStyle(tempDir, mainWindow): triggerContextMenuAction(rw.committedFiles.viewport(), "path display style/full") assert ["c/c2-2.txt"] == qlvGetRowData(rw.committedFiles) + triggerContextMenuAction(rw.committedFiles.viewport(), "path display style/name first") + assert ["c2-2.txt c"] == qlvGetRowData(rw.committedFiles) + def testFileListShowInFolder(tempDir, mainWindow): wd = unpackRepo(tempDir) From f53e7dbd48512297223ac86831e8f32d5be100b5 Mon Sep 17 00:00:00 2001 From: Zach Rammell Date: Tue, 3 Feb 2026 11:45:28 -0800 Subject: [PATCH 4/4] Make double-clicking stage/unstage files --- gitfourchette/filelists/dirtyfiles.py | 3 +++ gitfourchette/filelists/filelist.py | 11 +++++++++++ gitfourchette/filelists/stagedfiles.py | 3 +++ 3 files changed, 17 insertions(+) diff --git a/gitfourchette/filelists/dirtyfiles.py b/gitfourchette/filelists/dirtyfiles.py index 8a0c8f5c..09a915df 100644 --- a/gitfourchette/filelists/dirtyfiles.py +++ b/gitfourchette/filelists/dirtyfiles.py @@ -174,3 +174,6 @@ def ignoreSelection(self): return NewIgnorePattern.invoke(self, selected[0]) + + def onItemDoubleClicked(self, index: QModelIndex): + self.stage() diff --git a/gitfourchette/filelists/filelist.py b/gitfourchette/filelists/filelist.py index c8201bf4..9d66ba2b 100644 --- a/gitfourchette/filelists/filelist.py +++ b/gitfourchette/filelists/filelist.py @@ -616,6 +616,17 @@ def onSpecialMouseClick(self): """ Override this if you want to react to a middle click. """ pass + def mouseDoubleClickEvent(self, event: QMouseEvent): + index = self.indexAt(event.pos()) + if index.isValid(): + self.onItemDoubleClicked(index) + else: + super().mouseDoubleClickEvent(event) + + def onItemDoubleClicked(self, index: QModelIndex): + """ Override this if you want to react to a double click. """ + pass + def selectedDeltas(self) -> Generator[GitDelta, None, None]: for index in self.selectedIndexes(): yield index.data(FileListModel.Role.Delta) diff --git a/gitfourchette/filelists/stagedfiles.py b/gitfourchette/filelists/stagedfiles.py index c5999d7d..6a7e2114 100644 --- a/gitfourchette/filelists/stagedfiles.py +++ b/gitfourchette/filelists/stagedfiles.py @@ -88,3 +88,6 @@ def unstageModeChange(self): def onSpecialMouseClick(self): if settings.prefs.middleClickToStage: self.unstage() + + def onItemDoubleClicked(self, index: QModelIndex): + self.unstage()