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..7e5a319 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-columns 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="nemoRepoColumns.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 nemoRepoColumns" >/dev/null 2>&1; then
+ echo >&2
+ echo "The plugin did not import cleanly:" >&2
+ PYTHONPATH="$coreDir:$extensionsDir" python3 -c "import nemoRepoColumns" >&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/nemoRepoColumns.py b/nemoRepoColumns.py
new file mode 100644
index 0000000..18ca790
--- /dev/null
+++ b/nemoRepoColumns.py
@@ -0,0 +1,98 @@
+#!/usr/bin/python3
+"""nemo-repo-columns: "Git Branch" and "Git Status" columns for Nemo.
+
+Enable them per folder through Nemo's own "Visible Columns" dialog, or
+globally under Edit > Preferences > List View. Values are filled in for
+repository roots and left blank everywhere else.
+
+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, localDirectoryPath,
+ repoCache, requireCore)
+
+requireCore("1.0.0")
+
+branchAttribute = "repo_branch"
+statusAttribute = "repo_status"
+
+pluginDefaults = {
+ "columns": {
+ "branch": True,
+ "status": True,
+ },
+}
+
+coreConfig.registerDefaults(pluginDefaults)
+
+
+class RepoColumnProvider(GObject.GObject, Nemo.ColumnProvider,
+ Nemo.InfoProvider, Nemo.NameAndDescProvider):
+
+ def get_columns(self):
+ """Columns are read once, when Nemo loads the extension.
+
+ Turning one off in the config therefore needs a Nemo restart, unlike
+ every other setting in this family.
+ """
+ columns = []
+ if coreConfig.sectionFlag("columns", "branch", True):
+ columns.append(Nemo.Column(name="NemoRepoColumns::branch",
+ attribute=branchAttribute,
+ label="Git Branch",
+ description="Checked out branch of the repository"))
+ if coreConfig.sectionFlag("columns", "status", True):
+ columns.append(Nemo.Column(name="NemoRepoColumns::status",
+ attribute=statusAttribute,
+ label="Git Status",
+ description="Sync state with the upstream plus working tree state"))
+ return columns
+
+ def update_file_info(self, file):
+ path = localDirectoryPath(file)
+ if path is None:
+ return
+
+ repoRoot = findRepoRoot(path)
+ if repoRoot is None:
+ file.add_string_attribute(branchAttribute, "")
+ file.add_string_attribute(statusAttribute, "")
+ return
+
+ repoCache.registerFileObject(repoRoot, file)
+ info = repoCache.get(repoRoot)
+
+ if info.error:
+ file.add_string_attribute(branchAttribute, "?")
+ file.add_string_attribute(statusAttribute, info.error)
+ return
+
+ file.add_string_attribute(branchAttribute, info.branchLabel)
+ file.add_string_attribute(statusAttribute, info.summary)
+
+ def get_name_and_desc(self):
+ return ["nemo-repo-columns:::Git Branch and Git Status columns for the list view"]
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..8b38391
--- /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/nemoRepoColumns.py"
+
+if [ -e "$target" ]; then
+ rm -f "$target"
+ echo "Removed $target"
+else
+ echo "Not installed: $target"
+fi
+
+rm -f "$extensionsDir/__pycache__/nemoRepoColumns".*.pyc
+
+echo
+echo "Restart Nemo to unload it: nemo -q"