From 1d80aa8879ad0e2010323c09cd1faf0a8d9a82e7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 5 Aug 2026 20:07:13 +0200 Subject: [PATCH] refactor: rebuild as a git menu plugin on nemo-repo-core Keeps the git half of the context menu from the single file extension. Open Terminal Here and Open in Editor are not git actions and work on any folder, so they moved to nemo-folder-tools. The actions are a table rather than three near identical blocks, which is what makes adding git fetch a one line change. Entries now also appear inside a repository rather than only on its root, using the upward walk from nemo-repo-core. --- .gitignore | 2 + LICENSE | 21 ++ config.default.json | 34 -- emblems/emblem-repo-claude.svg | 6 - emblems/emblem-repo-dirty.svg | 4 - emblems/emblem-repo-git.svg | 10 - emblems/emblem-repo-unpushed.svg | 7 - install.sh | 79 ++-- nemoRepoGitMenu.py | 96 +++++ nemo_repo_emblems.py | 607 ------------------------------- uninstall.sh | 20 + 11 files changed, 190 insertions(+), 696 deletions(-) create mode 100644 .gitignore create mode 100644 LICENSE delete mode 100644 config.default.json delete mode 100644 emblems/emblem-repo-claude.svg delete mode 100644 emblems/emblem-repo-dirty.svg delete mode 100644 emblems/emblem-repo-git.svg delete mode 100644 emblems/emblem-repo-unpushed.svg create mode 100644 nemoRepoGitMenu.py delete mode 100644 nemo_repo_emblems.py create mode 100755 uninstall.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43ae0e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7f75fac --- /dev/null +++ b/LICENSE @@ -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. diff --git a/config.default.json b/config.default.json deleted file mode 100644 index 5ec9de2..0000000 --- a/config.default.json +++ /dev/null @@ -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 -} diff --git a/emblems/emblem-repo-claude.svg b/emblems/emblem-repo-claude.svg deleted file mode 100644 index 484fc84..0000000 --- a/emblems/emblem-repo-claude.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/emblems/emblem-repo-dirty.svg b/emblems/emblem-repo-dirty.svg deleted file mode 100644 index 9eac45f..0000000 --- a/emblems/emblem-repo-dirty.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - ! - diff --git a/emblems/emblem-repo-git.svg b/emblems/emblem-repo-git.svg deleted file mode 100644 index aa07951..0000000 --- a/emblems/emblem-repo-git.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/emblems/emblem-repo-unpushed.svg b/emblems/emblem-repo-unpushed.svg deleted file mode 100644 index bad5946..0000000 --- a/emblems/emblem-repo-unpushed.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/install.sh b/install.sh index a5b155e..064ccf7 100755 --- a/install.sh +++ b/install.sh @@ -1,40 +1,63 @@ #!/usr/bin/env bash -# Installs the nemo-repo-emblems extension for the current user: -# - symlinks the extension .py into ~/.local/share/nemo-python/extensions/ -# - symlinks the bundled emblem SVGs into ~/.local/share/icons/hicolor/scalable/emblems/ -# - creates ~/.config/nemo-repo-emblems.json from the bundled default if missing -# - refreshes the icon cache and restarts nemo +# Installs the nemo-repo-git-menu plugin for the current user. No root needed. +# +# ./install.sh link the plugin (edits in this checkout go live) +# ./install.sh --copy copy the plugin instead of linking it +# +# Requires nemo-repo-core: https://github.com/dualfroz/nemo-repo-core 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="nemoRepoGitMenu.py" -EXT_DIR="$HOME/.local/share/nemo-python/extensions" -ICON_DIR="$HOME/.local/share/icons/hicolor/scalable/emblems" -CONFIG_PATH="$HOME/.config/nemo-repo-emblems.json" +mode="link" +if [ "${1:-}" = "--copy" ]; then + mode="copy" +elif [ -n "${1:-}" ]; then + echo "usage: $0 [--copy]" >&2 + exit 2 +fi -mkdir -p "$EXT_DIR" "$ICON_DIR" "$(dirname "$CONFIG_PATH")" +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: -ln -sf "$SRC_DIR/nemo_repo_emblems.py" "$EXT_DIR/nemo_repo_emblems.py" -echo "Linked extension -> $EXT_DIR/nemo_repo_emblems.py" + git clone https://github.com/dualfroz/nemo-repo-core + cd nemo-repo-core && ./install.sh +MESSAGE + exit 1 +fi -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" +mkdir -p "$extensionsDir" +target="$extensionsDir/$pluginFile" +rm -f "$target" +if [ "$mode" = "copy" ]; then + cp "$sourceDir/$pluginFile" "$target" + echo "Copied plugin -> $target" else - echo "Config already exists, left untouched -> $CONFIG_PATH" + ln -s "$sourceDir/$pluginFile" "$target" + echo "Linked plugin -> $target" fi -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 +if ! PYTHONPATH="$coreDir:$extensionsDir" python3 -c "import nemoRepoGitMenu" >/dev/null 2>&1; then + echo >&2 + echo "The plugin did not import cleanly:" >&2 + PYTHONPATH="$coreDir:$extensionsDir" python3 -c "import nemoRepoGitMenu" >&2 || true + exit 1 +fi +echo "Import check passed." + +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 "Restarting nemo..." -nemo -q || true -(nemo --no-desktop >/dev/null 2>&1 &) || true - -echo "Done. Edit $CONFIG_PATH to toggle indicators/actions (live-reloaded, no restart needed)." +echo +echo "Restart Nemo to load the plugin: nemo -q" diff --git a/nemoRepoGitMenu.py b/nemoRepoGitMenu.py new file mode 100644 index 0000000..040d14f --- /dev/null +++ b/nemoRepoGitMenu.py @@ -0,0 +1,96 @@ +#!/usr/bin/python3 +"""nemo-repo-git-menu: git commands in Nemo's right click menu. + +Entries appear on a folder that is inside a git repository, on the folder +itself and on the empty background of an open folder. Each one opens a +terminal in the repository root, runs the command and leaves the shell open +so the output stays readable. + +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, findEnclosingRepoRoot, gitBinary, + launchTerminal, localDirectoryPath, requireCore) + +requireCore("1.0.0") + +pluginDefaults = { + "actions": { + "git_status": True, + "git_log": True, + "git_fetch": True, + "git_pull": True, + }, +} + +coreConfig.registerDefaults(pluginDefaults) + +gitActions = [ + ("git_status", "Git Status", "git status", + "Show the working tree status in a terminal"), + ("git_log", "Git Log", "git log --oneline --graph --decorate -30", + "Show the last 30 commits as a graph in a terminal"), + ("git_fetch", "Git Fetch", "git fetch --all --prune", + "Fetch every remote and prune deleted branches"), + ("git_pull", "Git Pull", "git pull", + "Pull the current branch from its upstream"), +] + + +def _menuItem(key, label, command, tip, repoRoot): + item = Nemo.MenuItem(name="NemoRepoGitMenu::%s" % key, + label="%s (terminal)" % label, + tip="%s, in %s" % (tip, repoRoot)) + item.connect("activate", lambda *arguments: launchTerminal(repoRoot, command)) + return item + + +class RepoGitMenuProvider(GObject.GObject, Nemo.MenuProvider, + Nemo.NameAndDescProvider): + + def _itemsFor(self, path): + if not gitBinary: + return [] + repoRoot = findEnclosingRepoRoot(path) + if repoRoot is None: + return [] + return [_menuItem(key, label, command, tip, repoRoot) + for key, label, command, tip in gitActions + if coreConfig.sectionFlag("actions", key, True)] + + def get_file_items(self, window, files): + if len(files) != 1: + return [] + path = localDirectoryPath(files[0]) + return self._itemsFor(path) if path else [] + + def get_background_items(self, window, currentFolder): + path = localDirectoryPath(currentFolder) + return self._itemsFor(path) if path else [] + + def get_name_and_desc(self): + return ["nemo-repo-git-menu:::Right click git actions (status, log, fetch, pull)"] diff --git a/nemo_repo_emblems.py b/nemo_repo_emblems.py deleted file mode 100644 index 00b91f5..0000000 --- a/nemo_repo_emblems.py +++ /dev/null @@ -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 ... - 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)")] diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..b78ca0e --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,20 @@ +#!/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/nemoRepoGitMenu.py" + +if [ -e "$target" ]; then + rm -f "$target" + echo "Removed $target" +else + echo "Not installed: $target" +fi + +rm -f "$extensionsDir/__pycache__/nemoRepoGitMenu".*.pyc + +echo +echo "Restart Nemo to unload it: nemo -q"