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
+
+
+
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 @@
+
+
+
+
+
+
+
+
\ 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(