mirror of
https://github.com/haexhub/haex-hub.git
synced 2025-12-16 14:10:52 +01:00
use window system
This commit is contained in:
@ -1,9 +1,227 @@
|
|||||||
{
|
{
|
||||||
"lastUpdated": "2025-10-16T00:00:00.000Z",
|
"session_date": "2025-10-20",
|
||||||
"todos": [],
|
"project": "haex-hub System Windows Architecture + Drizzle CRDT RETURNING Fix + PK-Remapping Refactor",
|
||||||
|
"status": "system_windows_ui_integration_completed",
|
||||||
|
|
||||||
"context": {
|
"context": {
|
||||||
"description": "Session context file for Claude Code. This file is automatically updated to persist state across sessions.",
|
"main_work_today": [
|
||||||
"currentFocus": null,
|
"Fixed Drizzle CRDT integration with RETURNING support",
|
||||||
"notes": []
|
"Implemented System Windows architecture (Settings, Marketplace as Desktop Windows)",
|
||||||
}
|
"Refactored executor functions: Split execute/query paths for cleaner code",
|
||||||
|
"Integrated System Windows UI: Launcher, Window Component, Placeholder Components"
|
||||||
|
],
|
||||||
|
"completed_today": [
|
||||||
|
"Added AST-based statement_has_returning() for safe RETURNING detection",
|
||||||
|
"Created SqlExecutor::query_internal() for INSERT/UPDATE/DELETE with RETURNING",
|
||||||
|
"Simplified execute_internal to use execute_internal_typed (now with PK-Remapping!)",
|
||||||
|
"Added sql_query_with_crdt Tauri command",
|
||||||
|
"Updated drizzleCallback in index.ts to route correctly",
|
||||||
|
"Extended IWindow interface: type ('system' | 'extension'), sourceId",
|
||||||
|
"Added SystemWindowDefinition interface",
|
||||||
|
"Created system windows registry in windowManager store",
|
||||||
|
"Extended openWindow() to support both system and extension windows",
|
||||||
|
"Added singleton support for system windows",
|
||||||
|
"Split execute_internal_typed_with_context into two functions (execute vs query)",
|
||||||
|
"Created query_internal_typed_with_context with full PK-Remapping support",
|
||||||
|
"Updated query_internal to use new typed function with PK-Remapping",
|
||||||
|
"Fixed manager.rs to use query_internal_typed_with_context for INSERT RETURNING",
|
||||||
|
"Extended Launcher to show System Windows + Extensions alphabetically",
|
||||||
|
"Adapted Window Component to render System Windows as Vue Components, Extensions as iFrames",
|
||||||
|
"Created placeholder components: Settings.vue and Marketplace.vue"
|
||||||
|
],
|
||||||
|
"tech_stack": "Vue 3, TypeScript, Pinia, Nuxt UI, Tauri, Rust, Drizzle ORM, SQLite"
|
||||||
|
},
|
||||||
|
|
||||||
|
"drizzle_crdt_implementation": {
|
||||||
|
"problem": "Drizzle .insert().returning() executed SQL twice and lost RETURNING data",
|
||||||
|
"solution": "Separate execute and query paths based on RETURNING clause",
|
||||||
|
|
||||||
|
"typescript_side": {
|
||||||
|
"file": "src/stores/vault/index.ts",
|
||||||
|
"drizzleCallback_logic": {
|
||||||
|
"select": "sql_select (unchanged)",
|
||||||
|
"with_returning": "sql_query_with_crdt (NEW)",
|
||||||
|
"without_returning": "sql_execute_with_crdt (unchanged)"
|
||||||
|
},
|
||||||
|
"hasReturning_check": "String-based RETURNING regex (safe enough for generated SQL)"
|
||||||
|
},
|
||||||
|
|
||||||
|
"rust_side": {
|
||||||
|
"files": [
|
||||||
|
"src-tauri/src/database/core.rs",
|
||||||
|
"src-tauri/src/extension/database/executor.rs",
|
||||||
|
"src-tauri/src/database/mod.rs"
|
||||||
|
],
|
||||||
|
"core_rs_changes": {
|
||||||
|
"statement_has_returning": {
|
||||||
|
"line": 84,
|
||||||
|
"purpose": "AST-based RETURNING check (INSERT, UPDATE, DELETE)",
|
||||||
|
"safety": "Checks actual AST, not string matching"
|
||||||
|
},
|
||||||
|
"convert_value_ref_to_json": {
|
||||||
|
"line": 333,
|
||||||
|
"visibility": "Made public for reuse"
|
||||||
|
},
|
||||||
|
"removed": "query_with_crdt function (replaced by SqlExecutor::query_internal)"
|
||||||
|
},
|
||||||
|
"executor_rs_changes": {
|
||||||
|
"execute_internal_typed_with_context": {
|
||||||
|
"line": 100,
|
||||||
|
"purpose": "Execute SQL WITHOUT RETURNING (with CRDT and FK-Remapping)",
|
||||||
|
"returns": "Result<HashSet<String>, DatabaseError>",
|
||||||
|
"behavior": "Handles INSERTs with FK-Remapping, uses execute()"
|
||||||
|
},
|
||||||
|
"query_internal_typed_with_context": {
|
||||||
|
"line": 186,
|
||||||
|
"purpose": "Execute SQL WITH RETURNING (with CRDT, PK-Remapping, FK-Remapping)",
|
||||||
|
"returns": "Result<(HashSet<String>, Vec<Vec<JsonValue>>), DatabaseError>",
|
||||||
|
"behavior": "Handles INSERTs with full PK-Remapping + FK-Remapping, returns all RETURNING columns"
|
||||||
|
},
|
||||||
|
"query_internal": {
|
||||||
|
"line": 454,
|
||||||
|
"purpose": "Execute with CRDT + return full RETURNING results (JsonValue params)",
|
||||||
|
"behavior": "Wrapper around query_internal_typed_with_context"
|
||||||
|
},
|
||||||
|
"execute_internal_refactor": {
|
||||||
|
"line": 345,
|
||||||
|
"change": "Now wrapper around execute_internal_typed",
|
||||||
|
"benefit": "Drizzle now gets PK-Remapping for ON CONFLICT!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mod_rs_changes": {
|
||||||
|
"sql_query_with_crdt": {
|
||||||
|
"line": 59,
|
||||||
|
"calls": "SqlExecutor::query_internal",
|
||||||
|
"returns": "Vec<Vec<JsonValue>>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lib_rs_changes": {
|
||||||
|
"registered_command": "sql_query_with_crdt added to invoke_handler"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"benefits": [
|
||||||
|
"SQL executed only once (not twice)",
|
||||||
|
"Full RETURNING results available to Drizzle",
|
||||||
|
"PK-Remapping now works for Drizzle (both execute and query paths)",
|
||||||
|
"AST-based RETURNING detection (safe)",
|
||||||
|
"Less code duplication",
|
||||||
|
"Cleaner code separation: execute vs query functions",
|
||||||
|
"FK-Remapping works across transactions with PkRemappingContext"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"system_windows_architecture": {
|
||||||
|
"concept": "ALL UI (Settings, Marketplace, etc.) as DesktopWindows, same as Extensions",
|
||||||
|
"status": "Store completed, UI integration pending",
|
||||||
|
|
||||||
|
"window_manager_store": {
|
||||||
|
"file": "src/stores/desktop/windowManager.ts",
|
||||||
|
"changes": {
|
||||||
|
"IWindow_interface": {
|
||||||
|
"added_fields": [
|
||||||
|
"type: 'system' | 'extension'",
|
||||||
|
"sourceId: string (replaces extensionId)"
|
||||||
|
],
|
||||||
|
"removed_fields": ["extensionId"]
|
||||||
|
},
|
||||||
|
"SystemWindowDefinition": {
|
||||||
|
"fields": "id, name, icon, component, defaultWidth, defaultHeight, resizable, singleton"
|
||||||
|
},
|
||||||
|
"system_windows_registry": {
|
||||||
|
"line": 46,
|
||||||
|
"entries": ["settings", "marketplace"],
|
||||||
|
"structure": "Record<string, SystemWindowDefinition>"
|
||||||
|
},
|
||||||
|
"openWindow_function": {
|
||||||
|
"line": 101,
|
||||||
|
"signature": "(type, sourceId, title?, icon?, width?, height?, sourcePosition?)",
|
||||||
|
"features": [
|
||||||
|
"Type-based handling (system vs extension)",
|
||||||
|
"Singleton check for system windows",
|
||||||
|
"Auto-loads defaults from registry",
|
||||||
|
"Activates existing singleton if already open"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"new_exports": ["getAllSystemWindows", "getSystemWindow"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"ui_integration": {
|
||||||
|
"launcher": {
|
||||||
|
"file": "src/components/haex/extension/launcher.vue",
|
||||||
|
"changes": [
|
||||||
|
"Combined system windows and extensions in unified launcherItems computed",
|
||||||
|
"Alphabetically sorted by name",
|
||||||
|
"openItem() function handles both types with correct openWindow() signature",
|
||||||
|
"Uses windowManagerStore.getAllSystemWindows()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"desktop_window_component": {
|
||||||
|
"file": "src/components/haex/desktop/index.vue",
|
||||||
|
"changes": [
|
||||||
|
"Dynamic component rendering: <component :is='getSystemWindowComponent()'> for system windows",
|
||||||
|
"HaexDesktopExtensionFrame for extensions (iFrame)",
|
||||||
|
"getSystemWindowComponent() function to retrieve Vue component from registry",
|
||||||
|
"Applied to both normal mode and overview mode"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholder_components": {
|
||||||
|
"created": [
|
||||||
|
"src/components/haex/system/settings.vue",
|
||||||
|
"src/components/haex/system/marketplace.vue"
|
||||||
|
],
|
||||||
|
"description": "Simple placeholder UI with sections and styling"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"next_steps": {
|
||||||
|
"priority": [
|
||||||
|
"Desktop Icons: Support system window icons (alongside extension icons)",
|
||||||
|
"Drag & Drop: Launcher → Desktop for all types (system + extension)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"workspace_overview_context": {
|
||||||
|
"still_active": "GNOME-style workspace overview with UDrawer, implemented yesterday",
|
||||||
|
"file": "src/components/haex/desktop/index.vue",
|
||||||
|
"status": "Working, workspace switching functional"
|
||||||
|
},
|
||||||
|
|
||||||
|
"file_changes_today": {
|
||||||
|
"modified": [
|
||||||
|
"src/stores/vault/index.ts (drizzleCallback with hasReturning)",
|
||||||
|
"src-tauri/src/database/core.rs (statement_has_returning, removed query_with_crdt)",
|
||||||
|
"src-tauri/src/extension/database/executor.rs (split execute/query functions, PK-Remapping)",
|
||||||
|
"src-tauri/src/database/mod.rs (sql_query_with_crdt command)",
|
||||||
|
"src-tauri/src/lib.rs (registered sql_query_with_crdt)",
|
||||||
|
"src/stores/desktop/windowManager.ts (system windows support)",
|
||||||
|
"src-tauri/src/extension/core/manager.rs (updated to use query_internal_typed_with_context)",
|
||||||
|
"src/components/haex/extension/launcher.vue (unified launcher for system + extensions)",
|
||||||
|
"src/components/haex/desktop/index.vue (dynamic component rendering)"
|
||||||
|
],
|
||||||
|
"created": [
|
||||||
|
"src/components/haex/system/settings.vue",
|
||||||
|
"src/components/haex/system/marketplace.vue"
|
||||||
|
],
|
||||||
|
"deleted": []
|
||||||
|
},
|
||||||
|
|
||||||
|
"important_notes": [
|
||||||
|
"Drizzle RETURNING now fully functional with CRDT",
|
||||||
|
"System windows use Vue components, Extensions use iFrame",
|
||||||
|
"sourceId is generic: extensionId for extensions, systemWindowId for system windows",
|
||||||
|
"Singleton system windows auto-activate if already open",
|
||||||
|
"PK-Remapping now works for both execute and query paths",
|
||||||
|
"executor.rs: Two separate functions for execute (no RETURNING) vs query (with RETURNING)",
|
||||||
|
"query_internal_typed_with_context returns full RETURNING results as Vec<Vec<JsonValue>>",
|
||||||
|
"FK-Remapping works across transaction using PkRemappingContext",
|
||||||
|
"Next session: Implement Launcher UI integration for system windows"
|
||||||
|
],
|
||||||
|
|
||||||
|
"todos_remaining": [
|
||||||
|
"Desktop Icons für System Windows unterstützen (neben Extension Icons)",
|
||||||
|
"Drag & Drop vom Launcher zum Desktop implementieren (für beide Typen)"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,20 @@ export default defineNuxtConfig({
|
|||||||
'pages/**',
|
'pages/**',
|
||||||
'types/**',
|
'types/**',
|
||||||
],
|
],
|
||||||
|
presets: [
|
||||||
|
{
|
||||||
|
from: '@vueuse/gesture',
|
||||||
|
imports: [
|
||||||
|
'useDrag',
|
||||||
|
'useGesture',
|
||||||
|
'useHover',
|
||||||
|
'useMove',
|
||||||
|
'usePinch',
|
||||||
|
'useScroll',
|
||||||
|
'useWheel',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
css: ['./assets/css/main.css'],
|
css: ['./assets/css/main.css'],
|
||||||
|
|||||||
@ -36,12 +36,14 @@
|
|||||||
"@tauri-apps/plugin-store": "^2.2.1",
|
"@tauri-apps/plugin-store": "^2.2.1",
|
||||||
"@vueuse/components": "^13.9.0",
|
"@vueuse/components": "^13.9.0",
|
||||||
"@vueuse/core": "^13.4.0",
|
"@vueuse/core": "^13.4.0",
|
||||||
|
"@vueuse/gesture": "^2.0.0",
|
||||||
"@vueuse/nuxt": "^13.4.0",
|
"@vueuse/nuxt": "^13.4.0",
|
||||||
"drizzle-orm": "^0.44.2",
|
"drizzle-orm": "^0.44.2",
|
||||||
"eslint": "^9.34.0",
|
"eslint": "^9.34.0",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"nuxt": "^4.0.3",
|
"nuxt": "^4.0.3",
|
||||||
"nuxt-zod-i18n": "^1.12.0",
|
"nuxt-zod-i18n": "^1.12.0",
|
||||||
|
"swiper": "^12.0.2",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
"vue": "^3.5.20",
|
"vue": "^3.5.20",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
|
|||||||
35
pnpm-lock.yaml
generated
35
pnpm-lock.yaml
generated
@ -65,6 +65,9 @@ importers:
|
|||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^13.4.0
|
specifier: ^13.4.0
|
||||||
version: 13.9.0(vue@3.5.21(typescript@5.9.2))
|
version: 13.9.0(vue@3.5.21(typescript@5.9.2))
|
||||||
|
'@vueuse/gesture':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.0.0(vue@3.5.21(typescript@5.9.2))
|
||||||
'@vueuse/nuxt':
|
'@vueuse/nuxt':
|
||||||
specifier: ^13.4.0
|
specifier: ^13.4.0
|
||||||
version: 13.9.0(magicast@0.3.5)(nuxt@4.1.1(@libsql/client@0.15.15)(@parcel/watcher@2.5.1)(@types/node@24.6.2)(@vue/compiler-sfc@3.5.21)(db0@0.3.2(@libsql/client@0.15.15)(drizzle-orm@0.44.5(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)))(drizzle-orm@0.44.5(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0))(eslint@9.35.0(jiti@2.5.1))(ioredis@5.7.0)(lightningcss@1.30.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.50.1)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.2)(vite@7.1.3(@types/node@24.6.2)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vue-tsc@3.0.6(typescript@5.9.2))(yaml@2.8.1))(vue@3.5.21(typescript@5.9.2))
|
version: 13.9.0(magicast@0.3.5)(nuxt@4.1.1(@libsql/client@0.15.15)(@parcel/watcher@2.5.1)(@types/node@24.6.2)(@vue/compiler-sfc@3.5.21)(db0@0.3.2(@libsql/client@0.15.15)(drizzle-orm@0.44.5(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)))(drizzle-orm@0.44.5(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0))(eslint@9.35.0(jiti@2.5.1))(ioredis@5.7.0)(lightningcss@1.30.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.50.1)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.2)(vite@7.1.3(@types/node@24.6.2)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vue-tsc@3.0.6(typescript@5.9.2))(yaml@2.8.1))(vue@3.5.21(typescript@5.9.2))
|
||||||
@ -83,6 +86,9 @@ importers:
|
|||||||
nuxt-zod-i18n:
|
nuxt-zod-i18n:
|
||||||
specifier: ^1.12.0
|
specifier: ^1.12.0
|
||||||
version: 1.12.1(magicast@0.3.5)
|
version: 1.12.1(magicast@0.3.5)
|
||||||
|
swiper:
|
||||||
|
specifier: ^12.0.2
|
||||||
|
version: 12.0.2
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.1.10
|
specifier: ^4.1.10
|
||||||
version: 4.1.13
|
version: 4.1.13
|
||||||
@ -2391,6 +2397,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.5.0
|
vue: ^3.5.0
|
||||||
|
|
||||||
|
'@vueuse/gesture@2.0.0':
|
||||||
|
resolution: {integrity: sha512-+F0bhhd8j+gxHaXG4fJgfokrkFfWenQ10MtrWOJk68B5UaTwtJm4EpsZFiVdluA3jpKExG6H+HtroJpvO7Qx0A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@vue/composition-api': ^1.4.1
|
||||||
|
vue: ^2.0.0 || >=3.0.0-rc.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@vue/composition-api':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@vueuse/integrations@13.9.0':
|
'@vueuse/integrations@13.9.0':
|
||||||
resolution: {integrity: sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ==}
|
resolution: {integrity: sha512-SDobKBbPIOe0cVL7QxMzGkuUGHvWTdihi9zOrrWaWUgFKe15cwEcwfWmgrcNzjT6kHnNmWuTajPHoIzUjYNYYQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -4858,6 +4873,10 @@ packages:
|
|||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
swiper@12.0.2:
|
||||||
|
resolution: {integrity: sha512-y8F6fDGXmTVVgwqJj6I00l4FdGuhpFJn0U/9Ucn1MwWOw3NdLV8aH88pZOjyhBgU/6PyBlUx+JuAQ5KMWz906Q==}
|
||||||
|
engines: {node: '>= 4.7.0'}
|
||||||
|
|
||||||
swrv@1.1.0:
|
swrv@1.1.0:
|
||||||
resolution: {integrity: sha512-pjllRDr2s0iTwiE5Isvip51dZGR7GjLH1gCSVyE8bQnbAx6xackXsFdojau+1O5u98yHF5V73HQGOFxKUXO9gQ==}
|
resolution: {integrity: sha512-pjllRDr2s0iTwiE5Isvip51dZGR7GjLH1gCSVyE8bQnbAx6xackXsFdojau+1O5u98yHF5V73HQGOFxKUXO9gQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -5158,6 +5177,10 @@ packages:
|
|||||||
unwasm@0.3.11:
|
unwasm@0.3.11:
|
||||||
resolution: {integrity: sha512-Vhp5gb1tusSQw5of/g3Q697srYgMXvwMgXMjcG4ZNga02fDX9coxJ9fAb0Ci38hM2Hv/U1FXRPGgjP2BYqhNoQ==}
|
resolution: {integrity: sha512-Vhp5gb1tusSQw5of/g3Q697srYgMXvwMgXMjcG4ZNga02fDX9coxJ9fAb0Ci38hM2Hv/U1FXRPGgjP2BYqhNoQ==}
|
||||||
|
|
||||||
|
upath@2.0.1:
|
||||||
|
resolution: {integrity: sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
update-browserslist-db@1.1.3:
|
update-browserslist-db@1.1.3:
|
||||||
resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
|
resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -7936,6 +7959,14 @@ snapshots:
|
|||||||
'@vueuse/shared': 13.9.0(vue@3.5.21(typescript@5.9.2))
|
'@vueuse/shared': 13.9.0(vue@3.5.21(typescript@5.9.2))
|
||||||
vue: 3.5.21(typescript@5.9.2)
|
vue: 3.5.21(typescript@5.9.2)
|
||||||
|
|
||||||
|
'@vueuse/gesture@2.0.0(vue@3.5.21(typescript@5.9.2))':
|
||||||
|
dependencies:
|
||||||
|
chokidar: 3.6.0
|
||||||
|
consola: 3.4.2
|
||||||
|
upath: 2.0.1
|
||||||
|
vue: 3.5.21(typescript@5.9.2)
|
||||||
|
vue-demi: 0.14.10(vue@3.5.21(typescript@5.9.2))
|
||||||
|
|
||||||
'@vueuse/integrations@13.9.0(change-case@5.4.4)(fuse.js@7.1.0)(vue@3.5.21(typescript@5.9.2))':
|
'@vueuse/integrations@13.9.0(change-case@5.4.4)(fuse.js@7.1.0)(vue@3.5.21(typescript@5.9.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vueuse/core': 13.9.0(vue@3.5.21(typescript@5.9.2))
|
'@vueuse/core': 13.9.0(vue@3.5.21(typescript@5.9.2))
|
||||||
@ -10556,6 +10587,8 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
sax: 1.4.1
|
sax: 1.4.1
|
||||||
|
|
||||||
|
swiper@12.0.2: {}
|
||||||
|
|
||||||
swrv@1.1.0(vue@3.5.21(typescript@5.9.2)):
|
swrv@1.1.0(vue@3.5.21(typescript@5.9.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.21(typescript@5.9.2)
|
vue: 3.5.21(typescript@5.9.2)
|
||||||
@ -10904,6 +10937,8 @@ snapshots:
|
|||||||
pkg-types: 2.3.0
|
pkg-types: 2.3.0
|
||||||
unplugin: 2.3.10
|
unplugin: 2.3.10
|
||||||
|
|
||||||
|
upath@2.0.1: {}
|
||||||
|
|
||||||
update-browserslist-db@1.1.3(browserslist@4.25.4):
|
update-browserslist-db@1.1.3(browserslist@4.25.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.25.4
|
browserslist: 4.25.4
|
||||||
|
|||||||
10
src-tauri/database/migrations/0004_mature_viper.sql
Normal file
10
src-tauri/database/migrations/0004_mature_viper.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE `haex_workspaces` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`position` integer DEFAULT 0 NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`haex_tombstone` integer,
|
||||||
|
`haex_timestamp` text
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `haex_desktop_items` ADD `workspace_id` text NOT NULL REFERENCES haex_workspaces(id);
|
||||||
1
src-tauri/database/migrations/0005_tidy_yellowjacket.sql
Normal file
1
src-tauri/database/migrations/0005_tidy_yellowjacket.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
CREATE UNIQUE INDEX `haex_workspaces_name_unique` ON `haex_workspaces` (`name`);
|
||||||
1065
src-tauri/database/migrations/meta/0004_snapshot.json
Normal file
1065
src-tauri/database/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1073
src-tauri/database/migrations/meta/0005_snapshot.json
Normal file
1073
src-tauri/database/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -29,6 +29,20 @@
|
|||||||
"when": 1760611690801,
|
"when": 1760611690801,
|
||||||
"tag": "0003_daily_polaris",
|
"tag": "0003_daily_polaris",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1760817142340,
|
||||||
|
"tag": "0004_mature_viper",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1760964548034,
|
||||||
|
"tag": "0005_tidy_yellowjacket",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -10,7 +10,9 @@ import {
|
|||||||
import tableNames from '../tableNames.json'
|
import tableNames from '../tableNames.json'
|
||||||
|
|
||||||
// Helper function to add common CRDT columns (haexTombstone and haexTimestamp)
|
// Helper function to add common CRDT columns (haexTombstone and haexTimestamp)
|
||||||
export const withCrdtColumns = <T extends Record<string, SQLiteColumnBuilderBase>>(
|
export const withCrdtColumns = <
|
||||||
|
T extends Record<string, SQLiteColumnBuilderBase>,
|
||||||
|
>(
|
||||||
columns: T,
|
columns: T,
|
||||||
columnNames: { haexTombstone: string; haexTimestamp: string },
|
columnNames: { haexTombstone: string; haexTimestamp: string },
|
||||||
) => ({
|
) => ({
|
||||||
@ -132,6 +134,30 @@ export const haexNotifications = sqliteTable(
|
|||||||
export type InsertHaexNotifications = typeof haexNotifications.$inferInsert
|
export type InsertHaexNotifications = typeof haexNotifications.$inferInsert
|
||||||
export type SelectHaexNotifications = typeof haexNotifications.$inferSelect
|
export type SelectHaexNotifications = typeof haexNotifications.$inferSelect
|
||||||
|
|
||||||
|
export const haexWorkspaces = sqliteTable(
|
||||||
|
tableNames.haex.workspaces.name,
|
||||||
|
withCrdtColumns(
|
||||||
|
{
|
||||||
|
id: text(tableNames.haex.workspaces.columns.id)
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
name: text(tableNames.haex.workspaces.columns.name).notNull(),
|
||||||
|
position: integer(tableNames.haex.workspaces.columns.position)
|
||||||
|
.notNull()
|
||||||
|
.default(0),
|
||||||
|
createdAt: integer(tableNames.haex.workspaces.columns.createdAt, {
|
||||||
|
mode: 'timestamp',
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date()),
|
||||||
|
},
|
||||||
|
tableNames.haex.workspaces.columns,
|
||||||
|
),
|
||||||
|
(table) => [unique().on(table.name)],
|
||||||
|
)
|
||||||
|
export type InsertHaexWorkspaces = typeof haexWorkspaces.$inferInsert
|
||||||
|
export type SelectHaexWorkspaces = typeof haexWorkspaces.$inferSelect
|
||||||
|
|
||||||
export const haexDesktopItems = sqliteTable(
|
export const haexDesktopItems = sqliteTable(
|
||||||
tableNames.haex.desktop_items.name,
|
tableNames.haex.desktop_items.name,
|
||||||
withCrdtColumns(
|
withCrdtColumns(
|
||||||
@ -139,10 +165,15 @@ export const haexDesktopItems = sqliteTable(
|
|||||||
id: text(tableNames.haex.desktop_items.columns.id)
|
id: text(tableNames.haex.desktop_items.columns.id)
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => crypto.randomUUID()),
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
workspaceId: text(tableNames.haex.desktop_items.columns.workspaceId)
|
||||||
|
.notNull()
|
||||||
|
.references(() => haexWorkspaces.id),
|
||||||
itemType: text(tableNames.haex.desktop_items.columns.itemType, {
|
itemType: text(tableNames.haex.desktop_items.columns.itemType, {
|
||||||
enum: ['extension', 'file', 'folder'],
|
enum: ['extension', 'file', 'folder'],
|
||||||
}).notNull(),
|
}).notNull(),
|
||||||
referenceId: text(tableNames.haex.desktop_items.columns.referenceId).notNull(), // extensionId für extensions, filePath für files/folders
|
referenceId: text(
|
||||||
|
tableNames.haex.desktop_items.columns.referenceId,
|
||||||
|
).notNull(), // extensionId für extensions, filePath für files/folders
|
||||||
positionX: integer(tableNames.haex.desktop_items.columns.positionX)
|
positionX: integer(tableNames.haex.desktop_items.columns.positionX)
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(0),
|
.default(0),
|
||||||
|
|||||||
@ -63,10 +63,22 @@
|
|||||||
"haexTimestamp": "haex_timestamp"
|
"haexTimestamp": "haex_timestamp"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"workspaces": {
|
||||||
|
"name": "haex_workspaces",
|
||||||
|
"columns": {
|
||||||
|
"id": "id",
|
||||||
|
"name": "name",
|
||||||
|
"position": "position",
|
||||||
|
"createdAt": "created_at",
|
||||||
|
"haexTombstone": "haex_tombstone",
|
||||||
|
"haexTimestamp": "haex_timestamp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"desktop_items": {
|
"desktop_items": {
|
||||||
"name": "haex_desktop_items",
|
"name": "haex_desktop_items",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": "id",
|
"id": "id",
|
||||||
|
"workspaceId": "workspace_id",
|
||||||
"itemType": "item_type",
|
"itemType": "item_type",
|
||||||
"referenceId": "reference_id",
|
"referenceId": "reference_id",
|
||||||
"positionX": "position_x",
|
"positionX": "position_x",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -18,6 +18,7 @@ struct Haex {
|
|||||||
extensions: TableDefinition,
|
extensions: TableDefinition,
|
||||||
extension_permissions: TableDefinition,
|
extension_permissions: TableDefinition,
|
||||||
notifications: TableDefinition,
|
notifications: TableDefinition,
|
||||||
|
desktop_items: TableDefinition,
|
||||||
crdt: Crdt,
|
crdt: Crdt,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,6 +108,16 @@ pub const COL_NOTIFICATIONS_TITLE: &str = "{c_notif_title}";
|
|||||||
pub const COL_NOTIFICATIONS_TYPE: &str = "{c_notif_type}";
|
pub const COL_NOTIFICATIONS_TYPE: &str = "{c_notif_type}";
|
||||||
pub const COL_NOTIFICATIONS_HAEX_TOMBSTONE: &str = "{c_notif_tombstone}";
|
pub const COL_NOTIFICATIONS_HAEX_TOMBSTONE: &str = "{c_notif_tombstone}";
|
||||||
|
|
||||||
|
// --- Table: haex_desktop_items ---
|
||||||
|
pub const TABLE_DESKTOP_ITEMS: &str = "{t_desktop_items}";
|
||||||
|
pub const COL_DESKTOP_ITEMS_ID: &str = "{c_desktop_id}";
|
||||||
|
pub const COL_DESKTOP_ITEMS_ITEM_TYPE: &str = "{c_desktop_itemType}";
|
||||||
|
pub const COL_DESKTOP_ITEMS_REFERENCE_ID: &str = "{c_desktop_referenceId}";
|
||||||
|
pub const COL_DESKTOP_ITEMS_POSITION_X: &str = "{c_desktop_positionX}";
|
||||||
|
pub const COL_DESKTOP_ITEMS_POSITION_Y: &str = "{c_desktop_positionY}";
|
||||||
|
pub const COL_DESKTOP_ITEMS_HAEX_TOMBSTONE: &str = "{c_desktop_tombstone}";
|
||||||
|
pub const COL_DESKTOP_ITEMS_HAEX_TIMESTAMP: &str = "{c_desktop_timestamp}";
|
||||||
|
|
||||||
// --- Table: haex_crdt_logs ---
|
// --- Table: haex_crdt_logs ---
|
||||||
pub const TABLE_CRDT_LOGS: &str = "{t_crdt_logs}";
|
pub const TABLE_CRDT_LOGS: &str = "{t_crdt_logs}";
|
||||||
pub const COL_CRDT_LOGS_ID: &str = "{c_crdt_logs_id}";
|
pub const COL_CRDT_LOGS_ID: &str = "{c_crdt_logs_id}";
|
||||||
@ -181,6 +192,15 @@ pub const COL_CRDT_CONFIGS_VALUE: &str = "{c_crdt_configs_value}";
|
|||||||
c_notif_title = haex.notifications.columns["title"],
|
c_notif_title = haex.notifications.columns["title"],
|
||||||
c_notif_type = haex.notifications.columns["type"],
|
c_notif_type = haex.notifications.columns["type"],
|
||||||
c_notif_tombstone = haex.notifications.columns["haexTombstone"],
|
c_notif_tombstone = haex.notifications.columns["haexTombstone"],
|
||||||
|
// Desktop Items
|
||||||
|
t_desktop_items = haex.desktop_items.name,
|
||||||
|
c_desktop_id = haex.desktop_items.columns["id"],
|
||||||
|
c_desktop_itemType = haex.desktop_items.columns["itemType"],
|
||||||
|
c_desktop_referenceId = haex.desktop_items.columns["referenceId"],
|
||||||
|
c_desktop_positionX = haex.desktop_items.columns["positionX"],
|
||||||
|
c_desktop_positionY = haex.desktop_items.columns["positionY"],
|
||||||
|
c_desktop_tombstone = haex.desktop_items.columns["haexTombstone"],
|
||||||
|
c_desktop_timestamp = haex.desktop_items.columns["haexTimestamp"],
|
||||||
// CRDT Logs
|
// CRDT Logs
|
||||||
t_crdt_logs = haex.crdt.logs.name,
|
t_crdt_logs = haex.crdt.logs.name,
|
||||||
c_crdt_logs_id = haex.crdt.logs.columns["id"],
|
c_crdt_logs_id = haex.crdt.logs.columns["id"],
|
||||||
|
|||||||
159
src-tauri/src/crdt/insert_transformer.rs
Normal file
159
src-tauri/src/crdt/insert_transformer.rs
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
// src-tauri/src/crdt/insert_transformer.rs
|
||||||
|
// INSERT-spezifische CRDT-Transformationen (ON CONFLICT, RETURNING)
|
||||||
|
|
||||||
|
use crate::crdt::trigger::{HLC_TIMESTAMP_COLUMN, TOMBSTONE_COLUMN};
|
||||||
|
use crate::database::error::DatabaseError;
|
||||||
|
use sqlparser::ast::{
|
||||||
|
Assignment, AssignmentTarget, BinaryOperator, Expr, Ident, Insert, ObjectNamePart,
|
||||||
|
OnConflict, OnConflictAction, OnInsert, SelectItem, SetExpr, Value,
|
||||||
|
};
|
||||||
|
use uhlc::Timestamp;
|
||||||
|
|
||||||
|
/// Helper-Struct für INSERT-Transformationen
|
||||||
|
pub struct InsertTransformer {
|
||||||
|
tombstone_column: &'static str,
|
||||||
|
hlc_timestamp_column: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InsertTransformer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
tombstone_column: TOMBSTONE_COLUMN,
|
||||||
|
hlc_timestamp_column: HLC_TIMESTAMP_COLUMN,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transformiert INSERT-Statements (fügt HLC-Timestamp hinzu und behandelt Tombstone-Konflikte)
|
||||||
|
/// Fügt automatisch RETURNING für Primary Keys hinzu, damit der Executor die tatsächlichen PKs kennt
|
||||||
|
pub fn transform_insert(
|
||||||
|
&self,
|
||||||
|
insert_stmt: &mut Insert,
|
||||||
|
timestamp: &Timestamp,
|
||||||
|
primary_keys: &[String],
|
||||||
|
foreign_keys: &[String],
|
||||||
|
) -> Result<(), DatabaseError> {
|
||||||
|
// Add both haex_timestamp and haex_tombstone columns
|
||||||
|
insert_stmt
|
||||||
|
.columns
|
||||||
|
.push(Ident::new(self.hlc_timestamp_column));
|
||||||
|
insert_stmt.columns.push(Ident::new(self.tombstone_column));
|
||||||
|
|
||||||
|
// Füge RETURNING für alle Primary Keys hinzu (falls noch nicht vorhanden)
|
||||||
|
// Dies erlaubt uns, die tatsächlichen PK-Werte nach ON CONFLICT zu kennen
|
||||||
|
if insert_stmt.returning.is_none() && !primary_keys.is_empty() {
|
||||||
|
insert_stmt.returning = Some(
|
||||||
|
primary_keys
|
||||||
|
.iter()
|
||||||
|
.map(|pk| SelectItem::UnnamedExpr(Expr::Identifier(Ident::new(pk))))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setze ON CONFLICT für UPSERT-Verhalten bei Tombstone-Einträgen
|
||||||
|
// Dies ermöglicht das Wiederverwenden von gelöschten Einträgen
|
||||||
|
if insert_stmt.on.is_none() {
|
||||||
|
// ON CONFLICT DO UPDATE SET ...
|
||||||
|
// Aktualisiere alle Spalten außer CRDT-Spalten, wenn ein Konflikt auftritt
|
||||||
|
|
||||||
|
// Erstelle UPDATE-Assignments für alle Spalten außer CRDT-Spalten, Primary Keys und Foreign Keys
|
||||||
|
let mut assignments = Vec::new();
|
||||||
|
for column in insert_stmt.columns.iter() {
|
||||||
|
let col_name = &column.value;
|
||||||
|
|
||||||
|
// Überspringe CRDT-Spalten
|
||||||
|
if col_name == self.hlc_timestamp_column || col_name == self.tombstone_column {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Überspringe Primary Key Spalten um FOREIGN KEY Konflikte zu vermeiden
|
||||||
|
if primary_keys.contains(col_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Überspringe Foreign Key Spalten um FOREIGN KEY Konflikte zu vermeiden
|
||||||
|
// Wenn eine FK auf eine neue ID verweist, die noch nicht existiert, schlägt der Constraint fehl
|
||||||
|
if foreign_keys.contains(col_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// excluded.column_name referenziert die neuen Werte aus dem INSERT
|
||||||
|
assignments.push(Assignment {
|
||||||
|
target: AssignmentTarget::ColumnName(sqlparser::ast::ObjectName(vec![
|
||||||
|
ObjectNamePart::Identifier(column.clone()),
|
||||||
|
])),
|
||||||
|
value: Expr::CompoundIdentifier(vec![Ident::new("excluded"), column.clone()]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Füge HLC-Timestamp Update hinzu (mit dem übergebenen timestamp)
|
||||||
|
assignments.push(Assignment {
|
||||||
|
target: AssignmentTarget::ColumnName(sqlparser::ast::ObjectName(vec![ObjectNamePart::Identifier(
|
||||||
|
Ident::new(self.hlc_timestamp_column),
|
||||||
|
)])),
|
||||||
|
value: Expr::Value(Value::SingleQuotedString(timestamp.to_string()).into()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setze Tombstone auf 0 (reaktiviere den Eintrag)
|
||||||
|
assignments.push(Assignment {
|
||||||
|
target: AssignmentTarget::ColumnName(sqlparser::ast::ObjectName(vec![ObjectNamePart::Identifier(
|
||||||
|
Ident::new(self.tombstone_column),
|
||||||
|
)])),
|
||||||
|
value: Expr::Value(Value::Number("0".to_string(), false).into()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ON CONFLICT nur wenn Tombstone = 1 (Eintrag wurde gelöscht)
|
||||||
|
// Ansonsten soll der INSERT fehlschlagen (UNIQUE constraint error)
|
||||||
|
let tombstone_condition = Expr::BinaryOp {
|
||||||
|
left: Box::new(Expr::Identifier(Ident::new(self.tombstone_column))),
|
||||||
|
op: BinaryOperator::Eq,
|
||||||
|
right: Box::new(Expr::Value(Value::Number("1".to_string(), false).into())),
|
||||||
|
};
|
||||||
|
|
||||||
|
insert_stmt.on = Some(OnInsert::OnConflict(OnConflict {
|
||||||
|
conflict_target: None, // Wird auf alle UNIQUE Constraints angewendet
|
||||||
|
action: OnConflictAction::DoUpdate(sqlparser::ast::DoUpdate {
|
||||||
|
assignments,
|
||||||
|
selection: Some(tombstone_condition),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
match insert_stmt.source.as_mut() {
|
||||||
|
Some(query) => match &mut *query.body {
|
||||||
|
SetExpr::Values(values) => {
|
||||||
|
for row in &mut values.rows {
|
||||||
|
// Add haex_timestamp value
|
||||||
|
row.push(Expr::Value(
|
||||||
|
Value::SingleQuotedString(timestamp.to_string()).into(),
|
||||||
|
));
|
||||||
|
// Add haex_tombstone value (0 = not deleted)
|
||||||
|
row.push(Expr::Value(Value::Number("0".to_string(), false).into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SetExpr::Select(select) => {
|
||||||
|
let hlc_expr =
|
||||||
|
Expr::Value(Value::SingleQuotedString(timestamp.to_string()).into());
|
||||||
|
select.projection.push(SelectItem::UnnamedExpr(hlc_expr));
|
||||||
|
// Add haex_tombstone value (0 = not deleted)
|
||||||
|
let tombstone_expr = Expr::Value(Value::Number("0".to_string(), false).into());
|
||||||
|
select
|
||||||
|
.projection
|
||||||
|
.push(SelectItem::UnnamedExpr(tombstone_expr));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(DatabaseError::UnsupportedStatement {
|
||||||
|
sql: insert_stmt.to_string(),
|
||||||
|
reason: "INSERT with unsupported source type".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
return Err(DatabaseError::UnsupportedStatement {
|
||||||
|
reason: "INSERT statement has no source".to_string(),
|
||||||
|
sql: insert_stmt.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
pub mod hlc;
|
pub mod hlc;
|
||||||
|
pub mod insert_transformer;
|
||||||
|
pub mod query_transformer;
|
||||||
pub mod transformer;
|
pub mod transformer;
|
||||||
pub mod trigger;
|
pub mod trigger;
|
||||||
|
|||||||
515
src-tauri/src/crdt/query_transformer.rs
Normal file
515
src-tauri/src/crdt/query_transformer.rs
Normal file
@ -0,0 +1,515 @@
|
|||||||
|
// src-tauri/src/crdt/query_transformer.rs
|
||||||
|
// SELECT-spezifische CRDT-Transformationen (Tombstone-Filterung)
|
||||||
|
|
||||||
|
use crate::crdt::trigger::{TOMBSTONE_COLUMN};
|
||||||
|
use crate::database::error::DatabaseError;
|
||||||
|
use sqlparser::ast::{
|
||||||
|
BinaryOperator, Expr, Ident, ObjectName, SelectItem, SetExpr, TableFactor, Value,
|
||||||
|
};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
/// Helper-Struct für SELECT-Transformationen
|
||||||
|
pub struct QueryTransformer {
|
||||||
|
tombstone_column: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueryTransformer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
tombstone_column: TOMBSTONE_COLUMN,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transformiert Query-Statements (fügt Tombstone-Filter hinzu)
|
||||||
|
pub fn transform_query_recursive(
|
||||||
|
&self,
|
||||||
|
query: &mut sqlparser::ast::Query,
|
||||||
|
excluded_tables: &std::collections::HashSet<&str>,
|
||||||
|
) -> Result<(), DatabaseError> {
|
||||||
|
self.add_tombstone_filters_recursive(&mut query.body, excluded_tables)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rekursive Behandlung aller SetExpr-Typen mit vollständiger Subquery-Unterstützung
|
||||||
|
fn add_tombstone_filters_recursive(
|
||||||
|
&self,
|
||||||
|
set_expr: &mut SetExpr,
|
||||||
|
excluded_tables: &std::collections::HashSet<&str>,
|
||||||
|
) -> Result<(), DatabaseError> {
|
||||||
|
match set_expr {
|
||||||
|
SetExpr::Select(select) => {
|
||||||
|
self.add_tombstone_filters_to_select(select, excluded_tables)?;
|
||||||
|
|
||||||
|
// Transformiere auch Subqueries in Projektionen
|
||||||
|
for projection in &mut select.projection {
|
||||||
|
match projection {
|
||||||
|
SelectItem::UnnamedExpr(expr) | SelectItem::ExprWithAlias { expr, .. } => {
|
||||||
|
self.transform_expression_subqueries(expr, excluded_tables)?;
|
||||||
|
}
|
||||||
|
_ => {} // Wildcard projections ignorieren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transformiere Subqueries in WHERE
|
||||||
|
if let Some(where_clause) = &mut select.selection {
|
||||||
|
self.transform_expression_subqueries(where_clause, excluded_tables)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transformiere Subqueries in GROUP BY
|
||||||
|
match &mut select.group_by {
|
||||||
|
sqlparser::ast::GroupByExpr::All(_) => {
|
||||||
|
// GROUP BY ALL - keine Expressions zu transformieren
|
||||||
|
}
|
||||||
|
sqlparser::ast::GroupByExpr::Expressions(exprs, _) => {
|
||||||
|
for group_expr in exprs {
|
||||||
|
self.transform_expression_subqueries(group_expr, excluded_tables)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transformiere Subqueries in HAVING
|
||||||
|
if let Some(having) = &mut select.having {
|
||||||
|
self.transform_expression_subqueries(having, excluded_tables)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SetExpr::SetOperation { left, right, .. } => {
|
||||||
|
self.add_tombstone_filters_recursive(left, excluded_tables)?;
|
||||||
|
self.add_tombstone_filters_recursive(right, excluded_tables)?;
|
||||||
|
}
|
||||||
|
SetExpr::Query(query) => {
|
||||||
|
self.add_tombstone_filters_recursive(&mut query.body, excluded_tables)?;
|
||||||
|
}
|
||||||
|
SetExpr::Values(values) => {
|
||||||
|
// Transformiere auch Subqueries in Values-Listen
|
||||||
|
for row in &mut values.rows {
|
||||||
|
for expr in row {
|
||||||
|
self.transform_expression_subqueries(expr, excluded_tables)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {} // Andere Fälle
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transformiert Subqueries innerhalb von Expressions
|
||||||
|
fn transform_expression_subqueries(
|
||||||
|
&self,
|
||||||
|
expr: &mut Expr,
|
||||||
|
excluded_tables: &std::collections::HashSet<&str>,
|
||||||
|
) -> Result<(), DatabaseError> {
|
||||||
|
match expr {
|
||||||
|
// Einfache Subqueries
|
||||||
|
Expr::Subquery(query) => {
|
||||||
|
self.add_tombstone_filters_recursive(&mut query.body, excluded_tables)?;
|
||||||
|
}
|
||||||
|
// EXISTS Subqueries
|
||||||
|
Expr::Exists { subquery, .. } => {
|
||||||
|
self.add_tombstone_filters_recursive(&mut subquery.body, excluded_tables)?;
|
||||||
|
}
|
||||||
|
// IN Subqueries
|
||||||
|
Expr::InSubquery {
|
||||||
|
expr: left_expr,
|
||||||
|
subquery,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
self.transform_expression_subqueries(left_expr, excluded_tables)?;
|
||||||
|
self.add_tombstone_filters_recursive(&mut subquery.body, excluded_tables)?;
|
||||||
|
}
|
||||||
|
// ANY/ALL Subqueries
|
||||||
|
Expr::AnyOp { left, right, .. } | Expr::AllOp { left, right, .. } => {
|
||||||
|
self.transform_expression_subqueries(left, excluded_tables)?;
|
||||||
|
self.transform_expression_subqueries(right, excluded_tables)?;
|
||||||
|
}
|
||||||
|
// Binäre Operationen
|
||||||
|
Expr::BinaryOp { left, right, .. } => {
|
||||||
|
self.transform_expression_subqueries(left, excluded_tables)?;
|
||||||
|
self.transform_expression_subqueries(right, excluded_tables)?;
|
||||||
|
}
|
||||||
|
// Unäre Operationen
|
||||||
|
Expr::UnaryOp {
|
||||||
|
expr: inner_expr, ..
|
||||||
|
} => {
|
||||||
|
self.transform_expression_subqueries(inner_expr, excluded_tables)?;
|
||||||
|
}
|
||||||
|
// Verschachtelte Ausdrücke
|
||||||
|
Expr::Nested(nested) => {
|
||||||
|
self.transform_expression_subqueries(nested, excluded_tables)?;
|
||||||
|
}
|
||||||
|
// CASE-Ausdrücke
|
||||||
|
Expr::Case {
|
||||||
|
operand,
|
||||||
|
conditions,
|
||||||
|
else_result,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if let Some(op) = operand {
|
||||||
|
self.transform_expression_subqueries(op, excluded_tables)?;
|
||||||
|
}
|
||||||
|
for case_when in conditions {
|
||||||
|
self.transform_expression_subqueries(&mut case_when.condition, excluded_tables)?;
|
||||||
|
self.transform_expression_subqueries(&mut case_when.result, excluded_tables)?;
|
||||||
|
}
|
||||||
|
if let Some(else_res) = else_result {
|
||||||
|
self.transform_expression_subqueries(else_res, excluded_tables)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Funktionsaufrufe
|
||||||
|
Expr::Function(func) => match &mut func.args {
|
||||||
|
sqlparser::ast::FunctionArguments::List(sqlparser::ast::FunctionArgumentList {
|
||||||
|
args,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
for arg in args {
|
||||||
|
if let sqlparser::ast::FunctionArg::Unnamed(
|
||||||
|
sqlparser::ast::FunctionArgExpr::Expr(expr),
|
||||||
|
) = arg
|
||||||
|
{
|
||||||
|
self.transform_expression_subqueries(expr, excluded_tables)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
// BETWEEN
|
||||||
|
Expr::Between {
|
||||||
|
expr: main_expr,
|
||||||
|
low,
|
||||||
|
high,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
self.transform_expression_subqueries(main_expr, excluded_tables)?;
|
||||||
|
self.transform_expression_subqueries(low, excluded_tables)?;
|
||||||
|
self.transform_expression_subqueries(high, excluded_tables)?;
|
||||||
|
}
|
||||||
|
// IN Liste
|
||||||
|
Expr::InList {
|
||||||
|
expr: main_expr,
|
||||||
|
list,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
self.transform_expression_subqueries(main_expr, excluded_tables)?;
|
||||||
|
for list_expr in list {
|
||||||
|
self.transform_expression_subqueries(list_expr, excluded_tables)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// IS NULL/IS NOT NULL
|
||||||
|
Expr::IsNull(inner) | Expr::IsNotNull(inner) => {
|
||||||
|
self.transform_expression_subqueries(inner, excluded_tables)?;
|
||||||
|
}
|
||||||
|
// Andere Expression-Typen benötigen keine Transformation
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erstellt einen Tombstone-Filter für eine Tabelle
|
||||||
|
pub fn create_tombstone_filter(&self, table_alias: Option<&str>) -> Expr {
|
||||||
|
let column_expr = match table_alias {
|
||||||
|
Some(alias) => {
|
||||||
|
Expr::CompoundIdentifier(vec![Ident::new(alias), Ident::new(self.tombstone_column)])
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
Expr::Identifier(Ident::new(self.tombstone_column))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Expr::BinaryOp {
|
||||||
|
left: Box::new(column_expr),
|
||||||
|
op: BinaryOperator::NotEq,
|
||||||
|
right: Box::new(Expr::Value(Value::Number("1".to_string(), false).into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalisiert Tabellennamen (entfernt Anführungszeichen)
|
||||||
|
pub fn normalize_table_name(&self, name: &ObjectName) -> String {
|
||||||
|
let name_str = name.to_string().to_lowercase();
|
||||||
|
name_str.trim_matches('`').trim_matches('"').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fügt Tombstone-Filter zu SELECT-Statements hinzu
|
||||||
|
pub fn add_tombstone_filters_to_select(
|
||||||
|
&self,
|
||||||
|
select: &mut sqlparser::ast::Select,
|
||||||
|
excluded_tables: &HashSet<&str>,
|
||||||
|
) -> Result<(), DatabaseError> {
|
||||||
|
// Sammle alle CRDT-Tabellen mit ihren Aliasen
|
||||||
|
let mut crdt_tables = Vec::new();
|
||||||
|
for twj in &select.from {
|
||||||
|
if let TableFactor::Table { name, alias, .. } = &twj.relation {
|
||||||
|
let table_name_str = self.normalize_table_name(name);
|
||||||
|
if !excluded_tables.contains(table_name_str.as_str()) {
|
||||||
|
let table_alias = alias.as_ref().map(|a| a.name.value.as_str());
|
||||||
|
crdt_tables.push((name.clone(), table_alias));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if crdt_tables.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe, welche Tombstone-Spalten bereits in der WHERE-Klausel referenziert werden
|
||||||
|
let explicitly_filtered_tables = if let Some(where_clause) = &select.selection {
|
||||||
|
self.find_explicitly_filtered_tombstone_tables(where_clause, &crdt_tables)
|
||||||
|
} else {
|
||||||
|
HashSet::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Erstelle Filter nur für Tabellen, die noch nicht explizit gefiltert werden
|
||||||
|
let mut tombstone_filters = Vec::new();
|
||||||
|
for (table_name, table_alias) in crdt_tables {
|
||||||
|
let table_name_string = table_name.to_string();
|
||||||
|
let table_key = table_alias.unwrap_or(&table_name_string);
|
||||||
|
if !explicitly_filtered_tables.contains(table_key) {
|
||||||
|
tombstone_filters.push(self.create_tombstone_filter(table_alias));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Füge die automatischen Filter hinzu
|
||||||
|
if !tombstone_filters.is_empty() {
|
||||||
|
let combined_filter = tombstone_filters
|
||||||
|
.into_iter()
|
||||||
|
.reduce(|acc, expr| Expr::BinaryOp {
|
||||||
|
left: Box::new(acc),
|
||||||
|
op: BinaryOperator::And,
|
||||||
|
right: Box::new(expr),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match &mut select.selection {
|
||||||
|
Some(existing) => {
|
||||||
|
*existing = Expr::BinaryOp {
|
||||||
|
left: Box::new(existing.clone()),
|
||||||
|
op: BinaryOperator::And,
|
||||||
|
right: Box::new(combined_filter),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
select.selection = Some(combined_filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Findet alle Tabellen, die bereits explizit Tombstone-Filter in der WHERE-Klausel haben
|
||||||
|
fn find_explicitly_filtered_tombstone_tables(
|
||||||
|
&self,
|
||||||
|
where_expr: &Expr,
|
||||||
|
crdt_tables: &[(ObjectName, Option<&str>)],
|
||||||
|
) -> HashSet<String> {
|
||||||
|
let mut filtered_tables = HashSet::new();
|
||||||
|
self.scan_expression_for_tombstone_references(
|
||||||
|
where_expr,
|
||||||
|
crdt_tables,
|
||||||
|
&mut filtered_tables,
|
||||||
|
);
|
||||||
|
filtered_tables
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rekursiv durchsucht einen Expression-Baum nach Tombstone-Spalten-Referenzen
|
||||||
|
fn scan_expression_for_tombstone_references(
|
||||||
|
&self,
|
||||||
|
expr: &Expr,
|
||||||
|
crdt_tables: &[(ObjectName, Option<&str>)],
|
||||||
|
filtered_tables: &mut HashSet<String>,
|
||||||
|
) {
|
||||||
|
match expr {
|
||||||
|
Expr::Identifier(ident) => {
|
||||||
|
if ident.value == self.tombstone_column && crdt_tables.len() == 1 {
|
||||||
|
let table_name_str = crdt_tables[0].0.to_string();
|
||||||
|
let table_key = crdt_tables[0].1.unwrap_or(&table_name_str);
|
||||||
|
filtered_tables.insert(table_key.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::CompoundIdentifier(idents) => {
|
||||||
|
if idents.len() == 2 && idents[1].value == self.tombstone_column {
|
||||||
|
let table_ref = &idents[0].value;
|
||||||
|
for (table_name, alias) in crdt_tables {
|
||||||
|
let table_name_str = table_name.to_string();
|
||||||
|
if table_ref == &table_name_str || alias.map_or(false, |a| a == table_ref) {
|
||||||
|
filtered_tables.insert(table_ref.clone());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::BinaryOp { left, right, .. } => {
|
||||||
|
self.scan_expression_for_tombstone_references(left, crdt_tables, filtered_tables);
|
||||||
|
self.scan_expression_for_tombstone_references(right, crdt_tables, filtered_tables);
|
||||||
|
}
|
||||||
|
Expr::UnaryOp { expr, .. } => {
|
||||||
|
self.scan_expression_for_tombstone_references(expr, crdt_tables, filtered_tables);
|
||||||
|
}
|
||||||
|
Expr::Nested(nested) => {
|
||||||
|
self.scan_expression_for_tombstone_references(nested, crdt_tables, filtered_tables);
|
||||||
|
}
|
||||||
|
Expr::InList { expr, .. } => {
|
||||||
|
self.scan_expression_for_tombstone_references(expr, crdt_tables, filtered_tables);
|
||||||
|
}
|
||||||
|
Expr::Between { expr, .. } => {
|
||||||
|
self.scan_expression_for_tombstone_references(expr, crdt_tables, filtered_tables);
|
||||||
|
}
|
||||||
|
Expr::IsNull(expr) | Expr::IsNotNull(expr) => {
|
||||||
|
self.scan_expression_for_tombstone_references(expr, crdt_tables, filtered_tables);
|
||||||
|
}
|
||||||
|
Expr::Function(func) => {
|
||||||
|
if let sqlparser::ast::FunctionArguments::List(
|
||||||
|
sqlparser::ast::FunctionArgumentList { args, .. },
|
||||||
|
) = &func.args
|
||||||
|
{
|
||||||
|
for arg in args {
|
||||||
|
if let sqlparser::ast::FunctionArg::Unnamed(
|
||||||
|
sqlparser::ast::FunctionArgExpr::Expr(expr),
|
||||||
|
) = arg
|
||||||
|
{
|
||||||
|
self.scan_expression_for_tombstone_references(
|
||||||
|
expr,
|
||||||
|
crdt_tables,
|
||||||
|
filtered_tables,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Case {
|
||||||
|
operand,
|
||||||
|
conditions,
|
||||||
|
else_result,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if let Some(op) = operand {
|
||||||
|
self.scan_expression_for_tombstone_references(op, crdt_tables, filtered_tables);
|
||||||
|
}
|
||||||
|
for case_when in conditions {
|
||||||
|
self.scan_expression_for_tombstone_references(
|
||||||
|
&case_when.condition,
|
||||||
|
crdt_tables,
|
||||||
|
filtered_tables,
|
||||||
|
);
|
||||||
|
self.scan_expression_for_tombstone_references(
|
||||||
|
&case_when.result,
|
||||||
|
crdt_tables,
|
||||||
|
filtered_tables,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(else_res) = else_result {
|
||||||
|
self.scan_expression_for_tombstone_references(
|
||||||
|
else_res,
|
||||||
|
crdt_tables,
|
||||||
|
filtered_tables,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Subquery(query) => {
|
||||||
|
self.analyze_query_for_tombstone_references(query, crdt_tables, filtered_tables)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
Expr::Exists { subquery, .. } => {
|
||||||
|
self.analyze_query_for_tombstone_references(subquery, crdt_tables, filtered_tables)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
Expr::InSubquery { expr, subquery, .. } => {
|
||||||
|
self.scan_expression_for_tombstone_references(expr, crdt_tables, filtered_tables);
|
||||||
|
self.analyze_query_for_tombstone_references(subquery, crdt_tables, filtered_tables)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
Expr::AnyOp { left, right, .. } | Expr::AllOp { left, right, .. } => {
|
||||||
|
self.scan_expression_for_tombstone_references(left, crdt_tables, filtered_tables);
|
||||||
|
self.scan_expression_for_tombstone_references(right, crdt_tables, filtered_tables);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn analyze_query_for_tombstone_references(
|
||||||
|
&self,
|
||||||
|
query: &sqlparser::ast::Query,
|
||||||
|
crdt_tables: &[(ObjectName, Option<&str>)],
|
||||||
|
filtered_tables: &mut HashSet<String>,
|
||||||
|
) -> Result<(), DatabaseError> {
|
||||||
|
self.analyze_set_expr_for_tombstone_references(&query.body, crdt_tables, filtered_tables)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn analyze_set_expr_for_tombstone_references(
|
||||||
|
&self,
|
||||||
|
set_expr: &SetExpr,
|
||||||
|
crdt_tables: &[(ObjectName, Option<&str>)],
|
||||||
|
filtered_tables: &mut HashSet<String>,
|
||||||
|
) -> Result<(), DatabaseError> {
|
||||||
|
match set_expr {
|
||||||
|
SetExpr::Select(select) => {
|
||||||
|
if let Some(where_clause) = &select.selection {
|
||||||
|
self.scan_expression_for_tombstone_references(
|
||||||
|
where_clause,
|
||||||
|
crdt_tables,
|
||||||
|
filtered_tables,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for projection in &select.projection {
|
||||||
|
match projection {
|
||||||
|
SelectItem::UnnamedExpr(expr) | SelectItem::ExprWithAlias { expr, .. } => {
|
||||||
|
self.scan_expression_for_tombstone_references(
|
||||||
|
expr,
|
||||||
|
crdt_tables,
|
||||||
|
filtered_tables,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match &select.group_by {
|
||||||
|
sqlparser::ast::GroupByExpr::All(_) => {}
|
||||||
|
sqlparser::ast::GroupByExpr::Expressions(exprs, _) => {
|
||||||
|
for group_expr in exprs {
|
||||||
|
self.scan_expression_for_tombstone_references(
|
||||||
|
group_expr,
|
||||||
|
crdt_tables,
|
||||||
|
filtered_tables,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(having) = &select.having {
|
||||||
|
self.scan_expression_for_tombstone_references(
|
||||||
|
having,
|
||||||
|
crdt_tables,
|
||||||
|
filtered_tables,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SetExpr::SetOperation { left, right, .. } => {
|
||||||
|
self.analyze_set_expr_for_tombstone_references(left, crdt_tables, filtered_tables)?;
|
||||||
|
self.analyze_set_expr_for_tombstone_references(
|
||||||
|
right,
|
||||||
|
crdt_tables,
|
||||||
|
filtered_tables,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
SetExpr::Query(query) => {
|
||||||
|
self.analyze_set_expr_for_tombstone_references(
|
||||||
|
&query.body,
|
||||||
|
crdt_tables,
|
||||||
|
filtered_tables,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
SetExpr::Values(values) => {
|
||||||
|
for row in &values.rows {
|
||||||
|
for expr in row {
|
||||||
|
self.scan_expression_for_tombstone_references(
|
||||||
|
expr,
|
||||||
|
crdt_tables,
|
||||||
|
filtered_tables,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,9 +1,12 @@
|
|||||||
|
use crate::crdt::insert_transformer::InsertTransformer;
|
||||||
|
use crate::crdt::query_transformer::QueryTransformer;
|
||||||
use crate::crdt::trigger::{HLC_TIMESTAMP_COLUMN, TOMBSTONE_COLUMN};
|
use crate::crdt::trigger::{HLC_TIMESTAMP_COLUMN, TOMBSTONE_COLUMN};
|
||||||
use crate::database::error::DatabaseError;
|
use crate::database::error::DatabaseError;
|
||||||
use crate::table_names::{TABLE_CRDT_CONFIGS, TABLE_CRDT_LOGS};
|
use crate::table_names::{TABLE_CRDT_CONFIGS, TABLE_CRDT_LOGS};
|
||||||
use sqlparser::ast::{
|
use sqlparser::ast::{
|
||||||
Assignment, AssignmentTarget, BinaryOperator, ColumnDef, DataType, Expr, Ident, Insert,
|
Assignment, AssignmentTarget, BinaryOperator, ColumnDef, DataType, Expr, Ident,
|
||||||
ObjectName, ObjectNamePart, SelectItem, SetExpr, Statement, TableFactor, TableObject, Value,
|
ObjectName, ObjectNamePart, Statement, TableFactor, TableObject,
|
||||||
|
Value,
|
||||||
};
|
};
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
@ -112,7 +115,10 @@ impl CrdtTransformer {
|
|||||||
|
|
||||||
pub fn transform_select_statement(&self, stmt: &mut Statement) -> Result<(), DatabaseError> {
|
pub fn transform_select_statement(&self, stmt: &mut Statement) -> Result<(), DatabaseError> {
|
||||||
match stmt {
|
match stmt {
|
||||||
Statement::Query(query) => self.transform_query_recursive(query),
|
Statement::Query(query) => {
|
||||||
|
let query_transformer = QueryTransformer::new();
|
||||||
|
query_transformer.transform_query_recursive(query, &self.excluded_tables)
|
||||||
|
}
|
||||||
// Fange alle anderen Fälle ab und gib einen Fehler zurück
|
// Fange alle anderen Fälle ab und gib einen Fehler zurück
|
||||||
_ => Err(DatabaseError::UnsupportedStatement {
|
_ => Err(DatabaseError::UnsupportedStatement {
|
||||||
sql: stmt.to_string(),
|
sql: stmt.to_string(),
|
||||||
@ -121,10 +127,12 @@ impl CrdtTransformer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn transform_execute_statement(
|
/// Transformiert Statements MIT Zugriff auf Tabelleninformationen (empfohlen)
|
||||||
|
pub fn transform_execute_statement_with_table_info(
|
||||||
&self,
|
&self,
|
||||||
stmt: &mut Statement,
|
stmt: &mut Statement,
|
||||||
hlc_timestamp: &Timestamp,
|
hlc_timestamp: &Timestamp,
|
||||||
|
tx: &rusqlite::Transaction,
|
||||||
) -> Result<Option<String>, DatabaseError> {
|
) -> Result<Option<String>, DatabaseError> {
|
||||||
match stmt {
|
match stmt {
|
||||||
Statement::CreateTable(create_table) => {
|
Statement::CreateTable(create_table) => {
|
||||||
@ -141,7 +149,100 @@ impl CrdtTransformer {
|
|||||||
Statement::Insert(insert_stmt) => {
|
Statement::Insert(insert_stmt) => {
|
||||||
if let TableObject::TableName(name) = &insert_stmt.table {
|
if let TableObject::TableName(name) = &insert_stmt.table {
|
||||||
if self.is_crdt_sync_table(name) {
|
if self.is_crdt_sync_table(name) {
|
||||||
self.transform_insert(insert_stmt, hlc_timestamp)?;
|
// Hole die Tabelleninformationen um PKs und FKs zu identifizieren
|
||||||
|
let table_name_str = self.normalize_table_name(name);
|
||||||
|
|
||||||
|
let columns = crate::crdt::trigger::get_table_schema(tx, &table_name_str)
|
||||||
|
.map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: format!("PRAGMA table_info('{}')", table_name_str),
|
||||||
|
reason: e.to_string(),
|
||||||
|
table: Some(table_name_str.to_string()),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let primary_keys: Vec<String> = columns
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.is_pk)
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let foreign_keys = crate::crdt::trigger::get_foreign_key_columns(tx, &table_name_str)
|
||||||
|
.map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: format!("PRAGMA foreign_key_list('{}')", table_name_str),
|
||||||
|
reason: e.to_string(),
|
||||||
|
table: Some(table_name_str.to_string()),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let insert_transformer = InsertTransformer::new();
|
||||||
|
insert_transformer.transform_insert(insert_stmt, hlc_timestamp, &primary_keys, &foreign_keys)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Statement::Update {
|
||||||
|
table, assignments, ..
|
||||||
|
} => {
|
||||||
|
if let TableFactor::Table { name, .. } = &table.relation {
|
||||||
|
if self.is_crdt_sync_table(name) {
|
||||||
|
assignments.push(self.columns.create_hlc_assignment(hlc_timestamp));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Statement::Delete(del_stmt) => {
|
||||||
|
if let Some(table_name) = self.extract_table_name_from_delete(del_stmt) {
|
||||||
|
let table_name_str = self.normalize_table_name(&table_name);
|
||||||
|
let is_crdt = self.is_crdt_sync_table(&table_name);
|
||||||
|
eprintln!("DEBUG DELETE (with_table_info): table='{}', is_crdt_sync={}, normalized='{}'",
|
||||||
|
table_name, is_crdt, table_name_str);
|
||||||
|
if is_crdt {
|
||||||
|
eprintln!("DEBUG: Transforming DELETE to UPDATE for table '{}'", table_name_str);
|
||||||
|
self.transform_delete_to_update(stmt, hlc_timestamp)?;
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Err(DatabaseError::UnsupportedStatement {
|
||||||
|
sql: del_stmt.to_string(),
|
||||||
|
reason: "DELETE from non-table source or multiple tables".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Statement::AlterTable { name, .. } => {
|
||||||
|
if self.is_crdt_sync_table(name) {
|
||||||
|
Ok(Some(self.normalize_table_name(name).into_owned()))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transform_execute_statement(
|
||||||
|
&self,
|
||||||
|
stmt: &mut Statement,
|
||||||
|
hlc_timestamp: &Timestamp,
|
||||||
|
) -> Result<Option<String>, DatabaseError> {
|
||||||
|
// Für INSERT-Statements ohne Connection nutzen wir eine leere PK-Liste
|
||||||
|
// Das bedeutet ALLE Spalten werden im ON CONFLICT UPDATE gesetzt
|
||||||
|
// Dies ist ein Fallback für den Fall, dass keine Connection verfügbar ist
|
||||||
|
match stmt {
|
||||||
|
Statement::CreateTable(create_table) => {
|
||||||
|
if self.is_crdt_sync_table(&create_table.name) {
|
||||||
|
self.columns
|
||||||
|
.add_to_table_definition(&mut create_table.columns);
|
||||||
|
Ok(Some(
|
||||||
|
self.normalize_table_name(&create_table.name).into_owned(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Statement::Insert(insert_stmt) => {
|
||||||
|
if let TableObject::TableName(name) = &insert_stmt.table {
|
||||||
|
if self.is_crdt_sync_table(name) {
|
||||||
|
// Ohne Connection: leere PK- und FK-Listen (alle Spalten werden upgedatet)
|
||||||
|
let insert_transformer = InsertTransformer::new();
|
||||||
|
insert_transformer.transform_insert(insert_stmt, hlc_timestamp, &[], &[])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@ -180,560 +281,6 @@ impl CrdtTransformer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transformiert Query-Statements (fügt Tombstone-Filter hinzu)
|
|
||||||
fn transform_query_recursive(
|
|
||||||
&self,
|
|
||||||
query: &mut sqlparser::ast::Query,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.add_tombstone_filters_recursive(&mut query.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rekursive Behandlung aller SetExpr-Typen mit vollständiger Subquery-Unterstützung
|
|
||||||
fn add_tombstone_filters_recursive(&self, set_expr: &mut SetExpr) -> Result<(), DatabaseError> {
|
|
||||||
match set_expr {
|
|
||||||
SetExpr::Select(select) => {
|
|
||||||
self.add_tombstone_filters_to_select(select)?;
|
|
||||||
|
|
||||||
// Transformiere auch Subqueries in Projektionen
|
|
||||||
for projection in &mut select.projection {
|
|
||||||
match projection {
|
|
||||||
SelectItem::UnnamedExpr(expr) | SelectItem::ExprWithAlias { expr, .. } => {
|
|
||||||
self.transform_expression_subqueries(expr)?;
|
|
||||||
}
|
|
||||||
_ => {} // Wildcard projections ignorieren
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transformiere Subqueries in WHERE
|
|
||||||
if let Some(where_clause) = &mut select.selection {
|
|
||||||
self.transform_expression_subqueries(where_clause)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transformiere Subqueries in GROUP BY
|
|
||||||
match &mut select.group_by {
|
|
||||||
sqlparser::ast::GroupByExpr::All(_) => {
|
|
||||||
// GROUP BY ALL - keine Expressions zu transformieren
|
|
||||||
}
|
|
||||||
sqlparser::ast::GroupByExpr::Expressions(exprs, _) => {
|
|
||||||
for group_expr in exprs {
|
|
||||||
self.transform_expression_subqueries(group_expr)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transformiere Subqueries in HAVING
|
|
||||||
if let Some(having) = &mut select.having {
|
|
||||||
self.transform_expression_subqueries(having)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SetExpr::SetOperation { left, right, .. } => {
|
|
||||||
self.add_tombstone_filters_recursive(left)?;
|
|
||||||
self.add_tombstone_filters_recursive(right)?;
|
|
||||||
}
|
|
||||||
SetExpr::Query(query) => {
|
|
||||||
self.add_tombstone_filters_recursive(&mut query.body)?;
|
|
||||||
}
|
|
||||||
SetExpr::Values(values) => {
|
|
||||||
// Transformiere auch Subqueries in Values-Listen
|
|
||||||
for row in &mut values.rows {
|
|
||||||
for expr in row {
|
|
||||||
self.transform_expression_subqueries(expr)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {} // Andere Fälle
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transformiert Subqueries innerhalb von Expressions
|
|
||||||
fn transform_expression_subqueries(&self, expr: &mut Expr) -> Result<(), DatabaseError> {
|
|
||||||
match expr {
|
|
||||||
// Einfache Subqueries
|
|
||||||
Expr::Subquery(query) => {
|
|
||||||
self.add_tombstone_filters_recursive(&mut query.body)?;
|
|
||||||
}
|
|
||||||
// EXISTS Subqueries
|
|
||||||
Expr::Exists { subquery, .. } => {
|
|
||||||
self.add_tombstone_filters_recursive(&mut subquery.body)?;
|
|
||||||
}
|
|
||||||
// IN Subqueries
|
|
||||||
Expr::InSubquery {
|
|
||||||
expr: left_expr,
|
|
||||||
subquery,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
self.transform_expression_subqueries(left_expr)?;
|
|
||||||
self.add_tombstone_filters_recursive(&mut subquery.body)?;
|
|
||||||
}
|
|
||||||
// ANY/ALL Subqueries
|
|
||||||
Expr::AnyOp { left, right, .. } | Expr::AllOp { left, right, .. } => {
|
|
||||||
self.transform_expression_subqueries(left)?;
|
|
||||||
self.transform_expression_subqueries(right)?;
|
|
||||||
}
|
|
||||||
// Binäre Operationen
|
|
||||||
Expr::BinaryOp { left, right, .. } => {
|
|
||||||
self.transform_expression_subqueries(left)?;
|
|
||||||
self.transform_expression_subqueries(right)?;
|
|
||||||
}
|
|
||||||
// Unäre Operationen
|
|
||||||
Expr::UnaryOp {
|
|
||||||
expr: inner_expr, ..
|
|
||||||
} => {
|
|
||||||
self.transform_expression_subqueries(inner_expr)?;
|
|
||||||
}
|
|
||||||
// Verschachtelte Ausdrücke
|
|
||||||
Expr::Nested(nested) => {
|
|
||||||
self.transform_expression_subqueries(nested)?;
|
|
||||||
}
|
|
||||||
// CASE-Ausdrücke
|
|
||||||
Expr::Case {
|
|
||||||
operand,
|
|
||||||
conditions,
|
|
||||||
else_result,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
if let Some(op) = operand {
|
|
||||||
self.transform_expression_subqueries(op)?;
|
|
||||||
}
|
|
||||||
for case_when in conditions {
|
|
||||||
self.transform_expression_subqueries(&mut case_when.condition)?;
|
|
||||||
self.transform_expression_subqueries(&mut case_when.result)?;
|
|
||||||
}
|
|
||||||
if let Some(else_res) = else_result {
|
|
||||||
self.transform_expression_subqueries(else_res)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Funktionsaufrufe
|
|
||||||
Expr::Function(func) => match &mut func.args {
|
|
||||||
sqlparser::ast::FunctionArguments::List(sqlparser::ast::FunctionArgumentList {
|
|
||||||
args,
|
|
||||||
..
|
|
||||||
}) => {
|
|
||||||
for arg in args {
|
|
||||||
if let sqlparser::ast::FunctionArg::Unnamed(
|
|
||||||
sqlparser::ast::FunctionArgExpr::Expr(expr),
|
|
||||||
) = arg
|
|
||||||
{
|
|
||||||
self.transform_expression_subqueries(expr)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
// BETWEEN
|
|
||||||
Expr::Between {
|
|
||||||
expr: main_expr,
|
|
||||||
low,
|
|
||||||
high,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
self.transform_expression_subqueries(main_expr)?;
|
|
||||||
self.transform_expression_subqueries(low)?;
|
|
||||||
self.transform_expression_subqueries(high)?;
|
|
||||||
}
|
|
||||||
// IN Liste
|
|
||||||
Expr::InList {
|
|
||||||
expr: main_expr,
|
|
||||||
list,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
self.transform_expression_subqueries(main_expr)?;
|
|
||||||
for list_expr in list {
|
|
||||||
self.transform_expression_subqueries(list_expr)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// IS NULL/IS NOT NULL
|
|
||||||
Expr::IsNull(inner) | Expr::IsNotNull(inner) => {
|
|
||||||
self.transform_expression_subqueries(inner)?;
|
|
||||||
}
|
|
||||||
// Andere Expression-Typen benötigen keine Transformation
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fügt Tombstone-Filter zu SELECT-Statements hinzu (nur wenn nicht explizit in WHERE gesetzt)
|
|
||||||
fn add_tombstone_filters_to_select(
|
|
||||||
&self,
|
|
||||||
select: &mut sqlparser::ast::Select,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
// Sammle alle CRDT-Tabellen mit ihren Aliasen
|
|
||||||
let mut crdt_tables = Vec::new();
|
|
||||||
for twj in &select.from {
|
|
||||||
if let TableFactor::Table { name, alias, .. } = &twj.relation {
|
|
||||||
if self.is_crdt_sync_table(name) {
|
|
||||||
let table_alias = alias.as_ref().map(|a| a.name.value.as_str());
|
|
||||||
crdt_tables.push((name.clone(), table_alias));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if crdt_tables.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prüfe, welche Tombstone-Spalten bereits in der WHERE-Klausel referenziert werden
|
|
||||||
let explicitly_filtered_tables = if let Some(where_clause) = &select.selection {
|
|
||||||
self.find_explicitly_filtered_tombstone_tables(where_clause, &crdt_tables)
|
|
||||||
} else {
|
|
||||||
HashSet::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Erstelle Filter nur für Tabellen, die noch nicht explizit gefiltert werden
|
|
||||||
let mut tombstone_filters = Vec::new();
|
|
||||||
for (table_name, table_alias) in crdt_tables {
|
|
||||||
let table_name_string = table_name.to_string();
|
|
||||||
let table_key = table_alias.unwrap_or(&table_name_string);
|
|
||||||
if !explicitly_filtered_tables.contains(table_key) {
|
|
||||||
tombstone_filters.push(self.columns.create_tombstone_filter(table_alias));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Füge die automatischen Filter hinzu
|
|
||||||
if !tombstone_filters.is_empty() {
|
|
||||||
let combined_filter = tombstone_filters
|
|
||||||
.into_iter()
|
|
||||||
.reduce(|acc, expr| Expr::BinaryOp {
|
|
||||||
left: Box::new(acc),
|
|
||||||
op: BinaryOperator::And,
|
|
||||||
right: Box::new(expr),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
match &mut select.selection {
|
|
||||||
Some(existing) => {
|
|
||||||
*existing = Expr::BinaryOp {
|
|
||||||
left: Box::new(existing.clone()),
|
|
||||||
op: BinaryOperator::And,
|
|
||||||
right: Box::new(combined_filter),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
select.selection = Some(combined_filter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Findet alle Tabellen, die bereits explizit Tombstone-Filter in der WHERE-Klausel haben
|
|
||||||
fn find_explicitly_filtered_tombstone_tables(
|
|
||||||
&self,
|
|
||||||
where_expr: &Expr,
|
|
||||||
crdt_tables: &[(ObjectName, Option<&str>)],
|
|
||||||
) -> HashSet<String> {
|
|
||||||
let mut filtered_tables = HashSet::new();
|
|
||||||
self.scan_expression_for_tombstone_references(
|
|
||||||
where_expr,
|
|
||||||
crdt_tables,
|
|
||||||
&mut filtered_tables,
|
|
||||||
);
|
|
||||||
filtered_tables
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rekursiv durchsucht einen Expression-Baum nach Tombstone-Spalten-Referenzen
|
|
||||||
fn scan_expression_for_tombstone_references(
|
|
||||||
&self,
|
|
||||||
expr: &Expr,
|
|
||||||
crdt_tables: &[(ObjectName, Option<&str>)],
|
|
||||||
filtered_tables: &mut HashSet<String>,
|
|
||||||
) {
|
|
||||||
match expr {
|
|
||||||
// Einfache Spaltenreferenz: tombstone = ?
|
|
||||||
Expr::Identifier(ident) => {
|
|
||||||
if ident.value == self.columns.tombstone {
|
|
||||||
// Wenn keine Tabelle spezifiziert ist und es nur eine CRDT-Tabelle gibt
|
|
||||||
if crdt_tables.len() == 1 {
|
|
||||||
let table_name_str = crdt_tables[0].0.to_string();
|
|
||||||
let table_key = crdt_tables[0].1.unwrap_or(&table_name_str);
|
|
||||||
filtered_tables.insert(table_key.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Qualifizierte Spaltenreferenz: table.tombstone = ? oder alias.tombstone = ?
|
|
||||||
Expr::CompoundIdentifier(idents) => {
|
|
||||||
if idents.len() == 2 && idents[1].value == self.columns.tombstone {
|
|
||||||
let table_ref = &idents[0].value;
|
|
||||||
|
|
||||||
// Prüfe, ob es eine unserer CRDT-Tabellen ist (nach Name oder Alias)
|
|
||||||
for (table_name, alias) in crdt_tables {
|
|
||||||
let table_name_str = table_name.to_string();
|
|
||||||
if table_ref == &table_name_str || alias.map_or(false, |a| a == table_ref) {
|
|
||||||
filtered_tables.insert(table_ref.clone());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Binäre Operationen: AND, OR, etc.
|
|
||||||
Expr::BinaryOp { left, right, .. } => {
|
|
||||||
self.scan_expression_for_tombstone_references(left, crdt_tables, filtered_tables);
|
|
||||||
self.scan_expression_for_tombstone_references(right, crdt_tables, filtered_tables);
|
|
||||||
}
|
|
||||||
// Unäre Operationen: NOT, etc.
|
|
||||||
Expr::UnaryOp { expr, .. } => {
|
|
||||||
self.scan_expression_for_tombstone_references(expr, crdt_tables, filtered_tables);
|
|
||||||
}
|
|
||||||
// Verschachtelte Ausdrücke
|
|
||||||
Expr::Nested(nested) => {
|
|
||||||
self.scan_expression_for_tombstone_references(nested, crdt_tables, filtered_tables);
|
|
||||||
}
|
|
||||||
// IN-Klauseln
|
|
||||||
Expr::InList { expr, .. } => {
|
|
||||||
self.scan_expression_for_tombstone_references(expr, crdt_tables, filtered_tables);
|
|
||||||
}
|
|
||||||
// BETWEEN-Klauseln
|
|
||||||
Expr::Between { expr, .. } => {
|
|
||||||
self.scan_expression_for_tombstone_references(expr, crdt_tables, filtered_tables);
|
|
||||||
}
|
|
||||||
// IS NULL/IS NOT NULL
|
|
||||||
Expr::IsNull(expr) | Expr::IsNotNull(expr) => {
|
|
||||||
self.scan_expression_for_tombstone_references(expr, crdt_tables, filtered_tables);
|
|
||||||
}
|
|
||||||
// Funktionsaufrufe - KORRIGIERT
|
|
||||||
Expr::Function(func) => {
|
|
||||||
match &func.args {
|
|
||||||
sqlparser::ast::FunctionArguments::List(
|
|
||||||
sqlparser::ast::FunctionArgumentList { args, .. },
|
|
||||||
) => {
|
|
||||||
for arg in args {
|
|
||||||
if let sqlparser::ast::FunctionArg::Unnamed(
|
|
||||||
sqlparser::ast::FunctionArgExpr::Expr(expr),
|
|
||||||
) = arg
|
|
||||||
{
|
|
||||||
self.scan_expression_for_tombstone_references(
|
|
||||||
expr,
|
|
||||||
crdt_tables,
|
|
||||||
filtered_tables,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {} // Andere FunctionArguments-Varianten ignorieren
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// CASE-Ausdrücke - KORRIGIERT
|
|
||||||
Expr::Case {
|
|
||||||
operand,
|
|
||||||
conditions,
|
|
||||||
else_result,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
if let Some(op) = operand {
|
|
||||||
self.scan_expression_for_tombstone_references(op, crdt_tables, filtered_tables);
|
|
||||||
}
|
|
||||||
for case_when in conditions {
|
|
||||||
self.scan_expression_for_tombstone_references(
|
|
||||||
&case_when.condition,
|
|
||||||
crdt_tables,
|
|
||||||
filtered_tables,
|
|
||||||
);
|
|
||||||
self.scan_expression_for_tombstone_references(
|
|
||||||
&case_when.result,
|
|
||||||
crdt_tables,
|
|
||||||
filtered_tables,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(else_res) = else_result {
|
|
||||||
self.scan_expression_for_tombstone_references(
|
|
||||||
else_res,
|
|
||||||
crdt_tables,
|
|
||||||
filtered_tables,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Subqueries mit vollständiger Unterstützung
|
|
||||||
Expr::Subquery(query) => {
|
|
||||||
self.transform_query_recursive_for_tombstone_analysis(
|
|
||||||
query,
|
|
||||||
crdt_tables,
|
|
||||||
filtered_tables,
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
// EXISTS/NOT EXISTS Subqueries
|
|
||||||
Expr::Exists { subquery, .. } => {
|
|
||||||
self.transform_query_recursive_for_tombstone_analysis(
|
|
||||||
subquery,
|
|
||||||
crdt_tables,
|
|
||||||
filtered_tables,
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
// IN/NOT IN Subqueries
|
|
||||||
Expr::InSubquery { expr, subquery, .. } => {
|
|
||||||
self.scan_expression_for_tombstone_references(expr, crdt_tables, filtered_tables);
|
|
||||||
self.transform_query_recursive_for_tombstone_analysis(
|
|
||||||
subquery,
|
|
||||||
crdt_tables,
|
|
||||||
filtered_tables,
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
// ANY/ALL Subqueries
|
|
||||||
Expr::AnyOp { left, right, .. } | Expr::AllOp { left, right, .. } => {
|
|
||||||
self.scan_expression_for_tombstone_references(left, crdt_tables, filtered_tables);
|
|
||||||
self.scan_expression_for_tombstone_references(right, crdt_tables, filtered_tables);
|
|
||||||
}
|
|
||||||
// Andere Expression-Typen ignorieren wir für jetzt
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Analysiert eine Subquery und sammelt Tombstone-Referenzen
|
|
||||||
fn transform_query_recursive_for_tombstone_analysis(
|
|
||||||
&self,
|
|
||||||
query: &sqlparser::ast::Query,
|
|
||||||
crdt_tables: &[(ObjectName, Option<&str>)],
|
|
||||||
filtered_tables: &mut HashSet<String>,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.analyze_set_expr_for_tombstone_references(&query.body, crdt_tables, filtered_tables)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rekursiv analysiert SetExpr für Tombstone-Referenzen
|
|
||||||
fn analyze_set_expr_for_tombstone_references(
|
|
||||||
&self,
|
|
||||||
set_expr: &SetExpr,
|
|
||||||
crdt_tables: &[(ObjectName, Option<&str>)],
|
|
||||||
filtered_tables: &mut HashSet<String>,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
match set_expr {
|
|
||||||
SetExpr::Select(select) => {
|
|
||||||
// Analysiere WHERE-Klausel
|
|
||||||
if let Some(where_clause) = &select.selection {
|
|
||||||
self.scan_expression_for_tombstone_references(
|
|
||||||
where_clause,
|
|
||||||
crdt_tables,
|
|
||||||
filtered_tables,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Analysiere alle Projektionen (können auch Subqueries enthalten)
|
|
||||||
for projection in &select.projection {
|
|
||||||
match projection {
|
|
||||||
SelectItem::UnnamedExpr(expr) | SelectItem::ExprWithAlias { expr, .. } => {
|
|
||||||
self.scan_expression_for_tombstone_references(
|
|
||||||
expr,
|
|
||||||
crdt_tables,
|
|
||||||
filtered_tables,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_ => {} // Wildcard projections ignorieren
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Analysiere GROUP BY
|
|
||||||
match &select.group_by {
|
|
||||||
sqlparser::ast::GroupByExpr::All(_) => {
|
|
||||||
// GROUP BY ALL - keine Expressions zu analysieren
|
|
||||||
}
|
|
||||||
sqlparser::ast::GroupByExpr::Expressions(exprs, _) => {
|
|
||||||
for group_expr in exprs {
|
|
||||||
self.scan_expression_for_tombstone_references(
|
|
||||||
group_expr,
|
|
||||||
crdt_tables,
|
|
||||||
filtered_tables,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Analysiere HAVING
|
|
||||||
if let Some(having) = &select.having {
|
|
||||||
self.scan_expression_for_tombstone_references(
|
|
||||||
having,
|
|
||||||
crdt_tables,
|
|
||||||
filtered_tables,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SetExpr::SetOperation { left, right, .. } => {
|
|
||||||
self.analyze_set_expr_for_tombstone_references(left, crdt_tables, filtered_tables)?;
|
|
||||||
self.analyze_set_expr_for_tombstone_references(
|
|
||||||
right,
|
|
||||||
crdt_tables,
|
|
||||||
filtered_tables,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
SetExpr::Query(query) => {
|
|
||||||
self.analyze_set_expr_for_tombstone_references(
|
|
||||||
&query.body,
|
|
||||||
crdt_tables,
|
|
||||||
filtered_tables,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
SetExpr::Values(values) => {
|
|
||||||
// Analysiere Values-Listen
|
|
||||||
for row in &values.rows {
|
|
||||||
for expr in row {
|
|
||||||
self.scan_expression_for_tombstone_references(
|
|
||||||
expr,
|
|
||||||
crdt_tables,
|
|
||||||
filtered_tables,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {} // Andere Varianten
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transformiert INSERT-Statements (fügt HLC-Timestamp hinzu)
|
|
||||||
fn transform_insert(
|
|
||||||
&self,
|
|
||||||
insert_stmt: &mut Insert,
|
|
||||||
timestamp: &Timestamp,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
// Add both haex_timestamp and haex_tombstone columns
|
|
||||||
insert_stmt
|
|
||||||
.columns
|
|
||||||
.push(Ident::new(self.columns.hlc_timestamp));
|
|
||||||
insert_stmt
|
|
||||||
.columns
|
|
||||||
.push(Ident::new(self.columns.tombstone));
|
|
||||||
|
|
||||||
match insert_stmt.source.as_mut() {
|
|
||||||
Some(query) => match &mut *query.body {
|
|
||||||
SetExpr::Values(values) => {
|
|
||||||
for row in &mut values.rows {
|
|
||||||
// Add haex_timestamp value
|
|
||||||
row.push(Expr::Value(
|
|
||||||
Value::SingleQuotedString(timestamp.to_string()).into(),
|
|
||||||
));
|
|
||||||
// Add haex_tombstone value (0 = not deleted)
|
|
||||||
row.push(Expr::Value(
|
|
||||||
Value::Number("0".to_string(), false).into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SetExpr::Select(select) => {
|
|
||||||
let hlc_expr =
|
|
||||||
Expr::Value(Value::SingleQuotedString(timestamp.to_string()).into());
|
|
||||||
select.projection.push(SelectItem::UnnamedExpr(hlc_expr));
|
|
||||||
// Add haex_tombstone value (0 = not deleted)
|
|
||||||
let tombstone_expr =
|
|
||||||
Expr::Value(Value::Number("0".to_string(), false).into());
|
|
||||||
select.projection.push(SelectItem::UnnamedExpr(tombstone_expr));
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(DatabaseError::UnsupportedStatement {
|
|
||||||
sql: insert_stmt.to_string(),
|
|
||||||
reason: "INSERT with unsupported source type".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
return Err(DatabaseError::UnsupportedStatement {
|
|
||||||
reason: "INSERT statement has no source".to_string(),
|
|
||||||
sql: insert_stmt.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transformiert DELETE zu UPDATE (soft delete)
|
/// Transformiert DELETE zu UPDATE (soft delete)
|
||||||
fn transform_delete_to_update(
|
fn transform_delete_to_update(
|
||||||
|
|||||||
@ -78,14 +78,14 @@ pub enum TriggerSetupResult {
|
|||||||
TableNotFound,
|
TableNotFound,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone)]
|
||||||
struct ColumnInfo {
|
pub struct ColumnInfo {
|
||||||
name: String,
|
pub name: String,
|
||||||
is_pk: bool,
|
pub is_pk: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ColumnInfo {
|
impl ColumnInfo {
|
||||||
fn from_row(row: &Row) -> RusqliteResult<Self> {
|
pub fn from_row(row: &Row) -> RusqliteResult<Self> {
|
||||||
Ok(ColumnInfo {
|
Ok(ColumnInfo {
|
||||||
name: row.get("name")?,
|
name: row.get("name")?,
|
||||||
is_pk: row.get::<_, i64>("pk")? > 0,
|
is_pk: row.get::<_, i64>("pk")? > 0,
|
||||||
@ -155,7 +155,7 @@ pub fn setup_triggers_for_table(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Holt das Schema für eine gegebene Tabelle.
|
/// Holt das Schema für eine gegebene Tabelle.
|
||||||
fn get_table_schema(conn: &Connection, table_name: &str) -> RusqliteResult<Vec<ColumnInfo>> {
|
pub fn get_table_schema(conn: &Connection, table_name: &str) -> RusqliteResult<Vec<ColumnInfo>> {
|
||||||
if !is_safe_identifier(table_name) {
|
if !is_safe_identifier(table_name) {
|
||||||
return Err(rusqlite::Error::InvalidParameterName(format!(
|
return Err(rusqlite::Error::InvalidParameterName(format!(
|
||||||
"Invalid or unsafe table name provided: {}",
|
"Invalid or unsafe table name provided: {}",
|
||||||
@ -170,6 +170,29 @@ fn get_table_schema(conn: &Connection, table_name: &str) -> RusqliteResult<Vec<C
|
|||||||
rows.collect()
|
rows.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Holt alle Foreign Key Spalten einer Tabelle.
|
||||||
|
/// Gibt eine Liste der Spaltennamen zurück, die Foreign Keys sind.
|
||||||
|
pub fn get_foreign_key_columns(conn: &Connection, table_name: &str) -> RusqliteResult<Vec<String>> {
|
||||||
|
if !is_safe_identifier(table_name) {
|
||||||
|
return Err(rusqlite::Error::InvalidParameterName(format!(
|
||||||
|
"Invalid or unsafe table name provided: {}",
|
||||||
|
table_name
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = format!("PRAGMA foreign_key_list(\"{}\");", table_name);
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
|
||||||
|
// foreign_key_list gibt Spalten zurück: id, seq, table, from, to, on_update, on_delete, match
|
||||||
|
// Wir brauchen die "from" Spalte, die den Namen der FK-Spalte in der aktuellen Tabelle enthält
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
row.get::<_, String>("from")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
rows.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn drop_triggers_for_table(
|
pub fn drop_triggers_for_table(
|
||||||
tx: &Transaction, // Arbeitet direkt auf einer Transaktion
|
tx: &Transaction, // Arbeitet direkt auf einer Transaktion
|
||||||
table_name: &str,
|
table_name: &str,
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use crate::database::error::DatabaseError;
|
use crate::database::error::DatabaseError;
|
||||||
use crate::database::DbConnection;
|
use crate::database::DbConnection;
|
||||||
|
use crate::extension::database::executor::SqlExecutor;
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
use rusqlite::types::Value as SqlValue;
|
use rusqlite::types::Value as SqlValue;
|
||||||
use rusqlite::{
|
use rusqlite::{
|
||||||
@ -79,6 +80,16 @@ pub fn parse_sql_statements(sql: &str) -> Result<Vec<Statement>, DatabaseError>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prüft ob ein Statement ein RETURNING Clause hat (AST-basiert, sicher)
|
||||||
|
pub fn statement_has_returning(statement: &Statement) -> bool {
|
||||||
|
match statement {
|
||||||
|
Statement::Insert(insert) => insert.returning.is_some(),
|
||||||
|
Statement::Update { returning, .. } => returning.is_some(),
|
||||||
|
Statement::Delete(delete) => delete.returning.is_some(),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ValueConverter;
|
pub struct ValueConverter;
|
||||||
|
|
||||||
impl ValueConverter {
|
impl ValueConverter {
|
||||||
@ -116,6 +127,25 @@ impl ValueConverter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Execute SQL mit CRDT-Transformation (für Drizzle-Integration)
|
||||||
|
/// Diese Funktion sollte von Drizzle verwendet werden, um CRDT-Support zu erhalten
|
||||||
|
pub fn execute_with_crdt(
|
||||||
|
sql: String,
|
||||||
|
params: Vec<JsonValue>,
|
||||||
|
connection: &DbConnection,
|
||||||
|
hlc_service: &std::sync::MutexGuard<crate::crdt::hlc::HlcService>,
|
||||||
|
) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
|
||||||
|
with_connection(connection, |conn| {
|
||||||
|
let tx = conn.transaction().map_err(DatabaseError::from)?;
|
||||||
|
let _modified_tables = SqlExecutor::execute_internal(&tx, hlc_service, &sql, ¶ms)?;
|
||||||
|
tx.commit().map_err(DatabaseError::from)?;
|
||||||
|
|
||||||
|
// Für Drizzle: gebe leeres Array zurück (wie bei execute ohne RETURNING)
|
||||||
|
Ok(vec![])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute SQL OHNE CRDT-Transformation (für spezielle Fälle)
|
||||||
pub fn execute(
|
pub fn execute(
|
||||||
sql: String,
|
sql: String,
|
||||||
params: Vec<JsonValue>,
|
params: Vec<JsonValue>,
|
||||||
@ -245,7 +275,7 @@ pub fn select(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Konvertiert rusqlite ValueRef zu JSON
|
/// Konvertiert rusqlite ValueRef zu JSON
|
||||||
fn convert_value_ref_to_json(value_ref: ValueRef) -> Result<JsonValue, DatabaseError> {
|
pub fn convert_value_ref_to_json(value_ref: ValueRef) -> Result<JsonValue, DatabaseError> {
|
||||||
let json_val = match value_ref {
|
let json_val = match value_ref {
|
||||||
ValueRef::Null => JsonValue::Null,
|
ValueRef::Null => JsonValue::Null,
|
||||||
ValueRef::Integer(i) => JsonValue::Number(i.into()),
|
ValueRef::Integer(i) => JsonValue::Number(i.into()),
|
||||||
|
|||||||
@ -6,6 +6,7 @@ pub mod generated;
|
|||||||
|
|
||||||
use crate::crdt::hlc::HlcService;
|
use crate::crdt::hlc::HlcService;
|
||||||
use crate::database::error::DatabaseError;
|
use crate::database::error::DatabaseError;
|
||||||
|
use crate::extension::database::executor::SqlExecutor;
|
||||||
use crate::table_names::TABLE_CRDT_CONFIGS;
|
use crate::table_names::TABLE_CRDT_CONFIGS;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
@ -42,6 +43,36 @@ pub fn sql_execute(
|
|||||||
core::execute(sql, params, &state.db)
|
core::execute(sql, params, &state.db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn sql_execute_with_crdt(
|
||||||
|
sql: String,
|
||||||
|
params: Vec<JsonValue>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
|
||||||
|
let hlc_service = state.hlc.lock().map_err(|_| DatabaseError::MutexPoisoned {
|
||||||
|
reason: "Failed to lock HLC service".to_string(),
|
||||||
|
})?;
|
||||||
|
core::execute_with_crdt(sql, params, &state.db, &hlc_service)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn sql_query_with_crdt(
|
||||||
|
sql: String,
|
||||||
|
params: Vec<JsonValue>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
|
||||||
|
let hlc_service = state.hlc.lock().map_err(|_| DatabaseError::MutexPoisoned {
|
||||||
|
reason: "Failed to lock HLC service".to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
core::with_connection(&state.db, |conn| {
|
||||||
|
let tx = conn.transaction().map_err(DatabaseError::from)?;
|
||||||
|
let result = SqlExecutor::query_internal(&tx, &hlc_service, &sql, ¶ms)?;
|
||||||
|
tx.commit().map_err(DatabaseError::from)?;
|
||||||
|
Ok(result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolves a database name to the full vault path
|
/// Resolves a database name to the full vault path
|
||||||
fn get_vault_path(app_handle: &AppHandle, vault_name: &str) -> Result<String, DatabaseError> {
|
fn get_vault_path(app_handle: &AppHandle, vault_name: &str) -> Result<String, DatabaseError> {
|
||||||
// Sicherstellen, dass der Name eine .db Endung hat
|
// Sicherstellen, dass der Name eine .db Endung hat
|
||||||
|
|||||||
@ -4,7 +4,7 @@ use crate::extension::core::manifest::{EditablePermissions, ExtensionManifest, E
|
|||||||
use crate::extension::core::types::{copy_directory, Extension, ExtensionSource};
|
use crate::extension::core::types::{copy_directory, Extension, ExtensionSource};
|
||||||
use crate::extension::core::ExtensionPermissions;
|
use crate::extension::core::ExtensionPermissions;
|
||||||
use crate::extension::crypto::ExtensionCrypto;
|
use crate::extension::crypto::ExtensionCrypto;
|
||||||
use crate::extension::database::executor::SqlExecutor;
|
use crate::extension::database::executor::{PkRemappingContext, SqlExecutor};
|
||||||
use crate::extension::error::ExtensionError;
|
use crate::extension::error::ExtensionError;
|
||||||
use crate::extension::permissions::manager::PermissionManager;
|
use crate::extension::permissions::manager::PermissionManager;
|
||||||
use crate::extension::permissions::types::ExtensionPermission;
|
use crate::extension::permissions::types::ExtensionPermission;
|
||||||
@ -315,7 +315,8 @@ impl ExtensionManager {
|
|||||||
name: extension_name.to_string(),
|
name: extension_name.to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
eprintln!("DEBUG: Removing extension with ID: {}", extension.id);
|
||||||
|
eprintln!("DEBUG: Extension name: {}, version: {}", extension_name, extension_version);
|
||||||
|
|
||||||
// Lösche Permissions und Extension-Eintrag in einer Transaktion
|
// Lösche Permissions und Extension-Eintrag in einer Transaktion
|
||||||
with_connection(&state.db, |conn| {
|
with_connection(&state.db, |conn| {
|
||||||
@ -326,6 +327,7 @@ impl ExtensionManager {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Lösche alle Permissions mit extension_id
|
// Lösche alle Permissions mit extension_id
|
||||||
|
eprintln!("DEBUG: Deleting permissions for extension_id: {}", extension.id);
|
||||||
PermissionManager::delete_permissions_in_transaction(
|
PermissionManager::delete_permissions_in_transaction(
|
||||||
&tx,
|
&tx,
|
||||||
&hlc_service,
|
&hlc_service,
|
||||||
@ -334,6 +336,7 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
// Lösche Extension-Eintrag mit extension_id
|
// Lösche Extension-Eintrag mit extension_id
|
||||||
let sql = format!("DELETE FROM {} WHERE id = ?", TABLE_EXTENSIONS);
|
let sql = format!("DELETE FROM {} WHERE id = ?", TABLE_EXTENSIONS);
|
||||||
|
eprintln!("DEBUG: Executing SQL: {} with id = {}", sql, extension.id);
|
||||||
SqlExecutor::execute_internal_typed(
|
SqlExecutor::execute_internal_typed(
|
||||||
&tx,
|
&tx,
|
||||||
&hlc_service,
|
&hlc_service,
|
||||||
@ -341,9 +344,12 @@ impl ExtensionManager {
|
|||||||
rusqlite::params![&extension.id],
|
rusqlite::params![&extension.id],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
eprintln!("DEBUG: Committing transaction");
|
||||||
tx.commit().map_err(DatabaseError::from)
|
tx.commit().map_err(DatabaseError::from)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
eprintln!("DEBUG: Transaction committed successfully");
|
||||||
|
|
||||||
// Entferne aus dem In-Memory-Manager
|
// Entferne aus dem In-Memory-Manager
|
||||||
self.remove_extension(public_key, extension_name)?;
|
self.remove_extension(public_key, extension_name)?;
|
||||||
|
|
||||||
@ -460,20 +466,25 @@ impl ExtensionManager {
|
|||||||
let permissions = custom_permissions.to_internal_permissions(&extension_id);
|
let permissions = custom_permissions.to_internal_permissions(&extension_id);
|
||||||
|
|
||||||
// Extension-Eintrag und Permissions in einer Transaktion speichern
|
// Extension-Eintrag und Permissions in einer Transaktion speichern
|
||||||
with_connection(&state.db, |conn| {
|
let actual_extension_id = with_connection(&state.db, |conn| {
|
||||||
let tx = conn.transaction().map_err(DatabaseError::from)?;
|
let tx = conn.transaction().map_err(DatabaseError::from)?;
|
||||||
|
|
||||||
let hlc_service = state.hlc.lock().map_err(|_| DatabaseError::MutexPoisoned {
|
let hlc_service = state.hlc.lock().map_err(|_| DatabaseError::MutexPoisoned {
|
||||||
reason: "Failed to lock HLC service".to_string(),
|
reason: "Failed to lock HLC service".to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Erstelle PK-Remapping Context für die gesamte Transaktion
|
||||||
|
// Dies ermöglicht automatisches FK-Remapping wenn ON CONFLICT bei Extension auftritt
|
||||||
|
let mut pk_context = PkRemappingContext::new();
|
||||||
|
|
||||||
// 1. Extension-Eintrag erstellen mit generierter UUID
|
// 1. Extension-Eintrag erstellen mit generierter UUID
|
||||||
|
// WICHTIG: RETURNING wird vom CRDT-Transformer automatisch hinzugefügt
|
||||||
let insert_ext_sql = format!(
|
let insert_ext_sql = format!(
|
||||||
"INSERT INTO {} (id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO {} (id, name, version, author, entry, icon, public_key, signature, homepage, description, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
|
||||||
TABLE_EXTENSIONS
|
TABLE_EXTENSIONS
|
||||||
);
|
);
|
||||||
|
|
||||||
SqlExecutor::execute_internal_typed(
|
let (_tables, returning_results) = SqlExecutor::query_internal_typed_with_context(
|
||||||
&tx,
|
&tx,
|
||||||
&hlc_service,
|
&hlc_service,
|
||||||
&insert_ext_sql,
|
&insert_ext_sql,
|
||||||
@ -490,11 +501,28 @@ impl ExtensionManager {
|
|||||||
extracted.manifest.description,
|
extracted.manifest.description,
|
||||||
true, // enabled
|
true, // enabled
|
||||||
],
|
],
|
||||||
|
&mut pk_context,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// Nutze die tatsächliche ID aus der Datenbank (wichtig bei ON CONFLICT)
|
||||||
|
// Die haex_extensions Tabelle hat einen single-column PK namens "id"
|
||||||
|
let actual_extension_id = returning_results
|
||||||
|
.first()
|
||||||
|
.and_then(|row| row.first())
|
||||||
|
.and_then(|val| val.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| extension_id.clone());
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"DEBUG: Extension UUID - Generated: {}, Actual from DB: {}",
|
||||||
|
extension_id, actual_extension_id
|
||||||
|
);
|
||||||
|
|
||||||
// 2. Permissions speichern (oder aktualisieren falls schon vorhanden)
|
// 2. Permissions speichern (oder aktualisieren falls schon vorhanden)
|
||||||
|
// Nutze einfaches INSERT - die CRDT-Transformation fügt automatisch ON CONFLICT hinzu
|
||||||
|
// FK-Werte (extension_id) werden automatisch remapped wenn Extension ON CONFLICT hatte
|
||||||
let insert_perm_sql = format!(
|
let insert_perm_sql = format!(
|
||||||
"INSERT OR REPLACE INTO {} (id, extension_id, resource_type, action, target, constraints, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO {} (id, extension_id, resource_type, action, target, constraints, status) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
TABLE_EXTENSION_PERMISSIONS
|
TABLE_EXTENSION_PERMISSIONS
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -502,7 +530,7 @@ impl ExtensionManager {
|
|||||||
use crate::database::generated::HaexExtensionPermissions;
|
use crate::database::generated::HaexExtensionPermissions;
|
||||||
let db_perm: HaexExtensionPermissions = perm.into();
|
let db_perm: HaexExtensionPermissions = perm.into();
|
||||||
|
|
||||||
SqlExecutor::execute_internal_typed(
|
SqlExecutor::execute_internal_typed_with_context(
|
||||||
&tx,
|
&tx,
|
||||||
&hlc_service,
|
&hlc_service,
|
||||||
&insert_perm_sql,
|
&insert_perm_sql,
|
||||||
@ -515,15 +543,16 @@ impl ExtensionManager {
|
|||||||
db_perm.constraints,
|
db_perm.constraints,
|
||||||
db_perm.status,
|
db_perm.status,
|
||||||
],
|
],
|
||||||
|
&mut pk_context,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.commit().map_err(DatabaseError::from)?;
|
tx.commit().map_err(DatabaseError::from)?;
|
||||||
Ok(extension_id.clone())
|
Ok(actual_extension_id.clone())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let extension = Extension {
|
let extension = Extension {
|
||||||
id: extension_id.clone(),
|
id: actual_extension_id.clone(), // Nutze die actual_extension_id aus der Transaktion
|
||||||
source: ExtensionSource::Production {
|
source: ExtensionSource::Production {
|
||||||
path: extensions_dir.clone(),
|
path: extensions_dir.clone(),
|
||||||
version: extracted.manifest.version.clone(),
|
version: extracted.manifest.version.clone(),
|
||||||
@ -535,7 +564,7 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
self.add_production_extension(extension)?;
|
self.add_production_extension(extension)?;
|
||||||
|
|
||||||
Ok(extension_id)
|
Ok(actual_extension_id) // Gebe die actual_extension_id an den Caller zurück
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scannt das Dateisystem beim Start und lädt alle installierten Erweiterungen.
|
/// Scannt das Dateisystem beim Start und lädt alle installierten Erweiterungen.
|
||||||
|
|||||||
@ -3,38 +3,123 @@
|
|||||||
use crate::crdt::hlc::HlcService;
|
use crate::crdt::hlc::HlcService;
|
||||||
use crate::crdt::transformer::CrdtTransformer;
|
use crate::crdt::transformer::CrdtTransformer;
|
||||||
use crate::crdt::trigger;
|
use crate::crdt::trigger;
|
||||||
use crate::database::core::{parse_sql_statements, ValueConverter};
|
use crate::database::core::{convert_value_ref_to_json, parse_sql_statements, ValueConverter};
|
||||||
use crate::database::error::DatabaseError;
|
use crate::database::error::DatabaseError;
|
||||||
use rusqlite::{params_from_iter, Params, Transaction};
|
use rusqlite::{params_from_iter, types::Value as SqliteValue, ToSql, Transaction};
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
use sqlparser::ast::Statement;
|
use sqlparser::ast::{Insert, Statement, TableObject};
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
/// Repräsentiert PK-Werte für eine Zeile (kann single oder composite key sein)
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct PkValues {
|
||||||
|
/// column_name -> value
|
||||||
|
values: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PkValues {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
values: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert(&mut self, column: String, value: String) {
|
||||||
|
self.values.insert(column, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, column: &str) -> Option<&String> {
|
||||||
|
self.values.get(column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Context für PK-Remapping während einer Transaktion
|
||||||
|
/// Trackt für jede Tabelle: welche PKs sollten eingefügt werden vs. welche sind tatsächlich in der DB
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct PkRemappingContext {
|
||||||
|
/// Für jede Tabelle: Liste von (original_pk_values, actual_pk_values) Mappings
|
||||||
|
/// Wird nur gespeichert wenn original != actual (d.h. ON CONFLICT hat PK geändert)
|
||||||
|
mappings: HashMap<String, Vec<(PkValues, PkValues)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PkRemappingContext {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fügt ein Mapping für eine Tabelle hinzu, aber nur wenn original != actual
|
||||||
|
/// original und actual sind die PK-Werte vor und nach dem INSERT
|
||||||
|
fn add_mapping(&mut self, table: String, original: PkValues, actual: PkValues) {
|
||||||
|
// Nur speichern wenn tatsächlich unterschiedlich (ON CONFLICT hat stattgefunden)
|
||||||
|
if original != actual {
|
||||||
|
eprintln!(
|
||||||
|
"DEBUG: PK Remapping for table '{}': {:?} -> {:?}",
|
||||||
|
table, original.values, actual.values
|
||||||
|
);
|
||||||
|
self.mappings
|
||||||
|
.entry(table)
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push((original, actual));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Versucht einen FK-Wert zu remappen
|
||||||
|
/// referenced_table: Die Tabelle auf die der FK zeigt
|
||||||
|
/// referenced_column: Die PK-Spalte in der referenced_table
|
||||||
|
/// value: Der FK-Wert der ersetzt werden soll
|
||||||
|
fn remap_fk_value(
|
||||||
|
&self,
|
||||||
|
referenced_table: &str,
|
||||||
|
referenced_column: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> String {
|
||||||
|
self.mappings
|
||||||
|
.get(referenced_table)
|
||||||
|
.and_then(|mappings| {
|
||||||
|
mappings.iter().find_map(|(original, actual)| {
|
||||||
|
if original.get(referenced_column)? == value {
|
||||||
|
let actual_val = actual.get(referenced_column)?.clone();
|
||||||
|
eprintln!(
|
||||||
|
"DEBUG: FK Remapping for {}.{}: {} -> {}",
|
||||||
|
referenced_table, referenced_column, value, actual_val
|
||||||
|
);
|
||||||
|
Some(actual_val)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// SQL-Executor OHNE Berechtigungsprüfung - für interne Nutzung
|
/// SQL-Executor OHNE Berechtigungsprüfung - für interne Nutzung
|
||||||
pub struct SqlExecutor;
|
pub struct SqlExecutor;
|
||||||
|
|
||||||
impl SqlExecutor {
|
impl SqlExecutor {
|
||||||
pub fn execute_internal_typed<P>(
|
/// Führt ein SQL Statement OHNE RETURNING aus (mit CRDT und PK-Remapping)
|
||||||
|
/// Unterstützt automatisches FK-Remapping wenn vorherige INSERTs ON CONFLICT getriggert haben
|
||||||
|
///
|
||||||
|
/// Diese Variante akzeptiert &[&dyn ToSql] direkt (wie von rusqlite::params![] erzeugt)
|
||||||
|
/// Returns: modified_schema_tables
|
||||||
|
pub fn execute_internal_typed_with_context(
|
||||||
tx: &Transaction,
|
tx: &Transaction,
|
||||||
hlc_service: &HlcService,
|
hlc_service: &HlcService,
|
||||||
sql: &str,
|
sql: &str,
|
||||||
params: P, // Akzeptiert jetzt alles, was rusqlite als Parameter versteht
|
params: &[&dyn ToSql],
|
||||||
) -> Result<HashSet<String>, DatabaseError>
|
pk_context: &mut PkRemappingContext,
|
||||||
where
|
) -> Result<HashSet<String>, DatabaseError> {
|
||||||
P: Params,
|
|
||||||
{
|
|
||||||
let mut ast_vec = parse_sql_statements(sql)?;
|
let mut ast_vec = parse_sql_statements(sql)?;
|
||||||
|
|
||||||
// Wir stellen sicher, dass wir nur EIN Statement verarbeiten. Das ist sicherer.
|
|
||||||
if ast_vec.len() != 1 {
|
if ast_vec.len() != 1 {
|
||||||
return Err(DatabaseError::ExecutionError {
|
return Err(DatabaseError::ExecutionError {
|
||||||
sql: sql.to_string(),
|
sql: sql.to_string(),
|
||||||
reason: "execute_internal_typed sollte nur ein einzelnes SQL-Statement erhalten"
|
reason: "execute_internal_typed_with_context sollte nur ein einzelnes SQL-Statement erhalten"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
table: None,
|
table: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Wir nehmen das einzige Statement aus dem Vektor.
|
|
||||||
let mut statement = ast_vec.pop().unwrap();
|
let mut statement = ast_vec.pop().unwrap();
|
||||||
|
|
||||||
let transformer = CrdtTransformer::new();
|
let transformer = CrdtTransformer::new();
|
||||||
@ -46,23 +131,72 @@ impl SqlExecutor {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut modified_schema_tables = HashSet::new();
|
let mut modified_schema_tables = HashSet::new();
|
||||||
if let Some(table_name) =
|
if let Some(table_name) = transformer.transform_execute_statement_with_table_info(
|
||||||
transformer.transform_execute_statement(&mut statement, &hlc_timestamp)?
|
&mut statement,
|
||||||
{
|
&hlc_timestamp,
|
||||||
|
tx,
|
||||||
|
)? {
|
||||||
modified_schema_tables.insert(table_name);
|
modified_schema_tables.insert(table_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Führe das transformierte Statement aus.
|
|
||||||
// `params` wird jetzt nur noch einmal hierher bewegt, was korrekt ist.
|
|
||||||
let sql_str = statement.to_string();
|
let sql_str = statement.to_string();
|
||||||
tx.execute(&sql_str, params)
|
eprintln!("DEBUG: Transformed SQL: {}", sql_str);
|
||||||
.map_err(|e| DatabaseError::ExecutionError {
|
|
||||||
sql: sql_str.clone(),
|
|
||||||
table: None,
|
|
||||||
reason: e.to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Die Trigger-Logik für CREATE TABLE bleibt erhalten.
|
// Spezielle Behandlung für INSERT Statements (mit FK-Remapping, OHNE RETURNING)
|
||||||
|
if let Statement::Insert(ref insert_stmt) = statement {
|
||||||
|
if let TableObject::TableName(ref table_name) = insert_stmt.table {
|
||||||
|
let table_name_str = table_name
|
||||||
|
.to_string()
|
||||||
|
.trim_matches('`')
|
||||||
|
.trim_matches('"')
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Konvertiere Params zu Vec für Manipulation
|
||||||
|
let mut param_vec = params_to_vec(params, tx)?;
|
||||||
|
|
||||||
|
// Hole Foreign Key Informationen
|
||||||
|
let fk_info = get_fk_info(tx, &table_name_str)?;
|
||||||
|
|
||||||
|
// Remap FK-Werte in params (falls Mappings existieren)
|
||||||
|
remap_fk_params(insert_stmt, &mut param_vec, &fk_info, pk_context)?;
|
||||||
|
|
||||||
|
// Führe INSERT mit execute() aus
|
||||||
|
let param_refs: Vec<&dyn ToSql> =
|
||||||
|
param_vec.iter().map(|v| v as &dyn ToSql).collect();
|
||||||
|
|
||||||
|
let mut stmt = tx
|
||||||
|
.prepare(&sql_str)
|
||||||
|
.map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: sql_str.clone(),
|
||||||
|
table: Some(table_name_str.clone()),
|
||||||
|
reason: format!("Prepare failed: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let _ = stmt
|
||||||
|
.query(params_from_iter(param_refs.iter()))
|
||||||
|
.map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: sql_str.clone(),
|
||||||
|
table: Some(table_name_str.clone()),
|
||||||
|
reason: format!("Query execution failed: {}", e),
|
||||||
|
})?;
|
||||||
|
/* tx.execute(&sql_str, params_from_iter(param_refs.iter()))
|
||||||
|
.map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: sql_str.clone(),
|
||||||
|
table: Some(table_name_str.clone()),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?; */
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Nicht-INSERT Statements normal ausführen
|
||||||
|
tx.execute(&sql_str, params)
|
||||||
|
.map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: sql_str.clone(),
|
||||||
|
table: None,
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger-Logik für CREATE TABLE
|
||||||
if let Statement::CreateTable(create_table_details) = statement {
|
if let Statement::CreateTable(create_table_details) = statement {
|
||||||
let table_name_str = create_table_details.name.to_string();
|
let table_name_str = create_table_details.name.to_string();
|
||||||
trigger::setup_triggers_for_table(tx, &table_name_str, false)?;
|
trigger::setup_triggers_for_table(tx, &table_name_str, false)?;
|
||||||
@ -70,7 +204,193 @@ impl SqlExecutor {
|
|||||||
|
|
||||||
Ok(modified_schema_tables)
|
Ok(modified_schema_tables)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Führt ein SQL Statement MIT RETURNING aus (mit CRDT und PK-Remapping)
|
||||||
|
/// Unterstützt automatisches FK-Remapping wenn vorherige INSERTs ON CONFLICT getriggert haben
|
||||||
|
///
|
||||||
|
/// Diese Variante akzeptiert &[&dyn ToSql] direkt (wie von rusqlite::params![] erzeugt)
|
||||||
|
/// Returns: (modified_schema_tables, returning_results)
|
||||||
|
/// returning_results enthält ALLE RETURNING-Spalten für INSERT/UPDATE/DELETE mit RETURNING
|
||||||
|
pub fn query_internal_typed_with_context(
|
||||||
|
tx: &Transaction,
|
||||||
|
hlc_service: &HlcService,
|
||||||
|
sql: &str,
|
||||||
|
params: &[&dyn ToSql],
|
||||||
|
pk_context: &mut PkRemappingContext,
|
||||||
|
) -> Result<(HashSet<String>, Vec<Vec<JsonValue>>), DatabaseError> {
|
||||||
|
let mut ast_vec = parse_sql_statements(sql)?;
|
||||||
|
|
||||||
|
if ast_vec.len() != 1 {
|
||||||
|
return Err(DatabaseError::ExecutionError {
|
||||||
|
sql: sql.to_string(),
|
||||||
|
reason: "query_internal_typed_with_context sollte nur ein einzelnes SQL-Statement erhalten"
|
||||||
|
.to_string(),
|
||||||
|
table: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut statement = ast_vec.pop().unwrap();
|
||||||
|
|
||||||
|
let transformer = CrdtTransformer::new();
|
||||||
|
let hlc_timestamp =
|
||||||
|
hlc_service
|
||||||
|
.new_timestamp_and_persist(tx)
|
||||||
|
.map_err(|e| DatabaseError::HlcError {
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut modified_schema_tables = HashSet::new();
|
||||||
|
if let Some(table_name) = transformer.transform_execute_statement_with_table_info(
|
||||||
|
&mut statement,
|
||||||
|
&hlc_timestamp,
|
||||||
|
tx,
|
||||||
|
)? {
|
||||||
|
modified_schema_tables.insert(table_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql_str = statement.to_string();
|
||||||
|
eprintln!("DEBUG: Transformed SQL (with RETURNING): {}", sql_str);
|
||||||
|
|
||||||
|
// Spezielle Behandlung für INSERT Statements (mit PK-Remapping + RETURNING)
|
||||||
|
if let Statement::Insert(ref insert_stmt) = statement {
|
||||||
|
if let TableObject::TableName(ref table_name) = insert_stmt.table {
|
||||||
|
let table_name_str = table_name
|
||||||
|
.to_string()
|
||||||
|
.trim_matches('`')
|
||||||
|
.trim_matches('"')
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Konvertiere Params zu Vec für Manipulation
|
||||||
|
let mut param_vec = params_to_vec(params, tx)?;
|
||||||
|
|
||||||
|
// Hole Table Schema um PKs und FKs zu identifizieren
|
||||||
|
let table_columns =
|
||||||
|
trigger::get_table_schema(tx, &table_name_str).map_err(|e| {
|
||||||
|
DatabaseError::ExecutionError {
|
||||||
|
sql: format!("PRAGMA table_info('{}')", table_name_str),
|
||||||
|
reason: e.to_string(),
|
||||||
|
table: Some(table_name_str.clone()),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let pk_columns: Vec<String> = table_columns
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.is_pk)
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Hole Foreign Key Informationen
|
||||||
|
let fk_info = get_fk_info(tx, &table_name_str)?;
|
||||||
|
|
||||||
|
// 1. Extrahiere Original PK-Werte aus params (vor FK-Remapping)
|
||||||
|
let original_pk =
|
||||||
|
extract_pk_values_from_params(insert_stmt, ¶m_vec, &pk_columns)?;
|
||||||
|
|
||||||
|
// 2. Remap FK-Werte in params (falls Mappings existieren)
|
||||||
|
remap_fk_params(insert_stmt, &mut param_vec, &fk_info, pk_context)?;
|
||||||
|
|
||||||
|
// 3. Führe INSERT mit query() aus um RETURNING zu lesen
|
||||||
|
let mut stmt = tx
|
||||||
|
.prepare(&sql_str)
|
||||||
|
.map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: sql_str.clone(),
|
||||||
|
table: Some(table_name_str.clone()),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let num_columns = stmt.column_count();
|
||||||
|
let param_refs: Vec<&dyn ToSql> =
|
||||||
|
param_vec.iter().map(|v| v as &dyn ToSql).collect();
|
||||||
|
let mut rows = stmt
|
||||||
|
.query(params_from_iter(param_refs.iter()))
|
||||||
|
.map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: sql_str.clone(),
|
||||||
|
table: Some(table_name_str.clone()),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut result_vec: Vec<Vec<JsonValue>> = Vec::new();
|
||||||
|
|
||||||
|
// 4. Lese ALLE RETURNING Werte und speichere PK-Mapping
|
||||||
|
while let Some(row) = rows.next().map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: sql_str.clone(),
|
||||||
|
table: Some(table_name_str.clone()),
|
||||||
|
reason: e.to_string(),
|
||||||
|
})? {
|
||||||
|
// Extrahiere PK-Werte für PK-Remapping
|
||||||
|
let actual_pk = extract_pk_values_from_row(&row, &pk_columns)?;
|
||||||
|
pk_context.add_mapping(
|
||||||
|
table_name_str.clone(),
|
||||||
|
original_pk.clone(),
|
||||||
|
actual_pk.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extrahiere ALLE Spalten für RETURNING-Ergebnis
|
||||||
|
let mut row_values: Vec<JsonValue> = Vec::with_capacity(num_columns);
|
||||||
|
for i in 0..num_columns {
|
||||||
|
let value_ref =
|
||||||
|
row.get_ref(i)
|
||||||
|
.map_err(|e| DatabaseError::RowProcessingError {
|
||||||
|
reason: format!("Failed to get column {}: {}", i, e),
|
||||||
|
})?;
|
||||||
|
let json_val = convert_value_ref_to_json(value_ref)?;
|
||||||
|
row_values.push(json_val);
|
||||||
|
}
|
||||||
|
result_vec.push(row_values);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok((modified_schema_tables, result_vec));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für UPDATE/DELETE mit RETURNING: query() verwenden (kein PK-Remapping nötig)
|
||||||
|
let mut stmt = tx
|
||||||
|
.prepare(&sql_str)
|
||||||
|
.map_err(|e| DatabaseError::PrepareError {
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let num_columns = stmt.column_count();
|
||||||
|
let mut rows = stmt.query(params).map_err(|e| DatabaseError::QueryError {
|
||||||
|
reason: e.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut result_vec: Vec<Vec<JsonValue>> = Vec::new();
|
||||||
|
|
||||||
|
while let Some(row) = rows.next().map_err(|e| DatabaseError::RowProcessingError {
|
||||||
|
reason: format!("Row iteration error: {}", e),
|
||||||
|
})? {
|
||||||
|
let mut row_values: Vec<JsonValue> = Vec::with_capacity(num_columns);
|
||||||
|
|
||||||
|
for i in 0..num_columns {
|
||||||
|
let value_ref = row
|
||||||
|
.get_ref(i)
|
||||||
|
.map_err(|e| DatabaseError::RowProcessingError {
|
||||||
|
reason: format!("Failed to get column {}: {}", i, e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let json_val = convert_value_ref_to_json(value_ref)?;
|
||||||
|
row_values.push(json_val);
|
||||||
|
}
|
||||||
|
result_vec.push(row_values);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((modified_schema_tables, result_vec))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy-Methode ohne PK-Remapping Context
|
||||||
|
pub fn execute_internal_typed(
|
||||||
|
tx: &Transaction,
|
||||||
|
hlc_service: &HlcService,
|
||||||
|
sql: &str,
|
||||||
|
params: &[&dyn ToSql],
|
||||||
|
) -> Result<HashSet<String>, DatabaseError> {
|
||||||
|
let mut context = PkRemappingContext::new();
|
||||||
|
Self::execute_internal_typed_with_context(tx, hlc_service, sql, params, &mut context)
|
||||||
|
}
|
||||||
/// Führt SQL aus (mit CRDT-Transformation) - OHNE Permission-Check
|
/// Führt SQL aus (mit CRDT-Transformation) - OHNE Permission-Check
|
||||||
|
/// Wrapper um execute_internal_typed für JsonValue-Parameter
|
||||||
|
/// Nutzt PK-Remapping Logik für INSERT mit ON CONFLICT
|
||||||
pub fn execute_internal(
|
pub fn execute_internal(
|
||||||
tx: &Transaction,
|
tx: &Transaction,
|
||||||
hlc_service: &HlcService,
|
hlc_service: &HlcService,
|
||||||
@ -87,50 +407,18 @@ impl SqlExecutor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQL parsing
|
// Convert JsonValue params to SqliteValue
|
||||||
let mut ast_vec = parse_sql_statements(sql)?;
|
let params_converted: Vec<SqliteValue> = params
|
||||||
|
.iter()
|
||||||
|
.map(ValueConverter::json_to_rusqlite_value)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
let transformer = CrdtTransformer::new();
|
// Convert to &dyn ToSql references
|
||||||
|
let param_refs: Vec<&dyn ToSql> =
|
||||||
|
params_converted.iter().map(|v| v as &dyn ToSql).collect();
|
||||||
|
|
||||||
// Generate HLC timestamp
|
// Call execute_internal_typed (mit PK-Remapping!)
|
||||||
let hlc_timestamp =
|
Self::execute_internal_typed(tx, hlc_service, sql, ¶m_refs)
|
||||||
hlc_service
|
|
||||||
.new_timestamp_and_persist(tx)
|
|
||||||
.map_err(|e| DatabaseError::HlcError {
|
|
||||||
reason: e.to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Transform statements
|
|
||||||
let mut modified_schema_tables = HashSet::new();
|
|
||||||
for statement in &mut ast_vec {
|
|
||||||
if let Some(table_name) =
|
|
||||||
transformer.transform_execute_statement(statement, &hlc_timestamp)?
|
|
||||||
{
|
|
||||||
modified_schema_tables.insert(table_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert parameters
|
|
||||||
let sql_values = ValueConverter::convert_params(params)?;
|
|
||||||
|
|
||||||
// Execute statements
|
|
||||||
for statement in ast_vec {
|
|
||||||
let sql_str = statement.to_string();
|
|
||||||
|
|
||||||
tx.execute(&sql_str, params_from_iter(sql_values.iter()))
|
|
||||||
.map_err(|e| DatabaseError::ExecutionError {
|
|
||||||
sql: sql_str.clone(),
|
|
||||||
table: None,
|
|
||||||
reason: e.to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if let Statement::CreateTable(create_table_details) = statement {
|
|
||||||
let table_name_str = create_table_details.name.to_string();
|
|
||||||
trigger::setup_triggers_for_table(tx, &table_name_str, false)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(modified_schema_tables)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Führt SELECT aus (mit CRDT-Transformation) - OHNE Permission-Check
|
/// Führt SELECT aus (mit CRDT-Transformation) - OHNE Permission-Check
|
||||||
@ -206,4 +494,240 @@ impl SqlExecutor {
|
|||||||
|
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Führt SQL mit CRDT-Transformation aus und gibt RETURNING-Ergebnisse zurück
|
||||||
|
/// Speziell für INSERT/UPDATE/DELETE mit RETURNING (Drizzle-Integration)
|
||||||
|
/// Nutzt PK-Remapping für INSERT-Operationen
|
||||||
|
pub fn query_internal(
|
||||||
|
tx: &Transaction,
|
||||||
|
hlc_service: &HlcService,
|
||||||
|
sql: &str,
|
||||||
|
params: &[JsonValue],
|
||||||
|
) -> Result<Vec<Vec<JsonValue>>, DatabaseError> {
|
||||||
|
// Parameter validation
|
||||||
|
let total_placeholders = sql.matches('?').count();
|
||||||
|
if total_placeholders != params.len() {
|
||||||
|
return Err(DatabaseError::ParameterMismatchError {
|
||||||
|
expected: total_placeholders,
|
||||||
|
provided: params.len(),
|
||||||
|
sql: sql.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameter konvertieren
|
||||||
|
let params_converted: Vec<SqliteValue> = params
|
||||||
|
.iter()
|
||||||
|
.map(ValueConverter::json_to_rusqlite_value)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
// Convert to &dyn ToSql references
|
||||||
|
let param_refs: Vec<&dyn ToSql> =
|
||||||
|
params_converted.iter().map(|v| v as &dyn ToSql).collect();
|
||||||
|
|
||||||
|
// Call query_internal_typed_with_context (mit PK-Remapping!)
|
||||||
|
let mut context = PkRemappingContext::new();
|
||||||
|
let (_tables, results) = Self::query_internal_typed_with_context(
|
||||||
|
tx,
|
||||||
|
hlc_service,
|
||||||
|
sql,
|
||||||
|
¶m_refs,
|
||||||
|
&mut context,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Helper-Funktionen für FK-Remapping
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
/// Strukturiert FK-Informationen für einfache Lookups
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct FkInfo {
|
||||||
|
/// column_name -> (referenced_table, referenced_column)
|
||||||
|
mappings: HashMap<String, (String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hole Foreign Key Informationen für eine Tabelle
|
||||||
|
fn get_fk_info(tx: &Transaction, table_name: &str) -> Result<FkInfo, DatabaseError> {
|
||||||
|
// Nutze PRAGMA foreign_key_list um FK-Beziehungen zu holen
|
||||||
|
let sql = format!("PRAGMA foreign_key_list('{}');", table_name);
|
||||||
|
let mut stmt = tx
|
||||||
|
.prepare(&sql)
|
||||||
|
.map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: sql.clone(),
|
||||||
|
reason: e.to_string(),
|
||||||
|
table: Some(table_name.to_string()),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut mappings = HashMap::new();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>("from")?, // FK column in this table
|
||||||
|
row.get::<_, String>("table")?, // referenced table
|
||||||
|
row.get::<_, String>("to")?, // referenced column
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql,
|
||||||
|
reason: e.to_string(),
|
||||||
|
table: Some(table_name.to_string()),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let (from_col, ref_table, ref_col) = row.map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: format!("PRAGMA foreign_key_list('{}')", table_name),
|
||||||
|
reason: e.to_string(),
|
||||||
|
table: Some(table_name.to_string()),
|
||||||
|
})?;
|
||||||
|
mappings.insert(from_col, (ref_table, ref_col));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(FkInfo { mappings })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Konvertiert &[&dyn ToSql] zu Vec<SqliteValue> für Manipulation
|
||||||
|
/// Nutzt einen Dummy-Query um die Parameter-Werte zu extrahieren
|
||||||
|
fn params_to_vec(
|
||||||
|
params: &[&dyn ToSql],
|
||||||
|
tx: &Transaction,
|
||||||
|
) -> Result<Vec<SqliteValue>, DatabaseError> {
|
||||||
|
let mut values = Vec::new();
|
||||||
|
|
||||||
|
// Erstelle eine Dummy-Query mit genau so vielen Platzhaltern wie wir Parameter haben
|
||||||
|
// z.B. "SELECT ?, ?, ?"
|
||||||
|
if params.is_empty() {
|
||||||
|
return Ok(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
let placeholders = vec!["?"; params.len()].join(", ");
|
||||||
|
let dummy_sql = format!("SELECT {}", placeholders);
|
||||||
|
|
||||||
|
let mut stmt = tx
|
||||||
|
.prepare(&dummy_sql)
|
||||||
|
.map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: dummy_sql.clone(),
|
||||||
|
reason: format!("Failed to prepare dummy query: {}", e),
|
||||||
|
table: None,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Führe die Query aus und extrahiere die Werte aus der Row
|
||||||
|
let mut rows = stmt
|
||||||
|
.query(params)
|
||||||
|
.map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: dummy_sql.clone(),
|
||||||
|
reason: format!("Failed to execute dummy query: {}", e),
|
||||||
|
table: None,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(row) = rows.next().map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: dummy_sql,
|
||||||
|
reason: format!("Failed to read dummy query result: {}", e),
|
||||||
|
table: None,
|
||||||
|
})? {
|
||||||
|
// Extrahiere alle Spalten-Werte
|
||||||
|
for i in 0..params.len() {
|
||||||
|
let value: SqliteValue = row.get(i).map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: format!("SELECT ..."),
|
||||||
|
reason: format!("Failed to extract value at index {}: {}", i, e),
|
||||||
|
table: None,
|
||||||
|
})?;
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrahiert PK-Werte aus den INSERT-Parametern
|
||||||
|
fn extract_pk_values_from_params(
|
||||||
|
insert_stmt: &Insert,
|
||||||
|
params: &[SqliteValue],
|
||||||
|
pk_columns: &[String],
|
||||||
|
) -> Result<PkValues, DatabaseError> {
|
||||||
|
let mut pk_values = PkValues::new();
|
||||||
|
|
||||||
|
// Finde die Positionen der PK-Spalten in der INSERT column list
|
||||||
|
for pk_col in pk_columns {
|
||||||
|
if let Some(pos) = insert_stmt.columns.iter().position(|c| &c.value == pk_col) {
|
||||||
|
// Hole den Parameter-Wert an dieser Position
|
||||||
|
if pos < params.len() {
|
||||||
|
// Konvertiere SqliteValue zu String
|
||||||
|
let value_str = value_to_string(¶ms[pos]);
|
||||||
|
pk_values.insert(pk_col.clone(), value_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(pk_values)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remapped FK-Werte in den Parametern basierend auf dem PK-Remapping Context
|
||||||
|
fn remap_fk_params(
|
||||||
|
insert_stmt: &Insert,
|
||||||
|
params: &mut Vec<SqliteValue>,
|
||||||
|
fk_info: &FkInfo,
|
||||||
|
pk_context: &PkRemappingContext,
|
||||||
|
) -> Result<(), DatabaseError> {
|
||||||
|
// Für jede FK-Spalte: prüfe ob Remapping nötig ist
|
||||||
|
for (col_name, (ref_table, ref_col)) in &fk_info.mappings {
|
||||||
|
// Finde Position der FK-Spalte in der INSERT column list
|
||||||
|
if let Some(pos) = insert_stmt
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.position(|c| &c.value == col_name)
|
||||||
|
{
|
||||||
|
if pos < params.len() {
|
||||||
|
// Hole aktuellen FK-Wert (als String)
|
||||||
|
let current_value = value_to_string(¶ms[pos]);
|
||||||
|
|
||||||
|
// Versuche zu remappen
|
||||||
|
let new_value = pk_context.remap_fk_value(ref_table, ref_col, ¤t_value);
|
||||||
|
|
||||||
|
if new_value != current_value {
|
||||||
|
// Ersetze den Parameter-Wert
|
||||||
|
params[pos] = SqliteValue::Text(new_value);
|
||||||
|
eprintln!(
|
||||||
|
"DEBUG: Remapped FK {}={} to {:?}",
|
||||||
|
col_name, current_value, params[pos]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hilfsfunktion: Konvertiert SqliteValue zu String für Vergleiche
|
||||||
|
fn value_to_string(value: &SqliteValue) -> String {
|
||||||
|
match value {
|
||||||
|
SqliteValue::Null => "NULL".to_string(),
|
||||||
|
SqliteValue::Integer(i) => i.to_string(),
|
||||||
|
SqliteValue::Real(r) => r.to_string(),
|
||||||
|
SqliteValue::Text(s) => s.clone(),
|
||||||
|
SqliteValue::Blob(b) => format!("BLOB({} bytes)", b.len()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrahiert PK-Werte aus einer RETURNING Row
|
||||||
|
fn extract_pk_values_from_row(
|
||||||
|
row: &rusqlite::Row,
|
||||||
|
pk_columns: &[String],
|
||||||
|
) -> Result<PkValues, DatabaseError> {
|
||||||
|
let mut pk_values = PkValues::new();
|
||||||
|
|
||||||
|
for (idx, pk_col) in pk_columns.iter().enumerate() {
|
||||||
|
// RETURNING gibt PKs in der Reihenfolge zurück, wie sie im RETURNING Clause stehen
|
||||||
|
let value: String = row.get(idx).map_err(|e| DatabaseError::ExecutionError {
|
||||||
|
sql: "RETURNING clause".to_string(),
|
||||||
|
reason: format!("Failed to extract PK column '{}': {}", pk_col, e),
|
||||||
|
table: None,
|
||||||
|
})?;
|
||||||
|
pk_values.insert(pk_col.clone(), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(pk_values)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,6 +71,8 @@ pub fn run() {
|
|||||||
database::list_vaults,
|
database::list_vaults,
|
||||||
database::open_encrypted_database,
|
database::open_encrypted_database,
|
||||||
database::sql_execute,
|
database::sql_execute,
|
||||||
|
database::sql_execute_with_crdt,
|
||||||
|
database::sql_query_with_crdt,
|
||||||
database::sql_select,
|
database::sql_select,
|
||||||
database::vault_exists,
|
database::vault_exists,
|
||||||
extension::database::extension_sql_execute,
|
extension::database::extension_sql_execute,
|
||||||
|
|||||||
@ -2,7 +2,7 @@ export default defineAppConfig({
|
|||||||
ui: {
|
ui: {
|
||||||
colors: {
|
colors: {
|
||||||
primary: 'sky',
|
primary: 'sky',
|
||||||
secondary: 'purple',
|
secondary: 'fuchsia',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<UApp :locale="locales[locale]">
|
<UApp :locale="locales[locale]">
|
||||||
<NuxtLayout>
|
<div data-vaul-drawer-wrapper>
|
||||||
<NuxtPage />
|
<NuxtLayout>
|
||||||
</NuxtLayout>
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
</UApp>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
169
src/components/haex/desktop/extension-frame.vue
Normal file
169
src/components/haex/desktop/extension-frame.vue
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full relative">
|
||||||
|
<!-- Error overlay for dev extensions when server is not reachable -->
|
||||||
|
<div
|
||||||
|
v-if="extension?.devServerUrl && hasError"
|
||||||
|
class="absolute inset-0 bg-white dark:bg-gray-900 flex items-center justify-center p-8"
|
||||||
|
>
|
||||||
|
<div class="max-w-md space-y-4 text-center">
|
||||||
|
<UIcon
|
||||||
|
name="i-heroicons-exclamation-circle"
|
||||||
|
class="w-16 h-16 mx-auto text-yellow-500"
|
||||||
|
/>
|
||||||
|
<h3 class="text-lg font-semibold">Dev Server Not Reachable</h3>
|
||||||
|
<p class="text-sm opacity-70">
|
||||||
|
The dev server at {{ extension.devServerUrl }} is not reachable.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="bg-gray-100 dark:bg-gray-800 p-4 rounded text-left text-xs font-mono"
|
||||||
|
>
|
||||||
|
<p class="opacity-70 mb-2">To start the dev server:</p>
|
||||||
|
<code class="block">cd /path/to/extension</code>
|
||||||
|
<code class="block">npm run dev</code>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
label="Retry"
|
||||||
|
@click="retryLoad"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Spinner -->
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="absolute inset-0 bg-white dark:bg-gray-900 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<div
|
||||||
|
class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"
|
||||||
|
></div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Loading extension...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<iframe
|
||||||
|
ref="iframeRef"
|
||||||
|
:class="[
|
||||||
|
'w-full h-full border-0 transition-all duration-1000 ease-out',
|
||||||
|
isLoading ? 'opacity-0 scale-0' : 'opacity-100 scale-100',
|
||||||
|
]"
|
||||||
|
:src="extensionUrl"
|
||||||
|
:sandbox="sandboxAttributes"
|
||||||
|
allow="autoplay; speaker-selection; encrypted-media;"
|
||||||
|
@load="handleIframeLoad"
|
||||||
|
@error="hasError = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
EXTENSION_PROTOCOL_PREFIX,
|
||||||
|
EXTENSION_PROTOCOL_NAME,
|
||||||
|
} from '~/config/constants'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
extensionId: string
|
||||||
|
windowId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const extensionsStore = useExtensionsStore()
|
||||||
|
const { platform } = useDeviceStore()
|
||||||
|
|
||||||
|
const iframeRef = useTemplateRef('iframeRef')
|
||||||
|
const hasError = ref(false)
|
||||||
|
const isLoading = ref(true)
|
||||||
|
|
||||||
|
// Convert windowId to ref for reactive tracking
|
||||||
|
const windowIdRef = toRef(props, 'windowId')
|
||||||
|
|
||||||
|
const extension = computed(() => {
|
||||||
|
return extensionsStore.availableExtensions.find(
|
||||||
|
(ext) => ext.id === props.extensionId,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleIframeLoad = () => {
|
||||||
|
// Delay the fade-in slightly to allow window animation to mostly complete
|
||||||
|
setTimeout(() => {
|
||||||
|
isLoading.value = false
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sandboxDefault = ['allow-scripts'] as const
|
||||||
|
|
||||||
|
const sandboxAttributes = computed(() => {
|
||||||
|
return extension.value?.devServerUrl
|
||||||
|
? [...sandboxDefault, 'allow-same-origin'].join(' ')
|
||||||
|
: sandboxDefault.join(' ')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generate extension URL
|
||||||
|
const extensionUrl = computed(() => {
|
||||||
|
if (!extension.value) return ''
|
||||||
|
|
||||||
|
const { publicKey, name, version, devServerUrl } = extension.value
|
||||||
|
const assetPath = 'index.html'
|
||||||
|
|
||||||
|
if (!publicKey || !name || !version) {
|
||||||
|
console.error('Missing required extension fields')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// If dev server URL is provided, load directly from dev server
|
||||||
|
if (devServerUrl) {
|
||||||
|
const cleanUrl = devServerUrl.replace(/\/$/, '')
|
||||||
|
const cleanPath = assetPath.replace(/^\//, '')
|
||||||
|
return cleanPath ? `${cleanUrl}/${cleanPath}` : cleanUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionInfo = {
|
||||||
|
name,
|
||||||
|
publicKey,
|
||||||
|
version,
|
||||||
|
}
|
||||||
|
const encodedInfo = btoa(JSON.stringify(extensionInfo))
|
||||||
|
|
||||||
|
if (platform === 'android' || platform === 'windows') {
|
||||||
|
// Android: Tauri uses http://{scheme}.localhost format
|
||||||
|
return `http://${EXTENSION_PROTOCOL_NAME}.localhost/${encodedInfo}/${assetPath}`
|
||||||
|
} else {
|
||||||
|
// Desktop: Use custom protocol with base64 as host
|
||||||
|
return `${EXTENSION_PROTOCOL_PREFIX}${encodedInfo}/${assetPath}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const retryLoad = () => {
|
||||||
|
hasError.value = false
|
||||||
|
if (iframeRef.value) {
|
||||||
|
//iframeRef.value.src = iframeRef.value.src // Force reload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize extension message handler to set up context
|
||||||
|
useExtensionMessageHandler(iframeRef, extension, windowIdRef)
|
||||||
|
|
||||||
|
// Additional explicit registration on mount to ensure iframe is registered
|
||||||
|
onMounted(() => {
|
||||||
|
// Wait for iframe to be ready
|
||||||
|
if (iframeRef.value && extension.value) {
|
||||||
|
console.log(
|
||||||
|
'[ExtensionFrame] Manually registering iframe on mount',
|
||||||
|
extension.value.name,
|
||||||
|
'windowId:',
|
||||||
|
props.windowId,
|
||||||
|
)
|
||||||
|
registerExtensionIFrame(iframeRef.value, extension.value, props.windowId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Explicit cleanup before unmount
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (iframeRef.value) {
|
||||||
|
console.log('[ExtensionFrame] Unregistering iframe on unmount')
|
||||||
|
unregisterExtensionIFrame(iframeRef.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@ -1,29 +1,69 @@
|
|||||||
<template>
|
<template>
|
||||||
<UContextMenu :items="contextMenuItems">
|
<div>
|
||||||
<div
|
<UiDialogConfirm
|
||||||
ref="draggableEl"
|
v-model:open="showUninstallDialog"
|
||||||
:style="style"
|
:title="t('confirmUninstall.title')"
|
||||||
class="select-none cursor-grab active:cursor-grabbing"
|
:description="t('confirmUninstall.message', { name: label })"
|
||||||
@pointerdown="handlePointerDown"
|
:confirm-label="t('confirmUninstall.confirm')"
|
||||||
@pointermove="handlePointerMove"
|
:abort-label="t('confirmUninstall.cancel')"
|
||||||
@pointerup="handlePointerUp"
|
confirm-icon="i-heroicons-trash"
|
||||||
@dblclick="handleOpen"
|
@confirm="handleConfirmUninstall"
|
||||||
>
|
/>
|
||||||
<div class="flex flex-col items-center gap-1 p-2">
|
|
||||||
<div
|
<UContextMenu :items="contextMenuItems">
|
||||||
class="w-16 h-16 flex items-center justify-center bg-white/90 dark:bg-gray-800/90 rounded-lg shadow-lg hover:shadow-xl transition-shadow"
|
<div
|
||||||
>
|
ref="draggableEl"
|
||||||
<img v-if="icon" :src="icon" :alt="label" class="w-12 h-12 object-contain" />
|
:style="style"
|
||||||
<Icon v-else name="i-heroicons-puzzle-piece-solid" class="w-12 h-12 text-gray-500" />
|
class="select-none cursor-grab active:cursor-grabbing"
|
||||||
|
@pointerdown.left="handlePointerDown"
|
||||||
|
@pointermove="handlePointerMove"
|
||||||
|
@pointerup="handlePointerUp"
|
||||||
|
@click.left="handleClick"
|
||||||
|
@dblclick="handleDoubleClick"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-2 p-3 group">
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'w-20 h-20 flex items-center justify-center rounded-2xl transition-all duration-200 ease-out',
|
||||||
|
'backdrop-blur-sm border',
|
||||||
|
isSelected
|
||||||
|
? 'bg-white/95 dark:bg-gray-800/95 border-blue-500 dark:border-blue-400 shadow-lg scale-105'
|
||||||
|
: 'bg-white/80 dark:bg-gray-800/80 border-gray-200/50 dark:border-gray-700/50 hover:bg-white/90 dark:hover:bg-gray-800/90 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-md hover:scale-105',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="icon"
|
||||||
|
:src="icon"
|
||||||
|
:alt="label"
|
||||||
|
class="w-14 h-14 object-contain transition-transform duration-200"
|
||||||
|
:class="{ 'scale-110': isSelected }"
|
||||||
|
/>
|
||||||
|
<UIcon
|
||||||
|
v-else
|
||||||
|
name="i-heroicons-puzzle-piece-solid"
|
||||||
|
:class="[
|
||||||
|
'w-14 h-14 transition-all duration-200',
|
||||||
|
isSelected
|
||||||
|
? 'text-blue-500 dark:text-blue-400 scale-110'
|
||||||
|
: 'text-gray-400 dark:text-gray-500 group-hover:text-gray-500 dark:group-hover:text-gray-400',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'text-xs text-center max-w-24 truncate px-3 py-1.5 rounded-lg transition-all duration-200',
|
||||||
|
'backdrop-blur-sm',
|
||||||
|
isSelected
|
||||||
|
? 'bg-white/95 dark:bg-gray-800/95 text-gray-900 dark:text-gray-100 font-medium shadow-md'
|
||||||
|
: 'bg-white/70 dark:bg-gray-800/70 text-gray-700 dark:text-gray-300 group-hover:bg-white/85 dark:group-hover:bg-gray-800/85',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
|
||||||
class="text-xs text-center max-w-20 truncate bg-white/80 dark:bg-gray-800/80 px-2 py-1 rounded shadow"
|
|
||||||
>
|
|
||||||
{{ label }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</UContextMenu>
|
||||||
</UContextMenu>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -39,24 +79,51 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
positionChanged: [id: string, x: number, y: number]
|
positionChanged: [id: string, x: number, y: number]
|
||||||
open: [itemType: string, referenceId: string]
|
|
||||||
uninstall: [itemType: string, referenceId: string]
|
|
||||||
dragStart: [id: string, itemType: string, referenceId: string]
|
dragStart: [id: string, itemType: string, referenceId: string]
|
||||||
dragEnd: []
|
dragEnd: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const desktopStore = useDesktopStore()
|
const desktopStore = useDesktopStore()
|
||||||
|
const showUninstallDialog = ref(false)
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const isSelected = computed(() => desktopStore.isItemSelected(props.id))
|
||||||
|
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
// Prevent selection during drag
|
||||||
|
if (isDragging.value) return
|
||||||
|
|
||||||
|
desktopStore.toggleSelection(props.id, e.ctrlKey || e.metaKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUninstallClick = () => {
|
||||||
|
showUninstallDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmUninstall = async () => {
|
||||||
|
showUninstallDialog.value = false
|
||||||
|
await desktopStore.uninstallDesktopItem(
|
||||||
|
props.id,
|
||||||
|
props.itemType,
|
||||||
|
props.referenceId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const contextMenuItems = computed(() =>
|
const contextMenuItems = computed(() =>
|
||||||
desktopStore.getContextMenuItems(
|
desktopStore.getContextMenuItems(
|
||||||
props.id,
|
props.id,
|
||||||
props.itemType,
|
props.itemType,
|
||||||
props.referenceId,
|
props.referenceId,
|
||||||
handleOpen,
|
handleUninstallClick,
|
||||||
handleUninstall,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Inject viewport size from parent desktop
|
||||||
|
const viewportSize = inject<{
|
||||||
|
width: Ref<number>
|
||||||
|
height: Ref<number>
|
||||||
|
}>('viewportSize')
|
||||||
|
|
||||||
const draggableEl = ref<HTMLElement>()
|
const draggableEl = ref<HTMLElement>()
|
||||||
const x = ref(props.initialX)
|
const x = ref(props.initialX)
|
||||||
const y = ref(props.initialY)
|
const y = ref(props.initialY)
|
||||||
@ -64,6 +131,10 @@ const isDragging = ref(false)
|
|||||||
const offsetX = ref(0)
|
const offsetX = ref(0)
|
||||||
const offsetY = ref(0)
|
const offsetY = ref(0)
|
||||||
|
|
||||||
|
// Icon dimensions (approximate)
|
||||||
|
const iconWidth = 120 // Matches design in template
|
||||||
|
const iconHeight = 140
|
||||||
|
|
||||||
const style = computed(() => ({
|
const style = computed(() => ({
|
||||||
position: 'absolute' as const,
|
position: 'absolute' as const,
|
||||||
left: `${x.value}px`,
|
left: `${x.value}px`,
|
||||||
@ -105,15 +176,52 @@ const handlePointerUp = (e: PointerEvent) => {
|
|||||||
if (draggableEl.value) {
|
if (draggableEl.value) {
|
||||||
draggableEl.value.releasePointerCapture(e.pointerId)
|
draggableEl.value.releasePointerCapture(e.pointerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snap icon to viewport bounds if outside
|
||||||
|
if (viewportSize) {
|
||||||
|
const maxX = Math.max(0, viewportSize.width.value - iconWidth)
|
||||||
|
const maxY = Math.max(0, viewportSize.height.value - iconHeight)
|
||||||
|
x.value = Math.max(0, Math.min(maxX, x.value))
|
||||||
|
y.value = Math.max(0, Math.min(maxY, y.value))
|
||||||
|
}
|
||||||
|
|
||||||
emit('dragEnd')
|
emit('dragEnd')
|
||||||
emit('positionChanged', props.id, x.value, y.value)
|
emit('positionChanged', props.id, x.value, y.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleDoubleClick = () => {
|
||||||
emit('open', props.itemType, props.referenceId)
|
// Get icon position and size for animation
|
||||||
}
|
if (draggableEl.value) {
|
||||||
|
const rect = draggableEl.value.getBoundingClientRect()
|
||||||
const handleUninstall = () => {
|
const sourcePosition = {
|
||||||
emit('uninstall', props.itemType, props.referenceId)
|
x: rect.left,
|
||||||
|
y: rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
}
|
||||||
|
desktopStore.openDesktopItem(
|
||||||
|
props.itemType,
|
||||||
|
props.referenceId,
|
||||||
|
sourcePosition,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
desktopStore.openDesktopItem(props.itemType, props.referenceId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<i18n lang="yaml">
|
||||||
|
de:
|
||||||
|
confirmUninstall:
|
||||||
|
title: Erweiterung deinstallieren
|
||||||
|
message: Möchten Sie die Erweiterung '{name}' wirklich deinstallieren? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||||
|
confirm: Deinstallieren
|
||||||
|
cancel: Abbrechen
|
||||||
|
|
||||||
|
en:
|
||||||
|
confirmUninstall:
|
||||||
|
title: Uninstall Extension
|
||||||
|
message: Do you really want to uninstall the extension '{name}'? This action cannot be undone.
|
||||||
|
confirm: Uninstall
|
||||||
|
cancel: Cancel
|
||||||
|
</i18n>
|
||||||
|
|||||||
@ -1,175 +1,415 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="w-full h-full relative overflow-hidden bg-gradient-to-br from-blue-50 to-blue-100 dark:from-gray-900 dark:to-gray-800"
|
ref="desktopEl"
|
||||||
|
class="w-full h-full relative overflow-hidden"
|
||||||
|
@click.self.stop="handleDesktopClick"
|
||||||
>
|
>
|
||||||
<!-- Dropzones (only visible during drag) -->
|
<Swiper
|
||||||
<Transition name="slide-down">
|
:modules="[SwiperNavigation]"
|
||||||
<div
|
:slides-per-view="1"
|
||||||
v-if="isDragging"
|
:space-between="0"
|
||||||
class="absolute top-0 left-0 right-0 flex gap-2 p-4 z-50"
|
:initial-slide="currentWorkspaceIndex"
|
||||||
|
:speed="300"
|
||||||
|
:touch-angle="45"
|
||||||
|
:threshold="10"
|
||||||
|
:no-swiping="true"
|
||||||
|
no-swiping-class="no-swipe"
|
||||||
|
:allow-touch-move="allowSwipe"
|
||||||
|
class="w-full h-full"
|
||||||
|
@swiper="onSwiperInit"
|
||||||
|
@slide-change="onSlideChange"
|
||||||
|
>
|
||||||
|
<SwiperSlide
|
||||||
|
v-for="workspace in workspaces"
|
||||||
|
:key="workspace.id"
|
||||||
|
class="w-full h-full"
|
||||||
>
|
>
|
||||||
<!-- Remove from Desktop Dropzone -->
|
|
||||||
<div
|
<div
|
||||||
ref="removeDropzoneEl"
|
class="w-full h-full relative bg-gradient-to-br from-gray-50 via-gray-100 to-gray-200 dark:from-gray-900 dark:via-gray-800 dark:to-gray-700"
|
||||||
class="flex-1 h-20 flex items-center justify-center gap-2 rounded-lg border-2 border-dashed transition-all"
|
@click.self.stop="handleDesktopClick"
|
||||||
:class="
|
@mousedown.left.self="handleAreaSelectStart"
|
||||||
isOverRemoveZone
|
|
||||||
? 'bg-orange-500/20 border-orange-500 dark:bg-orange-400/20 dark:border-orange-400'
|
|
||||||
: 'border-orange-500/50 dark:border-orange-400/50'
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<Icon
|
<!-- Grid Pattern Background -->
|
||||||
name="i-heroicons-x-mark"
|
<div
|
||||||
class="w-6 h-6"
|
class="absolute inset-0 pointer-events-none opacity-30"
|
||||||
:class="
|
:style="{
|
||||||
isOverRemoveZone
|
backgroundImage:
|
||||||
? 'text-orange-700 dark:text-orange-300'
|
'linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px)',
|
||||||
: 'text-orange-600 dark:text-orange-400'
|
backgroundSize: '32px 32px',
|
||||||
"
|
}"
|
||||||
/>
|
/>
|
||||||
<span
|
|
||||||
class="font-semibold"
|
|
||||||
:class="
|
|
||||||
isOverRemoveZone
|
|
||||||
? 'text-orange-700 dark:text-orange-300'
|
|
||||||
: 'text-orange-600 dark:text-orange-400'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Von Desktop entfernen
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Uninstall Dropzone -->
|
<!-- Snap Dropzones (only visible when window drag near edge) -->
|
||||||
<div
|
<Transition name="fade">
|
||||||
ref="uninstallDropzoneEl"
|
<div
|
||||||
class="flex-1 h-20 flex items-center justify-center gap-2 rounded-lg border-2 border-dashed transition-all"
|
v-if="showLeftSnapZone"
|
||||||
:class="
|
class="absolute left-0 top-0 bottom-0 w-1/2 bg-blue-500/20 border-2 border-blue-500 pointer-events-none backdrop-blur-sm z-40"
|
||||||
isOverUninstallZone
|
/>
|
||||||
? 'bg-red-500/20 border-red-500 dark:bg-red-400/20 dark:border-red-400'
|
</Transition>
|
||||||
: 'border-red-500/50 dark:border-red-400/50'
|
<Transition name="fade">
|
||||||
"
|
<div
|
||||||
>
|
v-if="showRightSnapZone"
|
||||||
<Icon
|
class="absolute right-0 top-0 bottom-0 w-1/2 bg-blue-500/20 border-2 border-blue-500 pointer-events-none backdrop-blur-sm z-40"
|
||||||
name="i-heroicons-trash"
|
/>
|
||||||
class="w-6 h-6"
|
</Transition>
|
||||||
:class="
|
|
||||||
isOverUninstallZone
|
<!-- Area Selection Box -->
|
||||||
? 'text-red-700 dark:text-red-300'
|
<div
|
||||||
: 'text-red-600 dark:text-red-400'
|
v-if="isAreaSelecting"
|
||||||
"
|
class="absolute bg-blue-500/20 border-2 border-blue-500 pointer-events-none z-30"
|
||||||
|
:style="selectionBoxStyle"
|
||||||
/>
|
/>
|
||||||
<span
|
|
||||||
class="font-semibold"
|
|
||||||
:class="
|
|
||||||
isOverUninstallZone
|
|
||||||
? 'text-red-700 dark:text-red-300'
|
|
||||||
: 'text-red-600 dark:text-red-400'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Deinstallieren
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<HaexDesktopIcon
|
<!-- Icons for this workspace -->
|
||||||
v-for="item in desktopItemIcons"
|
<HaexDesktopIcon
|
||||||
:key="item.id"
|
v-for="item in getWorkspaceIcons(workspace.id)"
|
||||||
:id="item.id"
|
:id="item.id"
|
||||||
:item-type="item.itemType"
|
:key="item.id"
|
||||||
:reference-id="item.referenceId"
|
:item-type="item.itemType"
|
||||||
:initial-x="item.positionX"
|
:reference-id="item.referenceId"
|
||||||
:initial-y="item.positionY"
|
:initial-x="item.positionX"
|
||||||
:label="item.label"
|
:initial-y="item.positionY"
|
||||||
:icon="item.icon"
|
:label="item.label"
|
||||||
@position-changed="handlePositionChanged"
|
:icon="item.icon"
|
||||||
@open="handleOpen"
|
class="no-swipe"
|
||||||
@drag-start="handleDragStart"
|
@position-changed="handlePositionChanged"
|
||||||
@drag-end="handleDragEnd"
|
@drag-start="handleDragStart"
|
||||||
@uninstall="handleUninstall"
|
@drag-end="handleDragEnd"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Windows for this workspace -->
|
||||||
|
<template
|
||||||
|
v-for="(window, index) in getWorkspaceWindows(workspace.id)"
|
||||||
|
:key="window.id"
|
||||||
|
>
|
||||||
|
<!-- Wrapper for Overview Mode Click/Drag -->
|
||||||
|
<div
|
||||||
|
v-if="false"
|
||||||
|
:style="
|
||||||
|
getOverviewWindowGridStyle(
|
||||||
|
index,
|
||||||
|
getWorkspaceWindows(workspace.id).length,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="absolute cursor-pointer group"
|
||||||
|
:draggable="true"
|
||||||
|
@dragstart="handleOverviewWindowDragStart($event, window.id)"
|
||||||
|
@dragend="handleOverviewWindowDragEnd"
|
||||||
|
@click="handleOverviewWindowClick(window.id)"
|
||||||
|
>
|
||||||
|
<!-- Overlay for click/drag events (prevents interaction with window content) -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 z-[100] bg-transparent group-hover:ring-4 group-hover:ring-purple-500 rounded-xl transition-all"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HaexDesktopWindow
|
||||||
|
:id="window.id"
|
||||||
|
:title="window.title"
|
||||||
|
:icon="window.icon"
|
||||||
|
:initial-x="window.x"
|
||||||
|
:initial-y="window.y"
|
||||||
|
:initial-width="window.width"
|
||||||
|
:initial-height="window.height"
|
||||||
|
:is-active="windowManager.isWindowActive(window.id)"
|
||||||
|
:source-x="window.sourceX"
|
||||||
|
:source-y="window.sourceY"
|
||||||
|
:source-width="window.sourceWidth"
|
||||||
|
:source-height="window.sourceHeight"
|
||||||
|
:is-opening="window.isOpening"
|
||||||
|
:is-closing="window.isClosing"
|
||||||
|
class="no-swipe pointer-events-none"
|
||||||
|
@close="windowManager.closeWindow(window.id)"
|
||||||
|
@minimize="windowManager.minimizeWindow(window.id)"
|
||||||
|
@activate="windowManager.activateWindow(window.id)"
|
||||||
|
@position-changed="
|
||||||
|
(x, y) => windowManager.updateWindowPosition(window.id, x, y)
|
||||||
|
"
|
||||||
|
@size-changed="
|
||||||
|
(width, height) =>
|
||||||
|
windowManager.updateWindowSize(window.id, width, height)
|
||||||
|
"
|
||||||
|
@drag-start="handleWindowDragStart(window.id)"
|
||||||
|
@drag-end="handleWindowDragEnd"
|
||||||
|
>
|
||||||
|
{{ window }}
|
||||||
|
<!-- System Window: Render Vue Component -->
|
||||||
|
<component
|
||||||
|
:is="getSystemWindowComponent(window.sourceId)"
|
||||||
|
v-if="window.type === 'system'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Extension Window: Render iFrame -->
|
||||||
|
<HaexDesktopExtensionFrame
|
||||||
|
v-else
|
||||||
|
:extension-id="window.sourceId"
|
||||||
|
:window-id="window.id"
|
||||||
|
/>
|
||||||
|
</HaexDesktopWindow>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Normal Mode (non-overview) -->
|
||||||
|
<HaexDesktopWindow
|
||||||
|
:id="window.id"
|
||||||
|
:title="window.title"
|
||||||
|
:icon="window.icon"
|
||||||
|
:initial-x="window.x"
|
||||||
|
:initial-y="window.y"
|
||||||
|
:initial-width="window.width"
|
||||||
|
:initial-height="window.height"
|
||||||
|
:is-active="windowManager.isWindowActive(window.id)"
|
||||||
|
:source-x="window.sourceX"
|
||||||
|
:source-y="window.sourceY"
|
||||||
|
:source-width="window.sourceWidth"
|
||||||
|
:source-height="window.sourceHeight"
|
||||||
|
:is-opening="window.isOpening"
|
||||||
|
:is-closing="window.isClosing"
|
||||||
|
class="no-swipe"
|
||||||
|
@close="windowManager.closeWindow(window.id)"
|
||||||
|
@minimize="windowManager.minimizeWindow(window.id)"
|
||||||
|
@activate="windowManager.activateWindow(window.id)"
|
||||||
|
@position-changed="
|
||||||
|
(x, y) => windowManager.updateWindowPosition(window.id, x, y)
|
||||||
|
"
|
||||||
|
@size-changed="
|
||||||
|
(width, height) =>
|
||||||
|
windowManager.updateWindowSize(window.id, width, height)
|
||||||
|
"
|
||||||
|
@drag-start="handleWindowDragStart(window.id)"
|
||||||
|
@drag-end="handleWindowDragEnd"
|
||||||
|
>
|
||||||
|
<!-- System Window: Render Vue Component -->
|
||||||
|
<component
|
||||||
|
:is="getSystemWindowComponent(window.sourceId)"
|
||||||
|
v-if="window.type === 'system'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Extension Window: Render iFrame -->
|
||||||
|
<HaexDesktopExtensionFrame
|
||||||
|
v-else
|
||||||
|
:extension-id="window.sourceId"
|
||||||
|
:window-id="window.id"
|
||||||
|
/>
|
||||||
|
</HaexDesktopWindow>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</SwiperSlide>
|
||||||
|
</Swiper>
|
||||||
|
|
||||||
|
<!-- Workspace Drawer -->
|
||||||
|
<UDrawer
|
||||||
|
v-model:open="isOverviewMode"
|
||||||
|
direction="left"
|
||||||
|
:dismissible="false"
|
||||||
|
:overlay="false"
|
||||||
|
:modal="false"
|
||||||
|
should-scale-background
|
||||||
|
set-background-color-on-scale
|
||||||
|
title="Workspaces"
|
||||||
|
description="Workspaces"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<div class="p-6 h-full overflow-y-auto">
|
||||||
|
<UButton
|
||||||
|
block
|
||||||
|
trailing-icon="mdi-close"
|
||||||
|
class="text-2xl font-bold ext-gray-900 dark:text-white mb-4"
|
||||||
|
@click="isOverviewMode = false"
|
||||||
|
>
|
||||||
|
Workspaces
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<!-- Workspace Cards -->
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<HaexWorkspaceCard
|
||||||
|
v-for="workspace in workspaces"
|
||||||
|
:key="workspace.id"
|
||||||
|
:workspace
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add New Workspace Button -->
|
||||||
|
<UButton
|
||||||
|
block
|
||||||
|
variant="outline"
|
||||||
|
class="mt-6"
|
||||||
|
@click="handleAddWorkspace"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-heroicons-plus" />
|
||||||
|
</template>
|
||||||
|
New Workspace
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UDrawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Swiper, SwiperSlide } from 'swiper/vue'
|
||||||
|
import { Navigation } from 'swiper/modules'
|
||||||
|
import 'swiper/css'
|
||||||
|
import 'swiper/css/navigation'
|
||||||
|
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { haexDesktopItems } from '~~/src-tauri/database/schemas'
|
||||||
|
|
||||||
|
const SwiperNavigation = Navigation
|
||||||
|
|
||||||
const desktopStore = useDesktopStore()
|
const desktopStore = useDesktopStore()
|
||||||
const extensionsStore = useExtensionsStore()
|
const extensionsStore = useExtensionsStore()
|
||||||
const router = useRouter()
|
const windowManager = useWindowManagerStore()
|
||||||
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
|
||||||
|
const { currentVault } = storeToRefs(useVaultStore())
|
||||||
const { desktopItems } = storeToRefs(desktopStore)
|
const { desktopItems } = storeToRefs(desktopStore)
|
||||||
const { availableExtensions } = storeToRefs(extensionsStore)
|
const { availableExtensions } = storeToRefs(extensionsStore)
|
||||||
|
const {
|
||||||
|
currentWorkspace,
|
||||||
|
currentWorkspaceIndex,
|
||||||
|
workspaces,
|
||||||
|
swiperInstance,
|
||||||
|
allowSwipe,
|
||||||
|
isOverviewMode,
|
||||||
|
} = storeToRefs(workspaceStore)
|
||||||
|
|
||||||
// Drag state
|
// Swiper instance
|
||||||
|
|
||||||
|
// Control Swiper touch behavior (disable during icon/window drag)
|
||||||
|
|
||||||
|
// Mouse position tracking
|
||||||
|
const { x: mouseX } = useMouse()
|
||||||
|
|
||||||
|
// Desktop element ref
|
||||||
|
const desktopEl = useTemplateRef('desktopEl')
|
||||||
|
|
||||||
|
// Track desktop viewport size reactively
|
||||||
|
const { width: viewportWidth, height: viewportHeight } =
|
||||||
|
useElementSize(desktopEl)
|
||||||
|
|
||||||
|
// Provide viewport size to child windows
|
||||||
|
provide('viewportSize', {
|
||||||
|
width: viewportWidth,
|
||||||
|
height: viewportHeight,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Area selection state
|
||||||
|
const isAreaSelecting = ref(false)
|
||||||
|
const selectionStart = ref({ x: 0, y: 0 })
|
||||||
|
const selectionEnd = ref({ x: 0, y: 0 })
|
||||||
|
|
||||||
|
const selectionBoxStyle = computed(() => {
|
||||||
|
const x1 = Math.min(selectionStart.value.x, selectionEnd.value.x)
|
||||||
|
const y1 = Math.min(selectionStart.value.y, selectionEnd.value.y)
|
||||||
|
const x2 = Math.max(selectionStart.value.x, selectionEnd.value.x)
|
||||||
|
const y2 = Math.max(selectionStart.value.y, selectionEnd.value.y)
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: `${x1}px`,
|
||||||
|
top: `${y1}px`,
|
||||||
|
width: `${x2 - x1}px`,
|
||||||
|
height: `${y2 - y1}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Drag state for desktop icons
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
const currentDraggedItemId = ref<string>()
|
const currentDraggedItemId = ref<string>()
|
||||||
const currentDraggedItemType = ref<string>()
|
const currentDraggedItemType = ref<string>()
|
||||||
const currentDraggedReferenceId = ref<string>()
|
const currentDraggedReferenceId = ref<string>()
|
||||||
|
|
||||||
|
// Window drag state for snap zones
|
||||||
|
const isWindowDragging = ref(false)
|
||||||
|
const currentDraggingWindowId = ref<string | null>(null)
|
||||||
|
const snapEdgeThreshold = 50 // pixels from edge to show snap zone
|
||||||
|
|
||||||
|
// Computed visibility for snap zones (uses mouseX from above)
|
||||||
|
const showLeftSnapZone = computed(() => {
|
||||||
|
return isWindowDragging.value && mouseX.value <= snapEdgeThreshold
|
||||||
|
})
|
||||||
|
|
||||||
|
const showRightSnapZone = computed(() => {
|
||||||
|
if (!isWindowDragging.value) return false
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
return mouseX.value >= viewportWidth - snapEdgeThreshold
|
||||||
|
})
|
||||||
|
|
||||||
// Dropzone refs
|
// Dropzone refs
|
||||||
const removeDropzoneEl = ref<HTMLElement>()
|
/* const removeDropzoneEl = ref<HTMLElement>()
|
||||||
const uninstallDropzoneEl = ref<HTMLElement>()
|
const uninstallDropzoneEl = ref<HTMLElement>() */
|
||||||
|
|
||||||
// Setup dropzones with VueUse
|
// Setup dropzones with VueUse
|
||||||
const { isOverDropZone: isOverRemoveZone } = useDropZone(removeDropzoneEl, {
|
/* const { isOverDropZone: isOverRemoveZone } = useDropZone(removeDropzoneEl, {
|
||||||
onDrop: () => {
|
onDrop: () => {
|
||||||
if (currentDraggedItemId.value) {
|
if (currentDraggedItemId.value) {
|
||||||
handleRemoveFromDesktop(currentDraggedItemId.value)
|
handleRemoveFromDesktop(currentDraggedItemId.value)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
}) */
|
||||||
|
|
||||||
const { isOverDropZone: isOverUninstallZone } = useDropZone(uninstallDropzoneEl, {
|
/* const { isOverDropZone: isOverUninstallZone } = useDropZone(uninstallDropzoneEl, {
|
||||||
onDrop: () => {
|
onDrop: () => {
|
||||||
if (currentDraggedItemType.value && currentDraggedReferenceId.value) {
|
if (currentDraggedItemType.value && currentDraggedReferenceId.value) {
|
||||||
handleUninstall(currentDraggedItemType.value, currentDraggedReferenceId.value)
|
handleUninstall(currentDraggedItemType.value, currentDraggedReferenceId.value)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
}) */
|
||||||
|
|
||||||
interface DesktopItemIcon extends IDesktopItem {
|
// Get icons for a specific workspace
|
||||||
label: string
|
const getWorkspaceIcons = (workspaceId: string) => {
|
||||||
icon?: string
|
return desktopItems.value
|
||||||
|
.filter((item) => item.workspaceId === workspaceId)
|
||||||
|
.map((item) => {
|
||||||
|
if (item.itemType === 'extension') {
|
||||||
|
const extension = availableExtensions.value.find(
|
||||||
|
(ext) => ext.id === item.referenceId,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
label: extension?.name || 'Unknown',
|
||||||
|
icon: extension?.icon || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.itemType === 'file') {
|
||||||
|
// Für später: file handling
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
label: item.referenceId,
|
||||||
|
icon: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.itemType === 'folder') {
|
||||||
|
// Für später: folder handling
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
label: item.referenceId,
|
||||||
|
icon: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
label: item.referenceId,
|
||||||
|
icon: undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const desktopItemIcons = computed<DesktopItemIcon[]>(() => {
|
// Get windows for a specific workspace
|
||||||
return desktopItems.value.map((item) => {
|
const getWorkspaceWindows = (workspaceId: string) => {
|
||||||
if (item.itemType === 'extension') {
|
return windowManager.windows.filter(
|
||||||
const extension = availableExtensions.value.find(
|
(w) => w.workspaceId === workspaceId && !w.isMinimized,
|
||||||
(ext) => ext.id === item.referenceId,
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
return {
|
// Get Vue Component for system window
|
||||||
...item,
|
const getSystemWindowComponent = (sourceId: string) => {
|
||||||
label: extension?.name || 'Unknown',
|
const systemWindow = windowManager.getSystemWindow(sourceId)
|
||||||
icon: extension?.icon || '',
|
return systemWindow?.component
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (item.itemType === 'file') {
|
|
||||||
// Für später: file handling
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
label: item.referenceId,
|
|
||||||
icon: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.itemType === 'folder') {
|
|
||||||
// Für später: folder handling
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
label: item.referenceId,
|
|
||||||
icon: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
label: item.referenceId,
|
|
||||||
icon: undefined,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const handlePositionChanged = async (id: string, x: number, y: number) => {
|
const handlePositionChanged = async (id: string, x: number, y: number) => {
|
||||||
try {
|
try {
|
||||||
@ -179,55 +419,335 @@ const handlePositionChanged = async (id: string, x: number, y: number) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const localePath = useLocalePath()
|
|
||||||
|
|
||||||
const handleOpen = (itemType: string, referenceId: string) => {
|
|
||||||
if (itemType === 'extension') {
|
|
||||||
router.push(
|
|
||||||
localePath({
|
|
||||||
name: 'extension',
|
|
||||||
params: { extensionId: referenceId },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Für später: file und folder handling
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragStart = (id: string, itemType: string, referenceId: string) => {
|
const handleDragStart = (id: string, itemType: string, referenceId: string) => {
|
||||||
isDragging.value = true
|
isDragging.value = true
|
||||||
currentDraggedItemId.value = id
|
currentDraggedItemId.value = id
|
||||||
currentDraggedItemType.value = itemType
|
currentDraggedItemType.value = itemType
|
||||||
currentDraggedReferenceId.value = referenceId
|
currentDraggedReferenceId.value = referenceId
|
||||||
|
allowSwipe.value = false // Disable Swiper during icon drag
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = async () => {
|
||||||
|
// Cleanup drag state
|
||||||
isDragging.value = false
|
isDragging.value = false
|
||||||
currentDraggedItemId.value = undefined
|
currentDraggedItemId.value = undefined
|
||||||
currentDraggedItemType.value = undefined
|
currentDraggedItemType.value = undefined
|
||||||
currentDraggedReferenceId.value = undefined
|
currentDraggedReferenceId.value = undefined
|
||||||
|
allowSwipe.value = true // Re-enable Swiper after drag
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUninstall = async (itemType: string, referenceId: string) => {
|
// Move desktop item to different workspace
|
||||||
if (itemType === 'extension') {
|
const moveItemToWorkspace = async (
|
||||||
try {
|
itemId: string,
|
||||||
const extension = availableExtensions.value.find((ext) => ext.id === referenceId)
|
targetWorkspaceId: string,
|
||||||
if (extension) {
|
) => {
|
||||||
await extensionsStore.removeExtensionAsync(
|
const item = desktopItems.value.find((i) => i.id === itemId)
|
||||||
extension.publicKey,
|
if (!item) return
|
||||||
extension.name,
|
|
||||||
extension.version,
|
try {
|
||||||
)
|
if (!currentVault.value?.drizzle) return
|
||||||
// Reload extensions after uninstall
|
|
||||||
await extensionsStore.loadExtensionsAsync()
|
await currentVault.value.drizzle
|
||||||
}
|
.update(haexDesktopItems)
|
||||||
} catch (error) {
|
.set({ workspaceId: targetWorkspaceId })
|
||||||
console.error('Fehler beim Deinstallieren:', error)
|
.where(eq(haexDesktopItems.id, itemId))
|
||||||
}
|
|
||||||
|
// Update local state
|
||||||
|
item.workspaceId = targetWorkspaceId
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Verschieben des Items:', error)
|
||||||
}
|
}
|
||||||
// Für später: file und folder handling
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDesktopClick = () => {
|
||||||
|
// Only clear selection if it was a simple click, not an area selection
|
||||||
|
// Check if we just finished an area selection (box size > threshold)
|
||||||
|
const boxWidth = Math.abs(selectionEnd.value.x - selectionStart.value.x)
|
||||||
|
const boxHeight = Math.abs(selectionEnd.value.y - selectionStart.value.y)
|
||||||
|
|
||||||
|
// If box is larger than 5px in any direction, it was an area select, not a click
|
||||||
|
if (boxWidth > 5 || boxHeight > 5) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
desktopStore.clearSelection()
|
||||||
|
|
||||||
|
isOverviewMode.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWindowDragStart = (windowId: string) => {
|
||||||
|
isWindowDragging.value = true
|
||||||
|
currentDraggingWindowId.value = windowId
|
||||||
|
allowSwipe.value = false // Disable Swiper during window drag
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWindowDragEnd = async () => {
|
||||||
|
// Window handles snapping itself, we just need to cleanup state
|
||||||
|
isWindowDragging.value = false
|
||||||
|
currentDraggingWindowId.value = null
|
||||||
|
allowSwipe.value = true // Re-enable Swiper after drag
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move window to different workspace
|
||||||
|
const moveWindowToWorkspace = async (
|
||||||
|
windowId: string,
|
||||||
|
targetWorkspaceId: string,
|
||||||
|
) => {
|
||||||
|
const window = windowManager.windows.find((w) => w.id === windowId)
|
||||||
|
if (!window) return
|
||||||
|
|
||||||
|
// Update window's workspaceId
|
||||||
|
window.workspaceId = targetWorkspaceId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Area selection handlers
|
||||||
|
const handleAreaSelectStart = (e: MouseEvent) => {
|
||||||
|
if (!desktopEl.value) return
|
||||||
|
|
||||||
|
const rect = desktopEl.value.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left
|
||||||
|
const y = e.clientY - rect.top
|
||||||
|
|
||||||
|
isAreaSelecting.value = true
|
||||||
|
selectionStart.value = { x, y }
|
||||||
|
selectionEnd.value = { x, y }
|
||||||
|
|
||||||
|
// Clear current selection
|
||||||
|
desktopStore.clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track mouse movement for area selection
|
||||||
|
useEventListener(window, 'mousemove', (e: MouseEvent) => {
|
||||||
|
if (isAreaSelecting.value && desktopEl.value) {
|
||||||
|
const rect = desktopEl.value.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left
|
||||||
|
const y = e.clientY - rect.top
|
||||||
|
|
||||||
|
selectionEnd.value = { x, y }
|
||||||
|
|
||||||
|
// Find all items within selection box
|
||||||
|
selectItemsInBox()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// End area selection
|
||||||
|
useEventListener(window, 'mouseup', () => {
|
||||||
|
if (isAreaSelecting.value) {
|
||||||
|
isAreaSelecting.value = false
|
||||||
|
|
||||||
|
// Reset selection coordinates after a short delay
|
||||||
|
// This allows handleDesktopClick to still check the box size
|
||||||
|
setTimeout(() => {
|
||||||
|
selectionStart.value = { x: 0, y: 0 }
|
||||||
|
selectionEnd.value = { x: 0, y: 0 }
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectItemsInBox = () => {
|
||||||
|
const x1 = Math.min(selectionStart.value.x, selectionEnd.value.x)
|
||||||
|
const y1 = Math.min(selectionStart.value.y, selectionEnd.value.y)
|
||||||
|
const x2 = Math.max(selectionStart.value.x, selectionEnd.value.x)
|
||||||
|
const y2 = Math.max(selectionStart.value.y, selectionEnd.value.y)
|
||||||
|
|
||||||
|
desktopStore.clearSelection()
|
||||||
|
|
||||||
|
desktopItems.value.forEach((item) => {
|
||||||
|
// Check if item position is within selection box
|
||||||
|
const itemX = item.positionX + 60 // Icon center (approx)
|
||||||
|
const itemY = item.positionY + 60
|
||||||
|
|
||||||
|
if (itemX >= x1 && itemX <= x2 && itemY >= y1 && itemY <= y2) {
|
||||||
|
desktopStore.toggleSelection(item.id, true) // true = add to selection
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swiper event handlers
|
||||||
|
const onSwiperInit = (swiper: SwiperType) => {
|
||||||
|
swiperInstance.value = swiper
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSlideChange = (swiper: SwiperType) => {
|
||||||
|
workspaceStore.switchToWorkspace(swiper.activeIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workspace control handlers
|
||||||
|
const handleAddWorkspace = async () => {
|
||||||
|
await workspaceStore.addWorkspaceAsync()
|
||||||
|
// Swiper will auto-slide to new workspace because we switch in addWorkspaceAsync
|
||||||
|
nextTick(() => {
|
||||||
|
if (swiperInstance.value) {
|
||||||
|
swiperInstance.value.slideTo(workspaces.value.length - 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSwitchToWorkspace = (index: number) => {
|
||||||
|
if (swiperInstance.value) {
|
||||||
|
swiperInstance.value.slideTo(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveWorkspace = async () => {
|
||||||
|
if (!currentWorkspace.value || workspaces.value.length <= 1) return
|
||||||
|
|
||||||
|
const currentIndex = currentWorkspaceIndex.value
|
||||||
|
await workspaceStore.removeWorkspaceAsync(currentWorkspace.value.id)
|
||||||
|
|
||||||
|
// Slide to adjusted index
|
||||||
|
nextTick(() => {
|
||||||
|
if (swiperInstance.value) {
|
||||||
|
const newIndex = Math.min(currentIndex, workspaces.value.length - 1)
|
||||||
|
swiperInstance.value.slideTo(newIndex)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawer handlers
|
||||||
|
const handleSwitchToWorkspaceFromDrawer = (index: number) => {
|
||||||
|
handleSwitchToWorkspace(index)
|
||||||
|
// Close drawer after switch
|
||||||
|
isOverviewMode.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDropWindowOnWorkspace = async (
|
||||||
|
event: DragEvent,
|
||||||
|
targetWorkspaceId: string,
|
||||||
|
) => {
|
||||||
|
// Get the window ID from drag data (will be set when we implement window dragging)
|
||||||
|
const windowId = event.dataTransfer?.getData('windowId')
|
||||||
|
if (windowId) {
|
||||||
|
await moveWindowToWorkspace(windowId, targetWorkspaceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overview Mode: Calculate grid positions and scale for windows
|
||||||
|
const getOverviewWindowGridStyle = (index: number, totalWindows: number) => {
|
||||||
|
if (!viewportWidth.value || !viewportHeight.value) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine grid layout based on number of windows
|
||||||
|
let cols = 1
|
||||||
|
let rows = 1
|
||||||
|
|
||||||
|
if (totalWindows === 1) {
|
||||||
|
cols = 1
|
||||||
|
rows = 1
|
||||||
|
} else if (totalWindows === 2) {
|
||||||
|
cols = 2
|
||||||
|
rows = 1
|
||||||
|
} else if (totalWindows <= 4) {
|
||||||
|
cols = 2
|
||||||
|
rows = 2
|
||||||
|
} else if (totalWindows <= 6) {
|
||||||
|
cols = 3
|
||||||
|
rows = 2
|
||||||
|
} else if (totalWindows <= 9) {
|
||||||
|
cols = 3
|
||||||
|
rows = 3
|
||||||
|
} else {
|
||||||
|
cols = 4
|
||||||
|
rows = Math.ceil(totalWindows / 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate grid cell position
|
||||||
|
const col = index % cols
|
||||||
|
const row = Math.floor(index / cols)
|
||||||
|
|
||||||
|
// Padding and gap
|
||||||
|
const padding = 40 // px from viewport edges
|
||||||
|
const gap = 30 // px between windows
|
||||||
|
|
||||||
|
// Available space
|
||||||
|
const availableWidth = viewportWidth.value - padding * 2 - gap * (cols - 1)
|
||||||
|
const availableHeight = viewportHeight.value - padding * 2 - gap * (rows - 1)
|
||||||
|
|
||||||
|
// Cell dimensions
|
||||||
|
const cellWidth = availableWidth / cols
|
||||||
|
const cellHeight = availableHeight / rows
|
||||||
|
|
||||||
|
// Window aspect ratio (assume 16:9 or use actual window dimensions)
|
||||||
|
const windowAspectRatio = 16 / 9
|
||||||
|
|
||||||
|
// Calculate scale to fit window in cell
|
||||||
|
const targetWidth = cellWidth
|
||||||
|
const targetHeight = cellHeight
|
||||||
|
const targetAspect = targetWidth / targetHeight
|
||||||
|
|
||||||
|
let scale = 0.25 // Default scale
|
||||||
|
let scaledWidth = 800 * scale
|
||||||
|
let scaledHeight = 600 * scale
|
||||||
|
|
||||||
|
if (targetAspect > windowAspectRatio) {
|
||||||
|
// Cell is wider than window aspect ratio - fit by height
|
||||||
|
scaledHeight = Math.min(targetHeight, 600 * 0.4)
|
||||||
|
scale = scaledHeight / 600
|
||||||
|
scaledWidth = 800 * scale
|
||||||
|
} else {
|
||||||
|
// Cell is taller than window aspect ratio - fit by width
|
||||||
|
scaledWidth = Math.min(targetWidth, 800 * 0.4)
|
||||||
|
scale = scaledWidth / 800
|
||||||
|
scaledHeight = 600 * scale
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate position to center window in cell
|
||||||
|
const cellX = padding + col * (cellWidth + gap)
|
||||||
|
const cellY = padding + row * (cellHeight + gap)
|
||||||
|
|
||||||
|
// Center window in cell
|
||||||
|
const x = cellX + (cellWidth - scaledWidth) / 2
|
||||||
|
const y = cellY + (cellHeight - scaledHeight) / 2
|
||||||
|
|
||||||
|
return {
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: 'top left',
|
||||||
|
left: `${x / scale}px`,
|
||||||
|
top: `${y / scale}px`,
|
||||||
|
width: '800px',
|
||||||
|
height: '600px',
|
||||||
|
zIndex: 91,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overview Mode handlers
|
||||||
|
const handleOverviewWindowClick = (windowId: string) => {
|
||||||
|
// Activate the window
|
||||||
|
windowManager.activateWindow(windowId)
|
||||||
|
// Close overview mode
|
||||||
|
isOverviewMode.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOverviewWindowDragStart = (event: DragEvent, windowId: string) => {
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
event.dataTransfer.setData('windowId', windowId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOverviewWindowDragEnd = () => {
|
||||||
|
// Cleanup after drag
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable Swiper in overview mode
|
||||||
|
watch(isOverviewMode, (newValue) => {
|
||||||
|
allowSwipe.value = !newValue
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for workspace changes to reload desktop items
|
||||||
|
watch(currentWorkspace, async () => {
|
||||||
|
if (currentWorkspace.value) {
|
||||||
|
await desktopStore.loadDesktopItemsAsync()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Load workspaces first
|
||||||
|
await workspaceStore.loadWorkspacesAsync()
|
||||||
|
|
||||||
|
// Then load desktop items for current workspace
|
||||||
await desktopStore.loadDesktopItemsAsync()
|
await desktopStore.loadDesktopItemsAsync()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@ -247,4 +767,14 @@ onMounted(async () => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-100%);
|
transform: translateY(-100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
478
src/components/haex/desktop/window.vue
Normal file
478
src/components/haex/desktop/window.vue
Normal file
@ -0,0 +1,478 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="windowEl"
|
||||||
|
:style="windowStyle"
|
||||||
|
:class="[
|
||||||
|
'absolute bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl rounded-xl shadow-2xl overflow-hidden',
|
||||||
|
'border border-gray-200 dark:border-gray-700 transition-all ease-out duration-600',
|
||||||
|
'flex flex-col',
|
||||||
|
isActive ? 'z-50' : 'z-10',
|
||||||
|
]"
|
||||||
|
@mousedown="handleActivate"
|
||||||
|
>
|
||||||
|
<!-- Window Titlebar -->
|
||||||
|
<div
|
||||||
|
ref="titlebarEl"
|
||||||
|
class="grid grid-cols-3 items-center px-3 py-1 bg-white/80 dark:bg-gray-800/80 border-b border-gray-200/50 dark:border-gray-700/50 cursor-move select-none touch-none"
|
||||||
|
@dblclick="handleMaximize"
|
||||||
|
>
|
||||||
|
<!-- Left: Icon -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
v-if="icon"
|
||||||
|
:src="icon"
|
||||||
|
:alt="title"
|
||||||
|
class="w-5 h-5 object-contain flex-shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Center: Title -->
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-full"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Window Controls -->
|
||||||
|
<div class="flex items-center gap-1 justify-end">
|
||||||
|
<button
|
||||||
|
class="w-8 h-8 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 flex items-center justify-center transition-colors"
|
||||||
|
@click.stop="handleMinimize"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-heroicons-minus"
|
||||||
|
class="w-4 h-4 text-gray-600 dark:text-gray-400"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-8 h-8 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 flex items-center justify-center transition-colors"
|
||||||
|
@click.stop="handleMaximize"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
:name="
|
||||||
|
isMaximized
|
||||||
|
? 'i-heroicons-arrows-pointing-in'
|
||||||
|
: 'i-heroicons-arrows-pointing-out'
|
||||||
|
"
|
||||||
|
class="w-4 h-4 text-gray-600 dark:text-gray-400"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-8 h-8 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 flex items-center justify-center transition-colors group"
|
||||||
|
@click.stop="handleClose"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-heroicons-x-mark"
|
||||||
|
class="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover:text-red-600 dark:group-hover:text-red-400"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Window Content -->
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'flex-1 overflow-hidden relative',
|
||||||
|
isDragging || isResizing ? 'pointer-events-none' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resize Handles -->
|
||||||
|
<template v-if="!isMaximized">
|
||||||
|
<div
|
||||||
|
class="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
|
||||||
|
@mousedown.left.stop="handleResizeStart('nw', $event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute top-0 right-0 w-2 h-2 cursor-ne-resize"
|
||||||
|
@mousedown.left.stop="handleResizeStart('ne', $event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-0 w-2 h-2 cursor-sw-resize"
|
||||||
|
@mousedown.left.stop="handleResizeStart('sw', $event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize"
|
||||||
|
@mousedown.left.stop="handleResizeStart('se', $event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute top-0 left-2 right-2 h-1 cursor-n-resize"
|
||||||
|
@mousedown.left.stop="handleResizeStart('n', $event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-2 right-2 h-1 cursor-s-resize"
|
||||||
|
@mousedown.left.stop="handleResizeStart('s', $event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute left-0 top-2 bottom-2 w-1 cursor-w-resize"
|
||||||
|
@mousedown.left.stop="handleResizeStart('w', $event)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute right-0 top-2 bottom-2 w-1 cursor-e-resize"
|
||||||
|
@mousedown.left.stop="handleResizeStart('e', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
icon?: string
|
||||||
|
initialX?: number
|
||||||
|
initialY?: number
|
||||||
|
initialWidth?: number
|
||||||
|
initialHeight?: number
|
||||||
|
isActive?: boolean
|
||||||
|
sourceX?: number
|
||||||
|
sourceY?: number
|
||||||
|
sourceWidth?: number
|
||||||
|
sourceHeight?: number
|
||||||
|
isOpening?: boolean
|
||||||
|
isClosing?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
minimize: []
|
||||||
|
activate: []
|
||||||
|
positionChanged: [x: number, y: number]
|
||||||
|
sizeChanged: [width: number, height: number]
|
||||||
|
dragStart: []
|
||||||
|
dragEnd: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const windowEl = ref<HTMLElement>()
|
||||||
|
const titlebarEl = useTemplateRef('titlebarEl')
|
||||||
|
|
||||||
|
// Inject viewport size from parent desktop
|
||||||
|
const viewportSize = inject<{
|
||||||
|
width: Ref<number>
|
||||||
|
height: Ref<number>
|
||||||
|
}>('viewportSize')
|
||||||
|
|
||||||
|
// Window state
|
||||||
|
const x = ref(props.initialX ?? 100)
|
||||||
|
const y = ref(props.initialY ?? 100)
|
||||||
|
const width = ref(props.initialWidth ?? 800)
|
||||||
|
const height = ref(props.initialHeight ?? 600)
|
||||||
|
const isMaximized = ref(false) // Don't start maximized
|
||||||
|
|
||||||
|
// Store initial position/size for restore
|
||||||
|
const preMaximizeState = ref({
|
||||||
|
x: props.initialX ?? 100,
|
||||||
|
y: props.initialY ?? 100,
|
||||||
|
width: props.initialWidth ?? 800,
|
||||||
|
height: props.initialHeight ?? 600,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dragging state
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStartX = ref(0)
|
||||||
|
const dragStartY = ref(0)
|
||||||
|
|
||||||
|
// Resizing state
|
||||||
|
const isResizing = ref(false)
|
||||||
|
const resizeDirection = ref<string>('')
|
||||||
|
const resizeStartX = ref(0)
|
||||||
|
const resizeStartY = ref(0)
|
||||||
|
const resizeStartWidth = ref(0)
|
||||||
|
const resizeStartHeight = ref(0)
|
||||||
|
const resizeStartPosX = ref(0)
|
||||||
|
const resizeStartPosY = ref(0)
|
||||||
|
|
||||||
|
// Snap settings
|
||||||
|
const snapEdgeThreshold = 50 // pixels from edge to trigger snap
|
||||||
|
const { x: mouseX } = useMouse()
|
||||||
|
|
||||||
|
// Setup drag with useDrag composable (supports mouse + touch)
|
||||||
|
useDrag(
|
||||||
|
({ movement: [mx, my], first, last }) => {
|
||||||
|
if (isMaximized.value) return
|
||||||
|
|
||||||
|
if (first) {
|
||||||
|
// Drag started - save initial position
|
||||||
|
isDragging.value = true
|
||||||
|
dragStartX.value = x.value
|
||||||
|
dragStartY.value = y.value
|
||||||
|
emit('dragStart')
|
||||||
|
return // Don't update position on first event
|
||||||
|
}
|
||||||
|
|
||||||
|
if (last) {
|
||||||
|
// Drag ended - apply snapping
|
||||||
|
isDragging.value = false
|
||||||
|
|
||||||
|
const viewportBounds = getViewportBounds()
|
||||||
|
if (viewportBounds) {
|
||||||
|
const viewportWidth = viewportBounds.width
|
||||||
|
const viewportHeight = viewportBounds.height
|
||||||
|
|
||||||
|
if (mouseX.value <= snapEdgeThreshold) {
|
||||||
|
// Snap to left half
|
||||||
|
x.value = 0
|
||||||
|
y.value = 0
|
||||||
|
width.value = viewportWidth / 2
|
||||||
|
height.value = viewportHeight
|
||||||
|
isMaximized.value = false
|
||||||
|
} else if (mouseX.value >= viewportWidth - snapEdgeThreshold) {
|
||||||
|
// Snap to right half
|
||||||
|
x.value = viewportWidth / 2
|
||||||
|
y.value = 0
|
||||||
|
width.value = viewportWidth / 2
|
||||||
|
height.value = viewportHeight
|
||||||
|
isMaximized.value = false
|
||||||
|
} else {
|
||||||
|
// Normal snap back to viewport
|
||||||
|
snapToViewport()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('positionChanged', x.value, y.value)
|
||||||
|
emit('sizeChanged', width.value, height.value)
|
||||||
|
emit('dragEnd')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dragging (not first, not last)
|
||||||
|
const newX = dragStartX.value + mx
|
||||||
|
const newY = dragStartY.value + my
|
||||||
|
|
||||||
|
// Apply constraints during drag
|
||||||
|
const constrained = constrainToViewportDuringDrag(newX, newY)
|
||||||
|
x.value = constrained.x
|
||||||
|
y.value = constrained.y
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domTarget: titlebarEl,
|
||||||
|
eventOptions: { passive: false },
|
||||||
|
pointer: { touch: true },
|
||||||
|
drag: {
|
||||||
|
threshold: 10, // 10px threshold prevents accidental drags and improves performance
|
||||||
|
filterTaps: true, // Filter out taps (clicks) vs drags
|
||||||
|
delay: 0, // No delay for immediate response
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const windowStyle = computed(() => {
|
||||||
|
const baseStyle: Record<string, string> = {}
|
||||||
|
|
||||||
|
// Opening animation: start from icon position
|
||||||
|
if (
|
||||||
|
props.isOpening &&
|
||||||
|
props.sourceX !== undefined &&
|
||||||
|
props.sourceY !== undefined
|
||||||
|
) {
|
||||||
|
baseStyle.left = `${props.sourceX}px`
|
||||||
|
baseStyle.top = `${props.sourceY}px`
|
||||||
|
baseStyle.width = `${props.sourceWidth || 100}px`
|
||||||
|
baseStyle.height = `${props.sourceHeight || 100}px`
|
||||||
|
baseStyle.opacity = '0'
|
||||||
|
baseStyle.transform = 'scale(0.3)'
|
||||||
|
}
|
||||||
|
// Closing animation: shrink to icon position
|
||||||
|
else if (
|
||||||
|
props.isClosing &&
|
||||||
|
props.sourceX !== undefined &&
|
||||||
|
props.sourceY !== undefined
|
||||||
|
) {
|
||||||
|
baseStyle.left = `${props.sourceX}px`
|
||||||
|
baseStyle.top = `${props.sourceY}px`
|
||||||
|
baseStyle.width = `${props.sourceWidth || 100}px`
|
||||||
|
baseStyle.height = `${props.sourceHeight || 100}px`
|
||||||
|
baseStyle.opacity = '0'
|
||||||
|
baseStyle.transform = 'scale(0.3)'
|
||||||
|
}
|
||||||
|
// Normal state
|
||||||
|
else if (isMaximized.value) {
|
||||||
|
baseStyle.left = '0px'
|
||||||
|
baseStyle.top = '0px'
|
||||||
|
baseStyle.width = '100%'
|
||||||
|
baseStyle.height = '100%'
|
||||||
|
baseStyle.borderRadius = '0'
|
||||||
|
baseStyle.opacity = '1'
|
||||||
|
baseStyle.transform = 'scale(1)'
|
||||||
|
} else {
|
||||||
|
baseStyle.left = `${x.value}px`
|
||||||
|
baseStyle.top = `${y.value}px`
|
||||||
|
baseStyle.width = `${width.value}px`
|
||||||
|
baseStyle.height = `${height.value}px`
|
||||||
|
baseStyle.opacity = '1'
|
||||||
|
baseStyle.transform = 'scale(1)'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance optimization: hint browser about transforms
|
||||||
|
if (isDragging.value || isResizing.value) {
|
||||||
|
baseStyle.willChange = 'transform, width, height'
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseStyle
|
||||||
|
})
|
||||||
|
|
||||||
|
const getViewportBounds = () => {
|
||||||
|
// Use reactive viewport size from parent if available
|
||||||
|
if (viewportSize) {
|
||||||
|
return {
|
||||||
|
width: viewportSize.width.value,
|
||||||
|
height: viewportSize.height.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to parent element measurement
|
||||||
|
if (!windowEl.value?.parentElement) return null
|
||||||
|
|
||||||
|
const parent = windowEl.value.parentElement
|
||||||
|
return {
|
||||||
|
width: parent.clientWidth,
|
||||||
|
height: parent.clientHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const constrainToViewportDuringDrag = (newX: number, newY: number) => {
|
||||||
|
const bounds = getViewportBounds()
|
||||||
|
if (!bounds) return { x: newX, y: newY }
|
||||||
|
|
||||||
|
const windowWidth = width.value
|
||||||
|
const windowHeight = height.value
|
||||||
|
|
||||||
|
// Allow max 1/3 of window to go outside viewport during drag
|
||||||
|
const maxOffscreenX = windowWidth / 3
|
||||||
|
const maxOffscreenY = windowHeight / 3
|
||||||
|
|
||||||
|
const maxX = bounds.width - windowWidth + maxOffscreenX
|
||||||
|
const minX = -maxOffscreenX
|
||||||
|
const maxY = bounds.height - windowHeight + maxOffscreenY
|
||||||
|
const minY = -maxOffscreenY
|
||||||
|
|
||||||
|
const constrainedX = Math.max(minX, Math.min(maxX, newX))
|
||||||
|
const constrainedY = Math.max(minY, Math.min(maxY, newY))
|
||||||
|
|
||||||
|
return { x: constrainedX, y: constrainedY }
|
||||||
|
}
|
||||||
|
|
||||||
|
const constrainToViewportFully = (
|
||||||
|
newX: number,
|
||||||
|
newY: number,
|
||||||
|
newWidth?: number,
|
||||||
|
newHeight?: number,
|
||||||
|
) => {
|
||||||
|
const bounds = getViewportBounds()
|
||||||
|
if (!bounds) return { x: newX, y: newY }
|
||||||
|
|
||||||
|
const windowWidth = newWidth ?? width.value
|
||||||
|
const windowHeight = newHeight ?? height.value
|
||||||
|
|
||||||
|
// Keep entire window within viewport
|
||||||
|
const maxX = bounds.width - windowWidth
|
||||||
|
const minX = 0
|
||||||
|
const maxY = bounds.height - windowHeight
|
||||||
|
const minY = 0
|
||||||
|
|
||||||
|
const constrainedX = Math.max(minX, Math.min(maxX, newX))
|
||||||
|
const constrainedY = Math.max(minY, Math.min(maxY, newY))
|
||||||
|
|
||||||
|
return { x: constrainedX, y: constrainedY }
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapToViewport = () => {
|
||||||
|
const bounds = getViewportBounds()
|
||||||
|
if (!bounds) return
|
||||||
|
|
||||||
|
const constrained = constrainToViewportFully(x.value, y.value)
|
||||||
|
x.value = constrained.x
|
||||||
|
y.value = constrained.y
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleActivate = () => {
|
||||||
|
emit('activate')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMinimize = () => {
|
||||||
|
emit('minimize')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMaximize = () => {
|
||||||
|
if (isMaximized.value) {
|
||||||
|
// Restore
|
||||||
|
x.value = preMaximizeState.value.x
|
||||||
|
y.value = preMaximizeState.value.y
|
||||||
|
width.value = preMaximizeState.value.width
|
||||||
|
height.value = preMaximizeState.value.height
|
||||||
|
isMaximized.value = false
|
||||||
|
} else {
|
||||||
|
// Maximize
|
||||||
|
preMaximizeState.value = {
|
||||||
|
x: x.value,
|
||||||
|
y: y.value,
|
||||||
|
width: width.value,
|
||||||
|
height: height.value,
|
||||||
|
}
|
||||||
|
isMaximized.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window resizing
|
||||||
|
const handleResizeStart = (direction: string, e: MouseEvent) => {
|
||||||
|
isResizing.value = true
|
||||||
|
resizeDirection.value = direction
|
||||||
|
resizeStartX.value = e.clientX
|
||||||
|
resizeStartY.value = e.clientY
|
||||||
|
resizeStartWidth.value = width.value
|
||||||
|
resizeStartHeight.value = height.value
|
||||||
|
resizeStartPosX.value = x.value
|
||||||
|
resizeStartPosY.value = y.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global mouse move handler (for resizing only, dragging handled by useDrag)
|
||||||
|
useEventListener(window, 'mousemove', (e: MouseEvent) => {
|
||||||
|
if (isResizing.value) {
|
||||||
|
const deltaX = e.clientX - resizeStartX.value
|
||||||
|
const deltaY = e.clientY - resizeStartY.value
|
||||||
|
|
||||||
|
const dir = resizeDirection.value
|
||||||
|
|
||||||
|
// Handle width changes
|
||||||
|
if (dir.includes('e')) {
|
||||||
|
width.value = Math.max(300, resizeStartWidth.value + deltaX)
|
||||||
|
} else if (dir.includes('w')) {
|
||||||
|
const newWidth = Math.max(300, resizeStartWidth.value - deltaX)
|
||||||
|
const widthDiff = resizeStartWidth.value - newWidth
|
||||||
|
x.value = resizeStartPosX.value + widthDiff
|
||||||
|
width.value = newWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle height changes
|
||||||
|
if (dir.includes('s')) {
|
||||||
|
height.value = Math.max(200, resizeStartHeight.value + deltaY)
|
||||||
|
} else if (dir.includes('n')) {
|
||||||
|
const newHeight = Math.max(200, resizeStartHeight.value - deltaY)
|
||||||
|
const heightDiff = resizeStartHeight.value - newHeight
|
||||||
|
y.value = resizeStartPosY.value + heightDiff
|
||||||
|
height.value = newHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Global mouse up handler (for resizing only, dragging handled by useDrag)
|
||||||
|
useEventListener(window, 'mouseup', () => {
|
||||||
|
if (isResizing.value) {
|
||||||
|
isResizing.value = false
|
||||||
|
|
||||||
|
// Snap back to viewport after resize ends
|
||||||
|
snapToViewport()
|
||||||
|
|
||||||
|
emit('positionChanged', x.value, y.value)
|
||||||
|
emit('sizeChanged', width.value, height.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@ -10,10 +10,10 @@
|
|||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<ul class="p-4 max-h-96 grid grid-cols-3 gap-2 overflow-scroll">
|
<ul class="p-4 max-h-96 grid grid-cols-3 gap-2 overflow-scroll">
|
||||||
<!-- Enabled Extensions -->
|
<!-- All launcher items (system windows + enabled extensions, alphabetically sorted) -->
|
||||||
<UiButton
|
<UiButton
|
||||||
v-for="extension in enabledExtensions"
|
v-for="item in launcherItems"
|
||||||
:key="extension.id"
|
:key="item.id"
|
||||||
square
|
square
|
||||||
size="xl"
|
size="xl"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -22,10 +22,10 @@
|
|||||||
leadingIcon: 'size-10',
|
leadingIcon: 'size-10',
|
||||||
label: 'w-full',
|
label: 'w-full',
|
||||||
}"
|
}"
|
||||||
:icon="extension.icon || 'i-heroicons-puzzle-piece-solid'"
|
:icon="item.icon"
|
||||||
:label="extension.name"
|
:label="item.name"
|
||||||
:tooltip="extension.name"
|
:tooltip="item.name"
|
||||||
@click="openExtension(extension.id)"
|
@click="openItem(item)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Disabled Extensions (grayed out) -->
|
<!-- Disabled Extensions (grayed out) -->
|
||||||
@ -45,30 +45,16 @@
|
|||||||
:label="extension.name"
|
:label="extension.name"
|
||||||
:tooltip="`${extension.name} (${t('disabled')})`"
|
:tooltip="`${extension.name} (${t('disabled')})`"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Marketplace Button (always at the end) -->
|
|
||||||
<UiButton
|
|
||||||
square
|
|
||||||
size="xl"
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
:ui="{
|
|
||||||
base: 'size-24 flex flex-wrap text-sm items-center justify-center overflow-visible',
|
|
||||||
leadingIcon: 'size-10',
|
|
||||||
label: 'w-full',
|
|
||||||
}"
|
|
||||||
icon="i-heroicons-plus-circle"
|
|
||||||
:label="t('marketplace')"
|
|
||||||
:tooltip="t('marketplace')"
|
|
||||||
@click="openMarketplace"
|
|
||||||
/>
|
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
</UPopover>
|
</UPopover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { SystemWindowDefinition } from '@/stores/desktop/windowManager'
|
||||||
|
|
||||||
const extensionStore = useExtensionsStore()
|
const extensionStore = useExtensionsStore()
|
||||||
|
const windowManagerStore = useWindowManagerStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
@ -76,38 +62,78 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
|
|
||||||
// Enabled extensions first
|
// Unified launcher item type
|
||||||
const enabledExtensions = computed(() => {
|
interface LauncherItem {
|
||||||
return extensionStore.availableExtensions.filter((ext) => ext.enabled)
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
type: 'system' | 'extension'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine system windows and enabled extensions, sorted alphabetically
|
||||||
|
const launcherItems = computed(() => {
|
||||||
|
const items: LauncherItem[] = []
|
||||||
|
|
||||||
|
// Add system windows
|
||||||
|
const systemWindows = windowManagerStore.getAllSystemWindows()
|
||||||
|
systemWindows.forEach((sysWin: SystemWindowDefinition) => {
|
||||||
|
items.push({
|
||||||
|
id: sysWin.id,
|
||||||
|
name: sysWin.name,
|
||||||
|
icon: sysWin.icon,
|
||||||
|
type: 'system',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add enabled extensions
|
||||||
|
const enabledExtensions = extensionStore.availableExtensions.filter(
|
||||||
|
(ext) => ext.enabled,
|
||||||
|
)
|
||||||
|
enabledExtensions.forEach((ext) => {
|
||||||
|
items.push({
|
||||||
|
id: ext.id,
|
||||||
|
name: ext.name,
|
||||||
|
icon: ext.icon || 'i-heroicons-puzzle-piece-solid',
|
||||||
|
type: 'extension',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort alphabetically by name
|
||||||
|
return items.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Disabled extensions last
|
// Disabled extensions (shown grayed out at the end)
|
||||||
const disabledExtensions = computed(() => {
|
const disabledExtensions = computed(() => {
|
||||||
return extensionStore.availableExtensions.filter((ext) => !ext.enabled)
|
return extensionStore.availableExtensions.filter((ext) => !ext.enabled)
|
||||||
})
|
})
|
||||||
|
|
||||||
const openExtension = (extensionId: string) => {
|
const { currentWorkspace } = storeToRefs(useWorkspaceStore())
|
||||||
router.push(
|
// Open launcher item (system window or extension)
|
||||||
localePath({
|
const openItem = async (item: LauncherItem) => {
|
||||||
name: 'haexExtension',
|
// Check if we're on the desktop page
|
||||||
params: {
|
const isOnDesktop = route.name === 'desktop'
|
||||||
vaultId: route.params.vaultId,
|
|
||||||
extensionId,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
open.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const openMarketplace = () => {
|
console.log('currentWorkspace', currentWorkspace.value)
|
||||||
router.push(
|
if (!isOnDesktop) {
|
||||||
localePath({
|
// Navigate to desktop first
|
||||||
name: 'extensionOverview',
|
await router.push(
|
||||||
params: {
|
localePath({
|
||||||
vaultId: route.params.vaultId,
|
name: 'desktop',
|
||||||
},
|
}),
|
||||||
}),
|
)
|
||||||
|
|
||||||
|
// Wait for navigation and DOM update
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the window with correct type and sourceId
|
||||||
|
windowManagerStore.openWindow(
|
||||||
|
item.type, // 'system' or 'extension'
|
||||||
|
item.id, // systemWindowId or extensionId
|
||||||
|
item.name,
|
||||||
|
item.icon,
|
||||||
)
|
)
|
||||||
|
|
||||||
open.value = false
|
open.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
145
src/components/haex/system/marketplace.vue
Normal file
145
src/components/haex/system/marketplace.vue
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full flex flex-col bg-white dark:bg-gray-900">
|
||||||
|
<!-- Marketplace Header -->
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 p-6"
|
||||||
|
>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Extension Marketplace
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Discover and install extensions for HaexHub
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 p-4"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
icon="i-heroicons-magnifying-glass"
|
||||||
|
size="lg"
|
||||||
|
placeholder="Search extensions..."
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Marketplace Content -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
|
<div class="max-w-4xl space-y-6">
|
||||||
|
<!-- Featured Extensions -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Featured Extensions
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- Example Extension Card -->
|
||||||
|
<div
|
||||||
|
class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 hover:shadow-lg transition-shadow cursor-pointer"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-lg bg-primary-500 flex items-center justify-center flex-shrink-0"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-heroicons-puzzle-piece"
|
||||||
|
class="w-6 h-6 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3
|
||||||
|
class="font-semibold text-gray-900 dark:text-white truncate"
|
||||||
|
>
|
||||||
|
Example Extension
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mt-1"
|
||||||
|
>
|
||||||
|
A powerful extension for HaexHub
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2 mt-2">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-500"
|
||||||
|
>v1.0.0</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-500"
|
||||||
|
>•</span
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-500"
|
||||||
|
>1.2k downloads</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
label="Install"
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Placeholder for more extensions -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 opacity-50">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-lg bg-gray-400 flex items-center justify-center flex-shrink-0"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-heroicons-puzzle-piece"
|
||||||
|
class="w-6 h-6 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3
|
||||||
|
class="font-semibold text-gray-900 dark:text-white truncate"
|
||||||
|
>
|
||||||
|
More extensions coming soon...
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Check back later for more extensions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Categories -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Categories
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<UBadge
|
||||||
|
label="Productivity"
|
||||||
|
color="primary"
|
||||||
|
variant="soft"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<UBadge
|
||||||
|
label="Development"
|
||||||
|
color="secondary"
|
||||||
|
variant="soft"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<UBadge
|
||||||
|
label="Security"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<UBadge
|
||||||
|
label="Utilities"
|
||||||
|
color="secondary"
|
||||||
|
variant="soft"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Marketplace component - placeholder implementation
|
||||||
|
</script>
|
||||||
96
src/components/haex/system/settings.vue
Normal file
96
src/components/haex/system/settings.vue
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full flex flex-col bg-white dark:bg-gray-900">
|
||||||
|
<!-- Settings Header -->
|
||||||
|
<div class="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Manage your HaexHub preferences and configuration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Content -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
|
<div class="max-w-2xl space-y-6">
|
||||||
|
<!-- General Section -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
General
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4 bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white">
|
||||||
|
Theme
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Choose your preferred theme
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
label="Auto"
|
||||||
|
variant="outline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white">
|
||||||
|
Language
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Select your language
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
label="English"
|
||||||
|
variant="outline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Privacy Section -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Privacy & Security
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4 bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white">
|
||||||
|
Auto-lock
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Lock vault after inactivity
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- About Section -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
About
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-2 bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Version</span>
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-white">0.1.0</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Platform</span>
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-white">Tauri + Vue</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Settings component - placeholder implementation
|
||||||
|
</script>
|
||||||
@ -98,7 +98,7 @@ const onCreateAsync = async () => {
|
|||||||
if (vaultId) {
|
if (vaultId) {
|
||||||
initVault()
|
initVault()
|
||||||
await navigateTo(
|
await navigateTo(
|
||||||
useLocaleRoute()({ name: 'vaultOverview', params: { vaultId } }),
|
useLocaleRoute()({ name: 'desktop', params: { vaultId } }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -150,7 +150,7 @@ const onOpenDatabase = async () => {
|
|||||||
|
|
||||||
await navigateTo(
|
await navigateTo(
|
||||||
localePath({
|
localePath({
|
||||||
name: 'vaultOverview',
|
name: 'desktop',
|
||||||
params: {
|
params: {
|
||||||
vaultId,
|
vaultId,
|
||||||
},
|
},
|
||||||
|
|||||||
35
src/components/haex/workspace/card.vue
Normal file
35
src/components/haex/workspace/card.vue
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<UCard
|
||||||
|
class="cursor-pointer transition-all h-32 w-72 shrink-0 group duration-500"
|
||||||
|
:class="[
|
||||||
|
workspace.position === currentWorkspaceIndex
|
||||||
|
? 'ring-2 ring-secondary bg-secondary/10'
|
||||||
|
: 'hover:ring-2 hover:ring-gray-300',
|
||||||
|
]"
|
||||||
|
@click="workspaceStore.slideToWorkspace(workspace.position)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white text-lg">
|
||||||
|
{{ workspace.name }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="workspaceStore.workspaces.length > 1"
|
||||||
|
icon="mdi-close"
|
||||||
|
variant="ghost"
|
||||||
|
class="group-hover:opacity-100 opacity-0 transition-opacity duration-300"
|
||||||
|
@click.stop="workspaceStore.closeWorkspaceAsync(workspace.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ workspace: IWorkspace }>()
|
||||||
|
|
||||||
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
|
||||||
|
const { currentWorkspaceIndex } = storeToRefs(workspaceStore)
|
||||||
|
</script>
|
||||||
@ -4,23 +4,15 @@
|
|||||||
:title
|
:title
|
||||||
:description
|
:description
|
||||||
>
|
>
|
||||||
<slot>
|
<template
|
||||||
<!-- <UiButton
|
v-for="(_, name) in $slots"
|
||||||
color="primary"
|
:key="name"
|
||||||
variant="outline"
|
#[name]="slotData"
|
||||||
icon="mdi:menu"
|
>
|
||||||
:ui="{
|
<slot
|
||||||
base: '',
|
:name="name"
|
||||||
}"
|
v-bind="slotData"
|
||||||
/> -->
|
/>
|
||||||
</slot>
|
|
||||||
|
|
||||||
<template #title>
|
|
||||||
<slot name="title" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #body>
|
|
||||||
<slot name="body" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@ -38,7 +30,7 @@
|
|||||||
:label="confirmLabel || t('confirm')"
|
:label="confirmLabel || t('confirm')"
|
||||||
block
|
block
|
||||||
color="primary"
|
color="primary"
|
||||||
varaint="solid"
|
variant="solid"
|
||||||
@click="$emit('confirm')"
|
@click="$emit('confirm')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,11 +16,15 @@ interface ExtensionRequest {
|
|||||||
|
|
||||||
// Globaler Handler - nur einmal registriert
|
// Globaler Handler - nur einmal registriert
|
||||||
let globalHandlerRegistered = false
|
let globalHandlerRegistered = false
|
||||||
const iframeRegistry = new Map<HTMLIFrameElement, IHaexHubExtension>()
|
interface ExtensionInstance {
|
||||||
// Map event.source (WindowProxy) to extension for sandbox-compatible matching
|
extension: IHaexHubExtension
|
||||||
const sourceRegistry = new Map<Window, IHaexHubExtension>()
|
windowId: string
|
||||||
// Reverse map: extension ID to Window for broadcasting
|
}
|
||||||
const extensionToWindowMap = new Map<string, Window>()
|
const iframeRegistry = new Map<HTMLIFrameElement, ExtensionInstance>()
|
||||||
|
// Map event.source (WindowProxy) to extension instance for sandbox-compatible matching
|
||||||
|
const sourceRegistry = new Map<Window, ExtensionInstance>()
|
||||||
|
// Reverse map: window ID to Window for broadcasting (supports multiple windows per extension)
|
||||||
|
const windowIdToWindowMap = new Map<string, Window>()
|
||||||
|
|
||||||
// Store context values that need to be accessed outside setup
|
// Store context values that need to be accessed outside setup
|
||||||
let contextGetters: {
|
let contextGetters: {
|
||||||
@ -40,15 +44,25 @@ const registerGlobalMessageHandler = () => {
|
|||||||
|
|
||||||
const request = event.data as ExtensionRequest
|
const request = event.data as ExtensionRequest
|
||||||
|
|
||||||
// Find extension by decoding event.origin (works with sandboxed iframes)
|
// Find extension instance by decoding event.origin (works with sandboxed iframes)
|
||||||
// Origin formats:
|
// Origin formats:
|
||||||
// - Desktop: haex-extension://<base64>
|
// - Desktop: haex-extension://<base64>
|
||||||
// - Android: http://haex-extension.localhost (need to check request URL for base64)
|
// - Android: http://haex-extension.localhost (need to check request URL for base64)
|
||||||
let extension: IHaexHubExtension | undefined
|
let instance: ExtensionInstance | undefined
|
||||||
|
|
||||||
|
// Debug: Find which extension sent this message
|
||||||
|
let sourceInfo = 'unknown source'
|
||||||
|
for (const [iframe, inst] of iframeRegistry.entries()) {
|
||||||
|
if (iframe.contentWindow === event.source) {
|
||||||
|
sourceInfo = `${inst.extension.name} (${inst.windowId})`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
'[ExtensionHandler] Received message from origin:',
|
'[ExtensionHandler] Received message from:',
|
||||||
event.origin,
|
sourceInfo,
|
||||||
|
'Method:',
|
||||||
|
request.method,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Try to decode extension info from origin
|
// Try to decode extension info from origin
|
||||||
@ -74,10 +88,10 @@ const registerGlobalMessageHandler = () => {
|
|||||||
if (iframeRegistry.size === 1) {
|
if (iframeRegistry.size === 1) {
|
||||||
const entry = Array.from(iframeRegistry.entries())[0]
|
const entry = Array.from(iframeRegistry.entries())[0]
|
||||||
if (entry) {
|
if (entry) {
|
||||||
const [_, ext] = entry
|
const [_, inst] = entry
|
||||||
extension = ext
|
instance = inst
|
||||||
sourceRegistry.set(event.source as Window, ext)
|
sourceRegistry.set(event.source as Window, inst)
|
||||||
extensionToWindowMap.set(ext.id, event.source as Window)
|
windowIdToWindowMap.set(inst.windowId, event.source as Window)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -91,16 +105,16 @@ const registerGlobalMessageHandler = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find matching extension in registry
|
// Find matching extension in registry
|
||||||
for (const [_, ext] of iframeRegistry.entries()) {
|
for (const [_, inst] of iframeRegistry.entries()) {
|
||||||
if (
|
if (
|
||||||
ext.name === decodedInfo.name &&
|
inst.extension.name === decodedInfo.name &&
|
||||||
ext.publicKey === decodedInfo.publicKey &&
|
inst.extension.publicKey === decodedInfo.publicKey &&
|
||||||
ext.version === decodedInfo.version
|
inst.extension.version === decodedInfo.version
|
||||||
) {
|
) {
|
||||||
extension = ext
|
instance = inst
|
||||||
// Register for future lookups
|
// Register for future lookups
|
||||||
sourceRegistry.set(event.source as Window, ext)
|
sourceRegistry.set(event.source as Window, inst)
|
||||||
extensionToWindowMap.set(ext.id, event.source as Window)
|
windowIdToWindowMap.set(inst.windowId, event.source as Window)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -110,31 +124,36 @@ const registerGlobalMessageHandler = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Try to find extension by event.source (for localhost origin or legacy)
|
// Fallback: Try to find extension instance by event.source (for localhost origin or legacy)
|
||||||
if (!extension) {
|
if (!instance) {
|
||||||
extension = sourceRegistry.get(event.source as Window)
|
instance = sourceRegistry.get(event.source as Window)
|
||||||
|
|
||||||
// If not registered yet, register on first message from this source
|
// If not registered yet, find by matching iframe.contentWindow to event.source
|
||||||
if (!extension && iframeRegistry.size === 1) {
|
if (!instance) {
|
||||||
// If we only have one iframe, assume this message is from it
|
for (const [iframe, inst] of iframeRegistry.entries()) {
|
||||||
const entry = Array.from(iframeRegistry.entries())[0]
|
if (iframe.contentWindow === event.source) {
|
||||||
if (entry) {
|
instance = inst
|
||||||
const [_, ext] = entry
|
// Register for future lookups
|
||||||
const windowSource = event.source as Window
|
sourceRegistry.set(event.source as Window, inst)
|
||||||
sourceRegistry.set(windowSource, ext)
|
windowIdToWindowMap.set(inst.windowId, event.source as Window)
|
||||||
extensionToWindowMap.set(ext.id, windowSource)
|
console.log(
|
||||||
extension = ext
|
'[ExtensionHandler] Registered instance via contentWindow match:',
|
||||||
|
inst.windowId,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (extension && !extensionToWindowMap.has(extension.id)) {
|
} else if (instance && !windowIdToWindowMap.has(instance.windowId)) {
|
||||||
// Also register in reverse map for broadcasting
|
// Also register in reverse map for broadcasting
|
||||||
extensionToWindowMap.set(extension.id, event.source as Window)
|
windowIdToWindowMap.set(instance.windowId, event.source as Window)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!extension) {
|
if (!instance) {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[ExtensionHandler] Could not identify extension for message:',
|
'[ExtensionHandler] Could not identify extension instance from event.source.',
|
||||||
event.origin,
|
'Registered iframes:',
|
||||||
|
iframeRegistry.size,
|
||||||
)
|
)
|
||||||
return // Message ist nicht von einem registrierten IFrame
|
return // Message ist nicht von einem registrierten IFrame
|
||||||
}
|
}
|
||||||
@ -148,19 +167,19 @@ const registerGlobalMessageHandler = () => {
|
|||||||
let result: unknown
|
let result: unknown
|
||||||
|
|
||||||
if (request.method.startsWith('extension.')) {
|
if (request.method.startsWith('extension.')) {
|
||||||
result = await handleExtensionMethodAsync(request, extension)
|
result = await handleExtensionMethodAsync(request, instance.extension)
|
||||||
} else if (request.method.startsWith('db.')) {
|
} else if (request.method.startsWith('db.')) {
|
||||||
result = await handleDatabaseMethodAsync(request, extension)
|
result = await handleDatabaseMethodAsync(request, instance.extension)
|
||||||
} else if (request.method.startsWith('fs.')) {
|
} else if (request.method.startsWith('fs.')) {
|
||||||
result = await handleFilesystemMethodAsync(request, extension)
|
result = await handleFilesystemMethodAsync(request, instance.extension)
|
||||||
} else if (request.method.startsWith('http.')) {
|
} else if (request.method.startsWith('http.')) {
|
||||||
result = await handleHttpMethodAsync(request, extension)
|
result = await handleHttpMethodAsync(request, instance.extension)
|
||||||
} else if (request.method.startsWith('permissions.')) {
|
} else if (request.method.startsWith('permissions.')) {
|
||||||
result = await handlePermissionsMethodAsync(request, extension)
|
result = await handlePermissionsMethodAsync(request, instance.extension)
|
||||||
} else if (request.method.startsWith('context.')) {
|
} else if (request.method.startsWith('context.')) {
|
||||||
result = await handleContextMethodAsync(request)
|
result = await handleContextMethodAsync(request)
|
||||||
} else if (request.method.startsWith('storage.')) {
|
} else if (request.method.startsWith('storage.')) {
|
||||||
result = await handleStorageMethodAsync(request, extension)
|
result = await handleStorageMethodAsync(request, instance)
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unknown method: ${request.method}`)
|
throw new Error(`Unknown method: ${request.method}`)
|
||||||
}
|
}
|
||||||
@ -203,6 +222,7 @@ const registerGlobalMessageHandler = () => {
|
|||||||
export const useExtensionMessageHandler = (
|
export const useExtensionMessageHandler = (
|
||||||
iframeRef: Ref<HTMLIFrameElement | undefined | null>,
|
iframeRef: Ref<HTMLIFrameElement | undefined | null>,
|
||||||
extension: ComputedRef<IHaexHubExtension | undefined | null>,
|
extension: ComputedRef<IHaexHubExtension | undefined | null>,
|
||||||
|
windowId: Ref<string>,
|
||||||
) => {
|
) => {
|
||||||
// Initialize context getters (can use composables here because we're in setup)
|
// Initialize context getters (can use composables here because we're in setup)
|
||||||
const { currentTheme } = storeToRefs(useUiStore())
|
const { currentTheme } = storeToRefs(useUiStore())
|
||||||
@ -223,13 +243,26 @@ export const useExtensionMessageHandler = (
|
|||||||
// Registriere dieses IFrame
|
// Registriere dieses IFrame
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (iframeRef.value && extension.value) {
|
if (iframeRef.value && extension.value) {
|
||||||
iframeRegistry.set(iframeRef.value, extension.value)
|
iframeRegistry.set(iframeRef.value, {
|
||||||
|
extension: extension.value,
|
||||||
|
windowId: windowId.value,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Cleanup beim Unmount
|
// Cleanup beim Unmount
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (iframeRef.value) {
|
if (iframeRef.value) {
|
||||||
|
const instance = iframeRegistry.get(iframeRef.value)
|
||||||
|
if (instance) {
|
||||||
|
// Remove from all maps
|
||||||
|
windowIdToWindowMap.delete(instance.windowId)
|
||||||
|
for (const [source, inst] of sourceRegistry.entries()) {
|
||||||
|
if (inst.windowId === instance.windowId) {
|
||||||
|
sourceRegistry.delete(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
iframeRegistry.delete(iframeRef.value)
|
iframeRegistry.delete(iframeRef.value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -239,6 +272,7 @@ export const useExtensionMessageHandler = (
|
|||||||
export const registerExtensionIFrame = (
|
export const registerExtensionIFrame = (
|
||||||
iframe: HTMLIFrameElement,
|
iframe: HTMLIFrameElement,
|
||||||
extension: IHaexHubExtension,
|
extension: IHaexHubExtension,
|
||||||
|
windowId: string,
|
||||||
) => {
|
) => {
|
||||||
// Stelle sicher, dass der globale Handler registriert ist
|
// Stelle sicher, dass der globale Handler registriert ist
|
||||||
registerGlobalMessageHandler()
|
registerGlobalMessageHandler()
|
||||||
@ -250,28 +284,48 @@ export const registerExtensionIFrame = (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
iframeRegistry.set(iframe, extension)
|
iframeRegistry.set(iframe, { extension, windowId })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const unregisterExtensionIFrame = (iframe: HTMLIFrameElement) => {
|
export const unregisterExtensionIFrame = (iframe: HTMLIFrameElement) => {
|
||||||
// Also remove from source registry
|
// Also remove from source registry and instance map
|
||||||
const ext = iframeRegistry.get(iframe)
|
const instance = iframeRegistry.get(iframe)
|
||||||
if (ext) {
|
if (instance) {
|
||||||
// Find and remove all sources pointing to this extension
|
// Find and remove all sources pointing to this instance
|
||||||
for (const [source, extension] of sourceRegistry.entries()) {
|
for (const [source, inst] of sourceRegistry.entries()) {
|
||||||
if (extension === ext) {
|
if (inst.windowId === instance.windowId) {
|
||||||
sourceRegistry.delete(source)
|
sourceRegistry.delete(source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Remove from extension-to-window map
|
// Remove from instance-to-window map
|
||||||
extensionToWindowMap.delete(ext.id)
|
windowIdToWindowMap.delete(instance.windowId)
|
||||||
}
|
}
|
||||||
iframeRegistry.delete(iframe)
|
iframeRegistry.delete(iframe)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export function to get Window for an extension (for broadcasting)
|
// Export function to get Window for a specific instance (for broadcasting)
|
||||||
|
export const getInstanceWindow = (windowId: string): Window | undefined => {
|
||||||
|
return windowIdToWindowMap.get(windowId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all windows for an extension (all instances)
|
||||||
|
export const getAllInstanceWindows = (extensionId: string): Window[] => {
|
||||||
|
const windows: Window[] = []
|
||||||
|
for (const [_, instance] of iframeRegistry.entries()) {
|
||||||
|
if (instance.extension.id === extensionId) {
|
||||||
|
const win = windowIdToWindowMap.get(instance.windowId)
|
||||||
|
if (win) {
|
||||||
|
windows.push(win)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated - kept for backwards compatibility
|
||||||
export const getExtensionWindow = (extensionId: string): Window | undefined => {
|
export const getExtensionWindow = (extensionId: string): Window | undefined => {
|
||||||
return extensionToWindowMap.get(extensionId)
|
// Return first window for this extension
|
||||||
|
return getAllInstanceWindows(extensionId)[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -436,11 +490,12 @@ async function handleContextMethodAsync(request: ExtensionRequest) {
|
|||||||
|
|
||||||
async function handleStorageMethodAsync(
|
async function handleStorageMethodAsync(
|
||||||
request: ExtensionRequest,
|
request: ExtensionRequest,
|
||||||
extension: IHaexHubExtension,
|
instance: ExtensionInstance,
|
||||||
) {
|
) {
|
||||||
const storageKey = `ext_${extension.id}_`
|
// Storage is now per-window, not per-extension
|
||||||
|
const storageKey = `ext_${instance.extension.id}_${instance.windowId}_`
|
||||||
console.log(
|
console.log(
|
||||||
`[HaexHub Storage] ${request.method} for extension ${extension.id}`,
|
`[HaexHub Storage] ${request.method} for window ${instance.windowId}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
switch (request.method) {
|
switch (request.method) {
|
||||||
@ -463,7 +518,7 @@ async function handleStorageMethodAsync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'storage.clear': {
|
case 'storage.clear': {
|
||||||
// Remove only extension-specific keys
|
// Remove only instance-specific keys
|
||||||
const keys = Object.keys(localStorage).filter((k) =>
|
const keys = Object.keys(localStorage).filter((k) =>
|
||||||
k.startsWith(storageKey),
|
k.startsWith(storageKey),
|
||||||
)
|
)
|
||||||
@ -472,7 +527,7 @@ async function handleStorageMethodAsync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'storage.keys': {
|
case 'storage.keys': {
|
||||||
// Return only extension-specific keys (without prefix)
|
// Return only instance-specific keys (without prefix)
|
||||||
const keys = Object.keys(localStorage)
|
const keys = Object.keys(localStorage)
|
||||||
.filter((k) => k.startsWith(storageKey))
|
.filter((k) => k.startsWith(storageKey))
|
||||||
.map((k) => k.substring(storageKey.length))
|
.map((k) => k.substring(storageKey.length))
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<NuxtLinkLocale
|
<NuxtLinkLocale
|
||||||
class="link text-base-content link-neutral text-xl font-semibold no-underline flex items-center"
|
class="link text-base-content link-neutral text-xl font-semibold no-underline flex items-center"
|
||||||
:to="{ name: 'vaultOverview' }"
|
:to="{ name: 'desktop' }"
|
||||||
>
|
>
|
||||||
<UiTextGradient class="text-nowrap">
|
<UiTextGradient class="text-nowrap">
|
||||||
{{ currentVaultName }}
|
{{ currentVaultName }}
|
||||||
@ -27,6 +27,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #links>
|
<template #links>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
:block="isSmallScreen"
|
||||||
|
@click="isOverviewMode = !isOverviewMode"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-heroicons-squares-2x2" />
|
||||||
|
</template>
|
||||||
|
Workspaces
|
||||||
|
</UButton>
|
||||||
<HaexExtensionLauncher :block="isSmallScreen" />
|
<HaexExtensionLauncher :block="isSmallScreen" />
|
||||||
<UiDropdownVault :block="isSmallScreen" />
|
<UiDropdownVault :block="isSmallScreen" />
|
||||||
</template>
|
</template>
|
||||||
@ -42,6 +53,8 @@
|
|||||||
const { currentVaultName } = storeToRefs(useVaultStore())
|
const { currentVaultName } = storeToRefs(useVaultStore())
|
||||||
|
|
||||||
const { isSmallScreen } = storeToRefs(useUiStore())
|
const { isSmallScreen } = storeToRefs(useUiStore())
|
||||||
|
|
||||||
|
const { isOverviewMode } = storeToRefs(useWorkspaceStore())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<i18n lang="yaml">
|
<i18n lang="yaml">
|
||||||
|
|||||||
@ -1,459 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="h-screen w-screen flex flex-col">
|
|
||||||
<!-- Tab Bar -->
|
|
||||||
<div
|
|
||||||
class="flex gap-2 bg-base-200 overflow-x-auto border-b border-base-300 flex-shrink-0"
|
|
||||||
>
|
|
||||||
<UButton
|
|
||||||
v-for="tab in tabsStore.sortedTabs"
|
|
||||||
:key="tab.extension.id"
|
|
||||||
:class="[
|
|
||||||
'gap-2',
|
|
||||||
tabsStore.activeTabId === tab.extension.id ? 'primary' : 'neutral',
|
|
||||||
]"
|
|
||||||
@click="tabsStore.setActiveTab(tab.extension.id)"
|
|
||||||
>
|
|
||||||
{{ tab.extension.name }}
|
|
||||||
|
|
||||||
<template #trailing>
|
|
||||||
<div
|
|
||||||
class="ml-1 hover:text-error"
|
|
||||||
@click.stop="tabsStore.closeTab(tab.extension.id)"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
name="mdi:close"
|
|
||||||
size="16"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<!-- Console Tab -->
|
|
||||||
<UButton
|
|
||||||
:class="['gap-2', showConsole ? 'primary' : 'neutral']"
|
|
||||||
@click="showConsole = !showConsole"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
name="mdi:console"
|
|
||||||
size="16"
|
|
||||||
/>
|
|
||||||
Console
|
|
||||||
<UBadge
|
|
||||||
v-if="visibleLogs.length > 0"
|
|
||||||
size="xs"
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{{ visibleLogs.length }}
|
|
||||||
</UBadge>
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- IFrame Container -->
|
|
||||||
<div class="flex-1 relative min-h-0">
|
|
||||||
<!-- Extension IFrames -->
|
|
||||||
<div
|
|
||||||
v-for="tab in tabsStore.sortedTabs"
|
|
||||||
:key="tab.extension.id"
|
|
||||||
:style="{ display: tab.isVisible && !showConsole ? 'block' : 'none' }"
|
|
||||||
class="absolute inset-0"
|
|
||||||
>
|
|
||||||
<!-- Error overlay for dev extensions when server is not reachable -->
|
|
||||||
<div
|
|
||||||
v-if="tab.extension.devServerUrl && iframe.errors[tab.extension.id]"
|
|
||||||
class="absolute inset-0 bg-base-100 flex items-center justify-center p-8"
|
|
||||||
>
|
|
||||||
<div class="max-w-md space-y-4 text-center">
|
|
||||||
<Icon
|
|
||||||
name="mdi:alert-circle-outline"
|
|
||||||
size="64"
|
|
||||||
class="mx-auto text-warning"
|
|
||||||
/>
|
|
||||||
<h3 class="text-lg font-semibold">
|
|
||||||
{{ t('devServer.notReachable.title') }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm opacity-70">
|
|
||||||
{{
|
|
||||||
t('devServer.notReachable.description', {
|
|
||||||
url: tab.extension.devServerUrl,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<div class="bg-base-200 p-4 rounded text-left text-xs font-mono">
|
|
||||||
<p class="opacity-70 mb-2">
|
|
||||||
{{ t('devServer.notReachable.howToStart') }}
|
|
||||||
</p>
|
|
||||||
<code class="block">cd /path/to/extension</code>
|
|
||||||
<code class="block">npm run dev</code>
|
|
||||||
</div>
|
|
||||||
<UButton
|
|
||||||
:label="t('devServer.notReachable.retry')"
|
|
||||||
@click="retryLoadIFrame(tab.extension.id)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<iframe
|
|
||||||
:ref="
|
|
||||||
(el) => registerIFrame(tab.extension.id, el as HTMLIFrameElement)
|
|
||||||
"
|
|
||||||
class="w-full h-full border-0"
|
|
||||||
:src="
|
|
||||||
getExtensionUrl(
|
|
||||||
tab.extension.publicKey,
|
|
||||||
tab.extension.name,
|
|
||||||
tab.extension.version,
|
|
||||||
'index.html',
|
|
||||||
tab.extension.devServerUrl ?? undefined,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:sandbox="iframe.sandboxAttributes(tab.extension.devServerUrl)"
|
|
||||||
allow="autoplay; speaker-selection; encrypted-media;"
|
|
||||||
@error="onIFrameError(tab.extension.id)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Console View -->
|
|
||||||
<div
|
|
||||||
v-if="showConsole"
|
|
||||||
class="absolute inset-0 bg-base-100 flex flex-col"
|
|
||||||
>
|
|
||||||
<!-- Console Header -->
|
|
||||||
<div
|
|
||||||
class="p-2 border-b border-base-300 flex justify-between items-center"
|
|
||||||
>
|
|
||||||
<h3 class="font-semibold">Console Output</h3>
|
|
||||||
<UButton
|
|
||||||
size="xs"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
@click="$clearConsoleLogs()"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Console Logs -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-2 font-mono text-sm">
|
|
||||||
<!-- Info banner if logs are limited -->
|
|
||||||
<div
|
|
||||||
v-if="consoleLogs.length > maxVisibleLogs"
|
|
||||||
class="mb-2 p-2 bg-warning/10 border border-warning/30 rounded text-xs"
|
|
||||||
>
|
|
||||||
Showing last {{ maxVisibleLogs }} of {{ consoleLogs.length }} logs
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Simple log list instead of accordion for better performance -->
|
|
||||||
<div
|
|
||||||
v-if="visibleLogs.length > 0"
|
|
||||||
class="space-y-1"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="(log, index) in visibleLogs"
|
|
||||||
:key="index"
|
|
||||||
class="border-b border-base-200 pb-2"
|
|
||||||
>
|
|
||||||
<!-- Log header with timestamp and level -->
|
|
||||||
<div class="flex justify-between items-center mb-1">
|
|
||||||
<span class="text-xs opacity-60">
|
|
||||||
[{{ log.timestamp }}] [{{ log.level.toUpperCase() }}]
|
|
||||||
</span>
|
|
||||||
<UButton
|
|
||||||
size="xs"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
icon="i-heroicons-clipboard-document"
|
|
||||||
@click="copyToClipboard(log.message)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<!-- Log message -->
|
|
||||||
<pre
|
|
||||||
:class="[
|
|
||||||
'text-xs whitespace-pre-wrap break-all',
|
|
||||||
log.level === 'error' ? 'text-error' : '',
|
|
||||||
log.level === 'warn' ? 'text-warning' : '',
|
|
||||||
log.level === 'info' ? 'text-info' : '',
|
|
||||||
log.level === 'debug' ? 'text-base-content/70' : '',
|
|
||||||
]"
|
|
||||||
>{{ log.message }}</pre
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="visibleLogs.length === 0"
|
|
||||||
class="text-center text-base-content/50 py-8"
|
|
||||||
>
|
|
||||||
No console messages yet
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div
|
|
||||||
v-if="tabsStore.tabCount === 0"
|
|
||||||
class="absolute inset-0 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<p>{{ t('loading') }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
EXTENSION_PROTOCOL_PREFIX,
|
|
||||||
EXTENSION_PROTOCOL_NAME,
|
|
||||||
} from '~/config/constants'
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
name: 'extension',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const tabsStore = useExtensionTabsStore()
|
|
||||||
|
|
||||||
// Track iframe errors (for dev mode)
|
|
||||||
//const iframeErrors = ref<Record<string, boolean>>({})
|
|
||||||
|
|
||||||
const sandboxDefault = [
|
|
||||||
'allow-scripts',
|
|
||||||
'allow-storage-access-by-user-activation',
|
|
||||||
'allow-forms',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const iframe = reactive<{
|
|
||||||
errors: Record<string, boolean>
|
|
||||||
sandboxAttributes: (devUrl?: string | null) => string
|
|
||||||
}>({
|
|
||||||
errors: {},
|
|
||||||
sandboxAttributes: (devUrl) => {
|
|
||||||
return devUrl
|
|
||||||
? [...sandboxDefault, 'allow-same-origin'].join(' ')
|
|
||||||
: sandboxDefault.join(' ')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { platform } = useDeviceStore()
|
|
||||||
|
|
||||||
// Generate extension URL (uses cached platform)
|
|
||||||
const getExtensionUrl = (
|
|
||||||
publicKey: string,
|
|
||||||
name: string,
|
|
||||||
version: string,
|
|
||||||
assetPath: string = 'index.html',
|
|
||||||
devServerUrl?: string,
|
|
||||||
) => {
|
|
||||||
if (!publicKey || !name || !version) {
|
|
||||||
console.error('Missing required extension fields')
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// If dev server URL is provided, load directly from dev server
|
|
||||||
if (devServerUrl) {
|
|
||||||
const cleanUrl = devServerUrl.replace(/\/$/, '')
|
|
||||||
const cleanPath = assetPath.replace(/^\//, '')
|
|
||||||
return cleanPath ? `${cleanUrl}/${cleanPath}` : cleanUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
const extensionInfo = {
|
|
||||||
name,
|
|
||||||
publicKey,
|
|
||||||
version,
|
|
||||||
}
|
|
||||||
const encodedInfo = btoa(JSON.stringify(extensionInfo))
|
|
||||||
|
|
||||||
if (platform === 'android' || platform === 'windows') {
|
|
||||||
// Android: Tauri uses http://{scheme}.localhost format
|
|
||||||
return `http://${EXTENSION_PROTOCOL_NAME}.localhost/${encodedInfo}/${assetPath}`
|
|
||||||
} else {
|
|
||||||
// Desktop: Use custom protocol with base64 as host
|
|
||||||
return `${EXTENSION_PROTOCOL_PREFIX}${encodedInfo}/${assetPath}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Console logging - use global logs from plugin
|
|
||||||
const { $consoleLogs, $clearConsoleLogs } = useNuxtApp()
|
|
||||||
const showConsole = ref(false)
|
|
||||||
const maxVisibleLogs = ref(100) // Limit for performance on mobile
|
|
||||||
const consoleLogs = $consoleLogs as Ref<
|
|
||||||
Array<{
|
|
||||||
timestamp: string
|
|
||||||
level: 'log' | 'info' | 'warn' | 'error' | 'debug'
|
|
||||||
message: string
|
|
||||||
}>
|
|
||||||
>
|
|
||||||
|
|
||||||
// Only show last N logs for performance
|
|
||||||
const visibleLogs = computed(() => {
|
|
||||||
return consoleLogs.value.slice(-maxVisibleLogs.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Extension aus Route öffnen
|
|
||||||
//const extensionId = computed(() => route.params.extensionId as string)
|
|
||||||
|
|
||||||
const { currentExtensionId } = storeToRefs(useExtensionsStore())
|
|
||||||
watchEffect(() => {
|
|
||||||
if (currentExtensionId.value) {
|
|
||||||
tabsStore.openTab(currentExtensionId.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Setup global message handler EINMAL im Setup-Kontext
|
|
||||||
// Dies registriert den globalen Event Listener
|
|
||||||
const dummyIframeRef = ref<HTMLIFrameElement | null>(null)
|
|
||||||
const dummyExtensionRef = computed(() => null)
|
|
||||||
useExtensionMessageHandler(dummyIframeRef, dummyExtensionRef)
|
|
||||||
|
|
||||||
// Track which iframes have been registered to prevent duplicate registrations
|
|
||||||
const registeredIFrames = new WeakSet<HTMLIFrameElement>()
|
|
||||||
|
|
||||||
const registerIFrame = (extensionId: string, el: HTMLIFrameElement | null) => {
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
// Prevent duplicate registration (Vue calls ref functions on every render)
|
|
||||||
if (registeredIFrames.has(el)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Vue Debug] ========== registerIFrame called ==========')
|
|
||||||
console.log('[Vue Debug] Extension ID:', extensionId)
|
|
||||||
console.log('[Vue Debug] Element:', 'HTMLIFrameElement')
|
|
||||||
|
|
||||||
// Mark as registered
|
|
||||||
registeredIFrames.add(el)
|
|
||||||
|
|
||||||
// Registriere IFrame im Store
|
|
||||||
tabsStore.registerIFrame(extensionId, el)
|
|
||||||
|
|
||||||
// Registriere IFrame im globalen Message Handler Registry
|
|
||||||
const tab = tabsStore.openTabs.get(extensionId)
|
|
||||||
if (tab?.extension) {
|
|
||||||
console.log(
|
|
||||||
'[Vue Debug] Registering iframe in message handler for:',
|
|
||||||
tab.extension.name,
|
|
||||||
)
|
|
||||||
registerExtensionIFrame(el, tab.extension)
|
|
||||||
console.log('[Vue Debug] Registration complete!')
|
|
||||||
} else {
|
|
||||||
console.error('[Vue Debug] ❌ No tab found for extension ID:', extensionId)
|
|
||||||
}
|
|
||||||
console.log('[Vue Debug] ========================================')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for console messages from extensions (via postMessage)
|
|
||||||
const handleExtensionConsole = (event: MessageEvent) => {
|
|
||||||
if (event.data?.type === 'console.forward') {
|
|
||||||
const { timestamp, level, message } = event.data.data
|
|
||||||
consoleLogs.value.push({
|
|
||||||
timestamp,
|
|
||||||
level,
|
|
||||||
message: `[Extension] ${message}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Limit to last 1000 logs
|
|
||||||
if (consoleLogs.value.length > 1000) {
|
|
||||||
consoleLogs.value = consoleLogs.value.slice(-1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener('message', handleExtensionConsole)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener('message', handleExtensionConsole)
|
|
||||||
|
|
||||||
// Unregister all iframes when the page unmounts
|
|
||||||
tabsStore.openTabs.forEach((tab) => {
|
|
||||||
if (tab.iframe) {
|
|
||||||
unregisterExtensionIFrame(tab.iframe)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cleanup wenn Tabs geschlossen werden
|
|
||||||
watch(
|
|
||||||
() => tabsStore.openTabs,
|
|
||||||
(newTabs, oldTabs) => {
|
|
||||||
if (oldTabs) {
|
|
||||||
// Finde gelöschte Tabs
|
|
||||||
oldTabs.forEach((tab, id) => {
|
|
||||||
if (!newTabs.has(id) && tab.iframe) {
|
|
||||||
unregisterExtensionIFrame(tab.iframe)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Context Changes an alle Tabs broadcasten
|
|
||||||
const { currentTheme } = storeToRefs(useUiStore())
|
|
||||||
const { locale } = useI18n()
|
|
||||||
|
|
||||||
watch([currentTheme, locale], () => {
|
|
||||||
tabsStore.broadcastToAllTabs({
|
|
||||||
type: 'context.changed',
|
|
||||||
data: {
|
|
||||||
context: {
|
|
||||||
theme: currentTheme.value || 'system',
|
|
||||||
locale: locale.value,
|
|
||||||
platform:
|
|
||||||
window.innerWidth < 768
|
|
||||||
? 'mobile'
|
|
||||||
: window.innerWidth < 1024
|
|
||||||
? 'tablet'
|
|
||||||
: 'desktop',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
timestamp: Date.now(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Copy to clipboard function
|
|
||||||
const copyToClipboard = async (text: string) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
// Optional: Show success toast
|
|
||||||
console.log('Copied to clipboard')
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle iframe errors (e.g., dev server not running)
|
|
||||||
const onIFrameError = (extensionId: string) => {
|
|
||||||
iframe.errors[extensionId] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry loading iframe (clears error and reloads)
|
|
||||||
const retryLoadIFrame = (extensionId: string) => {
|
|
||||||
iframe.errors[extensionId] = false
|
|
||||||
// Reload the iframe by updating the tab
|
|
||||||
const tab = tabsStore.openTabs.get(extensionId)
|
|
||||||
if (tab?.iframe) {
|
|
||||||
tab.iframe.src = tab.iframe.src // Force reload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<i18n lang="yaml">
|
|
||||||
de:
|
|
||||||
loading: Erweiterung wird geladen
|
|
||||||
devServer:
|
|
||||||
notReachable:
|
|
||||||
title: Dev-Server nicht erreichbar
|
|
||||||
description: Der Dev-Server unter {url} ist nicht erreichbar.
|
|
||||||
howToStart: 'So starten Sie den Dev-Server:'
|
|
||||||
retry: Erneut versuchen
|
|
||||||
en:
|
|
||||||
loading: Extension is loading
|
|
||||||
devServer:
|
|
||||||
notReachable:
|
|
||||||
title: Dev Server Not Reachable
|
|
||||||
description: The dev server at {url} is not reachable.
|
|
||||||
howToStart: 'To start the dev server:'
|
|
||||||
retry: Retry
|
|
||||||
</i18n>
|
|
||||||
@ -1,599 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col h-full">
|
|
||||||
<!-- Header with Actions -->
|
|
||||||
<div
|
|
||||||
class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-6 border-b border-gray-200 dark:border-gray-800"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold">
|
|
||||||
{{ t('title') }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
{{ t('subtitle') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3"
|
|
||||||
>
|
|
||||||
<!-- Marketplace Selector -->
|
|
||||||
<USelectMenu
|
|
||||||
v-model="selectedMarketplace"
|
|
||||||
:items="marketplaces"
|
|
||||||
value-key="id"
|
|
||||||
class="w-full sm:w-48"
|
|
||||||
>
|
|
||||||
<template #leading>
|
|
||||||
<UIcon name="i-heroicons-building-storefront" />
|
|
||||||
</template>
|
|
||||||
</USelectMenu>
|
|
||||||
|
|
||||||
<!-- Install from File Button -->
|
|
||||||
<UiButton
|
|
||||||
:label="t('extension.installFromFile')"
|
|
||||||
icon="i-heroicons-arrow-up-tray"
|
|
||||||
color="neutral"
|
|
||||||
@click="onSelectExtensionAsync"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search and Filters -->
|
|
||||||
<div
|
|
||||||
class="flex flex-col sm:flex-row items-stretch sm:items-center gap-4 p-6 border-b border-gray-200 dark:border-gray-800"
|
|
||||||
>
|
|
||||||
<UInput
|
|
||||||
v-model="searchQuery"
|
|
||||||
:placeholder="t('search.placeholder')"
|
|
||||||
icon="i-heroicons-magnifying-glass"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
<USelectMenu
|
|
||||||
v-model="selectedCategory"
|
|
||||||
:items="categories"
|
|
||||||
:placeholder="t('filter.category')"
|
|
||||||
value-key="id"
|
|
||||||
class="w-full sm:w-48"
|
|
||||||
>
|
|
||||||
<template #leading>
|
|
||||||
<UIcon name="i-heroicons-tag" />
|
|
||||||
</template>
|
|
||||||
</USelectMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Extensions Grid -->
|
|
||||||
<div class="flex-1 overflow-auto p-6">
|
|
||||||
<div
|
|
||||||
v-if="filteredExtensions.length"
|
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
|
||||||
>
|
|
||||||
<!-- Marketplace Extension Card -->
|
|
||||||
<HaexExtensionMarketplaceCard
|
|
||||||
v-for="ext in filteredExtensions"
|
|
||||||
:key="ext.id"
|
|
||||||
:extension="ext"
|
|
||||||
@install="onInstallFromMarketplace(ext)"
|
|
||||||
@details="onShowExtensionDetails(ext)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex flex-col items-center justify-center h-full text-center"
|
|
||||||
>
|
|
||||||
<UIcon
|
|
||||||
name="i-heroicons-magnifying-glass"
|
|
||||||
class="w-16 h-16 text-gray-400 mb-4"
|
|
||||||
/>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
{{ t('empty.title') }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-gray-500 dark:text-gray-400 mt-2">
|
|
||||||
{{ t('empty.description') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HaexExtensionDialogReinstall
|
|
||||||
v-model:open="openOverwriteDialog"
|
|
||||||
v-model:preview="preview"
|
|
||||||
@confirm="reinstallExtensionAsync"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HaexExtensionDialogInstall
|
|
||||||
v-model:open="showConfirmation"
|
|
||||||
:preview="preview"
|
|
||||||
@confirm="(addToDesktop) => addExtensionAsync(addToDesktop)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<HaexExtensionDialogRemove
|
|
||||||
v-model:open="showRemoveDialog"
|
|
||||||
:extension="extensionToBeRemoved"
|
|
||||||
@confirm="removeExtensionAsync"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type {
|
|
||||||
IHaexHubExtension,
|
|
||||||
IHaexHubExtensionManifest,
|
|
||||||
IMarketplaceExtension,
|
|
||||||
} from '~/types/haexhub'
|
|
||||||
import { open } from '@tauri-apps/plugin-dialog'
|
|
||||||
import type { ExtensionPreview } from '~~/src-tauri/bindings/ExtensionPreview'
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
name: 'extensionOverview',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const extensionStore = useExtensionsStore()
|
|
||||||
const desktopStore = useDesktopStore()
|
|
||||||
|
|
||||||
const showConfirmation = ref(false)
|
|
||||||
const openOverwriteDialog = ref(false)
|
|
||||||
|
|
||||||
const extension = reactive<{
|
|
||||||
manifest: IHaexHubExtensionManifest | null | undefined
|
|
||||||
path: string | null
|
|
||||||
}>({
|
|
||||||
manifest: null,
|
|
||||||
path: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
/* const loadExtensionManifestAsync = async () => {
|
|
||||||
try {
|
|
||||||
extension.path = await open({ directory: true, recursive: true })
|
|
||||||
if (!extension.path) return
|
|
||||||
|
|
||||||
const manifestFile = JSON.parse(
|
|
||||||
await readTextFile(await join(extension.path, 'manifest.json')),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!extensionStore.checkManifest(manifestFile))
|
|
||||||
throw new Error(`Manifest fehlerhaft ${JSON.stringify(manifestFile)}`)
|
|
||||||
|
|
||||||
return manifestFile
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler loadExtensionManifestAsync:', error)
|
|
||||||
add({ color: 'error', description: JSON.stringify(error) })
|
|
||||||
await addNotificationAsync({ text: JSON.stringify(error), type: 'error' })
|
|
||||||
}
|
|
||||||
} */
|
|
||||||
|
|
||||||
const { add } = useToast()
|
|
||||||
const { addNotificationAsync } = useNotificationStore()
|
|
||||||
|
|
||||||
const preview = ref<ExtensionPreview>()
|
|
||||||
|
|
||||||
// Marketplace State
|
|
||||||
const selectedMarketplace = ref('official')
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const selectedCategory = ref('all')
|
|
||||||
|
|
||||||
// Marketplaces (später von API laden)
|
|
||||||
const marketplaces = [
|
|
||||||
{
|
|
||||||
id: 'official',
|
|
||||||
label: t('marketplace.official'),
|
|
||||||
icon: 'i-heroicons-building-storefront',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'community',
|
|
||||||
label: t('marketplace.community'),
|
|
||||||
icon: 'i-heroicons-users',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// Categories
|
|
||||||
const categories = computed(() => [
|
|
||||||
{ id: 'all', label: t('category.all') },
|
|
||||||
{ id: 'productivity', label: t('category.productivity') },
|
|
||||||
{ id: 'security', label: t('category.security') },
|
|
||||||
{ id: 'utilities', label: t('category.utilities') },
|
|
||||||
{ id: 'integration', label: t('category.integration') },
|
|
||||||
])
|
|
||||||
|
|
||||||
// Dummy Marketplace Extensions (später von API laden)
|
|
||||||
const marketplaceExtensions = ref<IMarketplaceExtension[]>([
|
|
||||||
{
|
|
||||||
id: 'haex-passy',
|
|
||||||
name: 'HaexPassDummy',
|
|
||||||
version: '1.0.0',
|
|
||||||
author: 'HaexHub Team',
|
|
||||||
public_key: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2',
|
|
||||||
description:
|
|
||||||
'Sicherer Passwort-Manager mit Ende-zu-Ende-Verschlüsselung und Autofill-Funktion.',
|
|
||||||
icon: 'i-heroicons-lock-closed',
|
|
||||||
homepage: null,
|
|
||||||
downloads: 15420,
|
|
||||||
rating: 4.8,
|
|
||||||
verified: true,
|
|
||||||
tags: ['security', 'password', 'productivity'],
|
|
||||||
category: 'security',
|
|
||||||
downloadUrl: '/extensions/haex-pass-1.0.0.haextension',
|
|
||||||
isInstalled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'haex-notes',
|
|
||||||
name: 'HaexNotes',
|
|
||||||
version: '2.1.0',
|
|
||||||
author: 'HaexHub Team',
|
|
||||||
public_key: 'b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3',
|
|
||||||
description:
|
|
||||||
'Markdown-basierter Notizen-Editor mit Syntax-Highlighting und Live-Preview.',
|
|
||||||
icon: 'i-heroicons-document-text',
|
|
||||||
homepage: null,
|
|
||||||
downloads: 8930,
|
|
||||||
rating: 4.5,
|
|
||||||
verified: true,
|
|
||||||
tags: ['productivity', 'notes', 'markdown'],
|
|
||||||
category: 'productivity',
|
|
||||||
downloadUrl: '/extensions/haex-notes-2.1.0.haextension',
|
|
||||||
isInstalled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'haex-backup',
|
|
||||||
name: 'HaexBackup',
|
|
||||||
version: '1.5.2',
|
|
||||||
author: 'Community',
|
|
||||||
public_key: 'c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4',
|
|
||||||
description:
|
|
||||||
'Automatische Backups deiner Daten mit Cloud-Sync-Unterstützung.',
|
|
||||||
icon: 'i-heroicons-cloud-arrow-up',
|
|
||||||
homepage: null,
|
|
||||||
downloads: 5240,
|
|
||||||
rating: 4.6,
|
|
||||||
verified: false,
|
|
||||||
tags: ['backup', 'cloud', 'utilities'],
|
|
||||||
category: 'utilities',
|
|
||||||
downloadUrl: '/extensions/haex-backup-1.5.2.haextension',
|
|
||||||
isInstalled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'haex-calendar',
|
|
||||||
name: 'HaexCalendar',
|
|
||||||
version: '3.0.1',
|
|
||||||
author: 'HaexHub Team',
|
|
||||||
public_key: 'd4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5',
|
|
||||||
description:
|
|
||||||
'Integrierter Kalender mit Event-Management und Synchronisation.',
|
|
||||||
icon: 'i-heroicons-calendar',
|
|
||||||
homepage: null,
|
|
||||||
downloads: 12100,
|
|
||||||
rating: 4.7,
|
|
||||||
verified: true,
|
|
||||||
tags: ['productivity', 'calendar', 'events'],
|
|
||||||
category: 'productivity',
|
|
||||||
downloadUrl: '/extensions/haex-calendar-3.0.1.haextension',
|
|
||||||
isInstalled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'haex-2fa',
|
|
||||||
name: 'Haex2FA',
|
|
||||||
version: '1.2.0',
|
|
||||||
author: 'Security Team',
|
|
||||||
public_key: 'e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6',
|
|
||||||
description:
|
|
||||||
'2-Faktor-Authentifizierung Manager mit TOTP und Backup-Codes.',
|
|
||||||
icon: 'i-heroicons-shield-check',
|
|
||||||
homepage: null,
|
|
||||||
downloads: 7800,
|
|
||||||
rating: 4.9,
|
|
||||||
verified: true,
|
|
||||||
tags: ['security', '2fa', 'authentication'],
|
|
||||||
category: 'security',
|
|
||||||
downloadUrl: '/extensions/haex-2fa-1.2.0.haextension',
|
|
||||||
isInstalled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'haex-github',
|
|
||||||
name: 'GitHub Integration',
|
|
||||||
version: '1.0.5',
|
|
||||||
author: 'Community',
|
|
||||||
public_key: 'f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7',
|
|
||||||
description:
|
|
||||||
'Direkter Zugriff auf GitHub Repositories, Issues und Pull Requests.',
|
|
||||||
icon: 'i-heroicons-code-bracket',
|
|
||||||
homepage: null,
|
|
||||||
downloads: 4120,
|
|
||||||
rating: 4.3,
|
|
||||||
verified: false,
|
|
||||||
tags: ['integration', 'github', 'development'],
|
|
||||||
category: 'integration',
|
|
||||||
downloadUrl: '/extensions/haex-github-1.0.5.haextension',
|
|
||||||
isInstalled: false,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// Mark marketplace extensions as installed if they exist in availableExtensions
|
|
||||||
const allExtensions = computed((): IMarketplaceExtension[] => {
|
|
||||||
return marketplaceExtensions.value.map((ext) => {
|
|
||||||
// Extensions are uniquely identified by public_key + name
|
|
||||||
const installedExt = extensionStore.availableExtensions.find((installed) => {
|
|
||||||
return installed.publicKey === ext.publicKey && installed.name === ext.name
|
|
||||||
})
|
|
||||||
|
|
||||||
if (installedExt) {
|
|
||||||
return {
|
|
||||||
...ext,
|
|
||||||
isInstalled: true,
|
|
||||||
// Show installed version if it differs from marketplace version
|
|
||||||
installedVersion: installedExt.version !== ext.version ? installedExt.version : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...ext,
|
|
||||||
isInstalled: false,
|
|
||||||
installedVersion: undefined,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Filtered Extensions
|
|
||||||
const filteredExtensions = computed(() => {
|
|
||||||
return allExtensions.value.filter((ext) => {
|
|
||||||
const matchesSearch =
|
|
||||||
!searchQuery.value ||
|
|
||||||
ext.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
|
||||||
ext.description?.toLowerCase().includes(searchQuery.value.toLowerCase())
|
|
||||||
|
|
||||||
const matchesCategory =
|
|
||||||
selectedCategory.value === 'all' ||
|
|
||||||
ext.category === selectedCategory.value
|
|
||||||
|
|
||||||
return matchesSearch && matchesCategory
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Install from marketplace
|
|
||||||
const onInstallFromMarketplace = async (ext: unknown) => {
|
|
||||||
console.log('Install from marketplace:', ext)
|
|
||||||
// TODO: Download extension from marketplace and install
|
|
||||||
add({ color: 'info', description: t('extension.marketplace.comingSoon') })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show extension details
|
|
||||||
const onShowExtensionDetails = (ext: unknown) => {
|
|
||||||
console.log('Show details:', ext)
|
|
||||||
// TODO: Show extension details modal
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSelectExtensionAsync = async () => {
|
|
||||||
try {
|
|
||||||
extension.path = await open({ directory: false, recursive: true })
|
|
||||||
if (!extension.path) return
|
|
||||||
|
|
||||||
preview.value = await extensionStore.previewManifestAsync(extension.path)
|
|
||||||
|
|
||||||
if (!preview.value?.manifest) return
|
|
||||||
|
|
||||||
// Check if already installed using public_key + name
|
|
||||||
const isAlreadyInstalled = extensionStore.availableExtensions.some(
|
|
||||||
(ext) =>
|
|
||||||
ext.publicKey === preview.value!.manifest.public_key &&
|
|
||||||
ext.name === preview.value!.manifest.name
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isAlreadyInstalled) {
|
|
||||||
openOverwriteDialog.value = true
|
|
||||||
} else {
|
|
||||||
showConfirmation.value = true
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
add({ color: 'error', description: JSON.stringify(error) })
|
|
||||||
await addNotificationAsync({ text: JSON.stringify(error), type: 'error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addExtensionAsync = async (addToDesktop: boolean = false) => {
|
|
||||||
try {
|
|
||||||
console.log(
|
|
||||||
'preview.value?.editable_permissions',
|
|
||||||
preview.value?.editable_permissions,
|
|
||||||
)
|
|
||||||
const extensionId = await extensionStore.installAsync(
|
|
||||||
extension.path,
|
|
||||||
preview.value?.editable_permissions,
|
|
||||||
)
|
|
||||||
await extensionStore.loadExtensionsAsync()
|
|
||||||
|
|
||||||
// Add to desktop if requested
|
|
||||||
if (addToDesktop && extensionId) {
|
|
||||||
await desktopStore.addDesktopItemAsync('extension', extensionId, 50, 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
add({
|
|
||||||
color: 'success',
|
|
||||||
title: t('extension.success.title', {
|
|
||||||
extension: extension.manifest?.name,
|
|
||||||
}),
|
|
||||||
description: t('extension.success.text'),
|
|
||||||
})
|
|
||||||
await addNotificationAsync({
|
|
||||||
text: t('extension.success.text'),
|
|
||||||
type: 'success',
|
|
||||||
title: t('extension.success.title', {
|
|
||||||
extension: extension.manifest?.name,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler addExtensionAsync:', error)
|
|
||||||
add({ color: 'error', description: JSON.stringify(error) })
|
|
||||||
await addNotificationAsync({ text: JSON.stringify(error), type: 'error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const reinstallExtensionAsync = async () => {
|
|
||||||
try {
|
|
||||||
if (!preview.value?.manifest) return
|
|
||||||
|
|
||||||
// Find the installed extension to get its current version
|
|
||||||
const installedExt = extensionStore.availableExtensions.find(
|
|
||||||
(ext) =>
|
|
||||||
ext.publicKey === preview.value!.manifest.public_key &&
|
|
||||||
ext.name === preview.value!.manifest.name
|
|
||||||
)
|
|
||||||
|
|
||||||
if (installedExt) {
|
|
||||||
// Remove old extension first
|
|
||||||
await extensionStore.removeExtensionAsync(
|
|
||||||
installedExt.publicKey,
|
|
||||||
installedExt.name,
|
|
||||||
installedExt.version
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then install new version
|
|
||||||
await addExtensionAsync()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler reinstallExtensionAsync:', error)
|
|
||||||
add({ color: 'error', description: JSON.stringify(error) })
|
|
||||||
await addNotificationAsync({ text: JSON.stringify(error), type: 'error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const extensionToBeRemoved = ref<IHaexHubExtension>()
|
|
||||||
const showRemoveDialog = ref(false)
|
|
||||||
|
|
||||||
// Load extensions on mount
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
await extensionStore.loadExtensionsAsync()
|
|
||||||
console.log('Loaded extensions:', extensionStore.availableExtensions)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load extensions:', error)
|
|
||||||
add({ color: 'error', description: 'Failed to load installed extensions' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/* const onShowRemoveDialog = (extension: IHaexHubExtension) => {
|
|
||||||
extensionToBeRemoved.value = extension
|
|
||||||
showRemoveDialog.value = true
|
|
||||||
} */
|
|
||||||
|
|
||||||
const removeExtensionAsync = async () => {
|
|
||||||
if (!extensionToBeRemoved.value?.publicKey || !extensionToBeRemoved.value?.name || !extensionToBeRemoved.value?.version) {
|
|
||||||
add({
|
|
||||||
color: 'error',
|
|
||||||
description: 'Erweiterung kann nicht gelöscht werden',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await extensionStore.removeExtensionAsync(
|
|
||||||
extensionToBeRemoved.value.publicKey,
|
|
||||||
extensionToBeRemoved.value.name,
|
|
||||||
extensionToBeRemoved.value.version,
|
|
||||||
)
|
|
||||||
await extensionStore.loadExtensionsAsync()
|
|
||||||
add({
|
|
||||||
color: 'success',
|
|
||||||
title: t('extension.remove.success.title', {
|
|
||||||
extensionName: extensionToBeRemoved.value.name,
|
|
||||||
}),
|
|
||||||
description: t('extension.remove.success.text', {
|
|
||||||
extensionName: extensionToBeRemoved.value.name,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
await addNotificationAsync({
|
|
||||||
text: t('extension.remove.success.text', {
|
|
||||||
extensionName: extensionToBeRemoved.value.name,
|
|
||||||
}),
|
|
||||||
type: 'success',
|
|
||||||
title: t('extension.remove.success.title', {
|
|
||||||
extensionName: extensionToBeRemoved.value.name,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
add({
|
|
||||||
color: 'error',
|
|
||||||
title: t('extension.remove.error.title'),
|
|
||||||
description: t('extension.remove.error.text', {
|
|
||||||
error: JSON.stringify(error),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
await addNotificationAsync({
|
|
||||||
type: 'error',
|
|
||||||
title: t('extension.remove.error.title'),
|
|
||||||
text: t('extension.remove.error.text', { error: JSON.stringify(error) }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<i18n lang="yaml">
|
|
||||||
de:
|
|
||||||
title: Erweiterungen
|
|
||||||
subtitle: Entdecke und installiere Erweiterungen für HaexHub
|
|
||||||
extension:
|
|
||||||
installFromFile: Von Datei installieren
|
|
||||||
add: Erweiterung hinzufügen
|
|
||||||
success:
|
|
||||||
title: '{extension} hinzugefügt'
|
|
||||||
text: Die Erweiterung wurde erfolgreich hinzugefügt
|
|
||||||
remove:
|
|
||||||
success:
|
|
||||||
text: 'Erweiterung {extensionName} wurde erfolgreich entfernt'
|
|
||||||
title: '{extensionName} entfernt'
|
|
||||||
error:
|
|
||||||
text: "Erweiterung {extensionName} konnte nicht entfernt werden. \n {error}"
|
|
||||||
title: 'Fehler beim Entfernen von {extensionName}'
|
|
||||||
marketplace:
|
|
||||||
comingSoon: Marketplace-Installation kommt bald!
|
|
||||||
marketplace:
|
|
||||||
official: Offizieller Marketplace
|
|
||||||
community: Community Marketplace
|
|
||||||
category:
|
|
||||||
all: Alle
|
|
||||||
productivity: Produktivität
|
|
||||||
security: Sicherheit
|
|
||||||
utilities: Werkzeuge
|
|
||||||
integration: Integration
|
|
||||||
search:
|
|
||||||
placeholder: Erweiterungen durchsuchen...
|
|
||||||
filter:
|
|
||||||
category: Kategorie auswählen
|
|
||||||
empty:
|
|
||||||
title: Keine Erweiterungen gefunden
|
|
||||||
description: Versuche einen anderen Suchbegriff oder eine andere Kategorie
|
|
||||||
|
|
||||||
en:
|
|
||||||
title: Extensions
|
|
||||||
subtitle: Discover and install extensions for HaexHub
|
|
||||||
extension:
|
|
||||||
installFromFile: Install from file
|
|
||||||
add: Add Extension
|
|
||||||
success:
|
|
||||||
title: '{extension} added'
|
|
||||||
text: Extension was added successfully
|
|
||||||
remove:
|
|
||||||
success:
|
|
||||||
text: 'Extension {extensionName} was removed'
|
|
||||||
title: '{extensionName} removed'
|
|
||||||
error:
|
|
||||||
text: "Extension {extensionName} couldn't be removed. \n {error}"
|
|
||||||
title: 'Exception during uninstall {extensionName}'
|
|
||||||
marketplace:
|
|
||||||
comingSoon: Marketplace installation coming soon!
|
|
||||||
marketplace:
|
|
||||||
official: Official Marketplace
|
|
||||||
community: Community Marketplace
|
|
||||||
category:
|
|
||||||
all: All
|
|
||||||
productivity: Productivity
|
|
||||||
security: Security
|
|
||||||
utilities: Utilities
|
|
||||||
integration: Integration
|
|
||||||
search:
|
|
||||||
placeholder: Search extensions...
|
|
||||||
filter:
|
|
||||||
category: Select category
|
|
||||||
empty:
|
|
||||||
title: No extensions found
|
|
||||||
description: Try a different search term or category
|
|
||||||
</i18n>
|
|
||||||
@ -6,6 +6,6 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
name: 'vaultOverview',
|
name: 'desktop',
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
13
src/stores/desktop/de.json
Normal file
13
src/stores/desktop/de.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"contextMenu": {
|
||||||
|
"open": "Öffnen",
|
||||||
|
"removeFromDesktop": "Von Desktop entfernen",
|
||||||
|
"uninstall": "Deinstallieren"
|
||||||
|
},
|
||||||
|
"confirmUninstall": {
|
||||||
|
"title": "Erweiterung deinstallieren",
|
||||||
|
"message": "Möchten Sie die Erweiterung '{name}' wirklich deinstallieren? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
"confirm": "Deinstallieren",
|
||||||
|
"cancel": "Abbrechen"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/stores/desktop/en.json
Normal file
13
src/stores/desktop/en.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"contextMenu": {
|
||||||
|
"open": "Open",
|
||||||
|
"removeFromDesktop": "Remove from Desktop",
|
||||||
|
"uninstall": "Uninstall"
|
||||||
|
},
|
||||||
|
"confirmUninstall": {
|
||||||
|
"title": "Uninstall Extension",
|
||||||
|
"message": "Do you really want to uninstall the extension '{name}'? This action cannot be undone.",
|
||||||
|
"confirm": "Uninstall",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,8 @@ import type {
|
|||||||
InsertHaexDesktopItems,
|
InsertHaexDesktopItems,
|
||||||
SelectHaexDesktopItems,
|
SelectHaexDesktopItems,
|
||||||
} from '~~/src-tauri/database/schemas'
|
} from '~~/src-tauri/database/schemas'
|
||||||
|
import de from './de.json'
|
||||||
|
import en from './en.json'
|
||||||
|
|
||||||
export type DesktopItemType = 'extension' | 'file' | 'folder'
|
export type DesktopItemType = 'extension' | 'file' | 'folder'
|
||||||
|
|
||||||
@ -14,8 +16,17 @@ export interface IDesktopItem extends SelectHaexDesktopItems {
|
|||||||
|
|
||||||
export const useDesktopStore = defineStore('desktopStore', () => {
|
export const useDesktopStore = defineStore('desktopStore', () => {
|
||||||
const { currentVault } = storeToRefs(useVaultStore())
|
const { currentVault } = storeToRefs(useVaultStore())
|
||||||
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
const { currentWorkspace } = storeToRefs(workspaceStore)
|
||||||
|
const { $i18n } = useNuxtApp()
|
||||||
|
|
||||||
|
$i18n.setLocaleMessage('de', {
|
||||||
|
desktop: de,
|
||||||
|
})
|
||||||
|
$i18n.setLocaleMessage('en', { desktop: en })
|
||||||
|
|
||||||
const desktopItems = ref<IDesktopItem[]>([])
|
const desktopItems = ref<IDesktopItem[]>([])
|
||||||
|
const selectedItemIds = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
const loadDesktopItemsAsync = async () => {
|
const loadDesktopItemsAsync = async () => {
|
||||||
if (!currentVault.value?.drizzle) {
|
if (!currentVault.value?.drizzle) {
|
||||||
@ -23,10 +34,16 @@ export const useDesktopStore = defineStore('desktopStore', () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!currentWorkspace.value) {
|
||||||
|
console.error('Kein Workspace aktiv')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const items = await currentVault.value.drizzle
|
const items = await currentVault.value.drizzle
|
||||||
.select()
|
.select()
|
||||||
.from(haexDesktopItems)
|
.from(haexDesktopItems)
|
||||||
|
.where(eq(haexDesktopItems.workspaceId, currentWorkspace.value.id))
|
||||||
|
|
||||||
desktopItems.value = items
|
desktopItems.value = items
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -45,8 +62,13 @@ export const useDesktopStore = defineStore('desktopStore', () => {
|
|||||||
throw new Error('Kein Vault geöffnet')
|
throw new Error('Kein Vault geöffnet')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!currentWorkspace.value) {
|
||||||
|
throw new Error('Kein Workspace aktiv')
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newItem: InsertHaexDesktopItems = {
|
const newItem: InsertHaexDesktopItems = {
|
||||||
|
workspaceId: currentWorkspace.value.id,
|
||||||
itemType: itemType,
|
itemType: itemType,
|
||||||
referenceId: referenceId,
|
referenceId: referenceId,
|
||||||
positionX: positionX,
|
positionX: positionX,
|
||||||
@ -100,6 +122,7 @@ export const useDesktopStore = defineStore('desktopStore', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeDesktopItemAsync = async (id: string) => {
|
const removeDesktopItemAsync = async (id: string) => {
|
||||||
|
console.log('removeDesktopItemAsync', id)
|
||||||
if (!currentVault.value?.drizzle) {
|
if (!currentVault.value?.drizzle) {
|
||||||
throw new Error('Kein Vault geöffnet')
|
throw new Error('Kein Vault geöffnet')
|
||||||
}
|
}
|
||||||
@ -126,33 +149,128 @@ export const useDesktopStore = defineStore('desktopStore', () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openDesktopItem = (
|
||||||
|
itemType: DesktopItemType,
|
||||||
|
referenceId: string,
|
||||||
|
sourcePosition?: { x: number; y: number; width: number; height: number },
|
||||||
|
) => {
|
||||||
|
if (itemType === 'extension') {
|
||||||
|
const windowManager = useWindowManagerStore()
|
||||||
|
const extensionsStore = useExtensionsStore()
|
||||||
|
|
||||||
|
const extension = extensionsStore.availableExtensions.find(
|
||||||
|
(ext) => ext.id === referenceId,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (extension) {
|
||||||
|
windowManager.openWindow(
|
||||||
|
'extension',
|
||||||
|
extension.id,
|
||||||
|
extension.name,
|
||||||
|
extension.icon || undefined,
|
||||||
|
undefined, // Use default viewport-aware width
|
||||||
|
undefined, // Use default viewport-aware height
|
||||||
|
sourcePosition,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Für später: file und folder handling
|
||||||
|
}
|
||||||
|
|
||||||
|
const uninstallDesktopItem = async (
|
||||||
|
id: string,
|
||||||
|
itemType: DesktopItemType,
|
||||||
|
referenceId: string,
|
||||||
|
) => {
|
||||||
|
if (itemType === 'extension') {
|
||||||
|
try {
|
||||||
|
const extensionsStore = useExtensionsStore()
|
||||||
|
const extension = extensionsStore.availableExtensions.find(
|
||||||
|
(ext) => ext.id === referenceId,
|
||||||
|
)
|
||||||
|
if (!extension) {
|
||||||
|
console.error('Extension nicht gefunden')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall the extension
|
||||||
|
await extensionsStore.removeExtensionAsync(
|
||||||
|
extension.publicKey,
|
||||||
|
extension.name,
|
||||||
|
extension.version,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reload extensions after uninstall
|
||||||
|
await extensionsStore.loadExtensionsAsync()
|
||||||
|
|
||||||
|
// Remove desktop item
|
||||||
|
await removeDesktopItemAsync(id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Deinstallieren:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Für später: file und folder handling
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSelection = (id: string, ctrlKey: boolean = false) => {
|
||||||
|
if (ctrlKey) {
|
||||||
|
// Mit Ctrl: Toggle einzelnes Element
|
||||||
|
if (selectedItemIds.value.has(id)) {
|
||||||
|
selectedItemIds.value.delete(id)
|
||||||
|
} else {
|
||||||
|
selectedItemIds.value.add(id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ohne Ctrl: Nur dieses Element auswählen
|
||||||
|
selectedItemIds.value.clear()
|
||||||
|
selectedItemIds.value.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
selectedItemIds.value.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isItemSelected = (id: string) => {
|
||||||
|
return selectedItemIds.value.has(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedItems = computed(() => {
|
||||||
|
return desktopItems.value.filter((item) =>
|
||||||
|
selectedItemIds.value.has(item.id),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const getContextMenuItems = (
|
const getContextMenuItems = (
|
||||||
id: string,
|
id: string,
|
||||||
itemType: DesktopItemType,
|
itemType: DesktopItemType,
|
||||||
referenceId: string,
|
referenceId: string,
|
||||||
onOpen: () => void,
|
|
||||||
onUninstall: () => void,
|
onUninstall: () => void,
|
||||||
) => {
|
) => {
|
||||||
|
const handleOpen = () => {
|
||||||
|
openDesktopItem(itemType, referenceId)
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
label: 'Öffnen',
|
label: $i18n.t('desktop.contextMenu.open'),
|
||||||
icon: 'i-heroicons-arrow-top-right-on-square',
|
icon: 'i-heroicons-arrow-top-right-on-square',
|
||||||
click: onOpen,
|
onSelect: handleOpen,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
label: 'Von Desktop entfernen',
|
label: $i18n.t('desktop.contextMenu.removeFromDesktop'),
|
||||||
icon: 'i-heroicons-x-mark',
|
icon: 'i-heroicons-x-mark',
|
||||||
click: async () => {
|
onSelect: async () => {
|
||||||
await removeDesktopItemAsync(id)
|
await removeDesktopItemAsync(id)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Deinstallieren',
|
label: $i18n.t('desktop.contextMenu.uninstall'),
|
||||||
icon: 'i-heroicons-trash',
|
icon: 'i-heroicons-trash',
|
||||||
click: onUninstall,
|
onSelect: onUninstall,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
@ -160,11 +278,18 @@ export const useDesktopStore = defineStore('desktopStore', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
desktopItems,
|
desktopItems,
|
||||||
|
selectedItemIds,
|
||||||
|
selectedItems,
|
||||||
loadDesktopItemsAsync,
|
loadDesktopItemsAsync,
|
||||||
addDesktopItemAsync,
|
addDesktopItemAsync,
|
||||||
updateDesktopItemPositionAsync,
|
updateDesktopItemPositionAsync,
|
||||||
removeDesktopItemAsync,
|
removeDesktopItemAsync,
|
||||||
getDesktopItemByReference,
|
getDesktopItemByReference,
|
||||||
getContextMenuItems,
|
getContextMenuItems,
|
||||||
|
openDesktopItem,
|
||||||
|
uninstallDesktopItem,
|
||||||
|
toggleSelection,
|
||||||
|
clearSelection,
|
||||||
|
isItemSelected,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
312
src/stores/desktop/windowManager.ts
Normal file
312
src/stores/desktop/windowManager.ts
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
import { defineAsyncComponent, type Component } from 'vue'
|
||||||
|
|
||||||
|
export interface IWindow {
|
||||||
|
id: string
|
||||||
|
workspaceId: string // Window belongs to a specific workspace
|
||||||
|
type: 'system' | 'extension'
|
||||||
|
sourceId: string // extensionId or systemWindowId (depends on type)
|
||||||
|
title: string
|
||||||
|
icon?: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
isMinimized: boolean
|
||||||
|
zIndex: number
|
||||||
|
// Animation source position (icon position)
|
||||||
|
sourceX?: number
|
||||||
|
sourceY?: number
|
||||||
|
sourceWidth?: number
|
||||||
|
sourceHeight?: number
|
||||||
|
// Animation state
|
||||||
|
isOpening?: boolean
|
||||||
|
isClosing?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemWindowDefinition {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
component: Component
|
||||||
|
defaultWidth: number
|
||||||
|
defaultHeight: number
|
||||||
|
resizable?: boolean
|
||||||
|
singleton?: boolean // Nur eine Instanz erlaubt?
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWindowManagerStore = defineStore('windowManager', () => {
|
||||||
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
const { currentWorkspace, workspaces } = storeToRefs(workspaceStore)
|
||||||
|
|
||||||
|
const windows = ref<IWindow[]>([])
|
||||||
|
const activeWindowId = ref<string | null>(null)
|
||||||
|
const nextZIndex = ref(100)
|
||||||
|
|
||||||
|
// System Windows Registry
|
||||||
|
const systemWindows: Record<string, SystemWindowDefinition> = {
|
||||||
|
settings: {
|
||||||
|
id: 'settings',
|
||||||
|
name: 'Settings',
|
||||||
|
icon: 'i-mdi-cog',
|
||||||
|
component: defineAsyncComponent(
|
||||||
|
() => import('@/components/haex/system/settings.vue'),
|
||||||
|
) as Component,
|
||||||
|
defaultWidth: 800,
|
||||||
|
defaultHeight: 600,
|
||||||
|
resizable: true,
|
||||||
|
singleton: true,
|
||||||
|
},
|
||||||
|
marketplace: {
|
||||||
|
id: 'marketplace',
|
||||||
|
name: 'Marketplace',
|
||||||
|
icon: 'i-mdi-store',
|
||||||
|
component: defineAsyncComponent(
|
||||||
|
() => import('@/components/haex/system/marketplace.vue'),
|
||||||
|
),
|
||||||
|
defaultWidth: 1000,
|
||||||
|
defaultHeight: 700,
|
||||||
|
resizable: true,
|
||||||
|
singleton: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSystemWindow = (id: string): SystemWindowDefinition | undefined => {
|
||||||
|
return systemWindows[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllSystemWindows = (): SystemWindowDefinition[] => {
|
||||||
|
return Object.values(systemWindows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window animation settings
|
||||||
|
const windowAnimationDuration = ref(600) // in milliseconds (matches Tailwind duration-600)
|
||||||
|
|
||||||
|
// Get windows for current workspace only
|
||||||
|
const currentWorkspaceWindows = computed(() => {
|
||||||
|
if (!currentWorkspace.value) return []
|
||||||
|
return windows.value.filter(
|
||||||
|
(w) => w.workspaceId === currentWorkspace.value?.id,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const windowsByWorkspaceId = (workspaceId: string) =>
|
||||||
|
computed(() =>
|
||||||
|
windows.value.filter((window) => window.workspaceId === workspaceId),
|
||||||
|
)
|
||||||
|
|
||||||
|
const moveWindowsToWorkspace = (
|
||||||
|
fromWorkspaceId: string,
|
||||||
|
toWorkspaceId: string,
|
||||||
|
) => {
|
||||||
|
const windowsFrom = windowsByWorkspaceId(fromWorkspaceId)
|
||||||
|
windowsFrom.value.forEach((window) => (window.workspaceId = toWorkspaceId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const openWindow = (
|
||||||
|
type: 'system' | 'extension',
|
||||||
|
sourceId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
title?: string,
|
||||||
|
icon?: string,
|
||||||
|
width?: number,
|
||||||
|
height?: number,
|
||||||
|
sourcePosition?: { x: number; y: number; width: number; height: number },
|
||||||
|
) => {
|
||||||
|
const workspace = workspaces.value.find((w) => w.id === workspaceId)
|
||||||
|
if (!workspace) {
|
||||||
|
console.error('Cannot open window: No active workspace')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// System Window specific handling
|
||||||
|
if (type === 'system') {
|
||||||
|
const systemWindowDef = getSystemWindow(sourceId)
|
||||||
|
if (!systemWindowDef) {
|
||||||
|
console.error(`System window '${sourceId}' not found in registry`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton check: If already open, activate existing window
|
||||||
|
if (systemWindowDef.singleton) {
|
||||||
|
const existingWindow = windows.value.find(
|
||||||
|
(w) => w.type === 'system' && w.sourceId === sourceId,
|
||||||
|
)
|
||||||
|
if (existingWindow) {
|
||||||
|
activateWindow(existingWindow.id)
|
||||||
|
return existingWindow.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use system window defaults
|
||||||
|
title = title ?? systemWindowDef.name
|
||||||
|
icon = icon ?? systemWindowDef.icon
|
||||||
|
width = width ?? systemWindowDef.defaultWidth
|
||||||
|
height = height ?? systemWindowDef.defaultHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new window
|
||||||
|
const windowId = crypto.randomUUID()
|
||||||
|
|
||||||
|
// Calculate viewport-aware size
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
const isMobile = viewportWidth < 768 // Tailwind md breakpoint
|
||||||
|
|
||||||
|
// Default size based on viewport
|
||||||
|
const defaultWidth = isMobile ? Math.floor(viewportWidth * 0.95) : 800
|
||||||
|
const defaultHeight = isMobile ? Math.floor(viewportHeight * 0.85) : 600
|
||||||
|
|
||||||
|
const windowWidth = width ?? defaultWidth
|
||||||
|
const windowHeight = height ?? defaultHeight
|
||||||
|
|
||||||
|
// Calculate centered position with cascading offset (only count windows in current workspace)
|
||||||
|
const offset = currentWorkspaceWindows.value.length * 30
|
||||||
|
const centerX = Math.max(0, (viewportWidth - windowWidth) / 2)
|
||||||
|
const centerY = Math.max(0, (viewportHeight - windowHeight) / 2)
|
||||||
|
const x = Math.min(centerX + offset, viewportWidth - windowWidth)
|
||||||
|
const y = Math.min(centerY + offset, viewportHeight - windowHeight)
|
||||||
|
|
||||||
|
const newWindow: IWindow = {
|
||||||
|
id: windowId,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
type,
|
||||||
|
sourceId,
|
||||||
|
title: title!,
|
||||||
|
icon,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: windowWidth,
|
||||||
|
height: windowHeight,
|
||||||
|
isMinimized: false,
|
||||||
|
zIndex: nextZIndex.value++,
|
||||||
|
sourceX: sourcePosition?.x,
|
||||||
|
sourceY: sourcePosition?.y,
|
||||||
|
sourceWidth: sourcePosition?.width,
|
||||||
|
sourceHeight: sourcePosition?.height,
|
||||||
|
isOpening: true,
|
||||||
|
isClosing: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
windows.value.push(newWindow)
|
||||||
|
activeWindowId.value = windowId
|
||||||
|
|
||||||
|
// Remove opening flag after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
const window = windows.value.find((w) => w.id === windowId)
|
||||||
|
if (window) {
|
||||||
|
window.isOpening = false
|
||||||
|
}
|
||||||
|
}, windowAnimationDuration.value)
|
||||||
|
|
||||||
|
return windowId
|
||||||
|
}
|
||||||
|
|
||||||
|
/*****************************************************************************************************
|
||||||
|
* TODO: Momentan werden die Fenster einfach nur geschlossen.
|
||||||
|
* In Zukunft sollte aber vorher ein close event an die Erweiterungen via postMessage geschickt werden,
|
||||||
|
* so dass die Erweiterungen darauf reagieren können, um eventuell ungespeicherte Daten zu sichern
|
||||||
|
*****************************************************************************************************/
|
||||||
|
const closeWindow = (windowId: string) => {
|
||||||
|
const window = windows.value.find((w) => w.id === windowId)
|
||||||
|
if (!window) return
|
||||||
|
|
||||||
|
// Start closing animation
|
||||||
|
window.isClosing = true
|
||||||
|
|
||||||
|
// Remove window after animation completes
|
||||||
|
setTimeout(() => {
|
||||||
|
const index = windows.value.findIndex((w) => w.id === windowId)
|
||||||
|
if (index !== -1) {
|
||||||
|
windows.value.splice(index, 1)
|
||||||
|
|
||||||
|
// If closed window was active, activate the topmost window
|
||||||
|
if (activeWindowId.value === windowId) {
|
||||||
|
if (windows.value.length > 0) {
|
||||||
|
const topWindow = windows.value.reduce((max, w) =>
|
||||||
|
w.zIndex > max.zIndex ? w : max,
|
||||||
|
)
|
||||||
|
activeWindowId.value = topWindow.id
|
||||||
|
} else {
|
||||||
|
activeWindowId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, windowAnimationDuration.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimizeWindow = (windowId: string) => {
|
||||||
|
const window = windows.value.find((w) => w.id === windowId)
|
||||||
|
if (window) {
|
||||||
|
window.isMinimized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreWindow = (windowId: string) => {
|
||||||
|
const window = windows.value.find((w) => w.id === windowId)
|
||||||
|
if (window) {
|
||||||
|
window.isMinimized = false
|
||||||
|
activateWindow(windowId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activateWindow = (windowId: string) => {
|
||||||
|
const window = windows.value.find((w) => w.id === windowId)
|
||||||
|
if (window) {
|
||||||
|
window.zIndex = nextZIndex.value++
|
||||||
|
activeWindowId.value = windowId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateWindowPosition = (windowId: string, x: number, y: number) => {
|
||||||
|
const window = windows.value.find((w) => w.id === windowId)
|
||||||
|
if (window) {
|
||||||
|
window.x = x
|
||||||
|
window.y = y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateWindowSize = (
|
||||||
|
windowId: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
) => {
|
||||||
|
const window = windows.value.find((w) => w.id === windowId)
|
||||||
|
if (window) {
|
||||||
|
window.width = width
|
||||||
|
window.height = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWindowActive = (windowId: string) => {
|
||||||
|
return activeWindowId.value === windowId
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVisibleWindows = computed(() => {
|
||||||
|
return currentWorkspaceWindows.value.filter((w) => !w.isMinimized)
|
||||||
|
})
|
||||||
|
|
||||||
|
const getMinimizedWindows = computed(() => {
|
||||||
|
return currentWorkspaceWindows.value.filter((w) => w.isMinimized)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
activateWindow,
|
||||||
|
activeWindowId,
|
||||||
|
closeWindow,
|
||||||
|
currentWorkspaceWindows,
|
||||||
|
getAllSystemWindows,
|
||||||
|
getMinimizedWindows,
|
||||||
|
getSystemWindow,
|
||||||
|
getVisibleWindows,
|
||||||
|
isWindowActive,
|
||||||
|
minimizeWindow,
|
||||||
|
moveWindowsToWorkspace,
|
||||||
|
openWindow,
|
||||||
|
restoreWindow,
|
||||||
|
updateWindowPosition,
|
||||||
|
updateWindowSize,
|
||||||
|
windowAnimationDuration,
|
||||||
|
windows,
|
||||||
|
windowsByWorkspaceId,
|
||||||
|
}
|
||||||
|
})
|
||||||
189
src/stores/desktop/workspace.ts
Normal file
189
src/stores/desktop/workspace.ts
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import { asc, eq } from 'drizzle-orm'
|
||||||
|
import {
|
||||||
|
haexWorkspaces,
|
||||||
|
type InsertHaexWorkspaces,
|
||||||
|
type SelectHaexWorkspaces,
|
||||||
|
} from '~~/src-tauri/database/schemas'
|
||||||
|
import type { Swiper } from 'swiper/types'
|
||||||
|
|
||||||
|
export type IWorkspace = SelectHaexWorkspaces
|
||||||
|
|
||||||
|
export const useWorkspaceStore = defineStore('workspaceStore', () => {
|
||||||
|
const vaultStore = useVaultStore()
|
||||||
|
const windowStore = useWindowManagerStore()
|
||||||
|
|
||||||
|
const { currentVault } = storeToRefs(vaultStore)
|
||||||
|
|
||||||
|
const swiperInstance = ref<Swiper | null>(null)
|
||||||
|
|
||||||
|
const allowSwipe = ref(true)
|
||||||
|
|
||||||
|
// Workspace Overview Mode (GNOME-style)
|
||||||
|
const isOverviewMode = ref(false)
|
||||||
|
|
||||||
|
const workspaces = ref<IWorkspace[]>([])
|
||||||
|
|
||||||
|
const currentWorkspaceIndex = ref(0)
|
||||||
|
|
||||||
|
// Load workspaces from database
|
||||||
|
const loadWorkspacesAsync = async () => {
|
||||||
|
if (!currentVault.value?.drizzle) {
|
||||||
|
console.error('Kein Vault geöffnet')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await currentVault.value.drizzle
|
||||||
|
.select()
|
||||||
|
.from(haexWorkspaces)
|
||||||
|
.orderBy(asc(haexWorkspaces.position))
|
||||||
|
|
||||||
|
workspaces.value = items
|
||||||
|
|
||||||
|
// Create default workspace if none exist
|
||||||
|
if (items.length === 0) {
|
||||||
|
await addWorkspaceAsync('Workspace 1')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Workspaces:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentWorkspace = computed(() => {
|
||||||
|
return workspaces.value[currentWorkspaceIndex.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
const addWorkspaceAsync = async (name?: string) => {
|
||||||
|
if (!currentVault.value?.drizzle) {
|
||||||
|
throw new Error('Kein Vault geöffnet')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newIndex = workspaces.value.length + 1
|
||||||
|
const newWorkspace: InsertHaexWorkspaces = {
|
||||||
|
name: name || `Workspace ${newIndex}`,
|
||||||
|
position: workspaces.value.length,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await currentVault.value.drizzle
|
||||||
|
.insert(haexWorkspaces)
|
||||||
|
.values(newWorkspace)
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (result.length > 0 && result[0]) {
|
||||||
|
workspaces.value.push(result[0])
|
||||||
|
currentWorkspaceIndex.value = workspaces.value.length - 1
|
||||||
|
return result[0]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Hinzufügen des Workspace:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeWorkspaceAsync = async (workspaceId: string) => {
|
||||||
|
const openWindows = windowStore.windowsByWorkspaceId(workspaceId)
|
||||||
|
|
||||||
|
for (const window of openWindows.value) {
|
||||||
|
windowStore.closeWindow(window.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await removeWorkspaceAsync(workspaceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeWorkspaceAsync = async (workspaceId: string) => {
|
||||||
|
// Don't allow removing the last workspace
|
||||||
|
if (workspaces.value.length <= 1) return
|
||||||
|
|
||||||
|
if (!currentVault.value?.drizzle) {
|
||||||
|
throw new Error('Kein Vault geöffnet')
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = workspaces.value.findIndex((ws) => ws.id === workspaceId)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await currentVault.value.drizzle
|
||||||
|
.delete(haexWorkspaces)
|
||||||
|
.where(eq(haexWorkspaces.id, workspaceId))
|
||||||
|
|
||||||
|
workspaces.value.splice(index, 1)
|
||||||
|
|
||||||
|
// Adjust current index if needed
|
||||||
|
if (currentWorkspaceIndex.value >= workspaces.value.length) {
|
||||||
|
currentWorkspaceIndex.value = workspaces.value.length - 1
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Entfernen des Workspace:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchToWorkspace = (index: number) => {
|
||||||
|
if (index >= 0 && index < workspaces.value.length) {
|
||||||
|
currentWorkspaceIndex.value = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchToNext = () => {
|
||||||
|
if (currentWorkspaceIndex.value < workspaces.value.length - 1) {
|
||||||
|
currentWorkspaceIndex.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchToPrevious = () => {
|
||||||
|
if (currentWorkspaceIndex.value > 0) {
|
||||||
|
currentWorkspaceIndex.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renameWorkspaceAsync = async (workspaceId: string, newName: string) => {
|
||||||
|
if (!currentVault.value?.drizzle) {
|
||||||
|
throw new Error('Kein Vault geöffnet')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await currentVault.value.drizzle
|
||||||
|
.update(haexWorkspaces)
|
||||||
|
.set({ name: newName })
|
||||||
|
.where(eq(haexWorkspaces.id, workspaceId))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (result.length > 0 && result[0]) {
|
||||||
|
const index = workspaces.value.findIndex((ws) => ws.id === workspaceId)
|
||||||
|
if (index !== -1) {
|
||||||
|
workspaces.value[index] = result[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Umbenennen des Workspace:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slideToWorkspace = (index: number) => {
|
||||||
|
if (swiperInstance.value) {
|
||||||
|
swiperInstance.value.slideTo(index)
|
||||||
|
}
|
||||||
|
isOverviewMode.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
addWorkspaceAsync,
|
||||||
|
allowSwipe,
|
||||||
|
closeWorkspaceAsync,
|
||||||
|
currentWorkspace,
|
||||||
|
currentWorkspaceIndex,
|
||||||
|
isOverviewMode,
|
||||||
|
slideToWorkspace,
|
||||||
|
loadWorkspacesAsync,
|
||||||
|
removeWorkspaceAsync,
|
||||||
|
renameWorkspaceAsync,
|
||||||
|
swiperInstance,
|
||||||
|
switchToNext,
|
||||||
|
switchToPrevious,
|
||||||
|
switchToWorkspace,
|
||||||
|
workspaces,
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -31,26 +31,12 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
/* const { addNotificationAsync } = useNotificationStore() */
|
/* const isActive = (id: string) =>
|
||||||
|
|
||||||
/* const extensionLinks = computed<ISidebarItem[]>(() =>
|
|
||||||
availableExtensions.value
|
|
||||||
.filter((extension) => extension.enabled && extension.installed)
|
|
||||||
.map((extension) => ({
|
|
||||||
icon: extension.icon ?? '',
|
|
||||||
id: extension.id,
|
|
||||||
name: extension.name ?? '',
|
|
||||||
tooltip: extension.name ?? '',
|
|
||||||
to: { name: 'haexExtension', params: { extensionId: extension.id } },
|
|
||||||
})),
|
|
||||||
) */
|
|
||||||
|
|
||||||
const isActive = (id: string) =>
|
|
||||||
computed(
|
computed(
|
||||||
() =>
|
() =>
|
||||||
currentRoute.value.name === 'extension' &&
|
currentRoute.value.name === 'extension' &&
|
||||||
currentRoute.value.params.extensionId === id,
|
currentRoute.value.params.extensionId === id,
|
||||||
)
|
) */
|
||||||
|
|
||||||
const extensionEntry = computed(() => {
|
const extensionEntry = computed(() => {
|
||||||
if (
|
if (
|
||||||
@ -65,7 +51,7 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
|||||||
currentExtension.value.name,
|
currentExtension.value.name,
|
||||||
currentExtension.value.version,
|
currentExtension.value.version,
|
||||||
'index.html',
|
'index.html',
|
||||||
currentExtension.value.devServerUrl ?? undefined
|
currentExtension.value.devServerUrl ?? undefined,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -286,7 +272,7 @@ export const useExtensionsStore = defineStore('extensionsStore', () => {
|
|||||||
currentExtensionId,
|
currentExtensionId,
|
||||||
extensionEntry,
|
extensionEntry,
|
||||||
installAsync,
|
installAsync,
|
||||||
isActive,
|
//isActive,
|
||||||
isExtensionInstalledAsync,
|
isExtensionInstalledAsync,
|
||||||
loadExtensionsAsync,
|
loadExtensionsAsync,
|
||||||
previewManifestAsync,
|
previewManifestAsync,
|
||||||
|
|||||||
@ -92,12 +92,20 @@ export const useVaultStore = defineStore('vaultStore', () => {
|
|||||||
delete openVaults.value?.[currentVaultId.value]
|
delete openVaults.value?.[currentVaultId.value]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existsVault = () => {
|
||||||
|
if (!currentVault.value?.drizzle) {
|
||||||
|
console.error('Kein Vault geöffnet')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
closeAsync,
|
closeAsync,
|
||||||
createAsync,
|
createAsync,
|
||||||
currentVault,
|
currentVault,
|
||||||
currentVaultId,
|
currentVaultId,
|
||||||
currentVaultName,
|
currentVaultName,
|
||||||
|
existsVault,
|
||||||
openAsync,
|
openAsync,
|
||||||
openVaults,
|
openVaults,
|
||||||
}
|
}
|
||||||
@ -118,6 +126,11 @@ const isSelectQuery = (sql: string) => {
|
|||||||
return selectRegex.test(sql)
|
return selectRegex.test(sql)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasReturning = (sql: string) => {
|
||||||
|
const returningRegex = /\bRETURNING\b/i
|
||||||
|
return returningRegex.test(sql)
|
||||||
|
}
|
||||||
|
|
||||||
const drizzleCallback = (async (
|
const drizzleCallback = (async (
|
||||||
sql: string,
|
sql: string,
|
||||||
params: unknown[],
|
params: unknown[],
|
||||||
@ -125,18 +138,33 @@ const drizzleCallback = (async (
|
|||||||
) => {
|
) => {
|
||||||
let rows: unknown[] = []
|
let rows: unknown[] = []
|
||||||
|
|
||||||
|
console.log('drizzleCallback', method, sql, params)
|
||||||
|
|
||||||
if (isSelectQuery(sql)) {
|
if (isSelectQuery(sql)) {
|
||||||
|
// SELECT statements
|
||||||
rows = await invoke<unknown[]>('sql_select', { sql, params }).catch((e) => {
|
rows = await invoke<unknown[]>('sql_select', { sql, params }).catch((e) => {
|
||||||
console.error('SQL select Error:', e, sql, params)
|
console.error('SQL select Error:', e, sql, params)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
} else if (hasReturning(sql)) {
|
||||||
|
// INSERT/UPDATE/DELETE with RETURNING → use query
|
||||||
|
rows = await invoke<unknown[]>('sql_query_with_crdt', {
|
||||||
|
sql,
|
||||||
|
params,
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error('SQL query with CRDT Error:', e, sql, params)
|
||||||
|
return []
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
rows = await invoke<unknown[]>('sql_execute', { sql, params }).catch(
|
// INSERT/UPDATE/DELETE without RETURNING → use execute
|
||||||
(e) => {
|
await invoke<unknown[]>('sql_execute_with_crdt', {
|
||||||
console.error('SQL execute Error:', e, sql, params)
|
sql,
|
||||||
return []
|
params,
|
||||||
},
|
}).catch((e) => {
|
||||||
)
|
console.error('SQL execute with CRDT Error:', e, sql, params, rows)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
return { rows: undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'get') {
|
if (method === 'get') {
|
||||||
|
|||||||
2
todos.md
2
todos.md
@ -4,3 +4,5 @@
|
|||||||
`${publicKey}_${extensionName}_${tableName}`
|
`${publicKey}_${extensionName}_${tableName}`
|
||||||
|
|
||||||
- im sdk und generell, muss ich das namensschema überdenken. das trennzeichen sollte vermutlich etwas anderes sein, als der "\_"
|
- im sdk und generell, muss ich das namensschema überdenken. das trennzeichen sollte vermutlich etwas anderes sein, als der "\_"
|
||||||
|
|
||||||
|
I'll continue by registering the sql_execute_with_crdt command in lib.rs and updating the frontend Drizzle callback.
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@bindings/*": ["./src-tauri/bindings/*"]
|
"@bindings/*": ["./src-tauri/bindings/*"],
|
||||||
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [],
|
"files": [],
|
||||||
|
|||||||
Reference in New Issue
Block a user