Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 additions & 7 deletions gitfourchette/mainwindow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion gitfourchette/toolbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions gitfourchette/toolbox/pathutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import enum
import os
from os import PathLike
from pathlib import Path

HOME = os.path.abspath(os.path.expanduser('~'))

Expand All @@ -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,
Expand Down
95 changes: 95 additions & 0 deletions test/test_gitfourchette.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
61 changes: 61 additions & 0 deletions test/test_pathutils.py
Original file line number Diff line number Diff line change
@@ -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