refactor: extract the shared foundation into an installable library

The single file extension carried 260 lines of infrastructure under its
four Nemo providers: the config file with live reload, the git query
layer, the TTL cache with .git watching, and repository detection. Any
second extension wanting repository awareness had to repeat all of it.

Move that foundation into a nemoRepoCore package with a deliberate plugin
API rather than whatever the providers happened to leave behind:

  * Config gains layered defaults, so a plugin registers its own keys and
    an empty config file is still a working one
  * RepoInfo gains a root, a named last commit tuple and a summary string
  * RepoCache gains refresh, invalidate and an invalidation callback
  * discovery separates the cheap root check used on every drawn folder
    from the upward walk used by on demand work such as menus
  * nemoFiles collapses the uri scheme and directory guards every
    provider was repeating
  * launchers learn the per emulator flag for running a command
  * version negotiation lets a plugin state the core it was built for

The config file moves to nemo-repo-core.json and is seeded from the old
nemo-repo-emblems.json when one exists, so no key changes name and
nothing needs editing by hand.
This commit is contained in:
dualfroz committed 2026-08-05 20:06:41 +02:00
1 parent d81b502300
commit 4ae3a496af
19 files changed
+871 -708

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
+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
}
-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="21" fill="#c96442" stroke="#7a3623" stroke-width="1.5"/>
<g fill="#fbead9">
<path d="M24 10 L27 20 L38 24 L27 28 L24 38 L21 28 L10 24 L21 20 Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 279 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="21" fill="#d9822b" stroke="#8a4f0f" stroke-width="1.5"/>
<text x="24" y="33" font-family="sans-serif" font-size="28" font-weight="bold" fill="#fff8ef" text-anchor="middle">!</text>
</svg>

Before

Width:  |  Height:  |  Size: 303 B

-10
View File
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="21" fill="#3f5a7d" stroke="#1e2f42" stroke-width="1.5"/>
<g fill="none" stroke="#e8edf3" stroke-width="3" stroke-linecap="round">
<circle cx="17" cy="15" r="3.2" fill="#e8edf3" stroke="none"/>
<circle cx="17" cy="33" r="3.2" fill="#e8edf3" stroke="none"/>
<circle cx="31" cy="24" r="3.2" fill="#e8edf3" stroke="none"/>
<path d="M17 18 L17 30"/>
<path d="M17 24 Q17 24 24 24 Q28 24 31 21"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 539 B

-7
View File
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="21" fill="#2f6f4f" stroke="#173c29" stroke-width="1.5"/>
<g fill="none" stroke="#e7f5ec" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M24 32 L24 15"/>
<path d="M16 22 L24 14 L32 22"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 352 B

