Skip to content

Commit 89a9126

Browse files
Bee-Marclaude
andauthored
Feat/currently installed panel (#296)
* feat: currently-installed tab, upgrade-from-details, robust package upgrade logic UI: - Add "Installed" rail tab showing all installed packages (marketplace + custom) - Add "Upgrade now" button in package details panel for upgradable packages - Replace p-listbox in upgrades panel with native styled list matching design system - Surface per-package git error messages in upgrade failure toasts Backend: - Add cwd= parameter to run_cmd() to avoid mutating process-wide working directory - Replace git checkout -- . with git stash/pop to preserve local module changes - Fix operator precedence bug causing InstallationHandler to never run post-pull - Add git reset --hard ORIG_HEAD rollback when dependency install fails after pull - Return tuple[bool, str] from package.upgrade() so error text reaches the API caller - Remove dead os.chdir() from package.update() (preceded a GitPython call, had no effect) - Update upgrade API endpoint to include per-package error details in failure list Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: promote MMPMEnv from instance to class-level attribute MMPMEnv is a singleton; instantiating it per-instance in __init__ was redundant. Moving it to a class-level attribute (`env: MMPMEnv = MMPMEnv()`) removes boilerplate across 14 classes and makes the singleton relationship explicit. Tests updated to use patch.object instead of instance assignment (blocked by __slots__ on MagicMirrorPackage). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: sync Mirror Preview with config.js saves and warn on uninstalled modules - Config Editor now emits a configJsSaved$ signal via SharedStoreService whenever config.js is successfully saved; Mirror Preview subscribes and calls resetLayout() so the visual grid always reflects the saved file - Added configJsDirty flag on SharedStoreService so Mirror Preview picks up the signal even when it was off-screen (destroyed by @if) at save time - Persistent warning banner added to both Config Editor and Mirror Preview listing any modules referenced in config.js that are not installed; banner clears automatically when packages are installed - Mirror Preview also re-evaluates the banner on every packages subscription emission so it stays live without requiring a manual reset - Replaced O(n²) category count loop in mmpm list --categories with a single Counter pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: add 4.6.0 changelog entry Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update placement in changelog --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7e890e4 commit 89a9126

39 files changed

Lines changed: 1140 additions & 146 deletions

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,3 +414,31 @@
414414
- Added 167 new unit tests, raising coverage from 66% to 99%
415415
- Added `TestDetectDisplayServerScript` covering X11, Wayland (via `XDG_SESSION_TYPE` and `WAYLAND_DISPLAY`), Windows, and the fallback path
416416

417+
## Version 4.6.0
418+
419+
### UI
420+
421+
- Added "Currently Installed" tab in the navigation rail showing all installed packages (marketplace and custom) with search, sort, card, and table views
422+
- Mirror Preview: Config Editor save now automatically triggers a layout reset so the visual grid always reflects the saved `config.js` without requiring a manual Reset click
423+
- Mirror Preview: persistent warning banner lists any modules referenced in `config.js` that are not installed; clears automatically when the missing packages are installed
424+
- Config Editor: persistent warning banner below the toolbar lists the same uninstalled modules while `config.js` is active; updates live when packages are installed or the file is saved
425+
- Upgrades panel: replaced `p-listbox` with native-styled list matching the design system
426+
- Package details panel: "Upgrade now" button appears for installed packages that have an available upgrade
427+
428+
### Backend
429+
430+
- `MagicMirrorPackage.upgrade()` rewritten: uses `git stash` / `git stash pop` to preserve local changes before pulling, surfaces error text from git through to the API response and UI toasts, rolls back via `git reset --hard ORIG_HEAD` if dependency installation fails after a successful pull
431+
- `run_cmd()` gained an optional `cwd` parameter; `upgrade()` passes it instead of using process-global `os.chdir()`
432+
- `MMPMEnv` promoted from a per-instance `__init__` assignment to a class-level attribute across all 14 consumer classes, making the singleton relationship explicit
433+
- `mmpm list --categories` package count replaced O(n²) double-scan with a single `Counter` pass
434+
435+
### Bug Fixes
436+
437+
- Mirror Preview warning banner now fires on every code path including the localStorage cache early-return, not only when `config.js` is fetched from the API
438+
- Mirror Preview correctly picks up a `config.js` save made while the panel was off-screen (`@if`-destroyed), via a `configJsDirty` flag checked on remount
439+
440+
### Tests
441+
442+
- Fixed test suite after `MMPMEnv` class-level attribute refactor: replaced instance-attribute assignment (blocked by `__slots__`) with `patch.object(MagicMirrorPackage, "env", ...)` + `addCleanup`
443+
- All 245 tests passing
444+

mmpm/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
major = 4
2-
minor = 5
2+
minor = 6
33
patch = 0
44

55
version = f"{major}.{minor}.{patch}"

mmpm/api/endpoints/ep_configs.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ class Configs(Endpoint):
1717
including retrieving and updating various configuration files.
1818
"""
1919

20+
env: MMPMEnv = MMPMEnv()
21+
2022
def __init__(self):
2123
self.name = "configs"
2224
self.blueprint = Blueprint(self.name, __name__, url_prefix=f"/api/{self.name}")
2325
self.handler = None
24-
self.env = MMPMEnv()
2526
self.mm_configs = MagicMirrorConfigs()
2627

2728
@self.blueprint.route("/retrieve/<filename>", methods=[http.GET])

mmpm/api/endpoints/ep_env.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ class Env(Endpoint):
1717
including retrieving the current environment, the default environment, and updating the environment settings.
1818
"""
1919

20+
env: MMPMEnv = MMPMEnv()
21+
2022
def __init__(self):
2123
self.name = "env"
2224
self.blueprint = Blueprint(self.name, __name__, url_prefix=f"/api/{self.name}")
23-
self.env = MMPMEnv()
2425

2526
@self.blueprint.route("/", methods=[http.GET])
2627
def retrieve() -> Response:

mmpm/api/endpoints/ep_mmpm.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ class Mmpm(Endpoint):
1515
A Flask endpoint class for interacting with the MMPM application in more 'meta' manner.
1616
"""
1717

18+
env: MMPMEnv = MMPMEnv()
19+
1820
def __init__(self):
1921
self.name = "mmpm"
2022
self.blueprint = Blueprint(self.name, __name__, url_prefix=f"/api/{self.name}")
21-
self.env = MMPMEnv()
2223

2324
@self.blueprint.route("/version", methods=[http.GET])
2425
def version() -> Response:

mmpm/api/endpoints/ep_packages.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,14 @@ def upgrade() -> Response:
130130

131131
for package in packages:
132132
pkg = MagicMirrorPackage(**package)
133+
ok, error = pkg.upgrade()
133134

134-
if pkg.upgrade():
135+
if ok:
135136
logger.debug(f"Upgraded {pkg.title}")
136137
success.append(package)
137138
else:
138-
logger.debug(f"Failed to upgrade {pkg.title}")
139-
failure.append(package)
139+
logger.debug(f"Failed to upgrade {pkg.title}: {error}")
140+
failure.append({"title": pkg.title, "error": error})
140141

141142
return self.success({"success": success, "failure": failure})
142143

mmpm/magicmirror/controller.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,9 @@ class MagicMirrorController(Singleton):
9595
or hide specific modules.
9696
"""
9797

98+
env: MMPMEnv = MMPMEnv()
99+
98100
def __init__(self):
99-
self.env = MMPMEnv()
100101
self.factory = MagicMirrorClientFactory()
101102

102103
def status(self):

mmpm/magicmirror/database.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ class MagicMirrorDatabase(Singleton):
2222
and managing the list of available MagicMirror modules and custom packages.
2323
"""
2424

25+
env: MMPMEnv = MMPMEnv()
26+
2527
def __init__(self):
26-
self.env = MMPMEnv()
2728
self.packages: List[MagicMirrorPackage] = None
2829
self.last_update: datetime.datetime = None
2930
self.expiration_date: datetime.datetime = None

mmpm/magicmirror/magicmirror.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616

1717

1818
class MagicMirrorConfigs(Singleton):
19-
def __init__(self) -> None:
20-
self.env = MMPMEnv()
19+
env: MMPMEnv = MMPMEnv()
2120

2221
def get(self, name: str) -> Optional[Path]:
2322
match name:
@@ -68,8 +67,7 @@ class MagicMirror(Singleton):
6867
and removal functionalities.
6968
"""
7069

71-
def __init__(self):
72-
self.env = MMPMEnv()
70+
env: MMPMEnv = MMPMEnv()
7371

7472
def update(self):
7573
"""

mmpm/magicmirror/package.py

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ class MagicMirrorPackage:
3535
MagicMirror package's metadata
3636
"""
3737

38+
env: MMPMEnv = MMPMEnv()
39+
3840
__slots__ = (
3941
"title",
4042
"author",
@@ -43,7 +45,6 @@ class MagicMirrorPackage:
4345
"category",
4446
"directory",
4547
"is_installed",
46-
"env",
4748
"is_upgradable",
4849
"stars",
4950
"last_updated",
@@ -80,7 +81,6 @@ def __init__(
8081
Additional keyword arguments are ignored, but intentionally provided as a means to simplify API interaction.
8182
"""
8283

83-
self.env = MMPMEnv()
8484
self.title = __sanitize__(title).strip()
8585
self.author = __sanitize__(author).strip()
8686
self.repository = repository.strip()
@@ -258,39 +258,76 @@ def update(self) -> None:
258258
self.is_upgradable = False
259259
return
260260

261-
os.chdir(modules_dir / self.directory)
262-
263261
try:
264262
self.is_upgradable = repo_up_to_date(modules_dir / self.directory)
265263
except KeyboardInterrupt:
266264
logger.info("User killed process with CTRL-C")
267265
sys.exit(127)
268266

269-
def upgrade(self, force: bool = False) -> bool:
267+
def upgrade(self, force: bool = False) -> tuple[bool, str]:
270268
"""
271269
Upgrades the package by pulling the latest changes from the remote repository.
272270
271+
Local modifications are stashed before the pull and restored afterwards so
272+
that a dirty working tree does not block the merge and user changes are not
273+
silently discarded. If dependency installation fails after a successful pull
274+
the commit is rolled back via ORIG_HEAD so the package is left in its last
275+
known-good state.
276+
273277
Parameters:
274-
force (bool): If True, forces the upgrade even if the repository is up to date.
278+
force (bool): If True, forces dependency reinstall even when already up to date.
275279
276280
Returns:
277-
bool: True if the upgrade is successful, False otherwise.
281+
tuple[bool, str]: (True, "") on success; (False, error_message) on failure.
278282
"""
279-
modules_dir: PosixPath = self.env.MMPM_MAGICMIRROR_ROOT.get() / "modules"
283+
pkg_dir: PosixPath = self.env.MMPM_MAGICMIRROR_ROOT.get() / "modules" / self.directory
280284

281-
os.chdir(modules_dir / self.directory)
285+
# Stash any local modifications so git pull cannot be blocked by dirty-tree
286+
# conflicts while still preserving the user's changes.
287+
_, status_out, _ = run_cmd(["git", "status", "--porcelain"], progress=False, cwd=pkg_dir)
288+
stashed = False
282289

283-
error_code, stdout, stderr = run_cmd(["git", "pull"], message="Retrieving changes")
290+
if status_out.strip():
291+
logger.debug(f"Stashing local changes in {self.directory} before upgrade")
292+
stash_code, _, stash_err = run_cmd(["git", "stash"], message="Stashing local changes", cwd=pkg_dir)
284293

285-
if error_code or stderr:
286-
logger.error(f"Failed to upgrade {self.title}: {stderr}")
287-
return False
294+
if stash_code:
295+
return False, f"Failed to stash local changes: {stash_err.strip()}"
296+
297+
stashed = True
298+
299+
error_code, stdout, stderr = run_cmd(["git", "pull"], message="Retrieving changes", cwd=pkg_dir)
300+
301+
# Restore stashed changes regardless of pull outcome.
302+
if stashed:
303+
pop_code, _, pop_err = run_cmd(["git", "stash", "pop"], message="Restoring local changes", cwd=pkg_dir)
304+
305+
if pop_code:
306+
# Conflicts between the stash and the pulled changes need manual resolution.
307+
logger.warning(
308+
f"git stash pop had conflicts in {self.directory}. Run 'git stash pop' in the module directory to resolve them manually."
309+
)
310+
311+
# Only a non-zero exit code signals failure — git writes fetch progress to
312+
# stderr even on a successful pull, so checking stderr alone gives false negatives.
313+
if error_code:
314+
error_msg = (stderr or stdout).strip()
315+
logger.error(f"Failed to upgrade {self.title}: {error_msg}")
316+
return False, error_msg
317+
318+
pulled_changes = "Already up to date." not in stdout
319+
320+
if pulled_changes or force:
321+
if not InstallationHandler(self).install():
322+
# Roll back the pull so the package is not left with a broken dependency state.
323+
logger.error(f"Dependency install failed after upgrading {self.title}; rolling back to previous commit")
324+
run_cmd(["git", "reset", "--hard", "ORIG_HEAD"], progress=False, cwd=pkg_dir)
325+
return False, "Upgrade pulled successfully but dependency installation failed; changes have been rolled back"
288326

289-
elif "up to date" not in stdout or force and InstallationHandler(self).install():
290327
print(f"Upgraded {color.n_green(self.title)}")
291-
logger.debug(f"Upgraded {color.n_green(self.title)}")
328+
logger.debug(f"Upgraded {self.title}")
292329

293-
return True
330+
return True, ""
294331

295332
@classmethod
296333
def from_json(cls, data: Dict[str, Any]):

0 commit comments

Comments
 (0)