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"