diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..672e082 --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +*.pyi diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/LICENSE b/LICENSE index 60bf0c0..2c9decc 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 The copyright holders +Copyright (c) 2025 Harsh Narayan Jha Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba9fdcd --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Albert Zed Workspaces Plugin + +Quickly find and open your Zed workspaces right from your favorite launcher. + +**Note:** This plugin is not officially associated with Zed or Zed Industries in any way. + +### Install + +To install, copy or symlink this directory to `~/.local/share/albert/python/plugins/albert_zed_workspaces/` + +Or just run `git clone https://github.com/HarshNarayanJha/albert_zed_workspaces ~/.local/share/albert/python/plugins/albert_zed_workspaces/` + +**Note:** For macOS users, be sure to go to `Zed > Install CLI` option to have `zed` in path so that the plugin can detect it. + +### Development Setup + +I use the Zed Editor (naturally). Python Development includes `pyright` as `lsp` and `ruff` as `linter`. + +Copy the `albert.pyi` file from `~/.local/share/albert/python/plugins/albert.pyi` to this directory for type definitions and completions! + +### References + +- The official jetbrains plugin - https://github.com/albertlauncher/albert-plugin-python-jetbrains-projects +- Zed Editor - https://zed.dev diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..5514245 --- /dev/null +++ b/__init__.py @@ -0,0 +1,222 @@ +# Copyright (c) 2025 Harsh Narayan Jha + +""" +This plugin allows you to quickly open workspaces in Zed Editor + +Disclaimer: This plugin is not officially affiliated with Zed or Zed Industries. +""" + +import logging +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from shutil import which +from sys import platform +from typing import cast, override + +from albert import ( # pyright: ignore[reportMissingModuleSource] + Action, + Item, + MatchConfig, + Matcher, + PluginInstance, + Query, + StandardItem, + TriggerQueryHandler, + makeThemeIcon, + runDetachedProcess, +) +from dateutil.parser import isoparse + +md_iid = "4.0" +md_version = "2.1" +md_name = "Zed Workspaces" +md_description = "Open your Zed workspaces" +md_license = "MIT" +md_url = "https://github.com/HarshNarayanJha/albert_zed_workspaces" +md_readme_url = "https://github.com/HarshNarayanJha/albert_zed_workspaces/blob/main/README.md" +md_lib_dependencies = ["python-dateutil"] +md_authors = ["@HarshNarayanJha"] +md_maintainers = ["@HarshNarayanJha"] + + +@dataclass +class Workspace: + id: str + name: str + path: str + last_opened: int + + +@dataclass +class Editor: + name: str + icon: str + config_dir_prefix: str + binary: str | None + + def __init__(self, name: str, icon: str, config_dir_prefix: str, binaries: list[str]): + self.name = name + self.icon = icon + self.config_dir_prefix = config_dir_prefix + self.binary = self._find_binary(binaries) + + def _find_binary(self, binaries: list[str]) -> str | None: + for binary in binaries: + if which(binary): + return binary + return None + + def list_workspaces(self) -> list[Workspace]: + config_dir = Path.home() / ".local/share/" + if platform == "darwin": + config_dir = Path.home() / "Library" / "Application Support" + + dirs = list(config_dir.glob(f"{self.config_dir_prefix}/")) + if not dirs: + return [] + latest = sorted(dirs)[-1] + return self._parse_recent_workspaces(Path(latest) / "db.sqlite") + + def _parse_recent_workspaces(self, recent_workspaces_file: Path) -> list[Workspace]: + try: + workspaces: list[Workspace] = [] + with sqlite3.connect(recent_workspaces_file) as conn: + cursor = conn.cursor() + # NOTE: path might contain multiple paths, need to check + cursor.execute("SELECT workspace_id, paths, timestamp FROM workspaces") + for row in cursor: + if not row[1]: + continue + + w_id = row[0] + local_path = row[1].strip() + timestamp = int(isoparse(row[2]).timestamp()) + + w_name = local_path.split("/")[-1] + + workspaces.append(Workspace(id=w_id, name=w_name, path=local_path, last_opened=timestamp)) + + return workspaces + + except sqlite3.OperationalError: + logging.error(f"Please update your Zed to the latest version for {recent_workspaces_file}") + return [] + + except FileNotFoundError: + return [] + + +class Plugin(PluginInstance, TriggerQueryHandler): + def __init__(self): + PluginInstance.__init__(self) + TriggerQueryHandler.__init__(self) + + self.fuzzy: bool = False + + self._match_path: bool + if (match_path := self.readConfig("match_path", bool)) is None: + self._match_path = True + else: + self._match_path = cast(bool, match_path) + + if platform == "darwin": + zed_dir_name = "Zed" + icon = "/Applications/Zed.app" + icon_preview = "/Applications/Zed-Preview.app" + elif platform == "linux": + zed_dir_name = "zed" + icon = "zed" + icon_preview = "zed-preview" + else: + raise NotImplementedError(f"Unsupported platform: {platform}") + + editors = [ + Editor( + name="Zed Editor", + icon=icon, + config_dir_prefix=f"{zed_dir_name}/db/0-stable", + binaries=["zed", "zeditor", "zedit", "zed-cli"], + ), + Editor( + name="Zed Editor (Preview)", + icon=icon_preview, + config_dir_prefix=f"{zed_dir_name}/db/0-preview", + binaries=["zed", "zeditor", "zedit", "zed-cli"], + ), + ] + self.editors: list[Editor] = [e for e in editors if e.binary is not None] + + @property + def match_path(self) -> bool: + return self._match_path + + @match_path.setter + def match_path(self, value: bool): + self._match_path = value + self.writeConfig("match_path", value) + + @override + def supportsFuzzyMatching(self): + return True + + @override + def setFuzzyMatching(self, enabled: bool): + self.fuzzy = enabled + + @override + def defaultTrigger(self) -> str: + return "zd " + + @override + def synopsis(self, query: str) -> str: + return "" if self._match_path else "" + + @override + def handleTriggerQuery(self, query: Query): + if not query.isValid: + return + + editor_workspace_pairs: list[tuple[Editor, Workspace]] = [] + + m = Matcher(query.string, MatchConfig(fuzzy=self.fuzzy)) + for editor in self.editors: + workspaces = editor.list_workspaces() + workspaces = [p for p in workspaces if Path(p.path).exists()] + if self._match_path: + workspaces = [p for p in workspaces if m.match(p.name) or m.match(p.path)] + else: + workspaces = [p for p in workspaces if m.match(p.name)] + + editor_workspace_pairs.extend([(editor, p) for p in workspaces]) + + # sort by last opened + editor_workspace_pairs.sort(key=lambda pair: pair[1].last_opened, reverse=True) + + query.add([self._make_item(editor, workspace, query) for editor, workspace in editor_workspace_pairs]) + + def _make_item(self, editor: Editor, workspace: Workspace, query: Query) -> Item: + return StandardItem( + id=f"{workspace.id}-{editor.binary}-{workspace.last_opened}", + text=workspace.name, + subtext=workspace.path, + input_action_text=workspace.name, + icon_factory=lambda: makeThemeIcon(editor.icon), + actions=[ + Action( + "Open", + "Open in %s" % editor.name, + lambda selected_workspace=workspace.path: runDetachedProcess( + # Binary has to be valid here + [editor.binary, selected_workspace] # pyright: ignore[reportArgumentType] + ), + ) + ], + ) + + @override + def configWidget(self): + return [ + {"type": "label", "text": str(__doc__).strip(), "widget_properties": {"textFormat": "Qt::MarkdownText"}}, + {"type": "checkbox", "property": "match_path", "label": "Match path"}, + ] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..21e682a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "albert-plugin-python-zed-workspaces" +version = "2.1" +authors = [{ name = "Harsh Narayan Jha", email = "harshnj at proton dot me" }] +description = "Open your Zed workspaces" +readme = "README.md" +license = "LICENSE" +requires-python = ">=3.13" +dependencies = [ + "python-dateutil>=2.9.0.post0", +]