feat: add gitcompanion
This commit is contained in:
13 files changed
+851
-7
No files matched your search
+12
-2
@@ -5,6 +5,16 @@ icon = "view-app-grid-symbolic"
|
||||
icon_size = 20
|
||||
tooltip = true
|
||||
|
||||
[widgets.git_companion]
|
||||
icon = ""
|
||||
label = false
|
||||
tooltip = true
|
||||
username = "rubiin"
|
||||
repository = "rubiin/tsumiki"
|
||||
avatar_size = 44
|
||||
default_tab = "issues"
|
||||
cache_ttl = 300
|
||||
|
||||
[widgets.clipboard]
|
||||
icon = ""
|
||||
label = false
|
||||
@@ -418,8 +428,8 @@ tooltip = true
|
||||
|
||||
[layout]
|
||||
left_section = ["@group:0"]
|
||||
middle_section = ["date_time", "weather", "mpris"]
|
||||
right_section = [ "@group:1","@group:2", "system_tray", "power"]
|
||||
middle_section = ["date_time", "weather", "git_companion"]
|
||||
right_section = [ "@group:1","@group:2", "git_companion", "system_tray", "power"]
|
||||
|
||||
[[widget_groups]]
|
||||
widgets = ["workspaces", "window_title"]
|
||||
|
||||
+12
-2
@@ -5,6 +5,16 @@ icon = "view-app-grid-symbolic"
|
||||
icon_size = 20
|
||||
tooltip = true
|
||||
|
||||
[widgets.git_companion]
|
||||
icon = ""
|
||||
label = false
|
||||
tooltip = true
|
||||
username = "rubiin"
|
||||
repository = "rubiin/tsumiki"
|
||||
avatar_size = 44
|
||||
default_tab = "issues"
|
||||
cache_ttl = 300
|
||||
|
||||
[widgets.clipboard]
|
||||
icon = ""
|
||||
label = false
|
||||
@@ -418,8 +428,8 @@ tooltip = true
|
||||
|
||||
[layout]
|
||||
left_section = ["@group:0"]
|
||||
middle_section = ["date_time", "weather", "mpris"]
|
||||
right_section = [ "@group:1","@group:2", "system_tray", "power"]
|
||||
middle_section = ["date_time", "weather", "git_companion"]
|
||||
right_section = [ "@group:1","@group:2", "git_companion", "system_tray", "power"]
|
||||
|
||||
[[widget_groups]]
|
||||
widgets = ["workspaces", "window_title"]
|
||||
|
||||
@@ -123,6 +123,7 @@ LAZY_WIDGETS_LIST = {
|
||||
"window_title": "widgets.window_title.WindowTitleWidget",
|
||||
"workspaces": "widgets.workspaces.WorkSpacesWidget",
|
||||
"world_clock": "widgets.world_clock.WorldClockWidget",
|
||||
"git_companion": "widgets.git_companion.GitCompanionWidget",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ pillow==12.3.0 # Consider: audit for lighter alternatives if memory footprint i
|
||||
psutil==7.2.2 # Consider: audit for lighter alternatives if memory footprint is a concern
|
||||
pycairo==1.29.0
|
||||
PyGObject==3.50.0
|
||||
PyGObject-stubs==2.17.0
|
||||
PyGObject-stubs==2.16.0
|
||||
pytomlpp==1.1.0
|
||||
qrcode==8.2
|
||||
rlottie-python==1.3.8
|
||||
@@ -0,0 +1,153 @@
|
||||
@use "sass:color";
|
||||
@use "theme";
|
||||
@use "common";
|
||||
@use "variable";
|
||||
|
||||
#git_companion {
|
||||
@include common.widget_border(
|
||||
variable.$bar-widgets-git_companion-border-radius,
|
||||
variable.$bar-widgets-git_companion-border-enabled,
|
||||
variable.$bar-widgets-git_companion-border-width
|
||||
);
|
||||
|
||||
@include common.nerd-icon(variable.$bar-widgets-git_companion-icon_size, null);
|
||||
}
|
||||
|
||||
|
||||
#git-companion-window {
|
||||
padding: 14px;
|
||||
border-radius: 26px;
|
||||
color: theme.$text-main;
|
||||
min-height: 630px;
|
||||
min-width: 420px;
|
||||
|
||||
#git-companion-header {
|
||||
padding: 0 2px 2px;
|
||||
}
|
||||
|
||||
.git-companion-brand {
|
||||
color: #1f6feb;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.git-companion-title {
|
||||
color: theme.$text-main;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.git-companion-action-btn {
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
border-radius: 999px;
|
||||
background-color: #e9edf5;
|
||||
border: 1px solid rgba(84, 104, 142, 0.18);
|
||||
color: #5c6c89;
|
||||
|
||||
&:hover {
|
||||
background-color: #f4f6fb;
|
||||
}
|
||||
}
|
||||
|
||||
.git-companion-action-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#git-companion-profile {
|
||||
padding: 8px 4px 2px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
#git-companion-avatar-box {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
border-radius: 999px;
|
||||
background-color: #eef2f8;
|
||||
border: 2px solid #2f6df6;
|
||||
|
||||
}
|
||||
|
||||
#git-companion-avatar {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
#git-companion-avatar-fallback {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
background-color: #eef2f8;
|
||||
}
|
||||
|
||||
.git-companion-avatar-fallback-icon {
|
||||
color: #2f6df6;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.git-companion-name {
|
||||
color: theme.$text-main;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.git-companion-bio {
|
||||
color: #7d8698;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#git-companion-tabs {
|
||||
padding: 2px;
|
||||
border-radius: 18px;
|
||||
background-color: theme.$accent-purple;
|
||||
border: 1px solid rgba(84, 104, 142, 0.18);
|
||||
}
|
||||
|
||||
.git-companion-tab-btn {
|
||||
min-height: 32px;
|
||||
padding: 0 14px;
|
||||
border-radius: 14px;
|
||||
background-color: transparent;
|
||||
color: #4d5c78;
|
||||
font-weight: 600;
|
||||
|
||||
&.active {
|
||||
background-color: theme.$accent-orange;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.git-companion-tab-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#git-companion-scroller {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
#git-companion-items {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.git-companion-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background-color: theme.$background-alt;
|
||||
border: 1px solid rgba(84, 104, 142, 0.12);
|
||||
color: theme.$text-main;
|
||||
min-height: 44px;
|
||||
transition: background-color 120ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: #eef3ff;
|
||||
}
|
||||
}
|
||||
|
||||
.git-companion-item-title {
|
||||
color: theme.$text-main;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.git-companion-item-subtitle {
|
||||
color: #6e7890;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,11 @@ $bar-widgets-emoji_picker-border-radius: 16px;
|
||||
$bar-widgets-emoji_picker-border-width: 1px;
|
||||
$bar-widgets-emoji_picker-icon_size: 12px;
|
||||
$bar-widgets-emoji_picker-spacing: 0.125em;
|
||||
$bar-widgets-git_companion-border-enabled: false;
|
||||
$bar-widgets-git_companion-border-radius: 16px;
|
||||
$bar-widgets-git_companion-border-width: 1px;
|
||||
$bar-widgets-git_companion-icon_size: 12px;
|
||||
$bar-widgets-git_companion-spacing: 0.125em;
|
||||
$bar-widgets-hypridle-border-enabled: false;
|
||||
$bar-widgets-hypridle-border-radius: 16px;
|
||||
$bar-widgets-hypridle-border-width: 1px;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
@use "collapsible_group.scss";
|
||||
@use "cheatsheet.scss";
|
||||
@use "settings_gui.scss";
|
||||
@use "git_companion.scss";
|
||||
@use "pomodoro.scss";
|
||||
@use "usb_manager.scss";
|
||||
|
||||
|
||||
@@ -133,6 +133,14 @@ radius = "16px"
|
||||
width = "1px"
|
||||
|
||||
|
||||
[bar.widgets.git_companion]
|
||||
icon_size = "12px"
|
||||
spacing = "0.125em"
|
||||
|
||||
[bar.widgets.git_companion.border]
|
||||
enabled = false
|
||||
radius = "16px"
|
||||
width = "1px"
|
||||
|
||||
[bar.widgets.pomodoro]
|
||||
icon_size = "12px"
|
||||
|
||||
@@ -29,6 +29,57 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"git_companion": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tooltip": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Determines whether the Git Companion widget has a tooltip."
|
||||
},
|
||||
"label": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Determines whether to show a text label beside the icon."
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Icon displayed in the Git Companion widget."
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"default": "Thomas-Philippot",
|
||||
"description": "GitHub username used for the profile card."
|
||||
},
|
||||
"repository": {
|
||||
"type": "string",
|
||||
"default": "Thomas-Philippot/removarr-with-angular",
|
||||
"description": "Repository used for open issues and pull requests."
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Optional GitHub token for authenticated API requests."
|
||||
},
|
||||
"avatar_size": {
|
||||
"type": "integer",
|
||||
"default": 44,
|
||||
"description": "Avatar diameter in pixels."
|
||||
},
|
||||
"default_tab": {
|
||||
"type": "string",
|
||||
"enum": ["issues", "pull_requests"],
|
||||
"default": "issues",
|
||||
"description": "Tab shown first when popup opens."
|
||||
},
|
||||
"cache_ttl": {
|
||||
"type": "integer",
|
||||
"default": 300,
|
||||
"description": "Cache time-to-live in seconds before refetching GitHub data."
|
||||
}
|
||||
}
|
||||
},
|
||||
"battery": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2169,6 +2220,7 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"app_launcher_button",
|
||||
"git_companion",
|
||||
"battery",
|
||||
"bluetooth",
|
||||
"brightness",
|
||||
@@ -2261,6 +2313,7 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"app_launcher_button",
|
||||
"git_companion",
|
||||
"battery",
|
||||
"bluetooth",
|
||||
"brightness",
|
||||
@@ -2353,6 +2406,7 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"app_launcher_button",
|
||||
"git_companion",
|
||||
"battery",
|
||||
"bluetooth",
|
||||
"brightness",
|
||||
@@ -2458,6 +2512,7 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"app_launcher_button",
|
||||
"git_companion",
|
||||
"battery",
|
||||
"bluetooth",
|
||||
"brightness",
|
||||
|
||||
@@ -152,6 +152,18 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
"divider": {"size": 2},
|
||||
"cheatsheet": {},
|
||||
"git_companion": {
|
||||
"icon": "",
|
||||
"label": False,
|
||||
"tooltip": True,
|
||||
"username": "rubiin",
|
||||
"repository": "rubiin/tsumiki",
|
||||
"width": 420,
|
||||
"height": 630,
|
||||
"avatar_size": 44,
|
||||
"default_tab": "issues",
|
||||
"cache_ttl": 300,
|
||||
},
|
||||
"pomodoro": {
|
||||
"icon": "🍅",
|
||||
"label": True,
|
||||
|
||||
@@ -55,6 +55,12 @@ def register_temp_resource(path: str):
|
||||
TEMP_PATHS.add(path)
|
||||
|
||||
|
||||
def expand_env(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return os.path.expanduser(os.path.expandvars(value))
|
||||
|
||||
|
||||
def normalize_address(address: str | None) -> str | None:
|
||||
if not address:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
from contextlib import suppress
|
||||
from time import monotonic
|
||||
from urllib.request import urlopen
|
||||
|
||||
from fabric.utils import GdkPixbuf, exec_shell_command_async, idle_add, logger
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.scrolledwindow import ScrolledWindow
|
||||
|
||||
import utils.functions as helpers
|
||||
from shared.circle_image import CircularImage
|
||||
from shared.mixins import PopoverMixin
|
||||
from shared.widget_container import ButtonWidget
|
||||
from utils.config import widget_config
|
||||
from utils.icons import get_text_icon
|
||||
from utils.widget_utils import nerd_font_icon
|
||||
|
||||
|
||||
def _load_pixbuf_from_bytes(image_bytes: bytes, size: int):
|
||||
loader = GdkPixbuf.PixbufLoader()
|
||||
loader.write(image_bytes)
|
||||
loader.close()
|
||||
|
||||
pixbuf = loader.get_pixbuf()
|
||||
if pixbuf is None:
|
||||
return None
|
||||
|
||||
return pixbuf.scale_simple(size, size, GdkPixbuf.InterpType.BILINEAR)
|
||||
|
||||
|
||||
class GitHubClient:
|
||||
"""Small GitHub API client for public repo and profile data."""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
self.username = str(config.get("username", "Thomas-Philippot")).strip()
|
||||
self.repository = str(
|
||||
config.get("repository", "Thomas-Philippot/removarr-with-angular")
|
||||
).strip()
|
||||
self.token = helpers.expand_env(
|
||||
str(config.get("token") or os.environ.get("GITHUB_TOKEN", ""))
|
||||
)
|
||||
|
||||
@property
|
||||
def repo_owner(self) -> str:
|
||||
if "/" in self.repository:
|
||||
return self.repository.split("/", 1)[0]
|
||||
return self.username
|
||||
|
||||
@property
|
||||
def repo_url(self) -> str:
|
||||
return f"https://github.com/{self.repository}" if self.repository else ""
|
||||
|
||||
@property
|
||||
def profile_url(self) -> str:
|
||||
return f"https://github.com/{self.username}" if self.username else ""
|
||||
|
||||
def _run_gh_api(self, endpoint: str, params: dict | None = None) -> dict | list:
|
||||
command = ["gh", "api", endpoint, "--jq", ".", "-X", "GET"]
|
||||
if params:
|
||||
for key, value in params.items():
|
||||
command.extend(["-f", f"{key}={value}"])
|
||||
|
||||
env = os.environ.copy()
|
||||
if self.token:
|
||||
env["GH_TOKEN"] = self.token
|
||||
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=12,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
payload = result.stdout.strip()
|
||||
if not payload:
|
||||
return {}
|
||||
return json.loads(payload)
|
||||
|
||||
def fetch_state(self, avatar_size: int) -> dict:
|
||||
profile = {
|
||||
"name": self.username,
|
||||
"bio": "No bio",
|
||||
"avatar_url": "",
|
||||
"html_url": self.profile_url,
|
||||
}
|
||||
issues: list[dict] = []
|
||||
pulls: list[dict] = []
|
||||
|
||||
with suppress(Exception):
|
||||
profile_data = self._run_gh_api("user")
|
||||
if isinstance(profile_data, dict):
|
||||
profile["name"] = profile_data.get("name") or profile_data.get(
|
||||
"login", self.username
|
||||
)
|
||||
profile["bio"] = profile_data.get("bio") or "No bio"
|
||||
profile["avatar_url"] = profile_data.get("avatar_url", "")
|
||||
profile["html_url"] = profile_data.get("html_url") or self.profile_url
|
||||
|
||||
if profile.get("avatar_url") == "":
|
||||
with suppress(Exception):
|
||||
profile_data = self._run_gh_api(f"users/{self.username}")
|
||||
if isinstance(profile_data, dict):
|
||||
profile["avatar_url"] = profile_data.get("avatar_url", "")
|
||||
profile["html_url"] = (
|
||||
profile_data.get("html_url") or self.profile_url
|
||||
)
|
||||
|
||||
with suppress(Exception):
|
||||
repo_data = self._run_gh_api(f"repos/{self.repository}")
|
||||
if isinstance(repo_data, dict):
|
||||
profile["repo_full_name"] = repo_data.get("full_name", self.repository)
|
||||
profile["repo_description"] = repo_data.get("description", "") or ""
|
||||
|
||||
with suppress(Exception):
|
||||
issues_data = self._run_gh_api(
|
||||
f"repos/{self.repository}/issues",
|
||||
params={"state": "open", "per_page": 100, "sort": "created"},
|
||||
)
|
||||
if isinstance(issues_data, list):
|
||||
issues = [
|
||||
{**item, "kind": "issue"}
|
||||
for item in issues_data
|
||||
if isinstance(item, dict) and "pull_request" not in item
|
||||
]
|
||||
|
||||
with suppress(Exception):
|
||||
pulls_data = self._run_gh_api(
|
||||
f"repos/{self.repository}/pulls",
|
||||
params={"state": "open", "per_page": 100, "sort": "created"},
|
||||
)
|
||||
if isinstance(pulls_data, list):
|
||||
pulls = [
|
||||
{**item, "kind": "pull_request"}
|
||||
for item in pulls_data
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
|
||||
avatar_pixbuf = None
|
||||
avatar_url = str(profile.get("avatar_url", "")).strip()
|
||||
if avatar_url:
|
||||
with suppress(Exception):
|
||||
with urlopen(avatar_url, timeout=8) as avatar_response:
|
||||
avatar_bytes = avatar_response.read()
|
||||
avatar_pixbuf = _load_pixbuf_from_bytes(
|
||||
avatar_bytes,
|
||||
avatar_size,
|
||||
)
|
||||
|
||||
return {
|
||||
"profile": profile,
|
||||
"issues": issues,
|
||||
"pull_requests": pulls,
|
||||
"avatar_pixbuf": avatar_pixbuf,
|
||||
}
|
||||
|
||||
|
||||
class GitCompanionPopoverContent(Box):
|
||||
"""Popover content that matches the Git Companion preview."""
|
||||
|
||||
def __init__(self, config: dict, parent=None, **kwargs):
|
||||
self.widget_config = config
|
||||
self.config = config.get("widgets", {}).get("git_companion", {})
|
||||
self.client = GitHubClient(self.config)
|
||||
self.avatar_size = int(self.config.get("avatar_size", 44))
|
||||
self.default_tab = str(self.config.get("default_tab", "issues")).strip()
|
||||
self.cache_ttl = int(self.config.get("cache_ttl", 300))
|
||||
self._last_refresh_at = 0.0
|
||||
self._request_generation = 0
|
||||
self._active_tab = self._normalize_tab(self.default_tab)
|
||||
self._state: dict = {
|
||||
"profile": {
|
||||
"name": self.client.username,
|
||||
"bio": "No bio",
|
||||
"avatar_url": "",
|
||||
"html_url": self.client.profile_url,
|
||||
},
|
||||
"issues": [
|
||||
{
|
||||
"title": "test issue",
|
||||
"html_url": self.client.repo_url,
|
||||
"repository_url": self.client.repo_url,
|
||||
"number": 1,
|
||||
"repository": self.client.repository,
|
||||
"subtitle": f"{self.client.repository}#1",
|
||||
}
|
||||
],
|
||||
"pull_requests": [],
|
||||
"avatar_pixbuf": None,
|
||||
}
|
||||
|
||||
super().__init__(
|
||||
name="git-companion-window",
|
||||
orientation="v",
|
||||
spacing=10,
|
||||
**kwargs,
|
||||
)
|
||||
self._parent = parent
|
||||
|
||||
self._build_ui()
|
||||
self._apply_state()
|
||||
self.refresh(force=True)
|
||||
|
||||
def _normalize_tab(self, tab: str) -> str:
|
||||
return tab if tab in {"issues", "pull_requests"} else "issues"
|
||||
|
||||
def _build_ui(self):
|
||||
self.header = Box(
|
||||
name="git-companion-header",
|
||||
orientation="h",
|
||||
h_expand=True,
|
||||
spacing=8,
|
||||
)
|
||||
|
||||
self.title_box = Box(
|
||||
orientation="h",
|
||||
spacing=8,
|
||||
h_expand=True,
|
||||
children=[
|
||||
nerd_font_icon(
|
||||
icon="",
|
||||
props={"style_classes": ["git-companion-brand"]},
|
||||
),
|
||||
Label(
|
||||
label="Git Companion",
|
||||
style_classes=["git-companion-title"],
|
||||
h_align="start",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
self.header_actions = Box(
|
||||
orientation="h",
|
||||
spacing=6,
|
||||
h_align="end",
|
||||
children=[
|
||||
self._make_action_button(
|
||||
get_text_icon("ui.refresh"),
|
||||
"Refresh",
|
||||
self._on_refresh_clicked,
|
||||
),
|
||||
self._make_action_button(
|
||||
"",
|
||||
"Open repository",
|
||||
self._on_repo_clicked,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
self.header.children = [self.title_box, self.header_actions]
|
||||
|
||||
self.profile_card = Box(
|
||||
name="git-companion-profile",
|
||||
orientation="h",
|
||||
spacing=10,
|
||||
children=[],
|
||||
)
|
||||
|
||||
self.avatar_box = Box(
|
||||
name="git-companion-avatar-box",
|
||||
style_classes=["git-companion-avatar-box"],
|
||||
size_request=(self.avatar_size, self.avatar_size),
|
||||
)
|
||||
|
||||
self.profile_text = Box(
|
||||
orientation="v",
|
||||
spacing=2,
|
||||
h_expand=True,
|
||||
children=[
|
||||
Label(
|
||||
label=self.client.username,
|
||||
style_classes=["git-companion-name"],
|
||||
h_align="start",
|
||||
),
|
||||
Label(
|
||||
label="No bio",
|
||||
style_classes=["git-companion-bio"],
|
||||
h_align="start",
|
||||
),
|
||||
],
|
||||
)
|
||||
self.profile_card.children = [self.avatar_box, self.profile_text]
|
||||
|
||||
self.tabs = Box(
|
||||
name="git-companion-tabs",
|
||||
orientation="h",
|
||||
spacing=0,
|
||||
children=[
|
||||
self._make_tab_button("issues", "Issues (0)"),
|
||||
self._make_tab_button("pull_requests", "Pull Requests (0)"),
|
||||
],
|
||||
)
|
||||
|
||||
self.items_box = Box(
|
||||
name="git-companion-items",
|
||||
orientation="v",
|
||||
spacing=8,
|
||||
v_expand=True,
|
||||
h_expand=True,
|
||||
)
|
||||
|
||||
self.scrolled = ScrolledWindow(
|
||||
name="git-companion-scroller",
|
||||
h_scrollbar_policy="never",
|
||||
v_scrollbar_policy="automatic",
|
||||
child=self.items_box,
|
||||
)
|
||||
self.scrolled.set_min_content_height(420)
|
||||
|
||||
self.children = [
|
||||
self.header,
|
||||
self.profile_card,
|
||||
self.tabs,
|
||||
self.scrolled,
|
||||
]
|
||||
|
||||
self.name_label = self.profile_text.get_children()[0]
|
||||
self.bio_label = self.profile_text.get_children()[1]
|
||||
|
||||
def _make_action_button(self, icon: str, tooltip: str, callback):
|
||||
return Button(
|
||||
name="git-companion-action-btn",
|
||||
style_classes=["git-companion-action-btn"],
|
||||
child=nerd_font_icon(
|
||||
icon=icon,
|
||||
props={"style_classes": ["git-companion-action-icon"]},
|
||||
),
|
||||
tooltip_text=tooltip,
|
||||
on_clicked=callback,
|
||||
)
|
||||
|
||||
def _make_tab_button(self, tab: str, label: str):
|
||||
button = Button(
|
||||
name=f"git-companion-tab-{tab}",
|
||||
style_classes=["git-companion-tab-btn"],
|
||||
child=Label(label=label, style_classes=["git-companion-tab-label"]),
|
||||
on_clicked=lambda *_: self.set_tab(tab),
|
||||
)
|
||||
button._tab_name = tab # type: ignore[attr-defined]
|
||||
return button
|
||||
|
||||
def _on_refresh_clicked(self, *_):
|
||||
self.refresh(force=True)
|
||||
|
||||
def _on_repo_clicked(self, *_):
|
||||
target = self.client.repository or self.client.repo_url
|
||||
if target:
|
||||
exec_shell_command_async(f"gh repo view {shlex.quote(target)} --web")
|
||||
|
||||
def _set_tab_button_state(self):
|
||||
for button in self.tabs.get_children():
|
||||
tab_name = getattr(button, "_tab_name", None)
|
||||
if tab_name == self._active_tab:
|
||||
button.add_style_class("active")
|
||||
else:
|
||||
button.remove_style_class("active")
|
||||
|
||||
def _update_tab_labels(self):
|
||||
issues_count = len(self._state.get("issues", []))
|
||||
pulls_count = len(self._state.get("pull_requests", []))
|
||||
|
||||
for button in self.tabs.get_children():
|
||||
tab_name = getattr(button, "_tab_name", None)
|
||||
label = button.get_child()
|
||||
if not isinstance(label, Label):
|
||||
continue
|
||||
if tab_name == "issues":
|
||||
label.set_label(f"Issues ({issues_count})")
|
||||
elif tab_name == "pull_requests":
|
||||
label.set_label(f"Pull Requests ({pulls_count})")
|
||||
|
||||
def set_tab(self, tab: str):
|
||||
self._active_tab = self._normalize_tab(tab)
|
||||
self._set_tab_button_state()
|
||||
self._render_items()
|
||||
|
||||
def _set_avatar(self, pixbuf):
|
||||
if pixbuf is None:
|
||||
self.avatar_box.children = [
|
||||
Box(
|
||||
name="git-companion-avatar-fallback",
|
||||
orientation="v",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
children=[
|
||||
nerd_font_icon(
|
||||
icon="",
|
||||
props={
|
||||
"style_classes": ["git-companion-avatar-fallback-icon"]
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
return
|
||||
|
||||
self.avatar_box.children = [
|
||||
CircularImage(
|
||||
pixbuf=pixbuf,
|
||||
size=self.avatar_size,
|
||||
name="git-companion-avatar",
|
||||
)
|
||||
]
|
||||
|
||||
def _apply_state(self):
|
||||
profile = self._state.get("profile", {})
|
||||
self.name_label.set_label(str(profile.get("name", self.client.username)))
|
||||
bio_text = str(profile.get("bio", "No bio") or "No bio")
|
||||
if len(bio_text) > 30:
|
||||
bio_text = f"{bio_text[:29].rstrip()}…"
|
||||
self.bio_label.set_label(bio_text)
|
||||
self._set_avatar(self._state.get("avatar_pixbuf"))
|
||||
self._set_tab_button_state()
|
||||
self._update_tab_labels()
|
||||
self._render_items()
|
||||
|
||||
def _truncate_text(self, text: str, max_length: int = 30) -> str:
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return f"{text[: max_length - 1].rstrip()}…"
|
||||
|
||||
def _normalize_description(self, text: str) -> str:
|
||||
normalized = str(text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
lines = [line.strip() for line in normalized.split("\n") if line.strip()]
|
||||
if not lines:
|
||||
return ""
|
||||
|
||||
excerpt = "\n".join(lines[:2])
|
||||
if len(excerpt) > 140:
|
||||
excerpt = f"{excerpt[:139].rstrip()}…"
|
||||
|
||||
return excerpt
|
||||
|
||||
def _make_item_button(self, item: dict):
|
||||
title = self._truncate_text(str(item.get("title", "Untitled")).strip())
|
||||
subtitle = str(
|
||||
item.get("subtitle")
|
||||
or (
|
||||
f"{item.get('repository', self.client.repository)}"
|
||||
f"#{item.get('number', '')}"
|
||||
)
|
||||
).strip()
|
||||
description = self._normalize_description(
|
||||
item.get("body") or item.get("description")
|
||||
)
|
||||
number = str(item.get("number", "")).strip()
|
||||
kind = str(item.get("kind", "issue"))
|
||||
command = (
|
||||
f"gh pr view {shlex.quote(number)} --repo "
|
||||
f"{shlex.quote(self.client.repository)} --web"
|
||||
if kind == "pull_request"
|
||||
else f"gh issue view {shlex.quote(number)} --repo "
|
||||
f"{shlex.quote(self.client.repository)} --web"
|
||||
)
|
||||
|
||||
item_children = [
|
||||
Label(
|
||||
label=title,
|
||||
style_classes=["git-companion-item-title"],
|
||||
h_align="start",
|
||||
),
|
||||
Label(
|
||||
label=subtitle,
|
||||
style_classes=["git-companion-item-subtitle"],
|
||||
h_align="start",
|
||||
),
|
||||
]
|
||||
|
||||
if description:
|
||||
description_label = Label(
|
||||
label=description,
|
||||
style_classes=["git-companion-item-description"],
|
||||
h_align="start",
|
||||
line_wrap="word-char",
|
||||
max_chars_width=34,
|
||||
)
|
||||
description_label.set_lines(2)
|
||||
description_label.set_ellipsize(3)
|
||||
item_children.append(description_label)
|
||||
|
||||
item_box = Box(
|
||||
orientation="v",
|
||||
spacing=1,
|
||||
children=item_children,
|
||||
)
|
||||
|
||||
return Button(
|
||||
name="git-companion-item",
|
||||
style_classes=["git-companion-item"],
|
||||
child=item_box,
|
||||
on_clicked=(lambda *_: exec_shell_command_async(command))
|
||||
if number
|
||||
else None,
|
||||
)
|
||||
|
||||
def _render_items(self):
|
||||
items = self._state.get(self._active_tab, [])
|
||||
if not isinstance(items, list):
|
||||
items = []
|
||||
|
||||
self.items_box.children = [self._make_item_button(item) for item in items]
|
||||
|
||||
@helpers.run_in_thread
|
||||
def _refresh_async(self, generation: int):
|
||||
try:
|
||||
state = self.client.fetch_state(self.avatar_size)
|
||||
idle_add(self._apply_refresh_result, generation, state)
|
||||
except Exception as error:
|
||||
logger.warning(f"[GitCompanion] Refresh failed: {error}")
|
||||
idle_add(self._mark_refresh_complete, generation)
|
||||
|
||||
def _apply_refresh_result(self, generation: int, state: dict):
|
||||
if generation != self._request_generation:
|
||||
return False
|
||||
|
||||
self._state.update(state)
|
||||
self._last_refresh_at = monotonic()
|
||||
self._apply_state()
|
||||
return False
|
||||
|
||||
def _mark_refresh_complete(self, generation: int):
|
||||
if generation != self._request_generation:
|
||||
return False
|
||||
self._last_refresh_at = 0.0
|
||||
return False
|
||||
|
||||
def _is_cache_valid(self) -> bool:
|
||||
if self.cache_ttl <= 0 or self._last_refresh_at <= 0:
|
||||
return False
|
||||
|
||||
return (monotonic() - self._last_refresh_at) < self.cache_ttl
|
||||
|
||||
def refresh(self, force: bool = False):
|
||||
if not force and self._is_cache_valid():
|
||||
return
|
||||
|
||||
self._request_generation += 1
|
||||
generation = self._request_generation
|
||||
self._refresh_async(generation)
|
||||
|
||||
|
||||
class GitCompanionWidget(ButtonWidget, PopoverMixin):
|
||||
"""Bar button that opens Git Companion popup."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(name="git_companion", **kwargs)
|
||||
|
||||
self.container_box.children = nerd_font_icon(
|
||||
icon=self.config.get("icon", ""),
|
||||
props={"style_classes": ["panel-font-icon"]},
|
||||
)
|
||||
|
||||
if self.config.get("label", False):
|
||||
self.container_box.add(Label(label="Git", style_classes=["panel-text"]))
|
||||
|
||||
if self.config.get("tooltip", True) and self.tooltips_enabled:
|
||||
self.set_tooltip_text(self.config.get("tooltip_text", "Open Git Companion"))
|
||||
|
||||
self.setup_popover(
|
||||
lambda: GitCompanionPopoverContent(widget_config, parent=self),
|
||||
connect_clicked=False,
|
||||
)
|
||||
self.connect("clicked", self.on_click)
|
||||
|
||||
def on_click(self, *_):
|
||||
self.toggle_popover()
|
||||
|
||||
def toggle_popover(self) -> None:
|
||||
popup = self.popup
|
||||
if popup and hasattr(popup, "content") and hasattr(popup.content, "refresh"):
|
||||
popup.content.refresh(force=False)
|
||||
super().toggle_popover()
|
||||
@@ -18,7 +18,7 @@ from shared.dialog import Dialog
|
||||
from shared.mixins import PopoverMixin
|
||||
from shared.widget_container import ButtonWidget
|
||||
from utils.constants import ASSETS_DIR
|
||||
from utils.functions import lazy_load_class, safe_disconnect
|
||||
from utils.functions import expand_env, lazy_load_class, safe_disconnect
|
||||
from utils.icons import get_text_icon, network_icon_to_text_icons
|
||||
from utils.widget_utils import (
|
||||
get_audio_icon_name,
|
||||
@@ -171,7 +171,7 @@ class QuickSettingsMenu(Box):
|
||||
controls_config = self.config.get("controls", {})
|
||||
|
||||
raw_avatar_path = user_config.get("avatar", "$HOME/.face")
|
||||
avatar_path = os.path.expanduser(os.path.expandvars(raw_avatar_path))
|
||||
avatar_path = expand_env(raw_avatar_path)
|
||||
default_image = f"{ASSETS_DIR}/images/banner.jpg"
|
||||
user_image = avatar_path if os.path.exists(avatar_path) else default_image
|
||||
if user_image == default_image:
|
||||
|
||||
Reference in new issue
Block a user