diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0650fbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7f75fac --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DualFroz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/config.default.json b/config.default.json deleted file mode 100644 index 5ec9de2..0000000 --- a/config.default.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "cache_ttl_seconds": 5, - "indicators": { - "git_repo": { - "enabled": true, - "emblem": "emblem-repo-git", - "description": "Folder contains a .git directory (or worktree file)" - }, - "claude_dir": { - "enabled": true, - "emblem": "emblem-repo-claude", - "description": "Folder contains a .claude directory" - }, - "uncommitted_changes": { - "enabled": true, - "emblem": "emblem-repo-dirty", - "description": "git status shows staged/unstaged/untracked changes" - }, - "unpushed_commits": { - "enabled": true, - "emblem": "emblem-repo-unpushed", - "description": "Current branch has commits ahead of its upstream (or no upstream at all)" - } - }, - "actions": { - "open_terminal": true, - "open_editor": true, - "git_status": true, - "git_log": true, - "git_pull": true - }, - "editor_command": "code", - "terminal_command": null -} diff --git a/emblems/emblem-repo-claude.svg b/emblems/emblem-repo-claude.svg deleted file mode 100644 index 484fc84..0000000 --- a/emblems/emblem-repo-claude.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/emblems/emblem-repo-dirty.svg b/emblems/emblem-repo-dirty.svg deleted file mode 100644 index 9eac45f..0000000 --- a/emblems/emblem-repo-dirty.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - ! - diff --git a/emblems/emblem-repo-git.svg b/emblems/emblem-repo-git.svg deleted file mode 100644 index aa07951..0000000 --- a/emblems/emblem-repo-git.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/emblems/emblem-repo-unpushed.svg b/emblems/emblem-repo-unpushed.svg deleted file mode 100644 index bad5946..0000000 --- a/emblems/emblem-repo-unpushed.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/install.sh b/install.sh deleted file mode 100755 index a5b155e..0000000 --- a/install.sh +++ /dev/null @@ -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)." diff --git a/nemoRepoCore/Config.py b/nemoRepoCore/Config.py new file mode 100644 index 0000000..bc61a7b --- /dev/null +++ b/nemoRepoCore/Config.py @@ -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() diff --git a/nemoRepoCore/RepoCache.py b/nemoRepoCore/RepoCache.py new file mode 100644 index 0000000..7a2afe8 --- /dev/null +++ b/nemoRepoCore/RepoCache.py @@ -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) diff --git a/nemoRepoCore/RepoInfo.py b/nemoRepoCore/RepoInfo.py new file mode 100644 index 0000000..1606e67 --- /dev/null +++ b/nemoRepoCore/RepoInfo.py @@ -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 "" % (self.root, self.branchLabel, self.summary) diff --git a/nemoRepoCore/__init__.py b/nemoRepoCore/__init__.py new file mode 100644 index 0000000..66cce49 --- /dev/null +++ b/nemoRepoCore/__init__.py @@ -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", +] diff --git a/nemoRepoCore/discovery.py b/nemoRepoCore/discovery.py new file mode 100644 index 0000000..8996bb7 --- /dev/null +++ b/nemoRepoCore/discovery.py @@ -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) diff --git a/nemoRepoCore/gitQuery.py b/nemoRepoCore/gitQuery.py new file mode 100644 index 0000000..5014b5b --- /dev/null +++ b/nemoRepoCore/gitQuery.py @@ -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 diff --git a/nemoRepoCore/launchers.py b/nemoRepoCore/launchers.py new file mode 100644 index 0000000..fb9c1ad --- /dev/null +++ b/nemoRepoCore/launchers.py @@ -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 diff --git a/nemoRepoCore/logs.py b/nemoRepoCore/logs.py new file mode 100644 index 0000000..42d263f --- /dev/null +++ b/nemoRepoCore/logs.py @@ -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) diff --git a/nemoRepoCore/nemoFiles.py b/nemoRepoCore/nemoFiles.py new file mode 100644 index 0000000..505c44b --- /dev/null +++ b/nemoRepoCore/nemoFiles.py @@ -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]) diff --git a/nemoRepoCore/version.py b/nemoRepoCore/version.py new file mode 100644 index 0000000..a600c13 --- /dev/null +++ b/nemoRepoCore/version.py @@ -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 diff --git a/nemo_repo_emblems.py b/nemo_repo_emblems.py deleted file mode 100644 index 00b91f5..0000000 --- a/nemo_repo_emblems.py +++ /dev/null @@ -1,607 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -""" -nemo-repo-emblems -================== - -A single nemo-python extension that gives Nemo (Cinnamon's file manager) -real awareness of git repositories: - - * Nemo.InfoProvider -> configurable emblem overlays (git repo / .claude - dir / uncommitted changes / unpushed commits) - * Nemo.ColumnProvider -> "Git Branch" / "Git Status" columns for list view - * Nemo.PropertyPageProvider -> a "Git" tab in the folder's Properties - dialog with branch, ahead/behind, dirty counts, - untracked count and last commit - * Nemo.MenuProvider -> right-click actions: open terminal here, open in - editor, git status / git log / git pull in a - terminal - -Config file: ~/.config/nemo-repo-emblems.json (created from -config.default.json next to this file on first run if missing). Edits are -picked up live via a file monitor -- no Nemo restart needed. - -Performance: every git-derived fact for a repo root is cached with a TTL -(default 5s, see "cache_ttl_seconds") AND eagerly invalidated by watching -.git/HEAD, .git/index and .git/refs/heads for changes, so emblems update -promptly after a commit/checkout/pull without shelling out to git on every -Nemo redraw. -""" - -import json -import logging -import os -import shutil -import subprocess -import time -import weakref - -import gi -gi.require_version("Nemo", "3.0") -gi.require_version("Gtk", "3.0") -from gi.repository import GObject, Gio, GLib, Gtk, Nemo - -logger = logging.getLogger("nemo-repo-emblems") -logging.basicConfig(level=logging.WARNING) - -CONFIG_PATH = os.path.expanduser("~/.config/nemo-repo-emblems.json") -DEFAULT_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.default.json") - -GIT_BIN = shutil.which("git") -CODE_BIN = shutil.which("code") -TERMINAL_CANDIDATES = ["gnome-terminal", "x-terminal-emulator", "tilix", "konsole", - "xfce4-terminal", "mate-terminal", "xterm"] - - -# -------------------------------------------------------------------------- -# Config -# -------------------------------------------------------------------------- - -DEFAULT_CONFIG = { - "cache_ttl_seconds": 5, - "indicators": { - "git_repo": {"enabled": True, "emblem": "emblem-repo-git"}, - "claude_dir": {"enabled": True, "emblem": "emblem-repo-claude"}, - "uncommitted_changes": {"enabled": True, "emblem": "emblem-repo-dirty"}, - "unpushed_commits": {"enabled": True, "emblem": "emblem-repo-unpushed"}, - }, - "actions": { - "open_terminal": True, - "open_editor": True, - "git_status": True, - "git_log": True, - "git_pull": True, - }, - "editor_command": "code", - "terminal_command": None, -} - - -class Config: - """Loads ~/.config/nemo-repo-emblems.json and reloads it live on change.""" - - def __init__(self): - self.data = dict(DEFAULT_CONFIG) - self._ensure_file_exists() - self._load() - self._monitor = None - self._watch() - - def _ensure_file_exists(self): - if os.path.exists(CONFIG_PATH): - return - os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) - try: - if os.path.exists(DEFAULT_CONFIG_PATH): - shutil.copyfile(DEFAULT_CONFIG_PATH, CONFIG_PATH) - else: - with open(CONFIG_PATH, "w") as f: - json.dump(DEFAULT_CONFIG, f, indent=2) - except OSError as e: - logger.warning("Could not create default config at %s: %s", CONFIG_PATH, e) - - def _load(self): - try: - with open(CONFIG_PATH) as f: - loaded = json.load(f) - merged = dict(DEFAULT_CONFIG) - merged["indicators"] = {**DEFAULT_CONFIG["indicators"], **loaded.get("indicators", {})} - merged["actions"] = {**DEFAULT_CONFIG["actions"], **loaded.get("actions", {})} - merged["cache_ttl_seconds"] = loaded.get("cache_ttl_seconds", DEFAULT_CONFIG["cache_ttl_seconds"]) - merged["editor_command"] = loaded.get("editor_command", DEFAULT_CONFIG["editor_command"]) - merged["terminal_command"] = loaded.get("terminal_command", DEFAULT_CONFIG["terminal_command"]) - self.data = merged - logger.info("Loaded config from %s", CONFIG_PATH) - except (OSError, ValueError) as e: - logger.warning("Failed to load %s (%s), using defaults", CONFIG_PATH, e) - self.data = dict(DEFAULT_CONFIG) - - def _watch(self): - try: - gfile = Gio.File.new_for_path(CONFIG_PATH) - self._monitor = gfile.monitor_file(Gio.FileMonitorFlags.NONE, None) - self._monitor.connect("changed", self._on_changed) - except GLib.Error as e: - logger.warning("Could not watch config file: %s", e) - - def _on_changed(self, monitor, file, other_file, event_type): - if event_type in (Gio.FileMonitorEvent.CHANGES_DONE_HINT, Gio.FileMonitorEvent.CREATED): - self._load() - - def indicator(self, name): - return self.data["indicators"].get(name, {"enabled": False, "emblem": ""}) - - def indicator_enabled(self, name): - return bool(self.indicator(name).get("enabled")) - - def indicator_emblem(self, name): - return self.indicator(name).get("emblem", "") - - def action_enabled(self, name): - return bool(self.data["actions"].get(name, False)) - - @property - def ttl(self): - try: - return float(self.data.get("cache_ttl_seconds", 5)) - except (TypeError, ValueError): - return 5.0 - - @property - def editor_command(self): - return self.data.get("editor_command") or "code" - - @property - def terminal_command(self): - return self.data.get("terminal_command") - - -CONFIG = Config() - - -# -------------------------------------------------------------------------- -# Git status gathering + cache -# -------------------------------------------------------------------------- - -class RepoInfo: - __slots__ = ("branch", "upstream", "ahead", "behind", "has_upstream", - "staged", "unstaged", "untracked", "last_commit", "error") - - def __init__(self): - self.branch = None - self.upstream = None - self.ahead = 0 - self.behind = 0 - self.has_upstream = False - self.staged = 0 - self.unstaged = 0 - self.untracked = 0 - self.last_commit = None # (hash, author, relative_date, subject) - self.error = None - - @property - def dirty(self): - return (self.staged + self.unstaged + self.untracked) > 0 - - @property - def unpushed(self): - # No upstream at all is also surfaced as "unpushed" (nothing to push TO) - return self.ahead > 0 or not self.has_upstream - - -def _run_git(args, cwd): - env = dict(os.environ) - env["GIT_OPTIONAL_LOCKS"] = "0" - try: - result = subprocess.run( - [GIT_BIN] + args, - cwd=cwd, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - env=env, - timeout=3, - ) - return result.stdout.decode("utf-8", "replace") - except (subprocess.TimeoutExpired, OSError): - return None - - -def compute_repo_info(repo_root): - info = RepoInfo() - if not GIT_BIN: - info.error = "git not found in PATH" - return info - - out = _run_git(["status", "--porcelain=2", "--branch"], repo_root) - if out is None: - info.error = "git status failed" - return info - - for line in out.splitlines(): - if line.startswith("# branch.head "): - info.branch = line.split(" ", 2)[2] - elif line.startswith("# branch.upstream "): - info.upstream = line.split(" ", 2)[2] - info.has_upstream = True - elif line.startswith("# branch.ab "): - parts = line.split(" ") - for p in parts[2:]: - if p.startswith("+"): - info.ahead = int(p[1:]) - elif p.startswith("-"): - info.behind = int(p[1:]) - elif line.startswith("1 ") or line.startswith("2 "): - # ordinary / renamed-copied change: XY ... - xy = line.split(" ", 2)[1] - x, y = xy[0], xy[1] - if x != ".": - info.staged += 1 - if y != ".": - info.unstaged += 1 - elif line.startswith("u "): - info.staged += 1 # unmerged conflict, counts as needing attention - elif line.startswith("? "): - info.untracked += 1 - - log_out = _run_git(["log", "-1", "--format=%h%x09%an%x09%ar%x09%s"], repo_root) - if log_out: - parts = log_out.strip("\n").split("\t") - if len(parts) == 4: - info.last_commit = tuple(parts) - - return info - - -class RepoCache: - """TTL cache + git-directory file-watch invalidation, keyed by repo root path.""" - - def __init__(self, ttl_fn): - self._ttl_fn = ttl_fn - self._entries = {} # repo_root -> (RepoInfo, timestamp) - self._monitors = {} # repo_root -> [Gio.FileMonitor, ...] - self._watchers = {} # repo_root -> weakref.WeakSet[Nemo.FileInfo] - - def get(self, repo_root): - now = time.monotonic() - entry = self._entries.get(repo_root) - if entry is not None: - info, ts = entry - if (now - ts) < self._ttl_fn(): - return info - info = compute_repo_info(repo_root) - self._entries[repo_root] = (info, now) - self._ensure_watch(repo_root) - return info - - def register_file_object(self, repo_root, nemo_file): - """Track a live Nemo.FileInfo so we can force a re-query on git changes.""" - watchers = self._watchers.setdefault(repo_root, weakref.WeakSet()) - try: - watchers.add(nemo_file) - except TypeError: - pass - - def _ensure_watch(self, repo_root): - if repo_root in self._monitors: - return - git_dir = os.path.join(repo_root, ".git") - # .git can be a file (worktrees / submodules) instead of a directory - if os.path.isfile(git_dir): - return - if not os.path.isdir(git_dir): - return - - monitors = [] - watch_paths = [ - git_dir, # HEAD, index, MERGE_HEAD, ... - os.path.join(git_dir, "refs", "heads"), # local branch tip updates - ] - for path in watch_paths: - if not os.path.exists(path): - continue - try: - gfile = Gio.File.new_for_path(path) - monitor = gfile.monitor_directory(Gio.FileMonitorFlags.NONE, None) - monitor.connect("changed", self._on_git_dir_changed, repo_root) - monitors.append(monitor) - except GLib.Error as e: - logger.debug("Could not watch %s: %s", path, e) - if monitors: - self._monitors[repo_root] = monitors - - def _on_git_dir_changed(self, monitor, gfile, other_file, event_type, repo_root): - # Invalidate immediately rather than waiting for TTL expiry. - self._entries.pop(repo_root, None) - for nemo_file in list(self._watchers.get(repo_root, ())): - try: - nemo_file.invalidate_extension_info() - except Exception: - pass - - -CACHE = RepoCache(lambda: CONFIG.ttl) - - -def find_repo_root(path): - """A directory is a repo root, for our purposes, if it directly contains .git.""" - if os.path.isdir(os.path.join(path, ".git")) or os.path.isfile(os.path.join(path, ".git")): - return path - return None - - -def has_claude_dir(path): - return os.path.isdir(os.path.join(path, ".claude")) - - -# -------------------------------------------------------------------------- -# InfoProvider: emblems -# -------------------------------------------------------------------------- - -class RepoEmblemProvider(GObject.GObject, Nemo.InfoProvider, Nemo.NameAndDescProvider): - - def update_file_info(self, file): - if file.get_uri_scheme() != "file": - return - if not file.is_directory(): - return - - path = GLib.filename_from_uri(file.get_uri())[0] - - if CONFIG.indicator_enabled("claude_dir") and has_claude_dir(path): - emblem = CONFIG.indicator_emblem("claude_dir") - if emblem: - file.add_emblem(emblem) - - repo_root = find_repo_root(path) - if repo_root is None: - return - - if CONFIG.indicator_enabled("git_repo"): - emblem = CONFIG.indicator_emblem("git_repo") - if emblem: - file.add_emblem(emblem) - - # Only shell out / consult the cache if we actually need dirty/unpushed state. - if CONFIG.indicator_enabled("uncommitted_changes") or CONFIG.indicator_enabled("unpushed_commits"): - CACHE.register_file_object(repo_root, file) - info = CACHE.get(repo_root) - - if info.error is None: - if CONFIG.indicator_enabled("uncommitted_changes") and info.dirty: - emblem = CONFIG.indicator_emblem("uncommitted_changes") - if emblem: - file.add_emblem(emblem) - if CONFIG.indicator_enabled("unpushed_commits") and info.unpushed: - emblem = CONFIG.indicator_emblem("unpushed_commits") - if emblem: - file.add_emblem(emblem) - - def get_name_and_desc(self): - return [("nemo-repo-emblems:::Configurable git-status emblems (.git / .claude / dirty / unpushed)")] - - -# -------------------------------------------------------------------------- -# ColumnProvider: extra list-view columns -# -------------------------------------------------------------------------- - -class RepoColumnProvider(GObject.GObject, Nemo.ColumnProvider, Nemo.InfoProvider, Nemo.NameAndDescProvider): - - def get_columns(self): - return [ - Nemo.Column(name="NemoRepoEmblems::branch_column", - attribute="repo_branch", - label="Git Branch", - description="Current branch of the git repository"), - Nemo.Column(name="NemoRepoEmblems::status_column", - attribute="repo_status", - label="Git Status", - description="Ahead/behind + working tree status"), - ] - - def update_file_info(self, file): - if file.get_uri_scheme() != "file" or not file.is_directory(): - return - - path = GLib.filename_from_uri(file.get_uri())[0] - repo_root = find_repo_root(path) - if repo_root is None: - file.add_string_attribute("repo_branch", "") - file.add_string_attribute("repo_status", "") - return - - CACHE.register_file_object(repo_root, file) - info = CACHE.get(repo_root) - - if info.error: - file.add_string_attribute("repo_branch", "?") - file.add_string_attribute("repo_status", info.error) - return - - file.add_string_attribute("repo_branch", info.branch or "(detached)") - - bits = [] - if info.ahead: - bits.append(f"↑{info.ahead}") - if info.behind: - bits.append(f"↓{info.behind}") - if not info.has_upstream: - bits.append("no upstream") - dirty_bits = [] - if info.staged or info.unstaged: - dirty_bits.append(f"{info.staged + info.unstaged} changed") - if info.untracked: - dirty_bits.append(f"{info.untracked} untracked") - if dirty_bits: - bits.append("+".join(dirty_bits)) - file.add_string_attribute("repo_status", ", ".join(bits) if bits else "clean") - - def get_name_and_desc(self): - return [("nemo-repo-emblems-columns:::Git Branch / Git Status list-view columns")] - - -# -------------------------------------------------------------------------- -# PropertyPageProvider: "Git" tab in Properties dialog -# -------------------------------------------------------------------------- - -class RepoPropertyPageProvider(GObject.GObject, Nemo.PropertyPageProvider, Nemo.NameAndDescProvider): - - def get_property_pages(self, files): - if len(files) != 1: - return [] - file = files[0] - if file.get_uri_scheme() != "file" or not file.is_directory(): - return [] - - path = GLib.filename_from_uri(file.get_uri())[0] - repo_root = find_repo_root(path) - if repo_root is None: - return [] - - info = CACHE.get(repo_root) - - grid = Gtk.Grid() - grid.set_row_spacing(6) - grid.set_column_spacing(12) - grid.set_margin_start(12) - grid.set_margin_end(12) - grid.set_margin_top(12) - grid.set_margin_bottom(12) - - def add_row(row, label_text, value_text): - lbl = Gtk.Label(label=label_text) - lbl.set_xalign(0) - lbl.get_style_context().add_class("dim-label") - val = Gtk.Label(label=value_text) - val.set_xalign(0) - val.set_selectable(True) - val.set_line_wrap(True) - grid.attach(lbl, 0, row, 1, 1) - grid.attach(val, 1, row, 1, 1) - - if info.error: - add_row(0, "Status", info.error) - else: - row = 0 - add_row(row, "Repository", repo_root); row += 1 - add_row(row, "Branch", info.branch or "(detached HEAD)"); row += 1 - add_row(row, "Upstream", info.upstream if info.has_upstream else "(none)"); row += 1 - add_row(row, "Ahead / Behind", f"{info.ahead} ahead, {info.behind} behind"); row += 1 - add_row(row, "Staged changes", str(info.staged)); row += 1 - add_row(row, "Unstaged changes", str(info.unstaged)); row += 1 - add_row(row, "Untracked files", str(info.untracked)); row += 1 - if info.last_commit: - h, author, when, subject = info.last_commit - add_row(row, "Last commit", f"{h} — {subject}"); row += 1 - add_row(row, "", f"by {author}, {when}"); row += 1 - - grid.show_all() - label = Gtk.Label(label="Git") - return [Nemo.PropertyPage(name="NemoRepoEmblems::git_page", label=label, page=grid)] - - def get_name_and_desc(self): - return [("nemo-repo-emblems-properties:::\"Git\" tab in the folder Properties dialog")] - - -# -------------------------------------------------------------------------- -# MenuProvider: right-click actions -# -------------------------------------------------------------------------- - -def _resolve_terminal(): - configured = CONFIG.terminal_command - if configured and shutil.which(configured.split(" ")[0]): - return configured - for candidate in TERMINAL_CANDIDATES: - if shutil.which(candidate): - return candidate - return None - - -def _launch_terminal(path, shell_command=None): - term = _resolve_terminal() - if not term: - logger.warning("No terminal emulator found") - return - try: - if shell_command: - inner = f"cd {GLib.shell_quote(path)} && {shell_command}; exec bash" - subprocess.Popen([term, "--", "bash", "-c", inner], cwd=path) - else: - subprocess.Popen([term], cwd=path) - except OSError as e: - logger.warning("Failed to launch terminal (%s): %s", term, e) - - -def _launch_editor(path): - editor = CONFIG.editor_command - binary = shutil.which(editor.split(" ")[0]) if editor else None - if not binary: - logger.warning("Editor command %r not found in PATH", editor) - return - try: - subprocess.Popen(editor.split(" ") + [path]) - except OSError as e: - logger.warning("Failed to launch editor: %s", e) - - -class RepoMenuProvider(GObject.GObject, Nemo.MenuProvider, Nemo.NameAndDescProvider): - - def _build_items(self, path, repo_root): - items = [] - is_repo = repo_root is not None - - if CONFIG.action_enabled("open_terminal"): - item = Nemo.MenuItem(name="NemoRepoEmblems::open_terminal", - label="Open Terminal Here", - tip=f"Open a terminal in {path}") - item.connect("activate", lambda *_: _launch_terminal(path)) - items.append(item) - - if CONFIG.action_enabled("open_editor") and CODE_BIN: - item = Nemo.MenuItem(name="NemoRepoEmblems::open_editor", - label="Open in Editor", - tip=f"Open {path} in {CONFIG.editor_command}") - item.connect("activate", lambda *_: _launch_editor(path)) - items.append(item) - - if is_repo and GIT_BIN: - if CONFIG.action_enabled("git_status"): - item = Nemo.MenuItem(name="NemoRepoEmblems::git_status", - label="Git Status (terminal)", - tip="Run git status in a terminal") - item.connect("activate", lambda *_: _launch_terminal(repo_root, "git status")) - items.append(item) - - if CONFIG.action_enabled("git_log"): - item = Nemo.MenuItem(name="NemoRepoEmblems::git_log", - label="Git Log (terminal)", - tip="Run git log in a terminal") - item.connect("activate", lambda *_: _launch_terminal( - repo_root, "git log --oneline --graph --decorate -30")) - items.append(item) - - if CONFIG.action_enabled("git_pull"): - item = Nemo.MenuItem(name="NemoRepoEmblems::git_pull", - label="Git Pull (terminal)", - tip="Run git pull in a terminal") - item.connect("activate", lambda *_: _launch_terminal(repo_root, "git pull")) - items.append(item) - - return items - - def get_file_items(self, window, files): - if len(files) != 1 or not files[0].is_directory(): - return [] - file = files[0] - if file.get_uri_scheme() != "file": - return [] - path = GLib.filename_from_uri(file.get_uri())[0] - return self._build_items(path, find_repo_root(path)) - - def get_background_items(self, window, current_folder): - if current_folder.get_uri_scheme() != "file": - return [] - path = GLib.filename_from_uri(current_folder.get_uri())[0] - return self._build_items(path, find_repo_root(path)) - - def get_name_and_desc(self): - return [("nemo-repo-emblems-menu:::Repo-aware right-click actions (terminal / editor / git status-log-pull)")]