From fe16d78c26d2a3304ea5588772c50ab8791da328 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Fri, 23 May 2025 08:13:58 +0700 Subject: [PATCH 01/23] refactor(ui): add buttons for adding and removing applications --- resources/ui/pages/application.blp | 9 +++++++ resources/ui/widgets/application-list.blp | 30 +++++++++++++++++------ src/ui/pages/application.ui | 11 +++++++++ src/ui/widgets/application-list.ui | 28 +++++++++++++++------ 4 files changed, 62 insertions(+), 16 deletions(-) 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/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 + + From 27911af38dfba7e12dd2a42c3c3663808a813e48 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Fri, 23 May 2025 09:30:20 +0700 Subject: [PATCH 02/23] refactor: update SchemaType and Application interfaces for consistency --- @types/types.d.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/@types/types.d.ts b/@types/types.d.ts index b59bdab..e9e66b8 100644 --- a/@types/types.d.ts +++ b/@types/types.d.ts @@ -1,17 +1,19 @@ 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; + mimeTypes?: string[]; + enable: boolean; } export interface ValidationResult { From 89260a079b5d119ae365fa1f5903a2265484ae66 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Fri, 23 May 2025 09:31:25 +0700 Subject: [PATCH 03/23] chore: add generateId for random IDs --- src/lib/prefs/utils.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/lib/prefs/utils.ts 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; +} From 8972c491a03507ed3a66d8cfea5079184c872a32 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Fri, 23 May 2025 09:33:09 +0700 Subject: [PATCH 04/23] refactor: simplify validation logic and align with updated schema --- src/lib/prefs/validation.ts | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) 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 { From 7aada67bf9be1dc7660b41dac089fe8fe2c4b817 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Fri, 23 May 2025 09:44:36 +0700 Subject: [PATCH 05/23] refactor: update application settings schema and enhance application management functionality --- src/lib/prefs/settings.ts | 135 +++++++++++++++++++++++++++++--------- 1 file changed, 104 insertions(+), 31 deletions(-) diff --git a/src/lib/prefs/settings.ts b/src/lib/prefs/settings.ts index d6a1e36..dae2817 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 = { +/** + * Maps each key from the `SchemaKey` type to its corresponding schema variant identifier. + * + * The values represent the type of schema variant: + * - `'as'`: Application schema + * - `'i'`: 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': 'i', '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. @@ -41,59 +54,119 @@ export function uninitSettings() { } /** - * Get a preference from GSettings and convert it from a GLib Variant to a - * JavaScript type. + * 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. * - * @param key - The key of the preference to set. - * @param value - The value to set the preference to. + * @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. + * + * 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. + * + * This function fetches the settings, parses each JSON string into an `Application` object, + * and filters out any invalid or unparsable entries. * - * @returns An array of `Application` objects parsed from the settings. + * @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) { + // Tidak ada aplikasi dengan id tersebut, tidak update + 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); } From 373aa47f51b642fa6c6057994997d8efa7f3f0a1 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Fri, 23 May 2025 10:28:37 +0700 Subject: [PATCH 06/23] refactor: update use schemaKey for settings retrieval --- src/prefs/general.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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); }); } }, From 3ebc1aecf9efbe9b1ac75f791b9bb9e75e0511d4 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Fri, 23 May 2025 11:07:55 +0700 Subject: [PATCH 07/23] refactor: update settings migration logic and adjust schema version handling --- src/lib/prefs/settings.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/lib/prefs/settings.ts b/src/lib/prefs/settings.ts index dae2817..4b56331 100644 --- a/src/lib/prefs/settings.ts +++ b/src/lib/prefs/settings.ts @@ -17,7 +17,7 @@ export const SchemaKey = { * * The values represent the type of schema variant: * - `'as'`: Application schema - * - `'i'`: Integer schema (e.g., version) + * - `'u'`: Unsigned integer schema (e.g., version) * - `'b'`: Boolean schema (e.g., submenu) * * @remarks @@ -25,7 +25,7 @@ export const SchemaKey = { */ const SchemaVariant: Record<(typeof SchemaKey)[keyof typeof SchemaKey], string> = { 'applications': 'as', - 'settings-version': 'i', + 'settings-version': 'u', 'submenu': 'b', } as const; @@ -40,6 +40,7 @@ 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; } @@ -53,6 +54,30 @@ export function uninitSettings() { (settings as Gio.Settings | null) = null; } +/** + * 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. * @@ -123,7 +148,6 @@ export function setAppSettings(newAppSettings: Application, bannerHandler?: Bann const appSettings = getAppSettings(); const idx = appSettings.findIndex(app => app.id === newAppSettings.id); if (idx === -1) { - // Tidak ada aplikasi dengan id tersebut, tidak update return; } const newSettings = appSettings.map(app => From 6aaa0435edb881f8195812a4014b88e7e2e68b48 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Sat, 24 May 2025 16:53:11 +0700 Subject: [PATCH 08/23] feat: now can select application to show in context menu --- ...e.shell.extensions.flickernaut.gschema.xml | 203 ++++-------------- src/prefs.ts | 12 +- src/prefs/application.ts | 170 ++++++++++++++- src/prefs/applicationList.ts | 116 +++++----- 4 files changed, 258 insertions(+), 243 deletions(-) diff --git a/schemas/org.gnome.shell.extensions.flickernaut.gschema.xml b/schemas/org.gnome.shell.extensions.flickernaut.gschema.xml index 769f8bb..8ffa6ea 100644 --- a/schemas/org.gnome.shell.extensions.flickernaut.gschema.xml +++ b/schemas/org.gnome.shell.extensions.flickernaut.gschema.xml @@ -1,183 +1,64 @@ - + + + The version of the settings to update from. 1 + + Group entry in submenu. false - - 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, + + + + List of applications. + List of applications to be shown in the menu. + [ + '{ + "id": "WK6ZbvHFJVGV", + "appId": "code.desktop", + "name": "VS Code", + "icon": "vscode", + "pinned": false, + "multipleFiles": false, + "multipleFolders": false, + "mimeTypes": ["application/x-code-workspace"], "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, + "id": "diEAs4v3mWE4", + "appId": "com.vscodium.codium.desktop", "name": "VSCodium", - "native": ["vscodium", "codium"], - "flatpak": ["com.vscodium.codium"], - "arguments": [], - "supports_files": true, + "icon": "com.vscodium.codium", + "pinned": false, + "multipleFiles": false, + "multipleFolders": false, + "mimeTypes": ["text/plain", "inode/directory", "application/x-codium-workspace"], "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, + "id": "K0alMDhM6BTJ", + "appId": "dev.zed.Zed.desktop", + "name": "Zed Editor", + "icon": "zed-stable", + "pinned": false, + "multipleFiles": false, + "multipleFolders": false, + "mimeTypes": ["text/plain", "application/x-zerosize", "x-scheme-handler/zed"], "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/prefs.ts b/src/prefs.ts index 8cae826..ede4c12 100644 --- a/src/prefs.ts +++ b/src/prefs.ts @@ -1,18 +1,20 @@ import type Adw from 'gi://Adw'; 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'; export default class FlickernautPrefs extends ExtensionPreferences { async fillPreferencesWindow(window: Adw.PreferencesWindow): Promise { - const bannerHandler = new BannerHandler(); - 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..d4317de 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,143 @@ 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?.() ?? []); + + 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, + 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..796a43f 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,45 @@ 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 _mime_types: Adw.EntryRow; 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._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._mime_types.text = normalizeArrayOutput(application.mimeTypes); this._toggleSwitch = new ToggleSwitchClass({ active: application.enable, @@ -47,7 +55,7 @@ export class ApplicationListClass extends Adw.ExpanderRow { this.add_suffix(this._toggleSwitch); 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 +75,41 @@ 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'); - - if (result.isDuplicate) { - input.add_css_class('error'); - input.set_tooltip_text( - _('Native command already exists'), - ); - return; - } - - input.remove_css_class('error'); - input.set_tooltip_text(null); - } - this._updateConfig(); + this._multiple_files.connect('notify::active', () => { + 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'); - - if (result.isDuplicate) { - input.add_css_class('error'); - input.set_tooltip_text( - _('Flatpak ID already exists'), - ); - return; - } - - input.remove_css_class('error'); - input.set_tooltip_text(null); - } - this._updateConfig(); + this._multiple_folders.connect('notify::active', () => { + this._updateAppSetting(); }); - this._arguments.connect('changed', () => { - this._updateConfig(); + this._mime_types.connect('changed', () => { + this._updateAppSetting(); }); - this._supports_files.connect('notify::active', () => { - this._updateConfig(); + this._toggleSwitch.connect('notify::active', () => { + 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, + mimeTypes: normalizeArray(this._mime_types.text), enable: this._toggleSwitch.active, }; @@ -140,8 +118,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 +134,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, From 0982a352f4dc084e67a54054931e994c341d6548 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Sat, 24 May 2025 16:54:18 +0700 Subject: [PATCH 09/23] chore: add script for nautilus-extension --- Makefile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 991c73b..b42a483 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 @@ -83,6 +83,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 \ From 09adc73baf82c0d75aca112ae8c749c1fb6595e9 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Sat, 24 May 2025 16:58:20 +0700 Subject: [PATCH 10/23] feat: add logging functionality with custom formatter (nautilus extension) --- nautilus-extension/Flickernaut/logger.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 nautilus-extension/Flickernaut/logger.py 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 From 4fd53ebc5163f032f25ce3474c2cece948331f74 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Sun, 25 May 2025 02:04:37 +0700 Subject: [PATCH 11/23] refactor: support for new app chooser feature and lot of improvement --- nautilus-extension/Flickernaut/manager.py | 190 +++++---- nautilus-extension/Flickernaut/models.py | 437 ++++++++++++++------- nautilus-extension/nautilus-flickernaut.py | 116 ++++-- 3 files changed, 492 insertions(+), 251 deletions(-) diff --git a/nautilus-extension/Flickernaut/manager.py b/nautilus-extension/Flickernaut/manager.py index 3b3bb73..01b7531 100644 --- a/nautilus-extension/Flickernaut/manager.py +++ b/nautilus-extension/Flickernaut/manager.py @@ -1,15 +1,39 @@ -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, ApplicationsRegistry, AppJsonStruct + +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 +43,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..befef52 100644 --- a/nautilus-extension/Flickernaut/models.py +++ b/nautilus-extension/Flickernaut/models.py @@ -7,32 +7,30 @@ """ import os +import shlex from gettext import gettext as _ -from typing import Optional -from gi.repository import Nautilus, GLib +from typing import Optional, TypedDict +from gi.repository import Nautilus, GLib, Gio # type: ignore +from .logger import get_logger +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}" + """Abstract base for application launch packages.""" - @property - def type_name(self) -> str: - return _("Unknown") - - @property - def type_name_raw(self) -> str: - return self.__class__.__name__ + def __str__(self) -> str: + return f"installed = {self.is_installed}" @property def run_command(self) -> tuple[str, ...]: @@ -43,161 +41,324 @@ 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 +class Launcher(Package): + """Represents a launchable desktop application.""" + + def __init__(self, app_id: str) -> None: + if not app_id or not isinstance(app_id, str): + logger.error("app_id must be a non-empty string") - 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 + self.app_id = "" + self.commandline = "" + self.installed = False + self._run_command = () + self._launch_method = "none" + self._init_failed = True + return - 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()], + self.app_id: str = app_id + self.commandline: str = "" + self.installed: bool = False + self._run_command: tuple[str, ...] = () + self._launch_method: str = "none" + self._init_failed: bool = False + + app_info = Gio.DesktopAppInfo.new(app_id) + if not app_info: + logger.error(f"Failed to load desktop file for: {app_id}") + self._init_failed = True + return + + self.installed = self._is_app_installed(app_info) + logger.debug("installed: %s", self.installed) + + self.commandline = self._get_commandline(app_info) + logger.debug("commandline: %s", self.commandline) + + self._set_launch_command(app_info) + + logger.debug( + "launcher: method=%s, command=%s", + self._launch_method, + self._run_command, + ) + + def _get_commandline(self, app_info: Gio.DesktopAppInfo) -> str: + """Get the commandline from the app_info, handling special cases.""" + commandline = app_info.get_commandline() or "" + + # Split commandline into tokens while respecting quotes + tokens = shlex.split(commandline) + + # 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("%") ] + # Join back to command string + return " ".join(filtered) - 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 + def _is_app_installed(self, app_info: Gio.DesktopAppInfo) -> bool: + _exec = app_info.get_executable() or "" + _package_type = os.path.basename(_exec) if _exec else "" - @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 _package_type == "flatpak": + logger.debug("package type: flatpak") - @property - def is_installed(self) -> bool: - return bool(self.cmd_path) + flatpak_dirs = [ + os.path.join(GLib.get_user_data_dir(), "flatpak/exports/bin"), + "/var/lib/flatpak/exports/bin", + ] - @property - def type_name(self) -> str: - return "" + bin_name = self.app_id[:-8] + for bin_dir in flatpak_dirs: + if os.path.exists(os.path.join(bin_dir, bin_name)): + return True + return False + elif _package_type.endswith(".appimage"): + logger.debug("package type: appimage") -class Flatpak(Package): - _flatpak_path = GLib.find_program_in_path("flatpak") or "" + if _exec and _exec.endswith(".appimage"): + if os.path.exists(_exec) and os.access(_exec, os.X_OK): + return True + return False - def __init__(self, app_id: str) -> None: - self.app_id: str = app_id + elif _exec: + logger.debug("package type: native") + + if os.path.isabs(_exec): + if os.path.exists(_exec) and os.access(_exec, os.X_OK): + 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) + ): + return True + 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)] + return False + + def _set_launch_command(self, app_info: Gio.DesktopAppInfo) -> None: + """Determine the best launch command for the application.""" + # Try gtk-launch first (most reliable for desktop integration) + launcher = GLib.find_program_in_path("gtk-launch") + if launcher and os.path.isfile(launcher): + desktop_id = ( + self.app_id[:-8] if self.app_id.endswith(".desktop") else self.app_id + ) + self._run_command = (launcher, desktop_id) + self._launch_method = "gtk-launch" + return + + # Fall back to direct commandline execution + if self.commandline: + commandline = GLib.find_program_in_path(self.commandline) + if ( + commandline + and os.path.isfile(commandline) + and os.access(commandline, os.X_OK) + ): + self._run_command = (commandline,) + self._launch_method = "commandline" + return + + # If we get here, no valid launch method was found + logger.error( + f"Could not find a valid way to launch {self.app_id}. " + f"Tried: gtk-launch, commandline ({self.commandline})" + ) + + self._run_command = () + self._launch_method = "none" + self._init_failed = True @property def run_command(self) -> tuple[str, ...]: - return (self._flatpak_path, "run", self.app_id) + """Get the command to run the application.""" + return self._run_command @property def is_installed(self) -> bool: - if not self._flatpak_path or not self.app_id.strip(): - return False - return any( - os.path.exists(os.path.join(bin_dir, self.app_id)) - for bin_dir in self._get_bin_dirs() - ) + """Check if the application appears to be installed.""" + return bool(self.installed) and not getattr(self, "_init_failed", False) + + def __str__(self) -> str: + """String representation for debugging.""" + return f"Launcher({self.app_id}, method={self._launch_method}, cmd={self._run_command})" - @property - def type_name(self) -> str: - return "Flatpak" +class Application: + """Represents an application entry configured in Flickernaut.""" -class Program: 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 + self.pinned: bool = pinned + self.multiple_files: bool = multiple_files + self.multiple_folders: bool = multiple_folders + self.package: Optional[Package] = None + try: + launcher = Launcher(app_id) + if launcher.is_installed: + self.package = launcher + else: + logger.warning( + f"Launcher for {app_id} is not installed or not runnable" + ) + except Exception as e: + logger.error(f"Failed to initialize launcher for {app_id}: {e}") + self.package = None @property def installed_packages(self) -> list[Package]: - return [pkg for pkg in self._packages.values() if pkg.is_installed] + return [self.package] if self.package and self.package.is_installed else [] + + +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 -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 _activate_menu_item(item: Nautilus.MenuItem, command: list[str]) -> None: + """Callback to activate a menu item and launch the command.""" + logger.debug(f"Launch command: {command}") + try: + pid, *_ = GLib.spawn_async(command) + GLib.spawn_close_pid(pid) + except Exception as e: + logger.error(f"Failed to spawn command {command}: {e}") + + def _create_menu_item( + self, + application: Application, + package: Package, + paths: list[str], + id_prefix: str, + is_file: bool, + ) -> Nautilus.MenuItem: + """Create a Nautilus.MenuItem for a given application and package.""" + 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, [*package.run_command, *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 any(package.is_installed for package in app.installed_packages): + 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, - path: str, + 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 path and context.""" + # Uncomment for debugging cache + # self.print_menu_cache() + + cache_key = ( + tuple(paths), + id_prefix, + is_file, + selection_count, + use_submenu, + ) - items: list[Nautilus.MenuItem] = [] - - for program in self: - if is_file and not program.supports_files: - continue + 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}") - installed = program.installed_packages + items: list[Nautilus.MenuItem] = [] - for pkg in installed: - if not pkg.is_installed: + for app in self._filter_applications( + is_file=is_file, selection_count=selection_count + ): + for package in app.installed_packages: + if not package.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], - ) - + item = self._create_menu_item(app, package, paths, id_prefix, is_file) items.append(item) if use_submenu and items: @@ -208,14 +369,18 @@ def get_menu_items( label = _("Open In...") if not is_file else _("Open With...") - submenu_item = Nautilus.MenuItem.new(id_prefix + "submenu", label) + submenu_item = Nautilus.MenuItem.new( + f"Flickernaut::submenu::{id_prefix}", label + ) submenu_item.set_submenu(submenu) - + self._menu_cache[cache_key] = [submenu_item] return [submenu_item] - return items + if not items: + logger.warning( + f"No menu items produced for paths: {paths!r} (is_file={is_file})" + ) - def __iadd__(self, program: Program) -> "ProgramRegistry": - self[program.id] = program - return self + self._menu_cache[cache_key] = items + return items diff --git a/nautilus-extension/nautilus-flickernaut.py b/nautilus-extension/nautilus-flickernaut.py index 8ae7e47..94f7978 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,98 @@ 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 - - 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() + + 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) + + 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 From 126fe920013b82f577f9c0c9f3eacb656113c19e Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Sun, 25 May 2025 02:05:59 +0700 Subject: [PATCH 12/23] chore(lang): update translation template --- po/flickernaut@imoize.github.io.pot | 48 ++++++++++++++--------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/po/flickernaut@imoize.github.io.pot b/po/flickernaut@imoize.github.io.pot index 8aa743c..fc77629 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-25 01:53+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,59 +52,55 @@ 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:22 -msgid "Flatpak ID" +#: src/ui/widgets/application-list.ui:23 +msgid "Multiple Folders" msgstr "" -#: src/ui/widgets/application-list.ui:27 -msgid "Arguments" +#: src/ui/widgets/application-list.ui:24 +msgid "Enable if the app supports opening several folders." msgstr "" -#: src/ui/widgets/application-list.ui:32 -msgid "Supports Files" +#: src/ui/widgets/application-list.ui:29 +msgid "Mime Types" msgstr "" -#: nautilus-extension/Flickernaut/models.py:31 -msgid "Unknown" +#: src/ui/widgets/application-list.ui:42 +msgid "Remove" msgstr "" -#: nautilus-extension/Flickernaut/models.py:184 +#: nautilus-extension/Flickernaut/models.py:286 #, python-format msgid "Open with %s" msgstr "" -#: nautilus-extension/Flickernaut/models.py:186 +#: nautilus-extension/Flickernaut/models.py:288 #, python-format msgid "Open in %s" msgstr "" -#: nautilus-extension/Flickernaut/models.py:209 +#: nautilus-extension/Flickernaut/models.py:370 msgid "Open In..." msgstr "" -#: nautilus-extension/Flickernaut/models.py:209 +#: nautilus-extension/Flickernaut/models.py:370 msgid "Open With..." msgstr "" -#: src/prefs/applicationList.ts:55 +#: src/prefs/applicationList.ts:67 msgid "Name cannot be empty" msgstr "" -#: src/prefs/applicationList.ts:57 +#: src/prefs/applicationList.ts:69 msgid "Name already exists" msgstr "" -#: src/prefs/applicationList.ts:79 -msgid "Native command already exists" -msgstr "" - -#: src/prefs/applicationList.ts:99 -msgid "Flatpak ID already exists" -msgstr "" - #: src/ui/widgets/banner.ts:26 msgid "Restart Nautilus to apply changes." msgstr "" From 6937743b8f5f0a81dfa72ae8d89ba34495ba7f9c Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Sun, 25 May 2025 06:37:56 +0700 Subject: [PATCH 13/23] feat(ui): add custom menu for preferences dialog --- resources/ui/widgets/menu.blp | 24 +++++++++ src/prefs.ts | 4 ++ src/ui/widgets/menu.ts | 98 +++++++++++++++++++++++++++++++++++ src/ui/widgets/menu.ui | 26 ++++++++++ 4 files changed, 152 insertions(+) create mode 100644 resources/ui/widgets/menu.blp create mode 100644 src/ui/widgets/menu.ts create mode 100644 src/ui/widgets/menu.ui 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/src/prefs.ts b/src/prefs.ts index ede4c12..475c0af 100644 --- a/src/prefs.ts +++ b/src/prefs.ts @@ -4,9 +4,13 @@ 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 { async fillPreferencesWindow(window: Adw.PreferencesWindow): Promise { + const menu = new Menu(); + menu.addMenu(window); + initSettings(this.getSettings()); const settings = this.getSettings(); diff --git a/src/ui/widgets/menu.ts b/src/ui/widgets/menu.ts new file mode 100644 index 0000000..bb14702 --- /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. + */ + addMenu(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 From 385739470fc0e06ef55346f36b1b01906d4b42f9 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Sun, 25 May 2025 06:39:58 +0700 Subject: [PATCH 14/23] chore(lang): update translation template --- po/flickernaut@imoize.github.io.pot | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/po/flickernaut@imoize.github.io.pot b/po/flickernaut@imoize.github.io.pot index fc77629..dc2bafa 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-25 01:53+0700\n" +"POT-Creation-Date: 2025-05-25 06:39+0700\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -75,6 +75,10 @@ msgstr "" msgid "Remove" msgstr "" +#: src/ui/widgets/menu.ui:12 +msgid "Project Page" +msgstr "" + #: nautilus-extension/Flickernaut/models.py:286 #, python-format msgid "Open with %s" From 70d573bfe942ac624404f9b0c34c363907e0caf1 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Sun, 25 May 2025 06:55:35 +0700 Subject: [PATCH 15/23] chore(menu): rename addMenu method to add for consistency --- src/prefs.ts | 2 +- src/ui/widgets/menu.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/prefs.ts b/src/prefs.ts index 475c0af..93fdb5d 100644 --- a/src/prefs.ts +++ b/src/prefs.ts @@ -9,7 +9,7 @@ import { Menu } from './ui/widgets/menu.js'; export default class FlickernautPrefs extends ExtensionPreferences { async fillPreferencesWindow(window: Adw.PreferencesWindow): Promise { const menu = new Menu(); - menu.addMenu(window); + menu.add(window); initSettings(this.getSettings()); diff --git a/src/ui/widgets/menu.ts b/src/ui/widgets/menu.ts index bb14702..f98ce0e 100644 --- a/src/ui/widgets/menu.ts +++ b/src/ui/widgets/menu.ts @@ -20,7 +20,7 @@ export class Menu { * - 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. */ - addMenu(window: Adw.PreferencesWindow) { + add(window: Adw.PreferencesWindow) { const builder = new Gtk.Builder(); try { builder.add_from_file(GLib.filename_from_uri( From 9edd04ce719e7e8413fc4078714b2c627e753ddf Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Sun, 25 May 2025 23:49:18 +0700 Subject: [PATCH 16/23] feat(ui): add custom icon --- Makefile | 1 + .../hicolor/scalable/actions/view-non-pin-symbolic.svg | 3 +++ src/prefs.ts | 10 ++++++++++ 3 files changed, 14 insertions(+) create mode 100644 resources/ui/icons/hicolor/scalable/actions/view-non-pin-symbolic.svg diff --git a/Makefile b/Makefile index b42a483..a98039b 100644 --- a/Makefile +++ b/Makefile @@ -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 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/src/prefs.ts b/src/prefs.ts index 93fdb5d..e3c0740 100644 --- a/src/prefs.ts +++ b/src/prefs.ts @@ -1,4 +1,7 @@ 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, SchemaKey, uninitSettings } from './lib/prefs/settings.js'; import { ApplicationPage } from './prefs/application.js'; @@ -7,6 +10,13 @@ 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 menu = new Menu(); menu.add(window); From 0302a22d8f0a415a2a9d778a9b3231f7cac4b43a Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Mon, 26 May 2025 00:35:07 +0700 Subject: [PATCH 17/23] feat: app entry can be pinned in main menu if submenu is enabled --- nautilus-extension/Flickernaut/models.py | 47 ++++++++++++++++++------ src/prefs/applicationList.ts | 34 +++++++++++++++++ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/nautilus-extension/Flickernaut/models.py b/nautilus-extension/Flickernaut/models.py index befef52..c8b3bf2 100644 --- a/nautilus-extension/Flickernaut/models.py +++ b/nautilus-extension/Flickernaut/models.py @@ -352,30 +352,53 @@ def get_menu_items( items: list[Nautilus.MenuItem] = [] + # Separate pinned items and submenu items + pinned_items: list[Nautilus.MenuItem] = [] + submenu_items: list[Nautilus.MenuItem] = [] + for app in self._filter_applications( is_file=is_file, selection_count=selection_count ): for package in app.installed_packages: if not package.is_installed: continue + item = self._create_menu_item(app, package, paths, id_prefix, is_file) - items.append(item) - if use_submenu and items: - submenu = Nautilus.Menu() + if use_submenu and app.pinned: + pinned_items.append(item) + elif use_submenu: + submenu_items.append(item) + else: + items.append(item) - for item in items: - submenu.append_item(item) + if use_submenu: + result_items = [] - label = _("Open In...") if not is_file else _("Open With...") + if submenu_items: + submenu = Nautilus.Menu() - submenu_item = Nautilus.MenuItem.new( - f"Flickernaut::submenu::{id_prefix}", label - ) + 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})" + ) - submenu_item.set_submenu(submenu) - self._menu_cache[cache_key] = [submenu_item] - return [submenu_item] + self._menu_cache[cache_key] = result_items + return result_items if not items: logger.warning( diff --git a/src/prefs/applicationList.ts b/src/prefs/applicationList.ts index 796a43f..2720c66 100644 --- a/src/prefs/applicationList.ts +++ b/src/prefs/applicationList.ts @@ -20,6 +20,7 @@ export class ApplicationListClass extends Adw.ExpanderRow { private declare _multiple_files: Adw.SwitchRow; private declare _multiple_folders: Adw.SwitchRow; 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; @@ -41,6 +42,8 @@ export class ApplicationListClass extends Adw.ExpanderRow { this._icon = application.icon; + this._pinned = application.pinned || false; + this._multiple_files.active = application.multipleFiles || false; this._multiple_folders.active = application.multipleFolders || false; @@ -54,6 +57,16 @@ 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 === 'string') { const input = this._name; @@ -95,6 +108,27 @@ export class ApplicationListClass extends Adw.ExpanderRow { this._updateAppSetting(); }); + if (this._pinned) { + this._pin_button.icon_name = 'view-pin-symbolic'; + } + + settings.connect('changed::submenu', () => { + const submenuState = settings.get_boolean('submenu'); + this._pin_button.visible = submenuState; + }); + + this._pin_button.connect('clicked', () => { + this._pinned = !this._pinned; + + if (this._pinned) { + this._pin_button.icon_name = 'view-pin-symbolic'; + } + else { + this._pin_button.icon_name = 'view-non-pin-symbolic'; + } + this._updateAppSetting(); + }); + this._remove_app_button.connect('clicked', () => { this.emit('remove-app', application.id); }); From 4d140d5badfbc7759775cc5cc5c1091c4701015a Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Mon, 26 May 2025 03:20:44 +0700 Subject: [PATCH 18/23] fix: fallback launcher retrieval and use gio for first launcher option --- nautilus-extension/Flickernaut/models.py | 174 +++++++++++++++-------- 1 file changed, 114 insertions(+), 60 deletions(-) diff --git a/nautilus-extension/Flickernaut/models.py b/nautilus-extension/Flickernaut/models.py index c8b3bf2..0154589 100644 --- a/nautilus-extension/Flickernaut/models.py +++ b/nautilus-extension/Flickernaut/models.py @@ -44,24 +44,28 @@ def is_installed(self) -> bool: class Launcher(Package): """Represents a launchable desktop application.""" - def __init__(self, app_id: str) -> None: + def __init__(self, app_id: str, name: str) -> None: if not app_id or not isinstance(app_id, str): logger.error("app_id must be a non-empty string") self.app_id = "" - self.commandline = "" + self.name = "" + self.commandline = [] self.installed = False self._run_command = () self._launch_method = "none" self._init_failed = True + self._app_info = None return self.app_id: str = app_id - self.commandline: str = "" + self.name: str = name + self.commandline: list[str] = [] self.installed: bool = False self._run_command: tuple[str, ...] = () self._launch_method: str = "none" self._init_failed: bool = False + self._app_info: Optional[Gio.DesktopAppInfo] = None app_info = Gio.DesktopAppInfo.new(app_id) if not app_info: @@ -69,22 +73,26 @@ def __init__(self, app_id: str) -> None: self._init_failed = True return + self._app_info = app_info + self.installed = self._is_app_installed(app_info) - logger.debug("installed: %s", self.installed) self.commandline = self._get_commandline(app_info) - logger.debug("commandline: %s", self.commandline) self._set_launch_command(app_info) - logger.debug( - "launcher: method=%s, command=%s", - self._launch_method, - self._run_command, - ) + logger.debug(f"installed: {self.installed}") + logger.debug(f"launcher method: {self._launch_method}") + logger.debug(f"commandline: {self.commandline}") - def _get_commandline(self, app_info: Gio.DesktopAppInfo) -> str: + 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 [] + commandline = app_info.get_commandline() or "" # Split commandline into tokens while respecting quotes @@ -110,18 +118,20 @@ def _get_commandline(self, app_info: Gio.DesktopAppInfo) -> str: "@@", "@", } - filtered = [ t for t in tokens if t not in placeholders and not t.startswith("%") ] - # Join back to command string - return " ".join(filtered) + + if bin_path and filtered: + filtered[0] = bin_path + + return filtered def _is_app_installed(self, app_info: Gio.DesktopAppInfo) -> bool: - _exec = app_info.get_executable() or "" - _package_type = os.path.basename(_exec) if _exec else "" + exec = app_info.get_executable() or "" + package_type = os.path.basename(exec) if exec else "" - if _package_type == "flatpak": + if package_type == "flatpak": logger.debug("package type: flatpak") flatpak_dirs = [ @@ -135,22 +145,22 @@ def _is_app_installed(self, app_info: Gio.DesktopAppInfo) -> bool: return True return False - elif _package_type.endswith(".appimage"): + elif package_type.endswith(".appimage"): logger.debug("package type: appimage") - if _exec and _exec.endswith(".appimage"): - if os.path.exists(_exec) and os.access(_exec, os.X_OK): + if exec and exec.endswith(".appimage"): + if os.path.exists(exec) and os.access(exec, os.X_OK): return True return False - elif _exec: + elif exec: logger.debug("package type: native") - if os.path.isabs(_exec): - if os.path.exists(_exec) and os.access(_exec, os.X_OK): + if os.path.isabs(exec): + if os.path.exists(exec) and os.access(exec, os.X_OK): return True else: - bin_path = GLib.find_program_in_path(_exec) + bin_path = GLib.find_program_in_path(exec) if ( bin_path and os.path.exists(bin_path) @@ -163,38 +173,72 @@ def _is_app_installed(self, app_info: Gio.DesktopAppInfo) -> bool: def _set_launch_command(self, app_info: Gio.DesktopAppInfo) -> None: """Determine the best launch command for the application.""" - # Try gtk-launch first (most reliable for desktop integration) - launcher = GLib.find_program_in_path("gtk-launch") - if launcher and os.path.isfile(launcher): + # 1. Try Gio.AppInfo.launch_uris first + if 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_id[:-8] if self.app_id.endswith(".desktop") else self.app_id ) - self._run_command = (launcher, desktop_id) self._launch_method = "gtk-launch" + self._run_command = (bin_path, desktop_id) return - # Fall back to direct commandline execution + # 3. Fallback to commandline if other methods are not available if self.commandline: - commandline = GLib.find_program_in_path(self.commandline) - if ( - commandline - and os.path.isfile(commandline) - and os.access(commandline, os.X_OK) - ): - self._run_command = (commandline,) - self._launch_method = "commandline" - return - - # If we get here, no valid launch method was found - logger.error( - f"Could not find a valid way to launch {self.app_id}. " - f"Tried: gtk-launch, commandline ({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: + uris = [GLib.filename_to_uri(p, None) for p in paths] + try: + logger.debug(f"Launching {self.name} with gio-launch: {uris}") + ctx = None + self._app_info.launch_uris(uris, 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, ...]: """Get the command to run the application.""" @@ -228,9 +272,9 @@ def __init__( self.pinned: bool = pinned self.multiple_files: bool = multiple_files self.multiple_folders: bool = multiple_folders - self.package: Optional[Package] = None + self.package: Optional[Launcher] = None try: - launcher = Launcher(app_id) + launcher = Launcher(app_id, name) if launcher.is_installed: self.package = launcher else: @@ -242,7 +286,7 @@ def __init__( self.package = None @property - def installed_packages(self) -> list[Package]: + def installed_packages(self) -> list[Launcher]: return [self.package] if self.package and self.package.is_installed else [] @@ -264,24 +308,36 @@ def add_application(self, application: Application) -> None: self[application.id] = application @staticmethod - def _activate_menu_item(item: Nautilus.MenuItem, command: list[str]) -> None: + def _activate_menu_item( + item: Nautilus.MenuItem, launcher: Launcher, paths: list[str] + ) -> None: """Callback to activate a menu item and launch the command.""" - logger.debug(f"Launch command: {command}") try: - pid, *_ = GLib.spawn_async(command) - GLib.spawn_close_pid(pid) + 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}" + ) + return + else: + logger.error(f"All launch methods failed for: {launcher.app_id}") except Exception as e: - logger.error(f"Failed to spawn command {command}: {e}") + logger.error(f"Error during launching application: {e}") def _create_menu_item( self, application: Application, - package: Package, + launcher: Launcher, paths: list[str], id_prefix: str, is_file: bool, ) -> Nautilus.MenuItem: - """Create a Nautilus.MenuItem for a given application and package.""" + """Create a Nautilus.MenuItem for a given application and launcher.""" label = ( _("Open with %s") % application.name if is_file @@ -293,9 +349,7 @@ def _create_menu_item( label=label, ) - item.connect( - "activate", self._activate_menu_item, [*package.run_command, *paths] - ) + item.connect("activate", self._activate_menu_item, launcher, paths) return item def _filter_applications( @@ -359,11 +413,11 @@ def get_menu_items( for app in self._filter_applications( is_file=is_file, selection_count=selection_count ): - for package in app.installed_packages: - if not package.is_installed: + for launcher in app.installed_packages: + if not launcher.is_installed: continue - item = self._create_menu_item(app, package, paths, id_prefix, is_file) + item = self._create_menu_item(app, launcher, paths, id_prefix, is_file) if use_submenu and app.pinned: pinned_items.append(item) From a72ca983187963e8429288bfef302df122baeed4 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Mon, 26 May 2025 16:16:16 +0700 Subject: [PATCH 19/23] refactor: organize nautilus-extension codebase into modules for better structure --- nautilus-extension/Flickernaut/launcher.py | 140 ++++++++ nautilus-extension/Flickernaut/manager.py | 3 +- nautilus-extension/Flickernaut/models.py | 400 ++------------------- nautilus-extension/Flickernaut/registry.py | 185 ++++++++++ nautilus-extension/nautilus-flickernaut.py | 23 +- 5 files changed, 377 insertions(+), 374 deletions(-) create mode 100644 nautilus-extension/Flickernaut/launcher.py create mode 100644 nautilus-extension/Flickernaut/registry.py 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/manager.py b/nautilus-extension/Flickernaut/manager.py index 01b7531..3a5bca2 100644 --- a/nautilus-extension/Flickernaut/manager.py +++ b/nautilus-extension/Flickernaut/manager.py @@ -4,7 +4,8 @@ from typing import Any, Optional from gi.repository import Gio, GLib # type: ignore from .logger import get_logger -from .models import Application, ApplicationsRegistry, AppJsonStruct +from .models import Application, AppJsonStruct +from .registry import ApplicationsRegistry logger = get_logger(__name__) diff --git a/nautilus-extension/Flickernaut/models.py b/nautilus-extension/Flickernaut/models.py index 0154589..5d2f851 100644 --- a/nautilus-extension/Flickernaut/models.py +++ b/nautilus-extension/Flickernaut/models.py @@ -7,11 +7,11 @@ """ import os -import shlex from gettext import gettext as _ from typing import Optional, TypedDict -from gi.repository import Nautilus, GLib, Gio # type: ignore +from gi.repository import GLib, Gio # type: ignore from .logger import get_logger +from .launcher import Launcher logger = get_logger(__name__) @@ -27,108 +27,23 @@ class AppJsonStruct(TypedDict): class Package: - """Abstract base for application launch packages.""" + """Handles app installation checking.""" - def __str__(self) -> str: - return f"installed = {self.is_installed}" - - @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 Launcher(Package): - """Represents a launchable desktop application.""" - - def __init__(self, app_id: str, name: str) -> None: - if not app_id or not isinstance(app_id, str): - logger.error("app_id must be a non-empty string") - - self.app_id = "" - self.name = "" - self.commandline = [] - self.installed = False - self._run_command = () - self._launch_method = "none" - self._init_failed = True - self._app_info = None - return - - self.app_id: str = app_id - self.name: str = name - self.commandline: list[str] = [] - self.installed: bool = False - self._run_command: tuple[str, ...] = () - self._launch_method: str = "none" - self._init_failed: bool = False - self._app_info: Optional[Gio.DesktopAppInfo] = None - - app_info = Gio.DesktopAppInfo.new(app_id) - if not app_info: - logger.error(f"Failed to load desktop file for: {app_id}") - self._init_failed = True - return - - self._app_info = app_info - - self.installed = self._is_app_installed(app_info) - - self.commandline = self._get_commandline(app_info) - - self._set_launch_command(app_info) - - logger.debug(f"installed: {self.installed}") - 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 [] - - commandline = app_info.get_commandline() or "" - - # Split commandline into tokens while respecting quotes - tokens = shlex.split(commandline) - - # 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 + if self._is_installed_cache is not None: + return self._is_installed_cache - return filtered + if not self.app_info: + self._is_installed_cache = False + return False - def _is_app_installed(self, app_info: Gio.DesktopAppInfo) -> bool: - exec = app_info.get_executable() or "" + exec = self.app_info.get_executable() or "" package_type = os.path.basename(exec) if exec else "" if package_type == "flatpak": @@ -142,7 +57,9 @@ def _is_app_installed(self, app_info: Gio.DesktopAppInfo) -> bool: 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 elif package_type.endswith(".appimage"): @@ -150,7 +67,9 @@ def _is_app_installed(self, app_info: Gio.DesktopAppInfo) -> bool: 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 elif exec: @@ -158,6 +77,7 @@ def _is_app_installed(self, app_info: Gio.DesktopAppInfo) -> bool: 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) @@ -166,93 +86,14 @@ def _is_app_installed(self, app_info: Gio.DesktopAppInfo) -> bool: 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 + self._is_installed_cache = False return False - def _set_launch_command(self, app_info: Gio.DesktopAppInfo) -> None: - """Determine the best launch command for the application.""" - # 1. Try Gio.AppInfo.launch_uris first - if 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_id[:-8] if self.app_id.endswith(".desktop") else self.app_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: - uris = [GLib.filename_to_uri(p, None) for p in paths] - try: - logger.debug(f"Launching {self.name} with gio-launch: {uris}") - ctx = None - self._app_info.launch_uris(uris, 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, ...]: - """Get the command to run the application.""" - return self._run_command - - @property - def is_installed(self) -> bool: - """Check if the application appears to be installed.""" - return bool(self.installed) and not getattr(self, "_init_failed", False) - - def __str__(self) -> str: - """String representation for debugging.""" - return f"Launcher({self.app_id}, method={self._launch_method}, cmd={self._run_command})" - class Application: """Represents an application entry configured in Flickernaut.""" @@ -272,192 +113,17 @@ def __init__( self.pinned: bool = pinned self.multiple_files: bool = multiple_files self.multiple_folders: bool = multiple_folders - self.package: Optional[Launcher] = None - try: - launcher = Launcher(app_id, name) - if launcher.is_installed: - self.package = launcher - else: - logger.warning( - f"Launcher for {app_id} is not installed or not runnable" - ) - except Exception as e: - logger.error(f"Failed to initialize launcher for {app_id}: {e}") - self.package = None + 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}") - @property def installed_packages(self) -> list[Launcher]: - return [self.package] if self.package and self.package.is_installed else [] - - -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}" - ) - return - else: - logger.error(f"All launch methods failed for: {launcher.app_id}") - 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 any(package.is_installed for package in app.installed_packages): - 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 path 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] = [] - - for app in self._filter_applications( - is_file=is_file, selection_count=selection_count - ): - for launcher in app.installed_packages: - if not launcher.is_installed: - 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 + # 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 94f7978..f57670c 100644 --- a/nautilus-extension/nautilus-flickernaut.py +++ b/nautilus-extension/nautilus-flickernaut.py @@ -47,7 +47,10 @@ def _get_items( selection_count: int = 1, ) -> list[Nautilus.MenuItem]: """Generate menu items for the given file(s) or folder(s).""" - paths = [f.get_location().get_path() for f in file_info_or_list] + # paths = [f.get_location().get_path() for f in file_info_or_list] + + # experimental: use get_uri() + paths = [f.get_uri() for f in file_info_or_list] return applications_registry.get_menu_items( paths, @@ -81,7 +84,10 @@ def get_file_items(self, *args) -> Optional[list[Nautilus.MenuItem]]: if selection_count == 1: target = selected_files[0] - path = target.get_location().get_path() + # 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}") @@ -91,15 +97,20 @@ def get_file_items(self, *args) -> Optional[list[Nautilus.MenuItem]]: ) 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_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) From 03e078bb0038668ea4e1eb7f717ea9feebc54628 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Mon, 26 May 2025 16:18:48 +0700 Subject: [PATCH 20/23] chore(lang): update translation template --- po/flickernaut@imoize.github.io.pot | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/po/flickernaut@imoize.github.io.pot b/po/flickernaut@imoize.github.io.pot index dc2bafa..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-25 06:39+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" @@ -79,29 +79,33 @@ msgstr "" msgid "Project Page" msgstr "" -#: nautilus-extension/Flickernaut/models.py:286 +#: nautilus-extension/Flickernaut/registry.py:61 #, python-format msgid "Open with %s" msgstr "" -#: nautilus-extension/Flickernaut/models.py:288 +#: nautilus-extension/Flickernaut/registry.py:63 #, python-format msgid "Open in %s" msgstr "" -#: nautilus-extension/Flickernaut/models.py:370 +#: nautilus-extension/Flickernaut/registry.py:160 msgid "Open In..." msgstr "" -#: nautilus-extension/Flickernaut/models.py:370 +#: nautilus-extension/Flickernaut/registry.py:160 msgid "Open With..." msgstr "" -#: src/prefs/applicationList.ts:67 +#: src/prefs/applicationList.ts:65 +msgid "Pin in main menu when submenu is enabled." +msgstr "" + +#: src/prefs/applicationList.ts:80 msgid "Name cannot be empty" msgstr "" -#: src/prefs/applicationList.ts:69 +#: src/prefs/applicationList.ts:82 msgid "Name already exists" msgstr "" From acc4660ab5b00eb2991beb8011a4cb75e88c4cb5 Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Mon, 26 May 2025 17:56:51 +0700 Subject: [PATCH 21/23] feat: add package type detection --- @types/types.d.ts | 1 + src/prefs/application.ts | 13 +++++++++++++ src/prefs/applicationList.ts | 4 ++++ 3 files changed, 18 insertions(+) diff --git a/@types/types.d.ts b/@types/types.d.ts index e9e66b8..d1fe4bd 100644 --- a/@types/types.d.ts +++ b/@types/types.d.ts @@ -12,6 +12,7 @@ export interface Application { pinned: boolean; multipleFiles: boolean; multipleFolders: boolean; + packageType: 'Flatpak' | 'AppImage' | 'Native'; mimeTypes?: string[]; enable: boolean; } diff --git a/src/prefs/application.ts b/src/prefs/application.ts index d4317de..ff3fb6c 100644 --- a/src/prefs/application.ts +++ b/src/prefs/application.ts @@ -164,6 +164,18 @@ export const ApplicationPage = GObject.registerClass( 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() ?? '', @@ -172,6 +184,7 @@ export const ApplicationPage = GObject.registerClass( pinned: false, multipleFiles: false, multipleFolders: false, + packageType, mimeTypes, enable: true, }; diff --git a/src/prefs/applicationList.ts b/src/prefs/applicationList.ts index 2720c66..8b0cb94 100644 --- a/src/prefs/applicationList.ts +++ b/src/prefs/applicationList.ts @@ -19,6 +19,7 @@ export class ApplicationListClass extends Adw.ExpanderRow { 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; @@ -48,6 +49,8 @@ export class ApplicationListClass extends Adw.ExpanderRow { this._multiple_folders.active = application.multipleFolders || false; + this._packageType = application.packageType || 'Native'; + this._mime_types.text = normalizeArrayOutput(application.mimeTypes); this._toggleSwitch = new ToggleSwitchClass({ @@ -143,6 +146,7 @@ export class ApplicationListClass extends Adw.ExpanderRow { 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, }; From 0d07c71d2cf48a41591c5e9d538c0e5644930fbf Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Mon, 26 May 2025 18:07:29 +0700 Subject: [PATCH 22/23] refactor: remove hardcoded application entries from schema --- ...e.shell.extensions.flickernaut.gschema.xml | 36 +------------------ 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/schemas/org.gnome.shell.extensions.flickernaut.gschema.xml b/schemas/org.gnome.shell.extensions.flickernaut.gschema.xml index 8ffa6ea..8e21ab3 100644 --- a/schemas/org.gnome.shell.extensions.flickernaut.gschema.xml +++ b/schemas/org.gnome.shell.extensions.flickernaut.gschema.xml @@ -18,41 +18,7 @@ List of applications. List of applications to be shown in the menu. - [ - '{ - "id": "WK6ZbvHFJVGV", - "appId": "code.desktop", - "name": "VS Code", - "icon": "vscode", - "pinned": false, - "multipleFiles": false, - "multipleFolders": false, - "mimeTypes": ["application/x-code-workspace"], - "enable": false - }', - '{ - "id": "diEAs4v3mWE4", - "appId": "com.vscodium.codium.desktop", - "name": "VSCodium", - "icon": "com.vscodium.codium", - "pinned": false, - "multipleFiles": false, - "multipleFolders": false, - "mimeTypes": ["text/plain", "inode/directory", "application/x-codium-workspace"], - "enable": false - }', - '{ - "id": "K0alMDhM6BTJ", - "appId": "dev.zed.Zed.desktop", - "name": "Zed Editor", - "icon": "zed-stable", - "pinned": false, - "multipleFiles": false, - "multipleFolders": false, - "mimeTypes": ["text/plain", "application/x-zerosize", "x-scheme-handler/zed"], - "enable": false - }' - ] + [ ] From 730227900c6625b969e254bf990dc2be6a46d7fe Mon Sep 17 00:00:00 2001 From: imoize <51510865+imoize@users.noreply.github.com> Date: Mon, 26 May 2025 21:53:16 +0700 Subject: [PATCH 23/23] misc: forgot to comment out --- nautilus-extension/nautilus-flickernaut.py | 26 +++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/nautilus-extension/nautilus-flickernaut.py b/nautilus-extension/nautilus-flickernaut.py index f57670c..4084568 100644 --- a/nautilus-extension/nautilus-flickernaut.py +++ b/nautilus-extension/nautilus-flickernaut.py @@ -47,10 +47,10 @@ def _get_items( selection_count: int = 1, ) -> list[Nautilus.MenuItem]: """Generate menu items for the given file(s) or folder(s).""" - # paths = [f.get_location().get_path() for f in file_info_or_list] + paths = [f.get_location().get_path() for f in file_info_or_list] # experimental: use get_uri() - paths = [f.get_uri() for f in file_info_or_list] + # paths = [f.get_uri() for f in file_info_or_list] return applications_registry.get_menu_items( paths, @@ -84,10 +84,10 @@ def get_file_items(self, *args) -> Optional[list[Nautilus.MenuItem]]: if selection_count == 1: target = selected_files[0] - # path = target.get_location().get_path() + path = target.get_location().get_path() # experimental: use get_uri() - path = target.get_uri() + # path = target.get_uri() if target.is_directory(): logger.info(f"Single folder selected: {path}") @@ -102,19 +102,19 @@ def get_file_items(self, *args) -> Optional[list[Nautilus.MenuItem]]: ) 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_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(