refactor: rebuild as an emblem plugin on nemo-repo-core

Everything that was not about drawing emblems moves out: the columns, the properties tab and the context menu are their own plugins now, and the config, git and cache layers come from nemo-repo-core.

The indicator list stops being hardcoded. Entries that are not one of the three git derived indicators are matched by marker_directory or marker_file, which is how claude_dir now works and how a user adds their own without touching the code.
This commit is contained in:
dualfroz committed 2026-08-05 20:07:00 +02:00
1 parent d81b502300
commit 1b916d4d00
7 files changed
+256 -671

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.py[cod]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DualFroz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-34
View File
@@ -1,34 +0,0 @@
{
"cache_ttl_seconds": 5,
"indicators": {
"git_repo": {
"enabled": true,
"emblem": "emblem-repo-git",
"description": "Folder contains a .git directory (or worktree file)"
},
"claude_dir": {
"enabled": true,
"emblem": "emblem-repo-claude",
"description": "Folder contains a .claude directory"
},
"uncommitted_changes": {
"enabled": true,
"emblem": "emblem-repo-dirty",
"description": "git status shows staged/unstaged/untracked changes"
},
"unpushed_commits": {
"enabled": true,
"emblem": "emblem-repo-unpushed",
"description": "Current branch has commits ahead of its upstream (or no upstream at all)"
}
},
"actions": {
"open_terminal": true,
"open_editor": true,
"git_status": true,
"git_log": true,
"git_pull": true
},
"editor_command": "code",
"terminal_command": null
}
+70 -30
View File
@@ -1,40 +1,80 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Installs the nemo-repo-emblems extension for the current user: # Installs the nemo-repo-emblems plugin for the current user. No root needed.
# - symlinks the extension .py into ~/.local/share/nemo-python/extensions/ #
# - symlinks the bundled emblem SVGs into ~/.local/share/icons/hicolor/scalable/emblems/ # ./install.sh link the plugin (edits in this checkout go live)
# - creates ~/.config/nemo-repo-emblems.json from the bundled default if missing # ./install.sh --copy copy the plugin instead of linking it
# - refreshes the icon cache and restarts nemo #
# Requires nemo-repo-core: https://github.com/dualfroz/nemo-repo-core
set -euo pipefail set -euo pipefail
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" sourceDir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
dataHome="${XDG_DATA_HOME:-$HOME/.local/share}"
extensionsDir="$dataHome/nemo-python/extensions"
coreDir="$dataHome/nemo-repo-core"
pluginFile="nemoRepoEmblems.py"
EXT_DIR="$HOME/.local/share/nemo-python/extensions" mode="link"
ICON_DIR="$HOME/.local/share/icons/hicolor/scalable/emblems" if [ "${1:-}" = "--copy" ]; then
CONFIG_PATH="$HOME/.config/nemo-repo-emblems.json" mode="copy"
elif [ -n "${1:-}" ]; then
mkdir -p "$EXT_DIR" "$ICON_DIR" "$(dirname "$CONFIG_PATH")" echo "usage: $0 [--copy]" >&2
exit 2
ln -sf "$SRC_DIR/nemo_repo_emblems.py" "$EXT_DIR/nemo_repo_emblems.py"
echo "Linked extension -> $EXT_DIR/nemo_repo_emblems.py"
for svg in "$SRC_DIR"/emblems/*.svg; do
ln -sf "$svg" "$ICON_DIR/$(basename "$svg")"
done
echo "Linked emblem icons -> $ICON_DIR"
if [ ! -f "$CONFIG_PATH" ]; then
cp "$SRC_DIR/config.default.json" "$CONFIG_PATH"
echo "Created default config -> $CONFIG_PATH"
else
echo "Config already exists, left untouched -> $CONFIG_PATH"
fi fi
if [ ! -d "$coreDir/nemoRepoCore" ] && ! python3 -c "import nemoRepoCore" >/dev/null 2>&1; then
cat >&2 <<'MESSAGE'
nemo-repo-core is not installed, and this plugin is nothing without it:
git clone https://github.com/dualfroz/nemo-repo-core
cd nemo-repo-core && ./install.sh
MESSAGE
exit 1
fi
mkdir -p "$extensionsDir"
target="$extensionsDir/$pluginFile"
rm -f "$target"
if [ "$mode" = "copy" ]; then
cp "$sourceDir/$pluginFile" "$target"
echo "Copied plugin -> $target"
else
ln -s "$sourceDir/$pluginFile" "$target"
echo "Linked plugin -> $target"
fi
iconDir="$dataHome/icons/hicolor/scalable/emblems"
mkdir -p "$iconDir"
for svg in "$sourceDir"/emblems/*.svg; do
name="$(basename "$svg")"
rm -f "$iconDir/$name"
if [ "$mode" = "copy" ]; then
cp "$svg" "$iconDir/$name"
else
ln -s "$svg" "$iconDir/$name"
fi
done
echo "Installed emblem icons -> $iconDir"
if command -v gtk-update-icon-cache >/dev/null 2>&1; then if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true gtk-update-icon-cache -f -t "$dataHome/icons/hicolor" >/dev/null 2>&1 || true
fi fi
echo "Restarting nemo..." if ! PYTHONPATH="$coreDir:$extensionsDir" python3 -c "import nemoRepoEmblems" >/dev/null 2>&1; then
nemo -q || true echo >&2
(nemo --no-desktop >/dev/null 2>&1 &) || true echo "The plugin did not import cleanly:" >&2
PYTHONPATH="$coreDir:$extensionsDir" python3 -c "import nemoRepoEmblems" >&2 || true
exit 1
fi
echo "Import check passed."
echo "Done. Edit $CONFIG_PATH to toggle indicators/actions (live-reloaded, no restart needed)." legacyExtension="$extensionsDir/nemo_repo_emblems.py"
if [ -e "$legacyExtension" ]; then
echo
echo "WARNING: the pre-split single file extension is still installed:"
echo " $legacyExtension"
echo "Running both duplicates emblems, columns and menu entries. Remove it with:"
echo " rm \"$legacyExtension\""
fi
echo
echo "Restart Nemo to load the plugin: nemo -q"
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/python3
"""nemo-repo-emblems: emblem overlays for repository folders in Nemo.
Four indicators ship by default: the folder is a git repository, it holds a
.claude directory, its working tree is dirty, it has commits the remote does
not. Indicators are declared in the config file, so extra ones can be added
without touching this file.
Requires nemo-repo-core: https://github.com/dualfroz/nemo-repo-core
"""
import importlib.util
import os
import sys
def _ensureCoreOnPath():
if importlib.util.find_spec("nemoRepoCore") is not None:
return
dataHome = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share")
dataDirs = (os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share").split(":")
for base in [dataHome] + dataDirs:
candidate = os.path.join(base, "nemo-repo-core")
if os.path.isdir(os.path.join(candidate, "nemoRepoCore")):
sys.path.insert(0, candidate)
return
_ensureCoreOnPath()
import gi
gi.require_version("Nemo", "3.0")
from gi.repository import GObject, Nemo
from nemoRepoCore import (coreConfig, findRepoRoot, hasMarkerDirectory,
localDirectoryPath, pluginLogger, repoCache,
requireCore)
requireCore("1.0.0")
logger = pluginLogger("emblems")
gitIndicators = ("git_repo", "uncommitted_changes", "unpushed_commits")
pluginDefaults = {
"indicators": {
"git_repo": {
"enabled": True,
"emblem": "emblem-repo-git",
"description": "Folder contains a .git directory (or worktree file)",
},
"claude_dir": {
"enabled": True,
"emblem": "emblem-repo-claude",
"marker_directory": ".claude",
"description": "Folder contains a .claude directory",
},
"uncommitted_changes": {
"enabled": True,
"emblem": "emblem-repo-dirty",
"description": "git status shows staged, unstaged or untracked changes",
},
"unpushed_commits": {
"enabled": True,
"emblem": "emblem-repo-unpushed",
"description": "Branch is ahead of its upstream, or has no upstream",
},
},
}
coreConfig.registerDefaults(pluginDefaults)
def _enabledIndicators():
"""Config entries that are switched on and name an emblem, by key."""
active = {}
for key, entry in coreConfig.section("indicators").items():
if not isinstance(entry, dict) or not entry.get("enabled"):
continue
if entry.get("emblem"):
active[key] = entry
return active
def _markerMatches(path, entry):
markerDirectory = entry.get("marker_directory")
if markerDirectory and hasMarkerDirectory(path, markerDirectory):
return True
markerFile = entry.get("marker_file")
return bool(markerFile) and os.path.isfile(os.path.join(path, markerFile))
class RepoEmblemProvider(GObject.GObject, Nemo.InfoProvider,
Nemo.NameAndDescProvider):
def update_file_info(self, file):
path = localDirectoryPath(file)
if path is None:
return
active = _enabledIndicators()
for key, entry in active.items():
if key not in gitIndicators and _markerMatches(path, entry):
file.add_emblem(entry["emblem"])
repoRoot = findRepoRoot(path)
if repoRoot is None:
return
if "git_repo" in active:
file.add_emblem(active["git_repo"]["emblem"])
wantsDirty = "uncommitted_changes" in active
wantsUnpushed = "unpushed_commits" in active
if not (wantsDirty or wantsUnpushed):
return
repoCache.registerFileObject(repoRoot, file)
info = repoCache.get(repoRoot)
if info.error:
return
if wantsDirty and info.dirty:
file.add_emblem(active["uncommitted_changes"]["emblem"])
if wantsUnpushed and info.unpushed:
file.add_emblem(active["unpushed_commits"]["emblem"])
def get_name_and_desc(self):
return ["nemo-repo-emblems:::Emblem overlays for git repositories "
"and marker directories"]
-607
View File
@@ -1,607 +0,0 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
"""
nemo-repo-emblems
==================
A single nemo-python extension that gives Nemo (Cinnamon's file manager)
real awareness of git repositories:
* Nemo.InfoProvider -> configurable emblem overlays (git repo / .claude
dir / uncommitted changes / unpushed commits)
* Nemo.ColumnProvider -> "Git Branch" / "Git Status" columns for list view
* Nemo.PropertyPageProvider -> a "Git" tab in the folder's Properties
dialog with branch, ahead/behind, dirty counts,
untracked count and last commit
* Nemo.MenuProvider -> right-click actions: open terminal here, open in
editor, git status / git log / git pull in a
terminal
Config file: ~/.config/nemo-repo-emblems.json (created from
config.default.json next to this file on first run if missing). Edits are
picked up live via a file monitor -- no Nemo restart needed.
Performance: every git-derived fact for a repo root is cached with a TTL
(default 5s, see "cache_ttl_seconds") AND eagerly invalidated by watching
.git/HEAD, .git/index and .git/refs/heads for changes, so emblems update
promptly after a commit/checkout/pull without shelling out to git on every
Nemo redraw.
"""
import json
import logging
import os
import shutil
import subprocess
import time
import weakref
import gi
gi.require_version("Nemo", "3.0")
gi.require_version("Gtk", "3.0")
from gi.repository import GObject, Gio, GLib, Gtk, Nemo
logger = logging.getLogger("nemo-repo-emblems")
logging.basicConfig(level=logging.WARNING)
CONFIG_PATH = os.path.expanduser("~/.config/nemo-repo-emblems.json")
DEFAULT_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.default.json")
GIT_BIN = shutil.which("git")
CODE_BIN = shutil.which("code")
TERMINAL_CANDIDATES = ["gnome-terminal", "x-terminal-emulator", "tilix", "konsole",
"xfce4-terminal", "mate-terminal", "xterm"]
# --------------------------------------------------------------------------
# Config
# --------------------------------------------------------------------------
DEFAULT_CONFIG = {
"cache_ttl_seconds": 5,
"indicators": {
"git_repo": {"enabled": True, "emblem": "emblem-repo-git"},
"claude_dir": {"enabled": True, "emblem": "emblem-repo-claude"},
"uncommitted_changes": {"enabled": True, "emblem": "emblem-repo-dirty"},
"unpushed_commits": {"enabled": True, "emblem": "emblem-repo-unpushed"},
},
"actions": {
"open_terminal": True,
"open_editor": True,
"git_status": True,
"git_log": True,
"git_pull": True,
},
"editor_command": "code",
"terminal_command": None,
}
class Config:
"""Loads ~/.config/nemo-repo-emblems.json and reloads it live on change."""
def __init__(self):
self.data = dict(DEFAULT_CONFIG)
self._ensure_file_exists()
self._load()
self._monitor = None
self._watch()
def _ensure_file_exists(self):
if os.path.exists(CONFIG_PATH):
return
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
try:
if os.path.exists(DEFAULT_CONFIG_PATH):
shutil.copyfile(DEFAULT_CONFIG_PATH, CONFIG_PATH)
else:
with open(CONFIG_PATH, "w") as f:
json.dump(DEFAULT_CONFIG, f, indent=2)
except OSError as e:
logger.warning("Could not create default config at %s: %s", CONFIG_PATH, e)
def _load(self):
try:
with open(CONFIG_PATH) as f:
loaded = json.load(f)
merged = dict(DEFAULT_CONFIG)
merged["indicators"] = {**DEFAULT_CONFIG["indicators"], **loaded.get("indicators", {})}
merged["actions"] = {**DEFAULT_CONFIG["actions"], **loaded.get("actions", {})}
merged["cache_ttl_seconds"] = loaded.get("cache_ttl_seconds", DEFAULT_CONFIG["cache_ttl_seconds"])
merged["editor_command"] = loaded.get("editor_command", DEFAULT_CONFIG["editor_command"])
merged["terminal_command"] = loaded.get("terminal_command", DEFAULT_CONFIG["terminal_command"])
self.data = merged
logger.info("Loaded config from %s", CONFIG_PATH)
except (OSError, ValueError) as e:
logger.warning("Failed to load %s (%s), using defaults", CONFIG_PATH, e)
self.data = dict(DEFAULT_CONFIG)
def _watch(self):
try:
gfile = Gio.File.new_for_path(CONFIG_PATH)
self._monitor = gfile.monitor_file(Gio.FileMonitorFlags.NONE, None)
self._monitor.connect("changed", self._on_changed)
except GLib.Error as e:
logger.warning("Could not watch config file: %s", e)
def _on_changed(self, monitor, file, other_file, event_type):
if event_type in (Gio.FileMonitorEvent.CHANGES_DONE_HINT, Gio.FileMonitorEvent.CREATED):
self._load()
def indicator(self, name):
return self.data["indicators"].get(name, {"enabled": False, "emblem": ""})
def indicator_enabled(self, name):
return bool(self.indicator(name).get("enabled"))
def indicator_emblem(self, name):
return self.indicator(name).get("emblem", "")
def action_enabled(self, name):
return bool(self.data["actions"].get(name, False))
@property
def ttl(self):
try:
return float(self.data.get("cache_ttl_seconds", 5))
except (TypeError, ValueError):
return 5.0
@property
def editor_command(self):
return self.data.get("editor_command") or "code"
@property
def terminal_command(self):
return self.data.get("terminal_command")
CONFIG = Config()
# --------------------------------------------------------------------------
# Git status gathering + cache
# --------------------------------------------------------------------------
class RepoInfo:
__slots__ = ("branch", "upstream", "ahead", "behind", "has_upstream",
"staged", "unstaged", "untracked", "last_commit", "error")
def __init__(self):
self.branch = None
self.upstream = None
self.ahead = 0
self.behind = 0
self.has_upstream = False
self.staged = 0
self.unstaged = 0
self.untracked = 0
self.last_commit = None # (hash, author, relative_date, subject)
self.error = None
@property
def dirty(self):
return (self.staged + self.unstaged + self.untracked) > 0
@property
def unpushed(self):
# No upstream at all is also surfaced as "unpushed" (nothing to push TO)
return self.ahead > 0 or not self.has_upstream
def _run_git(args, cwd):
env = dict(os.environ)
env["GIT_OPTIONAL_LOCKS"] = "0"
try:
result = subprocess.run(
[GIT_BIN] + args,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
env=env,
timeout=3,
)
return result.stdout.decode("utf-8", "replace")
except (subprocess.TimeoutExpired, OSError):
return None
def compute_repo_info(repo_root):
info = RepoInfo()
if not GIT_BIN:
info.error = "git not found in PATH"
return info
out = _run_git(["status", "--porcelain=2", "--branch"], repo_root)
if out is None:
info.error = "git status failed"
return info
for line in out.splitlines():
if line.startswith("# branch.head "):
info.branch = line.split(" ", 2)[2]
elif line.startswith("# branch.upstream "):
info.upstream = line.split(" ", 2)[2]
info.has_upstream = True
elif line.startswith("# branch.ab "):
parts = line.split(" ")
for p in parts[2:]:
if p.startswith("+"):
info.ahead = int(p[1:])
elif p.startswith("-"):
info.behind = int(p[1:])
elif line.startswith("1 ") or line.startswith("2 "):
# ordinary / renamed-copied change: XY <sub> ...
xy = line.split(" ", 2)[1]
x, y = xy[0], xy[1]
if x != ".":
info.staged += 1
if y != ".":
info.unstaged += 1
elif line.startswith("u "):
info.staged += 1 # unmerged conflict, counts as needing attention
elif line.startswith("? "):
info.untracked += 1
log_out = _run_git(["log", "-1", "--format=%h%x09%an%x09%ar%x09%s"], repo_root)
if log_out:
parts = log_out.strip("\n").split("\t")
if len(parts) == 4:
info.last_commit = tuple(parts)
return info
class RepoCache:
"""TTL cache + git-directory file-watch invalidation, keyed by repo root path."""
def __init__(self, ttl_fn):
self._ttl_fn = ttl_fn
self._entries = {} # repo_root -> (RepoInfo, timestamp)
self._monitors = {} # repo_root -> [Gio.FileMonitor, ...]
self._watchers = {} # repo_root -> weakref.WeakSet[Nemo.FileInfo]
def get(self, repo_root):
now = time.monotonic()
entry = self._entries.get(repo_root)
if entry is not None:
info, ts = entry
if (now - ts) < self._ttl_fn():
return info
info = compute_repo_info(repo_root)
self._entries[repo_root] = (info, now)
self._ensure_watch(repo_root)
return info
def register_file_object(self, repo_root, nemo_file):
"""Track a live Nemo.FileInfo so we can force a re-query on git changes."""
watchers = self._watchers.setdefault(repo_root, weakref.WeakSet())
try:
watchers.add(nemo_file)
except TypeError:
pass
def _ensure_watch(self, repo_root):
if repo_root in self._monitors:
return
git_dir = os.path.join(repo_root, ".git")
# .git can be a file (worktrees / submodules) instead of a directory
if os.path.isfile(git_dir):
return
if not os.path.isdir(git_dir):
return
monitors = []
watch_paths = [
git_dir, # HEAD, index, MERGE_HEAD, ...
os.path.join(git_dir, "refs", "heads"), # local branch tip updates
]
for path in watch_paths:
if not os.path.exists(path):
continue
try:
gfile = Gio.File.new_for_path(path)
monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None)
monitor.connect("changed", self._on_git_dir_changed, repo_root)
monitors.append(monitor)
except GLib.Error as e:
logger.debug("Could not watch %s: %s", path, e)
if monitors:
self._monitors[repo_root] = monitors
def _on_git_dir_changed(self, monitor, gfile, other_file, event_type, repo_root):
# Invalidate immediately rather than waiting for TTL expiry.
self._entries.pop(repo_root, None)
for nemo_file in list(self._watchers.get(repo_root, ())):
try:
nemo_file.invalidate_extension_info()
except Exception:
pass
CACHE = RepoCache(lambda: CONFIG.ttl)
def find_repo_root(path):
"""A directory is a repo root, for our purposes, if it directly contains .git."""
if os.path.isdir(os.path.join(path, ".git")) or os.path.isfile(os.path.join(path, ".git")):
return path
return None
def has_claude_dir(path):
return os.path.isdir(os.path.join(path, ".claude"))
# --------------------------------------------------------------------------
# InfoProvider: emblems
# --------------------------------------------------------------------------
class RepoEmblemProvider(GObject.GObject, Nemo.InfoProvider, Nemo.NameAndDescProvider):
def update_file_info(self, file):
if file.get_uri_scheme() != "file":
return
if not file.is_directory():
return
path = GLib.filename_from_uri(file.get_uri())[0]
if CONFIG.indicator_enabled("claude_dir") and has_claude_dir(path):
emblem = CONFIG.indicator_emblem("claude_dir")
if emblem:
file.add_emblem(emblem)
repo_root = find_repo_root(path)
if repo_root is None:
return
if CONFIG.indicator_enabled("git_repo"):
emblem = CONFIG.indicator_emblem("git_repo")
if emblem:
file.add_emblem(emblem)
# Only shell out / consult the cache if we actually need dirty/unpushed state.
if CONFIG.indicator_enabled("uncommitted_changes") or CONFIG.indicator_enabled("unpushed_commits"):
CACHE.register_file_object(repo_root, file)
info = CACHE.get(repo_root)
if info.error is None:
if CONFIG.indicator_enabled("uncommitted_changes") and info.dirty:
emblem = CONFIG.indicator_emblem("uncommitted_changes")
if emblem:
file.add_emblem(emblem)
if CONFIG.indicator_enabled("unpushed_commits") and info.unpushed:
emblem = CONFIG.indicator_emblem("unpushed_commits")
if emblem:
file.add_emblem(emblem)
def get_name_and_desc(self):
return [("nemo-repo-emblems:::Configurable git-status emblems (.git / .claude / dirty / unpushed)")]
# --------------------------------------------------------------------------
# ColumnProvider: extra list-view columns
# --------------------------------------------------------------------------
class RepoColumnProvider(GObject.GObject, Nemo.ColumnProvider, Nemo.InfoProvider, Nemo.NameAndDescProvider):
def get_columns(self):
return [
Nemo.Column(name="NemoRepoEmblems::branch_column",
attribute="repo_branch",
label="Git Branch",
description="Current branch of the git repository"),
Nemo.Column(name="NemoRepoEmblems::status_column",
attribute="repo_status",
label="Git Status",
description="Ahead/behind + working tree status"),
]
def update_file_info(self, file):
if file.get_uri_scheme() != "file" or not file.is_directory():
return
path = GLib.filename_from_uri(file.get_uri())[0]
repo_root = find_repo_root(path)
if repo_root is None:
file.add_string_attribute("repo_branch", "")
file.add_string_attribute("repo_status", "")
return
CACHE.register_file_object(repo_root, file)
info = CACHE.get(repo_root)
if info.error:
file.add_string_attribute("repo_branch", "?")
file.add_string_attribute("repo_status", info.error)
return
file.add_string_attribute("repo_branch", info.branch or "(detached)")
bits = []
if info.ahead:
bits.append(f"↑{info.ahead}")
if info.behind:
bits.append(f"↓{info.behind}")
if not info.has_upstream:
bits.append("no upstream")
dirty_bits = []
if info.staged or info.unstaged:
dirty_bits.append(f"{info.staged + info.unstaged} changed")
if info.untracked:
dirty_bits.append(f"{info.untracked} untracked")
if dirty_bits:
bits.append("+".join(dirty_bits))
file.add_string_attribute("repo_status", ", ".join(bits) if bits else "clean")
def get_name_and_desc(self):
return [("nemo-repo-emblems-columns:::Git Branch / Git Status list-view columns")]
# --------------------------------------------------------------------------
# PropertyPageProvider: "Git" tab in Properties dialog
# --------------------------------------------------------------------------
class RepoPropertyPageProvider(GObject.GObject, Nemo.PropertyPageProvider, Nemo.NameAndDescProvider):
def get_property_pages(self, files):
if len(files) != 1:
return []
file = files[0]
if file.get_uri_scheme() != "file" or not file.is_directory():
return []
path = GLib.filename_from_uri(file.get_uri())[0]
repo_root = find_repo_root(path)
if repo_root is None:
return []
info = CACHE.get(repo_root)
grid = Gtk.Grid()
grid.set_row_spacing(6)
grid.set_column_spacing(12)
grid.set_margin_start(12)
grid.set_margin_end(12)
grid.set_margin_top(12)
grid.set_margin_bottom(12)
def add_row(row, label_text, value_text):
lbl = Gtk.Label(label=label_text)
lbl.set_xalign(0)
lbl.get_style_context().add_class("dim-label")
val = Gtk.Label(label=value_text)
val.set_xalign(0)
val.set_selectable(True)
val.set_line_wrap(True)
grid.attach(lbl, 0, row, 1, 1)
grid.attach(val, 1, row, 1, 1)
if info.error:
add_row(0, "Status", info.error)
else:
row = 0
add_row(row, "Repository", repo_root); row += 1
add_row(row, "Branch", info.branch or "(detached HEAD)"); row += 1
add_row(row, "Upstream", info.upstream if info.has_upstream else "(none)"); row += 1
add_row(row, "Ahead / Behind", f"{info.ahead} ahead, {info.behind} behind"); row += 1
add_row(row, "Staged changes", str(info.staged)); row += 1
add_row(row, "Unstaged changes", str(info.unstaged)); row += 1
add_row(row, "Untracked files", str(info.untracked)); row += 1
if info.last_commit:
h, author, when, subject = info.last_commit
add_row(row, "Last commit", f"{h} — {subject}"); row += 1
add_row(row, "", f"by {author}, {when}"); row += 1
grid.show_all()
label = Gtk.Label(label="Git")
return [Nemo.PropertyPage(name="NemoRepoEmblems::git_page", label=label, page=grid)]
def get_name_and_desc(self):
return [("nemo-repo-emblems-properties:::\"Git\" tab in the folder Properties dialog")]
# --------------------------------------------------------------------------
# MenuProvider: right-click actions
# --------------------------------------------------------------------------
def _resolve_terminal():
configured = CONFIG.terminal_command
if configured and shutil.which(configured.split(" ")[0]):
return configured
for candidate in TERMINAL_CANDIDATES:
if shutil.which(candidate):
return candidate
return None
def _launch_terminal(path, shell_command=None):
term = _resolve_terminal()
if not term:
logger.warning("No terminal emulator found")
return
try:
if shell_command:
inner = f"cd {GLib.shell_quote(path)} && {shell_command}; exec bash"
subprocess.Popen([term, "--", "bash", "-c", inner], cwd=path)
else:
subprocess.Popen([term], cwd=path)
except OSError as e:
logger.warning("Failed to launch terminal (%s): %s", term, e)
def _launch_editor(path):
editor = CONFIG.editor_command
binary = shutil.which(editor.split(" ")[0]) if editor else None
if not binary:
logger.warning("Editor command %r not found in PATH", editor)
return
try:
subprocess.Popen(editor.split(" ") + [path])
except OSError as e:
logger.warning("Failed to launch editor: %s", e)
class RepoMenuProvider(GObject.GObject, Nemo.MenuProvider, Nemo.NameAndDescProvider):
def _build_items(self, path, repo_root):
items = []
is_repo = repo_root is not None
if CONFIG.action_enabled("open_terminal"):
item = Nemo.MenuItem(name="NemoRepoEmblems::open_terminal",
label="Open Terminal Here",
tip=f"Open a terminal in {path}")
item.connect("activate", lambda *_: _launch_terminal(path))
items.append(item)
if CONFIG.action_enabled("open_editor") and CODE_BIN:
item = Nemo.MenuItem(name="NemoRepoEmblems::open_editor",
label="Open in Editor",
tip=f"Open {path} in {CONFIG.editor_command}")
item.connect("activate", lambda *_: _launch_editor(path))
items.append(item)
if is_repo and GIT_BIN:
if CONFIG.action_enabled("git_status"):
item = Nemo.MenuItem(name="NemoRepoEmblems::git_status",
label="Git Status (terminal)",
tip="Run git status in a terminal")
item.connect("activate", lambda *_: _launch_terminal(repo_root, "git status"))
items.append(item)
if CONFIG.action_enabled("git_log"):
item = Nemo.MenuItem(name="NemoRepoEmblems::git_log",
label="Git Log (terminal)",
tip="Run git log in a terminal")
item.connect("activate", lambda *_: _launch_terminal(
repo_root, "git log --oneline --graph --decorate -30"))
items.append(item)
if CONFIG.action_enabled("git_pull"):
item = Nemo.MenuItem(name="NemoRepoEmblems::git_pull",
label="Git Pull (terminal)",
tip="Run git pull in a terminal")
item.connect("activate", lambda *_: _launch_terminal(repo_root, "git pull"))
items.append(item)
return items
def get_file_items(self, window, files):
if len(files) != 1 or not files[0].is_directory():
return []
file = files[0]
if file.get_uri_scheme() != "file":
return []
path = GLib.filename_from_uri(file.get_uri())[0]
return self._build_items(path, find_repo_root(path))
def get_background_items(self, window, current_folder):
if current_folder.get_uri_scheme() != "file":
return []
path = GLib.filename_from_uri(current_folder.get_uri())[0]
return self._build_items(path, find_repo_root(path))
def get_name_and_desc(self):
return [("nemo-repo-emblems-menu:::Repo-aware right-click actions (terminal / editor / git status-log-pull)")]
Executable
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Removes this plugin from Nemo. The core, the other plugins and the config
# file are left alone.
set -euo pipefail
dataHome="${XDG_DATA_HOME:-$HOME/.local/share}"
extensionsDir="$dataHome/nemo-python/extensions"
target="$extensionsDir/nemoRepoEmblems.py"
if [ -e "$target" ]; then
rm -f "$target"
echo "Removed $target"
else
echo "Not installed: $target"
fi
rm -f "$extensionsDir/__pycache__/nemoRepoEmblems".*.pyc
iconDir="$dataHome/icons/hicolor/scalable/emblems"
for name in emblem-repo-git emblem-repo-claude emblem-repo-dirty emblem-repo-unpushed; do
if [ -e "$iconDir/$name.svg" ]; then
rm -f "$iconDir/$name.svg"
echo "Removed $iconDir/$name.svg"
fi
done
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -f -t "$dataHome/icons/hicolor" >/dev/null 2>&1 || true
fi
echo
echo "Restart Nemo to unload it: nemo -q"