-40
View File
@@ -1,40 +0,0 @@
#!/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
set -euo pipefail
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
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"
mkdir -p "$EXT_DIR" "$ICON_DIR" "$(dirname "$CONFIG_PATH")"
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
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
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)."
+187
View File
@@ -0,0 +1,187 @@
"""The single JSON config file shared by the core and all of its plugins.
Layout on disk (every key optional, plugins fill in their own defaults):
{
"cache_ttl_seconds": 5,
"editor_command": "code",
"terminal_command": null,
"log_level": "warning",
"indicators": { ... }, // owned by whichever plugin registers them
"actions": { ... }
}
Keys use the JSON spelling they have always had, so a config written for the
pre-split single-file extension keeps working unchanged.
"""
import copy
import json
import os
import shutil
from gi.repository import Gio, GLib
from .logs import applyLogLevel, pluginLogger
logger = pluginLogger("config")
configFileName = "nemo-repo-core.json"
legacyConfigFileName = "nemo-repo-emblems.json"
coreDefaults = {
"cache_ttl_seconds": 5,
"editor_command": "code",
"terminal_command": None,
"log_level": "warning",
}
def configDirectory():
return os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
def defaultConfigPath():
override = os.environ.get("NEMO_REPO_CORE_CONFIG")
if override:
return os.path.expanduser(override)
return os.path.join(configDirectory(), configFileName)
def legacyConfigPath():
return os.path.join(configDirectory(), legacyConfigFileName)
def mergeDictionaries(base, overlay):
"""Recursive merge; `overlay` wins, dictionaries merge instead of replace."""
merged = copy.deepcopy(base)
for key, value in overlay.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = mergeDictionaries(merged[key], value)
else:
merged[key] = copy.deepcopy(value)
return merged
class Config:
"""Live view of the config file, reloaded whenever the file changes.
Effective values are the merge of three layers, later ones winning:
core defaults, defaults registered by plugins, then the user's file.
"""
def __init__(self, path=None):
self.path = path or defaultConfigPath()
self.defaults = copy.deepcopy(coreDefaults)
self.userData = {}
self.data = copy.deepcopy(self.defaults)
self._changeCallbacks = []
self._monitor = None
self._createFileIfMissing()
self.reload()
self._watchFile()
# -- layers ------------------------------------------------------------
def registerDefaults(self, defaults):
"""Add a plugin's default block, then recompute effective values.
Safe to call from several plugins; blocks are deep-merged, so two
plugins can both contribute keys to the same section.
"""
self.defaults = mergeDictionaries(self.defaults, defaults)
self._recompute()
def _recompute(self):
self.data = mergeDictionaries(self.defaults, self.userData)
applyLogLevel(self.data.get("log_level", "warning"))
# -- reading -----------------------------------------------------------
def section(self, name):
"""Merged sub-object for `name`, or an empty dict when absent."""
value = self.data.get(name)
return value if isinstance(value, dict) else {}
def value(self, name, fallback=None):
current = self.data.get(name)
return fallback if current is None else current
def flag(self, name, fallback=False):
return bool(self.data.get(name, fallback))
def sectionFlag(self, sectionName, key, fallback=False):
entry = self.section(sectionName).get(key, fallback)
if isinstance(entry, dict):
return bool(entry.get("enabled", fallback))
return bool(entry)
@property
def ttlSeconds(self):
try:
return max(0.0, float(self.data.get("cache_ttl_seconds", 5)))
except (TypeError, ValueError):
return 5.0
@property
def editorCommand(self):
return self.data.get("editor_command") or "code"
@property
def terminalCommand(self):
return self.data.get("terminal_command")
# -- change notification ------------------------------------------------
def onChange(self, callback):
"""Register `callback()` to run after every successful reload."""
self._changeCallbacks.append(callback)
# -- file handling -------------------------------------------------------
def _createFileIfMissing(self):
if os.path.exists(self.path):
return
directory = os.path.dirname(self.path)
try:
os.makedirs(directory, exist_ok=True)
legacy = legacyConfigPath()
if os.path.isfile(legacy):
shutil.copyfile(legacy, self.path)
logger.info("Migrated config from %s to %s", legacy, self.path)
return
with open(self.path, "w") as handle:
json.dump(coreDefaults, handle, indent=2)
handle.write("\n")
except OSError as error:
logger.warning("Could not create %s: %s", self.path, error)
def reload(self):
try:
with open(self.path) as handle:
loaded = json.load(handle)
except FileNotFoundError:
loaded = {}
except (OSError, ValueError) as error:
logger.warning("Ignoring unreadable config %s: %s", self.path, error)
return
self.userData = loaded if isinstance(loaded, dict) else {}
self._recompute()
for callback in list(self._changeCallbacks):
callback()
def _watchFile(self):
try:
gfile = Gio.File.new_for_path(self.path)
self._monitor = gfile.monitor_file(Gio.FileMonitorFlags.NONE, None)
self._monitor.connect("changed", self._onFileChanged)
except GLib.Error as error:
logger.warning("Config file will not live-reload: %s", error)
def _onFileChanged(self, monitor, gfile, otherFile, eventType):
if eventType in (Gio.FileMonitorEvent.CHANGES_DONE_HINT,
Gio.FileMonitorEvent.CREATED):
self.reload()
coreConfig = Config()
+110
View File
@@ -0,0 +1,110 @@
"""TTL cache in front of git, invalidated eagerly by watching .git.
Without this, Nemo would fork a git process for every folder it draws, on
every redraw. With it, a repository is queried at most once per TTL, and the
entry is dropped the moment .git changes so a commit or checkout shows up
immediately instead of after the TTL.
"""
import os
import time
import weakref
from gi.repository import Gio, GLib
from .Config import coreConfig
from .gitQuery import computeRepoInfo
from .logs import pluginLogger
logger = pluginLogger("cache")
class RepoCache:
"""Keyed by repository root path. Shared by every plugin in the family."""
def __init__(self, ttlProvider):
self._ttlProvider = ttlProvider
self._entries = {}
self._monitors = {}
self._trackedFiles = {}
self._invalidateCallbacks = []
def get(self, repoRoot):
"""Cached RepoInfo for `repoRoot`, computing it when stale or absent."""
now = time.monotonic()
entry = self._entries.get(repoRoot)
if entry is not None:
info, storedAt = entry
if (now - storedAt) < self._ttlProvider():
return info
return self.refresh(repoRoot)
def refresh(self, repoRoot):
"""Recompute now, ignoring the TTL, and store the result."""
info = computeRepoInfo(repoRoot)
self._entries[repoRoot] = (info, time.monotonic())
self._ensureWatch(repoRoot)
return info
def invalidate(self, repoRoot=None):
"""Drop one entry, or the whole cache when called without argument."""
if repoRoot is None:
roots = list(self._entries)
self._entries.clear()
else:
roots = [repoRoot] if self._entries.pop(repoRoot, None) else []
for root in roots:
self._notify(root)
def registerFileObject(self, repoRoot, nemoFile):
"""Track a live Nemo.FileInfo so it can be redrawn on git changes.
References are weak: Nemo owns the object's lifetime, we only ask it
to re-query us when the repository underneath it moves.
"""
tracked = self._trackedFiles.setdefault(repoRoot, weakref.WeakSet())
try:
tracked.add(nemoFile)
except TypeError:
logger.debug("Object for %s is not weak-referenceable", repoRoot)
def onInvalidate(self, callback):
"""Register `callback(repoRoot)`, run whenever an entry is dropped."""
self._invalidateCallbacks.append(callback)
# -- internals ----------------------------------------------------------
def _notify(self, repoRoot):
for nemoFile in list(self._trackedFiles.get(repoRoot, ())):
nemoFile.invalidate_extension_info()
for callback in list(self._invalidateCallbacks):
callback(repoRoot)
def _ensureWatch(self, repoRoot):
if repoRoot in self._monitors:
return
gitDirectory = os.path.join(repoRoot, ".git")
if not os.path.isdir(gitDirectory):
return
monitors = []
for path in (gitDirectory, os.path.join(gitDirectory, "refs", "heads")):
if not os.path.exists(path):
continue
try:
gfile = Gio.File.new_for_path(path)
monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None)
except GLib.Error as error:
logger.debug("Cannot watch %s: %s", path, error)
continue
monitor.connect("changed", self._onGitDirectoryChanged, repoRoot)
monitors.append(monitor)
if monitors:
self._monitors[repoRoot] = monitors
def _onGitDirectoryChanged(self, monitor, gfile, otherFile, eventType, repoRoot):
self._entries.pop(repoRoot, None)
self._notify(repoRoot)
repoCache = RepoCache(lambda: coreConfig.ttlSeconds)
+74
View File
@@ -0,0 +1,74 @@
"""The snapshot of a repository that every plugin reads."""
from collections import namedtuple
LastCommit = namedtuple("LastCommit", "hash author relativeDate subject")
class RepoInfo:
"""Everything one `git status` and one `git log -1` can tell us.
Instances are immutable in practice: the cache hands the same object to
every caller for the lifetime of a cache entry, so plugins must treat
the fields as read-only.
When `error` is set, every other field is at its zero value and must not
be displayed as fact.
"""
__slots__ = ("root", "branch", "upstream", "ahead", "behind", "hasUpstream",
"staged", "unstaged", "untracked", "lastCommit", "error")
def __init__(self, root=None):
self.root = root
self.branch = None
self.upstream = None
self.ahead = 0
self.behind = 0
self.hasUpstream = False
self.staged = 0
self.unstaged = 0
self.untracked = 0
self.lastCommit = None
self.error = None
@property
def dirty(self):
return (self.staged + self.unstaged + self.untracked) > 0
@property
def unpushed(self):
"""Commits the remote does not have, including "there is no remote"."""
return self.ahead > 0 or not self.hasUpstream
@property
def detached(self):
return self.branch in (None, "(detached)")
@property
def branchLabel(self):
return self.branch or "(detached)"
@property
def summary(self):
"""One ASCII line combining sync state and working tree state."""
if self.error:
return self.error
parts = []
if self.ahead:
parts.append("%d ahead" % self.ahead)
if self.behind:
parts.append("%d behind" % self.behind)
if not self.hasUpstream:
parts.append("no upstream")
treeParts = []
if self.staged or self.unstaged:
treeParts.append("%d changed" % (self.staged + self.unstaged))
if self.untracked:
treeParts.append("%d untracked" % self.untracked)
if treeParts:
parts.append(", ".join(treeParts))
return ", ".join(parts) if parts else "clean"
def __repr__(self):
return "<RepoInfo %s %s: %s>" % (self.root, self.branchLabel, self.summary)
+82
View File
@@ -0,0 +1,82 @@
"""nemo-repo-core: the shared foundation for repository-aware Nemo plugins.
A plugin is a single .py file in ~/.local/share/nemo-python/extensions/ that
imports this package and registers one Nemo provider. Everything expensive
or fiddly lives here: the config file, the git query layer, the cache that
keeps Nemo from forking git on every redraw, and the path handling that
turns a Nemo.FileInfo into something git can be pointed at.
Minimal plugin:
from nemoRepoCore import (coreConfig, findRepoRoot, localDirectoryPath,
repoCache, requireCore)
requireCore("1.0.0")
coreConfig.registerDefaults({"indicators": {"mine": {"enabled": True}}})
class MyProvider(GObject.GObject, Nemo.InfoProvider):
def update_file_info(self, file):
path = localDirectoryPath(file)
if path is None:
return
root = findRepoRoot(path)
if root is None:
return
repoCache.registerFileObject(root, file)
info = repoCache.get(root)
...
Stability policy for everything listed in __all__:
* Within a major version, names are never removed and signatures only ever
gain optional arguments. New helpers may appear; a plugin that asked for
1.0.0 keeps working on 1.4.0.
* A major bump may remove or reshape anything. requireCore() refuses to
load a plugin against a different major, so a mismatch is a clear error
at startup instead of a subtle misbehaviour later.
* Names not in __all__, and anything with a leading underscore, are
internal and may change in any release.
"""
from .Config import Config, coreConfig, coreDefaults, defaultConfigPath, legacyConfigPath
from .discovery import (findEnclosingRepoRoot, findRepoRoot, hasMarkerDirectory,
isRepoRoot, repoInfoFor)
from .gitQuery import computeRepoInfo, gitBinary, runGit
from .launchers import (editorAvailable, launchEditor, launchTerminal,
resolveTerminal)
from .logs import pluginLogger
from .nemoFiles import localDirectoryPath, localPath, singleLocalDirectory
from .RepoCache import RepoCache, repoCache
from .RepoInfo import LastCommit, RepoInfo
from .version import CoreVersionError, coreVersion, requireCore
__all__ = [
"Config",
"CoreVersionError",
"LastCommit",
"RepoCache",
"RepoInfo",
"computeRepoInfo",
"coreConfig",
"coreDefaults",
"coreVersion",
"defaultConfigPath",
"editorAvailable",
"findEnclosingRepoRoot",
"findRepoRoot",
"gitBinary",
"hasMarkerDirectory",
"isRepoRoot",
"launchEditor",
"launchTerminal",
"legacyConfigPath",
"localDirectoryPath",
"localPath",
"pluginLogger",
"repoCache",
"repoInfoFor",
"requireCore",
"resolveTerminal",
"runGit",
"singleLocalDirectory",
]
+55
View File
@@ -0,0 +1,55 @@
"""Deciding whether a directory is a repository, and finding which one."""
import os
from .RepoCache import repoCache
def isRepoRoot(path):
"""True when `path` itself holds the repository.
.git is a directory in a normal clone and a file in a worktree or
submodule; both count.
"""
gitPath = os.path.join(path, ".git")
return os.path.isdir(gitPath) or os.path.isfile(gitPath)
def findRepoRoot(path):
"""Repository root, but only if `path` is that root. Cheap: one stat.
This is what emblem and column plugins want, because they run for every
folder Nemo draws and must not walk the filesystem upwards.
"""
return path if isRepoRoot(path) else None
def findEnclosingRepoRoot(path, maximumDepth=64):
"""Nearest repository root at or above `path`, or None.
For on-demand work such as a context menu, where walking a handful of
parent directories is free and the user expects git actions to work from
anywhere inside their checkout.
"""
current = os.path.abspath(path)
for _ in range(maximumDepth):
if isRepoRoot(current):
return current
parent = os.path.dirname(current)
if parent == current:
return None
current = parent
return None
def hasMarkerDirectory(path, name):
"""True when `path` directly contains a subdirectory called `name`."""
return os.path.isdir(os.path.join(path, name))
def repoInfoFor(path, walkUp=False):
"""Cached RepoInfo for the repository at (or above) `path`, else None."""
root = findEnclosingRepoRoot(path) if walkUp else findRepoRoot(path)
if root is None:
return None
return repoCache.get(root)
+94
View File
@@ -0,0 +1,94 @@
"""Everything that shells out to git lives here, and nowhere else."""
import os
import shutil
import subprocess
from .logs import pluginLogger
from .RepoInfo import LastCommit, RepoInfo
logger = pluginLogger("git")
gitBinary = shutil.which("git")
defaultTimeoutSeconds = 3
def runGit(arguments, cwd, timeoutSeconds=defaultTimeoutSeconds):
"""Run git and return stdout as text, or None when it could not run.
Never raises and never blocks Nemo for long: stderr is discarded, index
locks are avoided via GIT_OPTIONAL_LOCKS, and the call is capped by a
timeout. A None result always means "no answer", never "empty answer".
"""
if not gitBinary:
return None
environment = dict(os.environ)
environment["GIT_OPTIONAL_LOCKS"] = "0"
try:
completed = subprocess.run(
[gitBinary] + list(arguments),
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
env=environment,
timeout=timeoutSeconds,
)
except (subprocess.TimeoutExpired, OSError) as error:
logger.debug("git %s failed in %s: %s", " ".join(arguments), cwd, error)
return None
if completed.returncode != 0:
return None
return completed.stdout.decode("utf-8", "replace")
def _applyStatusLine(info, line):
if line.startswith("# branch.head "):
info.branch = line.split(" ", 2)[2]
elif line.startswith("# branch.upstream "):
info.upstream = line.split(" ", 2)[2]
info.hasUpstream = True
elif line.startswith("# branch.ab "):
for token in line.split(" ")[2:]:
if token.startswith("+"):
info.ahead = int(token[1:])
elif token.startswith("-"):
info.behind = int(token[1:])
elif line.startswith("1 ") or line.startswith("2 "):
indexState, worktreeState = line.split(" ", 2)[1][:2]
if indexState != ".":
info.staged += 1
if worktreeState != ".":
info.unstaged += 1
elif line.startswith("u "):
info.staged += 1
elif line.startswith("? "):
info.untracked += 1
def computeRepoInfo(repoRoot):
"""Build a fresh RepoInfo for `repoRoot`, bypassing any cache.
Plugins should normally call repoCache.get() instead; this is the
uncached primitive behind it.
"""
info = RepoInfo(repoRoot)
if not gitBinary:
info.error = "git not found in PATH"
return info
status = runGit(["status", "--porcelain=2", "--branch"], repoRoot)
if status is None:
info.error = "git status failed"
return info
for line in status.splitlines():
_applyStatusLine(info, line)
logLine = runGit(["log", "-1", "--format=%h%x09%an%x09%ar%x09%s"], repoRoot)
if logLine:
fields = logLine.strip("\n").split("\t")
if len(fields) == 4:
info.lastCommit = LastCommit(*fields)
return info
+106
View File
@@ -0,0 +1,106 @@
"""Starting a terminal or an editor from a Nemo menu item.
Terminal emulators disagree about the flag that means "run this command",
so the mapping below is explicit rather than assuming one form works
everywhere. Unknown emulators fall back to -e, which is the oldest and most
widely accepted spelling.
"""
import os
import shutil
import subprocess
from gi.repository import GLib
from .Config import coreConfig
from .logs import pluginLogger
logger = pluginLogger("launchers")
terminalCandidates = [
"gnome-terminal",
"x-terminal-emulator",
"tilix",
"konsole",
"xfce4-terminal",
"mate-terminal",
"kitty",
"alacritty",
"xterm",
]
terminalCommandFlags = {
"gnome-terminal": ["--"],
"mate-terminal": ["--"],
"tilix": ["-e"],
"konsole": ["-e"],
"xfce4-terminal": ["-x"],
"kitty": [],
"alacritty": ["-e"],
"xterm": ["-e"],
"x-terminal-emulator": ["-e"],
}
fallbackCommandFlags = ["-e"]
def resolveTerminal():
"""The terminal command to use: the configured one, else the first found."""
configured = coreConfig.terminalCommand
if configured and shutil.which(configured.split(" ")[0]):
return configured
for candidate in terminalCandidates:
if shutil.which(candidate):
return candidate
return None
def _commandFlagsFor(terminal):
return terminalCommandFlags.get(os.path.basename(terminal), fallbackCommandFlags)
def launchTerminal(path, shellCommand=None):
"""Open a terminal in `path`, optionally running `shellCommand` first.
The shell stays open afterwards so the user can read the output of
whatever was run and keep working in the same directory.
"""
terminal = resolveTerminal()
if not terminal:
logger.warning("No terminal emulator found; tried %s",
", ".join(terminalCandidates))
return False
parts = terminal.split(" ")
try:
if shellCommand:
script = "cd %s && %s; exec \"${SHELL:-bash}\"" % (
GLib.shell_quote(path), shellCommand)
arguments = parts + _commandFlagsFor(parts[0]) + ["bash", "-c", script]
else:
arguments = parts
subprocess.Popen(arguments, cwd=path)
except OSError as error:
logger.warning("Could not start %s: %s", terminal, error)
return False
return True
def launchEditor(path):
"""Open `path` in the configured editor command."""
editor = coreConfig.editorCommand
parts = editor.split(" ")
if not shutil.which(parts[0]):
logger.warning("Editor command %r is not in PATH", editor)
return False
try:
subprocess.Popen(parts + [path])
except OSError as error:
logger.warning("Could not start editor %r: %s", editor, error)
return False
return True
def editorAvailable():
"""True when the configured editor command can actually be started."""
return shutil.which(coreConfig.editorCommand.split(" ")[0]) is not None
+54
View File
@@ -0,0 +1,54 @@
"""One logging setup shared by the core and every plugin built on it."""
import logging
import os
import sys
_rootLogger = logging.getLogger("nemo-repo")
_handlerAttached = False
_levelNames = {
"debug": logging.DEBUG,
"info": logging.INFO,
"warning": logging.WARNING,
"warn": logging.WARNING,
"error": logging.ERROR,
"critical": logging.CRITICAL,
}
def _attachHandler():
global _handlerAttached
if _handlerAttached:
return
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(name)s: %(levelname)s: %(message)s"))
_rootLogger.addHandler(handler)
_rootLogger.propagate = False
_rootLogger.setLevel(_levelFromEnvironment())
_handlerAttached = True
def _levelFromEnvironment():
requested = os.environ.get("NEMO_REPO_LOG", "").strip().lower()
return _levelNames.get(requested, logging.WARNING)
def applyLogLevel(name):
"""Set the level for the whole `nemo-repo` logger tree.
The NEMO_REPO_LOG environment variable always wins, so a user can raise
verbosity for one Nemo session without editing the config file.
"""
_attachHandler()
fromEnvironment = os.environ.get("NEMO_REPO_LOG", "").strip().lower()
if fromEnvironment in _levelNames:
_rootLogger.setLevel(_levelNames[fromEnvironment])
return
_rootLogger.setLevel(_levelNames.get(str(name).strip().lower(), logging.WARNING))
def pluginLogger(name):
"""Return the logger a plugin should use, e.g. pluginLogger("emblems")."""
_attachHandler()
return _rootLogger.getChild(name)
+36
View File
@@ -0,0 +1,36 @@
"""Turning the Nemo.FileInfo objects handed to a provider into local paths.
Every provider needs the same two guards before it can do anything useful:
the item must live on the local filesystem, and for repository work it must
be a directory. Getting either wrong means running git against a path that
does not exist, or against an ftp:// URI.
"""
from gi.repository import GLib
def localPath(nemoFile):
"""Filesystem path for `nemoFile`, or None if it is not a local file."""
if nemoFile.get_uri_scheme() != "file":
return None
try:
return GLib.filename_from_uri(nemoFile.get_uri())[0]
except GLib.Error:
return None
def localDirectoryPath(nemoFile):
"""Filesystem path for `nemoFile` when it is a local directory, else None."""
if not nemoFile.is_directory():
return None
return localPath(nemoFile)
def singleLocalDirectory(files):
"""Path of the one selected local directory, or None for any other case.
Property pages and single-target menu items all want exactly this.
"""
if len(files) != 1:
return None
return localDirectoryPath(files[0])
+47
View File
@@ -0,0 +1,47 @@
"""Version of the core library and the compatibility check plugins call."""
coreVersion = "1.0.0"
class CoreVersionError(RuntimeError):
"""Raised when the installed core cannot satisfy a plugin's requirement."""
def parseVersion(text):
"""Turn "1.2.3" into (1, 2, 3). Missing components count as zero."""
parts = []
for chunk in str(text).split("."):
digits = "".join(character for character in chunk if character.isdigit())
parts.append(int(digits) if digits else 0)
while len(parts) < 3:
parts.append(0)
return tuple(parts[:3])
def satisfies(installed, minimum):
"""True when `installed` can serve a plugin that asked for `minimum`.
The rule is plain semantic versioning: the major component must match
exactly, and the rest must be greater than or equal to the requirement.
"""
installedParts = parseVersion(installed)
minimumParts = parseVersion(minimum)
if installedParts[0] != minimumParts[0]:
return False
return installedParts >= minimumParts
def requireCore(minimumVersion):
"""Assert that this core is new enough for the calling plugin.
Call it once, at import time, before touching any other core symbol.
Nemo logs the resulting exception and skips the extension, which is the
behaviour we want: a plugin built against a newer core must not load
half-working.
"""
if not satisfies(coreVersion, minimumVersion):
raise CoreVersionError(
"nemo-repo-core %s cannot satisfy the required version %s; "
"update the core library" % (coreVersion, minimumVersion)
)
return coreVersion
-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)")]