From 64d3b52069dc2285c9e1a3365c71780ceccfc1ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Nilsson=20Str=C3=B6m?= Date: Mon, 22 Jun 2026 12:34:40 +0200 Subject: [PATCH] Feature: Unique tab names When multiple tabs are open with the same name, use parent folder names to create unique tab names. E.g. two repos in folders "work/a/app" and "work/b/app" will be named "a/app" and "b/app". If parents have identical names, look upward in the tree until unique parent name can be found and hide identical parts with ellipsis. Notes: * Duplicate nicknames are allowed, as nicknames are always preferred over unique tab names. * A repo which has a name that conflicts with another repo's nickname will not be renamed. --- gitfourchette/mainwindow.py | 39 +++++++++--- gitfourchette/toolbox/__init__.py | 2 +- gitfourchette/toolbox/pathutils.py | 25 ++++++++ test/test_gitfourchette.py | 95 ++++++++++++++++++++++++++++++ test/test_pathutils.py | 61 +++++++++++++++++++ 5 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 test/test_pathutils.py diff --git a/gitfourchette/mainwindow.py b/gitfourchette/mainwindow.py index 031cff46..98aab2b5 100644 --- a/gitfourchette/mainwindow.py +++ b/gitfourchette/mainwindow.py @@ -646,6 +646,7 @@ def _openRepo(self, path: str, foreground=True, tabIndex=-1, exactMatch=True, lo self.tabs.setTabTooltip(tabIndex, compactPath(path)) if foreground: self.tabs.setCurrentIndex(tabIndex) + self.refreshAllTabTexts() # We've got at least one tab now, so switch away from WelcomeWidget assert self.tabs.count() > 0 @@ -663,7 +664,7 @@ def installRepoWidget(self, rw: RepoWidget, tabIndex: int): assert tabIndex >= 0, "stub to replace isn't in tabs" assert isinstance(repoStub, RepoStub), "yanked widget isn't RepoStub" - rw.nameChange.connect(lambda: self.onRepoNameChanged(rw)) + rw.nameChange.connect(self.onRepoNameChanged) rw.requestAttention.connect(lambda: self.onRepoRequestsAttention(rw)) rw.openRepo.connect(lambda path, locator: self.openRepoNextTo(rw, path, locator)) rw.openPrefs.connect(self.openPrefsDialog) @@ -677,6 +678,7 @@ def installRepoWidget(self, rw: RepoWidget, tabIndex: int): rw.windowTitleChanged.connect(lambda: self.onRepoWindowTitleChanged(rw)) self.tabs.swapWidget(tabIndex, rw) + self.refreshAllTabTexts() repoStub.setParent(None) # tabs don't deparent the widget repoStub.deleteLater() @@ -685,6 +687,7 @@ def replaceRepoWidgetWithStub(self, oldWidget: RepoWidget, stub: RepoStub): tabIndex = self.tabs.indexOf(oldWidget) assert tabIndex >= 0, "RepoWidget to replace isn't in tabs" self.tabs.swapWidget(tabIndex, stub) + self.refreshAllTabTexts() oldWidget.setParent(None) # tabs don't deparent the widget oldWidget.close() # will call cleanup @@ -700,8 +703,8 @@ def onRegainForeground(self): with suppress(NoRepoWidgetError): self.currentRepoWidget().refreshRepo() - def onRepoNameChanged(self, rw: RepoWidget): - self.refreshTabText(rw) + def onRepoNameChanged(self): + self.refreshAllTabTexts() self.fillRecentMenu() def onRepoWindowTitleChanged(self, rw: RepoWidget): @@ -853,6 +856,7 @@ def closeTab(self, index: int, finalTab: bool = True): # Remove the tab BEFORE cleaning up the widget # to prevent any interaction with it while it's wrapping up. self.tabs.removeTab(index) + self.refreshAllTabTexts() # Clean up the widget widget.close() # will call RepoWidget.cleanup() @@ -910,10 +914,31 @@ def reloadAllTabs(self): rw = self.currentRepoWidget() rw.replaceWithStub() - def refreshTabText(self, rw): - index = self.tabs.indexOf(rw) - title = escamp(rw.getTitle()) - self.tabs.setTabText(index, title) + def refreshAllTabTexts(self): + widgets = list(self.tabs.widgets()) + baseTitles = [widget.getTitle() for widget in widgets] + newTitles = baseTitles[:] + + groupsByTitle: dict[str, list[int]] = {} + for i, title in enumerate(baseTitles): + groupsByTitle.setdefault(title, []).append(i) + + for group in groupsByTitle.values(): + if len(group) <= 1: + continue + # Only disambiguate default (basename) titles; custom nicknames are kept as-is. + defaultIndices = [ + i for i in group + if not settings.history.getRepoNickname(widgets[i].workdir, strict=True)] + if len(defaultIndices) <= 1: + continue + workdirs = [widgets[i].workdir for i in defaultIndices] + disambiguatedTitles = disambiguateTabTitlesByPath(workdirs) + for idx, title in zip(defaultIndices, disambiguatedTitles, strict=True): + newTitles[idx] = title + + for i, title in enumerate(newTitles): + self.tabs.setTabText(i, escamp(title)) def openRepoNextTo(self, rw, path: str, locator: NavLocator = NavLocator.Empty): index = self.tabs.indexOf(rw) diff --git a/gitfourchette/toolbox/__init__.py b/gitfourchette/toolbox/__init__.py index d35adae4..7191e5cc 100644 --- a/gitfourchette/toolbox/__init__.py +++ b/gitfourchette/toolbox/__init__.py @@ -33,7 +33,7 @@ addULToMessageBox, NonCriticalOperation) from .iconbank import stockIcon, stockIconImgTag -from .pathutils import PathDisplayStyle, abbreviatePath, compactPath +from .pathutils import PathDisplayStyle, abbreviatePath, compactPath, disambiguateTabTitlesByPath from .persistentfiledialog import PersistentFileDialog from .qbusyspinner import QBusySpinner from .qcomboboxwithpreview import QComboBoxWithPreview diff --git a/gitfourchette/toolbox/pathutils.py b/gitfourchette/toolbox/pathutils.py index cdce5b3d..f69b2b5b 100644 --- a/gitfourchette/toolbox/pathutils.py +++ b/gitfourchette/toolbox/pathutils.py @@ -7,6 +7,7 @@ import enum import os from os import PathLike +from pathlib import Path HOME = os.path.abspath(os.path.expanduser('~')) @@ -26,6 +27,30 @@ def compactPath(path: str | PathLike) -> str: return path +def disambiguateTabTitlesByPath(workdirs: list[str]) -> list[str]: + """Return unique tab titles for repos that share the same base name.""" + partsList = [Path(compactPath(workdir)).parts for workdir in workdirs] + + maxDepth = max(len(parts) for parts in partsList) + uniqueDepth = maxDepth + for depth in range(1, maxDepth + 1): + tails = [parts[-depth:] for parts in partsList] + if len(set(tails)) == len(tails): + uniqueDepth = depth + break + + labels = [] + for parts in partsList: + tail = parts[-uniqueDepth:] + if len(tail) == 1: + labels.append(tail[0]) + elif len(tail) == 2: + labels.append(str(Path(*tail))) + else: + labels.append(str(Path(tail[0], "…", tail[-1]))) + return labels + + def abbreviatePath( path: str, style: PathDisplayStyle = PathDisplayStyle.FullPaths, diff --git a/test/test_gitfourchette.py b/test/test_gitfourchette.py index 84409bd1..a00258a7 100644 --- a/test/test_gitfourchette.py +++ b/test/test_gitfourchette.py @@ -4,7 +4,9 @@ # For full terms, see the included LICENSE file. # ----------------------------------------------------------------------------- +import os import os.path +import shutil from contextlib import suppress import pytest @@ -288,6 +290,99 @@ def testRepoNickname(tempDir, mainWindow): assert "TestGitRepository" in mainWindow.windowTitle() +def testTabNameDisambiguationByParentFolders(tempDir, mainWindow): + """Test that tab names are disambiguated by parent folders.""" + wd1 = unpackRepo(tempDir, renameTo="tmprepo1") + wd2 = unpackRepo(tempDir, renameTo="tmprepo2") + + # Create two repos in different parent folders + path1 = os.path.join(tempDir.name, "a", "repo") + path2 = os.path.join(tempDir.name, "b", "repo") + os.makedirs(os.path.dirname(path1), exist_ok=True) + os.makedirs(os.path.dirname(path2), exist_ok=True) + shutil.move(os.path.normpath(wd1), path1) + shutil.move(os.path.normpath(wd2), path2) + + mainWindow.openRepo(path1) + mainWindow.openRepo(path2) + + # Tab names should be "a/repo" and "b/repo" (adjusted for OS separator) + tabBar = mainWindow.tabs.tabs + assert tabBar.tabText(0) == os.path.join("a", "repo") + assert tabBar.tabText(1) == os.path.join("b", "repo") + + +def testTabNameDisambiguationFallbackToMiddleEllipsis(tempDir, mainWindow): + """If parent folders differ only far from the repo, elide the middle of the path.""" + wd1 = unpackRepo(tempDir, renameTo="tmprepo1") + wd2 = unpackRepo(tempDir, renameTo="tmprepo2") + + # Create two repos in different parent folders. Two levels above are identical (x, y). + path1 = os.path.join(tempDir.name, "a", "x", "y", "repo") + path2 = os.path.join(tempDir.name, "b", "x", "y", "repo") + os.makedirs(os.path.dirname(path1), exist_ok=True) + os.makedirs(os.path.dirname(path2), exist_ok=True) + shutil.move(os.path.normpath(wd1), path1) + shutil.move(os.path.normpath(wd2), path2) + + mainWindow.openRepo(path1) + mainWindow.openRepo(path2) + + tabBar = mainWindow.tabs.tabs + ellipsis = "…" + assert tabBar.tabText(0) == os.path.join("a", ellipsis, "repo") + assert tabBar.tabText(1) == os.path.join("b", ellipsis, "repo") + + +def testTabNameDisambiguationRevertsOnClose(tempDir, mainWindow): + """Closing one of two disambiguated tabs reverts the other to the repo name.""" + wd1 = unpackRepo(tempDir, renameTo="tmprepo1") + wd2 = unpackRepo(tempDir, renameTo="tmprepo2") + + path1 = os.path.join(tempDir.name, "a", "repo") + path2 = os.path.join(tempDir.name, "b", "repo") + os.makedirs(os.path.dirname(path1), exist_ok=True) + os.makedirs(os.path.dirname(path2), exist_ok=True) + shutil.move(os.path.normpath(wd1), path1) + shutil.move(os.path.normpath(wd2), path2) + + mainWindow.openRepo(path1) + mainWindow.openRepo(path2) + + tabBar = mainWindow.tabs.tabs + assert tabBar.tabText(0) == os.path.join("a", "repo") + assert tabBar.tabText(1) == os.path.join("b", "repo") + + mainWindow.closeTab(0) + assert mainWindow.tabs.count() == 1 + assert tabBar.tabText(0) == "repo" + + +def testTabNameDisambiguationRespectsCustomNickname(tempDir, mainWindow): + """Custom nicknames are kept even when multiple tabs share the same name.""" + from gitfourchette import settings + + wd1 = unpackRepo(tempDir, renameTo="tmprepo1") + wd2 = unpackRepo(tempDir, renameTo="tmprepo2") + + path1 = os.path.realpath(os.path.join(tempDir.name, "a", "repo")) + path2 = os.path.realpath(os.path.join(tempDir.name, "b", "repo")) + os.makedirs(os.path.dirname(path1), exist_ok=True) + os.makedirs(os.path.dirname(path2), exist_ok=True) + shutil.move(os.path.normpath(wd1), path1) + shutil.move(os.path.normpath(wd2), path2) + + settings.history.setRepoNickname(path1, "myrepo") + settings.history.setRepoNickname(path2, "myrepo") + + mainWindow.openRepo(path1) + mainWindow.openRepo(path2) + + tabBar = mainWindow.tabs.tabs + assert tabBar.tabText(0) == "myrepo" + assert tabBar.tabText(1) == "myrepo" + + @pytest.mark.parametrize("name", ["Zhack Sheerack", ""]) @pytest.mark.parametrize("email", ["chichi@example.com", ""]) def testCustomRepoIdentity(tempDir, mainWindow, name, email): diff --git a/test/test_pathutils.py b/test/test_pathutils.py new file mode 100644 index 00000000..bf1d234d --- /dev/null +++ b/test/test_pathutils.py @@ -0,0 +1,61 @@ +# ----------------------------------------------------------------------------- +# 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. +# ----------------------------------------------------------------------------- + +import os +from pathlib import Path + +import pytest + +from gitfourchette.toolbox.pathutils import HOME, disambiguateTabTitlesByPath + + +def _tabLabel(*parts: str) -> str: + if len(parts) == 2: + return str(Path(*parts)) + return str(Path(parts[0], "…", parts[-1])) + + +@pytest.mark.parametrize( + "workdirs, labels", + [ + pytest.param( + ["/work/a/app", "/work/b/app"], + [("a", "app"), ("b", "app")], + id="adjacent-parent", + ), + pytest.param( + ["/w/a/app", "/w/b/app", "/w/c/app"], + [("a", "app"), ("b", "app"), ("c", "app")], + id="three-tabs", + ), + pytest.param( + ["/a/x/y/repo", "/b/x/y/repo"], + [("a", "…", "repo"), ("b", "…", "repo")], + id="middle-ellipsis", + ), + pytest.param( + [ + os.path.join(HOME, "nested", "app"), + os.path.abspath("/work/nested/app"), + ], + [("~", "…", "app"), ("work", "…", "app")], + id="home-vs-absolute", + ), + pytest.param( + ["/a/foo/app", "/b/foo/app"], + [("a", "…", "app"), ("b", "…", "app")], + id="shared-intermediate", + ), + pytest.param( + ["/x/foo/v1/app", "/x/bar/v1/app"], + [("foo", "…", "app"), ("bar", "…", "app")], + id="deeper-disambiguator", + ), + ], +) +def testDisambiguateTabTitlesByPath(workdirs, labels): + expected = [_tabLabel(*parts) for parts in labels] + assert disambiguateTabTitlesByPath(workdirs) == expected