diff --git a/@types/types.d.ts b/@types/types.d.ts index b59bdab..d1fe4bd 100644 --- a/@types/types.d.ts +++ b/@types/types.d.ts @@ -1,17 +1,20 @@ export interface SchemaType { - 'settings-version': number; - 'submenu': boolean; - 'editors': string[]; + settingsVersion: number; + submenu: boolean; + applications: string[]; } export interface Application { - id: number; + id: string; + appId: string; name: string; - enable?: boolean; - native?: string[]; - flatpak?: string[]; - arguments?: string[]; - supports_files?: boolean; + icon: string; + pinned: boolean; + multipleFiles: boolean; + multipleFolders: boolean; + packageType: 'Flatpak' | 'AppImage' | 'Native'; + mimeTypes?: string[]; + enable: boolean; } export interface ValidationResult { diff --git a/Makefile b/Makefile index 991c73b..a98039b 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ UI_FILES := $(patsubst resources/ui/%.blp,src/ui/%.ui,$(BLP_FILES)) UI_SRC := $(shell find src/ui -name '*.ui') UI_DST := $(patsubst src/ui/%,dist/ui/%,$(UI_SRC)) -.PHONY: all build build-ui pot pot-merge mo pack install test test-shell remove clean +.PHONY: all build build-ui pot pot-merge mo pack install test test-py test-shell remove clean all: pack @@ -73,6 +73,7 @@ pack: build schemas/gschemas.compiled copy-ui mo @cp metadata.json dist/ @cp -r schemas dist/ @cp -r nautilus-extension/* dist/ + @cp -r resources/ui/icons dist/ui/ @(cd dist && zip ../$(UUID).shell-extension.zip -9r .) install: pack @@ -83,6 +84,11 @@ test: pack @cp -r dist $(HOME)/.local/share/gnome-shell/extensions/$(UUID) gnome-extensions prefs $(UUID) +test-py: + @rm -rf $(HOME)/.local/share/gnome-shell/extensions/$(UUID)/Flickernaut + @rm -rf $(HOME)/.local/share/gnome-shell/extensions/$(UUID)/nautilus-flickernaut.py + @cp -r nautilus-extension/* $(HOME)/.local/share/gnome-shell/extensions/$(UUID) + test-shell: @env GNOME_SHELL_SLOWDOWN_FACTOR=2 \ MUTTER_DEBUG_DUMMY_MODE_SPECS=1500x1000 \ diff --git a/nautilus-extension/Flickernaut/launcher.py b/nautilus-extension/Flickernaut/launcher.py new file mode 100644 index 0000000..9d77633 --- /dev/null +++ b/nautilus-extension/Flickernaut/launcher.py @@ -0,0 +1,140 @@ +import os +import shlex +from gi.repository import GLib, Gio # type: ignore +from .logger import get_logger + +logger = get_logger(__name__) + + +class Launcher: + """Handles launching a desktop application.""" + + def __init__(self, app_info: Gio.DesktopAppInfo, app_id: str, name: str) -> None: + self.app_id = app_id + self.name = name + self._app_info = app_info + self._launch_method = "none" + self._run_command = () + self._commandline = self._get_commandline(app_info) + self._set_launch_command() + + logger.debug(f"launcher method: {self._launch_method}") + logger.debug(f"commandline: {self._commandline}") + + def _get_commandline(self, app_info: Gio.DesktopAppInfo) -> list[str]: + """Get the commandline from the app_info, handling special cases.""" + executable = os.path.basename(app_info.get_executable()) or "" + + bin_path = GLib.find_program_in_path(executable) + if not bin_path: + return [] + + cmd = app_info.get_commandline() or "" + + # Split commandline into tokens while respecting quotes + tokens = shlex.split(cmd) + + # Placeholder tokens + placeholders = { + "%f", + "%F", + "%u", + "%U", + "%d", + "%D", + "%n", + "%N", + "%k", + "%v", + "%m", + "%i", + "%c", + "%r", + "@@u", + "@@", + "@", + } + filtered = [ + t for t in tokens if t not in placeholders and not t.startswith("%") + ] + + if bin_path and filtered: + filtered[0] = bin_path + + return filtered + + def _set_launch_command(self) -> None: + """Determine the best launch command for the application.""" + # 1. Try Gio.AppInfo.launch_uris first + if self._app_info: + self._launch_method = "gio-launch" + self._run_command = () + return + + # 2. Fallback to gtk-launch if gio-launch is not available + bin_path = GLib.find_program_in_path("gtk-launch") + if bin_path and os.path.isfile(bin_path): + desktop_id = ( + self._app_info.get_id()[:-8] + if self._app_info.get_id().endswith(".desktop") + else self._app_info.get_id() + ) + self._launch_method = "gtk-launch" + self._run_command = (bin_path, desktop_id) + return + + # 3. Fallback to commandline if other methods are not available + if self._commandline: + self._launch_method = "commandline" + self._run_command = tuple(self._commandline) + return + + self._run_command = () + self._launch_method = "none" + self._init_failed = True + + def launch(self, paths: list[str]) -> bool: + """Launch the application based _launch_method.""" + if self._launch_method == "gio-launch" and self._app_info: + try: + logger.debug(f"Launching {self.name} with gio-launch: {paths}") + ctx = None + self._app_info.launch_uris_async(paths, ctx) + return True + except Exception as e: + logger.error( + f"Failed to launch {self.name} with Gio.AppInfo.launch_uris: {e}" + ) + return False + + elif self._launch_method == "gtk-launch": + try: + command = list(self._run_command) + list(paths) + logger.debug(f"Launching {self.name}: {command}") + pid, *_ = GLib.spawn_async(command) + GLib.spawn_close_pid(pid) + return True + except Exception as e: + logger.error(f"Failed to launch {self.name} with gtk-launch: {e}") + return False + + elif self._launch_method == "commandline": + try: + command = list(self._run_command) + list(paths) + logger.debug(f"Launching {self.name} with commandline: {command}") + pid, *_ = GLib.spawn_async(command) + GLib.spawn_close_pid(pid) + return True + except Exception as e: + logger.error(f"Failed to launch {self.name} with commandline: {e}") + return False + + logger.error(f"No valid launch method for {self.app_id}") + return False + + @property + def run_command(self) -> tuple[str, ...]: + return self._run_command + + def __str__(self) -> str: + return f"Launcher({self.name}, method={self._launch_method}, cmd={self._run_command})" diff --git a/nautilus-extension/Flickernaut/logger.py b/nautilus-extension/Flickernaut/logger.py new file mode 100644 index 0000000..494e185 --- /dev/null +++ b/nautilus-extension/Flickernaut/logger.py @@ -0,0 +1,20 @@ +import logging + +# Set to True for development only +FLICKERNAUT_DEBUG: bool = False + + +class FlickernautFormatter(logging.Formatter): + def format(self, record): + record.msg = f"[Flickernaut] [{record.levelname}] : {record.msg}" + return super().format(record) + + +def get_logger(name: str) -> logging.Logger: + logger = logging.getLogger(name) + if not logger.hasHandlers(): + handler = logging.StreamHandler() + handler.setFormatter(FlickernautFormatter()) + logger.addHandler(handler) + logger.setLevel(logging.DEBUG if FLICKERNAUT_DEBUG else logging.WARNING) + return logger diff --git a/nautilus-extension/Flickernaut/manager.py b/nautilus-extension/Flickernaut/manager.py index 3b3bb73..3a5bca2 100644 --- a/nautilus-extension/Flickernaut/manager.py +++ b/nautilus-extension/Flickernaut/manager.py @@ -1,15 +1,40 @@ -import os +import os.path import json from functools import lru_cache -from typing import Any -from gi.repository import Gio, GLib -from .models import ProgramRegistry, Program, Native, Flatpak +from typing import Any, Optional +from gi.repository import Gio, GLib # type: ignore +from .logger import get_logger +from .models import Application, AppJsonStruct +from .registry import ApplicationsRegistry + +logger = get_logger(__name__) + + +def parse_app_entry(app: dict) -> Optional[AppJsonStruct]: + """Helper to validate and map a JSON entry into AppJsonStruct.""" + try: + # Accept both camelCase and snake_case for compatibility + return AppJsonStruct( + id=app.get("id", "").strip(), + app_id=app.get("app_id", app.get("appId", "")).strip(), + name=app.get("name", "").strip(), + pinned=app.get("pinned", False), + multiple_files=app.get("multiple_files", app.get("multipleFiles", False)), + multiple_folders=app.get( + "multiple_folders", app.get("multipleFolders", False) + ), + enable=app.get("enable", True), + ) + except Exception as e: + logger.error(f"Failed to map app entry: {e}") + return None -class ProgramConfigLoader: +class ApplicationConfigLoader: @staticmethod @lru_cache(maxsize=1) def get_schema_dir() -> str: + """Return the schema directory path.""" return os.path.join( GLib.get_user_data_dir(), "gnome-shell", @@ -19,115 +44,107 @@ def get_schema_dir() -> str: ) @staticmethod - def _create_packages(entry: dict[str, Any]) -> list: - """Create package instances from JSON entry. - - Args: - entry: Dictionary from JSON containing 'native' and/or 'flatpak' keys - - Returns: - List of initialized Package objects (Native/Flatpak) - """ - packages: list = [] - - packages.extend( - Native(cmd.strip()) - for cmd in entry.get("native", []) - if isinstance(cmd, str) and cmd.strip() - ) - - packages.extend( - Flatpak(app_id.strip()) - for app_id in entry.get("flatpak", []) - if isinstance(app_id, str) and app_id.strip() - ) - - return packages - - @staticmethod - def get_settings(key: str) -> Any: - """Retrieve a value from GSettings for any given key. - - Args: - key (str): The GSettings key to retrieve the value for. - - Returns: - Any: The unpacked value associated with the given key. - - Raises: - RuntimeError: If the schema source or schema cannot be loaded, - or if the key is not found in the schema. - """ - schema_dir = ProgramConfigLoader.get_schema_dir() + @lru_cache(maxsize=1) + def get_schema_source() -> Gio.SettingsSchemaSource: + """Return the GSettings schema source.""" + schema_dir = ApplicationConfigLoader.get_schema_dir() schema_source = Gio.SettingsSchemaSource.new_from_directory( schema_dir, Gio.SettingsSchemaSource.get_default(), False ) if not schema_source: - raise RuntimeError(f"Failed to load schema source from {schema_dir}") + logger.error(f"Failed to load schema source from {schema_dir}") + return None - schema = schema_source.lookup("org.gnome.shell.extensions.flickernaut", True) + return schema_source + + @staticmethod + def get_gsettings(key: str) -> Optional[Any]: + """Retrieve a value from GSettings for any given key.""" + schema_source = ApplicationConfigLoader.get_schema_source() + if schema_source is None: + logger.critical("Schema source is None. Cannot read GSettings.") + return None + schema = schema_source.lookup("org.gnome.shell.extensions.flickernaut", True) if not schema: - raise RuntimeError( - "Schema 'org.gnome.shell.extensions.flickernaut' not found" + logger.critical( + f"Schema 'org.gnome.shell.extensions.flickernaut' not found." ) + return None settings = Gio.Settings.new_full(schema, None, None) value = settings.get_value(key).unpack() - return value @staticmethod def get_submenu_setting() -> bool: - """ - Determines whether the submenu feature is enabled. - - Returns: - bool: True if the submenu feature is enabled, False otherwise. - - Raises: - RuntimeError: If the "submenu" GSettings key does not return a boolean. - """ - value = ProgramConfigLoader.get_settings("submenu") + """Return True if submenu feature is enabled, else False.""" + value = ApplicationConfigLoader.get_gsettings("submenu") if not isinstance(value, bool): - raise RuntimeError("GSettings key 'submenu' did not return a boolean") + logger.error( + f"GSettings key 'submenu' returned unexpected type: {type(value)}" + ) + return False return value @staticmethod - def get_applications() -> ProgramRegistry: - values = ProgramConfigLoader.get_settings("editors") - programs: ProgramRegistry = ProgramRegistry() - - for value in values: - try: - entry = json.loads(value) - if not entry.get("enable", True): + def get_applications() -> ApplicationsRegistry: + """Load and parse the configured applications from GSettings.""" + try: + settings = ApplicationConfigLoader.get_gsettings("applications") + registry = ApplicationsRegistry() + + if not settings: + logger.warning("No applications found in GSettings") + return registry + + # sort entries by name before adding to registry + entries = [] + + for value in settings: + app_dict = None + try: + app_dict = json.loads(value) if isinstance(value, str) else value + except Exception as e: + logger.error(f"Error parsing application entry: {e}", exc_info=True) + continue + + schemaKey = parse_app_entry(app_dict) + if not schemaKey or not schemaKey["enable"]: continue + entries.append(schemaKey) + + # Sort entries by 'name' (case-insensitive) + entries = sorted(entries, key=lambda x: x["name"].lower()) - arguments = [ - arg.strip() - for arg in entry.get("arguments", []) - if isinstance(arg, str) and arg.strip() - ] - - program = Program( - int(entry["id"]), - entry["name"], - *ProgramConfigLoader._create_packages(entry), - arguments=arguments, - supports_files=entry.get("supports_files", False), + for idx, schemaKey in enumerate(entries, 1): + logger.debug(f"--- Application Menu Entry {idx} ---") + + for k, v in schemaKey.items(): + logger.debug(f"{k}: {v!r}") + + application = Application( + schemaKey["id"], + schemaKey["app_id"], + schemaKey["name"], + schemaKey["pinned"], + schemaKey["multiple_files"], + schemaKey["multiple_folders"], ) - programs[program.id] = program + logger.debug("") + + registry.add_application(application) - except (json.JSONDecodeError, KeyError) as e: - raise RuntimeError(f"Error parsing editor entry: {e}") + return registry - return programs + except Exception as e: + logger.critical(f"Fatal error in get_applications: {e}", exc_info=True) + raise -configured_programs: ProgramRegistry = ProgramConfigLoader.get_applications() -use_submenu: bool = ProgramConfigLoader.get_submenu_setting() +submenu: bool = ApplicationConfigLoader.get_submenu_setting() +applications_registry: ApplicationsRegistry = ApplicationConfigLoader.get_applications() diff --git a/nautilus-extension/Flickernaut/models.py b/nautilus-extension/Flickernaut/models.py index 75944a3..5d2f851 100644 --- a/nautilus-extension/Flickernaut/models.py +++ b/nautilus-extension/Flickernaut/models.py @@ -8,214 +8,122 @@ import os from gettext import gettext as _ -from typing import Optional -from gi.repository import Nautilus, GLib +from typing import Optional, TypedDict +from gi.repository import GLib, Gio # type: ignore +from .logger import get_logger +from .launcher import Launcher +logger = get_logger(__name__) -class ProgramDict(dict): - def __iter__(self): - # Override to iterate over values (Program instances) - return iter(self.values()) - @property - def names(self) -> list[str]: - return list(self.keys()) +class AppJsonStruct(TypedDict): + id: str + app_id: str + name: str + pinned: bool + multiple_files: bool + multiple_folders: bool + enable: bool class Package: - def __str__(self) -> str: - return f"{self.type_name}:\n installed = {self.is_installed}" - - @property - def type_name(self) -> str: - return _("Unknown") + """Handles app installation checking.""" - @property - def type_name_raw(self) -> str: - return self.__class__.__name__ - - @property - def run_command(self) -> tuple[str, ...]: - raise NotImplementedError + def __init__(self, app_id: str): + self.app_id = app_id + self.app_info = Gio.DesktopAppInfo.new(app_id) if app_id else None + self._is_installed_cache = None @property def is_installed(self) -> bool: - raise NotImplementedError - - -class Native(Package): - def __init__(self, *commands: str) -> None: - self.commands: tuple[str, ...] = commands - self.cmd_path: str = "" - self.desktop_id: Optional[str] = None - - for cmd in commands: - if cmd_path := GLib.find_program_in_path(cmd): - self.cmd_path = cmd_path - self.desktop_id = self._find_desktop_id(cmd) - break - - def _find_desktop_id(self, command_name: str) -> Optional[str]: - search_dirs: list[str] = [ - os.path.join(GLib.get_user_data_dir(), "applications"), - *[os.path.join(d, "applications") for d in GLib.get_system_data_dirs()], - ] - - for dir_path in search_dirs: - try: - for file in os.listdir(dir_path): - if file.endswith(".desktop"): - basename = file[:-8] - if "-url-handler" in basename and basename.startswith( - command_name - ): - return command_name - elif command_name in basename: - return basename - except FileNotFoundError: - continue - return None - - @property - def run_command(self) -> tuple[str, ...]: - if self.desktop_id: - launcher = GLib.find_program_in_path("gtk-launch") or "/usr/bin/gtk-launch" - return (launcher, self.desktop_id) - return (self.cmd_path,) if self.cmd_path else () + if self._is_installed_cache is not None: + return self._is_installed_cache - @property - def is_installed(self) -> bool: - return bool(self.cmd_path) + if not self.app_info: + self._is_installed_cache = False + return False - @property - def type_name(self) -> str: - return "" + exec = self.app_info.get_executable() or "" + package_type = os.path.basename(exec) if exec else "" + if package_type == "flatpak": + logger.debug("package type: flatpak") -class Flatpak(Package): - _flatpak_path = GLib.find_program_in_path("flatpak") or "" + flatpak_dirs = [ + os.path.join(GLib.get_user_data_dir(), "flatpak/exports/bin"), + "/var/lib/flatpak/exports/bin", + ] - def __init__(self, app_id: str) -> None: - self.app_id: str = app_id + bin_name = self.app_id[:-8] + for bin_dir in flatpak_dirs: + if os.path.exists(os.path.join(bin_dir, bin_name)): + self._is_installed_cache = True + return True + self._is_installed_cache = False + return False - @classmethod - def _get_bin_dirs(cls) -> list[str]: - dirs = [ - os.path.join(GLib.get_user_data_dir(), "flatpak/exports/bin"), - "/var/lib/flatpak/exports/bin", - ] - return [d for d in dirs if os.path.isdir(d)] + elif package_type.endswith(".appimage"): + logger.debug("package type: appimage") - @property - def run_command(self) -> tuple[str, ...]: - return (self._flatpak_path, "run", self.app_id) + if exec and exec.endswith(".appimage"): + if os.path.exists(exec) and os.access(exec, os.X_OK): + self._is_installed_cache = True + return True + self._is_installed_cache = False + return False - @property - def is_installed(self) -> bool: - if not self._flatpak_path or not self.app_id.strip(): + elif exec: + logger.debug("package type: native") + + if os.path.isabs(exec): + if os.path.exists(exec) and os.access(exec, os.X_OK): + self._is_installed_cache = True + return True + else: + bin_path = GLib.find_program_in_path(exec) + if ( + bin_path + and os.path.exists(bin_path) + and os.access(bin_path, os.X_OK) + ): + self._is_installed_cache = True + return True + self._is_installed_cache = False return False - return any( - os.path.exists(os.path.join(bin_dir, self.app_id)) - for bin_dir in self._get_bin_dirs() - ) - @property - def type_name(self) -> str: - return "Flatpak" + self._is_installed_cache = False + return False -class Program: +class Application: + """Represents an application entry configured in Flickernaut.""" + def __init__( self, - id: int, + id: str, + app_id: str, name: str, - *packages: Package, - arguments: Optional[list[str]] = None, - supports_files: bool = False, + pinned: bool = False, + multiple_files: bool = False, + multiple_folders: bool = False, ) -> None: - self.id: int = id + self.id: str = id + self.app_id: str = app_id self.name: str = name - self.arguments: list[str] = arguments or [] - self.supports_files: bool = supports_files - self._packages: ProgramDict = ProgramDict() - - for pkg in packages: - self._packages[pkg.type_name_raw] = pkg - - @property - def packages(self) -> ProgramDict: - return self._packages - - @property - def installed_packages(self) -> list[Package]: - return [pkg for pkg in self._packages.values() if pkg.is_installed] - - -class ProgramRegistry(ProgramDict): - @staticmethod - def _activate_item(item: Nautilus.MenuItem, command: list[str]) -> None: - pid, *_ = GLib.spawn_async(command) - GLib.spawn_close_pid(pid) - - def get_menu_items( - self, - path: str, - *, - id_prefix: str = "", - is_file: bool = False, - use_submenu: bool = False, - ) -> list[Nautilus.MenuItem]: - - items: list[Nautilus.MenuItem] = [] - - for program in self: - if is_file and not program.supports_files: - continue - - installed = program.installed_packages - - for pkg in installed: - if not pkg.is_installed: - continue - - show_type = len(installed) > 1 and pkg.type_name - - if is_file: - label = _("Open with %s") % program.name - else: - label = _("Open in %s") % program.name - - if show_type: - label += f" ({pkg.type_name})" - - item = Nautilus.MenuItem.new( - name=f"{id_prefix}program-{program.id}", label=label - ) - - item.connect( - "activate", - self._activate_item, - [*pkg.run_command, *program.arguments, path], - ) - - items.append(item) - - if use_submenu and items: - submenu = Nautilus.Menu() - - for item in items: - submenu.append_item(item) - - label = _("Open In...") if not is_file else _("Open With...") - - submenu_item = Nautilus.MenuItem.new(id_prefix + "submenu", label) - - submenu_item.set_submenu(submenu) - - return [submenu_item] - - return items - - def __iadd__(self, program: Program) -> "ProgramRegistry": - self[program.id] = program - return self + self.pinned: bool = pinned + self.multiple_files: bool = multiple_files + self.multiple_folders: bool = multiple_folders + self.package = Package(app_id) + self.launcher: Optional[Launcher] = None + if self.package.is_installed: + logger.debug(f"installed: {self.package.is_installed}") + app_info = self.package.app_info + try: + self.launcher = Launcher(app_info, app_id, name) if app_info else None + except Exception as e: + logger.error(f"Failed to initialize launcher for {app_id}: {e}") + + def installed_packages(self) -> list[Launcher]: + # Deprecated: installed_packages property is kept for compatibility + # but should not be used for is_installed checking. + return [self.launcher] if self.launcher else [] diff --git a/nautilus-extension/Flickernaut/registry.py b/nautilus-extension/Flickernaut/registry.py new file mode 100644 index 0000000..7a2839b --- /dev/null +++ b/nautilus-extension/Flickernaut/registry.py @@ -0,0 +1,185 @@ +from gettext import gettext as _ +from gi.repository import Nautilus, GLib # type: ignore +from .logger import get_logger +from .launcher import Launcher +from .models import Application + +logger = get_logger(__name__) + + +class ApplicationsRegistry(dict[str, Application]): + """Registry of configured applications.""" + + def __init__(self): + super().__init__() + self._menu_cache = {} + + def print_menu_cache(self): + """Debug: Print all menu cache keys and their sizes.""" + logger.debug("---- Menu Cache Contents ----") + for k, v in self._menu_cache.items(): + logger.debug(f"Cache key: {k} | Items: {len(v)}") + logger.debug("---- End of Menu Cache ----") + + def add_application(self, application: Application) -> None: + self[application.id] = application + + @staticmethod + def _activate_menu_item( + item: Nautilus.MenuItem, launcher: Launcher, paths: list[str] + ) -> None: + """Callback to activate a menu item and launch the command.""" + try: + if not launcher: + logger.error("No valid launcher provided for menu item activation.") + return + if not paths: + logger.error("No paths provided for launcher.") + return + if launcher.launch(paths): + logger.debug( + f"Launch succeeded for {launcher.name} with paths: {paths!r}" + ) + return + else: + logger.error( + f"All launch methods failed for: {getattr(launcher, 'app_id', 'unknown')}" + ) + except Exception as e: + logger.error(f"Error during launching application: {e}") + + def _create_menu_item( + self, + application: Application, + launcher: Launcher, + paths: list[str], + id_prefix: str, + is_file: bool, + ) -> Nautilus.MenuItem: + """Create a Nautilus.MenuItem for a given application and launcher.""" + label = ( + _("Open with %s") % application.name + if is_file + else _("Open in %s") % application.name + ) + + item = Nautilus.MenuItem.new( + name=f"Flickernaut::{id_prefix}::{application.id}", + label=label, + ) + + item.connect("activate", self._activate_menu_item, launcher, paths) + return item + + def _filter_applications( + self, + *, + is_file: bool, + selection_count: int = 1, + ) -> list[Application]: + """Filter applications based on context and installation status. + - For single selection: return all installed apps. + - For multi-select: only apps supporting multiple files/folders. + """ + filtered: list[Application] = [] + for app in self.values(): + if not app.package.is_installed: + continue + if selection_count > 1: + # Multi-select: filter by support for multiple files/folders + if is_file and not app.multiple_files: + continue + if not is_file and not app.multiple_folders: + continue + # For single selection, always show if installed + filtered.append(app) + return filtered + + def get_menu_items( + self, + paths: list[str], + *, + id_prefix: str = "", + is_file: bool = False, + selection_count: int = 1, + use_submenu: bool = False, + ) -> list[Nautilus.MenuItem]: + """Generate Nautilus menu items for the given paths and context.""" + # Uncomment for debugging cache + # self.print_menu_cache() + + cache_key = ( + tuple(paths), + id_prefix, + is_file, + selection_count, + use_submenu, + ) + + if cache_key in self._menu_cache: + # Uncomment for debugging cache hits + # logger.debug(f"[CACHE HIT] Menu cache used for key: {cache_key}") + return self._menu_cache[cache_key] + # Uncomment for debugging cache misses + # logger.debug(f"[CACHE MISS] Building menu for key: {cache_key}") + + items: list[Nautilus.MenuItem] = [] + + # Separate pinned items and submenu items + pinned_items: list[Nautilus.MenuItem] = [] + submenu_items: list[Nautilus.MenuItem] = [] + + # registry level patch: Convert all paths to uris once, up front + # uris = [GLib.filename_to_uri(p, None) for p in paths] + + for app in self._filter_applications( + is_file=is_file, selection_count=selection_count + ): + launcher = app.launcher + if not launcher: + continue + + item = self._create_menu_item(app, launcher, paths, id_prefix, is_file) + + if use_submenu and app.pinned: + pinned_items.append(item) + elif use_submenu: + submenu_items.append(item) + else: + items.append(item) + + if use_submenu: + result_items = [] + + if submenu_items: + submenu = Nautilus.Menu() + + for item in submenu_items: + submenu.append_item(item) + + label = _("Open In...") if not is_file else _("Open With...") + + submenu_item = Nautilus.MenuItem.new( + f"Flickernaut::submenu::{id_prefix}", label + ) + + submenu_item.set_submenu(submenu) + result_items.append(submenu_item) + + result_items.extend(pinned_items) + + if not result_items: + logger.warning( + f"No menu items produced for paths: {paths!r} (is_file={is_file})" + ) + + self._menu_cache[cache_key] = result_items + return result_items + + if not items: + logger.warning( + f"No menu items produced for paths: {paths!r} (is_file={is_file})" + ) + + self._menu_cache[cache_key] = items + return items diff --git a/nautilus-extension/nautilus-flickernaut.py b/nautilus-extension/nautilus-flickernaut.py index 8ae7e47..4084568 100644 --- a/nautilus-extension/nautilus-flickernaut.py +++ b/nautilus-extension/nautilus-flickernaut.py @@ -1,28 +1,34 @@ import os.path import gettext from typing import Optional -from gi.repository import Nautilus, GObject, GLib -from Flickernaut.manager import configured_programs, use_submenu +from Flickernaut.logger import get_logger +from gi.repository import Nautilus, GObject, GLib # type: ignore +from Flickernaut.manager import applications_registry, submenu + +logger = get_logger(__name__) # Init gettext translations +UUID: str = "flickernaut@imoize.github.io" + LOCALE_DIR = os.path.join( GLib.get_user_data_dir(), "gnome-shell", "extensions", - "flickernaut@imoize.github.io", + UUID, "locale", ) if not os.path.exists(LOCALE_DIR): + logger.warning(f"Locale dir {LOCALE_DIR} not found, disabling translation.") LOCALE_DIR = None try: - gettext.bindtextdomain("flickernaut@imoize.github.io", LOCALE_DIR) - gettext.textdomain("flickernaut@imoize.github.io") + gettext.bindtextdomain(UUID, LOCALE_DIR) + gettext.textdomain(UUID) _ = gettext.gettext except Exception as e: - print(f"Flickernaut: gettext init failed: {e}") + logger.error(f"gettext init failed: {e}") _ = lambda s: s @@ -33,44 +39,109 @@ def __init__(self) -> None: super().__init__() def _get_items( - self, folder: Nautilus.FileInfo, *, id_prefix: str = "", is_file: bool = False + self, + file_info_or_list: list[Nautilus.FileInfo], + *, + id_prefix: str = "", + is_file: bool = False, + selection_count: int = 1, ) -> list[Nautilus.MenuItem]: - """Generate menu items for the given folder/file. + """Generate menu items for the given file(s) or folder(s).""" + paths = [f.get_location().get_path() for f in file_info_or_list] - Args: - folder: The target folder or file object - id_prefix: Prefix for menu item IDs - is_file: Whether the target is a file + # experimental: use get_uri() + # paths = [f.get_uri() for f in file_info_or_list] - Returns: - List of menu items to display - """ - folder_path = folder.get_location().get_path() - return configured_programs.get_menu_items( - folder_path, + return applications_registry.get_menu_items( + paths, id_prefix=id_prefix, is_file=is_file, - use_submenu=use_submenu, + selection_count=selection_count, + use_submenu=submenu, ) def get_background_items(self, *args) -> list[Nautilus.MenuItem]: """Generate menu items for background (directory) clicks.""" current_folder = args[-1] - return self._get_items(current_folder) + + return self._get_items( + [current_folder], id_prefix="background", is_file=False, selection_count=1 + ) def get_file_items(self, *args) -> Optional[list[Nautilus.MenuItem]]: """Generate menu items for file selections. Returns: - List of menu items for single selection, None for multiple selections + Optional[list[Nautilus.MenuItem]]: List of menu items for single/multi selection, None if not handled. """ selected_files = args[-1] - # Handle only single file selection - if not isinstance(selected_files, list) or len(selected_files) != 1: + if not isinstance(selected_files, list) or not selected_files: + logger.info("No selection or invalid selection type.") return None - target = selected_files[0] - if target.is_directory(): - return self._get_items(target, id_prefix="selected.") - return self._get_items(target, id_prefix="selected.", is_file=True) + selection_count = len(selected_files) + + if selection_count == 1: + target = selected_files[0] + path = target.get_location().get_path() + + # experimental: use get_uri() + # path = target.get_uri() + + if target.is_directory(): + logger.info(f"Single folder selected: {path}") + + return self._get_items( + [target], id_prefix="selected", is_file=False, selection_count=1 + ) + else: + logger.info(f"Single file selected: {path}") + return self._get_items( + [target], id_prefix="selected", is_file=True, selection_count=1 + ) + else: + # Multi-select: determine if all are files or all are directories + types_and_paths = [ + (f.is_directory(), f.get_location().get_path()) for f in selected_files + ] + types, paths = zip(*types_and_paths) + multiple_dirs = all(types) + multiple_files = not any(types) + + # experimental : use get_uri() + # types_and_paths = [(f.is_directory(), f.get_uri()) for f in selected_files] + # types, paths = zip(*types_and_paths) + # multiple_dirs = all(types) + # multiple_files = not any(types) + + MAX_MULTIPLE = 5 + if selection_count > MAX_MULTIPLE: + logger.debug( + f"Too many items selected ({selection_count}), max allowed is {MAX_MULTIPLE}." + ) + return None + + if multiple_dirs: + logger.info(f"Multiple folders selected: {paths}") + + return self._get_items( + selected_files, + id_prefix="multiple", + is_file=False, + selection_count=selection_count, + ) + elif multiple_files: + logger.info(f"Multiple files selected: {paths}") + + return self._get_items( + selected_files, + id_prefix="multiple", + is_file=True, + selection_count=selection_count, + ) + else: + logger.info( + f"Invalid multi-selection (mixed files and folders): {paths}" + ) + return None diff --git a/po/flickernaut@imoize.github.io.pot b/po/flickernaut@imoize.github.io.pot index 8aa743c..021c7bf 100644 --- a/po/flickernaut@imoize.github.io.pot +++ b/po/flickernaut@imoize.github.io.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: flickernaut@imoize.github.io\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-05-20 10:00+0700\n" +"POT-Creation-Date: 2025-05-26 16:17+0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -27,6 +27,10 @@ msgid "" "You can Enable/Disable using the toggle switch." msgstr "" +#: src/ui/pages/application.ui:28 +msgid "Add" +msgstr "" + #: src/ui/pages/general.ui:12 msgid "General" msgstr "" @@ -48,57 +52,61 @@ msgid "Name" msgstr "" #: src/ui/widgets/application-list.ui:17 -msgid "Native Cmd" +msgid "Multiple Files" +msgstr "" + +#: src/ui/widgets/application-list.ui:18 +msgid "Enable if the app supports opening several files." +msgstr "" + +#: src/ui/widgets/application-list.ui:23 +msgid "Multiple Folders" msgstr "" -#: src/ui/widgets/application-list.ui:22 -msgid "Flatpak ID" +#: src/ui/widgets/application-list.ui:24 +msgid "Enable if the app supports opening several folders." msgstr "" -#: src/ui/widgets/application-list.ui:27 -msgid "Arguments" +#: src/ui/widgets/application-list.ui:29 +msgid "Mime Types" msgstr "" -#: src/ui/widgets/application-list.ui:32 -msgid "Supports Files" +#: src/ui/widgets/application-list.ui:42 +msgid "Remove" msgstr "" -#: nautilus-extension/Flickernaut/models.py:31 -msgid "Unknown" +#: src/ui/widgets/menu.ui:12 +msgid "Project Page" msgstr "" -#: nautilus-extension/Flickernaut/models.py:184 +#: nautilus-extension/Flickernaut/registry.py:61 #, python-format msgid "Open with %s" msgstr "" -#: nautilus-extension/Flickernaut/models.py:186 +#: nautilus-extension/Flickernaut/registry.py:63 #, python-format msgid "Open in %s" msgstr "" -#: nautilus-extension/Flickernaut/models.py:209 +#: nautilus-extension/Flickernaut/registry.py:160 msgid "Open In..." msgstr "" -#: nautilus-extension/Flickernaut/models.py:209 +#: nautilus-extension/Flickernaut/registry.py:160 msgid "Open With..." msgstr "" -#: src/prefs/applicationList.ts:55 -msgid "Name cannot be empty" -msgstr "" - -#: src/prefs/applicationList.ts:57 -msgid "Name already exists" +#: src/prefs/applicationList.ts:65 +msgid "Pin in main menu when submenu is enabled." msgstr "" -#: src/prefs/applicationList.ts:79 -msgid "Native command already exists" +#: src/prefs/applicationList.ts:80 +msgid "Name cannot be empty" msgstr "" -#: src/prefs/applicationList.ts:99 -msgid "Flatpak ID already exists" +#: src/prefs/applicationList.ts:82 +msgid "Name already exists" msgstr "" #: src/ui/widgets/banner.ts:26 diff --git a/resources/ui/icons/hicolor/scalable/actions/view-non-pin-symbolic.svg b/resources/ui/icons/hicolor/scalable/actions/view-non-pin-symbolic.svg new file mode 100644 index 0000000..5441acc --- /dev/null +++ b/resources/ui/icons/hicolor/scalable/actions/view-non-pin-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/ui/pages/application.blp b/resources/ui/pages/application.blp index c539f15..b7f7ea5 100644 --- a/resources/ui/pages/application.blp +++ b/resources/ui/pages/application.blp @@ -13,5 +13,14 @@ template $Application: Adw.PreferencesPage { title: _("Apps"); description: _("Applications listed here will appear in the Nautilus context menu.\nYou can Enable/Disable using the toggle switch."); separate-rows: true; + + header-suffix: Gtk.Button add_app_button { + valign: center; + + child: Adw.ButtonContent { + icon-name: "list-add-symbolic"; + label: _("Add"); + }; + }; } } diff --git a/resources/ui/widgets/application-list.blp b/resources/ui/widgets/application-list.blp index 5d735ca..7bb9ca4 100644 --- a/resources/ui/widgets/application-list.blp +++ b/resources/ui/widgets/application-list.blp @@ -7,19 +7,33 @@ template $ApplicationList: Adw.ExpanderRow { title: _("Name"); } - Adw.EntryRow native { - title: _("Native Cmd"); + Adw.SwitchRow multiple_files { + title: _("Multiple Files"); + subtitle: _("Enable if the app supports opening several files."); } - Adw.EntryRow flatpak { - title: _("Flatpak ID"); + Adw.SwitchRow multiple_folders { + title: _("Multiple Folders"); + subtitle: _("Enable if the app supports opening several folders."); } - Adw.EntryRow arguments { - title: _("Arguments"); + Adw.EntryRow mime_types { + title: _("Mime Types"); + sensitive: false; } - Adw.SwitchRow supports_files { - title: _("Supports Files"); + Adw.WrapBox { + align: 1; + margin-top: 6; + margin-end: 6; + margin-bottom: 6; + + Gtk.Button remove_app_button { + css-classes: [ + "error", + ]; + + label: _("Remove"); + } } } diff --git a/resources/ui/widgets/menu.blp b/resources/ui/widgets/menu.blp new file mode 100644 index 0000000..9feaaac --- /dev/null +++ b/resources/ui/widgets/menu.blp @@ -0,0 +1,24 @@ +using Gtk 4.0; +using Adw 1; +translation-domain "flickernaut@imoize.github.io"; + +menu info_menu_model { + section { + item { + label: _("Project Page"); + action: "prefs.open-github"; + } + + item { + label: "Ko-fi"; + action: "prefs.donate-kofi"; + } + } +} + +MenuButton info_menu { + menu-model: info_menu_model; + icon-name: "emote-love-symbolic"; +} + +Adw.PreferencesPage menu_util {} diff --git a/schemas/org.gnome.shell.extensions.flickernaut.gschema.xml b/schemas/org.gnome.shell.extensions.flickernaut.gschema.xml index 769f8bb..8e21ab3 100644 --- a/schemas/org.gnome.shell.extensions.flickernaut.gschema.xml +++ b/schemas/org.gnome.shell.extensions.flickernaut.gschema.xml @@ -1,183 +1,30 @@ - + + + The version of the settings to update from. 1 + + Group entry in submenu. false + + + + List of applications. + List of applications to be shown in the menu. + [ ] + + + - Editor App - Editor App - - [ - '{ - "id": 1, - "name": "Android Studio", - "native": ["studio"], - "flatpak": ["com.google.AndroidStudio"], - "arguments": [], - "supports_files": false, - "enable": false - }', - '{ - "id": 2, - "name": "CLion", - "native": ["clion"], - "flatpak": ["com.jetbrains.CLion"], - "arguments": [], - "supports_files": false, - "enable": false - }', - '{ - "id": 3, - "name": "CLion (EAP)", - "native": ["clion-eap"], - "flatpak": [], - "arguments": [], - "supports_files": false, - "enable": false - }', - '{ - "id": 4, - "name": "Goland", - "native": ["goland"], - "flatpak": ["com.jetbrains.GoLand"], - "arguments": [], - "supports_files": true, - "enable": false - }', - '{ - "id": 5, - "name": "Goland (EAP)", - "native": ["goland-eap"], - "flatpak": [], - "arguments": [], - "supports_files": true, - "enable": false - }', - '{ - "id": 6, - "name": "IntelliJ IDEA", - "native": ["idea"], - "flatpak": [], - "arguments": [], - "supports_files": true, - "enable": false - }', - '{ - "id": 7, - "name": "IntelliJ IDEA (EAP)", - "native": ["idea-eap"], - "flatpak": [], - "arguments": [], - "supports_files": true, - "enable": false - }', - '{ - "id": 8, - "name": "IntelliJ IDEA CE", - "native": [], - "flatpak": ["com.jetbrains.IntelliJ-IDEA-Community"], - "arguments": [], - "supports_files": true, - "enable": false - }', - '{ - "id": 9, - "name": "IntelliJ IDEA Ultimate", - "native": [], - "flatpak": ["com.jetbrains.IntelliJ-IDEA-Ultimate"], - "arguments": [], - "supports_files": true, - "enable": false - }', - '{ - "id": 10, - "name": "RustRover", - "native": ["rustrover"], - "flatpak": [], - "arguments": [], - "supports_files": true, - "enable": false - }', - '{ - "id": 11, - "name": "Sublime", - "native": ["subl"], - "flatpak": ["com.sublimetext.three"], - "arguments": [], - "supports_files": false, - "enable": false - }', - '{ - "id": 12, - "name": "VSCode", - "native": ["code"], - "flatpak": ["com.visualstudio.code"], - "arguments": [], - "supports_files": true, - "enable": true - }', - '{ - "id": 13, - "name": "VSCode (Insiders)", - "native": ["code-insiders"], - "flatpak": ["com.visualstudio.code.insiders"], - "arguments": [], - "supports_files": true, - "enable": false - }', - '{ - "id": 14, - "name": "VSCodium", - "native": ["vscodium", "codium"], - "flatpak": ["com.vscodium.codium"], - "arguments": [], - "supports_files": true, - "enable": false - }', - '{ - "id": 15, - "name": "Webstorm", - "native": ["webstorm"], - "flatpak": ["com.jetbrains.WebStorm"], - "arguments": [], - "supports_files": true, - "enable": true - }', - '{ - "id": 16, - "name": "Webstorm (EAP)", - "native": ["webstorm-eap"], - "flatpak": [], - "arguments": [], - "supports_files": true, - "enable": false - }', - '{ - "id": 17, - "name": "Windsurf", - "native": ["windsurf"], - "flatpak": [], - "arguments": [], - "supports_files": true, - "enable": false - }', - '{ - "id": 18, - "name": "Zed", - "native": ["zed"], - "flatpak": ["dev.zed.Zed"], - "arguments": [], - "supports_files": true, - "enable": true - }' - ] - + Editor App (DEPRECATED) + [ ] \ No newline at end of file diff --git a/src/lib/prefs/settings.ts b/src/lib/prefs/settings.ts index d6a1e36..4b56331 100644 --- a/src/lib/prefs/settings.ts +++ b/src/lib/prefs/settings.ts @@ -6,20 +6,33 @@ import GLib from 'gi://GLib'; /** * All existing schema keys. */ -export type SchemaKey = keyof SchemaType; +export const SchemaKey = { + applications: 'applications', + settingsVersion: 'settings-version', + submenu: 'submenu', +} as const; -/** Mapping of schema keys to GLib Variant type string */ -export const SchemaVariant = { - 'settings-version': 'i', +/** + * Maps each key from the `SchemaKey` type to its corresponding schema variant identifier. + * + * The values represent the type of schema variant: + * - `'as'`: Application schema + * - `'u'`: Unsigned integer schema (e.g., version) + * - `'b'`: Boolean schema (e.g., submenu) + * + * @remarks + * This record ensures type safety by restricting keys to those defined in `SchemaKey`. + */ +const SchemaVariant: Record<(typeof SchemaKey)[keyof typeof SchemaKey], string> = { + 'applications': 'as', + 'settings-version': 'u', 'submenu': 'b', - 'editors': 'as', -}; +} as const; /** - * Raw GSettings object for direct manipulation. + * Raw GSettings object. */ -// eslint-disable-next-line import/no-mutable-exports -export let settings: Gio.Settings; +let settings: Gio.Settings; /** * Initializes the GSettings object. @@ -27,6 +40,7 @@ export let settings: Gio.Settings; * @param gSettings - A `Gio.Settings` to initialize the settings with. */ export function initSettings(gSettings: Gio.Settings): void { + migrateSettings(gSettings); settings = gSettings; } @@ -41,59 +55,142 @@ export function uninitSettings() { } /** - * Get a preference from GSettings and convert it from a GLib Variant to a - * JavaScript type. + * Migrates the application settings to the latest version if necessary. + * + * This function checks the current version of the settings stored in `Gio.Settings`. + * If the settings are outdated (i.e., the stored version is less than the required `lastVersion`), + * it performs necessary migration steps, such as resetting deprecated keys, + * and updates the settings version to the latest. + * + * @param settings - The `Gio.Settings` instance containing the application's settings. + */ +function migrateSettings(settings: Gio.Settings) { + const lastVersion = 2; + const currentVersion = settings + .get_user_value(SchemaKey.settingsVersion) + ?.recursiveUnpack(); + + if (!currentVersion || currentVersion < lastVersion) { + if (settings.list_keys().includes('editors')) { + settings.reset('editors'); + } + settings.set_uint(SchemaKey.settingsVersion, lastVersion); + } +} + +/** + * Retrieves the settings value associated with the specified key. * - * @param key - The key of the preference to get. - * @returns The value of the preference. + * @template K - A key that extends the keys of the `SchemaKey` object. + * @param key - The key used to look up the corresponding schema value. + * @returns The unpacked value of the setting associated with the given key, + * with the type inferred from the `SchemaType` mapping. */ -export function getSettings(key: K): SchemaType[K] { - return settings.get_value(key).recursiveUnpack(); +export function getSettings(key: K): SchemaType[K] { + const schemaKey = SchemaKey[key]; + return settings.get_value(schemaKey).recursiveUnpack(); } /** - * Pack a value into a GLib Variant type and store it in GSettings. + * Sets a setting value in the application's settings schema. + * + * @typeParam K - The key of the setting, constrained to the keys of `SchemaKey`. + * @param key - The key of the setting to update. + * @param value - The value to set for the specified key, matching the type defined in `SchemaType[K]`. + * @param bannerHandler - Optional handler to display a banner after the setting is updated. * - * @param key - The key of the preference to set. - * @param value - The value to set the preference to. + * This function retrieves the schema key and variant type for the provided key, + * creates a new `GLib.Variant` with the specified value, and updates the setting. + * If a `bannerHandler` is provided, it will trigger the display of all banners. */ -export function setSettings(key: K, value: SchemaType[K], bannerHandler?: BannerHandler) { - const variant = new GLib.Variant(SchemaVariant[key], value); +export function setSettings(key: K, value: SchemaType[K], bannerHandler?: BannerHandler) { + const schemaKey = SchemaKey[key]; + const variantType = SchemaVariant[schemaKey]; - settings.set_value(key, variant); + const variant = new GLib.Variant(variantType, value); + settings.set_value(schemaKey, variant); if (bannerHandler) bannerHandler.showAll(); } /** - * Retrieves the list of application configurations from the settings. + * Retrieves the list of application settings from the 'applications' key. * - * @returns An array of `Application` objects parsed from the settings. + * This function fetches the settings, parses each JSON string into an `Application` object, + * and filters out any invalid or unparsable entries. + * + * @returns {Application[]} An array of valid `Application` objects. */ export function getAppSettings(): Application[] { - return getSettings('editors') + return getSettings('applications') .map((json) => { try { return JSON.parse(json) as Application; } - catch { + catch (e) { + console.error(`Failed to parse application entry:`, json, e); return null; } }) - .filter((e): e is Application => e !== null); + .filter((app): app is Application => app !== null); } /** - * Updates the settings with a new or modified application configuration. + * Updates the application settings by replacing the existing configuration + * with the provided `newAppSettings` for the matching application ID. + * Persists the updated settings using the `setSettings` function. * - * @param newAppSettings - The new or updated `Application` configuration to be saved. + * @param newAppSettings - The updated application settings to be saved. + * @param bannerHandler - Optional handler for displaying banners or notifications. */ export function setAppSettings(newAppSettings: Application, bannerHandler?: BannerHandler): void { - const configs = getAppSettings(); - const newConfigs = configs.map(e => - e.id === newAppSettings.id ? newAppSettings : e, + const appSettings = getAppSettings(); + const idx = appSettings.findIndex(app => app.id === newAppSettings.id); + if (idx === -1) { + return; + } + const newSettings = appSettings.map(app => + app.id === newAppSettings.id ? newAppSettings : app, ); + setSettings('applications', newSettings.map(app => JSON.stringify(app)), bannerHandler); +} + +/** + * Handles adding or removing an application from the application settings. + * + * @param action - The action to perform: `'add'` to add a new application, or `'remove'` to remove an existing one. + * @param app - The application to add (as an `Application` object) or the application ID to remove (as a `string`). + * @param bannerHandler - Optional handler for displaying banners or notifications after the operation. + * + * @remarks + * - When adding, the function checks for duplicates based on `id` or `appId` before adding. + * - When removing, the function filters out the application with the matching `id`. + * - Updates the settings by serializing the application list and invoking `setSettings`. + */ +export function appHandler( + action: 'add' | 'remove', + app: Application | string, + bannerHandler?: BannerHandler, +): void { + const appSettings = getAppSettings(); + + let newAppList: Application[]; + + if (action === 'add' && typeof app === 'object') { + if (appSettings.some(a => a.id === app.id || a.appId === app.appId)) { + return; + } + newAppList = [...appSettings, app]; + } + + else if (action === 'remove' && typeof app === 'string') { + newAppList = appSettings.filter(a => a.id !== app); + } + + else { + return; + } - setSettings('editors', newConfigs.map(e => JSON.stringify(e)), bannerHandler); + setSettings('applications', newAppList.map(a => JSON.stringify(a)), bannerHandler); } diff --git a/src/lib/prefs/utils.ts b/src/lib/prefs/utils.ts new file mode 100644 index 0000000..262cd52 --- /dev/null +++ b/src/lib/prefs/utils.ts @@ -0,0 +1,14 @@ +/** + * Generates a random alphanumeric ID, with a length of 12 characters. + * + * @returns {string} Randomly generated 12-character ID + */ +export function generateId(): string { + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let id = ''; + for (let i = 0; i < 12; i++) { + const random = Math.floor(Math.random() * characters.length); + id += characters[random]; + } + return id; +} diff --git a/src/lib/prefs/validation.ts b/src/lib/prefs/validation.ts index 6dd8aab..f45e0c8 100644 --- a/src/lib/prefs/validation.ts +++ b/src/lib/prefs/validation.ts @@ -1,40 +1,34 @@ import type { ValidationResult } from '../../../@types/types.js'; import { getAppSettings } from './settings.js'; -type ValidateField = 'name' | 'native' | 'flatpak'; +type ValidateField = 'name'; /** * Validates a given value against a specific field and checks for duplicates. * * @param val - The value to validate. - * @param id - The ID of the current editor to exclude from duplicate checks. - * @param field - The field to validate against. Can be 'name', 'native', or 'flatpak'. + * @param id - The ID of the current application to exclude from duplicate checks. + * @param field - The field to validate against. Currently supports 'name'. * @returns An object containing validation results: - * - `isValid`: Whether the value is valid (not a duplicate). + * - `isValid`: Whether the value is valid (not a duplicate and not empty). * - `isDuplicate`: Whether the value is a duplicate. * - `isEmpty`: Whether the value is empty. */ export function validate( val: string, - id: number, + id: string, field: ValidateField, ): ValidationResult { - const values = val.trim(); - if (!values) { + const value = val.trim(); + if (!value) { return { isValid: false, isDuplicate: false, isEmpty: true }; } - const editors = getAppSettings().filter(editor => editor.id !== id); + const applications = getAppSettings().filter(app => app.id !== id); let isDuplicate = false; if (field === 'name') { - isDuplicate = editors.some(editor => editor.name === values); - } - else if (field === 'native') { - isDuplicate = editors.some(editor => Array.isArray(editor.native) && editor.native.join(' ') === values); - } - else if (field === 'flatpak') { - isDuplicate = editors.some(editor => Array.isArray(editor.flatpak) && editor.flatpak.join(' ') === values); + isDuplicate = applications.some(app => app.name === value); } return { diff --git a/src/prefs.ts b/src/prefs.ts index 8cae826..e3c0740 100644 --- a/src/prefs.ts +++ b/src/prefs.ts @@ -1,18 +1,34 @@ import type Adw from 'gi://Adw'; +import type { ExtensionMetadata } from 'resource:///org/gnome/shell/extensions/extension.js'; +import Gdk from 'gi://Gdk'; +import Gtk from 'gi://Gtk'; import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; -import { initSettings, uninitSettings } from './lib/prefs/settings.js'; +import { initSettings, SchemaKey, uninitSettings } from './lib/prefs/settings.js'; import { ApplicationPage } from './prefs/application.js'; import { GeneralPage } from './prefs/general.js'; import { BannerHandler } from './ui/widgets/banner.js'; +import { Menu } from './ui/widgets/menu.js'; export default class FlickernautPrefs extends ExtensionPreferences { + constructor(metadata: ExtensionMetadata) { + super(metadata); + const iconTheme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default() as Gdk.Display); + const UIFolderPath = `${this.path}/ui`; + iconTheme.add_search_path(`${UIFolderPath}/icons`); + } + async fillPreferencesWindow(window: Adw.PreferencesWindow): Promise { - const bannerHandler = new BannerHandler(); + const menu = new Menu(); + menu.add(window); initSettings(this.getSettings()); - window.add(new GeneralPage(bannerHandler)); - window.add(new ApplicationPage(bannerHandler)); + const settings = this.getSettings(); + const schemaKey = SchemaKey; + const bannerHandler = new BannerHandler(); + + window.add(new GeneralPage(schemaKey, bannerHandler)); + window.add(new ApplicationPage(settings, bannerHandler)); // Clean up resources when the window is closed window.connect('close-request', () => { diff --git a/src/prefs/application.ts b/src/prefs/application.ts index b11efff..ff3fb6c 100644 --- a/src/prefs/application.ts +++ b/src/prefs/application.ts @@ -1,10 +1,49 @@ +import type { Application } from '../../@types/types.js'; import type { BannerHandler } from '../ui/widgets/banner.js'; import Adw from 'gi://Adw'; +import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; -import { getAppSettings } from '../lib/prefs/settings.js'; +import Gtk from 'gi://Gtk'; +import { normalizeText } from '../lib/prefs/normalize.js'; +import { appHandler, getAppSettings } from '../lib/prefs/settings.js'; +import { generateId } from '../lib/prefs/utils.js'; import { ApplicationList } from './applicationList.js'; +const AppDialog = GObject.registerClass( + class AppDialog extends Gtk.AppChooserDialog { + constructor(parent: Gtk.Window) { + super({ + transient_for: parent, + modal: true, + default_width: 350, + default_height: 450, + content_type: 'application/octet-stream', + }); + + this.get_widget().set({ + show_all: true, + show_other: true, + }); + + this.get_widget().connect('application-selected', this._updateSensitivity.bind(this)); + this._updateSensitivity(); + } + + private _updateSensitivity() { + const appInfo = this.get_app_info(); + const applications = getAppSettings(); + + const isDuplicate = !!appInfo && applications.some(a => a.appId === appInfo.get_id()); + const supportsUris = appInfo?.supports_uris?.() ?? !!appInfo?.supports_uris; + const supportsFiles = appInfo?.supports_files?.() ?? !!appInfo?.supports_files; + + const isValid = !!appInfo && !isDuplicate && (supportsUris || supportsFiles); + this.set_response_sensitive(Gtk.ResponseType.OK, isValid); + } + }, +); + export const ApplicationPage = GObject.registerClass( { Template: GLib.uri_resolve_relative( @@ -17,34 +56,156 @@ export const ApplicationPage = GObject.registerClass( InternalChildren: [ 'banner', 'app_group', + 'add_app_button', ], }, class extends Adw.PreferencesPage { private declare _banner: Adw.Banner; private declare _app_group: Adw.PreferencesGroup; + private declare _add_app_button: Gtk.Button; + private declare _settings: Gio.Settings; private declare _bannerHandler: BannerHandler; + private declare _applicationsList: Application[]; + private declare _applicationsListUi: Application[]; + private declare _applications: { Row: Adw.ExpanderRow }[]; + private declare _count: number | null; - constructor(bannerHandler: BannerHandler) { + constructor(settings: Gio.Settings, bannerHandler: BannerHandler) { super(); + this._settings = settings; + this._bannerHandler = bannerHandler; this._bannerHandler.register(this._banner); + this._applicationsList = []; + this._applicationsListUi = []; + this._applications = []; + this._count = null; + + this._refreshWidgets(); + this._add_app_button.connect('clicked', this._onAddApp.bind(this)); + } + + private _refreshWidgets() { const applications = getAppSettings(); - for (const application of applications) { - try { - if (!application.name) { - console.warn('Skipping application with no name'); - continue; - } + // Clear the ExpanderRow widgets + this._applicationsList.length = 0; - this._app_group.add(new ApplicationList(application, this._bannerHandler)); + applications.forEach((app) => { + if (!app.appId) + return; + const appInfo = Gio.DesktopAppInfo.new(app.appId); + if (appInfo) { + this._applicationsList.push(app); } - catch (e) { - console.error('Failed to create application row:', e); + }); + + // Sort the applications list by name + this._applicationsList.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + + // Check if the widgets UI needs to be updated + if (JSON.stringify(this._applicationsListUi) !== JSON.stringify(this._applicationsList)) { + // Remove the old widgets + if (this._count) { + for (let i = 0; i < this._count; i++) { + this._app_group.remove(this._applications[i].Row); + } + this._count = null; } + + // Build new ExpanderRow widgets with the updated applications list + if (this._applicationsList.length > 0) { + this._applications = []; + + for (const app of this._applicationsList) { + try { + if (!app.name) { + console.warn('Skipping application with no name'); + continue; + } + + const row = new ApplicationList(this._settings, app, this._bannerHandler); + + row.connect('remove-app', (_row, id: string) => { + this._onRemoveApp(id); + }); + + this._app_group.add(row); + + if (!this._applications) + this._applications = []; + + this._applications.push({ Row: row }); + } + catch (e) { + console.error('Failed to create application row:', e); + } + } + + this._count = this._applicationsList.length; + } + + // Update the UI + this._applicationsListUi = [...this._applicationsList]; } + return 0; + } + + private _onAddApp() { + const dialog = new AppDialog(this.get_root() as Gtk.Window); + + dialog.connect('response', (_source, id) => { + const appInfo = id === Gtk.ResponseType.OK ? dialog.get_app_info() : null; + + const applications = getAppSettings(); + + if (appInfo && !applications.some(app => app.appId === appInfo.get_id())) { + const mimeTypes = Array.from(appInfo.get_supported_types?.() ?? []); + + let packageType: 'Flatpak' | 'AppImage' | 'Native'; + const executable = appInfo.get_executable(); + if (executable.endsWith('flatpak')) { + packageType = 'Flatpak'; + } + else if (executable.endsWith('.appimage')) { + packageType = 'AppImage'; + } + else { + packageType = 'Native'; + } + + const app: Application = { + id: generateId(), + appId: appInfo.get_id() ?? '', + name: normalizeText(appInfo.get_name() ?? ''), + icon: appInfo.get_icon()?.to_string() ?? '', + pinned: false, + multipleFiles: false, + multipleFolders: false, + packageType, + mimeTypes, + enable: true, + }; + + try { + appHandler('add', app, this._bannerHandler); + } + catch (e) { + console.error('Failed to add new application:', e); + } + + this._refreshWidgets(); + } + dialog.destroy(); + }); + dialog.show(); + } + + private _onRemoveApp(id: string) { + appHandler('remove', id, this._bannerHandler); + this._refreshWidgets(); } }, ); diff --git a/src/prefs/applicationList.ts b/src/prefs/applicationList.ts index 9a3555c..8b0cb94 100644 --- a/src/prefs/applicationList.ts +++ b/src/prefs/applicationList.ts @@ -1,3 +1,4 @@ +import type Gio from 'gi://Gio'; import type { Application } from '../../@types/types.js'; import type { BannerHandler } from '../ui/widgets/banner.js'; import Adw from 'gi://Adw'; @@ -6,38 +7,51 @@ import GObject from 'gi://GObject'; import Gtk from 'gi://Gtk'; import { gettext as _ } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; import { normalizeArray, normalizeArrayOutput, normalizeText } from '../lib/prefs/normalize.js'; -import { setAppSettings, settings } from '../lib/prefs/settings.js'; +import { setAppSettings } from '../lib/prefs/settings.js'; import { validate } from '../lib/prefs/validation.js'; import { ToggleSwitchClass } from '../ui/widgets/switch.js'; export class ApplicationListClass extends Adw.ExpanderRow { - private declare _id: number; + private declare _id: string; + private declare _app_Id: string; private declare _name: Adw.EntryRow; - private declare _native: Adw.EntryRow; - private declare _flatpak: Adw.EntryRow; - private declare _arguments: Adw.EntryRow; - private declare _supports_files: Adw.SwitchRow; + private declare _icon: string; + private declare _pinned: boolean; + private declare _multiple_files: Adw.SwitchRow; + private declare _multiple_folders: Adw.SwitchRow; + private declare _packageType: 'Flatpak' | 'AppImage' | 'Native'; + private declare _mime_types: Adw.EntryRow; + private declare _pin_button: Gtk.Button; private declare _toggleSwitch: ToggleSwitchClass; + private declare _remove_app_button: Gtk.Button; private declare _bannerHandler: BannerHandler; - constructor(application: Application, bannerHandler: BannerHandler) { + constructor(settings: Gio.Settings, application: Application, bannerHandler: BannerHandler) { super(); this._bannerHandler = bannerHandler; this.title = application.name; + this.subtitle = application.appId.replace('.desktop', ''); + this._id = application.id; + this._app_Id = application.appId; + this._name.text = normalizeText(application.name); - this._native.text = normalizeArrayOutput(application.native); + this._icon = application.icon; + + this._pinned = application.pinned || false; - this._flatpak.text = normalizeArrayOutput(application.flatpak); + this._multiple_files.active = application.multipleFiles || false; - this._arguments.text = normalizeArrayOutput(application.arguments); + this._multiple_folders.active = application.multipleFolders || false; - this._supports_files.active = application.supports_files || false; + this._packageType = application.packageType || 'Native'; + + this._mime_types.text = normalizeArrayOutput(application.mimeTypes); this._toggleSwitch = new ToggleSwitchClass({ active: application.enable, @@ -46,8 +60,18 @@ export class ApplicationListClass extends Adw.ExpanderRow { this.add_suffix(this._toggleSwitch); + this._pin_button = new Gtk.Button({ + valign: Gtk.Align.CENTER, + css_classes: ['flat'], + icon_name: 'view-non-pin-symbolic', + visible: settings.get_boolean('submenu'), + tooltip_text: _('Pin in main menu when submenu is enabled.'), + }); + + this.add_suffix(this._pin_button); + this._name.connect('changed', () => { - if (settings && typeof application.id === 'number') { + if (settings && typeof application.id === 'string') { const input = this._name; const val = input.text; const result = validate(val, application.id, 'name'); @@ -67,71 +91,63 @@ export class ApplicationListClass extends Adw.ExpanderRow { input.remove_css_class('error'); input.set_tooltip_text(null); - this._updateConfig(); + this._updateAppSetting(); } }); - this._native.connect('changed', () => { - if (settings && typeof application.id === 'number') { - const input = this._native; - const val = input.text; - const result = validate(val, application.id, 'native'); + this._multiple_files.connect('notify::active', () => { + this._updateAppSetting(); + }); - if (result.isDuplicate) { - input.add_css_class('error'); - input.set_tooltip_text( - _('Native command already exists'), - ); - return; - } + this._multiple_folders.connect('notify::active', () => { + this._updateAppSetting(); + }); - input.remove_css_class('error'); - input.set_tooltip_text(null); - } - this._updateConfig(); + this._mime_types.connect('changed', () => { + this._updateAppSetting(); }); - this._flatpak.connect('changed', () => { - if (settings && typeof application.id === 'number') { - const input = this._flatpak; - const val = input.text; - const result = validate(val, application.id, 'flatpak'); + this._toggleSwitch.connect('notify::active', () => { + this._updateAppSetting(); + }); - if (result.isDuplicate) { - input.add_css_class('error'); - input.set_tooltip_text( - _('Flatpak ID already exists'), - ); - return; - } + if (this._pinned) { + this._pin_button.icon_name = 'view-pin-symbolic'; + } - input.remove_css_class('error'); - input.set_tooltip_text(null); - } - this._updateConfig(); + settings.connect('changed::submenu', () => { + const submenuState = settings.get_boolean('submenu'); + this._pin_button.visible = submenuState; }); - this._arguments.connect('changed', () => { - this._updateConfig(); - }); + this._pin_button.connect('clicked', () => { + this._pinned = !this._pinned; - this._supports_files.connect('notify::active', () => { - this._updateConfig(); + if (this._pinned) { + this._pin_button.icon_name = 'view-pin-symbolic'; + } + else { + this._pin_button.icon_name = 'view-non-pin-symbolic'; + } + this._updateAppSetting(); }); - this._toggleSwitch.connect('notify::active', () => { - this._updateConfig(); + this._remove_app_button.connect('clicked', () => { + this.emit('remove-app', application.id); }); } - private _updateConfig() { + private _updateAppSetting() { const newAppSettings: Application = { id: this._id, + appId: this._app_Id, name: normalizeText(this._name.text), - native: normalizeArray(this._native.text), - flatpak: normalizeArray(this._flatpak.text), - arguments: normalizeArray(this._arguments.text), - supports_files: this._supports_files.active, + icon: this._icon, + pinned: this._pinned, + multipleFiles: this._multiple_files.active, + multipleFolders: this._multiple_folders.active, + packageType: this._packageType, + mimeTypes: normalizeArray(this._mime_types.text), enable: this._toggleSwitch.active, }; @@ -140,8 +156,8 @@ export class ApplicationListClass extends Adw.ExpanderRow { try { setAppSettings(newAppSettings, this._bannerHandler); } - catch (error) { - console.error('Failed to update application configuration:', error); + catch (e) { + console.error('Failed to update application configuration:', e); } } } @@ -156,12 +172,18 @@ export const ApplicationList = GObject.registerClass( GTypeName: 'ApplicationList', + Signals: { + 'remove-app': { + param_types: [GObject.TYPE_STRING], + }, + }, + InternalChildren: [ 'name', - 'native', - 'flatpak', - 'arguments', - 'supports_files', + 'multiple_files', + 'multiple_folders', + 'mime_types', + 'remove_app_button', ], }, ApplicationListClass, diff --git a/src/prefs/general.ts b/src/prefs/general.ts index efa95f9..6971a37 100644 --- a/src/prefs/general.ts +++ b/src/prefs/general.ts @@ -1,3 +1,4 @@ +import type { SchemaKey } from '../lib/prefs/settings.js'; import type { BannerHandler } from '../ui/widgets/banner.js'; import Adw from 'gi://Adw'; import GLib from 'gi://GLib'; @@ -23,20 +24,22 @@ export const GeneralPage = GObject.registerClass( private declare _banner: Adw.Banner; private declare _behavior: Adw.PreferencesGroup; private declare _submenu: Adw.SwitchRow; + private declare _schemaKey: typeof SchemaKey; private declare _bannerHandler: BannerHandler; - constructor(bannerHandler: BannerHandler) { + constructor(schemaKey: typeof SchemaKey, bannerHandler: BannerHandler) { super(); + this._schemaKey = schemaKey; this._bannerHandler = bannerHandler; this._bannerHandler.register(this._banner); - const state = getSettings('submenu').valueOf(); + const state = getSettings(this._schemaKey.submenu).valueOf(); this._submenu.active = state; this._submenu.connect('notify::active', () => { - setSettings('submenu', this._submenu.active, this._bannerHandler); + setSettings(this._schemaKey.submenu, this._submenu.active, this._bannerHandler); }); } }, diff --git a/src/ui/pages/application.ui b/src/ui/pages/application.ui index bbb609a..7795c71 100644 --- a/src/ui/pages/application.ui +++ b/src/ui/pages/application.ui @@ -19,6 +19,17 @@ corresponding .blp file and regenerate this file with blueprint-compiler. Applications listed here will appear in the Nautilus context menu. You can Enable/Disable using the toggle switch. true + + + 3 + + + list-add-symbolic + Add + + + + diff --git a/src/ui/widgets/application-list.ui b/src/ui/widgets/application-list.ui index 4f3fce8..035d4c9 100644 --- a/src/ui/widgets/application-list.ui +++ b/src/ui/widgets/application-list.ui @@ -13,23 +13,35 @@ corresponding .blp file and regenerate this file with blueprint-compiler. - - Native Cmd + + Multiple Files + Enable if the app supports opening several files. - - Flatpak ID + + Multiple Folders + Enable if the app supports opening several folders. - - Arguments + + Mime Types + false - - Supports Files + + 1 + 6 + 6 + 6 + + + error + Remove + + diff --git a/src/ui/widgets/menu.ts b/src/ui/widgets/menu.ts new file mode 100644 index 0000000..f98ce0e --- /dev/null +++ b/src/ui/widgets/menu.ts @@ -0,0 +1,98 @@ +import type Adw from 'gi://Adw'; +import Gdk from 'gi://Gdk'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import Gtk from 'gi://Gtk'; + +export class Menu { + /** + * Adds a custom menu to the provided Adw.PreferencesWindow instance. + * + * This method loads a menu UI definition from a `menu.ui` file, retrieves the + * `info_menu` object, and inserts it into the window's header bar. It also + * creates a `Gio.SimpleActionGroup` with actions for opening external links + * (such as GitHub and Ko-fi) and attaches them to the window. + * + * @param window - The Adw.PreferencesWindow to which the menu will be added. + * + * @remarks + * - The method expects a `menu.ui` file to be present and accessible relative to the module URL. + * - If the menu UI or header bar cannot be found, the method will return early. + * - The actions added will open external URLs in the user's default browser. + */ + add(window: Adw.PreferencesWindow) { + const builder = new Gtk.Builder(); + try { + builder.add_from_file(GLib.filename_from_uri( + GLib.uri_resolve_relative( + import.meta.url, + 'menu.ui', + GLib.UriFlags.NONE, + ), + )[0]); + } + catch (e) { + console.log(`Failed to load menu.ui: ${e}`); + return; + } + + const infoMenu = builder.get_object('info_menu') as Gtk.MenuButton | null; + if (!infoMenu) { + return; + } + + const headerbar = this._find(window, ['AdwHeaderBar', 'Adw_HeaderBar']) as Adw.HeaderBar | null; + if (!headerbar) { + return; + } + + (headerbar as any).pack_start(infoMenu); + + const actionGroup = new Gio.SimpleActionGroup(); + window.insert_action_group('prefs', actionGroup); + + const actions = [ + { + name: 'open-github', + link: 'https://github.com/imoize/flickernaut', + }, + { + name: 'donate-kofi', + link: 'https://ko-fi.com/brilliantnz', + }, + ]; + + actions.forEach((action) => { + const act = new Gio.SimpleAction({ name: action.name }); + act.connect('activate', () => { + Gtk.show_uri(window, action.link, Gdk.CURRENT_TIME); + }); + actionGroup.add_action(act); + }); + } + + /** + * Recursively searches for the first descendant widget of any specified types within a widget tree. + * + * @param widget - The root Gtk.Widget to start the search from. + * @param widgetTypes - An array of widget type names (as strings) to search for. + * @param depth - (Optional) The current recursion depth, used internally for traversal. + * @returns The first Gtk.Widget found that matches any of the specified types, or `null` if none are found. + */ + private _find(widget: Gtk.Widget, widgetTypes: string[], depth = 0): Gtk.Widget | null { + const widgetType = (widget.constructor as any).name; + if (widgetTypes.includes(widgetType)) { + return widget; + } + + let child = widget.get_first_child?.(); + while (child) { + const found = this._find(child, widgetTypes, depth + 1); + if (found) { + return found; + } + child = child.get_next_sibling?.(); + } + return null; + } +} diff --git a/src/ui/widgets/menu.ui b/src/ui/widgets/menu.ui new file mode 100644 index 0000000..72f2672 --- /dev/null +++ b/src/ui/widgets/menu.ui @@ -0,0 +1,26 @@ + + + + + +
+ + Project Page + prefs.open-github + + + Ko-fi + prefs.donate-kofi + +
+
+ + info_menu_model + emote-love-symbolic + + +
\ No newline at end of file