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:
1 parent
d81b502300
commit
4ae3a496af
19 files changed
+871
-708
No files matched your search
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
@@ -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)."
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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)")]
|
||||
Reference in new issue
Block a user