From 86161d66e28b2013e2170221d3fd45be158b60d2 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 18 Oct 2026 16:37:36 -0500 Subject: [PATCH] feat: shell controller, TabStage, layout and theme plugins with repair view --- CHANGELOG.md | 13 + drizzle/mysql/0073_user_look_preferences.sql | 2 + drizzle/mysql/meta/0073_snapshot.json | 4759 ++++++++++++++++ drizzle/mysql/meta/_journal.json | 7 + .../postgres/0073_user_look_preferences.sql | 2 + drizzle/postgres/meta/0073_snapshot.json | 4791 +++++++++++++++++ drizzle/postgres/meta/_journal.json | 7 + drizzle/sqlite/0068_user_look_preferences.sql | 2 + drizzle/sqlite/meta/0068_snapshot.json | 4480 +++++++++++++++ drizzle/sqlite/meta/_journal.json | 7 + env.catalog.json | 6 + package-lock.json | 6 +- package.json | 2 +- packages/plugin-sdk/CHANGELOG.md | 16 + packages/plugin-sdk/PLUGIN-GUIDE.md | 67 +- packages/plugin-sdk/cli/commands/pack.mjs | 14 +- packages/plugin-sdk/cli/commands/validate.mjs | 27 +- packages/plugin-sdk/cli/lib/tmxplug.mjs | 9 + packages/plugin-sdk/package.json | 2 +- .../plugin-sdk/schema/manifest.schema.json | 119 +- packages/plugin-sdk/src/capabilities.ts | 2 + packages/plugin-sdk/src/frontend.ts | 138 + packages/plugin-sdk/src/manifest.ts | 165 +- packages/plugin-sdk/src/testing/setup.ts | 1 + packages/plugin-sdk/src/theme-css.ts | 150 + packages/plugin-sdk/ui.api.txt | 2 + scripts/bundled-plugins.test.ts | 68 + scripts/check-sdk-ui-exports.cjs | 2 + scripts/lib/bundled-plugins.cjs | 41 +- scripts/plugin-pack.test.ts | 26 + scripts/plugin-validate.test.ts | 50 + src/backend/database/database.ts | 2 + src/backend/database/db/index.ts | 2 + src/backend/database/db/schema.mysql.ts | 2 + src/backend/database/db/schema.pg.ts | 2 + src/backend/database/db/schema.ts | 2 + src/backend/database/routes/look.ts | 205 + src/backend/database/routes/plugins.ts | 4 +- .../database/routes/user-preferences.ts | 39 + src/backend/plugins/assets.ts | 39 +- src/backend/plugins/bundled-index.ts | 22 +- src/backend/plugins/loader.ts | 92 +- src/backend/plugins/looks.ts | 57 + src/backend/plugins/manage.ts | 12 +- src/backend/plugins/manifest.ts | 1 + .../tests/database/routes/look.test.ts | 191 + .../tests/database/routes/plugins.test.ts | 3 + .../database/routes/user-preferences.test.ts | 96 + src/backend/tests/electron/app-quit.test.ts | 2 +- src/backend/tests/plugins/assets.test.ts | 46 + .../tests/plugins/bundled-index.test.ts | 27 + src/backend/tests/plugins/loader.test.ts | 79 + src/backend/tests/plugins/looks.test.ts | 105 + src/backend/tests/plugins/manage.test.ts | 26 + src/backend/tests/plugins/manifest.test.ts | 76 + src/backend/tests/plugins/theme-css.test.ts | 66 + src/main.tsx | 8 + src/types/ui-types.ts | 3 +- src/ui/AppShell.tsx | 3177 +---------- src/ui/api/look-api.ts | 48 + src/ui/api/open-tabs-api.ts | 4 + src/ui/api/plugins-api.ts | 22 + src/ui/components/theme-provider.tsx | 50 +- src/ui/locales/en.json | 57 + src/ui/look/RepairView.tsx | 376 ++ src/ui/look/layout-registry.ts | 90 + src/ui/look/layout-status.ts | 99 + src/ui/look/look-store.ts | 133 + src/ui/look/resolve-look.ts | 49 + src/ui/look/theme-css.ts | 65 + src/ui/look/theme-registry.ts | 174 + src/ui/look/use-look-theme.ts | 65 + src/ui/plugin-host/app.ts | 27 +- src/ui/plugin-host/bridge.ts | 18 +- src/ui/plugin-host/loader.ts | 101 +- src/ui/plugin-host/scope.tsx | 22 + src/ui/plugin-host/sdk-ui.ts | 3 + src/ui/plugin-host/view-ownership.ts | 5 +- src/ui/plugins/PluginDetail.tsx | 26 +- src/ui/plugins/PluginsTab.tsx | 54 +- src/ui/plugins/plugin-model.ts | 5 + src/ui/plugins/use-plugins-manager.ts | 2 + src/ui/settings/appearance/LookSettings.tsx | 94 + .../appearance/appearance-controls.tsx | 125 +- src/ui/settings/settings-search-index.ts | 1 + src/ui/shell/ShellRoot.tsx | 98 + .../controller/ShellControllerProvider.tsx | 342 ++ src/ui/shell/controller/plugin-shell.ts | 125 + src/ui/shell/controller/shell-store.ts | 32 + src/ui/shell/controller/types.ts | 192 + src/ui/shell/controller/use-hosts.ts | 76 + src/ui/shell/controller/use-regions.ts | 230 + .../shell/controller/use-shell-keybindings.ts | 265 + .../shell/controller/use-shell-lifecycle.ts | 261 + src/ui/shell/controller/use-split.ts | 610 +++ src/ui/shell/controller/use-tabs.ts | 944 ++++ .../shell/controller/use-workspace-layout.ts | 165 + src/ui/shell/layout/LayoutHost.tsx | 118 + src/ui/shell/layout/builtin/DefaultLayout.tsx | 396 ++ src/ui/shell/layout/builtin/index.ts | 11 + src/ui/shell/stage/TabPortals.tsx | 66 + src/ui/shell/stage/TabStage.tsx | 73 + src/ui/shell/stage/tab-stage-store.ts | 194 + src/ui/sidebar/UserProfilePanel.tsx | 59 +- .../tests/components/theme-provider.test.tsx | 78 + src/ui/tests/look/RepairView.test.tsx | 235 + src/ui/tests/look/layout-status.test.ts | 154 + src/ui/tests/look/look-store.test.ts | 85 + src/ui/tests/look/resolve-look.test.ts | 83 + src/ui/tests/look/theme-css.test.ts | 63 + src/ui/tests/look/theme-registry.test.ts | 81 + src/ui/tests/plugin-host/bridge.test.ts | 51 + src/ui/tests/plugin-host/loader.test.tsx | 122 + .../settings/appearance/LookSettings.test.tsx | 66 + .../appearance/appearance-controls.test.tsx | 57 +- .../ShellControllerProvider.test.tsx | 166 + .../shell/controller/plugin-shell.test.tsx | 70 + .../shell/controller/use-regions.test.ts | 79 + src/ui/tests/shell/layout/LayoutHost.test.tsx | 202 + src/ui/tests/shell/stage/TabStage.test.tsx | 141 + 120 files changed, 23693 insertions(+), 3381 deletions(-) create mode 100644 drizzle/mysql/0073_user_look_preferences.sql create mode 100644 drizzle/mysql/meta/0073_snapshot.json create mode 100644 drizzle/postgres/0073_user_look_preferences.sql create mode 100644 drizzle/postgres/meta/0073_snapshot.json create mode 100644 drizzle/sqlite/0068_user_look_preferences.sql create mode 100644 drizzle/sqlite/meta/0068_snapshot.json create mode 100644 packages/plugin-sdk/src/theme-css.ts create mode 100644 src/backend/database/routes/look.ts create mode 100644 src/backend/plugins/looks.ts create mode 100644 src/backend/tests/database/routes/look.test.ts create mode 100644 src/backend/tests/database/routes/user-preferences.test.ts create mode 100644 src/backend/tests/plugins/looks.test.ts create mode 100644 src/backend/tests/plugins/theme-css.test.ts create mode 100644 src/ui/api/look-api.ts create mode 100644 src/ui/look/RepairView.tsx create mode 100644 src/ui/look/layout-registry.ts create mode 100644 src/ui/look/layout-status.ts create mode 100644 src/ui/look/look-store.ts create mode 100644 src/ui/look/resolve-look.ts create mode 100644 src/ui/look/theme-css.ts create mode 100644 src/ui/look/theme-registry.ts create mode 100644 src/ui/look/use-look-theme.ts create mode 100644 src/ui/settings/appearance/LookSettings.tsx create mode 100644 src/ui/shell/ShellRoot.tsx create mode 100644 src/ui/shell/controller/ShellControllerProvider.tsx create mode 100644 src/ui/shell/controller/plugin-shell.ts create mode 100644 src/ui/shell/controller/shell-store.ts create mode 100644 src/ui/shell/controller/types.ts create mode 100644 src/ui/shell/controller/use-hosts.ts create mode 100644 src/ui/shell/controller/use-regions.ts create mode 100644 src/ui/shell/controller/use-shell-keybindings.ts create mode 100644 src/ui/shell/controller/use-shell-lifecycle.ts create mode 100644 src/ui/shell/controller/use-split.ts create mode 100644 src/ui/shell/controller/use-tabs.ts create mode 100644 src/ui/shell/controller/use-workspace-layout.ts create mode 100644 src/ui/shell/layout/LayoutHost.tsx create mode 100644 src/ui/shell/layout/builtin/DefaultLayout.tsx create mode 100644 src/ui/shell/layout/builtin/index.ts create mode 100644 src/ui/shell/stage/TabPortals.tsx create mode 100644 src/ui/shell/stage/TabStage.tsx create mode 100644 src/ui/shell/stage/tab-stage-store.ts create mode 100644 src/ui/tests/components/theme-provider.test.tsx create mode 100644 src/ui/tests/look/RepairView.test.tsx create mode 100644 src/ui/tests/look/layout-status.test.ts create mode 100644 src/ui/tests/look/look-store.test.ts create mode 100644 src/ui/tests/look/resolve-look.test.ts create mode 100644 src/ui/tests/look/theme-css.test.ts create mode 100644 src/ui/tests/look/theme-registry.test.ts create mode 100644 src/ui/tests/settings/appearance/LookSettings.test.tsx create mode 100644 src/ui/tests/shell/controller/ShellControllerProvider.test.tsx create mode 100644 src/ui/tests/shell/controller/plugin-shell.test.tsx create mode 100644 src/ui/tests/shell/controller/use-regions.test.ts create mode 100644 src/ui/tests/shell/layout/LayoutHost.test.tsx create mode 100644 src/ui/tests/shell/stage/TabStage.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ff63f5dbd..d491e55aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 26.10.3 + +### Added + +- Choose the layout of the whole app in Settings, Appearance. Admins can also set the default layout and theme for everyone +- Plugins can add layouts and themes +- A repair screen shows when the chosen layout cannot load, with ways to update, switch, reset or turn it off. Open it any time with ?safe-layout=1, or for everyone with TERMIX_SAFE_LAYOUT=1 +- Hovering a theme in Settings previews it + +### Changed + +- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only + ## 26.10.2 ### Added diff --git a/drizzle/mysql/0073_user_look_preferences.sql b/drizzle/mysql/0073_user_look_preferences.sql new file mode 100644 index 000000000..51ee2231b --- /dev/null +++ b/drizzle/mysql/0073_user_look_preferences.sql @@ -0,0 +1,2 @@ +ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint +ALTER TABLE `user_preferences` ADD `theme_id` text; \ No newline at end of file diff --git a/drizzle/mysql/meta/0073_snapshot.json b/drizzle/mysql/meta/0073_snapshot.json new file mode 100644 index 000000000..be815ab65 --- /dev/null +++ b/drizzle/mysql/meta/0073_snapshot.json @@ -0,0 +1,4759 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "5d267d9f-4402-4b0e-a14c-3d69a3854963", + "prevId": "65ebfe5f-a44f-434d-9208-93c8ea964729", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "api_keys_id": { + "name": "api_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + "action", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + "resource_type", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audit_logs_id": { + "name": "audit_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "credential_access": { + "name": "credential_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('use')" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "credential_access_id": { + "name": "credential_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "credential_sidebar_preferences_user_id": { + "name": "credential_sidebar_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "folder_access": { + "name": "folder_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('connect')" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + "owner_user_id", + "folder" + ], + "isUnique": false + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "folder_access_id": { + "name": "folder_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('connect')" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_access_id": { + "name": "host_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_defaults": { + "name": "host_defaults", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "level": { + "name": "level", + "type": "enum('admin','user','folder')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder_id": { + "name": "folder_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_host_defaults_scope_key": { + "name": "idx_host_defaults_scope_key", + "columns": [ + "scope_key", + "namespace", + "key" + ], + "isUnique": true + }, + "idx_host_defaults_user": { + "name": "idx_host_defaults_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_defaults_folder": { + "name": "idx_host_defaults_folder", + "columns": [ + "folder_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_defaults_user_id_users_id_fk": { + "name": "host_defaults_user_id_users_id_fk", + "tableFrom": "host_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_defaults_folder_id_ssh_folders_id_fk": { + "name": "host_defaults_folder_id_ssh_folders_id_fk", + "tableFrom": "host_defaults", + "tableTo": "ssh_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_defaults_id": { + "name": "host_defaults_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_protocol_auth": { + "name": "host_protocol_auth", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('direct')" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_fields": { + "name": "secret_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_host_protocol_auth_host_protocol": { + "name": "idx_host_protocol_auth_host_protocol", + "columns": [ + "host_id", + "protocol" + ], + "isUnique": true + }, + "idx_host_protocol_auth_user": { + "name": "idx_host_protocol_auth_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_protocol_auth_credential": { + "name": "idx_host_protocol_auth_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_protocol_auth_host_id_ssh_data_id_fk": { + "name": "host_protocol_auth_host_id_ssh_data_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_user_id_users_id_fk": { + "name": "host_protocol_auth_user_id_users_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_credential_id_ssh_credentials_id_fk": { + "name": "host_protocol_auth_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_protocol_auth_id": { + "name": "host_protocol_auth_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "host_sidebar_preferences_user_id": { + "name": "host_sidebar_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('ssh')" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_options": { + "name": "ssh_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "ssh_port": { + "name": "ssh_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "('sha256')" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_overrides": { + "name": "default_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + "parent_host_id" + ], + "isUnique": false + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_data_id": { + "name": "ssh_data_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "plugin_install_counts": { + "name": "plugin_install_counts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + "plugin_id", + "registry_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_install_counts_id": { + "name": "plugin_install_counts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_migrations": { + "name": "plugin_migrations", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "migration_id": { + "name": "migration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + "plugin_id", + "migration_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_migrations_id": { + "name": "plugin_migrations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_permission_grants": { + "name": "plugin_permission_grants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability": { + "name": "capability", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('admin')" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + "plugin_id", + "capability" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_permission_grants_id": { + "name": "plugin_permission_grants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_registries": { + "name": "plugin_registries", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('community')" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_registries_id": { + "name": "plugin_registries_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "enum('admin','user','host','secret')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted": { + "name": "encrypted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + "plugin_id", + "scope", + "scope_id", + "key" + ], + "isUnique": true + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + "plugin_id", + "scope" + ], + "isUnique": false + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_settings_id": { + "name": "plugin_settings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + "plugin_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_storage_id": { + "name": "plugin_storage_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('available')" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('community')" + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('disabled')" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pinned_version": { + "name": "pinned_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('stable')" + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + "registry_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugins_id": { + "name": "plugins_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "role_name": { + "name": "role_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + "role_name", + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rbac_applied_defaults_id": { + "name": "rbac_applied_defaults_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rbac_known_permissions": { + "name": "rbac_known_permissions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rbac_known_permissions_id": { + "name": "rbac_known_permissions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "recent_activity": { + "name": "recent_activity", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_recent_activity_user_ts": { + "name": "idx_recent_activity_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "recent_activity_id": { + "name": "recent_activity_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "roles_id": { + "name": "roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ] + } + }, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_session_ref": { + "name": "external_session_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "settings_key": { + "name": "settings_key", + "columns": [ + "key" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_credential_secrets": { + "name": "shared_credential_secrets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('password')" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_shared_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + "credential_access_id", + "target_user_id" + ], + "isUnique": true + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + "target_user_id", + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_credential_secrets_id": { + "name": "shared_credential_secrets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + "host_id", + "user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_host_auth_overrides_id": { + "name": "shared_host_auth_overrides_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "shared_host_secrets": { + "name": "shared_host_secrets", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('credential')" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_fields": { + "name": "encrypted_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + "host_access_id", + "target_user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shared_host_secrets_id": { + "name": "shared_host_secrets_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credential_usage_id": { + "name": "ssh_credential_usage_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_credentials_id": { + "name": "ssh_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ssh_folders_id": { + "name": "ssh_folders_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ] + } + }, + "checkConstraint": {} + }, + "sync_conflicts": { + "name": "sync_conflicts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_revision": { + "name": "server_revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_conflicts_id": { + "name": "sync_conflicts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_link": { + "name": "sync_link", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "('idle')" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_link_id": { + "name": "sync_link_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sync_records": { + "name": "sync_records", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_hash": { + "name": "error_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + "user_id", + "entity_type", + "sync_id" + ], + "isUnique": true + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + "user_id", + "seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_records_id": { + "name": "sync_records_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "trusted_devices": { + "name": "trusted_devices", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "trusted_devices_id": { + "name": "trusted_devices_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ui_preferences_user_id": { + "name": "ui_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_external_identities": { + "name": "user_external_identities", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + "provider_id", + "subject" + ], + "isUnique": true + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_external_identities_id": { + "name": "user_external_identities_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_open_tabs": { + "name": "user_open_tabs", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_open_tabs_id": { + "name": "user_open_tabs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_pin_app_rail_button": { + "name": "show_pin_app_rail_button", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_preferences_user_id": { + "name": "user_preferences_user_id", + "columns": [ + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + "user_id", + "role_id" + ], + "isUnique": true + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_id": { + "name": "user_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user_second_factors": { + "name": "user_second_factors", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "factor_id": { + "name": "factor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + "user_id", + "plugin_id", + "factor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_second_factors_id": { + "name": "user_second_factors_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "idx_users_username": { + "name": "idx_users_username", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/mysql/meta/_journal.json b/drizzle/mysql/meta/_journal.json index de667d10d..fd37620c4 100644 --- a/drizzle/mysql/meta/_journal.json +++ b/drizzle/mysql/meta/_journal.json @@ -512,6 +512,13 @@ "when": 1791935283211, "tag": "0072_unique_username", "breakpoints": true + }, + { + "idx": 73, + "version": "5", + "when": 1792357129912, + "tag": "0073_user_look_preferences", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/postgres/0073_user_look_preferences.sql b/drizzle/postgres/0073_user_look_preferences.sql new file mode 100644 index 000000000..dee98a8a0 --- /dev/null +++ b/drizzle/postgres/0073_user_look_preferences.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user_preferences" ADD COLUMN "layout_id" text;--> statement-breakpoint +ALTER TABLE "user_preferences" ADD COLUMN "theme_id" text; \ No newline at end of file diff --git a/drizzle/postgres/meta/0073_snapshot.json b/drizzle/postgres/meta/0073_snapshot.json new file mode 100644 index 000000000..056a527af --- /dev/null +++ b/drizzle/postgres/meta/0073_snapshot.json @@ -0,0 +1,4791 @@ +{ + "id": "4d77fb47-8211-4b7b-a4d3-8c3c3c414bbf", + "prevId": "788b2d3a-f430-4726-b4ea-4958e1ed5d24", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_access": { + "name": "credential_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'use'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder_access": { + "name": "folder_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "folder", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_access": { + "name": "host_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_defaults": { + "name": "host_defaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_defaults_scope_key": { + "name": "idx_host_defaults_scope_key", + "columns": [ + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_defaults_user": { + "name": "idx_host_defaults_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_defaults_folder": { + "name": "idx_host_defaults_folder", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_defaults_user_id_users_id_fk": { + "name": "host_defaults_user_id_users_id_fk", + "tableFrom": "host_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_defaults_folder_id_ssh_folders_id_fk": { + "name": "host_defaults_folder_id_ssh_folders_id_fk", + "tableFrom": "host_defaults", + "tableTo": "ssh_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_protocol_auth": { + "name": "host_protocol_auth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'direct'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_fields": { + "name": "secret_fields", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_protocol_auth_host_protocol": { + "name": "idx_host_protocol_auth_host_protocol", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_protocol_auth_user": { + "name": "idx_host_protocol_auth_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_host_protocol_auth_credential": { + "name": "idx_host_protocol_auth_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_protocol_auth_host_id_ssh_data_id_fk": { + "name": "host_protocol_auth_host_id_ssh_data_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_user_id_users_id_fk": { + "name": "host_protocol_auth_user_id_users_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_credential_id_ssh_credentials_id_fk": { + "name": "host_protocol_auth_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_data": { + "name": "ssh_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ssh_options": { + "name": "ssh_options", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 22 + }, + "use_socks5": { + "name": "use_socks5", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_overrides": { + "name": "default_overrides", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + { + "expression": "parent_host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_install_counts": { + "name": "plugin_install_counts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_migrations": { + "name": "plugin_migrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "migration_id": { + "name": "migration_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "migration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_permission_grants": { + "name": "plugin_permission_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "capability": { + "name": "capability", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "capability", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_registries": { + "name": "plugin_registries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'community'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_settings": { + "name": "plugin_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted": { + "name": "encrypted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugins": { + "name": "plugins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'community'" + }, + "registry_id": { + "name": "registry_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'disabled'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pinned_version": { + "name": "pinned_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stable'" + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + { + "expression": "registry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "role_name": { + "name": "role_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + { + "expression": "role_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rbac_known_permissions": { + "name": "rbac_known_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + { + "expression": "permission", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recent_activity": { + "name": "recent_activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_recent_activity_user_ts": { + "name": "idx_recent_activity_user_ts", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_session_ref": { + "name": "external_session_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_credential_secrets": { + "name": "shared_credential_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'password'" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + { + "expression": "credential_access_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shared_host_secrets": { + "name": "shared_host_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'credential'" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_fields": { + "name": "encrypted_fields", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + { + "expression": "host_access_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "protocol", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credential_usage": { + "name": "ssh_credential_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_credentials": { + "name": "ssh_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_folders": { + "name": "ssh_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "local_only": { + "name": "local_only", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "nullsNotDistinct": false, + "columns": [ + "sync_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_conflicts": { + "name": "sync_conflicts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_revision": { + "name": "server_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_link": { + "name": "sync_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_records": { + "name": "sync_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sync_id": { + "name": "sync_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_hash": { + "name": "error_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sync_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trusted_devices": { + "name": "trusted_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ui_preferences": { + "name": "ui_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_external_identities": { + "name": "user_external_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_open_tabs": { + "name": "user_open_tabs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "show_pin_app_rail_button": { + "name": "show_pin_app_rail_button", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_second_factors": { + "name": "user_second_factors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "factor_id": { + "name": "factor_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "factor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_users_username": { + "name": "idx_users_username", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index d2025bbdd..834d84c12 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -512,6 +512,13 @@ "when": 1791935282340, "tag": "0072_unique_username", "breakpoints": true + }, + { + "idx": 73, + "version": "7", + "when": 1792357128746, + "tag": "0073_user_look_preferences", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0068_user_look_preferences.sql b/drizzle/sqlite/0068_user_look_preferences.sql new file mode 100644 index 000000000..51ee2231b --- /dev/null +++ b/drizzle/sqlite/0068_user_look_preferences.sql @@ -0,0 +1,2 @@ +ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint +ALTER TABLE `user_preferences` ADD `theme_id` text; \ No newline at end of file diff --git a/drizzle/sqlite/meta/0068_snapshot.json b/drizzle/sqlite/meta/0068_snapshot.json new file mode 100644 index 000000000..e71c25455 --- /dev/null +++ b/drizzle/sqlite/meta/0068_snapshot.json @@ -0,0 +1,4480 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "811cbb62-1490-48cd-9959-fd470be269d2", + "prevId": "689622f2-8e11-46a3-bac9-b2c55444b3a2", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_audit_logs_timestamp": { + "name": "idx_audit_logs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_user_ts": { + "name": "idx_audit_logs_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_action_ts": { + "name": "idx_audit_logs_action_ts", + "columns": [ + "action", + "timestamp" + ], + "isUnique": false + }, + "idx_audit_logs_resource_ts": { + "name": "idx_audit_logs_resource_ts", + "columns": [ + "resource_type", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credential_access": { + "name": "credential_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'use'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_credential_access_user_id": { + "name": "idx_credential_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_credential_access_role_id": { + "name": "idx_credential_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_credential_access_credential_id": { + "name": "idx_credential_access_credential_id", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "credential_access_credential_id_ssh_credentials_id_fk": { + "name": "credential_access_credential_id_ssh_credentials_id_fk", + "tableFrom": "credential_access", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_user_id_users_id_fk": { + "name": "credential_access_user_id_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_role_id_roles_id_fk": { + "name": "credential_access_role_id_roles_id_fk", + "tableFrom": "credential_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_access_granted_by_users_id_fk": { + "name": "credential_access_granted_by_users_id_fk", + "tableFrom": "credential_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credential_sidebar_preferences": { + "name": "credential_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_sidebar_preferences_user_id_users_id_fk": { + "name": "credential_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "credential_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "folder_access": { + "name": "folder_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_folder_access_owner_folder": { + "name": "idx_folder_access_owner_folder", + "columns": [ + "owner_user_id", + "folder" + ], + "isUnique": false + } + }, + "foreignKeys": { + "folder_access_owner_user_id_users_id_fk": { + "name": "folder_access_owner_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_user_id_users_id_fk": { + "name": "folder_access_user_id_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_role_id_roles_id_fk": { + "name": "folder_access_role_id_roles_id_fk", + "tableFrom": "folder_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_access_granted_by_users_id_fk": { + "name": "folder_access_granted_by_users_id_fk", + "tableFrom": "folder_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_access": { + "name": "host_access", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'connect'" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_host_access_user_id": { + "name": "idx_host_access_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_access_role_id": { + "name": "idx_host_access_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + }, + "idx_host_access_host_id": { + "name": "idx_host_access_host_id", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "idx_host_access_expires_at": { + "name": "idx_host_access_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_access_host_id_ssh_data_id_fk": { + "name": "host_access_host_id_ssh_data_id_fk", + "tableFrom": "host_access", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_user_id_users_id_fk": { + "name": "host_access_user_id_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_role_id_roles_id_fk": { + "name": "host_access_role_id_roles_id_fk", + "tableFrom": "host_access", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_access_granted_by_users_id_fk": { + "name": "host_access_granted_by_users_id_fk", + "tableFrom": "host_access", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_defaults": { + "name": "host_defaults", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder_id": { + "name": "folder_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_defaults_scope_key": { + "name": "idx_host_defaults_scope_key", + "columns": [ + "scope_key", + "namespace", + "key" + ], + "isUnique": true + }, + "idx_host_defaults_user": { + "name": "idx_host_defaults_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_defaults_folder": { + "name": "idx_host_defaults_folder", + "columns": [ + "folder_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_defaults_user_id_users_id_fk": { + "name": "host_defaults_user_id_users_id_fk", + "tableFrom": "host_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_defaults_folder_id_ssh_folders_id_fk": { + "name": "host_defaults_folder_id_ssh_folders_id_fk", + "tableFrom": "host_defaults", + "tableTo": "ssh_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_protocol_auth": { + "name": "host_protocol_auth", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_fields": { + "name": "secret_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_host_protocol_auth_host_protocol": { + "name": "idx_host_protocol_auth_host_protocol", + "columns": [ + "host_id", + "protocol" + ], + "isUnique": true + }, + "idx_host_protocol_auth_user": { + "name": "idx_host_protocol_auth_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_host_protocol_auth_credential": { + "name": "idx_host_protocol_auth_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_protocol_auth_host_id_ssh_data_id_fk": { + "name": "host_protocol_auth_host_id_ssh_data_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_user_id_users_id_fk": { + "name": "host_protocol_auth_user_id_users_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "host_protocol_auth_credential_id_ssh_credentials_id_fk": { + "name": "host_protocol_auth_credential_id_ssh_credentials_id_fk", + "tableFrom": "host_protocol_auth", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_sidebar_preferences": { + "name": "host_sidebar_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "host_sidebar_preferences_user_id_users_id_fk": { + "name": "host_sidebar_preferences_user_id_users_id_fk", + "tableFrom": "host_sidebar_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_data": { + "name": "ssh_data", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_host_id": { + "name": "parent_host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "share_ssh_auth": { + "name": "share_ssh_auth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "force_keyboard_interactive": { + "name": "force_keyboard_interactive", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_password": { + "name": "sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "override_credential_username": { + "name": "override_credential_username", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jump_hosts": { + "name": "jump_hosts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_check_enabled": { + "name": "status_check_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "status_check_interval": { + "name": "status_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_options": { + "name": "ssh_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_ssh": { + "name": "enable_ssh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "ssh_port": { + "name": "ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "use_socks5": { + "name": "use_socks5", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_host": { + "name": "socks5_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_port": { + "name": "socks5_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_username": { + "name": "socks5_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_password": { + "name": "socks5_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "socks5_proxy_chain": { + "name": "socks5_proxy_chain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_origin": { + "name": "connection_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_knock_sequence": { + "name": "port_knock_sequence", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_fingerprint": { + "name": "host_key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_type": { + "name": "host_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_algorithm": { + "name": "host_key_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'sha256'" + }, + "host_key_first_seen": { + "name": "host_key_first_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_last_verified": { + "name": "host_key_last_verified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_key_changed_count": { + "name": "host_key_changed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_overrides": { + "name": "default_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_data_sync_id_unique": { + "name": "ssh_data_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_data_user_id": { + "name": "idx_ssh_data_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_ssh_data_parent_host": { + "name": "idx_ssh_data_parent_host", + "columns": [ + "parent_host_id" + ], + "isUnique": false + }, + "idx_ssh_data_credential": { + "name": "idx_ssh_data_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_data_user_id_users_id_fk": { + "name": "ssh_data_user_id_users_id_fk", + "tableFrom": "ssh_data", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_data_parent_host_id_ssh_data_id_fk": { + "name": "ssh_data_parent_host_id_ssh_data_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_data", + "columnsFrom": [ + "parent_host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ssh_data_credential_id_ssh_credentials_id_fk": { + "name": "ssh_data_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_data", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_install_counts": { + "name": "plugin_install_counts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registry_id": { + "name": "registry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_install_counts_plugin_registry": { + "name": "idx_plugin_install_counts_plugin_registry", + "columns": [ + "plugin_id", + "registry_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_migrations": { + "name": "plugin_migrations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "migration_id": { + "name": "migration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_migrations_plugin_migration": { + "name": "idx_plugin_migrations_plugin_migration", + "columns": [ + "plugin_id", + "migration_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_migrations_plugin_id_plugins_id_fk": { + "name": "plugin_migrations_plugin_id_plugins_id_fk", + "tableFrom": "plugin_migrations", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_permission_grants": { + "name": "plugin_permission_grants", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'admin'" + } + }, + "indexes": { + "idx_plugin_permission_grants_plugin_capability": { + "name": "idx_plugin_permission_grants_plugin_capability", + "columns": [ + "plugin_id", + "capability" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_permission_grants_plugin_id_plugins_id_fk": { + "name": "plugin_permission_grants_plugin_id_plugins_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_permission_grants_granted_by_users_id_fk": { + "name": "plugin_permission_grants_granted_by_users_id_fk", + "tableFrom": "plugin_permission_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_registries": { + "name": "plugin_registries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'community'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_index_hash": { + "name": "last_index_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted": { + "name": "encrypted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_settings_scope_key": { + "name": "idx_plugin_settings_scope_key", + "columns": [ + "plugin_id", + "scope", + "scope_id", + "key" + ], + "isUnique": true + }, + "idx_plugin_settings_plugin_scope": { + "name": "idx_plugin_settings_plugin_scope", + "columns": [ + "plugin_id", + "scope" + ], + "isUnique": false + } + }, + "foreignKeys": { + "plugin_settings_plugin_id_plugins_id_fk": { + "name": "plugin_settings_plugin_id_plugins_id_fk", + "tableFrom": "plugin_settings", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_plugin_storage_plugin_key": { + "name": "idx_plugin_storage_plugin_key", + "columns": [ + "plugin_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "plugin_storage_plugin_id_plugins_id_fk": { + "name": "plugin_storage_plugin_id_plugins_id_fk", + "tableFrom": "plugin_storage", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'available'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'community'" + }, + "registry_id": { + "name": "registry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'disabled'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "auto_update": { + "name": "auto_update", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pinned_version": { + "name": "pinned_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stable'" + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_plugins_registry_id": { + "name": "idx_plugins_registry_id", + "columns": [ + "registry_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rbac_applied_defaults": { + "name": "rbac_applied_defaults", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_applied_defaults_role_permission": { + "name": "idx_rbac_applied_defaults_role_permission", + "columns": [ + "role_name", + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rbac_known_permissions": { + "name": "rbac_known_permissions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_rbac_known_permissions_permission": { + "name": "idx_rbac_known_permissions_permission", + "columns": [ + "permission" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recent_activity": { + "name": "recent_activity", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_recent_activity_user_ts": { + "name": "idx_recent_activity_user_ts", + "columns": [ + "user_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "recent_activity_user_id_users_id_fk": { + "name": "recent_activity_user_id_users_id_fk", + "tableFrom": "recent_activity", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recent_activity_host_id_ssh_data_id_fk": { + "name": "recent_activity_host_id_ssh_data_id_fk", + "tableFrom": "recent_activity", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "jwt_token": { + "name": "jwt_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_session_ref": { + "name": "external_session_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_credential_secrets": { + "name": "shared_credential_secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_access_id": { + "name": "credential_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'password'" + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_credential_secrets_scope": { + "name": "idx_shared_credential_secrets_scope", + "columns": [ + "credential_access_id", + "target_user_id" + ], + "isUnique": true + }, + "idx_shared_credential_secrets_target": { + "name": "idx_shared_credential_secrets_target", + "columns": [ + "target_user_id", + "credential_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "shared_credential_secrets_target_user_id_users_id_fk": { + "name": "shared_credential_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_credential_secrets_credential_id_ssh_credentials_id_fk": { + "name": "shared_credential_secrets_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_cred_secrets_access_id_fk": { + "name": "shared_cred_secrets_access_id_fk", + "tableFrom": "shared_credential_secrets", + "tableTo": "credential_access", + "columnsFrom": [ + "credential_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_host_auth_overrides": { + "name": "shared_host_auth_overrides", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "shared_host_auth_overrides_host_user_protocol_unique": { + "name": "shared_host_auth_overrides_host_user_protocol_unique", + "columns": [ + "host_id", + "user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_auth_overrides_host_id_ssh_data_id_fk": { + "name": "shared_host_auth_overrides_host_id_ssh_data_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_user_id_users_id_fk": { + "name": "shared_host_auth_overrides_user_id_users_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_auth_overrides_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_auth_overrides", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shared_host_secrets": { + "name": "shared_host_secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "host_access_id": { + "name": "host_access_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ssh'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'credential'" + }, + "original_credential_id": { + "name": "original_credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_username": { + "name": "encrypted_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_auth_type": { + "name": "encrypted_auth_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_password": { + "name": "encrypted_key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_key_type": { + "name": "encrypted_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_domain": { + "name": "encrypted_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_fields": { + "name": "encrypted_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_shared_host_secrets_scope": { + "name": "idx_shared_host_secrets_scope", + "columns": [ + "host_access_id", + "target_user_id", + "protocol" + ], + "isUnique": true + } + }, + "foreignKeys": { + "shared_host_secrets_host_access_id_host_access_id_fk": { + "name": "shared_host_secrets_host_access_id_host_access_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "host_access", + "columnsFrom": [ + "host_access_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_target_user_id_users_id_fk": { + "name": "shared_host_secrets_target_user_id_users_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "users", + "columnsFrom": [ + "target_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shared_host_secrets_original_credential_id_ssh_credentials_id_fk": { + "name": "shared_host_secrets_original_credential_id_ssh_credentials_id_fk", + "tableFrom": "shared_host_secrets", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "original_credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credential_usage": { + "name": "ssh_credential_usage", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_ssh_credential_usage_credential": { + "name": "idx_ssh_credential_usage_credential", + "columns": [ + "credential_id" + ], + "isUnique": false + }, + "idx_ssh_credential_usage_user": { + "name": "idx_ssh_credential_usage_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credential_usage_credential_id_ssh_credentials_id_fk": { + "name": "ssh_credential_usage_credential_id_ssh_credentials_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_host_id_ssh_data_id_fk": { + "name": "ssh_credential_usage_host_id_ssh_data_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ssh_credential_usage_user_id_users_id_fk": { + "name": "ssh_credential_usage_user_id_users_id_fk", + "tableFrom": "ssh_credential_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_credentials": { + "name": "ssh_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin": { + "name": "pin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text(16384)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text(4096)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_password": { + "name": "key_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detected_key_type": { + "name": "detected_key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cert_public_key": { + "name": "cert_public_key", + "type": "text(8192)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared_source": { + "name": "shared_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_credentials_sync_id_unique": { + "name": "ssh_credentials_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_credentials_user_id": { + "name": "idx_ssh_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_credentials_user_id_users_id_fk": { + "name": "ssh_credentials_user_id_users_id_fk", + "tableFrom": "ssh_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ssh_folders": { + "name": "ssh_folders", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local_only": { + "name": "local_only", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "ssh_folders_sync_id_unique": { + "name": "ssh_folders_sync_id_unique", + "columns": [ + "sync_id" + ], + "isUnique": true + }, + "idx_ssh_folders_user_id": { + "name": "idx_ssh_folders_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ssh_folders_user_id_users_id_fk": { + "name": "ssh_folders_user_id_users_id_fk", + "tableFrom": "ssh_folders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_conflicts": { + "name": "sync_conflicts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_row": { + "name": "local_row", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_revision": { + "name": "server_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_conflicts_user": { + "name": "idx_sync_conflicts_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_conflicts_user_id_users_id_fk": { + "name": "sync_conflicts_user_id_users_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_link": { + "name": "sync_link", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_name": { + "name": "server_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_version": { + "name": "server_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basic_auth": { + "name": "basic_auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_invalid_certificate": { + "name": "allow_invalid_certificate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_user_id": { + "name": "remote_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_username": { + "name": "remote_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account": { + "name": "account", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "known_types": { + "name": "known_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sync_link_user_id_users_id_fk": { + "name": "sync_link_user_id_users_id_fk", + "tableFrom": "sync_link", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_records": { + "name": "sync_records", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sync_id": { + "name": "sync_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_hash": { + "name": "error_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_sync_records_user_entity_sync": { + "name": "idx_sync_records_user_entity_sync", + "columns": [ + "user_id", + "entity_type", + "sync_id" + ], + "isUnique": true + }, + "idx_sync_records_user_seq": { + "name": "idx_sync_records_user_seq", + "columns": [ + "user_id", + "seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_records_user_id_users_id_fk": { + "name": "sync_records_user_id_users_id_fk", + "tableFrom": "sync_records", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "trusted_devices": { + "name": "trusted_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_fingerprint": { + "name": "device_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_info": { + "name": "device_info", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_trusted_devices_user_id": { + "name": "idx_trusted_devices_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "trusted_devices_user_id_users_id_fk": { + "name": "trusted_devices_user_id_users_id_fk", + "tableFrom": "trusted_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "ui_preferences_user_id_users_id_fk": { + "name": "ui_preferences_user_id_users_id_fk", + "tableFrom": "ui_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_external_identities": { + "name": "user_external_identities", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_external_identities_provider_subject": { + "name": "idx_user_external_identities_provider_subject", + "columns": [ + "provider_id", + "subject" + ], + "isUnique": true + }, + "idx_user_external_identities_user": { + "name": "idx_user_external_identities_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_external_identities_user_id_users_id_fk": { + "name": "user_external_identities_user_id_users_id_fk", + "tableFrom": "user_external_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_open_tabs": { + "name": "user_open_tabs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_type": { + "name": "tab_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "backend_session_id": { + "name": "backend_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_open_tabs_user_id": { + "name": "idx_user_open_tabs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_open_tabs_user_id_users_id_fk": { + "name": "user_open_tabs_user_id_users_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_open_tabs_host_id_ssh_data_id_fk": { + "name": "user_open_tabs_host_id_ssh_data_id_fk", + "tableFrom": "user_open_tabs", + "tableTo": "ssh_data", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "reopen_tabs_on_login": { + "name": "reopen_tabs_on_login", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "font_size": { + "name": "font_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_mode": { + "name": "storage_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command_palette_enabled": { + "name": "command_palette_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_host_tags": { + "name": "show_host_tags", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_tray_on_click": { + "name": "host_tray_on_click", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_app_rail": { + "name": "pin_app_rail", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expand_app_rail_on_hover": { + "name": "expand_app_rail_on_hover", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_pin_app_rail_button": { + "name": "show_pin_app_rail_button", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folders_collapsed": { + "name": "folders_collapsed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_update_check": { + "name": "disable_update_check", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_tab_close": { + "name": "confirm_tab_close", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_rail_tabs": { + "name": "hidden_rail_tabs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compact_host_view": { + "name": "compact_host_view", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_color_scheme": { + "name": "status_color_scheme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_keybindings": { + "name": "custom_keybindings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "layout_id": { + "name": "layout_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "granted_at": { + "name": "granted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_roles_user_role": { + "name": "idx_user_roles_user_role", + "columns": [ + "user_id", + "role_id" + ], + "isUnique": true + }, + "idx_user_roles_role_id": { + "name": "idx_user_roles_role_id", + "columns": [ + "role_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_granted_by_users_id_fk": { + "name": "user_roles_granted_by_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_second_factors": { + "name": "user_second_factors", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "factor_id": { + "name": "factor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_user_second_factors_user_factor": { + "name": "idx_user_second_factors_user_factor", + "columns": [ + "user_id", + "plugin_id", + "factor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_second_factors_user_id_users_id_fk": { + "name": "user_second_factors_user_id_users_id_fk", + "tableFrom": "user_second_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "registered_at": { + "name": "registered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_users_username": { + "name": "idx_users_username", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index 238908477..8348d4639 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -477,6 +477,13 @@ "when": 1791935281343, "tag": "0067_unique_username", "breakpoints": true + }, + { + "idx": 68, + "version": "6", + "when": 1792357127581, + "tag": "0068_user_look_preferences", + "breakpoints": true } ] } \ No newline at end of file diff --git a/env.catalog.json b/env.catalog.json index 94988e38d..6567e9373 100644 --- a/env.catalog.json +++ b/env.catalog.json @@ -370,6 +370,12 @@ "default": "false", "description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode." }, + { + "name": "TERMIX_SAFE_LAYOUT", + "group": "plugins", + "default": "false", + "description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app." + }, { "name": "TERMIX_BUNDLED_PLUGINS_DIR", "group": "plugins", diff --git a/package-lock.json b/package-lock.json index f0f821eb1..94eb72e22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "26.10.2", + "version": "26.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "26.10.2", + "version": "26.10.3", "hasInstallScript": true, "workspaces": [ "packages/*" @@ -15058,7 +15058,7 @@ }, "packages/plugin-sdk": { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.11", + "version": "1.0.12", "license": "Apache-2.0", "dependencies": { "@deadendjs/swagger-jsdoc": "^8.1.2", diff --git a/package.json b/package.json index 7942d0a80..282cea5da 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "workspaces": [ "packages/*" ], - "version": "26.10.2", + "version": "26.10.3", "description": "Self-hosted, plugin-based server management.", "author": "Karmaa", "main": "electron/main.cjs", diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 08db60eba..8babb7242 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 1.0.12 + +Plugin API 1.4.0. + +### Added + +- `contributes.layouts` and `app.registerLayout()` for a whole-app layout, with the `ui:layout` capability. Needs `engine.api` 1.4 +- `contributes.themes` for CSS-only themes, with the `ui:theme` capability. `termix-plugin validate` checks theme CSS and `pack` ships it +- `useShell()` gives a layout the live shell: tabs, splits, hosts, docks, the command palette and the registries +- `TabStage` and `ShellConfirmCapture` in `@termix-ssh/plugin-sdk/ui`, for a layout to place tabs +- `validateThemeCss()` in `@termix-ssh/plugin-sdk/manifest` + +### Fixed + +- The manifest JSON schema lists the `notify:hub` and `plugins:read` capabilities + ## 1.0.11 Plugin API 1.3.0. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index b252da84f..739027a62 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -1,6 +1,6 @@ # Termix plugin guide -SDK 1.0.11 | Plugin API 1.3.0 +SDK 1.0.12 | Plugin API 1.4.0 This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. @@ -175,7 +175,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem `contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`, `settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`, -`uiPresets` and `uses`. Frontend `register*` calls need their ids declared here first. +`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first. Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your code may reach. A permission (`hello.use`, with a dot) is what a user may do. @@ -478,6 +478,7 @@ your plugin's error boundary and Suspense. | `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point | | `registerOnboardingStep` | A first run setup step | | `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI | +| `registerLayout` | A whole-app layout (`contributes.layouts`, API 1.4) | | `api` | Axios client for your `/plugin-api//` routes, auth included | | `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth | | `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers | @@ -485,7 +486,7 @@ your plugin's error boundary and Suspense. Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`, `useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`, -`useSlotContributions`, `useSshAuthTypes`, `useExtensions`. +`useSlotContributions`, `useSshAuthTypes`, `useExtensions`, `useShell`. Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries @@ -496,6 +497,64 @@ Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `termin `hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See https://docs.termix.site/develop/more-extension-points. +## Layouts and themes + +A plugin can replace the whole app layout or add themes (API 1.4). Users pick both in Settings, Appearance; +admins set the default for everyone. + +A layout is declared in `contributes.layouts` (`id`, `label`, optional `description` and `defaultTheme`) and +needs the `ui:layout` capability, which admins see as high risk because a layout draws every screen. Register +it in `activate`: + +```tsx +import { useShell, type TermixApp } from "@termix-ssh/plugin-sdk/frontend"; +import { + ShellConfirmCapture, + SurfaceScope, + TabStage, +} from "@termix-ssh/plugin-sdk/ui"; + +function MyLayout() { + const shell = useShell(); + if (!shell) return null; + return ( +
+
+ {/* tabs from shell.tabs.topLevel, shell.palette.open() and so on */} +
+ + + + +
+ ); +} + +export function activate(app: TermixApp) { + app.registerLayout({ + id: "my-layout", + label: "layout.name", + component: MyLayout, + }); +} +``` + +- `useShell()` is the live shell with nothing drawn: tabs, splits, hosts, the left and right docks, the + command palette, the registries as hooks and the user. Core keeps all the state, so switching layouts keeps + every session. +- `TabStage` is where tabs show. Place it anywhere; tabs are never remounted when it moves, so a terminal + keeps its connection. The command palette, overlays and onboarding stay in core. +- `mobileComponent` is used on phones and narrow windows. The desktop app always gets `component`. +- Only the chosen layout's plugin is loaded. If it fails to load or crashes twice, Termix shows a repair + screen where people can switch, reset, update or turn it off. `?safe-layout=1` opens it any time. + +A theme is CSS only. Declare it in `contributes.themes` (`id`, `label`, `mode` light or dark, `css` path, +optional `swatch` colors for the picker) with the `ui:theme` capability. The id becomes a class on ``, +so the file may only hold rules whose selectors start with `.`, `:root.` or `html.`, optionally +inside `@media` or `@supports`. `@import`, `url()` to another host and other at-rules are refused by +`termix-plugin validate` and by the server. Set the same tokens core's themes set (`--background`, +`--foreground`, `--card`, `--border`, `--muted` and the rest). + ## Looking native Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of @@ -775,6 +834,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie | `auth:provide` | high | Add login methods, second factors, SSH auth types | | `device:serial` | high | Open serial and USB ports on the server | | `notify:hub` | high | Receive every alert (the alerts hub only) | +| `ui:layout` | high | Replace the whole app layout | | `hosts:write` | medium | Add and edit hosts | | `credentials:use` | medium | Connect with saved credentials without seeing them | | `credentials:write` | medium | Save new credentials | @@ -794,6 +854,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie | `settings:read-core` | low | Read Termix server settings | | `plugins:read` | low | See installed plugins | | `ui:surface` | low | Add tabs, panels and settings | +| `ui:theme` | low | Add color themes, CSS only | An update that adds a capability waits for an admin to approve it. diff --git a/packages/plugin-sdk/cli/commands/pack.mjs b/packages/plugin-sdk/cli/commands/pack.mjs index 73115b12f..4092cf9a2 100644 --- a/packages/plugin-sdk/cli/commands/pack.mjs +++ b/packages/plugin-sdk/cli/commands/pack.mjs @@ -1,7 +1,12 @@ import fs from "node:fs"; import path from "node:path"; import { readManifest } from "../lib/plugin-dir.mjs"; -import { collectFiles, createTmxplug } from "../lib/tmxplug.mjs"; +import { + collectFiles, + createTmxplug, + themeFiles, + TMXPLUG_CONTENTS, +} from "../lib/tmxplug.mjs"; import { sha256 } from "../lib/signing.mjs"; import { validate } from "./validate.mjs"; @@ -32,7 +37,12 @@ export async function pack({ cwd, args = [] }) { fs.mkdirSync(outDir, { recursive: true }); const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`); - const archive = createTmxplug(cwd, collectFiles(cwd)); + const archive = createTmxplug( + cwd, + collectFiles(cwd, [ + ...new Set([...TMXPLUG_CONTENTS, ...themeFiles(manifest)]), + ]), + ); fs.writeFileSync(outfile, archive); console.log(`packed ${path.basename(outfile)}`); diff --git a/packages/plugin-sdk/cli/commands/validate.mjs b/packages/plugin-sdk/cli/commands/validate.mjs index 10b423f2a..b45dc6584 100644 --- a/packages/plugin-sdk/cli/commands/validate.mjs +++ b/packages/plugin-sdk/cli/commands/validate.mjs @@ -17,7 +17,7 @@ export async function loadSdkModule(name) { export async function validate({ cwd }) { const raw = readManifest(cwd); - const { parseManifest } = await loadSdkModule("manifest"); + const { parseManifest, validateThemeCss } = await loadSdkModule("manifest"); const { errors } = parseManifest(raw); const problems = [...errors]; @@ -34,6 +34,7 @@ export async function validate({ cwd }) { } } + problems.push(...validateThemeFiles(cwd, raw, validateThemeCss)); problems.push( ...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))), ); @@ -53,6 +54,30 @@ export async function validate({ cwd }) { console.log(`ok ${raw.id ?? path.basename(cwd)}`); } +/** Every declared theme file exists and holds only CSS a theme may hold. */ +export function validateThemeFiles(cwd, raw, validateThemeCss) { + const problems = []; + const themes = raw?.contributes?.themes; + if (!Array.isArray(themes)) return problems; + for (const theme of themes) { + if (typeof theme?.css !== "string" || typeof theme?.id !== "string") { + continue; + } + const file = path.join(cwd, theme.css); + if (!fs.existsSync(file)) { + problems.push(`theme ${theme.id}: ${theme.css} does not exist`); + continue; + } + for (const error of validateThemeCss( + fs.readFileSync(file, "utf8"), + theme.id, + )) { + problems.push(`theme ${theme.id}: ${error}`); + } + } + return problems; +} + /** Env vars core sets for everyone, so a plugin need not list them. */ const CORE_ENV = new Set([ "DATA_DIR", diff --git a/packages/plugin-sdk/cli/lib/tmxplug.mjs b/packages/plugin-sdk/cli/lib/tmxplug.mjs index 6c14d4fdc..d12e14385 100644 --- a/packages/plugin-sdk/cli/lib/tmxplug.mjs +++ b/packages/plugin-sdk/cli/lib/tmxplug.mjs @@ -2,6 +2,15 @@ import fs from "node:fs"; import path from "node:path"; import zlib from "node:zlib"; +/** Theme CSS files a manifest declares, which travel with the plugin too. */ +export function themeFiles(manifest) { + const themes = manifest?.contributes?.themes; + if (!Array.isArray(themes)) return []; + return themes + .map((theme) => theme?.css) + .filter((css) => typeof css === "string"); +} + /** What a .tmxplug holds, in the order the spec lists it. */ export const TMXPLUG_CONTENTS = [ "manifest.json", diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index ca163ae7f..713c78a0b 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.11", + "version": "1.0.12", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index 4ef59dd72..05c7878cd 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -82,7 +82,11 @@ "features": { "type": "array", "maxItems": 20, - "items": { "type": "string", "minLength": 1, "maxLength": 160 }, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, "description": "Short lines listing what the plugin does, shown on its page." }, "docs": { @@ -98,11 +102,24 @@ "additionalProperties": false, "required": ["name", "description"], "properties": { - "name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" }, - "description": { "type": "string", "minLength": 1, "maxLength": 300 }, - "default": { "type": "string" }, - "required": { "type": "boolean" }, - "secret": { "type": "boolean" } + "name": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,63}$" + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 300 + }, + "default": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "secret": { + "type": "boolean" + } } } }, @@ -155,7 +172,11 @@ "files:own", "secrets:own", "settings:read-core", - "ui:surface" + "ui:surface", + "notify:hub", + "plugins:read", + "ui:layout", + "ui:theme" ] } }, @@ -944,7 +965,9 @@ "defaultLevels": { "type": "array", "minItems": 1, - "items": { "enum": ["admin", "user", "folder"] }, + "items": { + "enum": ["admin", "user", "folder"] + }, "description": "Levels that may set a default for the field. All three by default." }, "personal": { @@ -1127,6 +1150,86 @@ "type": "string", "pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$" } + }, + "layouts": { + "type": "array", + "description": "Whole-app layouts the frontend registers with app.registerLayout. Needs the ui:layout capability.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,39}$" + }, + "label": { + "type": "string", + "minLength": 1, + "description": "i18n key in the plugin namespace, or plain text." + }, + "description": { + "type": "string", + "minLength": 1 + }, + "defaultTheme": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,39}$", + "description": "Theme picked with this layout when nobody chose one." + }, + "default": { + "type": "boolean", + "description": "The layout a default-look plugin starts with. At most one." + } + } + } + }, + "themes": { + "type": "array", + "description": "Themes: one CSS file each with token rules scoped under .. Needs the ui:theme capability.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label", "mode", "css"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,39}$" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "mode": { + "type": "string", + "enum": ["light", "dark"] + }, + "css": { + "type": "string", + "pattern": ".css$", + "description": "Path of the CSS file inside the plugin." + }, + "swatch": { + "type": "object", + "additionalProperties": false, + "required": ["background", "panel", "text"], + "properties": { + "background": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{3,8}$" + }, + "panel": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{3,8}$" + }, + "text": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{3,8}$" + } + } + } + } + } } } }, diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts index 58f7cb93e..7d8a6c25c 100644 --- a/packages/plugin-sdk/src/capabilities.ts +++ b/packages/plugin-sdk/src/capabilities.ts @@ -49,6 +49,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("auth:provide", "high"), entry("device:serial", "high"), entry("notify:hub", "high"), + entry("ui:layout", "high"), entry("hosts:write", "medium"), entry("credentials:use", "medium"), @@ -70,6 +71,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("settings:read-core", "low"), entry("plugins:read", "low"), entry("ui:surface", "low"), + entry("ui:theme", "low"), ] as const; export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map( diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index d7995dccd..e67b31324 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -248,6 +248,129 @@ export interface TabsApi extends Pick< onReady: (listener: () => void) => Disposer; } +// --------------------------------------------------------------------------- +// Layouts (plugin API 1.4) +// --------------------------------------------------------------------------- + +/** + * A whole-app layout. Core renders the chosen one inside its providers and an + * error boundary; everything it shows comes from useShell(). Declare it in + * manifest contributes.layouts and add the ui:layout capability. + */ +export interface LayoutContribution { + id: string; + /** Your own i18n key or plain text, the same as the manifest's label. */ + label: string; + component: ComponentType; + /** Shown on phones and narrow windows. The desktop app never uses it. */ + mobileComponent?: ComponentType; + /** Theme to start with when nobody chose one. */ + defaultTheme?: string; +} + +/** A tab as a layout sees it. Splits are tabs too, with a `split` field. */ +export interface ShellTabState extends PluginTabRecord { + instanceId: string; + customLabel?: string; + /** Set while the tab sits in a split pane. */ + parentSplitTabId?: string; + openedAt?: number; +} + +/** What a dock region shows and how it is sized, in Normal-size pixels. */ +export interface ShellDockState { + view: string | null; + open: boolean; + width: number; + dragging: boolean; + setView: (view: string | null) => void; + setWidth: (width: number) => void; + /** Starts a drag from the dock's edge, from a mousedown. */ + startResize: (event: { clientX: number; preventDefault: () => void }) => void; + resetWidth: () => void; +} + +/** The live shell: state and actions, nothing drawn. Read with useShell(). */ +export interface ShellController { + tabs: { + list: ShellTabState[]; + /** What a tab bar shows: tabs not sitting in a split. */ + topLevel: ShellTabState[]; + activeId: string; + /** True once saved tabs were restored after login. */ + ready: boolean; + /** The user turned on tab numbers. */ + showNumbers: boolean; + setActive: (id: string) => void; + reorder: (topLevel: ShellTabState[]) => void; + close: (id: string) => Promise; + rename: (id: string, label: string) => void; + /** Reconnects or reloads a session tab. */ + refresh: (id: string) => void; + reconnectAll: () => void; + openSingleton: (type: string) => string | undefined; + }; + split: { + /** The split shown now, or null. */ + activeId: string | null; + focusedPaneId: string | null; + /** Every split with its panes, for a split menu. */ + summaries: Array<{ + id: string; + label: string; + full: boolean; + panes: Array<{ + id: string; + index: number; + tabId: string | null; + label: string | null; + }>; + }>; + activeFull: boolean; + /** Split a tab, apply a preset, unsplit, add to a pane. */ + handleAction: (action: Record & { kind: string }) => void; + }; + hosts: { + list: PluginHostRecord[]; + loading: boolean; + loaded: boolean; + reload: () => Promise; + }; + regions: { + left: ShellDockState & { view: string }; + right: ShellDockState; + /** What a rail click does: toggles or switches the left dock. */ + railClick: (view: string) => void; + openInRightDock: (view: string) => void; + toggleRightDock: () => void; + }; + palette: { isOpen: boolean; open: () => void; close: () => void }; + fullscreen: { active: boolean; toggle: () => void }; + /** The same callbacks tabs and panels get. */ + actions: ShellApi; + env: { + isMobile: boolean; + isElectron: boolean; + user: { username: string; isAdmin: boolean }; + permissions: string[]; + branding: { + appName: string | null; + tagline: string | null; + logo: string | null; + }; + }; + /** Core registries, as hooks a layout calls while rendering. */ + registries: { + useRailItems: () => ReadonlyArray<{ id: string } & Record>; + usePanels: () => ReadonlyArray<{ id: string } & Record>; + useTabTypes: () => ReadonlyArray<{ id: string } & Record>; + useDashboardCards: () => ReadonlyArray< + { id: string } & Record + >; + }; + logout: (options?: { manual?: boolean }) => void; +} + // --------------------------------------------------------------------------- // Registrations // --------------------------------------------------------------------------- @@ -988,6 +1111,12 @@ export interface TermixApp extends TermixAppInfo { registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer; registerLoginMethod: (method: LoginMethodContribution) => Disposer; registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer; + /** + * A whole-app layout. Declared in manifest contributes.layouts with the + * ui:layout capability. Only the chosen layout's plugin is loaded, so + * register it in activate. Needs plugin API 1.4. + */ + registerLayout: (layout: LayoutContribution) => Disposer; /** * Translates a key from the plugin's own locales, for code outside a @@ -1122,6 +1251,7 @@ export interface PluginHostBridge { useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean }; useCurrentUser: () => CurrentUser | null; useTheme: () => { theme: "light" | "dark" }; + useShell: () => ShellController | null; toast: ToastApi; getApi: (pluginId: string) => PluginApiClient; getApiFor: (pluginId: string, origin: unknown) => PluginApiClient; @@ -1372,6 +1502,14 @@ export function useTheme(): { theme: "light" | "dark" } { return requireHost().useTheme(); } +/** + * The live shell, for a layout. Null outside the signed-in app. Needs plugin + * API 1.4. + */ +export function useShell(): ShellController | null { + return requireHost().useShell(); +} + export function useToast(): ToastApi { return requireHost().toast; } diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index c60138b44..f1e3b79ca 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -19,6 +19,7 @@ import { type PluginEnvVar, } from "./docs.js"; +export { validateThemeCss } from "./theme-css.js"; export { ENV_NAME_PATTERN, MAX_ENV_DESCRIPTION_LENGTH, @@ -36,7 +37,7 @@ export { * "^1.2" needs 1.2 or later). engine.termix is checked against the core * version by the server. */ -export const PLUGIN_API_VERSION = "1.3.0"; +export const PLUGIN_API_VERSION = "1.4.0"; /** The API major, the default engine.api for a new plugin. */ export const SUPPORTED_PLUGIN_API_VERSION = String( @@ -426,6 +427,40 @@ export interface PluginAuthContribution { keyboardInteractive?: string[]; } +/** A layout id or theme id: short, lowercase, used as a CSS class for themes. */ +export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/; + +/** + * A whole-app layout the frontend registers with app.registerLayout. Needs + * the ui:layout capability. Declared here so core can list and choose it + * before any plugin code runs. + */ +export interface PluginLayoutContribution { + id: string; + /** i18n key in the plugin's namespace, or plain text. */ + label: string; + description?: string; + /** Theme picked with this layout when nobody chose one. */ + defaultTheme?: string; + /** The layout a default-look plugin starts with. */ + default?: boolean; +} + +/** + * A theme: one CSS file of token rules scoped under `.`, applied as a + * class on . Needs the ui:theme capability. No code runs for a theme. + */ +export interface PluginThemeContribution { + id: string; + /** i18n key in the plugin's namespace, or plain text. */ + label: string; + mode: "light" | "dark"; + /** Path of the CSS file inside the plugin. */ + css: string; + /** Colors the theme picker previews it with. */ + swatch?: { background: string; panel: string; text: string }; +} + export interface PluginContributions { tabs?: PluginTabContribution[]; panels?: PluginViewContribution[]; @@ -487,6 +522,10 @@ export interface PluginContributions { * ctx.credentials.resolveHostProtocol. */ protocols?: PluginProtocolContribution[]; + /** Whole-app layouts. Needs the ui:layout capability. */ + layouts?: PluginLayoutContribution[]; + /** Themes. Needs the ui:theme capability. */ + themes?: PluginThemeContribution[]; } /** A field a protocol login carries besides username and password. */ @@ -708,6 +747,8 @@ const ALLOWED_CONTRIBUTES = new Set([ "uses", "keybindingActions", "protocols", + "layouts", + "themes", ]); const ALLOWED_SETTINGS_FIELD = [ @@ -1300,6 +1341,111 @@ function validateContributes( validateUiPresets(contributes.uiPresets, errors); validateKeybindingActions(contributes.keybindingActions, errors); validateProtocols(contributes.protocols, errors); + validateLayouts(contributes.layouts, errors); + validateThemes(contributes.themes, errors); +} + +/** An array of objects with unique look ids; calls `each` for every valid entry. */ +function validateLookList( + value: unknown, + field: string, + allowed: string[], + errors: string[], + each: (raw: Record, where: string) => void, +): void { + if (value === undefined) return; + if (!Array.isArray(value)) { + errors.push(`Field "contributes.${field}" must be an array`); + return; + } + const seen = new Set(); + value.forEach((raw, index) => { + const where = `contributes.${field}[${index}]`; + if (!isPlainObject(raw)) { + errors.push(`${where} must be an object`); + return; + } + rejectUnknown(raw, allowed, where, errors); + if (typeof raw.id !== "string" || !LOOK_ID_PATTERN.test(raw.id)) { + errors.push( + `${where}.id must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`, + ); + } else { + if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`); + seen.add(raw.id); + } + requireString(raw.label, `${where}.label`, errors); + each(raw, where); + }); +} + +function validateLayouts(value: unknown, errors: string[]): void { + validateLookList( + value, + "layouts", + ["id", "label", "description", "defaultTheme", "default"], + errors, + (raw, where) => { + if (raw.description !== undefined) + requireString(raw.description, `${where}.description`, errors); + if ( + raw.defaultTheme !== undefined && + (typeof raw.defaultTheme !== "string" || + !LOOK_ID_PATTERN.test(raw.defaultTheme)) + ) { + errors.push(`${where}.defaultTheme must be a theme id`); + } + if (raw.default !== undefined && typeof raw.default !== "boolean") { + errors.push(`${where}.default must be a boolean`); + } + }, + ); + if ( + Array.isArray(value) && + value.filter((raw) => isPlainObject(raw) && raw.default === true).length > 1 + ) { + errors.push('Only one entry in "contributes.layouts" may set default'); + } +} + +const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/; + +function validateThemes(value: unknown, errors: string[]): void { + validateLookList( + value, + "themes", + ["id", "label", "mode", "css", "swatch"], + errors, + (raw, where) => { + if (raw.mode !== "light" && raw.mode !== "dark") { + errors.push(`${where}.mode must be "light" or "dark"`); + } + if (requireString(raw.css, `${where}.css`, errors)) { + requireRelativePath(raw.css, `${where}.css`, errors); + if (!raw.css.endsWith(".css")) { + errors.push(`${where}.css must be a .css file`); + } + } + if (raw.swatch !== undefined) { + if (!isPlainObject(raw.swatch)) { + errors.push(`${where}.swatch must be an object`); + return; + } + rejectUnknown( + raw.swatch, + ["background", "panel", "text"], + `${where}.swatch`, + errors, + ); + for (const key of ["background", "panel", "text"]) { + const color = raw.swatch[key]; + if (typeof color !== "string" || !HEX_COLOR.test(color)) { + errors.push(`${where}.swatch.${key} must be a hex color`); + } + } + } + }, + ); } export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/; @@ -2324,6 +2470,23 @@ export function parseManifest(raw: unknown): ParsedManifest { ); } + if ( + (manifest.contributes?.layouts?.length ?? 0) > 0 && + !manifest.capabilities.includes("ui:layout") + ) { + errors.push( + 'contributes.layouts needs the "ui:layout" capability in the capabilities array', + ); + } + if ( + (manifest.contributes?.themes?.length ?? 0) > 0 && + !manifest.capabilities.includes("ui:theme") + ) { + errors.push( + 'contributes.themes needs the "ui:theme" capability in the capabilities array', + ); + } + for (const secret of manifest.requiresSecret ?? []) { if (secret.plugin === manifest.id) { errors.push( diff --git a/packages/plugin-sdk/src/testing/setup.ts b/packages/plugin-sdk/src/testing/setup.ts index 80fbf1892..ceb03b693 100644 --- a/packages/plugin-sdk/src/testing/setup.ts +++ b/packages/plugin-sdk/src/testing/setup.ts @@ -97,6 +97,7 @@ const offline = () => useHostStatus: () => null, useCurrentUser: () => null, useTheme: () => ({ theme: "dark" }), + useShell: () => null, toast: { success: noop, error: noop, info: noop, warning: noop }, getApi: () => ({ get: offline, diff --git a/packages/plugin-sdk/src/theme-css.ts b/packages/plugin-sdk/src/theme-css.ts new file mode 100644 index 000000000..883480e03 --- /dev/null +++ b/packages/plugin-sdk/src/theme-css.ts @@ -0,0 +1,150 @@ +/** + * Checks a theme's CSS before core serves it. + * + * A theme is colors and styles only, so its file may hold nothing but rules + * whose every selector starts with the theme's own class (`.nord`, + * `:root.nord`, `html.nord ...`), optionally inside @media or @supports. + * Anything that could load from elsewhere or run code is refused: @import, + * url() to another host, other at-rules, expression() and javascript:. + */ + +const ALLOWED_BLOCK_AT_RULES = new Set(["media", "supports"]); + +function stripComments(css: string): string { + return css.replace(/\/\*[\s\S]*?\*\//g, " "); +} + +/** Splits on commas that are not inside parentheses or brackets. */ +function splitSelectors(prelude: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const ch of prelude) { + if (ch === "(" || ch === "[") depth += 1; + if (ch === ")" || ch === "]") depth -= 1; + if (ch === "," && depth === 0) { + parts.push(current); + current = ""; + continue; + } + current += ch; + } + parts.push(current); + return parts.map((part) => part.trim()); +} + +function withoutStrings(css: string): string { + return css.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""'); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Index of the brace closing the one at `open`, skipping strings. */ +function matchingBrace(css: string, open: number): number { + let depth = 0; + let quote: string | null = null; + for (let i = open; i < css.length; i += 1) { + const ch = css[i]; + if (quote) { + if (ch === "\\") i += 1; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") quote = ch; + else if (ch === "{") depth += 1; + else if (ch === "}") { + depth -= 1; + if (depth === 0) return i; + } + } + return -1; +} + +function checkUrls(css: string, errors: string[]): void { + const urls = css.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi); + for (const match of urls) { + const target = match[2].trim(); + if (target.startsWith("data:")) continue; + if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) { + errors.push(`url(${target}) points outside the plugin`); + } + } +} + +function checkRules( + css: string, + scope: RegExp, + themeId: string, + errors: string[], +): void { + let index = 0; + while (index < css.length) { + const rest = css.slice(index); + if (rest.trim() === "") return; + const open = css.indexOf("{", index); + const semicolon = css.indexOf(";", index); + if (semicolon !== -1 && (open === -1 || semicolon < open)) { + const statement = css.slice(index, semicolon).trim(); + errors.push( + statement.startsWith("@") + ? `${statement.split(/\s/)[0]} is not allowed in a theme` + : `Unexpected "${statement}" outside a rule`, + ); + index = semicolon + 1; + continue; + } + if (open === -1) { + errors.push(`Unexpected "${rest.trim()}" at the end`); + return; + } + const close = matchingBrace(css, open); + if (close === -1) { + errors.push("A rule is missing its closing brace"); + return; + } + const prelude = css.slice(index, open).trim(); + const body = css.slice(open + 1, close); + + if (prelude.startsWith("@")) { + const name = prelude.slice(1).split(/[\s(]/)[0].toLowerCase(); + if (ALLOWED_BLOCK_AT_RULES.has(name)) { + checkRules(body, scope, themeId, errors); + } else { + errors.push(`@${name} is not allowed in a theme`); + } + } else { + for (const selector of splitSelectors(prelude)) { + if (!scope.test(selector)) { + errors.push( + `Selector "${selector}" must start with .${themeId}, :root.${themeId} or html.${themeId}`, + ); + } + } + if (withoutStrings(body).includes("{")) { + errors.push(`Nested rules are not allowed in "${prelude}"`); + } + } + index = close + 1; + } +} + +/** Every problem with a theme's CSS, empty when it is safe to serve. */ +export function validateThemeCss(css: string, themeId: string): string[] { + const errors: string[] = []; + const clean = stripComments(css); + if (/@import\b/i.test(clean)) + errors.push("@import is not allowed in a theme"); + if (/expression\s*\(/i.test(clean)) + errors.push("expression() is not allowed in a theme"); + if (/javascript:/i.test(clean)) + errors.push("javascript: is not allowed in a theme"); + if (/-moz-binding|behavior\s*:/i.test(clean)) + errors.push("Script bindings are not allowed in a theme"); + checkUrls(clean, errors); + const id = escapeRegExp(themeId); + const scope = new RegExp(`^(?::root|html)?\\.${id}(?![\\w-])`); + checkRules(clean, scope, themeId, errors); + return [...new Set(errors)]; +} diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index 3a9b122c3..5d6c8a103 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -173,6 +173,7 @@ SheetFooter (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes) => Element SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes) => Element +ShellConfirmCapture (value): () => null Skeleton (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes) => Element Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element @@ -183,6 +184,7 @@ SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: Switch (value): ({ className, ...props }: SwitchProps & RefAttributes) => Element SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element +TabStage (value): ({ className }: { className?: string; }) => Element TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing, className, }: { tabs: TabStripItem[]; activeTab: string; onTabChange: (id: string) => void; isActive?: (id: string) => boolean; variant?: "secondary" | "primary"; trailing?: ReactNode; className?: string; }) => Element TabStripItem (type): TabStripItem TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element diff --git a/scripts/bundled-plugins.test.ts b/scripts/bundled-plugins.test.ts index 2f77790c5..a5fc8de1b 100644 --- a/scripts/bundled-plugins.test.ts +++ b/scripts/bundled-plugins.test.ts @@ -416,3 +416,71 @@ describe("rebuilding local plugins", () => { expect(run.sort()).toEqual(["a", "b"]); }); }); + +describe("default look", () => { + const entry = { source: "tmxplug", path: "a", sha256: SHA }; + + it("reads the defaultLook flag and refuses anything but a boolean", () => { + const { plugins, problems } = parseBundledPlugins({ + plugins: [ + { ...entry, id: "look", defaultLook: true }, + { ...entry, id: "plain" }, + { ...entry, id: "odd", defaultLook: "yes" }, + ], + }); + expect(problems).toEqual(["odd defaultLook must be a boolean"]); + expect( + plugins.map((p: { id: string; defaultLook: boolean }) => [ + p.id, + p.defaultLook, + ]), + ).toEqual([ + ["look", true], + ["plain", false], + ]); + }); + + it("carries it into the bundled index and through a re-pin", () => { + expect( + buildBundledIndex( + [ + { id: "look", onboarding: null, defaultLook: true }, + { id: "plain", onboarding: null, defaultLook: false }, + ], + ["look", "plain"], + ), + ).toEqual({ version: 1, plugins: { look: { defaultLook: true } } }); + + const index = { + plugins: [ + { + id: "look", + versions: [ + { version: "1.0.0", url: "https://x/l.tmxplug", sha256: SHA }, + ], + }, + ], + }; + expect( + pinsFromIndex(index, null, [{ id: "look", defaultLook: true }])[0], + ).toMatchObject({ id: "look", defaultLook: true }); + }); + + it("copies a local build's theme files", () => { + const repo = tempDir(); + fs.mkdirSync(path.join(repo, "dist"), { recursive: true }); + fs.mkdirSync(path.join(repo, "themes"), { recursive: true }); + fs.writeFileSync(path.join(repo, "dist", "backend.js"), "export {}"); + fs.writeFileSync(path.join(repo, "themes", "paper.css"), ".paper{}"); + fs.writeFileSync( + path.join(repo, "manifest.json"), + JSON.stringify({ + id: "inks", + contributes: { themes: [{ id: "paper", css: "themes/paper.css" }] }, + }), + ); + const out = path.join(tempDir(), "inks"); + copyLocalBuild(repo, out); + expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true); + }); +}); diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index c8072663d..ede1ef83e 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -194,6 +194,7 @@ const ALLOWED = [ "SheetHeader", "SheetTitle", "SheetTrigger", + "ShellConfirmCapture", "Skeleton", "Slider", "Sparkline", @@ -204,6 +205,7 @@ const ALLOWED = [ "Switch", "SwitchRow", "TOTPDialog", + "TabStage", "TabStrip", "TabStripItem", "TagInput", diff --git a/scripts/lib/bundled-plugins.cjs b/scripts/lib/bundled-plugins.cjs index ddbbc034d..196f060a2 100644 --- a/scripts/lib/bundled-plugins.cjs +++ b/scripts/lib/bundled-plugins.cjs @@ -15,6 +15,11 @@ * enabledByEnv counts the plugin as recommended when any of those * environment variables is set, for a server configured before first boot. * Plugins without it start unchecked. + * + * "defaultLook": true marks the plugin whose layout (and that layout's + * theme) a fresh install shows. Core finds the default look this way, so it + * never names a plugin. Such a plugin always starts enabled and cannot be + * uninstalled, only swapped for another look. */ const crypto = require("node:crypto"); @@ -110,6 +115,12 @@ function parseBundledPlugins(raw) { } const before = problems.length; const onboarding = parseOnboarding(entry.onboarding, entry.id, problems); + if ( + entry.defaultLook !== undefined && + typeof entry.defaultLook !== "boolean" + ) { + problems.push(`${entry.id} defaultLook must be a boolean`); + } if (problems.length > before) continue; plugins.push({ id: entry.id, @@ -118,6 +129,7 @@ function parseBundledPlugins(raw) { path: hasPath ? entry.path : null, sha256: entry.sha256, onboarding, + defaultLook: entry.defaultLook === true, }); } else { problems.push(`${entry.id} has an unknown source "${entry.source}"`); @@ -218,8 +230,12 @@ function buildBundledIndex(plugins, stagedIds) { const staged = new Set(stagedIds); const out = {}; for (const plugin of plugins) { - if (!staged.has(plugin.id) || !plugin.onboarding) continue; - out[plugin.id] = { onboarding: plugin.onboarding }; + if (!staged.has(plugin.id)) continue; + if (!plugin.onboarding && !plugin.defaultLook) continue; + out[plugin.id] = { + ...(plugin.onboarding ? { onboarding: plugin.onboarding } : {}), + ...(plugin.defaultLook ? { defaultLook: true } : {}), + }; } return { version: 1, plugins: out }; } @@ -238,12 +254,14 @@ function pinsFromIndex(index, ids, current = []) { const latest = byId.get(id)?.versions?.[0]; if (!latest) throw new Error(`${id} is not in the registry index`); const onboarding = currentById.get(id)?.onboarding; + const defaultLook = currentById.get(id)?.defaultLook === true; return { id, source: "tmxplug", url: latest.url, sha256: latest.sha256, ...(onboarding ? { onboarding } : {}), + ...(defaultLook ? { defaultLook } : {}), }; }); } @@ -357,9 +375,26 @@ function rebuildLocalPlugins(dir, ids, run) { } /** Copies a local plugin build into the staging folder. */ +/** Theme CSS files the repo's manifest declares, which ship with it too. */ +function localThemeFiles(repo) { + try { + const manifest = JSON.parse( + fs.readFileSync(path.join(repo, "manifest.json"), "utf8"), + ); + const themes = manifest?.contributes?.themes; + return Array.isArray(themes) + ? themes + .map((theme) => theme?.css) + .filter((css) => typeof css === "string") + : []; + } catch { + return []; + } +} + function copyLocalBuild(repo, destination) { fs.mkdirSync(destination, { recursive: true }); - for (const entry of LOCAL_FILES) { + for (const entry of new Set([...LOCAL_FILES, ...localThemeFiles(repo)])) { const from = path.join(repo, entry); if (fs.existsSync(from)) { fs.cpSync(from, path.join(destination, entry), { recursive: true }); diff --git a/scripts/plugin-pack.test.ts b/scripts/plugin-pack.test.ts index 3d975a0f1..60e5aaa80 100644 --- a/scripts/plugin-pack.test.ts +++ b/scripts/plugin-pack.test.ts @@ -142,3 +142,29 @@ describe("termix-plugin pack", () => { expect(fs.readdirSync(out)).toEqual([]); }); }); + +describe("termix-plugin pack themes", () => { + it("ships the CSS files the manifest's themes declare", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const dir = fixturePlugin({ + capabilities: ["ui:theme"], + contributes: { + themes: [ + { + id: "paper", + label: "Paper", + mode: "light", + css: "styles/paper.css", + }, + ], + }, + }); + writeFile(dir, "styles/paper.css", ".paper { --background: #fff; }\n"); + writeFile(dir, "styles/draft.css", ".draft {}\n"); + const file = await pack({ cwd: dir, args: ["--out", tempDir("t-")] }); + const names = await entries(file); + expect(names).toContain("styles/paper.css"); + expect(names).not.toContain("styles/draft.css"); + }); +}); diff --git a/scripts/plugin-validate.test.ts b/scripts/plugin-validate.test.ts index 06b9602d1..3d1f5998b 100644 --- a/scripts/plugin-validate.test.ts +++ b/scripts/plugin-validate.test.ts @@ -49,3 +49,53 @@ describe("termix-plugin validate migrations", () => { expect(output).not.toMatch(/migrations\/\w+ is missing/); }); }); + +describe("termix-plugin validate themes", () => { + function themed(css: string | null) { + const root = plugin([]); + fs.writeFileSync( + path.join(root, "manifest.json"), + JSON.stringify({ + id: "fixture", + version: "1.0.0", + capabilities: ["ui:theme"], + contributes: { + themes: [ + { + id: "paper", + label: "Paper", + mode: "light", + css: "themes/paper.css", + }, + ], + }, + }), + ); + if (css !== null) { + fs.mkdirSync(path.join(root, "themes")); + fs.writeFileSync(path.join(root, "themes", "paper.css"), css); + } + return root; + } + + it("reports a missing theme file", async () => { + expect(await problems(themed(null))).toContain( + "theme paper: themes/paper.css does not exist", + ); + }); + + it("reports theme CSS that reaches outside or leaves its class", async () => { + const output = await problems( + themed('@import "x.css"; body { color: red }'), + ); + expect(output).toContain("theme paper: @import is not allowed in a theme"); + expect(output).toContain( + 'theme paper: Selector "body" must start with .paper', + ); + }); + + it("says nothing about CSS that is fine", async () => { + const output = await problems(themed(".paper { --background: #fff; }")); + expect(output).not.toContain("theme paper"); + }); +}); diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index c93ae8ea1..508f1cf26 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -22,6 +22,7 @@ import sshAuthRoutes from "./routes/ssh-auth-routes.js"; import rbacRoutes from "./routes/rbac.js"; import openTabsRoutes from "./routes/open-tabs.js"; import userPreferencesRoutes from "./routes/user-preferences.js"; +import lookRoutes from "./routes/look.js"; import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js"; import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js"; import uiPreferencesRoutes from "./routes/ui-preferences.js"; @@ -1616,6 +1617,7 @@ app.use("/credentials", credentialsRoutes); app.use("/ssh-auth", sshAuthRoutes); app.use("/rbac", rbacRoutes); app.use("/open-tabs", openTabsRoutes); +app.use("/user-preferences/look", lookRoutes); app.use("/user-preferences", userPreferencesRoutes); app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes); app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes); diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index e72930e99..be0eb86c6 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -985,6 +985,8 @@ const migrateSchema = () => { addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER"); addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT"); addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT"); + addColumnIfNotExists("user_preferences", "layout_id", "TEXT"); + addColumnIfNotExists("user_preferences", "theme_id", "TEXT"); addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0"); diff --git a/src/backend/database/db/schema.mysql.ts b/src/backend/database/db/schema.mysql.ts index 8876d9c1c..3c90ef39e 100644 --- a/src/backend/database/db/schema.mysql.ts +++ b/src/backend/database/db/schema.mysql.ts @@ -709,6 +709,8 @@ export const userPreferences = mysqlTable("user_preferences", { compactHostView: boolean("compact_host_view"), statusColorScheme: text("status_color_scheme"), customKeybindings: text("custom_keybindings"), + layoutId: text("layout_id"), + themeId: text("theme_id"), updatedAt: text("updated_at") .notNull() .default(sql`(CURRENT_TIMESTAMP)`), diff --git a/src/backend/database/db/schema.pg.ts b/src/backend/database/db/schema.pg.ts index 6f3de67fa..53d62933d 100644 --- a/src/backend/database/db/schema.pg.ts +++ b/src/backend/database/db/schema.pg.ts @@ -709,6 +709,8 @@ export const userPreferences = pgTable("user_preferences", { compactHostView: boolean("compact_host_view"), statusColorScheme: text("status_color_scheme"), customKeybindings: text("custom_keybindings"), + layoutId: text("layout_id"), + themeId: text("theme_id"), updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 0bd971ffb..b5c1fb513 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -703,6 +703,8 @@ export const userPreferences = sqliteTable("user_preferences", { compactHostView: integer("compact_host_view", { mode: "boolean" }), statusColorScheme: text("status_color_scheme"), customKeybindings: text("custom_keybindings"), + layoutId: text("layout_id"), + themeId: text("theme_id"), updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/routes/look.ts b/src/backend/database/routes/look.ts new file mode 100644 index 000000000..6d5df4fb7 --- /dev/null +++ b/src/backend/database/routes/look.ts @@ -0,0 +1,205 @@ +import type { AuthenticatedRequest } from "../../../types/index.js"; +import express, { type Request, type Response } from "express"; +import { databaseLogger } from "../../utils/logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; +import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { + createCurrentSettingsRepository, + createCurrentUserPreferenceRepository, + createCurrentUserRepository, +} from "../repositories/factory.js"; +import { getPluginRuntime } from "../../plugins/index.js"; +import { + LAYOUT_SETTING, + LOOK_ID_PATTERN, + THEME_SETTING, + resolveDefaultLook, + safeLayoutForced, + unavailableLooks, +} from "../../plugins/looks.js"; + +const router = express.Router(); +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); + +async function adminDefaults() { + const settings = createCurrentSettingsRepository(); + return { + layout: await settings.get(LAYOUT_SETTING), + theme: await settings.get(THEME_SETTING), + }; +} + +/** + * @openapi + * /user-preferences/look: + * get: + * summary: Get the layout and theme to show the current user + * description: The user's own choice, the admin defaults, the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these. + * tags: + * - User Preferences + * responses: + * 200: + * description: What the look is chosen from. + * content: + * application/json: + * schema: + * type: object + * properties: + * layoutId: + * type: string + * nullable: true + * themeId: + * type: string + * nullable: true + * defaults: + * type: object + * description: Admin defaults, null where unset. + * properties: + * layout: + * type: string + * nullable: true + * theme: + * type: string + * nullable: true + * defaultLook: + * type: object + * description: The default-look plugin's layout and theme, null where none is installed. + * properties: + * layout: + * type: string + * nullable: true + * theme: + * type: string + * nullable: true + * safeLayout: + * type: boolean + * unavailable: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * version: + * type: string + * nullable: true + * layouts: + * type: array + * items: + * type: string + * themes: + * type: array + * items: + * type: string + * error: + * type: string + */ +router.get("/", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const preferences = + await createCurrentUserPreferenceRepository().findByUserId(userId); + const { loader } = getPluginRuntime(); + res.json({ + layoutId: preferences?.layoutId ?? null, + themeId: preferences?.themeId ?? null, + defaults: await adminDefaults(), + defaultLook: resolveDefaultLook(loader), + safeLayout: safeLayoutForced(), + unavailable: unavailableLooks(loader), + }); + } catch (error) { + databaseLogger.error("Failed to get the look", error, { + operation: "get_look", + userId, + }); + res.status(500).json({ error: "Failed to get the look" }); + } +}); + +/** + * @openapi + * /user-preferences/look/defaults: + * put: + * summary: Set the default layout and theme (admin only) + * description: What everyone sees until they choose their own. Null clears a default so the default look applies. + * tags: + * - User Preferences + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * layout: + * type: string + * nullable: true + * theme: + * type: string + * nullable: true + * responses: + * 200: + * description: The defaults now in effect. + * 400: + * description: Not a layout or theme id. + * 403: + * description: Not an admin. + */ +router.put( + "/defaults", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const actor = await createCurrentUserRepository().findById(userId); + if (!actor?.isAdmin) { + return res.status(403).json({ error: "Not authorized" }); + } + const body = (req.body ?? {}) as { layout?: unknown; theme?: unknown }; + const changes: Array<[string, unknown]> = [ + [LAYOUT_SETTING, body.layout], + [THEME_SETTING, body.theme], + ]; + for (const [key, value] of changes) { + if ( + value !== undefined && + value !== null && + (typeof value !== "string" || !LOOK_ID_PATTERN.test(value)) + ) { + return res + .status(400) + .json({ error: `${key} must be a layout or theme id, or null` }); + } + } + const settings = createCurrentSettingsRepository(); + for (const [key, value] of changes) { + if (value === undefined) continue; + if (value === null) await settings.delete(key); + else await settings.set(key, value as string); + } + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: actor.username ?? userId, + action: "update_default_look", + resourceType: "setting", + details: JSON.stringify({ layout: body.layout, theme: body.theme }), + ipAddress, + userAgent, + success: true, + }); + res.json(await adminDefaults()); + } catch (error) { + databaseLogger.error("Failed to set the default look", error, { + operation: "set_default_look", + userId, + }); + res.status(500).json({ error: "Failed to set the default look" }); + } + }, +); + +export default router; diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index 73da18085..f1e9c04d5 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -14,6 +14,7 @@ import { } from "../repositories/factory.js"; import { getPluginRuntime } from "../../plugins/index.js"; import { describePluginFrontend } from "../../plugins/assets.js"; +import { isDefaultLookPlugin } from "../../plugins/bundled-index.js"; import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; import { getPluginPublicHttpRoutes } from "../../plugins/http.js"; import { getPluginPublicWsRoutes } from "../../plugins/ws.js"; @@ -352,6 +353,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { dependencies, optionalDependencies, docs, + defaultLook: isDefaultLookPlugin(record.id), ...describePluginFrontend(loaded), }; @@ -2306,7 +2308,7 @@ router.post( * 404: * description: No such plugin. * 409: - * description: Other running plugins depend on it, or managed by a linked server. + * description: Other running plugins depend on it, it is the default look, or a linked server manages it. */ router.delete( "/:id", diff --git a/src/backend/database/routes/user-preferences.ts b/src/backend/database/routes/user-preferences.ts index 6cd16af89..86cd5b200 100644 --- a/src/backend/database/routes/user-preferences.ts +++ b/src/backend/database/routes/user-preferences.ts @@ -32,8 +32,13 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({ compactHostView: row?.compactHostView ?? null, statusColorScheme: row?.statusColorScheme ?? null, customKeybindings: row?.customKeybindings ?? null, + layoutId: row?.layoutId ?? null, + themeId: row?.themeId ?? null, }); +/** Layout and theme ids, the same shape the plugin manifest allows. */ +const LOOK_ID = /^[a-z][a-z0-9-]{1,39}$/; + /** * @openapi * /user-preferences: @@ -104,6 +109,14 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({ * type: string * nullable: true * description: JSON-encoded array of the user's custom keybindings. An action's type is one of the shell's own or one a plugin declares in contributes.keybindingActions. + * layoutId: + * type: string + * nullable: true + * description: The layout this user chose, overriding the admin default. Null follows the default. + * themeId: + * type: string + * nullable: true + * description: The theme this user chose, overriding the admin default. Null follows the default. */ router.get("/", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -165,6 +178,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { * customKeybindings: * type: string * description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type. + * layoutId: + * type: string + * nullable: true + * description: A layout id, or null to follow the admin default. + * themeId: + * type: string + * nullable: true + * description: A theme id, or null to follow the admin default. * responses: * 200: * description: Preferences updated successfully. @@ -186,6 +207,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { confirmTabClose, hiddenRailTabs, customKeybindings, + layoutId, + themeId, } = req.body as { reopenTabsOnLogin?: boolean; theme?: string | null; @@ -201,6 +224,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { confirmTabClose?: boolean | null; hiddenRailTabs?: string | null; customKeybindings?: string | null; + layoutId?: string | null; + themeId?: string | null; }; // showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no // longer writable here -- they moved to /host-sidebar/preferences as of the @@ -258,6 +283,18 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { } } + for (const [key, value] of Object.entries({ layoutId, themeId })) { + if ( + value !== undefined && + value !== null && + (typeof value !== "string" || !LOOK_ID.test(value)) + ) { + return res + .status(400) + .json({ error: `${key} must be a layout or theme id, or null` }); + } + } + const boolFields: Record = { commandPaletteEnabled, pinAppRail, @@ -290,6 +327,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => { if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose; if (customKeybindings !== undefined) updates.customKeybindings = customKeybindings; + if (layoutId !== undefined) updates.layoutId = layoutId; + if (themeId !== undefined) updates.themeId = themeId; if (Object.keys(updates).length === 1) { return res.status(400).json({ error: "No preferences provided" }); diff --git a/src/backend/plugins/assets.ts b/src/backend/plugins/assets.ts index 27c978b3f..44b55b683 100644 --- a/src/backend/plugins/assets.ts +++ b/src/backend/plugins/assets.ts @@ -1,4 +1,5 @@ -// Serves a plugin's built frontend: its bundle, stylesheet and locales. +// Serves a plugin's built frontend: its bundle, stylesheet, locales and the +// CSS files its themes declare. // // Unauthenticated on purpose, the same as /assets: import() cannot carry the // bearer header, and nothing here is secret. It only ever serves files from @@ -28,6 +29,22 @@ export interface PluginFrontendInfo { assetVersion: string | null; /** Language codes with a locale file, "en" plus xx_YY names. */ locales: string[]; + /** Changes whenever a theme's CSS does, for its ?v= link. */ + themeVersion: string | null; +} + +/** Where a theme's CSS is served: /plugin-assets//_themes/.css. */ +export const THEME_ASSET_PREFIX = "_themes/"; + +/** The declared CSS file of each of the plugin's themes, by theme id. */ +function themeFiles(plugin: AssetPlugin): Map { + const files = new Map(); + const manifest = plugin.manifest as PluginManifest; + for (const theme of manifest.contributes?.themes ?? []) { + const file = path.resolve(plugin.dir, theme.css); + if (isInside(plugin.dir, file)) files.set(theme.id, file); + } + return files; } function frontendEntry( @@ -72,9 +89,16 @@ export function describePluginFrontend( css: false, assetVersion: null, locales: [], + themeVersion: null, }; if (!plugin) return none; + const themes = [...themeFiles(plugin).values()].filter(fileExists); + const themeVersion = + themes.length > 0 + ? contentVersion(themes.sort(), plugin.manifest.version) + : null; + const locales: string[] = []; const localeFiles: string[] = []; const dir = localesDir(plugin); @@ -96,7 +120,8 @@ export function describePluginFrontend( } const entry = frontendEntry(plugin); - if (!entry || !fileExists(entry.file)) return { ...none, locales }; + if (!entry || !fileExists(entry.file)) + return { ...none, locales, themeVersion }; const cssFile = path.join(entry.dir, "frontend.css"); const css = fileExists(cssFile); @@ -111,6 +136,7 @@ export function describePluginFrontend( css, assetVersion, locales: locales.sort(), + themeVersion, }; } @@ -158,7 +184,14 @@ export function resolvePluginAsset( let root: string | null; let rest: string; - if (clean.startsWith("locales/")) { + if (clean.startsWith(THEME_ASSET_PREFIX)) { + // Only a declared theme file, which the loader checked on load. + const themeId = clean.slice(THEME_ASSET_PREFIX.length, -".css".length); + const file = themeFiles(plugin).get(themeId); + if (!file || !clean.endsWith(".css")) return null; + root = path.dirname(file); + rest = path.basename(file); + } else if (clean.startsWith("locales/")) { root = localesDir(plugin); rest = clean.slice("locales/".length); } else { diff --git a/src/backend/plugins/bundled-index.ts b/src/backend/plugins/bundled-index.ts index 4b5d785a9..46333cf8c 100644 --- a/src/backend/plugins/bundled-index.ts +++ b/src/backend/plugins/bundled-index.ts @@ -1,5 +1,5 @@ /** - * The onboarding defaults of the bundled plugins, from + * The onboarding defaults and the default look of the bundled plugins, from * dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from * docker/bundled-plugins.json). Core never names plugins, so which ones a * fresh install keeps lives in that file and not here. @@ -27,6 +27,7 @@ interface BundledIndex { consent?: boolean; enabledByEnv?: string[]; }; + defaultLook?: boolean; } >; } @@ -52,6 +53,23 @@ export function readBundledIndex(): BundledIndex | null { return cached; } +/** + * Bundled plugins whose layout and theme a fresh install shows. Core reads + * the default look from here so it never has to name a plugin. + */ +export function defaultLookPluginIds(): string[] { + const index = readBundledIndex(); + if (!index) return []; + return Object.entries(index.plugins) + .filter(([, entry]) => entry?.defaultLook === true) + .map(([id]) => id) + .sort(); +} + +export function isDefaultLookPlugin(pluginId: string): boolean { + return defaultLookPluginIds().includes(pluginId); +} + export function resetBundledIndexCache(): void { cached = undefined; } @@ -96,6 +114,8 @@ export function initialPluginState( ): "enabled" | "disabled" { if (source !== "bundled") return "disabled"; if (!freshInstall) return "enabled"; + // The default look is what the app is drawn with. + if (isDefaultLookPlugin(pluginId)) return "enabled"; const { recommended, consent } = onboardingDefaults(pluginId); return recommended && !consent ? "enabled" : "disabled"; } diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts index 862ac6537..7fad700c0 100644 --- a/src/backend/plugins/loader.ts +++ b/src/backend/plugins/loader.ts @@ -18,7 +18,11 @@ import path from "node:path"; import semver from "semver"; import * as tar from "tar"; import { pluginLogger } from "../utils/logger.js"; -import { isTermixCompatible, parseManifest } from "./manifest.js"; +import { + isTermixCompatible, + parseManifest, + validateThemeCss, +} from "./manifest.js"; import { getLocalVersion } from "../utils/app-version.js"; import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; import { @@ -85,6 +89,19 @@ export interface LoadedPlugin { signedTier?: KeyTier; } +/** + * A plugin folder that did not load. Kept so the layout repair view can say + * why a chosen layout or theme is missing instead of only "not installed". + */ +export interface SkippedPlugin { + /** Manifest id when it could be read, else the folder or file name. */ + id: string; + version: string | null; + layouts: string[]; + themes: string[]; + error: string; +} + export interface PluginLoaderOptions { errorThreshold?: number; errorWindowMs?: number; @@ -92,8 +109,70 @@ export interface PluginLoaderOptions { onFailed?: (plugin: LoadedPlugin) => void; } +/** + * Every theme file a manifest declares must sit inside the plugin and hold + * only what a theme may hold. Refusing the plugin keeps a bad file from ever + * being served. + */ +function validatePluginThemes(dir: string, manifest: PluginManifest): void { + for (const theme of manifest.contributes?.themes ?? []) { + const file = path.join(dir, theme.css); + if (!fs.existsSync(file) || !isRealPathInside(dir, file)) { + throw new Error( + `Plugin ${manifest.id} theme ${theme.id} file ${theme.css} is missing`, + ); + } + const errors = validateThemeCss(fs.readFileSync(file, "utf8"), theme.id); + if (errors.length > 0) { + throw new Error( + `Plugin ${manifest.id} theme ${theme.id} is not allowed: ${errors.join("; ")}`, + ); + } + } +} + +/** What can still be said about a plugin folder that failed to load. */ +function describeSkipped( + dir: string, + name: string, + isArtifact: boolean, + error: unknown, +): SkippedPlugin { + const skipped: SkippedPlugin = { + id: isArtifact ? name.replace(/(-\d[^/]*)?\.tmxplug$/, "") : name, + version: null, + layouts: [], + themes: [], + error: error instanceof Error ? error.message : String(error), + }; + if (isArtifact) return skipped; + try { + const raw = JSON.parse( + fs.readFileSync(getPluginManifestPath(dir), "utf8"), + ) as { + id?: unknown; + version?: unknown; + contributes?: { layouts?: unknown; themes?: unknown }; + }; + const ids = (list: unknown) => + Array.isArray(list) + ? list + .map((entry) => (entry as { id?: unknown })?.id) + .filter((id): id is string => typeof id === "string") + : []; + if (typeof raw.id === "string") skipped.id = raw.id; + if (typeof raw.version === "string") skipped.version = raw.version; + skipped.layouts = ids(raw.contributes?.layouts); + skipped.themes = ids(raw.contributes?.themes); + } catch { + // An unreadable manifest leaves only the folder name. + } + return skipped; +} + export class PluginLoader { private readonly plugins = new Map(); + private skippedPlugins: SkippedPlugin[] = []; /** Version of each bundled plugin as shipped, the floor for any update. */ private readonly bundledVersions = new Map(); /** Activation order, so shutdown can run it backwards. */ @@ -113,6 +192,11 @@ export class PluginLoader { return [...this.plugins.values()]; } + /** Plugin folders the last full load skipped, with the reason. */ + skipped(): SkippedPlugin[] { + return [...this.skippedPlugins]; + } + get(pluginId: string): LoadedPlugin | undefined { return this.plugins.get(pluginId); } @@ -210,6 +294,8 @@ export class PluginLoader { ); } + validatePluginThemes(dir, manifest); + if (this.plugins.has(manifest.id)) { throw new Error(`another plugin already uses the id "${manifest.id}"`); } @@ -258,6 +344,7 @@ export class PluginLoader { async loadAll(): Promise { this.plugins.clear(); this.bundledVersions.clear(); + this.skippedPlugins = []; await removeStaleStaging(); const loaded: LoadedPlugin[] = []; // Every bundled folder name, loaded or not: a bundled plugin that fails @@ -322,6 +409,9 @@ export class PluginLoader { } loaded.push(plugin); } catch (error) { + this.skippedPlugins.push( + describeSkipped(dir, entry.name, isArtifact, error), + ); pluginLogger.error( `Skipping plugin directory ${entry.name} in ${root}`, error instanceof Error ? error : new Error(String(error)), diff --git a/src/backend/plugins/looks.ts b/src/backend/plugins/looks.ts new file mode 100644 index 000000000..0d7f7816b --- /dev/null +++ b/src/backend/plugins/looks.ts @@ -0,0 +1,57 @@ +/** + * Layouts and themes on the server side: which look a fresh install shows, + * and which installed looks could not load. Core names no plugin here; the + * default look comes from the bundled index's defaultLook flag. + */ + +import { defaultLookPluginIds } from "./bundled-index.js"; +import type { LoadedPlugin, SkippedPlugin } from "./loader.js"; + +export interface DefaultLook { + layout: string | null; + theme: string | null; +} + +interface LookLoader { + get(pluginId: string): Pick | undefined; + skipped(): SkippedPlugin[]; +} + +/** + * The first default-look plugin's default layout (else its first one) and + * that layout's theme. A default-look plugin with themes only fills in the + * theme. Null where none is installed, and the browser uses its built-in. + */ +export function resolveDefaultLook(loader: LookLoader): DefaultLook { + const look: DefaultLook = { layout: null, theme: null }; + for (const id of defaultLookPluginIds()) { + const contributes = loader.get(id)?.manifest.contributes; + const layouts = contributes?.layouts ?? []; + const layout = layouts.find((entry) => entry.default) ?? layouts[0]; + if (layout && !look.layout) { + look.layout = layout.id; + look.theme ??= layout.defaultTheme ?? null; + } + if (!look.theme && contributes?.themes?.length) { + look.theme = contributes.themes[0].id; + } + } + return look; +} + +/** Plugins with a layout or theme that were found but did not load. */ +export function unavailableLooks(loader: LookLoader): SkippedPlugin[] { + return loader + .skipped() + .filter((plugin) => plugin.layouts.length > 0 || plugin.themes.length > 0); +} + +/** TERMIX_SAFE_LAYOUT=1 starts everyone in the layout repair view. */ +export function safeLayoutForced(): boolean { + const value = process.env.TERMIX_SAFE_LAYOUT?.trim().toLowerCase(); + return value === "1" || value === "true"; +} + +export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/; +export const LAYOUT_SETTING = "ui.layout"; +export const THEME_SETTING = "ui.theme"; diff --git a/src/backend/plugins/manage.ts b/src/backend/plugins/manage.ts index 6fd33de7e..21e80c9fc 100644 --- a/src/backend/plugins/manage.ts +++ b/src/backend/plugins/manage.ts @@ -7,7 +7,9 @@ * sha256 and signature checks and stays marked unverified. Bundled plugins ship in the * image and can still be uninstalled: their files are deleted and the id is * recorded in the settings table, so the shipped copy is ignored from then - * on and a reinstall downloads the plugin like any other. + * on and a reinstall downloads the plugin like any other. The one exception + * is a default-look plugin (bundled-index.ts): the app is drawn with it, so + * it can be disabled or swapped but never uninstalled. */ import crypto from "node:crypto"; @@ -15,6 +17,7 @@ import fs from "node:fs"; import path from "node:path"; import semver from "semver"; import { pluginLogger } from "../utils/logger.js"; +import { isDefaultLookPlugin } from "./bundled-index.js"; import { activatePlugin, deactivatePlugin, @@ -1154,6 +1157,13 @@ export async function uninstallPluginUnlocked( throw new PluginManageError("Plugin not found", 404, "NOT_FOUND"); } const shipped = loader.bundledIds().has(pluginId); + if (shipped && isDefaultLookPlugin(pluginId)) { + throw new PluginManageError( + "The default look cannot be uninstalled", + 409, + "DEFAULT_LOOK", + ); + } const dependents = dependentChain(pluginId, await enabledIds()); if (dependents.length > 0) { diff --git a/src/backend/plugins/manifest.ts b/src/backend/plugins/manifest.ts index ee76eb848..7229a7e0e 100644 --- a/src/backend/plugins/manifest.ts +++ b/src/backend/plugins/manifest.ts @@ -21,6 +21,7 @@ export { pluginFeatures, pluginDocsUrl, pluginEnvVars, + validateThemeCss, } from "@termix-ssh/plugin-sdk/manifest"; export type { diff --git a/src/backend/tests/database/routes/look.test.ts b/src/backend/tests/database/routes/look.test.ts new file mode 100644 index 000000000..648768667 --- /dev/null +++ b/src/backend/tests/database/routes/look.test.ts @@ -0,0 +1,191 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import express from "express"; + +const state = vi.hoisted(() => ({ + userId: "user-1", + admins: new Set(["admin-1"]), + prefs: null as { layoutId: string | null; themeId: string | null } | null, + settings: new Map(), + audits: [] as unknown[], + skipped: [] as unknown[], + defaultLookIds: [] as string[], +})); + +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); +vi.mock("../../../utils/audit-logger.js", () => ({ + logAudit: async (entry: unknown) => { + state.audits.push(entry); + }, + getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }), +})); +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ + createAuthMiddleware: + () => (req: express.Request, _res: unknown, next: () => void) => { + (req as unknown as { userId: string }).userId = state.userId; + next(); + }, + }), + }, +})); +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentUserPreferenceRepository: () => ({ + findByUserId: async () => state.prefs, + }), + createCurrentUserRepository: () => ({ + findById: async (id: string) => ({ + id, + username: id, + isAdmin: state.admins.has(id), + }), + }), + createCurrentSettingsRepository: () => ({ + get: async (key: string) => state.settings.get(key) ?? null, + set: async (key: string, value: string) => { + state.settings.set(key, value); + }, + delete: async (key: string) => { + state.settings.delete(key); + }, + }), +})); +vi.mock("../../../plugins/index.js", () => ({ + getPluginRuntime: () => ({ + loader: { + get: (id: string) => + id === "shipped-look" + ? { + manifest: { + contributes: { + layouts: [ + { id: "shipped", label: "Shipped", defaultTheme: "ink" }, + ], + }, + }, + } + : undefined, + skipped: () => state.skipped, + }, + }), +})); +vi.mock("../../../plugins/bundled-index.js", () => ({ + defaultLookPluginIds: () => state.defaultLookIds, +})); + +const { default: lookRoutes } = + await import("../../../database/routes/look.js"); + +let server: http.Server; +let baseUrl: string; + +beforeEach(async () => { + state.userId = "user-1"; + state.prefs = null; + state.settings.clear(); + state.audits = []; + state.skipped = []; + state.defaultLookIds = []; + delete process.env.TERMIX_SAFE_LAYOUT; + + const app = express(); + app.use(express.json()); + app.use("/user-preferences/look", lookRoutes); + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterEach(async () => { + delete process.env.TERMIX_SAFE_LAYOUT; + await new Promise((resolve) => server.close(() => resolve())); +}); + +async function getLook() { + const res = await fetch(`${baseUrl}/user-preferences/look`); + return { status: res.status, body: await res.json() }; +} + +async function putDefaults(body: unknown) { + const res = await fetch(`${baseUrl}/user-preferences/look/defaults`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; +} + +describe("GET /user-preferences/look", () => { + it("is all empty on a fresh install", async () => { + expect((await getLook()).body).toEqual({ + layoutId: null, + themeId: null, + defaults: { layout: null, theme: null }, + defaultLook: { layout: null, theme: null }, + safeLayout: false, + unavailable: [], + }); + }); + + it("returns the user's pick, the admin defaults and the default look", async () => { + state.prefs = { layoutId: "mine", themeId: "nord" }; + state.settings.set("ui.layout", "admin-layout"); + state.settings.set("ui.theme", "gruvbox"); + state.defaultLookIds = ["shipped-look"]; + state.skipped = [ + { + id: "broken", + version: "1.0.0", + layouts: ["b"], + themes: [], + error: "x", + }, + { id: "other", version: null, layouts: [], themes: [], error: "y" }, + ]; + process.env.TERMIX_SAFE_LAYOUT = "1"; + + const { body } = await getLook(); + expect(body).toMatchObject({ + layoutId: "mine", + themeId: "nord", + defaults: { layout: "admin-layout", theme: "gruvbox" }, + defaultLook: { layout: "shipped", theme: "ink" }, + safeLayout: true, + }); + expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual( + ["broken"], + ); + }); +}); + +describe("PUT /user-preferences/look/defaults", () => { + it("is for admins only", async () => { + const { status } = await putDefaults({ layout: "x" }); + expect(status).toBe(403); + expect(state.settings.size).toBe(0); + }); + + it("sets and clears the defaults, and audits it", async () => { + state.userId = "admin-1"; + let res = await putDefaults({ layout: "fancy", theme: "nord" }); + expect(res).toEqual({ + status: 200, + body: { layout: "fancy", theme: "nord" }, + }); + res = await putDefaults({ layout: null }); + expect(res.body).toEqual({ layout: null, theme: "nord" }); + expect(state.audits).toHaveLength(2); + expect(state.audits[0]).toMatchObject({ action: "update_default_look" }); + }); + + it("refuses something that is not an id", async () => { + state.userId = "admin-1"; + expect((await putDefaults({ theme: "../evil" })).status).toBe(400); + expect((await putDefaults({ layout: 5 })).status).toBe(400); + expect(state.settings.size).toBe(0); + }); +}); diff --git a/src/backend/tests/database/routes/plugins.test.ts b/src/backend/tests/database/routes/plugins.test.ts index a554d4271..0e4e07dc7 100644 --- a/src/backend/tests/database/routes/plugins.test.ts +++ b/src/backend/tests/database/routes/plugins.test.ts @@ -433,6 +433,7 @@ describe("plugins route", () => { "name", "optionalDependencies", "state", + "themeVersion", "version", ].sort(), ); @@ -624,10 +625,12 @@ describe("plugins route", () => { contributes: null, dependencies: {}, optionalDependencies: {}, + defaultLook: false, frontend: false, css: false, assetVersion: null, locales: [], + themeVersion: null, }); }); diff --git a/src/backend/tests/database/routes/user-preferences.test.ts b/src/backend/tests/database/routes/user-preferences.test.ts new file mode 100644 index 000000000..cd0a12692 --- /dev/null +++ b/src/backend/tests/database/routes/user-preferences.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import express from "express"; + +const state = vi.hoisted(() => ({ + row: null as Record | null, + upserts: [] as Record[], +})); + +vi.mock("../../../utils/logger.js", () => ({ + databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); +vi.mock("../../../utils/auth-manager.js", () => ({ + AuthManager: { + getInstance: () => ({ + createAuthMiddleware: + () => (req: express.Request, _res: unknown, next: () => void) => { + (req as unknown as { userId: string }).userId = "user-1"; + next(); + }, + }), + }, +})); +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentUserPreferenceRepository: () => ({ + findByUserId: async () => state.row, + upsert: async (_userId: string, updates: Record) => { + state.upserts.push(updates); + state.row = { ...(state.row ?? {}), ...updates }; + }, + }), +})); +vi.mock("../../../database/routes/keybinding-validation.js", () => ({ + isValidKeybinding: () => true, +})); + +const { default: routes } = + await import("../../../database/routes/user-preferences.js"); + +let server: http.Server; +let baseUrl: string; + +beforeEach(async () => { + state.row = null; + state.upserts = []; + const app = express(); + app.use(express.json()); + app.use("/user-preferences", routes); + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +async function put(body: unknown) { + const res = await fetch(`${baseUrl}/user-preferences`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; +} + +describe("user preferences: layout and theme", () => { + it("reads null for both until chosen", async () => { + const body = await (await fetch(`${baseUrl}/user-preferences`)).json(); + expect(body).toMatchObject({ layoutId: null, themeId: null }); + }); + + it("saves a layout and theme id, and null to follow the default", async () => { + expect((await put({ layoutId: "fancy", themeId: "nord" })).status).toBe( + 200, + ); + expect(state.upserts[0]).toMatchObject({ + layoutId: "fancy", + themeId: "nord", + }); + const body = await (await fetch(`${baseUrl}/user-preferences`)).json(); + expect(body).toMatchObject({ layoutId: "fancy", themeId: "nord" }); + + await put({ layoutId: null }); + expect(state.upserts[1]).toMatchObject({ layoutId: null }); + expect(state.upserts[1]).not.toHaveProperty("themeId"); + }); + + it("refuses anything that is not an id", async () => { + for (const bad of ["Fancy", "../x", "a", 7]) { + expect((await put({ themeId: bad })).status).toBe(400); + } + expect(state.upserts).toHaveLength(0); + }); +}); diff --git a/src/backend/tests/electron/app-quit.test.ts b/src/backend/tests/electron/app-quit.test.ts index 0705bb36b..82c22d5d3 100644 --- a/src/backend/tests/electron/app-quit.test.ts +++ b/src/backend/tests/electron/app-quit.test.ts @@ -8,7 +8,7 @@ const main = fs.readFileSync( "utf8", ); const shell = fs.readFileSync( - new URL("../../../ui/AppShell.tsx", import.meta.url), + new URL("../../../ui/shell/controller/use-tabs.ts", import.meta.url), "utf8", ); diff --git a/src/backend/tests/plugins/assets.test.ts b/src/backend/tests/plugins/assets.test.ts index b6b876e7d..ace18db5c 100644 --- a/src/backend/tests/plugins/assets.test.ts +++ b/src/backend/tests/plugins/assets.test.ts @@ -108,6 +108,7 @@ describe("describePluginFrontend", () => { css: false, assetVersion: null, locales: [], + themeVersion: null, }); }); }); @@ -175,3 +176,48 @@ describe("GET /plugin-assets", () => { } }); }); + +describe("theme stylesheets", () => { + let themed: { id: string; dir: string; manifest: never }; + + beforeAll(() => { + const dir = path.join(root, "inks"); + fs.mkdirSync(path.join(dir, "themes"), { recursive: true }); + fs.writeFileSync(path.join(dir, "themes", "paper.css"), ".paper{}"); + fs.writeFileSync(path.join(dir, "themes", "secret.css"), ".secret{}"); + themed = { + id: "inks", + dir, + manifest: { + id: "inks", + version: "1.0.0", + contributes: { + themes: [ + { + id: "paper", + label: "Paper", + mode: "light", + css: "themes/paper.css", + }, + ], + }, + } as never, + }; + }); + + it("serves a declared theme's CSS under _themes/.css only", () => { + expect(resolvePluginAsset(themed, "_themes/paper.css")).toBe( + path.join(themed.dir, "themes", "paper.css"), + ); + expect(resolvePluginAsset(themed, "_themes/secret.css")).toBeNull(); + expect(resolvePluginAsset(themed, "themes/paper.css")).toBeNull(); + expect(resolvePluginAsset(themed, "_themes/paper.js")).toBeNull(); + }); + + it("versions theme files even without a frontend bundle", () => { + const info = describePluginFrontend(themed); + expect(info.frontend).toBe(false); + expect(info.themeVersion).toMatch(/^[0-9a-f]{12}$/); + expect(describePluginFrontend(plugin).themeVersion).toBeNull(); + }); +}); diff --git a/src/backend/tests/plugins/bundled-index.test.ts b/src/backend/tests/plugins/bundled-index.test.ts index eff196e52..bbf30d5f4 100644 --- a/src/backend/tests/plugins/bundled-index.test.ts +++ b/src/backend/tests/plugins/bundled-index.test.ts @@ -3,7 +3,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { + defaultLookPluginIds, initialPluginState, + isDefaultLookPlugin, onboardingDefaults, resetBundledIndexCache, } from "../../plugins/bundled-index.js"; @@ -103,3 +105,28 @@ describe("initialPluginState", () => { expect(initialPluginState("kept", "user", false)).toBe("disabled"); }); }); + +describe("default look", () => { + it("lists the plugins flagged as the default look", () => { + writeIndex({ + "some-layout": { defaultLook: true }, + "some-themes": { defaultLook: true, onboarding: { recommended: false } }, + other: { onboarding: { recommended: true } }, + }); + expect(defaultLookPluginIds()).toEqual(["some-layout", "some-themes"]); + expect(isDefaultLookPlugin("some-themes")).toBe(true); + expect(isDefaultLookPlugin("other")).toBe(false); + }); + + it("has none without an index", () => { + expect(defaultLookPluginIds()).toEqual([]); + }); + + it("starts the default look enabled even on a fresh install", () => { + writeIndex({ + "some-themes": { defaultLook: true, onboarding: { recommended: false } }, + }); + expect(initialPluginState("some-themes", "bundled", true)).toBe("enabled"); + expect(initialPluginState("some-themes", "user", true)).toBe("disabled"); + }); +}); diff --git a/src/backend/tests/plugins/loader.test.ts b/src/backend/tests/plugins/loader.test.ts index c3da10399..b670356ce 100644 --- a/src/backend/tests/plugins/loader.test.ts +++ b/src/backend/tests/plugins/loader.test.ts @@ -132,6 +132,85 @@ describe("PluginLoader discovery", () => { }); }); +describe("PluginLoader themes and skipped looks", () => { + const themed = { + capabilities: ["ui:theme", "ui:layout"], + manifestOverrides: { + contributes: { + themes: [ + { + id: "paper", + label: "Paper", + mode: "light", + css: "themes/paper.css", + }, + ], + layouts: [{ id: "fancy", label: "Fancy" }], + }, + }, + }; + + it("loads a plugin whose theme CSS is allowed", async () => { + const fixture = createFixturePlugin(themed); + cleanups.push(fixture.cleanup); + fs.mkdirSync(path.join(fixture.dir, "themes")); + fs.writeFileSync( + path.join(fixture.dir, "themes", "paper.css"), + ".paper { --background: #fff; }", + ); + const plugin = await new PluginLoader().load(fixture.dir, "bundled"); + expect(plugin.manifest.contributes?.themes?.[0].id).toBe("paper"); + }); + + it("refuses a plugin whose theme CSS reaches outside", async () => { + const fixture = createFixturePlugin(themed); + cleanups.push(fixture.cleanup); + fs.mkdirSync(path.join(fixture.dir, "themes")); + fs.writeFileSync( + path.join(fixture.dir, "themes", "paper.css"), + '@import url("https://example.com/x.css");', + ); + await expect( + new PluginLoader().load(fixture.dir, "bundled"), + ).rejects.toThrow(/theme paper is not allowed: @import/); + }); + + it("refuses a plugin whose theme file is missing", async () => { + const fixture = createFixturePlugin(themed); + cleanups.push(fixture.cleanup); + await expect( + new PluginLoader().load(fixture.dir, "bundled"), + ).rejects.toThrow(/theme paper file themes\/paper.css is missing/); + }); + + it("remembers why a layout plugin was skipped", async () => { + const bundled = tempRoot("termix-bundled-"); + const user = tempRoot("termix-data-"); + createFixturePlugin({ + ...themed, + id: "fancy-layout", + root: bundled, + manifestOverrides: { + ...themed.manifestOverrides, + version: "2.0.0", + engine: { termix: ">=99.0.0", api: "1" }, + }, + }); + setRoots(bundled, user); + + const loader = new PluginLoader(); + await loader.loadAll(); + const [skipped] = loader.skipped(); + expect(skipped).toMatchObject({ + id: "fancy-layout", + version: "2.0.0", + layouts: ["fancy"], + themes: ["paper"], + }); + expect(skipped.error).toMatch(/needs Termix >=99.0.0/); + }); +}); + describe("PluginLoader dependency resolution", () => { async function loadAll( specs: Array<{ id: string; manifestOverrides?: Record }>, diff --git a/src/backend/tests/plugins/looks.test.ts b/src/backend/tests/plugins/looks.test.ts new file mode 100644 index 000000000..096fb2f23 --- /dev/null +++ b/src/backend/tests/plugins/looks.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const index = vi.hoisted(() => ({ ids: [] as string[] })); +vi.mock("../../plugins/bundled-index.js", () => ({ + defaultLookPluginIds: () => index.ids, +})); + +import { + resolveDefaultLook, + safeLayoutForced, + unavailableLooks, +} from "../../plugins/looks.js"; +import type { SkippedPlugin } from "../../plugins/loader.js"; + +type Contributes = Record; + +function loaderWith( + plugins: Record, + skipped: SkippedPlugin[] = [], +) { + return { + get: (id: string) => + plugins[id] + ? ({ manifest: { contributes: plugins[id] } } as never) + : undefined, + skipped: () => skipped, + }; +} + +beforeEach(() => { + index.ids = []; +}); +afterEach(() => { + delete process.env.TERMIX_SAFE_LAYOUT; +}); + +describe("resolveDefaultLook", () => { + it("is empty without a default-look plugin", () => { + expect(resolveDefaultLook(loaderWith({}))).toEqual({ + layout: null, + theme: null, + }); + }); + + it("takes the layout marked default and its theme", () => { + index.ids = ["look-a"]; + const loader = loaderWith({ + "look-a": { + layouts: [ + { id: "first", label: "First", defaultTheme: "one" }, + { id: "main", label: "Main", defaultTheme: "two", default: true }, + ], + }, + }); + expect(resolveDefaultLook(loader)).toEqual({ + layout: "main", + theme: "two", + }); + }); + + it("takes the first layout, and a theme from a themes-only plugin", () => { + index.ids = ["look-a", "look-b"]; + const loader = loaderWith({ + "look-a": { themes: [{ id: "paper", label: "Paper" }] }, + "look-b": { layouts: [{ id: "solo", label: "Solo" }] }, + }); + expect(resolveDefaultLook(loader)).toEqual({ + layout: "solo", + theme: "paper", + }); + }); + + it("skips a default-look plugin that did not load", () => { + index.ids = ["gone"]; + expect(resolveDefaultLook(loaderWith({}))).toEqual({ + layout: null, + theme: null, + }); + }); +}); + +describe("unavailableLooks", () => { + it("lists only skipped plugins that brought a layout or theme", () => { + const skipped: SkippedPlugin[] = [ + { id: "a", version: "1.0.0", layouts: ["x"], themes: [], error: "e" }, + { id: "b", version: null, layouts: [], themes: [], error: "e" }, + { id: "c", version: "2.0.0", layouts: [], themes: ["t"], error: "e" }, + ]; + expect( + unavailableLooks(loaderWith({}, skipped)).map((plugin) => plugin.id), + ).toEqual(["a", "c"]); + }); +}); + +describe("safeLayoutForced", () => { + it("reads TERMIX_SAFE_LAYOUT", () => { + expect(safeLayoutForced()).toBe(false); + process.env.TERMIX_SAFE_LAYOUT = "1"; + expect(safeLayoutForced()).toBe(true); + process.env.TERMIX_SAFE_LAYOUT = "true"; + expect(safeLayoutForced()).toBe(true); + process.env.TERMIX_SAFE_LAYOUT = "0"; + expect(safeLayoutForced()).toBe(false); + }); +}); diff --git a/src/backend/tests/plugins/manage.test.ts b/src/backend/tests/plugins/manage.test.ts index 7ea95dba7..6aa90f589 100644 --- a/src/backend/tests/plugins/manage.test.ts +++ b/src/backend/tests/plugins/manage.test.ts @@ -289,6 +289,32 @@ describe("uninstall and reinstall of a bundled plugin", () => { expect(loader.get("docker")?.source).toBe("user"); }); + it("refuses to uninstall the default look but lets it be turned off", async () => { + createFixturePlugin({ id: "some-look", root: bundled }); + fs.writeFileSync( + path.join(bundled, "bundled-index.json"), + JSON.stringify({ + version: 1, + plugins: { "some-look": { defaultLook: true } }, + }), + ); + const { resetBundledIndexCache } = + await import("../../plugins/bundled-index.js"); + resetBundledIndexCache(); + try { + const loader = await boot(); + await expect(manage.uninstallPlugin("some-look")).rejects.toMatchObject({ + code: "DEFAULT_LOOK", + status: 409, + }); + expect(loader.get("some-look")?.state).toBe("active"); + expect(fs.existsSync(path.join(bundled, "some-look"))).toBe(true); + } finally { + fs.rmSync(path.join(bundled, "bundled-index.json"), { force: true }); + resetBundledIndexCache(); + } + }); + it("deletes a plugin dropped in as a plain folder", async () => { createFixturePlugin({ id: "notes", root: path.join(data, "plugins") }); const loader = await boot(); diff --git a/src/backend/tests/plugins/manifest.test.ts b/src/backend/tests/plugins/manifest.test.ts index 89d95e2ad..b56c09347 100644 --- a/src/backend/tests/plugins/manifest.test.ts +++ b/src/backend/tests/plugins/manifest.test.ts @@ -1002,3 +1002,79 @@ describe("docs and env fields", () => { expect(pluginDocsPage(base, "", "top")).toBe(`${base}#top`); }); }); + +describe("layouts and themes", () => { + const layouts = [ + { + id: "fancy", + label: "layout.fancy", + defaultTheme: "paper", + default: true, + }, + ]; + const themes = [ + { + id: "paper", + label: "theme.paper", + mode: "light", + css: "themes/paper.css", + swatch: { background: "#ffffff", panel: "#eeeeee", text: "#111111" }, + }, + ]; + + it("accepts layouts and themes with their capabilities", () => { + const { manifest, errors } = parseManifest( + base({ + capabilities: ["ui:layout", "ui:theme"], + contributes: { layouts, themes }, + }), + ); + expect(errors).toEqual([]); + expect(manifest?.contributes?.layouts?.[0].id).toBe("fancy"); + expect(manifest?.contributes?.themes?.[0].mode).toBe("light"); + }); + + it("needs ui:layout for layouts and ui:theme for themes", () => { + const { errors } = parseManifest( + base({ capabilities: ["kv:own"], contributes: { layouts, themes } }), + ); + expect(errors.some((e) => e.includes('"ui:layout"'))).toBe(true); + expect(errors.some((e) => e.includes('"ui:theme"'))).toBe(true); + }); + + it("refuses bad ids, modes, paths and swatches", () => { + const errors = validateManifest( + base({ + capabilities: ["ui:layout", "ui:theme"], + contributes: { + layouts: [ + { id: "Fancy", label: "x" }, + { id: "two", label: "x", default: true }, + { id: "three", label: "x", default: true, extra: 1 }, + ], + themes: [ + { id: "paper", label: "x", mode: "sepia", css: "../paper.css" }, + { id: "ink", label: "x", mode: "dark", css: "ink.scss" }, + { + id: "dusk", + label: "x", + mode: "dark", + css: "dusk.css", + swatch: { background: "red", panel: "#000", text: "#fff" }, + }, + { id: "dusk", label: "x", mode: "dark", css: "dusk2.css" }, + ], + }, + }), + ); + const has = (text: string) => errors.some((e) => e.includes(text)); + expect(has("contributes.layouts[0].id must match")).toBe(true); + expect(has("Only one entry")).toBe(true); + expect(has('Unknown field "extra"')).toBe(true); + expect(has('mode must be "light" or "dark"')).toBe(true); + expect(has("relative path inside the plugin")).toBe(true); + expect(has("must be a .css file")).toBe(true); + expect(has("swatch.background must be a hex color")).toBe(true); + expect(has('duplicates "dusk"')).toBe(true); + }); +}); diff --git a/src/backend/tests/plugins/theme-css.test.ts b/src/backend/tests/plugins/theme-css.test.ts new file mode 100644 index 000000000..3455d1eb1 --- /dev/null +++ b/src/backend/tests/plugins/theme-css.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { validateThemeCss } from "../../plugins/manifest.js"; + +describe("validateThemeCss", () => { + it("accepts token rules scoped under the theme class", () => { + const css = ` + /* Paper */ + .paper { --background: #fff; --foreground: #111; } + :root.paper, html.paper .card { --card: #f8f8f8; } + @media (prefers-reduced-motion: reduce) { + .paper * { transition: none; } + } + @supports (color: oklch(0 0 0)) { .paper { --border: oklch(0.9 0 0); } } + .paper .logo { background: url("logo.svg") no-repeat; } + .paper .dot { background: url(data:image/png;base64,AAAA); } + `; + expect(validateThemeCss(css, "paper")).toEqual([]); + }); + + it("refuses selectors outside the theme", () => { + const errors = validateThemeCss( + ".paper { --a: 1 } body { color: red } .paperish { --b: 2 } .dark { --c: 3 }", + "paper", + ); + expect(errors).toHaveLength(3); + expect(errors[0]).toContain('"body"'); + }); + + it("refuses anything that loads or runs", () => { + const errors = validateThemeCss( + `@import url("https://evil.example/x.css"); + .paper { background: url(https://evil.example/a.png); } + .paper { background: url(//evil.example/a.png); } + .paper { width: expression(alert(1)); } + .paper { background: url(javascript:alert(1)); } + .paper { behavior: url(x.htc); } + @font-face { font-family: x; src: url(x.woff2); } + @keyframes spin { from { opacity: 0 } }`, + "paper", + ); + const has = (text: string) => errors.some((e) => e.includes(text)); + expect(has("@import is not allowed")).toBe(true); + expect(has("url(https://evil.example/a.png) points outside")).toBe(true); + expect(has("url(//evil.example/a.png) points outside")).toBe(true); + expect(has("expression() is not allowed")).toBe(true); + expect(has("javascript: is not allowed")).toBe(true); + expect(has("Script bindings")).toBe(true); + expect(has("@font-face is not allowed")).toBe(true); + expect(has("@keyframes is not allowed")).toBe(true); + }); + + it("refuses nested rules and broken braces", () => { + expect(validateThemeCss(".paper { .x { color: red } }", "paper")).toEqual([ + 'Nested rules are not allowed in ".paper"', + ]); + expect(validateThemeCss(".paper { color: red", "paper")).toEqual([ + "A rule is missing its closing brace", + ]); + }); + + it("does not trip on braces inside strings", () => { + expect( + validateThemeCss('.paper .x::after { content: "{ }"; }', "paper"), + ).toEqual([]); + }); +}); diff --git a/src/main.tsx b/src/main.tsx index 9e7090dc1..008b0486f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -32,6 +32,9 @@ import { } from "@/plugin-host/loader"; import { settledPromise } from "@/plugin-host/plugin-store"; import { resetShellBridge } from "@/plugin-host/shell-bridge"; +import { loadLook, resetLook } from "@/look/look-store"; +import { resetPluginThemes } from "@/look/theme-registry"; +import { resetPluginLayouts } from "@/look/layout-registry"; import { preloadPermissions } from "@/hooks/use-permissions"; import { getUserPreferences } from "@/api/open-tabs-api"; import { standaloneViewFor } from "@/shell/tab-registry"; @@ -209,6 +212,7 @@ function App() { .then(() => settledPromise()), preloadPermissions(), getUserPreferences().catch(() => undefined), + loadLook(), ]); await enterApp(); }) @@ -296,6 +300,7 @@ function App() { .then(() => settledPromise()), preloadPermissions(), getUserPreferences().catch(() => undefined), + loadLook(), ]); await enterApp(); })(); @@ -321,6 +326,9 @@ function App() { // should survive into the next session. void stopPluginRuntime(); resetShellBridge(); + resetLook(); + resetPluginThemes(); + resetPluginLayouts(); clearStoredAuth(); localStorage.removeItem("jwt"); if (isElectron() && options?.manual) { diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 78a7b09ca..84e0d52d3 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -223,7 +223,8 @@ export type AdminSection = | "api-keys" | "audit-log" | "ssl"; -export type ThemeId = +export type ThemeId = string; +export type LegacyThemeId = | "dark" | "light" | "system" diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 49db7806f..dfbba2ba1 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -1,266 +1,10 @@ /* eslint-disable react-refresh/only-export-components */ -/* eslint-disable react-hooks/exhaustive-deps */ -import type { TabHandle } from "@termix-ssh/plugin-sdk/frontend"; -import { toast } from "sonner"; -import { useTranslation } from "react-i18next"; -import { Sheet, SheetContent } from "@/components/sheet"; -import { Columns2, Maximize2, RotateCcw, Rows2, X } from "lucide-react"; -import { - useState, - useRef, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - createRef, - lazy, - Suspense, -} from "react"; -import { createPortal } from "react-dom"; -import { useIsMobile } from "@/hooks/use-mobile"; -import { resetPermissionsCache } from "@/hooks/use-permissions"; -import { MobileBar } from "@/shell/MobileBar"; -import { isElectron } from "@/lib/electron"; -import { AppRail, type RailView } from "@/sidebar/AppRail"; -import { ComponentSlot } from "@/shell/ActionSlot"; -import { - isCoreRailView, - railItemLabel, - promotableIds, - rightDockableIds, - useRailItems, -} from "@/sidebar/rail-items"; -import { - DOCK_DEFAULT_WIDTH, - DockPanel, - DockReopenStrip, -} from "@/shell/DockPanel"; -import { - SurfaceScope, - useConfirm, - type ConfirmOptions, - type EditingWidth, -} from "@/components/surface/surface-scope"; -import { OnboardingHost } from "@/onboarding/OnboardingHost"; -import { DesktopUpdateToasts } from "@/shell/DesktopUpdateToasts"; -import { SplitView, type SplitViewActions } from "@/shell/split/SplitView"; -import { SplitDropOverlay } from "@/shell/split/SplitDropOverlay"; -import { - EmptyPanePicker, - type PickHostTarget, -} from "@/shell/split/EmptyPanePicker"; -import { renderTabContent } from "@/shell/tabUtils"; -import { TabBar } from "@/shell/TabBar"; -import { reconnectDisconnectedTabs } from "@/shell/reconnect-tabs"; -import { canCloseTab } from "@/shell/tab-close-guards"; -import { - MANAGE_REQUEST_EVENT, - type ManageMode, - requestFromLegacyEvent, - requestManage, -} from "@/manage/manage-requests"; -import { - dispatchCtrlW, - createCommandPaletteShortcutMatcher, - isShiftKey, -} from "@/lib/app-keyboard-shortcuts"; -import { parseCustomKeybindings } from "@/api/open-tabs-api"; -import { findMatchingKeybinding } from "@/lib/keybinding-match"; -import type { - CustomKeybinding, - KeybindingActionType, -} from "@/types/keybindings"; -import { - GLOBAL_KEYBINDING_EVENT, - getKeybindingAction, - runKeybindingAction, -} from "@/shell/keybinding-registry"; - -// Shell surfaces that are not needed for first paint. -const CommandPalette = lazy(() => - import("@/shell/CommandPalette").then((m) => ({ - default: m.CommandPalette, - })), -); -const HostsPanel = lazy(() => - import("@/sidebar/HostsPanel").then((m) => ({ default: m.HostsPanel })), -); -const QuickConnectPanel = lazy(() => - import("@/sidebar/QuickConnectPanel").then((m) => ({ - default: m.QuickConnectPanel, - })), -); - -const SyncPanel = lazy(() => - import("@/settings/sync/SyncPanel").then((m) => ({ - default: m.SyncPanel, - })), -); -const CredentialsPanel = lazy(() => - import("@/sidebar/CredentialsPanel").then((m) => ({ - default: m.CredentialsPanel, - })), -); -const ConnectionsPanel = lazy(() => - import("@/sidebar/ConnectionsPanel").then((m) => ({ - default: m.ConnectionsPanel, - })), -); - -function SidebarPanelFallback() { - return ( -
-
-
- ); -} -import type { - Tab, - TabType, - Host, - HostFolder, - ThemeId, - FontSizeId, - WorkspacePayload, -} from "@/types/ui-types"; -import { applyAccentColor, applyFontSize } from "@/lib/theme"; -import { globalShortcutHandler } from "@/lib/global-shortcut-handler"; -import { getPaneNavDirection } from "@/lib/pane-nav-hotkey"; -import { getTabJumpDigit } from "@/lib/tab-jump-hotkey"; -import { useTheme } from "@/components/theme-provider"; -import { - getSSHHosts, - getSSHFolders, - getUserInfo, - getOpenTabs, - addOpenTab, - deleteOpenTab, - patchOpenTab, - createSSHHost, - getActiveSessions, - getUserPreferences, - saveUserPreferences, - type UserPreferences, - type OpenTabRecord, -} from "@/main-axios"; -import { - buildLayoutPayload, - resolveLayoutSplits, - resolveLayoutTabTarget, - snapshotData, -} from "@/shell/shell-layout"; -import { useSyncStatus } from "@/hooks/use-sync-status"; -import { remScale } from "@/lib/rem"; -import { dbHealthMonitor } from "@/lib/db-health-monitor"; -import { ServerStatusProvider } from "@/lib/ServerStatusContext"; -import { sshHostToHost } from "@/sidebar/HostManagerData"; -import { resolveHostTabType } from "@/lib/host-connection-tabs"; -import { changeAppLanguage, consumeLoginLanguage } from "@/i18n/i18n"; -import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host"; -import { buildHostTree } from "@/sidebar/build-host-tree"; -import { - addSplitTab, - canJoinSplit, - closeSplitTab, - isSplitTab, - makeSplitTab, - nextSplitNumber, - placeTabInPane, - removeTab, - restoreSplitTabs, - serializeSplitTabs, - splitTabOf, - summarizeSplits, - updateSplit, - type SplitTab, - type TabSplitAction, -} from "@/shell/split/split-tabs"; -import { - addPaneAtEdge, - assignTab, - canAddPane, - createPane, - createSplitNode, - createSplitState, - equalizeSplit, - findPane, - findPaneByTab, - focusPane, - listPanes, - MAX_PANES, - movePane, - neighborPane, - removePane, - resizeSplit, - shownPanes, - splitPane, - swapPanes, - toggleZoom, - type NavDirection, - type PaneEdge, - type PaneRect, - type SplitState, -} from "@/shell/split/split-tree"; -import { - applyPreset, - buildPreset, - fromLegacyConfig, - type SplitPresetId, -} from "@/shell/split/split-presets"; -import { - setSplitDropHandler, - type SplitDragSource, - type SplitDropHover, -} from "@/shell/split/split-drag"; -import { - publishSplitTargets, - setSplitOpener, - type SplitOpenTarget, -} from "@/shell/split/split-targets"; -import { registerPaletteEntry } from "@/shell/palette-registry"; -import { createId } from "@/lib/create-id"; -import { - canRestoreTabType, - getTabType, - isPersistentTabType, - isSessionTabType, - useTabTypes, - type TabShellCallbacks, -} from "@/shell/tab-registry"; -import { runHostAction } from "@/sidebar/host-contributions"; -import { getPanel, usePanels } from "@/shell/panel-registry"; -import { usePluginStore } from "@/plugin-host/plugin-store"; -import { - notifyShellReady, - notifyTabsChanged, - setShellCallbacks, - setShellHosts, - setShellLayoutProvider, -} from "@/plugin-host/shell-bridge"; -import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder"; -import { - OPEN_PLUGINS_EVENT, - OPEN_PLUGIN_SETTINGS_EVENT, -} from "@/plugins/open-plugins"; +import { ShellRoot } from "@/shell/ShellRoot"; export { buildHostTree } from "@/sidebar/build-host-tree"; export { tabIcon, renderTabContent } from "@/shell/tabUtils"; -/** Hands the main area's confirm to code outside it, like closeTab. */ -function ShellConfirmCapture({ - confirmRef, -}: { - confirmRef: React.MutableRefObject< - ((options: ConfirmOptions) => Promise) | null - >; -}) { - const confirm = useConfirm(); - confirmRef.current = confirm; - return null; -} - -// ─── AppShell ──────────────────────────────────────────────────────────────── - +/** The signed-in app. State lives in the shell controller, drawing in the layout. */ export function AppShell({ username, onLogout, @@ -268,2920 +12,5 @@ export function AppShell({ username: string; onLogout: (options?: { manual?: boolean }) => void; }) { - const { t, i18n } = useTranslation(); - const { setTheme } = useTheme(); - // Re-render when plugins add or remove rail items, panels or tabs. - useRailItems(); - const registeredPanels = usePanels(); - const tabTypes = useTabTypes(); - const { settled: pluginsSettled } = usePluginStore(); - const [tabs, setTabs] = useState([ - { - id: "dashboard", - instanceId: "dashboard", - type: "dashboard", - label: t("nav.dashboard"), - openedAt: Date.now(), - }, - ]); - const [activeTabId, setActiveTabId] = useState("dashboard"); - const [userPrefs, setUserPrefs] = useState({ - reopenTabsOnLogin: false, - }); - const [userPrefsLoaded, setUserPrefsLoaded] = useState(false); - const [hostsLoaded, setHostsLoaded] = useState(false); - // Flips to true once the initial DB read (restore or skip) is done. Sync must not fire before this - const [tabsReady, setTabsReady] = useState(false); - const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); - // Split tabs are restored once their child tabs have stable live ids. - const paneLayoutRestoredRef = useRef(false); - const splitTabsRestoredRef = useRef(false); - const [realHostTree, setRealHostTree] = useState(null); - const [hostsLoading, setHostsLoading] = useState(true); - const [allHosts, setAllHosts] = useState([]); - const allHostsRef = useRef(allHosts); - allHostsRef.current = allHosts; - const [isAdmin, setIsAdmin] = useState(false); - // The standalone desktop backend still owns system settings such as the - // Tailscale API key, even though it has only one implicit user. - const showAdminUI = isAdmin; - const [backgroundTabRecords, setBackgroundTabRecords] = useState< - OpenTabRecord[] - >([]); - - const [sidebarOpen, setSidebarOpen] = useState(true); - const [railView, setRailView] = useState("hosts"); - - // Anything that asks to add or edit a host, a credential or host defaults - // ends up in the Manage tab, whichever event it still speaks. - useEffect(() => { - const names = [ - "host-manager:add-host", - "host-manager:edit-host", - "host-manager:add-credential", - "host-manager:show-credentials", - "host-manager:edit-defaults", - "termix:open-host-defaults", - ]; - const handler = (e: Event) => { - const request = requestFromLegacyEvent(e.type, (e as CustomEvent).detail); - if (request) requestManage(request); - }; - const open = () => openSingletonTabRef.current("host-manager"); - for (const name of names) window.addEventListener(name, handler); - window.addEventListener(MANAGE_REQUEST_EVENT, open); - return () => { - for (const name of names) window.removeEventListener(name, handler); - window.removeEventListener(MANAGE_REQUEST_EVENT, open); - }; - }, []); - const [sidebarWidth, setSidebarWidth] = useState(() => { - const saved = localStorage.getItem("termix_sidebarWidth"); - return saved ? parseInt(saved, 10) : 291; - }); - const [sidebarDragging, setSidebarDragging] = useState(false); - // How far each panel wants the sidebar widened: what the panel says itself, - // and what its open inline views say. - const [panelEditing, setPanelEditing] = useState< - Record - >({}); - const [scopeEditing, setScopeEditing] = useState< - Record - >({}); - const setPanelEditingFor = useCallback( - (view: string, editing: EditingWidth) => - setPanelEditing((prev) => - prev[view] === editing ? prev : { ...prev, [view]: editing }, - ), - [], - ); - const setScopeEditingFor = useCallback( - (view: string, editing: EditingWidth) => - setScopeEditing((prev) => - prev[view] === editing ? prev : { ...prev, [view]: editing }, - ), - [], - ); - const editingSetters = useRef( - new Map void>(), - ); - const panelEditingSetter = (view: string) => { - const key = `panel:${view}`; - if (!editingSetters.current.has(key)) - editingSetters.current.set(key, (e) => setPanelEditingFor(view, e)); - return editingSetters.current.get(key)!; - }; - const scopeEditingSetter = (view: string) => { - const key = `scope:${view}`; - if (!editingSetters.current.has(key)) - editingSetters.current.set(key, (e) => setScopeEditingFor(view, e)); - return editingSetters.current.get(key)!; - }; - // The root confirm, for questions the shell itself asks (closing a tab). - const shellConfirmRef = useRef< - ((options: ConfirmOptions) => Promise) | null - >(null); - - // Right dock: a second panel column so reference panels like history can - // stay visible while the left sidebar is used for something else. - const [rightRailView, setRightRailView] = useState(() => { - const saved = localStorage.getItem("termix_rightRailView"); - return saved && rightDockableIds().includes(saved) - ? (saved as RailView) - : null; - }); - const [rightSidebarWidth, setRightSidebarWidth] = useState(() => { - const saved = localStorage.getItem("termix_rightSidebarWidth"); - return saved ? parseInt(saved, 10) : 291; - }); - const [rightSidebarDragging, setRightSidebarDragging] = useState(false); - // Remembers the last panel shown in the dock so the tab bar toggle can bring - // it back instead of always falling back to the same default. - const lastRightRailViewRef = useRef( - localStorage.getItem("termix_lastRightRailView"), - ); - const [isAppFullscreen, setIsAppFullscreen] = useState( - () => !!document.fullscreenElement, - ); - - useEffect(() => { - localStorage.setItem("termix_sidebarWidth", String(sidebarWidth)); - }, [sidebarWidth]); - - useEffect(() => { - localStorage.setItem("termix_rightSidebarWidth", String(rightSidebarWidth)); - }, [rightSidebarWidth]); - - useEffect(() => { - if (rightRailView) { - localStorage.setItem("termix_rightRailView", rightRailView); - lastRightRailViewRef.current = rightRailView; - localStorage.setItem("termix_lastRightRailView", rightRailView); - } else { - localStorage.removeItem("termix_rightRailView"); - } - }, [rightRailView]); - - useEffect(() => { - if (!splitTabsRestoredRef.current) return; - localStorage.setItem( - "termix_splitTabs", - JSON.stringify(serializeSplitTabs(tabs)), - ); - }, [tabs]); - - const isMobile = useIsMobile(); - const sidebarOpenBeforeMobile = useRef(sidebarOpen); - useEffect(() => { - if (isMobile) { - sidebarOpenBeforeMobile.current = sidebarOpen; - setSidebarOpen(false); - } else { - setSidebarOpen(sidebarOpenBeforeMobile.current); - } - }, [isMobile]); - - useEffect(() => { - getUserInfo() - .then((info) => setIsAdmin(info.is_admin)) - .catch(() => setIsAdmin(false)); - }, []); - - const toggleAppFullscreen = useCallback(async () => { - try { - if (document.fullscreenElement) { - await document.exitFullscreen(); - return; - } - - if (!document.fullscreenEnabled) { - toast.error(t("nav.fullscreenUnsupported")); - return; - } - - await document.documentElement.requestFullscreen(); - } catch { - toast.error(t("nav.fullscreenFailed")); - } - }, []); - - useEffect(() => { - const handleFullscreenChange = () => { - setIsAppFullscreen(!!document.fullscreenElement); - }; - - document.addEventListener("fullscreenchange", handleFullscreenChange); - return () => - document.removeEventListener("fullscreenchange", handleFullscreenChange); - }, []); - - const tabsRef = useRef(tabs); - const activeTabIdRef = useRef(activeTabId); - const closeActiveTabRef = useRef<() => void>(() => {}); - const globalKeybindingsRef = useRef([]); - // Split actions for handlers registered once, e.g. the global hotkeys. - const splitActionsRef = useRef<{ - splitActive: (edge: "right" | "bottom") => void; - navigate: (direction: NavDirection) => boolean; - zoomFocused: () => boolean; - closeFocusedPane: () => void; - } | null>(null); - useEffect(() => { - tabsRef.current = tabs; - }, [tabs]); - - useEffect(() => { - let cancelled = false; - const load = () => { - getUserPreferences() - .then((prefs) => { - if (cancelled) return; - globalKeybindingsRef.current = parseCustomKeybindings( - prefs.customKeybindings, - ).filter((binding) => binding.enabled); - }) - .catch(() => {}); - }; - load(); - window.addEventListener("customKeybindingsChanged", load); - return () => { - cancelled = true; - window.removeEventListener("customKeybindingsChanged", load); - }; - }, []); - - useEffect(() => { - const runAction = (type: KeybindingActionType) => { - if (type === "openCommandPalette") { - setCommandPaletteOpen(true); - return; - } - if (type === "reconnectSession") { - const tabId = focusedSessionTabId(); - if (!tabId) return; - const termRef = terminalRefs.current.get(tabId); - (termRef?.current as TabHandle | null)?.reconnect?.(); - return; - } - const currentTabs = tabsRef.current.filter( - (tab) => !tab.parentSplitTabId, - ); - if (currentTabs.length < 2) return; - const index = currentTabs.findIndex( - (tab) => tab.id === activeTabIdRef.current, - ); - const offset = type === "nextTab" ? 1 : -1; - const next = (index + offset + currentTabs.length) % currentTabs.length; - setActiveTabId(currentTabs[next].id); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.target instanceof Element && - event.target.closest("[data-keybinding-recorder]") - ) - return; - // Only actions that run anywhere; a terminal's own are its to handle. - const binding = findMatchingKeybinding( - event, - globalKeybindingsRef.current.filter( - (entry) => getKeybindingAction(entry.action.type)?.scope === "global", - ), - ); - if (!binding) return; - event.preventDefault(); - event.stopPropagation(); - runKeybindingAction(binding.action); - }; - const handleAction = (event: Event) => - runAction( - (event as CustomEvent<{ type: KeybindingActionType }>).detail.type, - ); - - window.addEventListener("keydown", handleKeyDown, true); - window.addEventListener(GLOBAL_KEYBINDING_EVENT, handleAction); - return () => { - window.removeEventListener("keydown", handleKeyDown, true); - window.removeEventListener(GLOBAL_KEYBINDING_EVENT, handleAction); - }; - }, []); - useEffect(() => { - activeTabIdRef.current = activeTabId; - }, [activeTabId]); - useEffect(() => { - return window.electronAPI?.onCloseActiveTab?.(() => { - if (dispatchCtrlW(document.activeElement)) return; - closeActiveTabRef.current(); - }); - }, []); - /** The tab the user is working in: the focused pane's inside a split. */ - function focusedSessionTabId(): string | null { - const id = activeTabIdRef.current; - const active = tabsRef.current.find((tab) => tab.id === id); - if (!isSplitTab(active)) return id || null; - return findPane(active.split, active.split.focusedPaneId)?.tabId ?? null; - } - - // ─── Where opened tabs go ───────────────────────────────────────────────── - - const PENDING_TARGET_MS = 60_000; - /** - * The pane the next opened tab should fill, set when the user picks one - * (the empty pane picker, "Open in split"). `fresh` marks a pane created in - * the same click, which the last committed tabs do not show yet. - */ - const pendingPaneTargetRef = useRef<{ - splitTabId: string; - paneId: string; - at: number; - fresh: boolean; - } | null>(null); - // Set while a saved layout is applied, so its tabs stay where it puts them. - const suppressPaneTargetRef = useRef(false); - - function targetPane(splitTabId: string, paneId: string, fresh = false) { - pendingPaneTargetRef.current = { - splitTabId, - paneId, - at: Date.now(), - fresh, - }; - } - - /** - * Shows a newly opened (or re-focused) tab. It fills the pane the user - * picked, else the focused pane of the active split when that is empty, - * else it simply becomes the active tab. - */ - const placeOpenedTabRef = useRef<(tabId: string) => void>(() => {}); - placeOpenedTabRef.current = (tabId: string) => { - const current = tabsRef.current; - const pending = pendingPaneTargetRef.current; - pendingPaneTargetRef.current = null; - if (suppressPaneTargetRef.current || tabId === "dashboard") { - setActiveTabId(tabId); - return; - } - const existing = current.find((tab) => tab.id === tabId); - - let target: { splitTabId: string; paneId: string } | null = null; - if (pending && Date.now() - pending.at < PENDING_TARGET_MS) { - const split = current.find((tab) => tab.id === pending.splitTabId); - if ( - pending.fresh || - (isSplitTab(split) && findPane(split.split, pending.paneId)) - ) { - target = pending; - } - } - // Opening a session that already sits in a split shows it there. - if (!target && existing?.parentSplitTabId) { - setActiveTabId(tabId); - return; - } - if (!target) { - const active = current.find((tab) => tab.id === activeTabIdRef.current); - if (isSplitTab(active)) { - const focused = findPane(active.split, active.split.focusedPaneId); - if (focused && focused.tabId === null) { - target = { splitTabId: active.id, paneId: focused.id }; - } - } - } - if (!target || (existing && !canJoinSplit(existing))) { - setActiveTabId(tabId); - return; - } - const { splitTabId, paneId } = target; - setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId)); - setActiveTabId(splitTabId); - }; - - // Panels that type into a terminal act on "the terminal you're working in". - // Once those panels can themselves be the active tab, activeTabId points at - // the panel and the lookup misses, so remember the last terminal instead. - // In a split, the tab being worked in is the focused pane's. - const workingTabId = (() => { - const active = tabs.find((t) => t.id === activeTabId); - if (!isSplitTab(active)) return activeTabId; - return findPane(active.split, active.split.focusedPaneId)?.tabId ?? ""; - })(); - const [lastTerminalTabId, setLastTerminalTabId] = useState(activeTabId); - useEffect(() => { - const working = tabs.find((t) => t.id === workingTabId); - if (working && getTabType(working.type)?.commandTarget) { - setLastTerminalTabId(working.id); - } - }, [workingTabId, tabs]); - const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] = - useState(() => { - const v = localStorage.getItem("commandPaletteShortcutEnabled"); - return v !== null ? v === "true" : true; - }); - const [showTabNumbers, setShowTabNumbers] = useState( - () => localStorage.getItem("showTabNumbers") === "true", - ); - const terminalRefs = useRef>>( - new Map(), - ); - // Each shown pane's content element, by pane id. The version bump - // re-renders so the placement effect moves tab nodes into new panes. - const paneElsRef = useRef>(new Map()); - const [, setPaneElsVersion] = useState(0); - - // Stable per-tab DOM nodes, created once per tab, never destroyed while the tab lives. - // We always portal each tab's content into its own node, then move that node between - // the normal-view container and the pane container via vanilla DOM so React's portal - // target never changes (changing the target causes a remount). - const tabNodesRef = useRef>(new Map()); - const normalViewRef = useRef(null); - // The area below the tab bar, where tabs and panes are dropped to split. - const mainAreaRef = useRef(null); - // Tab id the enter animation has already played for, so a re-render while - // the tab stays active (there can be several right after a switch) doesn't - // replay it, only a genuine switch to a different tab should. - const lastAnimatedTabIdRef = useRef(null); - - const getTabNode = useCallback((tabId: string, isTerminal: boolean) => { - if (!tabNodesRef.current.has(tabId)) { - const el = document.createElement("div"); - el.style.position = "absolute"; - el.style.inset = "0"; - el.style.overflow = "hidden"; - if (!isTerminal) el.classList.add("bg-background"); - tabNodesRef.current.set(tabId, el); - } - return tabNodesRef.current.get(tabId)!; - }, []); - - // Portal render order for tab content, kept independent of the tab bar's - // visual order. Reordering tabs in the bar reorders `tabs`, and mapping - // that array directly to portals reshuffles the Suspense-wrapped portal - // children's sibling order in the fiber tree, React then runs its - // Offscreen disconnect/reconnect pass on the ones that moved, which tears - // down and rebuilds every passive effect underneath (including - // react-xtermjs's terminal-creation effect), dropping the live terminal - // and its WebSocket. Portal position doesn't need to track tab order at - // all, so we only ever append new ids and drop closed ones here. - const portalOrderRef = useRef([]); - { - const liveIds = new Set(tabs.map((t) => t.id)); - portalOrderRef.current = portalOrderRef.current.filter((id) => - liveIds.has(id), - ); - const known = new Set(portalOrderRef.current); - for (const tab of tabs) { - if (!known.has(tab.id)) portalOrderRef.current.push(tab.id); - } - } - const tabsByPortalOrder = portalOrderRef.current - .map((id) => tabs.find((t) => t.id === id)) - .filter((t): t is Tab => t !== undefined); - - const onPaneContentRef = useCallback( - (paneId: string, el: HTMLDivElement | null) => { - const els = paneElsRef.current; - if (el) { - if (els.get(paneId) === el) return; - els.set(paneId, el); - } else { - if (!els.has(paneId)) return; - els.delete(paneId); - } - setPaneElsVersion((version) => version + 1); - }, - [], - ); - - // Titles come from the shared rail definitions so they stay translated and - // in step with the rail itself. - const sidebarTitle = (view: RailView): string => railItemLabel(view, t); - - // Double-shift or Ctrl+K opens the command palette. Double-shift alone was - // hard to discover. - useEffect(() => { - if (!commandPaletteShortcutEnabled) return; - const shortcut = createCommandPaletteShortcutMatcher(); - const handleKeyDown = (e: KeyboardEvent) => { - if (!shortcut.matches(e)) return; - if (!isShiftKey(e)) e.preventDefault(); - setCommandPaletteOpen((prev) => !prev); - }; - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("blur", shortcut.reset); - window.addEventListener("compositionstart", shortcut.reset); - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("blur", shortcut.reset); - window.removeEventListener("compositionstart", shortcut.reset); - }; - }, [commandPaletteShortcutEnabled]); - - // Ctrl+Shift+E toggles between the two most recent sidebar panels. - const previousRailViewRef = useRef(null); - const currentRailViewRef = useRef(railView); - useEffect(() => { - if (currentRailViewRef.current !== railView) { - previousRailViewRef.current = currentRailViewRef.current; - currentRailViewRef.current = railView; - } - }, [railView]); - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (!e.ctrlKey || !e.shiftKey || e.altKey || e.code !== "KeyE") return; - e.preventDefault(); - const previous = previousRailViewRef.current; - if (!sidebarOpen) { - setSidebarOpen(true); - return; - } - if (previous && previous !== railView) handleRailClick(previous); - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [railView, sidebarOpen]); - - // Split-screen and tab navigation hotkeys - // Also registered in globalShortcutHandler so xterm can invoke directly - // without going through synthetic DOM events (which are unreliable). - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { - if (e.code === "KeyF") { - e.preventDefault(); - toggleAppFullscreen(); - return; - } - } - - // Ctrl+Shift+\ splits right, Ctrl+Shift+- splits down, Ctrl+Shift+Enter - // zooms the focused pane. - if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { - if (e.code === "Backslash" || e.code === "Minus") { - e.preventDefault(); - splitActionsRef.current?.splitActive( - e.code === "Backslash" ? "right" : "bottom", - ); - return; - } - if (e.code === "Enter" && splitActionsRef.current?.zoomFocused()) { - e.preventDefault(); - return; - } - } - - // Alt+Arrow (Cmd+Option+Arrow on macOS) moves between panes of the active split - const paneDirection = getPaneNavDirection(e); - if (paneDirection && splitActionsRef.current?.navigate(paneDirection)) { - e.preventDefault(); - return; - } - - // Cmd+1..9 on macOS, Alt+1..9 elsewhere: jump directly to the tab at that position - const tabDigit = getTabJumpDigit(e); - if (tabDigit !== null) { - const currentTabs = tabsRef.current.filter( - (tab) => !tab.parentSplitTabId, - ); - const index = tabDigit - 1; - if (index < currentTabs.length) { - e.preventDefault(); - setActiveTabId(currentTabs[index].id); - } - return; - } - - // Ctrl+Shift+] / Ctrl+Shift+[: cycle through open tabs (] = next, [ = previous) - if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { - if (e.code === "BracketRight" || e.code === "BracketLeft") { - e.preventDefault(); - const currentTabs = tabsRef.current.filter( - (tab) => !tab.parentSplitTabId, - ); - if (currentTabs.length < 2) return; - const currentId = activeTabIdRef.current; - const idx = currentTabs.findIndex((t) => t.id === currentId); - const next = - e.code === "BracketRight" - ? (idx + 1) % currentTabs.length - : (idx - 1 + currentTabs.length) % currentTabs.length; - setActiveTabId(currentTabs[next].id); - return; - } - } - }; - - globalShortcutHandler.current = handleKeyDown; - window.addEventListener("keydown", handleKeyDown); - return () => { - globalShortcutHandler.current = null; - window.removeEventListener("keydown", handleKeyDown); - }; - }, []); - - useEffect(() => { - const handler = () => { - const v = localStorage.getItem("commandPaletteShortcutEnabled"); - setCommandPaletteShortcutEnabled(v !== null ? v === "true" : true); - }; - window.addEventListener("commandPaletteShortcutEnabledChanged", handler); - return () => - window.removeEventListener( - "commandPaletteShortcutEnabledChanged", - handler, - ); - }, []); - - useEffect(() => { - const handler = () => { - setShowTabNumbers(localStorage.getItem("showTabNumbers") === "true"); - }; - window.addEventListener("showTabNumbersChanged", handler); - return () => window.removeEventListener("showTabNumbersChanged", handler); - }, []); - - useEffect(() => { - const handle = (event: Event) => { - const manual = - event instanceof CustomEvent && - (event.detail as { manual?: boolean } | undefined)?.manual === true; - // Drop the cached grants so the next user never inherits them. - resetPermissionsCache(); - onLogout(manual ? { manual: true } : undefined); - }; - window.addEventListener("termix:logout", handle); - return () => window.removeEventListener("termix:logout", handle); - }, [onLogout]); - - useEffect(() => { - const handleSessionExpired = () => onLogout(); - dbHealthMonitor.on("session-expired", handleSessionExpired); - return () => dbHealthMonitor.off("session-expired", handleSessionExpired); - }, [onLogout]); - - useEffect(() => { - const activeTab = tabs.find((t) => t.id === activeTabId); - if (!activeTab?.terminalRef) return; - let innerRafId: number; - const outerRafId = requestAnimationFrame(() => { - innerRafId = requestAnimationFrame(() => { - const ref = activeTab.terminalRef?.current; - ref?.fit?.(); - ref?.notifyResize?.(); - ref?.refresh?.(); - }); - }); - return () => { - cancelAnimationFrame(outerRafId); - cancelAnimationFrame(innerRafId); - }; - }, [activeTabId]); - - useEffect(() => { - const handleDegraded = () => { - toast.loading(t("common.connectionDegraded"), { - id: "db-connection-degraded", - duration: Infinity, - dismissible: false, - action: { - label: t("common.reload"), - onClick: () => window.location.reload(), - }, - }); - }; - - const handleRestored = () => { - toast.dismiss("db-connection-degraded"); - toast.success(t("common.backendReconnected"), { duration: 3000 }); - }; - - dbHealthMonitor.on("database-connection-degraded", handleDegraded); - dbHealthMonitor.on("database-connection-degraded-cleared", handleRestored); - - return () => { - dbHealthMonitor.off("database-connection-degraded", handleDegraded); - dbHealthMonitor.off( - "database-connection-degraded-cleared", - handleRestored, - ); - }; - }, [t]); - - useEffect(() => { - getUserPreferences() - .then((prefs) => { - const loginLanguage = consumeLoginLanguage(); - setUserPrefs(prefs); - if (prefs.storageMode === "cloud") { - // Persist the current browser values before overwriting, so any tab can restore them - if (!localStorage.getItem("termix-local-snapshot")) { - const SNAPSHOT_KEYS = [ - "termix-accent", - "termix-font-size", - "termix-ui-font", - "i18nextLng", - "commandPaletteShortcutEnabled", - "showHostTags", - "hostTrayOnClick", - "pinAppRail", - "expandAppRailOnHover", - "disableUpdateCheck", - "confirmTabClose", - "hiddenRailTabs", - ]; - const snap: Record = { - __theme: localStorage.getItem("termix-theme"), - }; - for (const key of SNAPSHOT_KEYS) - snap[key] = localStorage.getItem(key); - localStorage.setItem("termix-local-snapshot", JSON.stringify(snap)); - } - if (prefs.theme) setTheme(prefs.theme as ThemeId); - if (prefs.fontSize) applyFontSize(prefs.fontSize as FontSizeId); - if (prefs.accentColor) { - localStorage.setItem("termix-accent", prefs.accentColor); - applyAccentColor(prefs.accentColor); - } - const preferredLanguage = loginLanguage ?? prefs.language; - if (preferredLanguage && preferredLanguage !== i18n.language) { - void changeAppLanguage(preferredLanguage); - } - if (loginLanguage && loginLanguage !== prefs.language) { - void saveUserPreferences({ language: loginLanguage }); - } - if ( - prefs.commandPaletteEnabled !== null && - prefs.commandPaletteEnabled !== undefined - ) - localStorage.setItem( - "commandPaletteShortcutEnabled", - String(prefs.commandPaletteEnabled), - ); - if (prefs.showHostTags !== null && prefs.showHostTags !== undefined) { - localStorage.setItem("showHostTags", String(prefs.showHostTags)); - window.dispatchEvent(new CustomEvent("showHostTagsChanged")); - } - if ( - prefs.hostTrayOnClick !== null && - prefs.hostTrayOnClick !== undefined - ) - localStorage.setItem( - "hostTrayOnClick", - String(prefs.hostTrayOnClick), - ); - if (prefs.pinAppRail !== null && prefs.pinAppRail !== undefined) { - localStorage.setItem("pinAppRail", String(prefs.pinAppRail)); - window.dispatchEvent(new Event("pinAppRailChanged")); - } - if ( - prefs.expandAppRailOnHover !== null && - prefs.expandAppRailOnHover !== undefined - ) { - localStorage.setItem( - "expandAppRailOnHover", - String(prefs.expandAppRailOnHover), - ); - window.dispatchEvent(new Event("expandAppRailOnHoverChanged")); - } - if ( - prefs.disableUpdateCheck !== null && - prefs.disableUpdateCheck !== undefined - ) - localStorage.setItem( - "disableUpdateCheck", - String(prefs.disableUpdateCheck), - ); - if ( - prefs.confirmTabClose !== null && - prefs.confirmTabClose !== undefined - ) - localStorage.setItem( - "confirmTabClose", - String(prefs.confirmTabClose), - ); - if ( - prefs.hiddenRailTabs !== null && - prefs.hiddenRailTabs !== undefined - ) { - localStorage.setItem("hiddenRailTabs", prefs.hiddenRailTabs); - window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged")); - } - } - }) - .catch(() => {}) - .finally(() => setUserPrefsLoaded(true)); - }, []); - - // Load real hosts from API - const loadHosts = useCallback(async () => { - try { - const [raw, folders] = await Promise.all([ - getSSHHosts(), - getSSHFolders().catch(() => []), - ]); - const converted = raw.map(sshHostToHost); - setAllHosts(converted); - const folderMeta = new Map< - string, - { - color?: string; - icon?: string; - credentialId?: number | null; - sortOrder?: number | null; - localOnly?: boolean; - } - >(); - for (const f of folders) { - folderMeta.set(f.name, { - color: f.color ?? undefined, - icon: f.icon ?? undefined, - credentialId: f.credentialId ?? null, - sortOrder: f.sortOrder ?? null, - localOnly: !!f.localOnly, - }); - } - setRealHostTree(buildHostTree(raw, folderMeta)); - } catch { - // Keep empty state on error - } finally { - setHostsLoading(false); - setHostsLoaded(true); - } - }, []); - - useEffect(() => { - loadHosts(); - }, [loadHosts]); - - useEffect(() => { - const onHostsChanged = () => { - void loadHosts(); - }; - window.addEventListener("termix:hosts-changed", onHostsChanged); - window.addEventListener("ssh-hosts:changed", onHostsChanged); - window.addEventListener("hosts:refresh", onHostsChanged); - return () => { - window.removeEventListener("termix:hosts-changed", onHostsChanged); - window.removeEventListener("ssh-hosts:changed", onHostsChanged); - window.removeEventListener("hosts:refresh", onHostsChanged); - }; - }, [loadHosts]); - - // Keeps the desktop's sync status polled while the app is open; a pass - // that changed data tells the panels to reload. - useSyncStatus(); - - // Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings) - useEffect(() => { - if (allHosts.length === 0) return; - const byId = new Map(allHosts.map((h) => [h.id, h])); - setTabs((prev) => { - let changed = false; - const next = prev.map((t) => { - const fresh = t.host ? byId.get(t.host.id) : undefined; - if (!fresh || fresh === t.host) return t; - // A reload hands back new objects; only replace hosts that changed so - // untouched tabs keep their identity and skip a re-render. - if (JSON.stringify(fresh) === JSON.stringify(t.host)) return t; - changed = true; - return { ...t, host: fresh }; - }); - return changed ? next : prev; - }); - }, [allHosts]); - - function buildWorkspacePayload(): WorkspacePayload { - return buildLayoutPayload({ - tabs, - activeTabId, - sidebar: { - left: { view: railView, open: sidebarOpen, width: sidebarWidth }, - right: { - view: rightRailView, - open: rightRailView !== null, - width: rightSidebarWidth, - }, - }, - }); - } - - async function applyLayout( - payload: WorkspacePayload, - name: string, - ): Promise<{ skipped: string[] }> { - // Tear down the current arrangement the same way an individual tab close does. - for (const tab of [...tabsRef.current]) { - doCloseTab(tab.id); - } - // Tabs reopened here must not be pulled into a pane of the old layout. - suppressPaneTargetRef.current = true; - pendingPaneTargetRef.current = null; - try { - const slotIdToNewTabId = new Map(); - const skippedTabs: string[] = []; - - for (const snapshot of payload.tabs) { - const target = resolveLayoutTabTarget(snapshot, allHostsRef.current); - - if (target.kind === "skip") { - skippedTabs.push(snapshot.hostNameSnapshot || snapshot.label); - continue; - } - - if (target.kind === "singleton") { - const newTabId = openSingletonTab( - snapshot.type, - undefined, - target.host, - snapshotData(snapshot), - ); - slotIdToNewTabId.set(snapshot.slotId, newTabId ?? snapshot.type); - continue; - } - - if (target.kind === "host") { - const newTabId = openTab( - target.host, - snapshot.type, - { - instanceId: createId(), - restoredSessionId: null, - savedLabel: snapshot.customLabel ?? snapshot.label, - }, - { data: snapshotData(snapshot) }, - ); - slotIdToNewTabId.set(snapshot.slotId, newTabId); - } - } - - const splitIdBySlotId = new Map(); - const splitIdByTabId = new Map(); - let firstSplitId: string | null = null; - resolveLayoutSplits(payload, slotIdToNewTabId).forEach((saved, index) => { - const splitTab = makeSplitTab( - createId(), - saved.label || name || splitLabel(index + 1), - saved.split, - ); - if (saved.slotId) splitIdBySlotId.set(saved.slotId, splitTab.id); - for (const pane of listPanes(saved.split.root)) { - if (pane.tabId) splitIdByTabId.set(pane.tabId, splitTab.id); - } - firstSplitId ??= splitTab.id; - setTabs((prev) => addSplitTab(prev, splitTab)); - }); - - // A tab that now sits in a split is shown through its split. - const activeSlot = payload.activeSlotId; - const activeTabInLayout = activeSlot - ? slotIdToNewTabId.get(activeSlot) - : undefined; - const activeId = - (activeSlot ? splitIdBySlotId.get(activeSlot) : undefined) ?? - (activeTabInLayout - ? (splitIdByTabId.get(activeTabInLayout) ?? activeTabInLayout) - : undefined) ?? - firstSplitId ?? - "dashboard"; - setActiveTabId(activeId); - - // Older payloads predate the sidebar field, so leave the docks alone then. - const sidebar = payload.sidebar; - if (sidebar) { - if (sidebar.left.view) { - setRailView( - (sidebar.left.view === "split-screen" - ? "hosts" - : sidebar.left.view) as RailView, - ); - } - setSidebarOpen(sidebar.left.open); - if (sidebar.left.width) setSidebarWidth(sidebar.left.width); - - const right = sidebar.right.open ? sidebar.right.view : null; - setRightRailView( - right && rightDockableIds().includes(right) - ? (right as RailView) - : null, - ); - if (sidebar.right.width) setRightSidebarWidth(sidebar.right.width); - } - - return { skipped: skippedTabs }; - } finally { - suppressPaneTargetRef.current = false; - } - } - - // On load: always read saved tabs from DB so background sessions are preserved across refreshes. - // If reopenTabsOnLogin is on, also restore them as open tabs in the tab bar. - const tabRestoreAttemptedRef = useRef(false); - useEffect(() => { - // Waits for plugins too, so a plugin's saved tabs come back as themselves. - if (!hostsLoaded || !userPrefsLoaded || !pluginsSettled) return; - if (tabRestoreAttemptedRef.current) return; - tabRestoreAttemptedRef.current = true; - - async function loadSavedTabs() { - try { - const [savedTabs, activeSessions] = await Promise.all([ - getOpenTabs(), - getActiveSessions(), - ]); - - if (!Array.isArray(savedTabs) || savedTabs.length === 0) return; - - const sessionByInstanceId = new Map( - (Array.isArray(activeSessions) ? activeSessions : []) - .filter((s) => s.tabInstanceId != null) - .map((s) => [s.tabInstanceId, s]), - ); - - if (userPrefs.reopenTabsOnLogin) { - const hasPersistentTabs = tabs.some((t) => - isPersistentTabType(t.type), - ); - if (!hasPersistentTabs) { - const restoredTabs: Tab[] = []; - for (const saved of savedTabs as OpenTabRecord[]) { - const host = saved.hostId - ? allHosts.find((h) => h.id === String(saved.hostId)) - : undefined; - if (!canRestoreTabType(saved.tabType, host)) continue; - - // Singleton tabs use their type as the stable ID; host-bound tabs get a unique ID - const tabId = host - ? `${host.name}-${saved.tabType}-${Date.now()}-${saved.tabOrder}` - : saved.id; - const liveSession = sessionByInstanceId.get(saved.id); - const restoredSessionId = - liveSession?.sessionId ?? saved.backendSessionId ?? null; - - const isCustomLabel = - host && - saved.label !== host.name && - !/^.+ \(\d+\)$/.test(saved.label); - - restoredTabs.push({ - id: tabId, - instanceId: saved.id, - type: saved.tabType as TabType, - label: saved.label, - customLabel: isCustomLabel ? saved.label : undefined, - host, - openedAt: new Date(saved.createdAt).getTime(), - restoredSessionId, - terminalRef: isSessionTabType(saved.tabType) - ? createRef() - : undefined, - }); - } - - if (restoredTabs.length > 0) { - setTabs((prev) => { - const existingIds = new Set(prev.map((t) => t.id)); - const newTabs = restoredTabs.filter( - (t) => !existingIds.has(t.id), - ); - return newTabs.length > 0 ? [...prev, ...newTabs] : prev; - }); - setActiveTabId(restoredTabs[0].id); - } - // Restored tabs are in the tab bar, not in background records - } - } else { - // Not restoring to tab bar, keep as background records for ConnectionsPanel - setBackgroundTabRecords(savedTabs as OpenTabRecord[]); - } - } catch { - // silently fail - } finally { - setTabsReady(true); - } - } - - loadSavedTabs(); - }, [hostsLoaded, userPrefsLoaded, pluginsSettled]); - - // Plugins that act once the session is back (the workspaces plugin applies - // a default workspace) wait for this. - useEffect(() => { - if (tabsReady) notifyShellReady(); - }, [tabsReady]); - - // Restore split tabs once their child sessions have stable live ids. The - // oldest single-split keys are migrated once into a first split. - useEffect(() => { - if (!tabsReady || paneLayoutRestoredRef.current) return; - paneLayoutRestoredRef.current = true; - - try { - const savedSplitTabs = JSON.parse( - localStorage.getItem("termix_splitTabs") ?? "[]", - ) as unknown; - if (Array.isArray(savedSplitTabs) && savedSplitTabs.length > 0) { - setTabs((prev) => restoreSplitTabs(savedSplitTabs, prev)); - return; - } - - const savedInstanceIds = JSON.parse( - localStorage.getItem("termix_paneInstanceIds") ?? "null", - ) as unknown; - const savedMode = localStorage.getItem("termix_splitMode"); - if (!Array.isArray(savedInstanceIds) || !savedMode) return; - const savedSizes = JSON.parse( - localStorage.getItem("termix_paneSizes") ?? "null", - ) as { rowSizes?: unknown; rowColSizes?: unknown } | null; - const tabIdByInstanceId = new Map( - tabs.map((tab) => [tab.instanceId, tab.id]), - ); - const root = fromLegacyConfig( - savedMode, - savedInstanceIds.map((instanceId) => - typeof instanceId === "string" - ? (tabIdByInstanceId.get(instanceId) ?? null) - : null, - ), - savedSizes?.rowSizes, - savedSizes?.rowColSizes, - ); - if (root && listPanes(root).some((pane) => pane.tabId)) { - const splitTab = makeSplitTab( - createId(), - splitLabel(1), - createSplitState(root), - ); - setTabs((prev) => addSplitTab(prev, splitTab)); - setActiveTabId(splitTab.id); - } - } catch { - // silently fail - } finally { - splitTabsRestoredRef.current = true; - localStorage.removeItem("termix_splitMode"); - localStorage.removeItem("termix_paneInstanceIds"); - localStorage.removeItem("termix_paneSizes"); - } - }, [tabsReady, tabs]); - - // Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB. - const orderSyncTimeoutRef = useRef | null>( - null, - ); - const prevTabOrderRef = useRef(""); - useEffect(() => { - if (!tabsReady) return; - const persistable = tabs.filter((t) => isPersistentTabType(t.type)); - const orderKey = persistable.map((t) => t.instanceId).join(","); - if (orderKey === prevTabOrderRef.current) return; - prevTabOrderRef.current = orderKey; - - if (orderSyncTimeoutRef.current) clearTimeout(orderSyncTimeoutRef.current); - orderSyncTimeoutRef.current = setTimeout(() => { - persistable.forEach((t, i) => { - patchOpenTab(t.instanceId, { tabOrder: i }).catch(() => {}); - }); - }, 500); - - return () => { - if (orderSyncTimeoutRef.current) - clearTimeout(orderSyncTimeoutRef.current); - }; - }, [tabs, tabsReady]); - - // Tells plugins the arrangement changed, e.g. so the workspaces plugin can - // keep its last-session snapshot current. - useEffect(() => { - if (tabsReady) notifyTabsChanged(); - }, [ - tabs, - tabsReady, - railView, - sidebarOpen, - sidebarWidth, - rightRailView, - rightSidebarWidth, - ]); - - // ─── Tab management ────────────────────────────────────────────────────── - - const openTab = useCallback(function openTab( - host: Host, - type: TabType, - restore?: { - instanceId: string; - restoredSessionId: string | null; - savedLabel?: string; - }, - options?: { - data?: Record; - label?: string; - forceNewTab?: boolean; - }, - ) { - if (!restore && !options?.forceNewTab) { - const dataKey = JSON.stringify(options?.data ?? null); - const existing = tabsRef.current.find( - (t) => - t.type === type && - t.host?.id === host.id && - JSON.stringify(t.data ?? null) === dataKey, - ); - if (existing) { - placeOpenedTabRef.current(existing.id); - return existing.id; - } - } - const instanceId = restore?.instanceId ?? createId(); - // Unique per open; a timestamp collides when a workspace opens several - // tabs in the same millisecond. - const tabId = `${type}-${createId()}`; - const openedAt = Date.now(); - const ref = isSessionTabType(type) ? createRef() : undefined; - if (ref) terminalRefs.current.set(tabId, ref); - - let finalLabel = host.name; - const savedLabel = restore?.savedLabel; - // A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label - const isCustomLabel = - savedLabel != null && - savedLabel !== host.name && - !/^.+ \(\d+\)$/.test(savedLabel); - - setTabs((prev) => { - if (isCustomLabel && savedLabel) { - finalLabel = savedLabel; - return [ - ...prev, - { - id: tabId, - instanceId, - type, - label: finalLabel, - customLabel: finalLabel, - host, - openedAt, - terminalRef: ref, - restoredSessionId: restore?.restoredSessionId ?? null, - data: options?.data, - }, - ]; - } - - const same = prev.filter( - (t) => - t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name, - ); - finalLabel = options?.label - ? options.label - : same.length === 0 - ? host.name - : `${host.name} (${same.length + 1})`; - - // Retrofit the first duplicate's label to "(1)" if needed - const next = - same.length === 1 && !/\(\d+\)$/.test(same[0].label) - ? prev.map((t) => - t.id === same[0].id ? { ...t, label: `${host.name} (1)` } : t, - ) - : prev; - - return [ - ...next, - { - id: tabId, - instanceId, - type, - label: finalLabel, - host, - openedAt, - terminalRef: ref, - restoredSessionId: restore?.restoredSessionId ?? null, - data: options?.data, - }, - ]; - }); - placeOpenedTabRef.current(tabId); - - if (isPersistentTabType(type)) { - addOpenTab({ - id: instanceId, - tabType: type, - hostId: host ? parseInt(host.id) : null, - label: finalLabel, - tabOrder: 0, - }).catch(() => {}); - } - - return tabId; - }, []); - - function connectHost( - host: Host, - preferredType?: TabType, - options?: { - data?: Record; - label?: string; - forceNewTab?: boolean; - }, - ) { - const type = resolveHostTabType(host, preferredType); - if (!type) return; - openTab(host, type, undefined, options); - } - - const saveQuickConnectHost = useCallback( - async (tab: Tab, host: Host) => { - try { - const savedHost = await createSSHHost(quickConnectHostToPayload(host)); - await patchOpenTab(tab.instanceId, { hostId: savedHost.id }); - await loadHosts(); - toast.success(t("hosts.hostCreated")); - } catch (error) { - toast.error(t("hosts.failedToSave")); - throw error; - } - }, - [loadHosts, t], - ); - - /** A tab type that opens a fresh tab every time (a local shell). */ - function openMultiInstanceTab( - type: TabType, - options?: { host?: Host; data?: Record; label?: string }, - ): string { - const instanceId = createId(); - const id = `${type}-${instanceId}`; - const titleKey = getTabType(type)?.titleKey; - const title = options?.label ?? (titleKey ? t(titleKey) : type); - setTabs((current) => { - const count = current.filter((tab) => tab.type === type).length; - return [ - ...current, - { - id, - instanceId, - type, - label: - count === 0 || options?.label ? title : `${title} (${count + 1})`, - openedAt: Date.now(), - ...(options?.host ? { host: options.host } : {}), - ...(options?.data !== undefined ? { data: options.data } : {}), - }, - ]; - }); - placeOpenedTabRef.current(id); - return id; - } - - const openSingletonTab = useCallback( - // `host` optionally preselects a host for a singleton plugin tab. - function openSingletonTab( - type: TabType, - pendingEvent?: string, - host?: Host, - data?: Record, - tabLabel?: string, - ): string | undefined { - // A local shell is never a singleton: each open is its own session. - if (getTabType(type)?.multiInstance) { - return openMultiInstanceTab(type, { host, data, label: tabLabel }); - } - if (type === "host-manager" && pendingEvent) { - const request = requestFromLegacyEvent(pendingEvent); - if (request) requestManage(request); - } - // Profile and admin are pages of the one Settings tab now. - if (type === "user-profile" || type === "admin-settings") { - return openSingletonTab( - "settings", - undefined, - undefined, - { - section: - typeof data?.section === "string" - ? data.section - : type === "admin-settings" - ? "admin-general" - : "account", - }, - tabLabel, - ); - } - // Asking for a page again still goes there after the user moved away - if (type === "settings" && data?.section && data.revealAt === undefined) { - data = { ...data, revealAt: Date.now() }; - } - const id = type; - const singletonLabels: Partial> = { - "host-manager": t("nav.manage"), - settings: t("nav.settings"), - plugins: t("nav.plugins"), - }; - // A plugin tab names itself; promoted rail panels reuse the rail's own - // label so the two stay in sync. - const titleKey = getTabType(type)?.titleKey; - const label = - singletonLabels[type] ?? - (titleKey ? t(titleKey) : railItemLabel(type, t)); - setTabs((prev) => { - const existing = prev.find((t) => t.id === id); - if (existing) { - // Refocusing a singleton with a host or data passes it on. - if (!host && data === undefined) return prev; - return prev.map((t) => - t.id === id - ? { - ...t, - ...(host ? { host } : {}), - ...(data !== undefined ? { data } : {}), - } - : t, - ); - } - return [ - ...prev, - { - id, - instanceId: id, - type, - label, - openedAt: Date.now(), - ...(host ? { host } : {}), - ...(data !== undefined ? { data } : {}), - }, - ]; - }); - placeOpenedTabRef.current(id); - if (isPersistentTabType(type)) { - addOpenTab({ - id, - tabType: type, - hostId: null, - label, - tabOrder: 0, - }).catch(() => {}); - } - return id; - }, - [t], - ); - - const openSingletonTabRef = useRef(openSingletonTab); - openSingletonTabRef.current = openSingletonTab; - - useEffect(() => { - const handler = () => openSingletonTabRef.current("plugins"); - window.addEventListener(OPEN_PLUGINS_EVENT, handler); - return () => window.removeEventListener(OPEN_PLUGINS_EVENT, handler); - }, []); - - useEffect(() => { - const handler = (event: Event) => { - const pluginId = (event as CustomEvent).detail; - if (typeof pluginId !== "string") return; - openSingletonTabRef.current("settings", undefined, undefined, { - section: `plugin:${pluginId}`, - revealAt: Date.now(), - }); - }; - window.addEventListener(OPEN_PLUGIN_SETTINGS_EVENT, handler); - return () => - window.removeEventListener(OPEN_PLUGIN_SETTINGS_EVENT, handler); - }, []); - - const getTabCloseLabel = useCallback((tab: Tab) => { - return tab.customLabel || tab.label || tab.host?.name || String(tab.id); - }, []); - - const isActiveConnectionTab = useCallback((tab: Tab) => { - if (!isSessionTabType(tab.type)) return false; - return tab.terminalRef?.current?.isConnected?.() === true; - }, []); - - const hasActiveConnection = useCallback(() => { - return tabsRef.current.some(isActiveConnectionTab); - }, [isActiveConnectionTab]); - - useEffect(() => { - const handleBeforeUnload = (event: BeforeUnloadEvent) => { - if (!hasActiveConnection()) return; - - event.preventDefault(); - event.returnValue = ""; - return ""; - }; - - window.addEventListener("beforeunload", handleBeforeUnload); - return () => { - window.removeEventListener("beforeunload", handleBeforeUnload); - }; - }, [hasActiveConnection]); - - const doCloseTabRef = useRef<(id: string) => void>(() => {}); - doCloseTabRef.current = doCloseTab; - function doCloseTab(id: string) { - const tabToClose = tabs.find((t) => t.id === id); - if (tabToClose?.terminalRef?.current?.disconnect) { - tabToClose.terminalRef.current.disconnect(); - } - if (tabToClose?.instanceId && isPersistentTabType(tabToClose.type)) { - deleteOpenTab(tabToClose.instanceId).catch(() => {}); - } - - terminalRefs.current.delete(id); - if (isSplitTab(tabToClose)) { - unsplit(id); - return; - } - if (id === activeTabId) { - const remaining = tabs.filter( - (tab) => tab.id !== id && !tab.parentSplitTabId, - ); - setActiveTabId( - remaining.length > 0 ? remaining[remaining.length - 1].id : "dashboard", - ); - } - setTabs((prev) => { - // A pane that held the tab stays, empty and focused, ready for the next one. - const next = removeTab(prev, id); - if (next.length === 0) - return [ - { - id: "dashboard", - instanceId: "dashboard", - type: "dashboard", - label: t("nav.dashboard"), - openedAt: Date.now(), - }, - ]; - return next; - }); - } - - const reconnectAllRef = useRef<() => void>(() => {}); - function reconnectAllDisconnected() { - const { reconnected, failed } = reconnectDisconnectedTabs(tabsRef.current); - if (reconnected || !failed) - toast( - t( - reconnected - ? "nav.reconnectingTerminals" - : "nav.noDisconnectedTerminals", - { count: reconnected }, - ), - ); - if (failed) - toast.error(t("nav.reconnectTerminalsFailed", { count: failed })); - } - - reconnectAllRef.current = reconnectAllDisconnected; - - function refreshTab(id: string) { - const tab = tabs.find((t) => t.id === id); - const handle = tab?.terminalRef?.current; - if (!handle) return; - // Session tabs expose one or the other on their handle. - if (handle.reconnect) handle.reconnect(); - else handle.refresh?.(); - } - - async function closeTab(id: string) { - const tab = tabsRef.current.find((t) => t.id === id); - const guard = tab?.terminalRef?.current?.confirmClose; - if (guard && !(await guard())) return; - if (!(await canCloseTab(id))) return; - const confirmEnabled = localStorage.getItem("confirmTabClose") === "true"; - if (tab && confirmEnabled && isActiveConnectionTab(tab)) { - const ask = shellConfirmRef.current; - const ok = ask - ? await ask({ - title: t("nav.confirmCloseHost", { host: getTabCloseLabel(tab) }), - confirmLabel: t("nav.close"), - }) - : true; - if (!ok) return; - } - doCloseTabRef.current(id); - } - - // In a split, closing acts on the focused pane: its session, or the pane - // itself once it is empty. - closeActiveTabRef.current = () => { - const id = activeTabIdRef.current; - const active = tabsRef.current.find((tab) => tab.id === id); - if (isSplitTab(active)) { - const tabId = focusedSessionTabId(); - if (tabId) closeTab(tabId); - else closePaneOf(active.id, active.split.focusedPaneId); - return; - } - if (id !== "dashboard") closeTab(id); - }; - - function renameTab(tabId: string, newLabel: string) { - setTabs((prev) => - prev.map((t) => - t.id === tabId ? { ...t, customLabel: newLabel, label: newLabel } : t, - ), - ); - const tab = tabs.find((t) => t.id === tabId); - if (tab?.instanceId && tab.type !== "split-screen") { - patchOpenTab(tab.instanceId, { label: newLabel }).catch(() => {}); - } - } - - // ─── Split screen ──────────────────────────────────────────────────────── - - const splitLabel = (number: number) => - t("splitScreen.defaultLabel", { number }); - - /** - * Edits a split with a change computed from the last committed state, for - * edits that create panes and so have to know the new pane's id. - */ - function commitSplit( - splitTabId: string, - fn: (state: SplitState) => SplitState | null, - ): SplitState | null { - const current = tabsRef.current.find((tab) => tab.id === splitTabId); - if (!isSplitTab(current)) return null; - const next = fn(current.split); - setTabs((prev) => - updateSplit(prev, splitTabId, (state) => - state === current.split ? next : fn(state), - ), - ); - return next; - } - - function editSplit( - splitTabId: string, - fn: (state: SplitState) => SplitState | null, - ) { - setTabs((prev) => updateSplit(prev, splitTabId, fn)); - } - - function warnSplitFull() { - toast.error(t("splitScreen.maxPanes", { count: MAX_PANES })); - } - - function openSplitTab(state: SplitState): SplitTab { - const number = nextSplitNumber(tabsRef.current, splitLabel); - const splitTab = makeSplitTab(createId(), splitLabel(number), state); - setTabs((prev) => addSplitTab(prev, splitTab)); - setActiveTabId(splitTab.id); - return splitTab; - } - - /** A new split of `tabId` (or of an empty pane) with a fresh pane at `edge`. */ - function startSplit(tabId: string | null, edge: PaneEdge): SplitTab { - const own = createPane(tabId); - const fresh = createPane(); - const leading = edge === "left" || edge === "top"; - const root = createSplitNode( - edge === "left" || edge === "right" ? "row" : "column", - leading ? [fresh, own] : [own, fresh], - ); - return openSplitTab(createSplitState(root, fresh.id)); - } - - function splitPaneOf(splitTabId: string, paneId: string, edge: PaneEdge) { - commitSplit(splitTabId, (state) => { - const result = splitPane(state, paneId, edge); - if (!result) warnSplitFull(); - return result ? result.state : state; - }); - setActiveTabId(splitTabId); - } - - /** - * Splits a tab: a split grows at its focused pane, a tab inside a split - * grows at its own pane, anything else becomes a new split. - */ - function splitTabAt(tabId: string, edge: PaneEdge) { - const current = tabsRef.current; - const tab = current.find((t) => t.id === tabId); - if (!tab) return; - if (isSplitTab(tab)) { - splitPaneOf(tab.id, tab.split.focusedPaneId, edge); - return; - } - const parent = splitTabOf(current, tabId); - const pane = parent ? findPaneByTab(parent.split, tabId) : undefined; - if (parent && pane) { - splitPaneOf(parent.id, pane.id, edge); - return; - } - startSplit(canJoinSplit(tab) ? tab.id : null, edge); - } - - function applyLayoutPreset(presetId: SplitPresetId) { - const active = tabsRef.current.find( - (tab) => tab.id === activeTabIdRef.current, - ); - if (isSplitTab(active)) { - commitSplit(active.id, (state) => applyPreset(state, presetId)); - return; - } - const root = buildPreset( - presetId, - active && canJoinSplit(active) ? [active.id] : [], - ); - openSplitTab(createSplitState(root)); - } - - /** Closes a split tab, sending its tabs back to the tab bar. */ - function unsplit(splitTabId: string) { - const split = tabsRef.current.find((tab) => tab.id === splitTabId); - if (!isSplitTab(split)) return; - const focusedTabId = - findPane(split.split, split.split.focusedPaneId)?.tabId ?? - listPanes(split.split.root).find((pane) => pane.tabId)?.tabId ?? - null; - setTabs((prev) => closeSplitTab(prev, splitTabId)); - if (activeTabIdRef.current === splitTabId) { - const fallback = tabsRef.current.filter( - (tab) => tab.id !== splitTabId && !tab.parentSplitTabId, - ); - setActiveTabId( - focusedTabId ?? fallback[fallback.length - 1]?.id ?? "dashboard", - ); - } - } - - /** Removes a pane; its tab, if any, goes back to the tab bar. */ - function closePaneOf(splitTabId: string, paneId: string) { - const split = tabsRef.current.find((tab) => tab.id === splitTabId); - if (!isSplitTab(split)) return; - if (listPanes(split.split.root).length <= 1) { - unsplit(splitTabId); - return; - } - editSplit(splitTabId, (state) => removePane(state, paneId)); - } - - function handleTabSplitAction(action: TabSplitAction) { - switch (action.kind) { - case "split": - splitTabAt(action.tabId, action.edge); - break; - case "splitActive": - splitTabAt(activeTabIdRef.current, action.edge); - break; - case "preset": - applyLayoutPreset(action.presetId); - break; - case "unsplit": - unsplit(action.splitTabId); - break; - case "addToPane": - setTabs((prev) => - placeTabInPane(prev, action.splitTabId, action.paneId, action.tabId), - ); - setActiveTabId(action.splitTabId); - break; - case "addPane": - commitSplit(action.splitTabId, (state) => { - const result = addPaneAtEdge(state, action.edge, action.tabId); - if (!result) warnSplitFull(); - return result ? result.state : state; - }); - setActiveTabId(action.splitTabId); - break; - } - } - - /** "Open in split" from the host list: aim the next tab, then open it. */ - const splitOpenerRef = useRef< - (target: SplitOpenTarget, open: () => void) => void - >(() => {}); - splitOpenerRef.current = (target, open) => { - if (target.kind === "pane") { - targetPane(target.splitTabId, target.paneId); - } else if (target.kind === "newPane") { - const split = tabsRef.current.find((tab) => tab.id === target.splitTabId); - if (!isSplitTab(split)) return; - const result = addPaneAtEdge(split.split, "right"); - if (!result) { - warnSplitFull(); - return; - } - editSplit(split.id, (state) => - state === split.split ? result.state : state, - ); - setActiveTabId(split.id); - targetPane(split.id, result.paneId, true); - } else { - const active = tabsRef.current.find( - (tab) => tab.id === activeTabIdRef.current, - ); - if (!active || !canJoinSplit(active)) return; - const splitTab = startSplit(active.id, "right"); - targetPane(splitTab.id, splitTab.split.focusedPaneId, true); - } - open(); - }; - - /** A tab or pane dropped on the main area. */ - const splitDropRef = useRef< - (source: SplitDragSource, hover: SplitDropHover) => void - >(() => {}); - splitDropRef.current = (source, hover) => { - if (source.kind === "pane") { - const toPaneId = hover.paneId; - if (!toPaneId) return; - editSplit(source.splitTabId, (state) => - movePane(state, source.paneId, toPaneId, hover.target), - ); - return; - } - const active = tabsRef.current.find( - (tab) => tab.id === activeTabIdRef.current, - ); - if (hover.paneId === null) { - if (hover.target === "center") return; - const other = - active && canJoinSplit(active) && active.id !== source.tabId - ? active.id - : null; - const dragged = createPane(source.tabId); - const rest = createPane(other); - const leading = hover.target === "left" || hover.target === "top"; - const root = createSplitNode( - hover.target === "left" || hover.target === "right" ? "row" : "column", - leading ? [dragged, rest] : [rest, dragged], - ); - openSplitTab(createSplitState(root, dragged.id)); - return; - } - if (!isSplitTab(active)) return; - const paneId = hover.paneId; - if (hover.target === "center") { - setTabs((prev) => placeTabInPane(prev, active.id, paneId, source.tabId)); - return; - } - const edge = hover.target; - commitSplit(active.id, (state) => { - const result = splitPane(state, paneId, edge, source.tabId); - if (!result) warnSplitFull(); - return result ? result.state : state; - }); - }; - - splitActionsRef.current = { - splitActive: (edge) => splitTabAt(activeTabIdRef.current, edge), - navigate: (direction) => { - const active = tabsRef.current.find( - (tab) => tab.id === activeTabIdRef.current, - ); - if (!isSplitTab(active)) return false; - const view = document.querySelector(`[data-split-view="${active.id}"]`); - if (!view) return false; - const rects: Record = {}; - view - .querySelectorAll("[data-split-pane-id]") - .forEach((el) => { - const id = el.dataset.splitPaneId; - if (id) rects[id] = el.getBoundingClientRect(); - }); - const next = neighborPane(active.split.focusedPaneId, direction, rects); - if (next) editSplit(active.id, (state) => focusPane(state, next)); - return true; - }, - zoomFocused: () => { - const active = tabsRef.current.find( - (tab) => tab.id === activeTabIdRef.current, - ); - if (!isSplitTab(active) || listPanes(active.split.root).length < 2) { - return false; - } - editSplit(active.id, (state) => toggleZoom(state, state.focusedPaneId)); - return true; - }, - closeFocusedPane: () => { - const active = tabsRef.current.find( - (tab) => tab.id === activeTabIdRef.current, - ); - if (isSplitTab(active)) { - closePaneOf(active.id, active.split.focusedPaneId); - } - }, - }; - - useEffect(() => { - const disposeOpener = setSplitOpener((target, open) => - splitOpenerRef.current(target, open), - ); - const disposeDrop = setSplitDropHandler((source, hover) => - splitDropRef.current(source, hover), - ); - const activeIsSplit = () => - isSplitTab( - tabsRef.current.find((tab) => tab.id === activeTabIdRef.current), - ); - const entries = [ - registerPaletteEntry({ - id: "core.reconnectDisconnected", - titleKey: "nav.reconnectDisconnectedTerminals", - icon: RotateCcw, - keywords: ["reconnect", "disconnected", "ssh", "all", "network"], - scope: "global", - run: () => reconnectAllRef.current(), - }), - registerPaletteEntry({ - id: "core.split.right", - titleKey: "splitScreen.splitRight", - icon: Columns2, - keywords: ["split", "pane"], - scope: "global", - run: () => splitActionsRef.current?.splitActive("right"), - }), - registerPaletteEntry({ - id: "core.split.down", - titleKey: "splitScreen.splitDown", - icon: Rows2, - keywords: ["split", "pane"], - scope: "global", - run: () => splitActionsRef.current?.splitActive("bottom"), - }), - registerPaletteEntry({ - id: "core.split.zoom", - titleKey: "splitScreen.zoom", - icon: Maximize2, - keywords: ["split", "pane"], - scope: "global", - when: activeIsSplit, - run: () => void splitActionsRef.current?.zoomFocused(), - }), - registerPaletteEntry({ - id: "core.split.closePane", - titleKey: "splitScreen.closePane", - icon: X, - keywords: ["split", "pane"], - scope: "global", - when: activeIsSplit, - run: () => splitActionsRef.current?.closeFocusedPane(), - }), - ]; - return () => { - disposeOpener(); - disposeDrop(); - for (const dispose of entries) dispose(); - }; - }, []); - - // ─── Rail / sidebar ────────────────────────────────────────────────────── - - // Moving a panel to the right dock rather than copying it: two live copies of - // the same panel would fight over the shared editing state. - function openInRightDock(view: RailView) { - setRightRailView(view); - if (railView === view) setSidebarOpen(false); - } - - // Tab bar toggle: reopens whatever was last in the dock, so it behaves like a - // show/hide rather than losing the user's choice each time. - function toggleRightDock() { - if (rightRailView) { - lastRightRailViewRef.current = rightRailView; - setRightRailView(null); - return; - } - const fallback = lastRightRailViewRef.current ?? rightDockableIds()[0]; - if (fallback) setRightRailView(fallback as RailView); - } - - function handleRailClick(view: RailView) { - if (railView === view && sidebarOpen) { - setSidebarOpen(false); - } else { - // A panel lives in one dock at a time, so the left dock reclaims it. - if (rightRailView === view) setRightRailView(null); - setRailView(view); - setSidebarOpen(true); - } - } - - function editHostInManager(host: Host) { - requestManage({ kind: "host", hostId: host.id }); - if (isMobile) setSidebarOpen(false); - } - - const onSidebarMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - setSidebarDragging(true); - const startX = e.clientX; - const startW = sidebarWidth; - // Widths are kept in Normal-size pixels, so a drag is scaled back. - const scale = remScale(); - // One width update per frame, not per mousemove. - let frame = 0; - let clientX = startX; - function onMove(ev: MouseEvent) { - clientX = ev.clientX; - if (frame) return; - frame = requestAnimationFrame(() => { - frame = 0; - setSidebarWidth( - Math.max(160, Math.min(480, startW + (clientX - startX) / scale)), - ); - }); - } - function onUp() { - cancelAnimationFrame(frame); - setSidebarWidth( - Math.max(160, Math.min(480, startW + (clientX - startX) / scale)), - ); - setSidebarDragging(false); - window.removeEventListener("mousemove", onMove); - window.removeEventListener("mouseup", onUp); - } - window.addEventListener("mousemove", onMove); - window.addEventListener("mouseup", onUp); - }, - [sidebarWidth], - ); - - // Same drag, mirrored: the right dock grows as the pointer moves left. - const onRightSidebarMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - setRightSidebarDragging(true); - const startX = e.clientX; - const startW = rightSidebarWidth; - const scale = remScale(); - let frame = 0; - let clientX = startX; - function onMove(ev: MouseEvent) { - clientX = ev.clientX; - if (frame) return; - frame = requestAnimationFrame(() => { - frame = 0; - setRightSidebarWidth( - Math.max(160, Math.min(480, startW - (clientX - startX) / scale)), - ); - }); - } - function onUp() { - cancelAnimationFrame(frame); - setRightSidebarWidth( - Math.max(160, Math.min(480, startW - (clientX - startX) / scale)), - ); - setRightSidebarDragging(false); - window.removeEventListener("mousemove", onMove); - window.removeEventListener("mouseup", onUp); - } - window.addEventListener("mousemove", onMove); - window.addEventListener("mouseup", onUp); - }, - [rightSidebarWidth], - ); - - // Resize all terminals in panes + active terminal when split mode or sidebar changes - const resizeAllTerminals = useCallback(() => { - const id = requestAnimationFrame(() => { - tabs.forEach((tab) => { - if (!tab.terminalRef) return; - const ref = tab.terminalRef.current; - ref?.fit?.(); - ref?.notifyResize?.(); - }); - }); - return id; - }, [tabs]); - - const activeTab = tabs.find((tab) => tab.id === activeTabId); - const activeSplit = isSplitTab(activeTab) ? activeTab : null; - // The pane each on-screen tab sits in, for the active split only. - const paneIdByTabId = new Map(); - if (activeSplit) { - for (const pane of shownPanes(activeSplit.split, isMobile)) { - if (pane.tabId) paneIdByTabId.set(pane.tabId, pane.id); - } - } - // Changes when panes appear, move or change tab, but not on a divider drag, - // which fits the terminals itself once it ends. - const splitShapeKey = activeSplit - ? `${activeSplit.id}|${activeSplit.split.zoomedPaneId ?? ""}|${[ - ...paneIdByTabId.entries(), - ].join(",")}|${listPanes(activeSplit.split.root).length}` - : ""; - - useEffect(() => { - // Refitting every terminal on each drag frame is what made sidebar drags - // stutter; fit once when the drag ends instead. - if (sidebarDragging || rightSidebarDragging) return; - const id = resizeAllTerminals(); - return () => cancelAnimationFrame(id); - }, [ - splitShapeKey, - sidebarWidth, - sidebarOpen, - rightSidebarWidth, - rightRailView, - sidebarDragging, - rightSidebarDragging, - ]); - - // A tab inside a split is never shown on its own: activating one (tab jump, - // the connections panel, an existing session) shows its split instead, - // focused on its pane. Also falls back when the active tab is gone. - useLayoutEffect(() => { - if (!activeTab) { - if (!tabsReady) return; - const topLevel = tabs.filter((tab) => !tab.parentSplitTabId); - setActiveTabId(topLevel[topLevel.length - 1]?.id ?? "dashboard"); - return; - } - if (!activeTab.parentSplitTabId) return; - const parent = splitTabOf(tabs, activeTab.id); - if (!parent) return; - const childId = activeTab.id; - setTabs((prev) => - updateSplit(prev, parent.id, (state) => { - const pane = findPaneByTab(state, childId); - return pane ? focusPane(state, pane.id) : state; - }), - ); - setActiveTabId(parent.id); - }, [activeTab, tabs, tabsReady]); - - // Moving focus to a pane moves keyboard focus into its session, unless the - // user is in a menu or already typing in that pane. - const focusedPaneId = activeSplit?.split.focusedPaneId ?? null; - const focusedPaneTabId = focusedPaneId - ? (findPane(activeSplit!.split, focusedPaneId)?.tabId ?? null) - : null; - useEffect(() => { - if (!focusedPaneId || !focusedPaneTabId) return; - let attempts = 0; - let timer: ReturnType | undefined; - const attempt = () => { - const focused = document.activeElement as HTMLElement | null; - const paneEl = paneElsRef.current.get(focusedPaneId); - if (focused && paneEl?.contains(focused)) return; - if ( - focused?.closest?.( - '[data-split-pane-header], [role="menu"], [role="dialog"]', - ) - ) { - return; - } - const tab = tabsRef.current.find((t) => t.id === focusedPaneTabId); - const handle = tab?.terminalRef?.current as TabHandle | null | undefined; - if (handle?.focus) { - handle.focus(); - return; - } - // A session opened into the pane a moment ago may not have mounted yet. - if (attempts++ < 10) timer = setTimeout(attempt, 100); - }; - const frame = requestAnimationFrame(attempt); - return () => { - cancelAnimationFrame(frame); - if (timer) clearTimeout(timer); - }; - }, [focusedPaneId, focusedPaneTabId]); - - useEffect(() => { - publishSplitTargets({ - splits: summarizeSplits(tabs, MAX_PANES), - canStartSplit: canJoinSplit(activeTab) && !activeTab?.parentSplitTabId, - }); - }, [tabs, activeTab]); - - // Move each tab's stable DOM node to the right container (pane or normal-view). - // This is vanilla DOM so React's portal target never changes. Changing the portal - // target causes a remount which is exactly what we're trying to avoid. - // useLayoutEffect (not useEffect) so visibility/display are corrected before - // the browser paints, otherwise the previous tab's node can flash on screen - // for a frame while still visible. - useLayoutEffect(() => { - const normalView = normalViewRef.current; - if (!normalView) return; - - const tabIds = new Set(tabs.map((t) => t.id)); - - // Remove nodes for closed tabs - for (const [id, node] of tabNodesRef.current) { - if (!tabIds.has(id)) { - node.remove(); - tabNodesRef.current.delete(id); - } - } - - for (const tab of tabs) { - const isTerminal = !!getTabType(tab.type)?.ownBackground; - const node = getTabNode(tab.id, isTerminal); - const paneId = paneIdByTabId.get(tab.id); - const paneEl = paneId ? paneElsRef.current.get(paneId) : undefined; - const inPane = !!paneId; - const activeInline = !inPane && tab.id === activeTabId; - - if (inPane && paneEl) { - if (node.parentElement !== paneEl) paneEl.appendChild(node); - node.style.visibility = "visible"; - node.style.pointerEvents = "auto"; - node.style.display = ""; - node.style.zIndex = ""; - node.style.contentVisibility = ""; - } else { - if (node.parentElement !== normalView) normalView.appendChild(node); - if (isTerminal) { - node.style.display = ""; - node.style.visibility = activeInline ? "visible" : "hidden"; - node.style.pointerEvents = activeInline ? "auto" : "none"; - node.style.zIndex = activeInline ? "1" : "0"; - // xterm renders to a ; visibility:hidden alone can still let - // a stale composited frame flash through for a tick when switching - // to/from a non-terminal tab. content-visibility:hidden fully skips - // painting the subtree while keeping its layout box intact, so - // fitAddon.fit() still sees correct dimensions once it's shown again. - node.style.contentVisibility = activeInline ? "" : "hidden"; - } else { - // Plays a quick opacity fade-in on the tab that just became active. - // Only play it on an actual switch into this tab -- gating on the - // class alone replayed the animation on every unrelated re-render - // that happened while the tab was still active (any render after - // animationend had stripped the class), which looked like the - // panel kept growing for up to a second after switching. - if (activeInline && lastAnimatedTabIdRef.current !== tab.id) { - lastAnimatedTabIdRef.current = tab.id; - node.classList.remove("motion-workspace-enter"); - // Force a reflow so re-adding the class restarts the animation - // instead of no-oping because it was already removed this tick. - void node.offsetWidth; - node.classList.add("motion-workspace-enter"); - node.addEventListener( - "animationend", - () => node.classList.remove("motion-workspace-enter"), - { once: true }, - ); - } else if (!activeInline) { - node.classList.remove("motion-workspace-enter"); - if (lastAnimatedTabIdRef.current === tab.id) { - lastAnimatedTabIdRef.current = null; - } - } - node.style.visibility = ""; - node.style.pointerEvents = ""; - node.style.zIndex = activeInline ? "2" : ""; - node.style.display = activeInline ? "" : "none"; - } - } - } - }); - - const terminalTabs = tabs.filter((t) => getTabType(t.type)?.commandTarget); - const topLevelTabs = tabs.filter((tab) => !tab.parentSplitTabId); - - function reorderTopLevelTabs(reordered: Tab[]) { - setTabs((prev) => [ - ...reordered, - ...prev.filter((tab) => tab.parentSplitTabId), - ]); - } - - // What command-target panels act on. Falls back to the remembered - // terminal when the active tab isn't one, and drops it once it's closed. - const targetTerminalTabId = terminalTabs.some((t) => t.id === workingTabId) - ? workingTabId - : terminalTabs.some((t) => t.id === lastTerminalTabId) - ? lastTerminalTabId - : ""; - - /** - * Sidebar panel content, shared between the desktop sidebar, the mobile - * sheet and the right dock. Takes the view rather than reading railView so - * both docks can render from the same code. - * - * `owned` marks the dock responsible for the panels that stay mounted while - * hidden (hosts, credentials, fleets). Only one dock may own them, otherwise - * two live instances fight over the shared editing state. - */ - // The param deliberately shadows the outer railView so the body reads the - // same whichever dock is rendering. - - /** - * What plugins may ask of the shell. Rebuilt every render so it always - * closes over current state, and published to the plugin runtime. - */ - const shellCallbacksImpl: TabShellCallbacks = { - openTab: (host, type, options) => { - if (!host || getTabType(type)?.singleton) { - openSingletonTab( - type, - undefined, - host ?? undefined, - options?.data, - options?.label, - ); - return; - } - openTab(host, type, undefined, options); - }, - openSingletonTab: (type, options) => - openSingletonTab(type, undefined, undefined, options?.data), - connectHost: (host, type) => connectHost(host, type as TabType), - closeTab: (tabId) => closeTab(tabId), - renameTab: (tabId, label) => renameTab(tabId, label), - - openRailView: (id) => { - setRailView(id as RailView); - setSidebarOpen(true); - }, - closeRailView: (id) => { - setRailView((prev) => (prev === id ? "hosts" : prev)); - setRightRailView((prev) => (prev === id ? null : prev)); - }, - openHostEditor: (draft) => - requestManage({ kind: "host", hostId: null, draft }), - saveQuickConnect: saveQuickConnectHost, - confirm: (options) => - shellConfirmRef.current - ? shellConfirmRef.current(options) - : Promise.resolve(false), - }; - - // Tabs get one stable bag that forwards to the latest callbacks, so a shell - // render does not re-render every open tab. - const shellImplRef = useRef(shellCallbacksImpl); - useLayoutEffect(() => { - shellImplRef.current = shellCallbacksImpl; - }); - const shellCallbacks = useMemo( - () => ({ - openTab: (...args) => shellImplRef.current.openTab(...args), - openSingletonTab: (...args) => - shellImplRef.current.openSingletonTab(...args), - connectHost: (...args) => shellImplRef.current.connectHost(...args), - closeTab: (...args) => shellImplRef.current.closeTab(...args), - renameTab: (...args) => shellImplRef.current.renameTab(...args), - openRailView: (...args) => shellImplRef.current.openRailView(...args), - closeRailView: (...args) => shellImplRef.current.closeRailView(...args), - openHostEditor: (...args) => - shellImplRef.current.openHostEditor?.(...args), - saveQuickConnect: (...args) => - shellImplRef.current.saveQuickConnect!(...args), - confirm: (...args) => shellImplRef.current.confirm!(...args), - }), - [], - ); - - // Panels close the sidebar on mobile after opening something, the same as - // the hosts panel does. - const panelShell = useMemo( - () => ({ - ...shellCallbacks, - openTab: (...args) => { - shellCallbacks.openTab(...args); - if (isMobile) setSidebarOpen(false); - }, - }), - [shellCallbacks, isMobile], - ); - - useEffect(() => { - setShellCallbacks(shellCallbacks); - setShellLayoutProvider({ - getLayout: () => (tabsReady ? buildWorkspacePayload() : null), - applyLayout: (layout, options) => - applyLayout(layout as WorkspacePayload, options?.name ?? ""), - }); - }); - - useEffect(() => { - setShellHosts(allHosts, hostsLoaded); - }, [allHosts, hostsLoaded]); - - // Every panel gets its own surface, so its inline views and confirms stay - // inside it. Only the left dock widens for them. - const panelScope = ( - view: string, - owned: boolean, - shown: boolean, - children: React.ReactNode, - className = "", - ) => ( - - {children} - - ); - - const renderSidebarPanels = (railView: RailView, owned = true) => ( - }> -
- {owned && ( - <> - {panelScope( - "hosts", - owned, - railView === "hosts", - { - connectHost(host, type, options); - if (isMobile) setSidebarOpen(false); - }} - onEditHost={editHostInManager} - onCloseSidebar={() => setSidebarOpen(false)} - hostTree={realHostTree ?? undefined} - loading={hostsLoading} - onEditingChange={panelEditingSetter("hosts")} - active={railView === "hosts"} - />, - )} - {panelScope( - "credentials", - owned, - railView === "credentials", - , - )} - - )} - - {railView === "quick-connect" && - panelScope( - "quick-connect", - owned, - true, - { - openTab(host, type); - if (isMobile) setSidebarOpen(false); - }} - />, - )} - - {registeredPanels.map((panel) => { - const shown = railView === panel.id; - // A kept-mounted panel lives in the owning dock only, so two live - // copies never fight over the same state. - if (panel.keepMounted ? !owned : !shown) return null; - const Panel = panel.component; - return panelScope( - panel.id, - owned, - shown, -
- tab.id === targetTerminalTabId, - )} - active={shown} - shell={panelShell} - setEditing={ - owned ? panelEditingSetter(panel.id) : () => undefined - } - activeTabType={ - tabs.find((tab) => tab.id === activeTabId)?.type ?? undefined - } - placement={owned ? "left" : "right"} - /> -
, - ); - })} - - {!isCoreRailView(railView) && !getPanel(railView) && ( - - )} - - {railView === "connections" && - panelScope( - "connections", - owned, - true, -
- { - setActiveTabId(tabId); - if (isMobile) setSidebarOpen(false); - }} - onCloseTab={closeTab} - onReopenTab={(record, restoredSessionId) => { - const host = record.hostId - ? allHosts.find((h) => h.id === String(record.hostId)) - : undefined; - if (!host && !getTabType(record.tabType)?.hostless) return; - setBackgroundTabRecords((prev) => - prev.filter((r) => r.id !== record.id), - ); - if (host) { - const effectiveSessionId = - restoredSessionId ?? record.backendSessionId ?? null; - openTab(host, record.tabType as TabType, { - instanceId: record.id, - restoredSessionId: effectiveSessionId, - savedLabel: record.label, - }); - } else { - openSingletonTab(record.tabType as TabType); - } - if (isMobile) setSidebarOpen(false); - }} - onForgetBackground={(recordId) => { - setBackgroundTabRecords((prev) => - prev.filter((r) => r.id !== recordId), - ); - }} - onRenameTab={renameTab} - onReorderTabs={setTabs} - /> -
, - )} - - {railView === "sync" && - panelScope( - "sync", - owned, - true, -
- -
, - )} -
-
- ); - - function splitViewActions(splitTabId: string): SplitViewActions { - return { - focusPane: (paneId) => - editSplit(splitTabId, (state) => focusPane(state, paneId)), - resize: (nodeId, sizes) => - editSplit(splitTabId, (state) => resizeSplit(state, nodeId, sizes)), - equalize: (nodeId) => - editSplit(splitTabId, (state) => equalizeSplit(state, nodeId)), - splitPane: (paneId, edge) => splitPaneOf(splitTabId, paneId, edge), - closePane: (paneId) => closePaneOf(splitTabId, paneId), - closeSession: (tabId) => closeTab(tabId), - moveToTab: (paneId) => closePaneOf(splitTabId, paneId), - showInPane: (paneId, tabId) => { - if (tabId) { - setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId)); - } else { - editSplit(splitTabId, (state) => assignTab(state, paneId, null)); - } - }, - swapPanes: (firstId, secondId) => - editSplit(splitTabId, (state) => swapPanes(state, firstId, secondId)), - toggleZoom: (paneId) => - editSplit(splitTabId, (state) => toggleZoom(state, paneId)), - resizeEnd: () => resizeAllTerminals(), - }; - } - - // Picking a host for a pane always starts a new session, so it never pulls - // a session out of the tab bar or another split. - const pickerShell: TabShellCallbacks = { - ...shellCallbacks, - openTab: (host, type, options) => - shellCallbacks.openTab(host, type, { ...options, forceNewTab: true }), - }; - - function renderEmptyPane( - splitTabId: string, - paneId: string, - paneIndex: number, - ) { - return ( - !tab.parentSplitTabId && canJoinSplit(tab), - )} - hosts={allHosts} - newTabOptions={tabTypes - .filter((def) => def.multiInstance) - .map((def) => ({ - type: def.id, - label: def.titleKey ? t(def.titleKey) : def.id, - icon: def.icon, - }))} - onPickTab={(tabId) => - setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId)) - } - onPickHost={(host, pick: PickHostTarget) => { - targetPane(splitTabId, paneId); - if ("item" in pick) { - const item = pick.action - .items?.(host) - .find((candidate) => candidate.id === pick.item.id); - item?.run(host, pickerShell); - } else { - runHostAction(pick.action, host, pickerShell); - } - }} - onOpenType={(type) => { - targetPane(splitTabId, paneId); - openMultiInstanceTab(type); - }} - onClosePane={() => closePaneOf(splitTabId, paneId)} - /> - ); - } - - const sidebarPanelContent = renderSidebarPanels(railView); - - const settingsTabProps = { - username, - isAdmin: showAdminUI, - onLogout, - userPrefs, - onPrefsChange: (updates: Partial) => - setUserPrefs((current) => ({ ...current, ...updates })), - onOpenHostTab: (host: Host) => connectHost(host), - }; - - const leftEditing: EditingWidth = (() => { - const values = [panelEditing[railView], scopeEditing[railView]]; - if (values.includes("wide")) return "wide"; - return values.some(Boolean); - })(); - - const dockProps = (view: RailView) => ({ - title: sidebarTitle(view), - onOpenAsTab: isMobile - ? undefined - : view === "hosts" || view === "credentials" - ? () => requestManage({ kind: "browse", mode: view as ManageMode }) - : promotableIds().includes(view) - ? () => openSingletonTab(view as TabType) - : undefined, - }); - - return ( - -
-
- {/* Skinny icon rail, desktop only */} - openSingletonTab("settings")} - onOpenNavigationSettings={() => - openSingletonTab("settings", undefined, undefined, { - section: "appearance", - reveal: "settings-navigation", - revealAt: Date.now(), - }) - } - onOpenPalette={() => setCommandPaletteOpen(true)} - onLogout={onLogout} - /> - - {/* Desktop: inline resizable sidebar */} - {!isMobile && ( - setSidebarOpen(false)} - onMoveToRightDock={ - rightDockableIds().includes(railView) - ? () => openInRightDock(railView) - : undefined - } - onResetWidth={() => setSidebarWidth(DOCK_DEFAULT_WIDTH)} - > - {sidebarPanelContent} - - )} - - {/* Mobile: sidebar as overlay sheet */} - {isMobile && ( - - - setSidebarOpen(false)} - > - {sidebarPanelContent} - - - - )} - - {/* Main content area */} -
- {!isMobile && !sidebarOpen && ( - setSidebarOpen(true)} /> - )} - - -
- -
- {activeSplit && ( -
- - renderEmptyPane(activeSplit.id, paneId, paneIndex) - } - /> -
- )} - - - {/* Normal-view container. Tab nodes are appended here (or to pane elements) - by the DOM-placement effect above. React portals each tab's content - into its stable per-tab node so the component is never remounted. - Hidden while a split is active. */} -
- {tabsByPortalOrder.map((tab) => { - const tabNode = getTabNode( - tab.id, - !!getTabType(tab.type)?.ownBackground, - ); - const paneId = paneIdByTabId.get(tab.id); - const inPane = !!paneId; - const activeInline = !inPane && tab.id === activeTabId; - const isFocusedPane = inPane - ? paneId === focusedPaneId - : activeInline; - return createPortal( - closeTab(tab.id) - } - > - {renderTabContent(tab, { - shell: shellCallbacks, - settings: settingsTabProps, - panelTargetTab: terminalTabs.find( - (t) => t.id === targetTerminalTabId, - ), - isVisible: inPane || activeInline, - isFocusedPane, - inSplit: inPane, - })} - , - tabNode, - tab.id, - ); - })} -
-
-
-
- - {/* Bottom nav bar, mobile only. The desktop app keeps the rail. */} - {!isElectron() && ( - openSingletonTab(type)} - onOpenSettings={() => openSingletonTab("settings")} - onOpenPalette={() => setCommandPaletteOpen(true)} - onLogout={() => onLogout({ manual: true })} - /> - )} -
- - {/* Right dock, desktop only, holds a second reference panel */} - {!isMobile && rightRailView && ( - setRightRailView(null)} - onResetWidth={() => setRightSidebarWidth(DOCK_DEFAULT_WIDTH)} - > - {renderSidebarPanels(rightRailView, false)} - - )} - - {/* Plugin panels pinned to the right edge, sized by the plugin */} - -
-
- - - - {commandPaletteOpen && ( - - handleRailClick(view as RailView)} - onEditHost={editHostInManager} - onOpenSettings={(section) => - openSingletonTab( - "settings", - undefined, - undefined, - section ? { section } : undefined, - ) - } - onOpenTab={(type, label, pendingEvent) => { - if ( - [ - "dashboard", - "host-manager", - "user-profile", - "admin-settings", - "plugins", - ].includes(type) - ) { - openSingletonTab(type, pendingEvent); - } else if (getTabType(type)?.multiInstance) { - openMultiInstanceTab(type); - } else if (getTabType(type)?.singleton) { - // A singleton plugin tab, optionally preselecting a host. - openSingletonTab( - type, - undefined, - label ? allHosts.find((h) => h.name === label) : undefined, - ); - } else if (label) { - const host = allHosts.find((h) => h.name === label); - if (host) openTab(host, type); - } - }} - /> - - )} - - {isElectron() && } -
- ); + return ; } diff --git a/src/ui/api/look-api.ts b/src/ui/api/look-api.ts new file mode 100644 index 000000000..a2ae0a202 --- /dev/null +++ b/src/ui/api/look-api.ts @@ -0,0 +1,48 @@ +import { authApi } from "@/main-axios"; + +/** A layout or theme plugin the server found but could not load. */ +export interface UnavailableLook { + id: string; + version: string | null; + layouts: string[]; + themes: string[]; + error: string; +} + +/** Everything the browser picks the layout and theme from. */ +export interface LookResponse { + layoutId: string | null; + themeId: string | null; + /** Admin defaults. */ + defaults: { layout: string | null; theme: string | null }; + /** The default-look plugin's layout and theme. */ + defaultLook: { layout: string | null; theme: string | null }; + /** TERMIX_SAFE_LAYOUT is set on the server. */ + safeLayout: boolean; + unavailable: UnavailableLook[]; +} + +export async function getLook(): Promise { + const response = await authApi.get("/user-preferences/look"); + return response.data; +} + +/** The user's own layout and theme; null follows the default. */ +export async function saveUserLook(look: { + layoutId?: string | null; + themeId?: string | null; +}): Promise { + await authApi.put("/user-preferences", look); +} + +/** Admin only. Null clears a default. */ +export async function saveDefaultLook(defaults: { + layout?: string | null; + theme?: string | null; +}): Promise<{ layout: string | null; theme: string | null }> { + const response = await authApi.put( + "/user-preferences/look/defaults", + defaults, + ); + return response.data; +} diff --git a/src/ui/api/open-tabs-api.ts b/src/ui/api/open-tabs-api.ts index e1757bd27..89efaa803 100644 --- a/src/ui/api/open-tabs-api.ts +++ b/src/ui/api/open-tabs-api.ts @@ -116,6 +116,10 @@ export interface UserPreferences { compactHostView?: boolean | null; statusColorScheme?: string | null; customKeybindings?: string | null; + /** Layout this user chose; null follows the admin default. */ + layoutId?: string | null; + /** Theme this user chose; null follows the admin default. */ + themeId?: string | null; } export function parseCustomKeybindings( diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index 4d46e3629..5b4575645 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -78,6 +78,22 @@ export interface PluginViewContribution { icon?: string; } +export interface PluginLayoutContribution { + id: string; + label: string; + description?: string; + defaultTheme?: string; + default?: boolean; +} + +export interface PluginThemeContribution { + id: string; + label: string; + mode: "light" | "dark"; + css: string; + swatch?: { background: string; panel: string; text: string }; +} + export interface PluginContributions { tabs?: PluginTabContribution[]; panels?: PluginViewContribution[]; @@ -87,6 +103,8 @@ export interface PluginContributions { settings?: PluginSettingsContribution; permissions?: { name: string; titleKey: string; descriptionKey: string }[]; uiPresets?: PluginUiPresets; + layouts?: PluginLayoutContribution[]; + themes?: PluginThemeContribution[]; } export { @@ -236,6 +254,10 @@ export interface PluginSummary { assetVersion?: string | null; /** "en" plus the xx_YY names of shipped translations. */ locales?: string[]; + /** Cache key for the theme CSS files; null without themes. */ + themeVersion?: string | null; + /** The app is drawn with this plugin by default; it cannot be uninstalled. */ + defaultLook?: boolean; tier?: string; source?: string; diff --git a/src/ui/components/theme-provider.tsx b/src/ui/components/theme-provider.tsx index 441681b3f..3bd5a3c48 100644 --- a/src/ui/components/theme-provider.tsx +++ b/src/ui/components/theme-provider.tsx @@ -1,6 +1,8 @@ /* eslint-disable react-refresh/only-export-components */ import { createContext, useContext, useEffect, useState } from "react"; import type { ThemeId } from "@/types/ui-types"; +import { SYSTEM_THEME, getTheme, useThemes } from "@/look/theme-registry"; +import { applyThemeCss } from "@/look/theme-css"; type ThemeProviderProps = { children: React.ReactNode; @@ -10,22 +12,11 @@ type ThemeProviderProps = { type ThemeProviderState = { theme: ThemeId; - setTheme: (theme: ThemeId) => void; + /** Pass persist false to show a theme without keeping it in this browser. */ + setTheme: (theme: ThemeId, persist?: boolean) => void; setThemePreview: (theme: ThemeId | null) => void; }; -const ALL_THEME_CLASSES = [ - "light", - "dark", - "dracula", - "catppuccin", - "nord", - "solarized", - "tokyo-night", - "one-dark", - "gruvbox", -]; - const initialState: ThemeProviderState = { theme: "dark", setTheme: () => null, @@ -34,6 +25,9 @@ const initialState: ThemeProviderState = { const ThemeProviderContext = createContext(initialState); +/** The class last put on , in case its theme has since gone away. */ +let appliedClass: string | null = null; + export function ThemeProvider({ children, defaultTheme = "dark", @@ -44,29 +38,31 @@ export function ThemeProvider({ () => (localStorage.getItem(storageKey) as ThemeId) || defaultTheme, ); const [previewTheme, setPreviewTheme] = useState(null); + // Plugin themes arrive after login; the class and CSS follow them. + const themes = useThemes(); const activeTheme = previewTheme ?? theme; useEffect(() => { const root = window.document.documentElement; - root.classList.remove(...ALL_THEME_CLASSES); + root.classList.remove(...themes.map((entry) => entry.id)); + if (appliedClass) root.classList.remove(appliedClass); - if (activeTheme === "system") { - const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") - .matches - ? "dark" - : "light"; - root.classList.add(systemTheme); - return; - } - - root.classList.add(activeTheme); - }, [activeTheme]); + const applied = + activeTheme === SYSTEM_THEME + ? window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + : activeTheme; + root.classList.add(applied); + appliedClass = applied; + applyThemeCss(applied, getTheme(applied)); + }, [activeTheme, themes]); const value = { theme, - setTheme: (theme: ThemeId) => { - localStorage.setItem(storageKey, theme); + setTheme: (theme: ThemeId, persist = true) => { + if (persist) localStorage.setItem(storageKey, theme); setTheme(theme); }, setThemePreview: (preview: ThemeId | null) => { diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 423ac7b85..b4ee07f37 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1770,6 +1770,54 @@ } } }, + "looks": { + "builtinLayout": "Classic", + "layoutLabel": "Layout", + "layoutHint": "How the whole app is laid out. Themes and layouts come from plugins.", + "defaultsLabel": "Default for everyone", + "defaultsHint": "What people see until they choose their own layout or theme.", + "followDefault": "Use the default", + "defaultSaved": "Default look saved", + "saveFailed": "Could not save the look", + "repair": { + "title": "This layout could not be shown", + "safeTitle": "Safe mode", + "layout": "Layout", + "plugin": "Plugin", + "reason": "Reason", + "error": "Error", + "noError": "No error message.", + "copy": "Copy", + "copied": "Copied", + "copyFailed": "Could not copy the details", + "retry": "Try again", + "reset": "Reset to the default look", + "resetEveryone": "Reset the default for everyone", + "settings": "Open Settings", + "back": "Back", + "update": "Update {{name}} to {{version}}", + "disable": "Turn off {{name}}", + "install": "Find layouts and themes", + "installTitle": "Layouts and themes", + "installButton": "Install", + "nothingToInstall": "No other layouts or themes are available right now.", + "registryUnreachable": "The plugin registry could not be reached.", + "askAdmin": "Ask an admin to update or fix this layout. You can switch to another layout or theme for yourself below.", + "switchLayout": "Switch layout", + "switchTheme": "Switch theme", + "saveFailed": "Could not save your choice", + "reasons": { + "safe-url": "The address asked for safe mode. Pick a layout below, or remove ?safe-layout=1 from the address.", + "safe-env": "The server is started with TERMIX_SAFE_LAYOUT, so everyone sees this screen until it is removed.", + "missing": "No installed plugin provides this layout.", + "disabled": "The plugin that provides this layout is turned off.", + "blocked": "The plugin that provides this layout is waiting on something it needs.", + "incompatible": "The plugin that provides this layout does not work with this version of Termix.", + "failed": "The layout failed to start.", + "crashed": "The layout crashed twice while drawing the app." + } + } + }, "plugins": { "capabilities": { "credentials:read": { @@ -1887,6 +1935,14 @@ "ui:surface": { "title": "Add its own screens", "consequence": "Tabs, panels and settings you can hide later." + }, + "ui:layout": { + "title": "Replace the whole app layout", + "consequence": "When chosen, its code draws every screen, including your terminals and settings." + }, + "ui:theme": { + "title": "Add color themes", + "consequence": "Only colors and styles. No code runs for a theme." } }, "runtime": { @@ -2016,6 +2072,7 @@ "options": "Could not save options for {{name}}: {{error}}", "deleteData": "Could not delete data for {{name}}: {{error}}", "managedByServer": "This desktop is linked to a server, which decides its plugins.", + "defaultLook": "{{name}} is the default look, so it can be turned off or swapped but not uninstalled.", "hasDependents": "{{name}} is needed by {{list}}. Turn those off or uninstall them first.", "missingDependencies": "{{name}} needs {{list}}, which is not installed.", "consentChanged": "What this plugin asks for changed since you looked. Check the list again.", diff --git a/src/ui/look/RepairView.tsx b/src/ui/look/RepairView.tsx new file mode 100644 index 000000000..6379aa59a --- /dev/null +++ b/src/ui/look/RepairView.tsx @@ -0,0 +1,376 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { + ArrowLeft, + Check, + Copy, + Download, + LayoutTemplate, + Palette, + Power, + RefreshCw, + RotateCcw, + Settings, + TriangleAlert, +} from "lucide-react"; +import { Button } from "@/components/button"; +import { ListRow } from "@/components/list-kit"; +import { InlineView, SurfaceScope } from "@/components/surface/surface-scope"; +import { useTheme } from "@/components/theme-provider"; +import { usePermissions } from "@/hooks/use-permissions"; +import { PluginConsentPrompt } from "@/plugins/PluginConsentPrompt"; +import { usePluginsManager } from "@/plugins/use-plugins-manager"; +import { PluginIconBox } from "@/plugins/plugin-bits"; +import { getPluginRecord } from "@/plugin-host/plugin-store"; +import { + ShellConfirmCapture, + useShellController, +} from "@/shell/controller/ShellControllerProvider"; +import { TabStage } from "@/shell/stage/TabStage"; +import { + chooseDefaultLook, + chooseLayout, + chooseThemeForAccount, +} from "./look-store"; +import { layoutLabel, useLayouts } from "./layout-registry"; +import { themeLabel, useThemes } from "./theme-registry"; +import { useChooseTheme } from "./use-look-theme"; +import type { LayoutStatus } from "./layout-status"; + +type RepairStatus = Extract; + +const LOOK_CAPABILITIES = ["ui:layout", "ui:theme"]; + +/** + * What an admin can do about a broken layout: update or disable its plugin, + * and install another layout or theme. Uses the Plugins tab's own flows. + */ +function AdminTools({ + status, + installOpen, + setInstallOpen, +}: { + status: RepairStatus; + installOpen: boolean; + setInstallOpen: (open: boolean) => void; +}) { + const { t } = useTranslation(); + const manager = usePluginsManager(); + const entry = status.pluginId + ? manager.plugins.find((plugin) => plugin.id === status.pluginId) + : undefined; + const installable = manager.plugins.filter( + (plugin) => + !plugin.installed && + plugin.versions[0]?.capabilities?.some((capability) => + LOOK_CAPABILITIES.includes(capability), + ), + ); + + return ( + <> + {entry?.installed && entry.updateAvailable && entry.latestVersion && ( + + )} + {entry?.installed && entry.enabled && !entry.managedByServer && ( + + )} + + + } + > +
+ {manager.loading && ( +

+ {t("common.loading")} +

+ )} + {!manager.loading && installable.length === 0 && ( +

+ {manager.registryError + ? t("looks.repair.registryUnreachable") + : t("looks.repair.nothingToInstall")} +

+ )} + {installable.map((plugin, index) => ( + } + title={plugin.name} + meta={plugin.description} + trailing={ + + } + /> + ))} +
+
+ + manager.setConsent(null)} + onConfirm={() => void manager.confirmConsent()} + /> + + ); +} + +/** + * Shown instead of a layout that cannot be drawn. Small on purpose: it has + * to work when everything else is broken, and gets people back to a working + * look in one click. + */ +export function RepairView({ + status, + onRetry, +}: { + status: RepairStatus; + onRetry: () => void; +}) { + const { t } = useTranslation(); + const shell = useShellController(); + const { isAdmin } = usePermissions(); + const { theme } = useTheme(); + const chooseTheme = useChooseTheme(); + const layouts = useLayouts(); + const themes = useThemes(); + const [copied, setCopied] = useState(false); + const [installOpen, setInstallOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + + const record = status.pluginId ? getPluginRecord(status.pluginId) : null; + const layoutName = + layouts.find((layout) => layout.id === status.layoutId) ?? null; + const name = layoutName ? layoutLabel(layoutName, t) : status.layoutId; + const details = [ + `${t("looks.repair.layout")}: ${name} (${status.layoutId})`, + status.pluginId + ? `${t("looks.repair.plugin")}: ${record?.summary.name ?? status.pluginId} ${status.version ?? ""}`.trim() + : null, + `${t("looks.repair.reason")}: ${t(`looks.repair.reasons.${status.reason}`)}`, + status.error ? `${t("looks.repair.error")}: ${status.error}` : null, + ] + .filter(Boolean) + .join("\n"); + + const copy = async () => { + try { + await navigator.clipboard.writeText(details); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + toast.error(t("looks.repair.copyFailed")); + } + }; + + const run = (action: () => Promise) => () => + void action().catch(() => toast.error(t("looks.repair.saveFailed"))); + + const resetOwn = run(async () => { + try { + localStorage.removeItem("vite-ui-theme"); + } catch { + // Private mode. + } + await chooseThemeForAccount(null); + await chooseLayout(null); + }); + + const resetForEveryone = run(() => + chooseDefaultLook({ layout: null, theme: null }), + ); + + if (settingsOpen) { + return ( +
+
+ +
+ + + + +
+ ); + } + + const safe = status.reason === "safe-url" || status.reason === "safe-env"; + + return ( + + +
+
+
+
+ +
+
+

+ {safe ? t("looks.repair.safeTitle") : t("looks.repair.title")} +

+

+ {t(`looks.repair.reasons.${status.reason}`)} +

+
+
+ +
+
+ + {name} + {status.pluginId && ( + + {" | "} + {record?.summary.name ?? status.pluginId} + {status.version ? ` ${status.version}` : ""} + + )} + + +
+
+              {status.error ?? t("looks.repair.noError")}
+            
+
+ +
+ {!safe && ( + + )} + + {isAdmin && ( + + )} + + {isAdmin && ( + + )} +
+ + {!isAdmin && ( +

+ {t("looks.repair.askAdmin")} +

+ )} + +
+

+ + {t("looks.repair.switchLayout")} +

+
+ {layouts.map((layout, index) => ( + chooseLayout(layout.id))} + /> + ))} +
+
+ +
+

+ + {t("looks.repair.switchTheme")} +

+
+ {themes.map((entry) => ( + + ))} +
+
+
+
+
+ ); +} diff --git a/src/ui/look/layout-registry.ts b/src/ui/look/layout-registry.ts new file mode 100644 index 000000000..c1f4068ac --- /dev/null +++ b/src/ui/look/layout-registry.ts @@ -0,0 +1,90 @@ +import { useSyncExternalStore, type ComponentType } from "react"; +import type { TFunction } from "i18next"; +import { createRegistry } from "@/lib/registry"; + +/** The layout core ships with, used when nothing else is chosen or installed. */ +export const BUILTIN_LAYOUT_ID = "default"; + +/** A layout someone can choose, known from a manifest before its code loads. */ +export interface LayoutDescriptor { + id: string; + /** i18n key: core's for the built-in, the plugin namespace's for plugins. */ + label: string; + description?: string; + defaultTheme?: string; + /** Set for a plugin layout. */ + pluginId?: string; +} + +/** A layout whose code is running, from app.registerLayout or core. */ +export interface RegisteredLayout { + id: string; + pluginId?: string; + component: ComponentType; + /** Shown on phones and narrow windows. Never in the desktop app. */ + mobileComponent?: ComponentType; + defaultTheme?: string; +} + +const BUILTIN_DESCRIPTOR: LayoutDescriptor = { + id: BUILTIN_LAYOUT_ID, + label: "looks.builtinLayout", +}; + +let pluginLayouts: LayoutDescriptor[] = []; +let descriptorSnapshot: LayoutDescriptor[] = [BUILTIN_DESCRIPTOR]; +const descriptorListeners = new Set<() => void>(); + +const registered = createRegistry(); + +export const registerLayout = registered.register; +export const getRegisteredLayout = registered.get; +export const useRegisteredLayouts = registered.useList; +export const resetRegisteredLayouts = registered.reset; + +/** Every layout that can be chosen: the built-in, then plugin layouts. */ +export function listLayouts(): LayoutDescriptor[] { + return descriptorSnapshot; +} + +export function getLayoutDescriptor(id: string): LayoutDescriptor | undefined { + return descriptorSnapshot.find((layout) => layout.id === id); +} + +/** Replaces the plugin layouts at once, from the plugin list. */ +export function setPluginLayouts(layouts: LayoutDescriptor[]): void { + const key = (list: LayoutDescriptor[]) => + JSON.stringify( + list.map((l) => [l.id, l.pluginId, l.label, l.defaultTheme]), + ); + if (key(layouts) === key(pluginLayouts)) return; + pluginLayouts = layouts; + descriptorSnapshot = [ + BUILTIN_DESCRIPTOR, + ...layouts.filter((layout) => layout.id !== BUILTIN_LAYOUT_ID), + ]; + for (const listener of descriptorListeners) listener(); +} + +function subscribeDescriptors(listener: () => void): () => void { + descriptorListeners.add(listener); + return () => { + descriptorListeners.delete(listener); + }; +} + +export function useLayouts(): LayoutDescriptor[] { + return useSyncExternalStore(subscribeDescriptors, listLayouts, listLayouts); +} + +export function layoutLabel(layout: LayoutDescriptor, t: TFunction): string { + if (!layout.pluginId) return t(layout.label); + return t(`${layout.pluginId}:${layout.label}`, { + defaultValue: layout.label, + }); +} + +/** Test seam. */ +export function resetPluginLayouts(): void { + setPluginLayouts([]); +} diff --git a/src/ui/look/layout-status.ts b/src/ui/look/layout-status.ts new file mode 100644 index 000000000..a80545e03 --- /dev/null +++ b/src/ui/look/layout-status.ts @@ -0,0 +1,99 @@ +import type { UnavailableLook } from "@/api/look-api"; +import type { PluginRecord } from "@/plugin-host/plugin-store"; +import type { RegisteredLayout } from "./layout-registry"; + +/** Why the repair view shows instead of the chosen layout. */ +export type RepairReason = + /** ?safe-layout=1 in the address. */ + | "safe-url" + /** TERMIX_SAFE_LAYOUT on the server. */ + | "safe-env" + /** No installed plugin provides the layout. */ + | "missing" + /** Its plugin is switched off. */ + | "disabled" + /** Its plugin waits on a missing dependency or was refused. */ + | "blocked" + /** Its plugin does not fit this Termix or plugin API. */ + | "incompatible" + /** Its code threw while starting. */ + | "failed" + /** It crashed while drawing, twice. */ + | "crashed"; + +export type LayoutStatus = + | { kind: "ok"; layout: RegisteredLayout } + | { kind: "loading" } + | { + kind: "repair"; + reason: RepairReason; + layoutId: string; + pluginId?: string; + version?: string | null; + error?: string | null; + }; + +/** A crash this many times in a row hands over to the repair view. */ +export const MAX_LAYOUT_CRASHES = 2; + +export interface LayoutStatusInput { + layoutId: string; + safeFromUrl: boolean; + safeFromEnv: boolean; + /** The look and the plugin list have both been read. */ + ready: boolean; + registered: RegisteredLayout | undefined; + /** The installed plugin whose manifest declares the layout, if any. */ + owner: PluginRecord | undefined; + /** A plugin the server skipped that declares the layout, if any. */ + unavailable: UnavailableLook | undefined; + crashes: number; + lastCrash: string | null; +} + +/** Whether the chosen layout can be drawn, and why not when it cannot. */ +export function layoutStatus(input: LayoutStatusInput): LayoutStatus { + const repair = ( + reason: RepairReason, + extra: { error?: string | null } = {}, + ): LayoutStatus => ({ + kind: "repair", + reason, + layoutId: input.layoutId, + pluginId: input.owner?.summary.id ?? input.unavailable?.id, + version: input.owner?.summary.version ?? input.unavailable?.version ?? null, + error: extra.error ?? null, + }); + + if (input.safeFromUrl) return repair("safe-url"); + if (input.safeFromEnv) return repair("safe-env"); + if (input.crashes >= MAX_LAYOUT_CRASHES) { + return repair("crashed", { error: input.lastCrash }); + } + if (input.registered) return { kind: "ok", layout: input.registered }; + if (!input.ready) return { kind: "loading" }; + + if (input.unavailable && !input.owner) { + return repair("incompatible", { error: input.unavailable.error }); + } + const owner = input.owner; + if (!owner) return repair("missing"); + const summary = owner.summary; + if (!summary.enabled) return repair("disabled"); + if (owner.frontend === "blocked" || summary.state === "blocked") { + return repair("blocked", { error: owner.error ?? summary.lastError }); + } + if (owner.frontend === "failed" || summary.state === "failed") { + return repair("failed", { error: owner.error ?? summary.lastError }); + } + if (owner.frontend === "loading") return { kind: "loading" }; + // Just chosen: the runtime is about to load its code. + if ( + summary.frontend && + (owner.frontend === "inactive" || owner.frontend === "none") + ) { + return { kind: "loading" }; + } + // Running, but it never registered the layout it declares. + return repair("missing"); +} diff --git a/src/ui/look/look-store.ts b/src/ui/look/look-store.ts new file mode 100644 index 000000000..4f7b6e3f1 --- /dev/null +++ b/src/ui/look/look-store.ts @@ -0,0 +1,133 @@ +import { useSyncExternalStore } from "react"; +import { + getLook, + saveDefaultLook, + saveUserLook, + type LookResponse, +} from "@/api/look-api"; +import { resolveLayoutId, safeLayoutInUrl } from "./resolve-look"; + +const LAST_LAYOUT_KEY = "termix-layout"; + +interface LookState { + /** The server answered, or failed to; the look is decided either way. */ + loaded: boolean; + response: LookResponse | null; +} + +let state: LookState = { loaded: false, response: null }; +let request: Promise | null = null; +const listeners = new Set<() => void>(); + +function setState(next: LookState) { + state = next; + for (const listener of listeners) listener(); +} + +function readLastLayout(): string | null { + try { + return localStorage.getItem(LAST_LAYOUT_KEY); + } catch { + return null; + } +} + +/** Fetches the look once per session. Never rejects: no answer means defaults. */ +export function loadLook(): Promise { + request ??= getLook() + .then((response) => { + setState({ loaded: true, response }); + return response; + }) + .catch(() => { + setState({ loaded: true, response: null }); + return null; + }); + return request; +} + +/** Fetches again, e.g. after an admin changed the defaults. */ +export function reloadLook(): Promise { + request = null; + return loadLook(); +} + +/** On logout: the next user has their own look. */ +export function resetLook(): void { + request = null; + setState({ loaded: false, response: null }); +} + +export function getLookState(): LookState { + return state; +} + +export function subscribeLook(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function useLookState(): LookState { + return useSyncExternalStore(subscribeLook, getLookState, getLookState); +} + +/** The layout to show now. Remembered so a failed fetch next time still knows. */ +export function chosenLayoutId(): string { + const id = resolveLayoutId(state.response, readLastLayout()); + if (state.response) { + try { + localStorage.setItem(LAST_LAYOUT_KEY, id); + } catch { + // Private mode; the server is asked every time anyway. + } + } + return id; +} + +/** The URL or the server asks for the repair view. */ +export function safeLayoutRequested(): boolean { + return ( + (typeof window !== "undefined" && + safeLayoutInUrl(window.location.search)) || + state.response?.safeLayout === true + ); +} + +function patchResponse(patch: Partial) { + if (!state.response) return; + setState({ ...state, response: { ...state.response, ...patch } }); +} + +/** Tells the plugin runtime to load the chosen layout's code. */ +function announceLayoutChange() { + if (typeof window !== "undefined") { + window.dispatchEvent(new Event("termix:plugins-changed")); + } +} + +/** The user's own layout; null goes back to the default. */ +export async function chooseLayout(layoutId: string | null): Promise { + await saveUserLook({ layoutId }); + patchResponse({ layoutId }); + announceLayoutChange(); +} + +/** The user's own theme; null goes back to the default. */ +export async function chooseThemeForAccount( + themeId: string | null, +): Promise { + await saveUserLook({ themeId }); + patchResponse({ themeId }); +} + +/** Admin only: what everyone sees until they choose their own. */ +export async function chooseDefaultLook(defaults: { + layout?: string | null; + theme?: string | null; +}): Promise { + const saved = await saveDefaultLook(defaults); + patchResponse({ defaults: saved }); + announceLayoutChange(); +} diff --git a/src/ui/look/resolve-look.ts b/src/ui/look/resolve-look.ts new file mode 100644 index 000000000..de425447a --- /dev/null +++ b/src/ui/look/resolve-look.ts @@ -0,0 +1,49 @@ +import type { LookResponse } from "@/api/look-api"; +import { BUILTIN_LAYOUT_ID } from "./layout-registry"; + +/** The theme a fresh install starts in when nothing else says otherwise. */ +export const FALLBACK_THEME = "dark"; + +/** + * The layout to show: the user's choice, else the admin default, else the + * default look, else core's built-in. Without an answer from the server the + * last layout this browser showed stands in. + */ +export function resolveLayoutId( + look: LookResponse | null, + lastShown: string | null = null, +): string { + if (!look) return lastShown ?? BUILTIN_LAYOUT_ID; + return ( + look.layoutId ?? + look.defaults.layout ?? + look.defaultLook.layout ?? + BUILTIN_LAYOUT_ID + ); +} + +/** + * The theme to show: the user's choice, else the one this browser kept + * (today's per-device setting, so existing users see no change), else the + * admin default, else the layout's own, else the default look's. + */ +export function resolveThemeId( + look: LookResponse | null, + localTheme: string | null, + layoutDefaultTheme?: string, +): string { + return ( + look?.themeId ?? + localTheme ?? + look?.defaults.theme ?? + layoutDefaultTheme ?? + look?.defaultLook.theme ?? + FALLBACK_THEME + ); +} + +/** ?safe-layout=1 in the address bar opens the repair view. */ +export function safeLayoutInUrl(search: string): boolean { + const value = new URLSearchParams(search).get("safe-layout"); + return value === "1" || value === "true"; +} diff --git a/src/ui/look/theme-css.ts b/src/ui/look/theme-css.ts new file mode 100644 index 000000000..40df94557 --- /dev/null +++ b/src/ui/look/theme-css.ts @@ -0,0 +1,65 @@ +import { arePluginThemesKnown, type ThemeDef } from "./theme-registry"; + +const LINK_ID = "termix-theme-css"; +/** The last plugin theme's stylesheet, so a reload shows it before login too. */ +const CACHE_KEY = "termix-theme-css"; + +function readCache(): { id: string; href: string } | null { + try { + const raw = localStorage.getItem(CACHE_KEY); + const parsed = raw ? (JSON.parse(raw) as unknown) : null; + if ( + parsed && + typeof (parsed as { id?: unknown }).id === "string" && + typeof (parsed as { href?: unknown }).href === "string" + ) { + return parsed as { id: string; href: string }; + } + } catch { + // Unreadable or private mode. + } + return null; +} + +function writeCache(value: { id: string; href: string } | null) { + try { + if (value) localStorage.setItem(CACHE_KEY, JSON.stringify(value)); + else localStorage.removeItem(CACHE_KEY); + } catch { + // Private mode; the theme still applies once plugins load. + } +} + +/** + * Loads the one plugin theme stylesheet the shown theme needs, and only that + * one. Built-in themes need none. A theme whose plugin has not loaded yet + * keeps the stylesheet it had last time. + */ +export function applyThemeCss(id: string, theme: ThemeDef | undefined): void { + if (typeof document === "undefined") return; + const cached = readCache(); + let href: string | null = null; + if (theme?.cssHref) { + href = theme.cssHref; + writeCache({ id, href }); + } else if (cached?.id === id && !arePluginThemesKnown()) { + // Its plugin has not said yet whether it still provides this theme. + href = cached.href; + } else if (cached && arePluginThemesKnown()) { + writeCache(null); + } + + let link = document.getElementById(LINK_ID) as HTMLLinkElement | null; + if (!href) { + link?.remove(); + return; + } + if (!link) { + link = document.createElement("link"); + link.id = LINK_ID; + link.rel = "stylesheet"; + document.head.appendChild(link); + } + const absolute = new URL(href, window.location.href).href; + if (link.href !== absolute) link.href = absolute; +} diff --git a/src/ui/look/theme-registry.ts b/src/ui/look/theme-registry.ts new file mode 100644 index 000000000..fb563093c --- /dev/null +++ b/src/ui/look/theme-registry.ts @@ -0,0 +1,174 @@ +import { useSyncExternalStore } from "react"; +import type { TFunction } from "i18next"; + +/** Follows the OS between the light and dark theme. Not a real theme. */ +export const SYSTEM_THEME = "system"; + +export interface ThemeSwatch { + background: string; + panel: string; + text: string; +} + +export interface ThemeDef { + /** Also the class put on while the theme is active. */ + id: string; + /** i18n key: core's for built-ins, the plugin namespace's for plugins. */ + label: string; + mode: "light" | "dark"; + swatch: ThemeSwatch; + /** Set for a plugin theme. */ + pluginId?: string; + /** Where a plugin theme's CSS is served. Built-ins live in index.css. */ + cssHref?: string; +} + +/** + * Themes core ships, with their tokens in index.css. A plugin theme with the + * same id takes the built-in's place while it is installed. + */ +export const BUILTIN_THEMES: readonly ThemeDef[] = [ + { + id: "light", + label: "newUi.sidebar.userProfile.themeLight", + mode: "light", + swatch: { background: "#ffffff", panel: "#eef0f3", text: "#1f2937" }, + }, + { + id: "dark", + label: "newUi.sidebar.userProfile.themeDark", + mode: "dark", + swatch: { background: "#1a1c22", panel: "#25272e", text: "#e5e7eb" }, + }, + { + id: "dracula", + label: "newUi.sidebar.userProfile.themeDracula", + mode: "dark", + swatch: { background: "#282a36", panel: "#44475a", text: "#f8f8f2" }, + }, + { + id: "catppuccin", + label: "newUi.sidebar.userProfile.themeCatppuccin", + mode: "dark", + swatch: { background: "#1e1e2e", panel: "#313244", text: "#cdd6f4" }, + }, + { + id: "nord", + label: "newUi.sidebar.userProfile.themeNord", + mode: "dark", + swatch: { background: "#2e3440", panel: "#3b4252", text: "#eceff4" }, + }, + { + id: "solarized", + label: "newUi.sidebar.userProfile.themeSolarized", + mode: "dark", + swatch: { background: "#002b36", panel: "#073642", text: "#93a1a1" }, + }, + { + id: "tokyo-night", + label: "newUi.sidebar.userProfile.themeTokyoNight", + mode: "dark", + swatch: { background: "#1a1b26", panel: "#24283b", text: "#c0caf5" }, + }, + { + id: "one-dark", + label: "newUi.sidebar.userProfile.themeOneDark", + mode: "dark", + swatch: { background: "#282c34", panel: "#21252b", text: "#abb2bf" }, + }, + { + id: "gruvbox", + label: "newUi.sidebar.userProfile.themeGruvbox", + mode: "dark", + swatch: { background: "#282828", panel: "#3c3836", text: "#ebdbb2" }, + }, +]; + +const builtins = new Map(BUILTIN_THEMES.map((theme) => [theme.id, theme])); +let pluginThemes = new Map(); +/** False until the plugin list has been read once this session. */ +let pluginThemesKnown = false; +const listeners = new Set<() => void>(); +let snapshot: ThemeDef[] | null = null; + +function emit() { + snapshot = null; + for (const listener of listeners) listener(); +} + +/** Built-ins in their order, a plugin theme in a built-in's place, then the rest. */ +export function listThemes(): ThemeDef[] { + if (!snapshot) { + const replaced = BUILTIN_THEMES.map( + (theme) => pluginThemes.get(theme.id) ?? theme, + ); + const extra = [...pluginThemes.values()] + .filter((theme) => !builtins.has(theme.id)) + .sort((a, b) => a.id.localeCompare(b.id)); + snapshot = [...replaced, ...extra]; + } + return snapshot; +} + +export function getTheme(id: string): ThemeDef | undefined { + return pluginThemes.get(id) ?? builtins.get(id); +} + +/** Replaces every plugin theme at once, from the plugin list. */ +export function setPluginThemes(themes: ThemeDef[]): void { + const first = !pluginThemesKnown; + pluginThemesKnown = true; + const next = new Map(themes.map((theme) => [theme.id, theme])); + const same = + next.size === pluginThemes.size && + [...next].every(([id, theme]) => { + const current = pluginThemes.get(id); + return ( + current?.cssHref === theme.cssHref && + current?.pluginId === theme.pluginId && + current?.label === theme.label && + current?.mode === theme.mode + ); + }); + if (same && !first) return; + pluginThemes = next; + emit(); +} + +export function subscribeThemes(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function useThemes(): ThemeDef[] { + return useSyncExternalStore(subscribeThemes, listThemes, listThemes); +} + +/** The light or dark mode a theme id stands for, following the OS for system. */ +export function themeMode(id: string): "light" | "dark" { + if (id === SYSTEM_THEME) { + return typeof window !== "undefined" && + window.matchMedia?.("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + return getTheme(id)?.mode ?? "dark"; +} + +export function themeLabel(theme: ThemeDef, t: TFunction): string { + if (!theme.pluginId) return t(theme.label); + return t(`${theme.pluginId}:${theme.label}`, { defaultValue: theme.label }); +} + +export function arePluginThemesKnown(): boolean { + return pluginThemesKnown; +} + +/** On logout, and a test seam. */ +export function resetPluginThemes(): void { + pluginThemes = new Map(); + pluginThemesKnown = false; + emit(); +} diff --git a/src/ui/look/use-look-theme.ts b/src/ui/look/use-look-theme.ts new file mode 100644 index 000000000..0216511a0 --- /dev/null +++ b/src/ui/look/use-look-theme.ts @@ -0,0 +1,65 @@ +import { useCallback, useLayoutEffect } from "react"; +import { useTheme } from "@/components/theme-provider"; +import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api"; +import { chooseThemeForAccount, useLookState } from "./look-store"; +import { getLayoutDescriptor, getRegisteredLayout } from "./layout-registry"; +import { resolveThemeId } from "./resolve-look"; + +const LOCAL_THEME_KEY = "vite-ui-theme"; + +function readLocalTheme(): string | null { + try { + return localStorage.getItem(LOCAL_THEME_KEY); + } catch { + return null; + } +} + +/** + * Shows the theme the look settles on once it is known. A theme someone + * picked themselves is kept in this browser too; a default is only shown, + * so a later default change still reaches them. + */ +export function useLookTheme(layoutId: string): void { + const { theme, setTheme } = useTheme(); + const look = useLookState(); + const layoutDefault = + getRegisteredLayout(layoutId)?.defaultTheme ?? + getLayoutDescriptor(layoutId)?.defaultTheme; + + useLayoutEffect(() => { + if (!look.loaded) return; + const next = resolveThemeId(look.response, readLocalTheme(), layoutDefault); + if (next === theme) return; + setTheme(next, !!look.response?.themeId); + // Only when the look itself changes, not on every theme switch. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [look, layoutDefault]); +} + +/** + * Picks a theme for this user: shown now and kept in this browser. Unless + * they keep settings on this device only, it is saved to their account too, + * which is what other devices follow. + */ +export function useChooseTheme(): (themeId: string) => void { + const { setTheme } = useTheme(); + return useCallback( + (themeId: string) => { + setTheme(themeId); + void syncThemeChoice(themeId); + }, + [setTheme], + ); +} + +async function syncThemeChoice(themeId: string): Promise { + const prefs = await getUserPreferences().catch(() => null); + // A device-only profile must not be overridden by an account choice. + const local = prefs?.storageMode === "local"; + await chooseThemeForAccount(local ? null : themeId).catch(() => {}); + // Older clients still read the profile field. + if (prefs?.storageMode === "cloud") { + await saveUserPreferences({ theme: themeId }).catch(() => {}); + } +} diff --git a/src/ui/plugin-host/app.ts b/src/ui/plugin-host/app.ts index 100b8d560..a82af22ca 100644 --- a/src/ui/plugin-host/app.ts +++ b/src/ui/plugin-host/app.ts @@ -64,7 +64,13 @@ import { import { shell, shellHost, tabsApi } from "./shell-bridge"; import { onRemoteServerChange, remoteServerUrl } from "./desktop"; import { isElectron } from "@/lib/electron"; -import { withPluginScope, withIconBoundary, guardCallback } from "./scope"; +import { + withPluginContext, + withPluginScope, + withIconBoundary, + guardCallback, +} from "./scope"; +import { registerLayout as registerLayoutComponent } from "@/look/layout-registry"; import { scopeHostArgs, scopeHostCallback, @@ -157,7 +163,9 @@ export function createPluginApp( ? "contributes.tabs" : kind === "panel" ? "contributes.panels or contributes.tabs" - : "contributes.dashboardCards"; + : kind === "layout" + ? "contributes.layouts" + : "contributes.dashboardCards"; throw new Error( `${pluginId}: ${what} "${id}" is not declared in manifest ${field}`, ); @@ -578,6 +586,21 @@ export function createPluginApp( ); }, + registerLayout(layout) { + requireDeclared("layout", layout.id, "layout"); + return track( + registerLayoutComponent({ + id: layout.id, + pluginId, + component: withPluginContext(pluginId, layout.component), + mobileComponent: layout.mobileComponent + ? withPluginContext(pluginId, layout.mobileComponent) + : undefined, + defaultTheme: layout.defaultTheme, + }), + ); + }, + t: ((key: string, options?: Record | string) => i18n.t( pluginKey(pluginId, key), diff --git a/src/ui/plugin-host/bridge.ts b/src/ui/plugin-host/bridge.ts index 53cb693fb..99693c4ae 100644 --- a/src/ui/plugin-host/bridge.ts +++ b/src/ui/plugin-host/bridge.ts @@ -48,6 +48,8 @@ import { invokeAction } from "@/shell/action-registry"; import { useSshAuthProviders } from "@/hooks/useSshAuthProviders"; import { useActionSlot } from "@/hooks/use-action-slot"; import { usePluginComponent } from "./component-registry"; +import { themeMode, useThemes } from "@/look/theme-registry"; +import { usePluginShell } from "@/shell/controller/plugin-shell"; import { getLiveHostStatus, useOptionalHostStatusEntry, @@ -337,15 +339,13 @@ export const pluginHostBridge: PluginHostBridge = { useTheme() { const { theme } = useTheme(); - if (theme === "light") return { theme: "light" }; - if ((theme as string) === "system" && typeof window !== "undefined") { - return { - theme: window.matchMedia?.("(prefers-color-scheme: light)").matches - ? "light" - : "dark", - }; - } - return { theme: "dark" }; + // Re-render when plugin themes arrive, since they carry their mode. + useThemes(); + return { theme: themeMode(theme) }; + }, + + useShell() { + return usePluginShell(usePluginScope()); }, toast: { diff --git a/src/ui/plugin-host/loader.ts b/src/ui/plugin-host/loader.ts index 1f2e2a3ae..3f3bc2231 100644 --- a/src/ui/plugin-host/loader.ts +++ b/src/ui/plugin-host/loader.ts @@ -16,6 +16,13 @@ import { setPluginSummaries, } from "./plugin-store"; import { syncHostFeatureTabs } from "@/settings/host-feature-tabs"; +import { + chosenLayoutId, + loadLook, + safeLayoutRequested, +} from "@/look/look-store"; +import { BUILTIN_THEMES, setPluginThemes } from "@/look/theme-registry"; +import { setPluginLayouts } from "@/look/layout-registry"; /** * Loads plugin frontends into the running shell. @@ -55,14 +62,22 @@ let queue: Promise = Promise.resolve(); let deps: PluginLoaderDeps | null = null; let started = false; let guestMode = false; +/** Signed in with the full runtime: only the chosen layout's code loads. */ +let gateLayouts = false; + +function assetUrlWithVersion( + pluginId: string, + file: string, + version: string | null | undefined, +): string { + const query = version ? `?v=${encodeURIComponent(version)}` : ""; + return getBackendUrl( + `/plugin-assets/${encodeURIComponent(pluginId)}/${file}${query}`, + ); +} function assetUrl(summary: PluginSummary, file: string): string { - const version = summary.assetVersion - ? `?v=${encodeURIComponent(summary.assetVersion)}` - : ""; - return getBackendUrl( - `/plugin-assets/${encodeURIComponent(summary.id)}/${file}${version}`, - ); + return assetUrlWithVersion(summary.id, file, summary.assetVersion); } /** Absolute URL, so import() resolves it against the page, not this module. */ @@ -286,11 +301,75 @@ function namespaceVersion(summary: PluginSummary): string { return `${summary.version}|${summary.assetVersion ?? ""}`; } +/** Puts every running plugin's themes and every enabled plugin's layouts on offer. */ +function syncLooks(summaries: PluginSummary[]): void { + const fallback = (mode: "light" | "dark") => + BUILTIN_THEMES.find((theme) => theme.id === mode)!.swatch; + setPluginThemes( + summaries.filter(serverServesAssets).flatMap((summary) => + summary.themeVersion + ? (summary.contributes?.themes ?? []).map((theme) => ({ + id: theme.id, + label: theme.label, + mode: theme.mode, + swatch: theme.swatch ?? fallback(theme.mode), + pluginId: summary.id, + cssHref: assetUrlWithVersion( + summary.id, + `_themes/${theme.id}.css`, + summary.themeVersion, + ), + })) + : [], + ), + ); + setPluginLayouts( + summaries + .filter((summary) => summary.enabled) + .flatMap((summary) => + (summary.contributes?.layouts ?? []).map((layout) => ({ + id: layout.id, + label: layout.label, + description: layout.description, + defaultTheme: layout.defaultTheme, + pluginId: summary.id, + })), + ), + ); +} + +/** + * A layout plugin's code runs only while one of its layouts is the one + * shown, and never in the repair view, so a broken layout cannot break the + * way out of it. + */ +async function withoutUnchosenLayouts( + summaries: PluginSummary[], +): Promise { + if (!gateLayouts) return summaries; + await loadLook(); + const chosen = safeLayoutRequested() ? null : chosenLayoutId(); + return summaries.map((summary) => { + const layouts = summary.contributes?.layouts ?? []; + if (layouts.length === 0) return summary; + if (layouts.some((layout) => layout.id === chosen)) return summary; + if ( + summary.frontend && + getPluginRecord(summary.id)?.frontend !== "inactive" + ) + setFrontendState(summary.id, "inactive"); + return { ...summary, frontend: false }; + }); +} + async function reconcile(summaries: PluginSummary[]): Promise { setPluginSummaries(summaries); + syncLooks(summaries); await loadNamespaces(summaries.filter(serverServesAssets)); - const { order, blocked } = orderForActivation(summaries); + const { order, blocked } = orderForActivation( + await withoutUnchosenLayouts(summaries), + ); const wanted = new Map(order.map((summary) => [summary.id, summary])); // Dependents go first, so nothing outlives what it depends on. @@ -393,6 +472,12 @@ export function syncPlugins(summaries?: PluginSummary[]): Promise { }); } +/** Tries a plugin's frontend again even though this bundle failed before. */ +export function retryPluginFrontend(pluginId: string): Promise { + failedVersions.delete(pluginId); + return syncPlugins(); +} + /** Fired by anything that changes plugin state, e.g. the admin toggle. */ const PLUGINS_CHANGED_EVENT = "termix:plugins-changed"; @@ -503,6 +588,7 @@ export function startPluginRuntime( return syncPlugins(); } + gateLayouts = true; if (!started && typeof window !== "undefined") { started = true; lastFocusSync = Date.now(); @@ -514,6 +600,7 @@ export function startPluginRuntime( /** Deactivates every plugin, on logout. */ export function stopPluginRuntime(): Promise { + gateLayouts = false; if (started && typeof window !== "undefined") { started = false; window.removeEventListener(PLUGINS_CHANGED_EVENT, onPluginsChanged); diff --git a/src/ui/plugin-host/scope.tsx b/src/ui/plugin-host/scope.tsx index 060065e89..f7a43b442 100644 --- a/src/ui/plugin-host/scope.tsx +++ b/src/ui/plugin-host/scope.tsx @@ -114,6 +114,28 @@ export function withPluginScope

( return Scoped; } +/** + * The plugin id for the hooks without the error boundary, for a component + * whose caller handles a crash itself (a layout, which the repair view takes + * over from). + */ +export function withPluginContext

( + pluginId: string, + Inner: ComponentType

, +): ComponentType

{ + function Scoped(props: P) { + return ( + + + + + + ); + } + Scoped.displayName = `Plugin(${pluginId}:${Inner.displayName ?? Inner.name ?? "Component"})`; + return Scoped; +} + /** Renders nothing when a plugin's icon throws, instead of a crash notice. */ class IconBoundary extends Component { state = { error: false }; diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts index 78fd103b4..726726947 100644 --- a/src/ui/plugin-host/sdk-ui.ts +++ b/src/ui/plugin-host/sdk-ui.ts @@ -152,6 +152,9 @@ export { useTabs, useTabsSafe } from "@/shell/TabContext"; // Slots: a plugin can offer places for other plugins to fill. export { ActionSlot, ComponentSlot } from "@/shell/ActionSlot"; +// For layouts: where tabs show, and the confirm a tab close asks with. +export { TabStage } from "@/shell/stage/TabStage"; +export { ShellConfirmCapture } from "@/shell/controller/ShellControllerProvider"; // A plugin's manifest host settings, drawn inside its own host editor section. export { diff --git a/src/ui/plugin-host/view-ownership.ts b/src/ui/plugin-host/view-ownership.ts index a6bb9c41c..86fdb4d17 100644 --- a/src/ui/plugin-host/view-ownership.ts +++ b/src/ui/plugin-host/view-ownership.ts @@ -7,7 +7,7 @@ import { getPluginStoreState, type PluginRecord } from "./plugin-store"; * because a disabled plugin never runs its frontend and so never registers * anything, yet a saved tab or card of its still has to say whose it is. */ -export type ViewKind = "tab" | "panel" | "card"; +export type ViewKind = "tab" | "panel" | "card" | "layout"; export type ViewStatus = /** Registered by a running plugin. */ @@ -31,6 +31,9 @@ function declares(record: PluginRecord, kind: ViewKind, id: string): boolean { !!contributes.tabs?.some((tab) => tab.id === id) ); } + if (kind === "layout") { + return !!contributes.layouts?.some((layout) => layout.id === id); + } return !!contributes.dashboardCards?.some((card) => card.id === id); } diff --git a/src/ui/plugins/PluginDetail.tsx b/src/ui/plugins/PluginDetail.tsx index b31a1bb7e..51f05b92f 100644 --- a/src/ui/plugins/PluginDetail.tsx +++ b/src/ui/plugins/PluginDetail.tsx @@ -184,18 +184,20 @@ export function PluginDetail({ ? t("plugins.manager.disable") : t("plugins.manager.enable")} - + {!plugin.defaultLook && ( + + )} ) : ( + {!plugin.defaultLook && ( + + )}

); @@ -916,17 +918,19 @@ function InstalledRowActions({ )} - + {!plugin.defaultLook && ( + + )} ); } diff --git a/src/ui/plugins/plugin-model.ts b/src/ui/plugins/plugin-model.ts index f7ab17cf8..91e998125 100644 --- a/src/ui/plugins/plugin-model.ts +++ b/src/ui/plugins/plugin-model.ts @@ -71,6 +71,8 @@ export interface PluginEntry { registryName: string | null; /** A linked server decides whether it runs here. */ managedByServer: boolean; + /** The app is drawn with it by default, so it can be swapped but not uninstalled. */ + defaultLook: boolean; } export type PluginSource = @@ -168,6 +170,7 @@ export function mergePlugins( registryId: entry?.registryId ?? summary.registryId ?? null, registryName: entry?.registryName ?? null, managedByServer: summary.managedByServer ?? false, + defaultLook: summary.defaultLook ?? false, }); } @@ -212,6 +215,7 @@ export function mergePlugins( registryId: entry.registryId ?? "official", registryName: entry.registryName ?? null, managedByServer: false, + defaultLook: false, }); } @@ -253,6 +257,7 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry { registryId: null, registryName: null, managedByServer: false, + defaultLook: false, }; } diff --git a/src/ui/plugins/use-plugins-manager.ts b/src/ui/plugins/use-plugins-manager.ts index 7c0debd33..2d6e4b0d1 100644 --- a/src/ui/plugins/use-plugins-manager.ts +++ b/src/ui/plugins/use-plugins-manager.ts @@ -146,6 +146,8 @@ export function usePluginsManager() { const info = readError(error, t("plugins.manager.errors.generic")); if (info.code === "MANAGED_BY_SERVER") { toast.error(t("plugins.manager.errors.managedByServer")); + } else if (info.code === "DEFAULT_LOOK") { + toast.error(t("plugins.manager.errors.defaultLook", { name })); } else if (info.code === "HAS_DEPENDENTS") { toast.error( t("plugins.manager.errors.hasDependents", { diff --git a/src/ui/settings/appearance/LookSettings.tsx b/src/ui/settings/appearance/LookSettings.tsx new file mode 100644 index 000000000..c65406ba3 --- /dev/null +++ b/src/ui/settings/appearance/LookSettings.tsx @@ -0,0 +1,94 @@ +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Select2 } from "@/components/select2"; +import { + chooseDefaultLook, + chooseLayout, + useLookState, +} from "@/look/look-store"; +import { layoutLabel, useLayouts } from "@/look/layout-registry"; +import { themeLabel, useThemes } from "@/look/theme-registry"; + +const selectClass = + "w-full px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring appearance-none pr-7"; + +/** + * The layout picker, plus the default layout and theme for everyone when an + * admin is looking. Lists what the layout and theme registries hold, so + * plugin layouts and themes show up as soon as they are installed. + */ +export function LookSettings({ isAdmin }: { isAdmin: boolean }) { + const { t } = useTranslation(); + const look = useLookState(); + const layouts = useLayouts(); + const themes = useThemes(); + const response = look.response; + + const failed = () => toast.error(t("looks.saveFailed")); + + return ( +
+ + void chooseLayout(e.target.value || null).catch(failed) + } + className={selectClass} + > + + {layouts.map((layout) => ( + + ))} + +

+ {t("looks.layoutHint")} +

+ + {isAdmin && response && ( +
+ + {t("looks.defaultsLabel")} + +

+ {t("looks.defaultsHint")} +

+ + void chooseDefaultLook({ layout: e.target.value || null }) + .then(() => toast.success(t("looks.defaultSaved"))) + .catch(failed) + } + className={selectClass} + > + + {layouts.map((layout) => ( + + ))} + + + void chooseDefaultLook({ theme: e.target.value || null }) + .then(() => toast.success(t("looks.defaultSaved"))) + .catch(failed) + } + className={selectClass} + > + + {themes.map((theme) => ( + + ))} + +
+ )} +
+ ); +} diff --git a/src/ui/settings/appearance/appearance-controls.tsx b/src/ui/settings/appearance/appearance-controls.tsx index 88ca3051d..372708a58 100644 --- a/src/ui/settings/appearance/appearance-controls.tsx +++ b/src/ui/settings/appearance/appearance-controls.tsx @@ -17,50 +17,55 @@ import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n"; import { LANGUAGES } from "@/i18n/languages"; import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api"; import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types"; +import { + BUILTIN_THEMES, + SYSTEM_THEME, + themeLabel, + useThemes, + type ThemeSwatch, +} from "@/look/theme-registry"; +import { useChooseTheme } from "@/look/use-look-theme"; export const DEFAULT_ACCENT = "#f59145"; +/** The built-in themes, with System first. Plugin themes come from useThemeOptions. */ export const THEMES: { id: ThemeId; preview: string }[] = [ - { id: "system", preview: "auto" }, - { id: "light", preview: "#ffffff" }, - { id: "dark", preview: "#1a1c22" }, - { id: "dracula", preview: "#282a36" }, - { id: "catppuccin", preview: "#1e1e2e" }, - { id: "nord", preview: "#2e3440" }, - { id: "solarized", preview: "#002b36" }, - { id: "tokyo-night", preview: "#1a1b26" }, - { id: "one-dark", preview: "#282c34" }, - { id: "gruvbox", preview: "#282828" }, + { id: SYSTEM_THEME, preview: "auto" }, + ...BUILTIN_THEMES.map((theme) => ({ + id: theme.id, + preview: theme.swatch.background, + })), ]; -/** Background, panel and text colours for the little theme previews. */ -const THEME_PALETTES: Record< - Exclude, - { bg: string; panel: string; text: string } -> = { - light: { bg: "#ffffff", panel: "#eef0f3", text: "#1f2937" }, - dark: { bg: "#1a1c22", panel: "#25272e", text: "#e5e7eb" }, - dracula: { bg: "#282a36", panel: "#44475a", text: "#f8f8f2" }, - catppuccin: { bg: "#1e1e2e", panel: "#313244", text: "#cdd6f4" }, - nord: { bg: "#2e3440", panel: "#3b4252", text: "#eceff4" }, - solarized: { bg: "#002b36", panel: "#073642", text: "#93a1a1" }, - "tokyo-night": { bg: "#1a1b26", panel: "#24283b", text: "#c0caf5" }, - "one-dark": { bg: "#282c34", panel: "#21252b", text: "#abb2bf" }, - gruvbox: { bg: "#282828", panel: "#3c3836", text: "#ebdbb2" }, -}; +export interface ThemeOption { + id: ThemeId; + label: string; + /** Null for System, which previews light and dark side by side. */ + swatch: ThemeSwatch | null; +} -export const THEME_LABEL_KEYS: Record = { - system: "newUi.sidebar.userProfile.themeSystem", - light: "newUi.sidebar.userProfile.themeLight", - dark: "newUi.sidebar.userProfile.themeDark", - dracula: "newUi.sidebar.userProfile.themeDracula", - catppuccin: "newUi.sidebar.userProfile.themeCatppuccin", - nord: "newUi.sidebar.userProfile.themeNord", - solarized: "newUi.sidebar.userProfile.themeSolarized", - "tokyo-night": "newUi.sidebar.userProfile.themeTokyoNight", - "one-dark": "newUi.sidebar.userProfile.themeOneDark", - gruvbox: "newUi.sidebar.userProfile.themeGruvbox", -}; +/** Every theme to pick from, System first, plugin themes included. */ +export function useThemeOptions(): ThemeOption[] { + const { t } = useTranslation(); + const themes = useThemes(); + return [ + { + id: SYSTEM_THEME, + label: t("newUi.sidebar.userProfile.themeSystem"), + swatch: null, + }, + ...themes.map((theme) => ({ + id: theme.id, + label: themeLabel(theme, t), + swatch: theme.swatch, + })), + ]; +} + +const LIGHT_SWATCH = BUILTIN_THEMES.find( + (theme) => theme.id === "light", +)!.swatch; +const DARK_SWATCH = BUILTIN_THEMES.find((theme) => theme.id === "dark")!.swatch; export function isHexColor(value: string): boolean { return /^#[0-9a-fA-F]{6}$/.test(value) || /^#[0-9a-fA-F]{3}$/.test(value); @@ -83,7 +88,8 @@ async function saveIfCloud(prefs: CloudPrefs): Promise { * way the profile settings store them. */ export function useAppearanceSettings() { - const { theme, setTheme } = useTheme(); + const { theme } = useTheme(); + const chooseTheme = useChooseTheme(); const [accent, setAccentState] = useState( () => localStorage.getItem("termix-accent") ?? DEFAULT_ACCENT, ); @@ -105,8 +111,7 @@ export function useAppearanceSettings() { uiFont, language, setTheme(id: ThemeId) { - setTheme(id); - void saveIfCloud({ theme: id }); + chooseTheme(id); }, setAccent(value: string) { setAccentState(value); @@ -134,37 +139,29 @@ export function useAppearanceSettings() { }; } -function ThemePreview({ id }: { id: ThemeId }) { - if (id === "system") { +function ThemePreview({ swatch }: { swatch: ThemeSwatch | null }) { + if (!swatch) { return (
- +
- +
); } return (
- +
); } -function MiniWindow({ - bg, - panel, - text, -}: { - bg: string; - panel: string; - text: string; -}) { +function MiniWindow({ background, panel, text }: ThemeSwatch) { return ( -
+
void; }) { - const { t } = useTranslation(); + const { setThemePreview } = useTheme(); + const options = useThemeOptions(); return (
- {THEMES.map(({ id }) => { + {options.map(({ id, label, swatch }) => { const active = value === id; return ( diff --git a/src/ui/settings/settings-search-index.ts b/src/ui/settings/settings-search-index.ts index 6c1e51ea9..0de7a98d8 100644 --- a/src/ui/settings/settings-search-index.ts +++ b/src/ui/settings/settings-search-index.ts @@ -322,6 +322,7 @@ export const SETTINGS_SEARCH_INDEX: Record = { "newUi.sidebar.userProfile.sectionApiKeys", ], "appearance": [ + "looks.layoutLabel", "newUi.sidebar.userProfile.accentColorLabel", "newUi.sidebar.userProfile.appRailHoverPushesContent", "newUi.sidebar.userProfile.appRailHoverPushesContentDesc", diff --git a/src/ui/shell/ShellRoot.tsx b/src/ui/shell/ShellRoot.tsx new file mode 100644 index 000000000..875503957 --- /dev/null +++ b/src/ui/shell/ShellRoot.tsx @@ -0,0 +1,98 @@ +import { lazy, Suspense } from "react"; +import { isElectron } from "@/lib/electron"; +import { ServerStatusProvider } from "@/lib/ServerStatusContext"; +import { ComponentSlot } from "@/shell/ActionSlot"; +import { OnboardingHost } from "@/onboarding/OnboardingHost"; +import { DesktopUpdateToasts } from "@/shell/DesktopUpdateToasts"; +import { getTabType } from "@/shell/tab-registry"; +import { TabPortals } from "@/shell/stage/TabPortals"; +import { LayoutHost } from "@/shell/layout/LayoutHost"; +import { + ShellControllerProvider, + useShellController, +} from "@/shell/controller/ShellControllerProvider"; + +// Not needed for first paint. +const CommandPalette = lazy(() => + import("@/shell/CommandPalette").then((m) => ({ + default: m.CommandPalette, + })), +); + +/** Core's palette, opened by any layout through the shell's palette.open(). */ +function ShellPalette() { + const shell = useShellController(); + const { tabs, hosts, palette } = shell; + if (!palette.isOpen) return null; + return ( + + shell.regions.railClick(view)} + onEditHost={hosts.edit} + onOpenSettings={(section) => + tabs.openSingleton( + "settings", + undefined, + undefined, + section ? { section } : undefined, + ) + } + onOpenTab={(type, label, pendingEvent) => { + if ( + [ + "dashboard", + "host-manager", + "user-profile", + "admin-settings", + "plugins", + ].includes(type) + ) { + tabs.openSingleton(type, pendingEvent); + } else if (getTabType(type)?.multiInstance) { + tabs.openMultiInstance(type); + } else if (getTabType(type)?.singleton) { + // A singleton plugin tab, optionally preselecting a host. + tabs.openSingleton( + type, + undefined, + label ? hosts.list.find((h) => h.name === label) : undefined, + ); + } else if (label) { + const host = hosts.list.find((h) => h.name === label); + if (host) tabs.open(host, type); + } + }} + /> + + ); +} + +/** + * The signed-in app: the shell's state, the chosen layout, every tab's + * content and the pieces core always owns (palette, overlays, onboarding). + */ +export function ShellRoot({ + username, + onLogout, +}: { + username: string; + onLogout: (options?: { manual?: boolean }) => void; +}) { + return ( + + + + + + + + {isElectron() && } + + + ); +} diff --git a/src/ui/shell/controller/ShellControllerProvider.tsx b/src/ui/shell/controller/ShellControllerProvider.tsx new file mode 100644 index 000000000..1794f9f28 --- /dev/null +++ b/src/ui/shell/controller/ShellControllerProvider.tsx @@ -0,0 +1,342 @@ +/* eslint-disable react-refresh/only-export-components */ +/* eslint-disable react-hooks/exhaustive-deps */ +import { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { useSyncStatus } from "@/hooks/use-sync-status"; +import { isElectron } from "@/lib/electron"; +import { + useConfirm, + type ConfirmOptions, +} from "@/components/surface/surface-scope"; +import type { Host, TabType } from "@/types/ui-types"; +import { requestManage } from "@/manage/manage-requests"; +import { useRailItems } from "@/sidebar/rail-items"; +import { usePanels } from "@/shell/panel-registry"; +import { getTabType, type TabShellCallbacks } from "@/shell/tab-registry"; +import { usePluginStore } from "@/plugin-host/plugin-store"; +import { setShellCallbacks } from "@/plugin-host/shell-bridge"; +import { + createTabStageStore, + type TabStageStore, +} from "@/shell/stage/tab-stage-store"; +import { useRegions } from "./use-regions"; +import { useHostList } from "./use-hosts"; +import { useTabs } from "./use-tabs"; +import { useSplit } from "./use-split"; +import { useWorkspaceLayout } from "./use-workspace-layout"; +import { useShellKeybindings } from "./use-shell-keybindings"; +import { useShellLifecycle } from "./use-shell-lifecycle"; +import { publishShellController } from "./shell-store"; +import type { ShellController, ShellPalette } from "./types"; + +const ShellControllerContext = createContext(null); +const TabStageContext = createContext(null); + +/** The live shell, for core layout code. Throws outside the shell. */ +export function useShellController(): ShellController { + const controller = useContext(ShellControllerContext); + if (!controller) throw new Error("useShellController outside the shell"); + return controller; +} + +/** The live shell, or null outside it. */ +export function useOptionalShellController(): ShellController | null { + return useContext(ShellControllerContext); +} + +export function useTabStageStore(): TabStageStore { + const store = useContext(TabStageContext); + if (!store) throw new Error("useTabStageStore outside the shell"); + return store; +} + +/** + * Hands the main area's confirm to code outside it, like closeTab. A layout + * renders it inside the scope its tab-close questions should show in. + */ +export function ShellConfirmCapture(): null { + const { confirmRef } = useShellController(); + const confirm = useConfirm(); + confirmRef.current = confirm; + return null; +} + +/** Owns every bit of shell state and behaviour a layout draws. */ +export function ShellControllerProvider({ + username, + onLogout, + children, +}: { + username: string; + onLogout: (options?: { manual?: boolean }) => void; + children: React.ReactNode; +}) { + // Re-render when plugins add or remove rail items, panels or tabs. + useRailItems(); + usePanels(); + const { settled: pluginsSettled } = usePluginStore(); + const isMobile = useIsMobile(); + const [stageStore] = useState(createTabStageStore); + // The root confirm, for questions the shell itself asks (closing a tab). + const confirmRef = useRef< + ((options: ConfirmOptions) => Promise) | null + >(null); + const openSingletonRef = useRef<(type: TabType) => string | undefined>( + () => undefined, + ); + + const lifecycle = useShellLifecycle({ + onLogout, + openSingletonTabRef: openSingletonRef, + }); + const regions = useRegions(isMobile); + const hosts = useHostList(); + const [paletteOpen, setPaletteOpen] = useState(false); + + const tabsState = useTabs({ + hosts: hosts.list, + hostsRef: hosts.listRef, + hostsLoaded: hosts.loaded, + reloadHosts: hosts.reload, + userPrefs: lifecycle.userPrefs, + userPrefsLoaded: lifecycle.userPrefsLoaded, + pluginsSettled, + confirmRef, + layoutDeps: [ + regions.left.view, + regions.left.open, + regions.left.width, + regions.right.view, + regions.right.width, + ], + }); + openSingletonRef.current = tabsState.openSingletonTab; + const { tabs, activeTabId } = tabsState; + + // Keeps the desktop's sync status polled while the app is open; a pass + // that changed data tells the panels to reload. + useSyncStatus(); + + const palette: ShellPalette = { + isOpen: paletteOpen, + open: () => setPaletteOpen(true), + close: () => setPaletteOpen(false), + setOpen: setPaletteOpen, + }; + + function editHostInManager(host: Host) { + requestManage({ kind: "host", hostId: host.id }); + if (isMobile) regions.left.setOpen(false); + } + + /** + * What plugins may ask of the shell. Rebuilt every render so it always + * closes over current state, and published to the plugin runtime. + */ + const shellCallbacksImpl: TabShellCallbacks = { + openTab: (host, type, options) => { + if (!host || getTabType(type)?.singleton) { + tabsState.openSingletonTab( + type, + undefined, + host ?? undefined, + options?.data, + options?.label, + ); + return; + } + tabsState.openTab(host, type, undefined, options); + }, + openSingletonTab: (type, options) => + tabsState.openSingletonTab(type, undefined, undefined, options?.data), + connectHost: (host, type) => tabsState.connectHost(host, type as TabType), + closeTab: (tabId) => tabsState.closeTab(tabId), + renameTab: (tabId, label) => tabsState.renameTab(tabId, label), + + openRailView: (id) => { + regions.left.setView(id); + regions.left.setOpen(true); + }, + closeRailView: (id) => { + regions.left.setView((prev) => (prev === id ? "hosts" : prev)); + regions.right.setView((prev) => (prev === id ? null : prev)); + }, + openHostEditor: (draft) => + requestManage({ kind: "host", hostId: null, draft }), + saveQuickConnect: tabsState.saveQuickConnectHost, + confirm: (options) => + confirmRef.current ? confirmRef.current(options) : Promise.resolve(false), + }; + + // Tabs get one stable bag that forwards to the latest callbacks, so a shell + // render does not re-render every open tab. + const shellImplRef = useRef(shellCallbacksImpl); + useLayoutEffect(() => { + shellImplRef.current = shellCallbacksImpl; + }); + const callbacks = useMemo( + () => ({ + openTab: (...args) => shellImplRef.current.openTab(...args), + openSingletonTab: (...args) => + shellImplRef.current.openSingletonTab(...args), + connectHost: (...args) => shellImplRef.current.connectHost(...args), + closeTab: (...args) => shellImplRef.current.closeTab(...args), + renameTab: (...args) => shellImplRef.current.renameTab(...args), + openRailView: (...args) => shellImplRef.current.openRailView(...args), + closeRailView: (...args) => shellImplRef.current.closeRailView(...args), + openHostEditor: (...args) => + shellImplRef.current.openHostEditor?.(...args), + saveQuickConnect: (...args) => + shellImplRef.current.saveQuickConnect!(...args), + confirm: (...args) => shellImplRef.current.confirm!(...args), + }), + [], + ); + + // Panels close the sidebar on mobile after opening something, the same as + // the hosts panel does. + const panelCallbacks = useMemo( + () => ({ + ...callbacks, + openTab: (...args) => { + callbacks.openTab(...args); + if (isMobile) regions.left.setOpen(false); + }, + }), + [callbacks, isMobile], + ); + + // Resize all terminals in panes + active terminal when split mode or sidebar changes + const resizeAllTerminals = useCallback(() => { + const id = requestAnimationFrame(() => { + tabs.forEach((tab) => { + if (!tab.terminalRef) return; + const ref = tab.terminalRef.current; + ref?.fit?.(); + ref?.notifyResize?.(); + }); + }); + return id; + }, [tabs]); + + const { split, splitShapeKey, splitActionsRef } = useSplit({ + tabsState, + isMobile, + paneEls: stageStore.paneEls, + callbacks, + resizeAllTerminals, + }); + + useEffect(() => { + // Refitting every terminal on each drag frame is what made sidebar drags + // stutter; fit once when the drag ends instead. + if (regions.left.dragging || regions.right.dragging) return; + const id = resizeAllTerminals(); + return () => cancelAnimationFrame(id); + }, [ + splitShapeKey, + regions.left.width, + regions.left.open, + regions.right.width, + regions.right.view, + regions.left.dragging, + regions.right.dragging, + ]); + + useWorkspaceLayout(tabsState, regions); + useShellKeybindings({ + tabsState, + regions, + palette, + splitActionsRef, + toggleFullscreen: lifecycle.toggleAppFullscreen, + }); + + useEffect(() => { + setShellCallbacks(callbacks); + }); + + const activeTab = tabs.find((tab) => tab.id === activeTabId); + const controller: ShellController = { + username, + isAdmin: lifecycle.isAdmin, + isMobile, + isElectron: isElectron(), + pluginsSettled, + logout: onLogout, + tabs: { + list: tabs, + topLevel: tabs.filter((tab) => !tab.parentSplitTabId), + activeId: activeTabId, + active: activeTab, + ready: tabsState.tabsReady, + terminalTabs: tabsState.terminalTabs, + targetTerminal: tabsState.targetTerminalTab, + showNumbers: tabsState.showTabNumbers, + background: tabsState.backgroundTabRecords, + setBackground: tabsState.setBackgroundTabRecords, + setActive: tabsState.setActiveTabId, + setList: tabsState.setTabs, + reorder: tabsState.reorderTopLevelTabs, + open: tabsState.openTab, + openSingleton: tabsState.openSingletonTab, + openMultiInstance: tabsState.openMultiInstanceTab, + connectHost: tabsState.connectHost, + close: tabsState.closeTab, + rename: tabsState.renameTab, + refresh: tabsState.refreshTab, + reconnectAll: tabsState.reconnectAllDisconnected, + handleFor: tabsState.handleFor, + }, + split, + hosts: { + list: hosts.list, + tree: hosts.tree, + loading: hosts.loading, + loaded: hosts.loaded, + reload: hosts.reload, + edit: editHostInManager, + }, + regions, + palette, + fullscreen: { + active: lifecycle.isAppFullscreen, + toggle: lifecycle.toggleAppFullscreen, + }, + callbacks, + panelCallbacks, + settings: { + username, + isAdmin: lifecycle.isAdmin, + onLogout, + userPrefs: lifecycle.userPrefs, + onPrefsChange: (updates) => + lifecycle.setUserPrefs((current) => ({ ...current, ...updates })), + onOpenHostTab: (host: Host) => tabsState.connectHost(host), + }, + confirmRef, + resizeAllTerminals, + }; + + useLayoutEffect(() => { + publishShellController(controller); + }); + useEffect(() => () => publishShellController(null), []); + + return ( + + + {children} + + + ); +} diff --git a/src/ui/shell/controller/plugin-shell.ts b/src/ui/shell/controller/plugin-shell.ts new file mode 100644 index 000000000..dbb8af4ab --- /dev/null +++ b/src/ui/shell/controller/plugin-shell.ts @@ -0,0 +1,125 @@ +import { useMemo } from "react"; +import type { + PluginHostRecord, + ShellController as PluginShellController, + ShellTabState, +} from "@termix-ssh/plugin-sdk/frontend"; +import { usePermissions } from "@/hooks/use-permissions"; +import { useBranding } from "@/contexts/BrandingContext"; +import { useRailItems } from "@/sidebar/rail-items"; +import { usePanels } from "@/shell/panel-registry"; +import { useTabTypes } from "@/shell/tab-registry"; +import { useRegisteredDashboardCards as useDashboardCards } from "@/dashboard/dashboard-cards-registry"; +import type { TabSplitAction } from "@/shell/split/split-tabs"; +import { toPluginHostRecord } from "@/plugin-host/bridge"; +import { useOptionalShellController } from "./ShellControllerProvider"; +import { useShellSnapshot } from "./shell-store"; + +const registries: PluginShellController["registries"] = { + useRailItems: () => + useRailItems() as unknown as ReturnType< + PluginShellController["registries"]["useRailItems"] + >, + usePanels: () => + usePanels() as unknown as ReturnType< + PluginShellController["registries"]["usePanels"] + >, + useTabTypes: () => + useTabTypes() as unknown as ReturnType< + PluginShellController["registries"]["useTabTypes"] + >, + useDashboardCards: () => + useDashboardCards() as unknown as ReturnType< + PluginShellController["registries"]["useDashboardCards"] + >, +}; + +/** + * The shell as a plugin layout sees it, through useShell() in the SDK. Inside + * the shell this reads the controller's context; outside (an overlay a + * plugin mounted elsewhere) the last published one. + */ +export function usePluginShell(pluginId: string): PluginShellController | null { + const fromContext = useOptionalShellController(); + const published = useShellSnapshot(); + const shell = fromContext ?? published; + const { permissions } = usePermissions(); + const branding = useBranding(); + + const hosts = shell?.hosts.list; + const hostRecords = useMemo( + () => (hosts ?? []).map((host) => toPluginHostRecord(host, pluginId)), + [hosts, pluginId], + ); + + if (!shell) return null; + const { tabs, split, regions } = shell; + return { + tabs: { + list: tabs.list as unknown as ShellTabState[], + topLevel: tabs.topLevel as unknown as ShellTabState[], + activeId: tabs.activeId, + ready: tabs.ready, + showNumbers: tabs.showNumbers, + setActive: tabs.setActive, + reorder: (next) => tabs.reorder(next as unknown as typeof tabs.topLevel), + close: tabs.close, + rename: tabs.rename, + refresh: tabs.refresh, + reconnectAll: tabs.reconnectAll, + openSingleton: (type) => tabs.openSingleton(type), + }, + split: { + activeId: split.active?.id ?? null, + focusedPaneId: split.focusedPaneId, + summaries: split.summaries, + activeFull: split.activeFull, + handleAction: (action) => + split.handleAction(action as unknown as TabSplitAction), + }, + hosts: { + list: hostRecords, + loading: shell.hosts.loading, + loaded: shell.hosts.loaded, + reload: shell.hosts.reload, + }, + regions: { + left: { + ...regions.left, + setView: (view) => regions.left.setView(view ?? "hosts"), + startResize: (event) => + regions.left.startResize(event as unknown as React.MouseEvent), + }, + right: { + ...regions.right, + editing: false, + setView: (view) => regions.right.setView(view), + startResize: (event) => + regions.right.startResize(event as unknown as React.MouseEvent), + } as PluginShellController["regions"]["right"], + railClick: regions.railClick, + openInRightDock: regions.openInRightDock, + toggleRightDock: regions.toggleRightDock, + }, + palette: { + isOpen: shell.palette.isOpen, + open: shell.palette.open, + close: shell.palette.close, + }, + fullscreen: shell.fullscreen, + actions: shell.callbacks as unknown as PluginShellController["actions"], + env: { + isMobile: shell.isMobile, + isElectron: shell.isElectron, + user: { username: shell.username, isAdmin: shell.isAdmin }, + permissions, + branding: { + appName: branding.appName || null, + tagline: branding.tagline || null, + logo: branding.logo, + }, + }, + registries, + logout: shell.logout, + }; +} diff --git a/src/ui/shell/controller/shell-store.ts b/src/ui/shell/controller/shell-store.ts new file mode 100644 index 000000000..3981af71b --- /dev/null +++ b/src/ui/shell/controller/shell-store.ts @@ -0,0 +1,32 @@ +import { useSyncExternalStore } from "react"; +import type { ShellController } from "./types"; + +/** + * The last rendered shell, for code that renders outside the shell's React + * tree (a plugin component in an overlay, the plugin bridge). Inside the + * shell, the context in ShellControllerProvider is the same value without the + * one render of lag. + */ +let current: ShellController | null = null; +const listeners = new Set<() => void>(); + +export function publishShellController(next: ShellController | null): void { + if (next === current) return; + current = next; + for (const listener of listeners) listener(); +} + +export function getShellController(): ShellController | null { + return current; +} + +export function useShellSnapshot(): ShellController | null { + return useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => current, + () => current, + ); +} diff --git a/src/ui/shell/controller/types.ts b/src/ui/shell/controller/types.ts new file mode 100644 index 000000000..dea6c32c5 --- /dev/null +++ b/src/ui/shell/controller/types.ts @@ -0,0 +1,192 @@ +import type { MutableRefObject } from "react"; +import type { TabHandle } from "@termix-ssh/plugin-sdk/frontend"; +import type { + ConfirmOptions, + EditingWidth, +} from "@/components/surface/surface-scope"; +import type { Host, HostFolder, Tab, TabType } from "@/types/ui-types"; +import type { OpenTabRecord, UserPreferences } from "@/main-axios"; +import type { TabShellCallbacks } from "@/shell/tab-registry"; +import type { SplitViewActions } from "@/shell/split/SplitView"; +import type { + NewTabOption, + PickHostTarget, +} from "@/shell/split/EmptyPanePicker"; +import type { + SplitSummary, + SplitTab, + TabSplitAction, +} from "@/shell/split/split-tabs"; + +export interface DockRegion { + view: string; + open: boolean; + width: number; + dragging: boolean; + editing: EditingWidth; + setView: (view: string | ((prev: string) => string)) => void; + setOpen: (open: boolean | ((prev: boolean) => boolean)) => void; + setWidth: (width: number) => void; + startResize: (e: React.MouseEvent) => void; + resetWidth: () => void; +} + +export interface RightDockRegion { + view: string | null; + open: boolean; + width: number; + dragging: boolean; + setView: ( + view: string | null | ((prev: string | null) => string | null), + ) => void; + setWidth: (width: number) => void; + startResize: (e: React.MouseEvent) => void; + resetWidth: () => void; +} + +export interface ShellRegions { + left: DockRegion; + right: RightDockRegion; + /** What clicking a rail item does: toggles or switches the left dock. */ + railClick: (view: string) => void; + openInRightDock: (view: string) => void; + toggleRightDock: () => void; + previousView: () => string | null; + /** Stable setters a panel reports its wanted sidebar width with. */ + panelEditingSetter: (view: string) => (editing: EditingWidth) => void; + scopeEditingSetter: (view: string) => (editing: EditingWidth) => void; +} + +export interface OpenTabOptions { + data?: Record; + label?: string; + forceNewTab?: boolean; +} + +export interface RestoreTarget { + instanceId: string; + restoredSessionId: string | null; + savedLabel?: string; +} + +export interface ShellTabs { + list: Tab[]; + /** Tabs shown in a tab bar: everything not sitting inside a split. */ + topLevel: Tab[]; + activeId: string; + active: Tab | undefined; + ready: boolean; + /** Tabs panels can type into. */ + terminalTabs: Tab[]; + /** The terminal command panels act on right now. */ + targetTerminal: Tab | undefined; + showNumbers: boolean; + background: OpenTabRecord[]; + setBackground: ( + next: OpenTabRecord[] | ((prev: OpenTabRecord[]) => OpenTabRecord[]), + ) => void; + setActive: (id: string) => void; + setList: (next: Tab[] | ((prev: Tab[]) => Tab[])) => void; + reorder: (topLevel: Tab[]) => void; + open: ( + host: Host, + type: TabType, + restore?: RestoreTarget, + options?: OpenTabOptions, + ) => string; + openSingleton: ( + type: TabType, + pendingEvent?: string, + host?: Host, + data?: Record, + label?: string, + ) => string | undefined; + openMultiInstance: ( + type: TabType, + options?: { host?: Host; data?: Record; label?: string }, + ) => string; + connectHost: (host: Host, type?: TabType, options?: OpenTabOptions) => void; + close: (id: string) => Promise; + rename: (id: string, label: string) => void; + refresh: (id: string) => void; + reconnectAll: () => void; + handleFor: (id: string) => TabHandle | null | undefined; +} + +export interface EmptyPaneApi { + freeTabs: Tab[]; + newTabOptions: NewTabOption[]; + pickTab: (splitTabId: string, paneId: string, tabId: string) => void; + pickHost: ( + splitTabId: string, + paneId: string, + host: Host, + pick: PickHostTarget, + ) => void; + openType: (splitTabId: string, paneId: string, type: string) => void; + closePane: (splitTabId: string, paneId: string) => void; +} + +export interface ShellSplit { + active: SplitTab | null; + /** The pane each on-screen tab sits in, for the active split only. */ + paneIdByTabId: Map; + focusedPaneId: string | null; + summaries: SplitSummary[]; + activeFull: boolean; + handleAction: (action: TabSplitAction) => void; + viewActions: (splitTabId: string) => SplitViewActions; + emptyPane: EmptyPaneApi; +} + +export interface ShellHosts { + list: Host[]; + tree: HostFolder | null; + loading: boolean; + loaded: boolean; + reload: () => Promise; + edit: (host: Host) => void; +} + +export interface ShellPalette { + isOpen: boolean; + open: () => void; + close: () => void; + setOpen: (open: boolean | ((prev: boolean) => boolean)) => void; +} + +export interface ShellSettingsProps { + username: string; + isAdmin: boolean; + onLogout: (options?: { manual?: boolean }) => void; + userPrefs: UserPreferences; + onPrefsChange: (updates: Partial) => void; + onOpenHostTab: (host: Host) => void; +} + +/** Everything the shell knows and can do, minus drawing it. */ +export interface ShellController { + username: string; + isAdmin: boolean; + isMobile: boolean; + isElectron: boolean; + pluginsSettled: boolean; + logout: (options?: { manual?: boolean }) => void; + tabs: ShellTabs; + split: ShellSplit; + hosts: ShellHosts; + regions: ShellRegions; + palette: ShellPalette; + fullscreen: { active: boolean; toggle: () => void }; + /** One stable bag that forwards to the latest shell, for tabs and plugins. */ + callbacks: TabShellCallbacks; + /** Same, but closes the sidebar on mobile after opening a tab. */ + panelCallbacks: TabShellCallbacks; + settings: ShellSettingsProps; + /** Where the main area's confirm is captured, see ShellConfirmCapture. */ + confirmRef: MutableRefObject< + ((options: ConfirmOptions) => Promise) | null + >; + /** Refits every visible terminal, e.g. after a pane resize ends. */ + resizeAllTerminals: () => number; +} diff --git a/src/ui/shell/controller/use-hosts.ts b/src/ui/shell/controller/use-hosts.ts new file mode 100644 index 000000000..b96adfa70 --- /dev/null +++ b/src/ui/shell/controller/use-hosts.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Host, HostFolder } from "@/types/ui-types"; +import { getSSHFolders, getSSHHosts } from "@/main-axios"; +import { sshHostToHost } from "@/sidebar/HostManagerData"; +import { buildHostTree } from "@/sidebar/build-host-tree"; +import { setShellHosts } from "@/plugin-host/shell-bridge"; + +/** The saved hosts and their folder tree, kept fresh on change events. */ +export function useHostList() { + const [tree, setTree] = useState(null); + const [loading, setLoading] = useState(true); + const [loaded, setLoaded] = useState(false); + const [list, setList] = useState([]); + const listRef = useRef(list); + listRef.current = list; + + const reload = useCallback(async () => { + try { + const [raw, folders] = await Promise.all([ + getSSHHosts(), + getSSHFolders().catch(() => []), + ]); + const converted = raw.map(sshHostToHost); + setList(converted); + const folderMeta = new Map< + string, + { + color?: string; + icon?: string; + credentialId?: number | null; + sortOrder?: number | null; + localOnly?: boolean; + } + >(); + for (const f of folders) { + folderMeta.set(f.name, { + color: f.color ?? undefined, + icon: f.icon ?? undefined, + credentialId: f.credentialId ?? null, + sortOrder: f.sortOrder ?? null, + localOnly: !!f.localOnly, + }); + } + setTree(buildHostTree(raw, folderMeta)); + } catch { + // Keep empty state on error + } finally { + setLoading(false); + setLoaded(true); + } + }, []); + + useEffect(() => { + reload(); + }, [reload]); + + useEffect(() => { + const onHostsChanged = () => { + void reload(); + }; + window.addEventListener("termix:hosts-changed", onHostsChanged); + window.addEventListener("ssh-hosts:changed", onHostsChanged); + window.addEventListener("hosts:refresh", onHostsChanged); + return () => { + window.removeEventListener("termix:hosts-changed", onHostsChanged); + window.removeEventListener("ssh-hosts:changed", onHostsChanged); + window.removeEventListener("hosts:refresh", onHostsChanged); + }; + }, [reload]); + + useEffect(() => { + setShellHosts(list, loaded); + }, [list, loaded]); + + return { list, listRef, tree, loading, loaded, reload }; +} diff --git a/src/ui/shell/controller/use-regions.ts b/src/ui/shell/controller/use-regions.ts new file mode 100644 index 000000000..60db53959 --- /dev/null +++ b/src/ui/shell/controller/use-regions.ts @@ -0,0 +1,230 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { EditingWidth } from "@/components/surface/surface-scope"; +import { remScale } from "@/lib/rem"; +import { rightDockableIds } from "@/sidebar/rail-items"; +import { DOCK_DEFAULT_WIDTH } from "@/shell/DockPanel"; +import type { ShellRegions } from "./types"; + +const MIN_WIDTH = 160; +const MAX_WIDTH = 480; + +function clampWidth(width: number): number { + return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width)); +} + +/** A drag on a dock edge, one width update per frame. `sign` is -1 for the right dock. */ +function startDockDrag( + e: React.MouseEvent, + startW: number, + sign: 1 | -1, + setWidth: (width: number) => void, + setDragging: (dragging: boolean) => void, +) { + e.preventDefault(); + setDragging(true); + const startX = e.clientX; + // Widths are kept in Normal-size pixels, so a drag is scaled back. + const scale = remScale(); + let frame = 0; + let clientX = startX; + function onMove(ev: MouseEvent) { + clientX = ev.clientX; + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + setWidth(clampWidth(startW + (sign * (clientX - startX)) / scale)); + }); + } + function onUp() { + cancelAnimationFrame(frame); + setWidth(clampWidth(startW + (sign * (clientX - startX)) / scale)); + setDragging(false); + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + } + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); +} + +/** The left sidebar and the right dock: what they show, open state and size. */ +export function useRegions(isMobile: boolean): ShellRegions { + const [sidebarOpen, setSidebarOpen] = useState(true); + const [railView, setRailView] = useState("hosts"); + const [sidebarWidth, setSidebarWidth] = useState(() => { + const saved = localStorage.getItem("termix_sidebarWidth"); + return saved ? parseInt(saved, 10) : 291; + }); + const [sidebarDragging, setSidebarDragging] = useState(false); + // How far each panel wants the sidebar widened: what the panel says itself, + // and what its open inline views say. + const [panelEditing, setPanelEditing] = useState< + Record + >({}); + const [scopeEditing, setScopeEditing] = useState< + Record + >({}); + const editingSetters = useRef( + new Map void>(), + ); + const panelEditingSetter = useCallback((view: string) => { + const key = `panel:${view}`; + if (!editingSetters.current.has(key)) + editingSetters.current.set(key, (editing) => + setPanelEditing((prev) => + prev[view] === editing ? prev : { ...prev, [view]: editing }, + ), + ); + return editingSetters.current.get(key)!; + }, []); + const scopeEditingSetter = useCallback((view: string) => { + const key = `scope:${view}`; + if (!editingSetters.current.has(key)) + editingSetters.current.set(key, (editing) => + setScopeEditing((prev) => + prev[view] === editing ? prev : { ...prev, [view]: editing }, + ), + ); + return editingSetters.current.get(key)!; + }, []); + + // Right dock: a second panel column so reference panels like history can + // stay visible while the left sidebar is used for something else. + const [rightRailView, setRightRailView] = useState(() => { + const saved = localStorage.getItem("termix_rightRailView"); + return saved && rightDockableIds().includes(saved) ? saved : null; + }); + const [rightSidebarWidth, setRightSidebarWidth] = useState(() => { + const saved = localStorage.getItem("termix_rightSidebarWidth"); + return saved ? parseInt(saved, 10) : 291; + }); + const [rightSidebarDragging, setRightSidebarDragging] = useState(false); + // Remembers the last panel shown in the dock so the tab bar toggle can bring + // it back instead of always falling back to the same default. + const lastRightRailViewRef = useRef( + localStorage.getItem("termix_lastRightRailView"), + ); + + useEffect(() => { + localStorage.setItem("termix_sidebarWidth", String(sidebarWidth)); + }, [sidebarWidth]); + + useEffect(() => { + localStorage.setItem("termix_rightSidebarWidth", String(rightSidebarWidth)); + }, [rightSidebarWidth]); + + useEffect(() => { + if (rightRailView) { + localStorage.setItem("termix_rightRailView", rightRailView); + lastRightRailViewRef.current = rightRailView; + localStorage.setItem("termix_lastRightRailView", rightRailView); + } else { + localStorage.removeItem("termix_rightRailView"); + } + }, [rightRailView]); + + const sidebarOpenBeforeMobile = useRef(sidebarOpen); + useEffect(() => { + if (isMobile) { + sidebarOpenBeforeMobile.current = sidebarOpen; + setSidebarOpen(false); + } else { + setSidebarOpen(sidebarOpenBeforeMobile.current); + } + }, [isMobile]); + + // The panel shown before the current one, for Ctrl+Shift+E. + const previousRailViewRef = useRef(null); + const currentRailViewRef = useRef(railView); + useEffect(() => { + if (currentRailViewRef.current !== railView) { + previousRailViewRef.current = currentRailViewRef.current; + currentRailViewRef.current = railView; + } + }, [railView]); + + // Moving a panel to the right dock rather than copying it: two live copies of + // the same panel would fight over the shared editing state. + function openInRightDock(view: string) { + setRightRailView(view); + if (railView === view) setSidebarOpen(false); + } + + // Tab bar toggle: reopens whatever was last in the dock, so it behaves like a + // show/hide rather than losing the user's choice each time. + function toggleRightDock() { + if (rightRailView) { + lastRightRailViewRef.current = rightRailView; + setRightRailView(null); + return; + } + const fallback = lastRightRailViewRef.current ?? rightDockableIds()[0]; + if (fallback) setRightRailView(fallback); + } + + function railClick(view: string) { + if (railView === view && sidebarOpen) { + setSidebarOpen(false); + } else { + // A panel lives in one dock at a time, so the left dock reclaims it. + if (rightRailView === view) setRightRailView(null); + setRailView(view); + setSidebarOpen(true); + } + } + + const startLeftResize = useCallback( + (e: React.MouseEvent) => + startDockDrag(e, sidebarWidth, 1, setSidebarWidth, setSidebarDragging), + [sidebarWidth], + ); + // Same drag, mirrored: the right dock grows as the pointer moves left. + const startRightResize = useCallback( + (e: React.MouseEvent) => + startDockDrag( + e, + rightSidebarWidth, + -1, + setRightSidebarWidth, + setRightSidebarDragging, + ), + [rightSidebarWidth], + ); + + const leftEditing: EditingWidth = (() => { + const values = [panelEditing[railView], scopeEditing[railView]]; + if (values.includes("wide")) return "wide"; + return values.some(Boolean); + })(); + + return { + left: { + view: railView, + open: sidebarOpen, + width: sidebarWidth, + dragging: sidebarDragging, + editing: leftEditing, + setView: setRailView, + setOpen: setSidebarOpen, + setWidth: setSidebarWidth, + startResize: startLeftResize, + resetWidth: () => setSidebarWidth(DOCK_DEFAULT_WIDTH), + }, + right: { + view: rightRailView, + open: rightRailView !== null, + width: rightSidebarWidth, + dragging: rightSidebarDragging, + setView: setRightRailView, + setWidth: setRightSidebarWidth, + startResize: startRightResize, + resetWidth: () => setRightSidebarWidth(DOCK_DEFAULT_WIDTH), + }, + railClick, + openInRightDock, + toggleRightDock, + previousView: () => previousRailViewRef.current, + panelEditingSetter, + scopeEditingSetter, + }; +} diff --git a/src/ui/shell/controller/use-shell-keybindings.ts b/src/ui/shell/controller/use-shell-keybindings.ts new file mode 100644 index 000000000..6f9aac0a6 --- /dev/null +++ b/src/ui/shell/controller/use-shell-keybindings.ts @@ -0,0 +1,265 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { useEffect, useRef, useState, type MutableRefObject } from "react"; +import type { TabHandle } from "@termix-ssh/plugin-sdk/frontend"; +import { + dispatchCtrlW, + createCommandPaletteShortcutMatcher, + isShiftKey, +} from "@/lib/app-keyboard-shortcuts"; +import { getUserPreferences } from "@/main-axios"; +import { parseCustomKeybindings } from "@/api/open-tabs-api"; +import { findMatchingKeybinding } from "@/lib/keybinding-match"; +import type { + CustomKeybinding, + KeybindingActionType, +} from "@/types/keybindings"; +import { + GLOBAL_KEYBINDING_EVENT, + getKeybindingAction, + runKeybindingAction, +} from "@/shell/keybinding-registry"; +import { globalShortcutHandler } from "@/lib/global-shortcut-handler"; +import { getPaneNavDirection } from "@/lib/pane-nav-hotkey"; +import { getTabJumpDigit } from "@/lib/tab-jump-hotkey"; +import type { TabsState } from "./use-tabs"; +import type { SplitActions } from "./use-split"; +import type { ShellPalette, ShellRegions } from "./types"; + +/** Every shell-wide shortcut: custom bindings, palette, panes and tabs. */ +export function useShellKeybindings({ + tabsState, + regions, + palette, + splitActionsRef, + toggleFullscreen, +}: { + tabsState: TabsState; + regions: ShellRegions; + palette: ShellPalette; + splitActionsRef: MutableRefObject; + toggleFullscreen: () => void; +}) { + const { tabsRef, activeTabIdRef, setActiveTabId, closeActiveTabRef } = + tabsState; + const globalKeybindingsRef = useRef([]); + const toggleFullscreenRef = useRef(toggleFullscreen); + toggleFullscreenRef.current = toggleFullscreen; + + useEffect(() => { + let cancelled = false; + const load = () => { + getUserPreferences() + .then((prefs) => { + if (cancelled) return; + globalKeybindingsRef.current = parseCustomKeybindings( + prefs.customKeybindings, + ).filter((binding) => binding.enabled); + }) + .catch(() => {}); + }; + load(); + window.addEventListener("customKeybindingsChanged", load); + return () => { + cancelled = true; + window.removeEventListener("customKeybindingsChanged", load); + }; + }, []); + + useEffect(() => { + const runAction = (type: KeybindingActionType) => { + if (type === "openCommandPalette") { + palette.setOpen(true); + return; + } + if (type === "reconnectSession") { + const tabId = tabsState.focusedSessionTabId(); + if (!tabId) return; + const termRef = tabsState.terminalRefs.current.get(tabId); + (termRef?.current as TabHandle | null)?.reconnect?.(); + return; + } + const currentTabs = tabsRef.current.filter( + (tab) => !tab.parentSplitTabId, + ); + if (currentTabs.length < 2) return; + const index = currentTabs.findIndex( + (tab) => tab.id === activeTabIdRef.current, + ); + const offset = type === "nextTab" ? 1 : -1; + const next = (index + offset + currentTabs.length) % currentTabs.length; + setActiveTabId(currentTabs[next].id); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.target instanceof Element && + event.target.closest("[data-keybinding-recorder]") + ) + return; + // Only actions that run anywhere; a terminal's own are its to handle. + const binding = findMatchingKeybinding( + event, + globalKeybindingsRef.current.filter( + (entry) => getKeybindingAction(entry.action.type)?.scope === "global", + ), + ); + if (!binding) return; + event.preventDefault(); + event.stopPropagation(); + runKeybindingAction(binding.action); + }; + const handleAction = (event: Event) => + runAction( + (event as CustomEvent<{ type: KeybindingActionType }>).detail.type, + ); + + window.addEventListener("keydown", handleKeyDown, true); + window.addEventListener(GLOBAL_KEYBINDING_EVENT, handleAction); + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + window.removeEventListener(GLOBAL_KEYBINDING_EVENT, handleAction); + }; + }, []); + + useEffect(() => { + return window.electronAPI?.onCloseActiveTab?.(() => { + if (dispatchCtrlW(document.activeElement)) return; + closeActiveTabRef.current(); + }); + }, []); + + const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] = + useState(() => { + const v = localStorage.getItem("commandPaletteShortcutEnabled"); + return v !== null ? v === "true" : true; + }); + + // Double-shift or Ctrl+K opens the command palette. Double-shift alone was + // hard to discover. + useEffect(() => { + if (!commandPaletteShortcutEnabled) return; + const shortcut = createCommandPaletteShortcutMatcher(); + const handleKeyDown = (e: KeyboardEvent) => { + if (!shortcut.matches(e)) return; + if (!isShiftKey(e)) e.preventDefault(); + palette.setOpen((prev) => !prev); + }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("blur", shortcut.reset); + window.addEventListener("compositionstart", shortcut.reset); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("blur", shortcut.reset); + window.removeEventListener("compositionstart", shortcut.reset); + }; + }, [commandPaletteShortcutEnabled]); + + // Ctrl+Shift+E toggles between the two most recent sidebar panels. + const railView = regions.left.view; + const sidebarOpen = regions.left.open; + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!e.ctrlKey || !e.shiftKey || e.altKey || e.code !== "KeyE") return; + e.preventDefault(); + const previous = regions.previousView(); + if (!sidebarOpen) { + regions.left.setOpen(true); + return; + } + if (previous && previous !== railView) regions.railClick(previous); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [railView, sidebarOpen]); + + // Split-screen and tab navigation hotkeys + // Also registered in globalShortcutHandler so xterm can invoke directly + // without going through synthetic DOM events (which are unreliable). + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { + if (e.code === "KeyF") { + e.preventDefault(); + toggleFullscreenRef.current(); + return; + } + } + + // Ctrl+Shift+\ splits right, Ctrl+Shift+- splits down, Ctrl+Shift+Enter + // zooms the focused pane. + if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { + if (e.code === "Backslash" || e.code === "Minus") { + e.preventDefault(); + splitActionsRef.current?.splitActive( + e.code === "Backslash" ? "right" : "bottom", + ); + return; + } + if (e.code === "Enter" && splitActionsRef.current?.zoomFocused()) { + e.preventDefault(); + return; + } + } + + // Alt+Arrow (Cmd+Option+Arrow on macOS) moves between panes of the active split + const paneDirection = getPaneNavDirection(e); + if (paneDirection && splitActionsRef.current?.navigate(paneDirection)) { + e.preventDefault(); + return; + } + + // Cmd+1..9 on macOS, Alt+1..9 elsewhere: jump directly to the tab at that position + const tabDigit = getTabJumpDigit(e); + if (tabDigit !== null) { + const currentTabs = tabsRef.current.filter( + (tab) => !tab.parentSplitTabId, + ); + const index = tabDigit - 1; + if (index < currentTabs.length) { + e.preventDefault(); + setActiveTabId(currentTabs[index].id); + } + return; + } + + // Ctrl+Shift+] / Ctrl+Shift+[: cycle through open tabs (] = next, [ = previous) + if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { + if (e.code === "BracketRight" || e.code === "BracketLeft") { + e.preventDefault(); + const currentTabs = tabsRef.current.filter( + (tab) => !tab.parentSplitTabId, + ); + if (currentTabs.length < 2) return; + const currentId = activeTabIdRef.current; + const idx = currentTabs.findIndex((t) => t.id === currentId); + const next = + e.code === "BracketRight" + ? (idx + 1) % currentTabs.length + : (idx - 1 + currentTabs.length) % currentTabs.length; + setActiveTabId(currentTabs[next].id); + return; + } + } + }; + + globalShortcutHandler.current = handleKeyDown; + window.addEventListener("keydown", handleKeyDown); + return () => { + globalShortcutHandler.current = null; + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); + + useEffect(() => { + const handler = () => { + const v = localStorage.getItem("commandPaletteShortcutEnabled"); + setCommandPaletteShortcutEnabled(v !== null ? v === "true" : true); + }; + window.addEventListener("commandPaletteShortcutEnabledChanged", handler); + return () => + window.removeEventListener( + "commandPaletteShortcutEnabledChanged", + handler, + ); + }, []); +} diff --git a/src/ui/shell/controller/use-shell-lifecycle.ts b/src/ui/shell/controller/use-shell-lifecycle.ts new file mode 100644 index 000000000..662011031 --- /dev/null +++ b/src/ui/shell/controller/use-shell-lifecycle.ts @@ -0,0 +1,261 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { useCallback, useEffect, useState, type MutableRefObject } from "react"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import type { FontSizeId, ThemeId } from "@/types/ui-types"; +import { resetPermissionsCache } from "@/hooks/use-permissions"; +import { applyAccentColor, applyFontSize } from "@/lib/theme"; +import { useTheme } from "@/components/theme-provider"; +import { + getUserInfo, + getUserPreferences, + saveUserPreferences, + type UserPreferences, +} from "@/main-axios"; +import { dbHealthMonitor } from "@/lib/db-health-monitor"; +import { changeAppLanguage, consumeLoginLanguage } from "@/i18n/i18n"; +import { + MANAGE_REQUEST_EVENT, + requestFromLegacyEvent, + requestManage, +} from "@/manage/manage-requests"; +import type { TabType } from "@/types/ui-types"; + +/** Keys a cloud-synced profile overwrites, snapshotted first so they can come back. */ +const SNAPSHOT_KEYS = [ + "termix-accent", + "termix-font-size", + "termix-ui-font", + "i18nextLng", + "commandPaletteShortcutEnabled", + "showHostTags", + "hostTrayOnClick", + "pinAppRail", + "expandAppRailOnHover", + "disableUpdateCheck", + "confirmTabClose", + "hiddenRailTabs", +]; + +/** Applies a cloud-synced profile to this browser. */ +function applyCloudPreferences( + prefs: UserPreferences, + setTheme: (theme: ThemeId) => void, + currentLanguage: string, + loginLanguage: string | null, +) { + // Persist the current browser values before overwriting, so any tab can restore them + if (!localStorage.getItem("termix-local-snapshot")) { + const snap: Record = { + __theme: localStorage.getItem("termix-theme"), + }; + for (const key of SNAPSHOT_KEYS) snap[key] = localStorage.getItem(key); + localStorage.setItem("termix-local-snapshot", JSON.stringify(snap)); + } + // The account's own pick wins over the older profile field. + const cloudTheme = prefs.themeId ?? prefs.theme; + if (cloudTheme) setTheme(cloudTheme as ThemeId); + if (prefs.fontSize) applyFontSize(prefs.fontSize as FontSizeId); + if (prefs.accentColor) { + localStorage.setItem("termix-accent", prefs.accentColor); + applyAccentColor(prefs.accentColor); + } + const preferredLanguage = loginLanguage ?? prefs.language; + if (preferredLanguage && preferredLanguage !== currentLanguage) { + void changeAppLanguage(preferredLanguage); + } + if (loginLanguage && loginLanguage !== prefs.language) { + void saveUserPreferences({ language: loginLanguage }); + } + if ( + prefs.commandPaletteEnabled !== null && + prefs.commandPaletteEnabled !== undefined + ) + localStorage.setItem( + "commandPaletteShortcutEnabled", + String(prefs.commandPaletteEnabled), + ); + if (prefs.showHostTags !== null && prefs.showHostTags !== undefined) { + localStorage.setItem("showHostTags", String(prefs.showHostTags)); + window.dispatchEvent(new CustomEvent("showHostTagsChanged")); + } + if (prefs.hostTrayOnClick !== null && prefs.hostTrayOnClick !== undefined) + localStorage.setItem("hostTrayOnClick", String(prefs.hostTrayOnClick)); + if (prefs.pinAppRail !== null && prefs.pinAppRail !== undefined) { + localStorage.setItem("pinAppRail", String(prefs.pinAppRail)); + window.dispatchEvent(new Event("pinAppRailChanged")); + } + if ( + prefs.expandAppRailOnHover !== null && + prefs.expandAppRailOnHover !== undefined + ) { + localStorage.setItem( + "expandAppRailOnHover", + String(prefs.expandAppRailOnHover), + ); + window.dispatchEvent(new Event("expandAppRailOnHoverChanged")); + } + if ( + prefs.disableUpdateCheck !== null && + prefs.disableUpdateCheck !== undefined + ) + localStorage.setItem( + "disableUpdateCheck", + String(prefs.disableUpdateCheck), + ); + if (prefs.confirmTabClose !== null && prefs.confirmTabClose !== undefined) + localStorage.setItem("confirmTabClose", String(prefs.confirmTabClose)); + if (prefs.hiddenRailTabs !== null && prefs.hiddenRailTabs !== undefined) { + localStorage.setItem("hiddenRailTabs", prefs.hiddenRailTabs); + window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged")); + } +} + +/** Who is signed in and what the shell does around the tabs: prefs, health, logout. */ +export function useShellLifecycle({ + onLogout, + openSingletonTabRef, +}: { + onLogout: (options?: { manual?: boolean }) => void; + openSingletonTabRef: MutableRefObject<(type: TabType) => string | undefined>; +}) { + const { t, i18n } = useTranslation(); + const { setTheme } = useTheme(); + const [userPrefs, setUserPrefs] = useState({ + reopenTabsOnLogin: false, + }); + const [userPrefsLoaded, setUserPrefsLoaded] = useState(false); + const [isAdmin, setIsAdmin] = useState(false); + const [isAppFullscreen, setIsAppFullscreen] = useState( + () => !!document.fullscreenElement, + ); + + // Anything that asks to add or edit a host, a credential or host defaults + // ends up in the Manage tab, whichever event it still speaks. + useEffect(() => { + const names = [ + "host-manager:add-host", + "host-manager:edit-host", + "host-manager:add-credential", + "host-manager:show-credentials", + "host-manager:edit-defaults", + "termix:open-host-defaults", + ]; + const handler = (e: Event) => { + const request = requestFromLegacyEvent(e.type, (e as CustomEvent).detail); + if (request) requestManage(request); + }; + const open = () => openSingletonTabRef.current("host-manager"); + for (const name of names) window.addEventListener(name, handler); + window.addEventListener(MANAGE_REQUEST_EVENT, open); + return () => { + for (const name of names) window.removeEventListener(name, handler); + window.removeEventListener(MANAGE_REQUEST_EVENT, open); + }; + }, []); + + useEffect(() => { + getUserInfo() + .then((info) => setIsAdmin(info.is_admin)) + .catch(() => setIsAdmin(false)); + }, []); + + const toggleAppFullscreen = useCallback(async () => { + try { + if (document.fullscreenElement) { + await document.exitFullscreen(); + return; + } + + if (!document.fullscreenEnabled) { + toast.error(t("nav.fullscreenUnsupported")); + return; + } + + await document.documentElement.requestFullscreen(); + } catch { + toast.error(t("nav.fullscreenFailed")); + } + }, []); + + useEffect(() => { + const handleFullscreenChange = () => { + setIsAppFullscreen(!!document.fullscreenElement); + }; + + document.addEventListener("fullscreenchange", handleFullscreenChange); + return () => + document.removeEventListener("fullscreenchange", handleFullscreenChange); + }, []); + + useEffect(() => { + const handle = (event: Event) => { + const manual = + event instanceof CustomEvent && + (event.detail as { manual?: boolean } | undefined)?.manual === true; + // Drop the cached grants so the next user never inherits them. + resetPermissionsCache(); + onLogout(manual ? { manual: true } : undefined); + }; + window.addEventListener("termix:logout", handle); + return () => window.removeEventListener("termix:logout", handle); + }, [onLogout]); + + useEffect(() => { + const handleSessionExpired = () => onLogout(); + dbHealthMonitor.on("session-expired", handleSessionExpired); + return () => dbHealthMonitor.off("session-expired", handleSessionExpired); + }, [onLogout]); + + useEffect(() => { + const handleDegraded = () => { + toast.loading(t("common.connectionDegraded"), { + id: "db-connection-degraded", + duration: Infinity, + dismissible: false, + action: { + label: t("common.reload"), + onClick: () => window.location.reload(), + }, + }); + }; + + const handleRestored = () => { + toast.dismiss("db-connection-degraded"); + toast.success(t("common.backendReconnected"), { duration: 3000 }); + }; + + dbHealthMonitor.on("database-connection-degraded", handleDegraded); + dbHealthMonitor.on("database-connection-degraded-cleared", handleRestored); + + return () => { + dbHealthMonitor.off("database-connection-degraded", handleDegraded); + dbHealthMonitor.off( + "database-connection-degraded-cleared", + handleRestored, + ); + }; + }, [t]); + + useEffect(() => { + getUserPreferences() + .then((prefs) => { + const loginLanguage = consumeLoginLanguage(); + setUserPrefs(prefs); + if (prefs.storageMode === "cloud") { + applyCloudPreferences(prefs, setTheme, i18n.language, loginLanguage); + } + }) + .catch(() => {}) + .finally(() => setUserPrefsLoaded(true)); + }, []); + + return { + isAdmin, + userPrefs, + setUserPrefs, + userPrefsLoaded, + isAppFullscreen, + toggleAppFullscreen, + }; +} diff --git a/src/ui/shell/controller/use-split.ts b/src/ui/shell/controller/use-split.ts new file mode 100644 index 000000000..fd0892a77 --- /dev/null +++ b/src/ui/shell/controller/use-split.ts @@ -0,0 +1,610 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { useEffect, useLayoutEffect, useRef } from "react"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { Columns2, Maximize2, RotateCcw, Rows2, X } from "lucide-react"; +import type { TabHandle } from "@termix-ssh/plugin-sdk/frontend"; +import type { Host } from "@/types/ui-types"; +import { createId } from "@/lib/create-id"; +import { registerPaletteEntry } from "@/shell/palette-registry"; +import type { TabShellCallbacks } from "@/shell/tab-registry"; +import { runHostAction } from "@/sidebar/host-contributions"; +import type { SplitViewActions } from "@/shell/split/SplitView"; +import type { PickHostTarget } from "@/shell/split/EmptyPanePicker"; +import { + addSplitTab, + canJoinSplit, + closeSplitTab, + isSplitTab, + makeSplitTab, + nextSplitNumber, + placeTabInPane, + splitTabOf, + summarizeSplits, + updateSplit, + type SplitTab, + type TabSplitAction, +} from "@/shell/split/split-tabs"; +import { + addPaneAtEdge, + assignTab, + canAddPane, + createPane, + createSplitNode, + createSplitState, + equalizeSplit, + findPane, + findPaneByTab, + focusPane, + listPanes, + MAX_PANES, + movePane, + neighborPane, + removePane, + resizeSplit, + shownPanes, + splitPane, + swapPanes, + toggleZoom, + type NavDirection, + type PaneEdge, + type PaneRect, + type SplitState, +} from "@/shell/split/split-tree"; +import { + applyPreset, + buildPreset, + type SplitPresetId, +} from "@/shell/split/split-presets"; +import { + setSplitDropHandler, + type SplitDragSource, + type SplitDropHover, +} from "@/shell/split/split-drag"; +import { + publishSplitTargets, + setSplitOpener, + type SplitOpenTarget, +} from "@/shell/split/split-targets"; +import { useTabTypes } from "@/shell/tab-registry"; +import { useSplitLabel, type TabsState } from "./use-tabs"; +import type { ShellSplit } from "./types"; + +export interface SplitActions { + splitActive: (edge: "right" | "bottom") => void; + navigate: (direction: NavDirection) => boolean; + zoomFocused: () => boolean; + closeFocusedPane: () => void; +} + +export function useSplit({ + tabsState, + isMobile, + paneEls, + callbacks, + resizeAllTerminals, +}: { + tabsState: TabsState; + isMobile: boolean; + paneEls: Map; + callbacks: TabShellCallbacks; + resizeAllTerminals: () => number; +}) { + const { t } = useTranslation(); + const splitLabel = useSplitLabel(); + const tabTypes = useTabTypes(); + const { + tabs, + setTabs, + tabsReady, + activeTabId, + setActiveTabId, + tabsRef, + activeTabIdRef, + targetPane, + } = tabsState; + + /** + * Edits a split with a change computed from the last committed state, for + * edits that create panes and so have to know the new pane's id. + */ + function commitSplit( + splitTabId: string, + fn: (state: SplitState) => SplitState | null, + ): SplitState | null { + const current = tabsRef.current.find((tab) => tab.id === splitTabId); + if (!isSplitTab(current)) return null; + const next = fn(current.split); + setTabs((prev) => + updateSplit(prev, splitTabId, (state) => + state === current.split ? next : fn(state), + ), + ); + return next; + } + + function editSplit( + splitTabId: string, + fn: (state: SplitState) => SplitState | null, + ) { + setTabs((prev) => updateSplit(prev, splitTabId, fn)); + } + + function warnSplitFull() { + toast.error(t("splitScreen.maxPanes", { count: MAX_PANES })); + } + + function openSplitTab(state: SplitState): SplitTab { + const number = nextSplitNumber(tabsRef.current, splitLabel); + const splitTab = makeSplitTab(createId(), splitLabel(number), state); + setTabs((prev) => addSplitTab(prev, splitTab)); + setActiveTabId(splitTab.id); + return splitTab; + } + + /** A new split of `tabId` (or of an empty pane) with a fresh pane at `edge`. */ + function startSplit(tabId: string | null, edge: PaneEdge): SplitTab { + const own = createPane(tabId); + const fresh = createPane(); + const leading = edge === "left" || edge === "top"; + const root = createSplitNode( + edge === "left" || edge === "right" ? "row" : "column", + leading ? [fresh, own] : [own, fresh], + ); + return openSplitTab(createSplitState(root, fresh.id)); + } + + function splitPaneOf(splitTabId: string, paneId: string, edge: PaneEdge) { + commitSplit(splitTabId, (state) => { + const result = splitPane(state, paneId, edge); + if (!result) warnSplitFull(); + return result ? result.state : state; + }); + setActiveTabId(splitTabId); + } + + /** + * Splits a tab: a split grows at its focused pane, a tab inside a split + * grows at its own pane, anything else becomes a new split. + */ + function splitTabAt(tabId: string, edge: PaneEdge) { + const current = tabsRef.current; + const tab = current.find((t) => t.id === tabId); + if (!tab) return; + if (isSplitTab(tab)) { + splitPaneOf(tab.id, tab.split.focusedPaneId, edge); + return; + } + const parent = splitTabOf(current, tabId); + const pane = parent ? findPaneByTab(parent.split, tabId) : undefined; + if (parent && pane) { + splitPaneOf(parent.id, pane.id, edge); + return; + } + startSplit(canJoinSplit(tab) ? tab.id : null, edge); + } + + function applyLayoutPreset(presetId: SplitPresetId) { + const active = tabsRef.current.find( + (tab) => tab.id === activeTabIdRef.current, + ); + if (isSplitTab(active)) { + commitSplit(active.id, (state) => applyPreset(state, presetId)); + return; + } + const root = buildPreset( + presetId, + active && canJoinSplit(active) ? [active.id] : [], + ); + openSplitTab(createSplitState(root)); + } + + /** Closes a split tab, sending its tabs back to the tab bar. */ + function unsplit(splitTabId: string) { + const split = tabsRef.current.find((tab) => tab.id === splitTabId); + if (!isSplitTab(split)) return; + const focusedTabId = + findPane(split.split, split.split.focusedPaneId)?.tabId ?? + listPanes(split.split.root).find((pane) => pane.tabId)?.tabId ?? + null; + setTabs((prev) => closeSplitTab(prev, splitTabId)); + if (activeTabIdRef.current === splitTabId) { + const fallback = tabsRef.current.filter( + (tab) => tab.id !== splitTabId && !tab.parentSplitTabId, + ); + setActiveTabId( + focusedTabId ?? fallback[fallback.length - 1]?.id ?? "dashboard", + ); + } + } + + /** Removes a pane; its tab, if any, goes back to the tab bar. */ + function closePaneOf(splitTabId: string, paneId: string) { + const split = tabsRef.current.find((tab) => tab.id === splitTabId); + if (!isSplitTab(split)) return; + if (listPanes(split.split.root).length <= 1) { + unsplit(splitTabId); + return; + } + editSplit(splitTabId, (state) => removePane(state, paneId)); + } + + tabsState.splitOpsRef.current = { unsplit, closePaneOf }; + + function handleTabSplitAction(action: TabSplitAction) { + switch (action.kind) { + case "split": + splitTabAt(action.tabId, action.edge); + break; + case "splitActive": + splitTabAt(activeTabIdRef.current, action.edge); + break; + case "preset": + applyLayoutPreset(action.presetId); + break; + case "unsplit": + unsplit(action.splitTabId); + break; + case "addToPane": + setTabs((prev) => + placeTabInPane(prev, action.splitTabId, action.paneId, action.tabId), + ); + setActiveTabId(action.splitTabId); + break; + case "addPane": + commitSplit(action.splitTabId, (state) => { + const result = addPaneAtEdge(state, action.edge, action.tabId); + if (!result) warnSplitFull(); + return result ? result.state : state; + }); + setActiveTabId(action.splitTabId); + break; + } + } + + /** "Open in split" from the host list: aim the next tab, then open it. */ + const splitOpenerRef = useRef< + (target: SplitOpenTarget, open: () => void) => void + >(() => {}); + splitOpenerRef.current = (target, open) => { + if (target.kind === "pane") { + targetPane(target.splitTabId, target.paneId); + } else if (target.kind === "newPane") { + const split = tabsRef.current.find((tab) => tab.id === target.splitTabId); + if (!isSplitTab(split)) return; + const result = addPaneAtEdge(split.split, "right"); + if (!result) { + warnSplitFull(); + return; + } + editSplit(split.id, (state) => + state === split.split ? result.state : state, + ); + setActiveTabId(split.id); + targetPane(split.id, result.paneId, true); + } else { + const active = tabsRef.current.find( + (tab) => tab.id === activeTabIdRef.current, + ); + if (!active || !canJoinSplit(active)) return; + const splitTab = startSplit(active.id, "right"); + targetPane(splitTab.id, splitTab.split.focusedPaneId, true); + } + open(); + }; + + /** A tab or pane dropped on the main area. */ + const splitDropRef = useRef< + (source: SplitDragSource, hover: SplitDropHover) => void + >(() => {}); + splitDropRef.current = (source, hover) => { + if (source.kind === "pane") { + const toPaneId = hover.paneId; + if (!toPaneId) return; + editSplit(source.splitTabId, (state) => + movePane(state, source.paneId, toPaneId, hover.target), + ); + return; + } + const active = tabsRef.current.find( + (tab) => tab.id === activeTabIdRef.current, + ); + if (hover.paneId === null) { + if (hover.target === "center") return; + const other = + active && canJoinSplit(active) && active.id !== source.tabId + ? active.id + : null; + const dragged = createPane(source.tabId); + const rest = createPane(other); + const leading = hover.target === "left" || hover.target === "top"; + const root = createSplitNode( + hover.target === "left" || hover.target === "right" ? "row" : "column", + leading ? [dragged, rest] : [rest, dragged], + ); + openSplitTab(createSplitState(root, dragged.id)); + return; + } + if (!isSplitTab(active)) return; + const paneId = hover.paneId; + if (hover.target === "center") { + setTabs((prev) => placeTabInPane(prev, active.id, paneId, source.tabId)); + return; + } + const edge = hover.target; + commitSplit(active.id, (state) => { + const result = splitPane(state, paneId, edge, source.tabId); + if (!result) warnSplitFull(); + return result ? result.state : state; + }); + }; + + // Split actions for handlers registered once, e.g. the global hotkeys. + const splitActionsRef = useRef(null); + splitActionsRef.current = { + splitActive: (edge) => splitTabAt(activeTabIdRef.current, edge), + navigate: (direction) => { + const active = tabsRef.current.find( + (tab) => tab.id === activeTabIdRef.current, + ); + if (!isSplitTab(active)) return false; + const view = document.querySelector(`[data-split-view="${active.id}"]`); + if (!view) return false; + const rects: Record = {}; + view + .querySelectorAll("[data-split-pane-id]") + .forEach((el) => { + const id = el.dataset.splitPaneId; + if (id) rects[id] = el.getBoundingClientRect(); + }); + const next = neighborPane(active.split.focusedPaneId, direction, rects); + if (next) editSplit(active.id, (state) => focusPane(state, next)); + return true; + }, + zoomFocused: () => { + const active = tabsRef.current.find( + (tab) => tab.id === activeTabIdRef.current, + ); + if (!isSplitTab(active) || listPanes(active.split.root).length < 2) { + return false; + } + editSplit(active.id, (state) => toggleZoom(state, state.focusedPaneId)); + return true; + }, + closeFocusedPane: () => { + const active = tabsRef.current.find( + (tab) => tab.id === activeTabIdRef.current, + ); + if (isSplitTab(active)) { + closePaneOf(active.id, active.split.focusedPaneId); + } + }, + }; + + useEffect(() => { + const disposeOpener = setSplitOpener((target, open) => + splitOpenerRef.current(target, open), + ); + const disposeDrop = setSplitDropHandler((source, hover) => + splitDropRef.current(source, hover), + ); + const activeIsSplit = () => + isSplitTab( + tabsRef.current.find((tab) => tab.id === activeTabIdRef.current), + ); + const entries = [ + registerPaletteEntry({ + id: "core.reconnectDisconnected", + titleKey: "nav.reconnectDisconnectedTerminals", + icon: RotateCcw, + keywords: ["reconnect", "disconnected", "ssh", "all", "network"], + scope: "global", + run: () => tabsState.reconnectAllRef.current(), + }), + registerPaletteEntry({ + id: "core.split.right", + titleKey: "splitScreen.splitRight", + icon: Columns2, + keywords: ["split", "pane"], + scope: "global", + run: () => splitActionsRef.current?.splitActive("right"), + }), + registerPaletteEntry({ + id: "core.split.down", + titleKey: "splitScreen.splitDown", + icon: Rows2, + keywords: ["split", "pane"], + scope: "global", + run: () => splitActionsRef.current?.splitActive("bottom"), + }), + registerPaletteEntry({ + id: "core.split.zoom", + titleKey: "splitScreen.zoom", + icon: Maximize2, + keywords: ["split", "pane"], + scope: "global", + when: activeIsSplit, + run: () => void splitActionsRef.current?.zoomFocused(), + }), + registerPaletteEntry({ + id: "core.split.closePane", + titleKey: "splitScreen.closePane", + icon: X, + keywords: ["split", "pane"], + scope: "global", + when: activeIsSplit, + run: () => splitActionsRef.current?.closeFocusedPane(), + }), + ]; + return () => { + disposeOpener(); + disposeDrop(); + for (const dispose of entries) dispose(); + }; + }, []); + + const activeTab = tabs.find((tab) => tab.id === activeTabId); + const activeSplit = isSplitTab(activeTab) ? activeTab : null; + // The pane each on-screen tab sits in, for the active split only. + const paneIdByTabId = new Map(); + if (activeSplit) { + for (const pane of shownPanes(activeSplit.split, isMobile)) { + if (pane.tabId) paneIdByTabId.set(pane.tabId, pane.id); + } + } + // Changes when panes appear, move or change tab, but not on a divider drag, + // which fits the terminals itself once it ends. + const splitShapeKey = activeSplit + ? `${activeSplit.id}|${activeSplit.split.zoomedPaneId ?? ""}|${[ + ...paneIdByTabId.entries(), + ].join(",")}|${listPanes(activeSplit.split.root).length}` + : ""; + + // A tab inside a split is never shown on its own: activating one (tab jump, + // the connections panel, an existing session) shows its split instead, + // focused on its pane. Also falls back when the active tab is gone. + useLayoutEffect(() => { + if (!activeTab) { + if (!tabsReady) return; + const topLevel = tabs.filter((tab) => !tab.parentSplitTabId); + setActiveTabId(topLevel[topLevel.length - 1]?.id ?? "dashboard"); + return; + } + if (!activeTab.parentSplitTabId) return; + const parent = splitTabOf(tabs, activeTab.id); + if (!parent) return; + const childId = activeTab.id; + setTabs((prev) => + updateSplit(prev, parent.id, (state) => { + const pane = findPaneByTab(state, childId); + return pane ? focusPane(state, pane.id) : state; + }), + ); + setActiveTabId(parent.id); + }, [activeTab, tabs, tabsReady]); + + // Moving focus to a pane moves keyboard focus into its session, unless the + // user is in a menu or already typing in that pane. + const focusedPaneId = activeSplit?.split.focusedPaneId ?? null; + const focusedPaneTabId = focusedPaneId + ? (findPane(activeSplit!.split, focusedPaneId)?.tabId ?? null) + : null; + useEffect(() => { + if (!focusedPaneId || !focusedPaneTabId) return; + let attempts = 0; + let timer: ReturnType | undefined; + const attempt = () => { + const focused = document.activeElement as HTMLElement | null; + const paneEl = paneEls.get(focusedPaneId); + if (focused && paneEl?.contains(focused)) return; + if ( + focused?.closest?.( + '[data-split-pane-header], [role="menu"], [role="dialog"]', + ) + ) { + return; + } + const tab = tabsRef.current.find((t) => t.id === focusedPaneTabId); + const handle = tab?.terminalRef?.current as TabHandle | null | undefined; + if (handle?.focus) { + handle.focus(); + return; + } + // A session opened into the pane a moment ago may not have mounted yet. + if (attempts++ < 10) timer = setTimeout(attempt, 100); + }; + const frame = requestAnimationFrame(attempt); + return () => { + cancelAnimationFrame(frame); + if (timer) clearTimeout(timer); + }; + }, [focusedPaneId, focusedPaneTabId]); + + useEffect(() => { + publishSplitTargets({ + splits: summarizeSplits(tabs, MAX_PANES), + canStartSplit: canJoinSplit(activeTab) && !activeTab?.parentSplitTabId, + }); + }, [tabs, activeTab]); + + function splitViewActions(splitTabId: string): SplitViewActions { + return { + focusPane: (paneId) => + editSplit(splitTabId, (state) => focusPane(state, paneId)), + resize: (nodeId, sizes) => + editSplit(splitTabId, (state) => resizeSplit(state, nodeId, sizes)), + equalize: (nodeId) => + editSplit(splitTabId, (state) => equalizeSplit(state, nodeId)), + splitPane: (paneId, edge) => splitPaneOf(splitTabId, paneId, edge), + closePane: (paneId) => closePaneOf(splitTabId, paneId), + closeSession: (tabId) => tabsState.closeTab(tabId), + moveToTab: (paneId) => closePaneOf(splitTabId, paneId), + showInPane: (paneId, tabId) => { + if (tabId) { + setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId)); + } else { + editSplit(splitTabId, (state) => assignTab(state, paneId, null)); + } + }, + swapPanes: (firstId, secondId) => + editSplit(splitTabId, (state) => swapPanes(state, firstId, secondId)), + toggleZoom: (paneId) => + editSplit(splitTabId, (state) => toggleZoom(state, paneId)), + resizeEnd: () => resizeAllTerminals(), + }; + } + + // Picking a host for a pane always starts a new session, so it never pulls + // a session out of the tab bar or another split. + const pickerShell: TabShellCallbacks = { + ...callbacks, + openTab: (host, type, options) => + callbacks.openTab(host, type, { ...options, forceNewTab: true }), + }; + + const split: ShellSplit = { + active: activeSplit, + paneIdByTabId, + focusedPaneId, + summaries: summarizeSplits(tabs, MAX_PANES), + activeFull: activeSplit ? !canAddPane(activeSplit.split) : false, + handleAction: handleTabSplitAction, + viewActions: splitViewActions, + emptyPane: { + freeTabs: tabs.filter( + (tab) => !tab.parentSplitTabId && canJoinSplit(tab), + ), + newTabOptions: tabTypes + .filter((def) => def.multiInstance) + .map((def) => ({ + type: def.id, + label: def.titleKey ? t(def.titleKey) : def.id, + icon: def.icon, + })), + pickTab: (splitTabId, paneId, tabId) => + setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId)), + pickHost: (splitTabId, paneId, host: Host, pick: PickHostTarget) => { + targetPane(splitTabId, paneId); + if ("item" in pick) { + const item = pick.action + .items?.(host) + .find((candidate) => candidate.id === pick.item.id); + item?.run(host, pickerShell); + } else { + runHostAction(pick.action, host, pickerShell); + } + }, + openType: (splitTabId, paneId, type) => { + targetPane(splitTabId, paneId); + tabsState.openMultiInstanceTab(type); + }, + closePane: closePaneOf, + }, + }; + + return { + split, + splitShapeKey, + splitActionsRef, + }; +} diff --git a/src/ui/shell/controller/use-tabs.ts b/src/ui/shell/controller/use-tabs.ts new file mode 100644 index 000000000..4ce20b342 --- /dev/null +++ b/src/ui/shell/controller/use-tabs.ts @@ -0,0 +1,944 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { + createRef, + useCallback, + useEffect, + useRef, + useState, + type MutableRefObject, +} from "react"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import type { TabHandle } from "@termix-ssh/plugin-sdk/frontend"; +import type { ConfirmOptions } from "@/components/surface/surface-scope"; +import type { Host, Tab, TabType } from "@/types/ui-types"; +import { + addOpenTab, + createSSHHost, + deleteOpenTab, + getActiveSessions, + getOpenTabs, + patchOpenTab, + type OpenTabRecord, + type UserPreferences, +} from "@/main-axios"; +import { createId } from "@/lib/create-id"; +import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host"; +import { railItemLabel } from "@/sidebar/rail-items"; +import { resolveHostTabType } from "@/lib/host-connection-tabs"; +import { reconnectDisconnectedTabs } from "@/shell/reconnect-tabs"; +import { canCloseTab } from "@/shell/tab-close-guards"; +import { + canRestoreTabType, + getTabType, + isPersistentTabType, + isSessionTabType, +} from "@/shell/tab-registry"; +import { + requestFromLegacyEvent, + requestManage, +} from "@/manage/manage-requests"; +import { + addSplitTab, + canJoinSplit, + isSplitTab, + makeSplitTab, + placeTabInPane, + removeTab, + restoreSplitTabs, + serializeSplitTabs, +} from "@/shell/split/split-tabs"; +import { + createSplitState, + findPane, + listPanes, +} from "@/shell/split/split-tree"; +import { fromLegacyConfig } from "@/shell/split/split-presets"; +import { + notifyShellReady, + notifyTabsChanged, +} from "@/plugin-host/shell-bridge"; +import { + OPEN_PLUGINS_EVENT, + OPEN_PLUGIN_SETTINGS_EVENT, +} from "@/plugins/open-plugins"; +import type { OpenTabOptions, RestoreTarget } from "./types"; + +const PENDING_TARGET_MS = 60_000; + +export function dashboardTab(label: string): Tab { + return { + id: "dashboard", + instanceId: "dashboard", + type: "dashboard", + label, + openedAt: Date.now(), + }; +} + +/** What the tab code needs from the split code, filled in by useSplit. */ +export interface SplitOps { + unsplit: (splitTabId: string) => void; + closePaneOf: (splitTabId: string, paneId: string) => void; +} + +export function useSplitLabel() { + const { t } = useTranslation(); + return (number: number) => t("splitScreen.defaultLabel", { number }); +} + +export function useTabs({ + hosts, + hostsRef, + hostsLoaded, + reloadHosts, + userPrefs, + userPrefsLoaded, + pluginsSettled, + confirmRef, + layoutDeps, +}: { + hosts: Host[]; + hostsRef: MutableRefObject; + hostsLoaded: boolean; + reloadHosts: () => Promise; + userPrefs: UserPreferences; + userPrefsLoaded: boolean; + pluginsSettled: boolean; + confirmRef: MutableRefObject< + ((options: ConfirmOptions) => Promise) | null + >; + /** Region state whose change also counts as a layout change for plugins. */ + layoutDeps: unknown[]; +}) { + const { t } = useTranslation(); + const splitLabel = useSplitLabel(); + const [tabs, setTabs] = useState([dashboardTab(t("nav.dashboard"))]); + const [activeTabId, setActiveTabId] = useState("dashboard"); + // Flips to true once the initial DB read (restore or skip) is done. Sync must not fire before this + const [tabsReady, setTabsReady] = useState(false); + const [backgroundTabRecords, setBackgroundTabRecords] = useState< + OpenTabRecord[] + >([]); + // Split tabs are restored once their child tabs have stable live ids. + const paneLayoutRestoredRef = useRef(false); + const splitTabsRestoredRef = useRef(false); + const splitOpsRef = useRef({ + unsplit: () => {}, + closePaneOf: () => {}, + }); + + useEffect(() => { + if (!splitTabsRestoredRef.current) return; + localStorage.setItem( + "termix_splitTabs", + JSON.stringify(serializeSplitTabs(tabs)), + ); + }, [tabs]); + + const tabsRef = useRef(tabs); + const activeTabIdRef = useRef(activeTabId); + const closeActiveTabRef = useRef<() => void>(() => {}); + useEffect(() => { + tabsRef.current = tabs; + }, [tabs]); + useEffect(() => { + activeTabIdRef.current = activeTabId; + }, [activeTabId]); + + const terminalRefs = useRef>>( + new Map(), + ); + + /** The tab the user is working in: the focused pane's inside a split. */ + function focusedSessionTabId(): string | null { + const id = activeTabIdRef.current; + const active = tabsRef.current.find((tab) => tab.id === id); + if (!isSplitTab(active)) return id || null; + return findPane(active.split, active.split.focusedPaneId)?.tabId ?? null; + } + + // ─── Where opened tabs go ───────────────────────────────────────────────── + + /** + * The pane the next opened tab should fill, set when the user picks one + * (the empty pane picker, "Open in split"). `fresh` marks a pane created in + * the same click, which the last committed tabs do not show yet. + */ + const pendingPaneTargetRef = useRef<{ + splitTabId: string; + paneId: string; + at: number; + fresh: boolean; + } | null>(null); + // Set while a saved layout is applied, so its tabs stay where it puts them. + const suppressPaneTargetRef = useRef(false); + + function targetPane(splitTabId: string, paneId: string, fresh = false) { + pendingPaneTargetRef.current = { + splitTabId, + paneId, + at: Date.now(), + fresh, + }; + } + + /** + * Shows a newly opened (or re-focused) tab. It fills the pane the user + * picked, else the focused pane of the active split when that is empty, + * else it simply becomes the active tab. + */ + const placeOpenedTabRef = useRef<(tabId: string) => void>(() => {}); + placeOpenedTabRef.current = (tabId: string) => { + const current = tabsRef.current; + const pending = pendingPaneTargetRef.current; + pendingPaneTargetRef.current = null; + if (suppressPaneTargetRef.current || tabId === "dashboard") { + setActiveTabId(tabId); + return; + } + const existing = current.find((tab) => tab.id === tabId); + + let target: { splitTabId: string; paneId: string } | null = null; + if (pending && Date.now() - pending.at < PENDING_TARGET_MS) { + const split = current.find((tab) => tab.id === pending.splitTabId); + if ( + pending.fresh || + (isSplitTab(split) && findPane(split.split, pending.paneId)) + ) { + target = pending; + } + } + // Opening a session that already sits in a split shows it there. + if (!target && existing?.parentSplitTabId) { + setActiveTabId(tabId); + return; + } + if (!target) { + const active = current.find((tab) => tab.id === activeTabIdRef.current); + if (isSplitTab(active)) { + const focused = findPane(active.split, active.split.focusedPaneId); + if (focused && focused.tabId === null) { + target = { splitTabId: active.id, paneId: focused.id }; + } + } + } + if (!target || (existing && !canJoinSplit(existing))) { + setActiveTabId(tabId); + return; + } + const { splitTabId, paneId } = target; + setTabs((prev) => placeTabInPane(prev, splitTabId, paneId, tabId)); + setActiveTabId(splitTabId); + }; + + // Panels that type into a terminal act on "the terminal you're working in". + // Once those panels can themselves be the active tab, activeTabId points at + // the panel and the lookup misses, so remember the last terminal instead. + // In a split, the tab being worked in is the focused pane's. + const workingTabId = (() => { + const active = tabs.find((t) => t.id === activeTabId); + if (!isSplitTab(active)) return activeTabId; + return findPane(active.split, active.split.focusedPaneId)?.tabId ?? ""; + })(); + const [lastTerminalTabId, setLastTerminalTabId] = useState(activeTabId); + useEffect(() => { + const working = tabs.find((t) => t.id === workingTabId); + if (working && getTabType(working.type)?.commandTarget) { + setLastTerminalTabId(working.id); + } + }, [workingTabId, tabs]); + + const [showTabNumbers, setShowTabNumbers] = useState( + () => localStorage.getItem("showTabNumbers") === "true", + ); + useEffect(() => { + const handler = () => { + setShowTabNumbers(localStorage.getItem("showTabNumbers") === "true"); + }; + window.addEventListener("showTabNumbersChanged", handler); + return () => window.removeEventListener("showTabNumbersChanged", handler); + }, []); + + useEffect(() => { + const activeTab = tabs.find((t) => t.id === activeTabId); + if (!activeTab?.terminalRef) return; + let innerRafId: number; + const outerRafId = requestAnimationFrame(() => { + innerRafId = requestAnimationFrame(() => { + const ref = activeTab.terminalRef?.current; + ref?.fit?.(); + ref?.notifyResize?.(); + ref?.refresh?.(); + }); + }); + return () => { + cancelAnimationFrame(outerRafId); + cancelAnimationFrame(innerRafId); + }; + }, [activeTabId]); + + // Sync tab host data when hosts update (e.g. after editing terminal theme in host settings) + useEffect(() => { + if (hosts.length === 0) return; + const byId = new Map(hosts.map((h) => [h.id, h])); + setTabs((prev) => { + let changed = false; + const next = prev.map((t) => { + const fresh = t.host ? byId.get(t.host.id) : undefined; + if (!fresh || fresh === t.host) return t; + // A reload hands back new objects; only replace hosts that changed so + // untouched tabs keep their identity and skip a re-render. + if (JSON.stringify(fresh) === JSON.stringify(t.host)) return t; + changed = true; + return { ...t, host: fresh }; + }); + return changed ? next : prev; + }); + }, [hosts]); + + // On load: always read saved tabs from DB so background sessions are preserved across refreshes. + // If reopenTabsOnLogin is on, also restore them as open tabs in the tab bar. + const tabRestoreAttemptedRef = useRef(false); + useEffect(() => { + // Waits for plugins too, so a plugin's saved tabs come back as themselves. + if (!hostsLoaded || !userPrefsLoaded || !pluginsSettled) return; + if (tabRestoreAttemptedRef.current) return; + tabRestoreAttemptedRef.current = true; + + async function loadSavedTabs() { + try { + const [savedTabs, activeSessions] = await Promise.all([ + getOpenTabs(), + getActiveSessions(), + ]); + + if (!Array.isArray(savedTabs) || savedTabs.length === 0) return; + + const sessionByInstanceId = new Map( + (Array.isArray(activeSessions) ? activeSessions : []) + .filter((s) => s.tabInstanceId != null) + .map((s) => [s.tabInstanceId, s]), + ); + + if (userPrefs.reopenTabsOnLogin) { + const hasPersistentTabs = tabs.some((t) => + isPersistentTabType(t.type), + ); + if (!hasPersistentTabs) { + const restoredTabs: Tab[] = []; + for (const saved of savedTabs as OpenTabRecord[]) { + const host = saved.hostId + ? hosts.find((h) => h.id === String(saved.hostId)) + : undefined; + if (!canRestoreTabType(saved.tabType, host)) continue; + + // Singleton tabs use their type as the stable ID; host-bound tabs get a unique ID + const tabId = host + ? `${host.name}-${saved.tabType}-${Date.now()}-${saved.tabOrder}` + : saved.id; + const liveSession = sessionByInstanceId.get(saved.id); + const restoredSessionId = + liveSession?.sessionId ?? saved.backendSessionId ?? null; + + const isCustomLabel = + host && + saved.label !== host.name && + !/^.+ \(\d+\)$/.test(saved.label); + + restoredTabs.push({ + id: tabId, + instanceId: saved.id, + type: saved.tabType as TabType, + label: saved.label, + customLabel: isCustomLabel ? saved.label : undefined, + host, + openedAt: new Date(saved.createdAt).getTime(), + restoredSessionId, + terminalRef: isSessionTabType(saved.tabType) + ? createRef() + : undefined, + }); + } + + if (restoredTabs.length > 0) { + setTabs((prev) => { + const existingIds = new Set(prev.map((t) => t.id)); + const newTabs = restoredTabs.filter( + (t) => !existingIds.has(t.id), + ); + return newTabs.length > 0 ? [...prev, ...newTabs] : prev; + }); + setActiveTabId(restoredTabs[0].id); + } + // Restored tabs are in the tab bar, not in background records + } + } else { + // Not restoring to tab bar, keep as background records for ConnectionsPanel + setBackgroundTabRecords(savedTabs as OpenTabRecord[]); + } + } catch { + // silently fail + } finally { + setTabsReady(true); + } + } + + loadSavedTabs(); + }, [hostsLoaded, userPrefsLoaded, pluginsSettled]); + + // Plugins that act once the session is back (the workspaces plugin applies + // a default workspace) wait for this. + useEffect(() => { + if (tabsReady) notifyShellReady(); + }, [tabsReady]); + + // Restore split tabs once their child sessions have stable live ids. The + // oldest single-split keys are migrated once into a first split. + useEffect(() => { + if (!tabsReady || paneLayoutRestoredRef.current) return; + paneLayoutRestoredRef.current = true; + + try { + const savedSplitTabs = JSON.parse( + localStorage.getItem("termix_splitTabs") ?? "[]", + ) as unknown; + if (Array.isArray(savedSplitTabs) && savedSplitTabs.length > 0) { + setTabs((prev) => restoreSplitTabs(savedSplitTabs, prev)); + return; + } + + const savedInstanceIds = JSON.parse( + localStorage.getItem("termix_paneInstanceIds") ?? "null", + ) as unknown; + const savedMode = localStorage.getItem("termix_splitMode"); + if (!Array.isArray(savedInstanceIds) || !savedMode) return; + const savedSizes = JSON.parse( + localStorage.getItem("termix_paneSizes") ?? "null", + ) as { rowSizes?: unknown; rowColSizes?: unknown } | null; + const tabIdByInstanceId = new Map( + tabs.map((tab) => [tab.instanceId, tab.id]), + ); + const root = fromLegacyConfig( + savedMode, + savedInstanceIds.map((instanceId) => + typeof instanceId === "string" + ? (tabIdByInstanceId.get(instanceId) ?? null) + : null, + ), + savedSizes?.rowSizes, + savedSizes?.rowColSizes, + ); + if (root && listPanes(root).some((pane) => pane.tabId)) { + const splitTab = makeSplitTab( + createId(), + splitLabel(1), + createSplitState(root), + ); + setTabs((prev) => addSplitTab(prev, splitTab)); + setActiveTabId(splitTab.id); + } + } catch { + // silently fail + } finally { + splitTabsRestoredRef.current = true; + localStorage.removeItem("termix_splitMode"); + localStorage.removeItem("termix_paneInstanceIds"); + localStorage.removeItem("termix_paneSizes"); + } + }, [tabsReady, tabs]); + + // Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB. + const orderSyncTimeoutRef = useRef | null>( + null, + ); + const prevTabOrderRef = useRef(""); + useEffect(() => { + if (!tabsReady) return; + const persistable = tabs.filter((t) => isPersistentTabType(t.type)); + const orderKey = persistable.map((t) => t.instanceId).join(","); + if (orderKey === prevTabOrderRef.current) return; + prevTabOrderRef.current = orderKey; + + if (orderSyncTimeoutRef.current) clearTimeout(orderSyncTimeoutRef.current); + orderSyncTimeoutRef.current = setTimeout(() => { + persistable.forEach((t, i) => { + patchOpenTab(t.instanceId, { tabOrder: i }).catch(() => {}); + }); + }, 500); + + return () => { + if (orderSyncTimeoutRef.current) + clearTimeout(orderSyncTimeoutRef.current); + }; + }, [tabs, tabsReady]); + + // Tells plugins the arrangement changed, e.g. so the workspaces plugin can + // keep its last-session snapshot current. + useEffect(() => { + if (tabsReady) notifyTabsChanged(); + }, [tabs, tabsReady, ...layoutDeps]); + + // ─── Tab management ────────────────────────────────────────────────────── + + const openTab = useCallback(function openTab( + host: Host, + type: TabType, + restore?: RestoreTarget, + options?: OpenTabOptions, + ) { + if (!restore && !options?.forceNewTab) { + const dataKey = JSON.stringify(options?.data ?? null); + const existing = tabsRef.current.find( + (t) => + t.type === type && + t.host?.id === host.id && + JSON.stringify(t.data ?? null) === dataKey, + ); + if (existing) { + placeOpenedTabRef.current(existing.id); + return existing.id; + } + } + const instanceId = restore?.instanceId ?? createId(); + // Unique per open; a timestamp collides when a workspace opens several + // tabs in the same millisecond. + const tabId = `${type}-${createId()}`; + const openedAt = Date.now(); + const ref = isSessionTabType(type) ? createRef() : undefined; + if (ref) terminalRefs.current.set(tabId, ref); + + let finalLabel = host.name; + const savedLabel = restore?.savedLabel; + // A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label + const isCustomLabel = + savedLabel != null && + savedLabel !== host.name && + !/^.+ \(\d+\)$/.test(savedLabel); + + setTabs((prev) => { + if (isCustomLabel && savedLabel) { + finalLabel = savedLabel; + return [ + ...prev, + { + id: tabId, + instanceId, + type, + label: finalLabel, + customLabel: finalLabel, + host, + openedAt, + terminalRef: ref, + restoredSessionId: restore?.restoredSessionId ?? null, + data: options?.data, + }, + ]; + } + + const same = prev.filter( + (t) => + t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name, + ); + finalLabel = options?.label + ? options.label + : same.length === 0 + ? host.name + : `${host.name} (${same.length + 1})`; + + // Retrofit the first duplicate's label to "(1)" if needed + const next = + same.length === 1 && !/\(\d+\)$/.test(same[0].label) + ? prev.map((t) => + t.id === same[0].id ? { ...t, label: `${host.name} (1)` } : t, + ) + : prev; + + return [ + ...next, + { + id: tabId, + instanceId, + type, + label: finalLabel, + host, + openedAt, + terminalRef: ref, + restoredSessionId: restore?.restoredSessionId ?? null, + data: options?.data, + }, + ]; + }); + placeOpenedTabRef.current(tabId); + + if (isPersistentTabType(type)) { + addOpenTab({ + id: instanceId, + tabType: type, + hostId: host ? parseInt(host.id) : null, + label: finalLabel, + tabOrder: 0, + }).catch(() => {}); + } + + return tabId; + }, []); + + function connectHost( + host: Host, + preferredType?: TabType, + options?: OpenTabOptions, + ) { + const type = resolveHostTabType(host, preferredType); + if (!type) return; + openTab(host, type, undefined, options); + } + + const saveQuickConnectHost = useCallback( + async (tab: Tab, host: Host) => { + try { + const savedHost = await createSSHHost(quickConnectHostToPayload(host)); + await patchOpenTab(tab.instanceId, { hostId: savedHost.id }); + await reloadHosts(); + toast.success(t("hosts.hostCreated")); + } catch (error) { + toast.error(t("hosts.failedToSave")); + throw error; + } + }, + [reloadHosts, t], + ); + + /** A tab type that opens a fresh tab every time (a local shell). */ + function openMultiInstanceTab( + type: TabType, + options?: { host?: Host; data?: Record; label?: string }, + ): string { + const instanceId = createId(); + const id = `${type}-${instanceId}`; + const titleKey = getTabType(type)?.titleKey; + const title = options?.label ?? (titleKey ? t(titleKey) : type); + setTabs((current) => { + const count = current.filter((tab) => tab.type === type).length; + return [ + ...current, + { + id, + instanceId, + type, + label: + count === 0 || options?.label ? title : `${title} (${count + 1})`, + openedAt: Date.now(), + ...(options?.host ? { host: options.host } : {}), + ...(options?.data !== undefined ? { data: options.data } : {}), + }, + ]; + }); + placeOpenedTabRef.current(id); + return id; + } + + const openSingletonTab = useCallback( + // `host` optionally preselects a host for a singleton plugin tab. + function openSingletonTab( + type: TabType, + pendingEvent?: string, + host?: Host, + data?: Record, + tabLabel?: string, + ): string | undefined { + // A local shell is never a singleton: each open is its own session. + if (getTabType(type)?.multiInstance) { + return openMultiInstanceTab(type, { host, data, label: tabLabel }); + } + if (type === "host-manager" && pendingEvent) { + const request = requestFromLegacyEvent(pendingEvent); + if (request) requestManage(request); + } + // Profile and admin are pages of the one Settings tab now. + if (type === "user-profile" || type === "admin-settings") { + return openSingletonTab( + "settings", + undefined, + undefined, + { + section: + typeof data?.section === "string" + ? data.section + : type === "admin-settings" + ? "admin-general" + : "account", + }, + tabLabel, + ); + } + // Asking for a page again still goes there after the user moved away + if (type === "settings" && data?.section && data.revealAt === undefined) { + data = { ...data, revealAt: Date.now() }; + } + const id = type; + const singletonLabels: Partial> = { + "host-manager": t("nav.manage"), + settings: t("nav.settings"), + plugins: t("nav.plugins"), + }; + // A plugin tab names itself; promoted rail panels reuse the rail's own + // label so the two stay in sync. + const titleKey = getTabType(type)?.titleKey; + const label = + singletonLabels[type] ?? + (titleKey ? t(titleKey) : railItemLabel(type, t)); + setTabs((prev) => { + const existing = prev.find((t) => t.id === id); + if (existing) { + // Refocusing a singleton with a host or data passes it on. + if (!host && data === undefined) return prev; + return prev.map((t) => + t.id === id + ? { + ...t, + ...(host ? { host } : {}), + ...(data !== undefined ? { data } : {}), + } + : t, + ); + } + return [ + ...prev, + { + id, + instanceId: id, + type, + label, + openedAt: Date.now(), + ...(host ? { host } : {}), + ...(data !== undefined ? { data } : {}), + }, + ]; + }); + placeOpenedTabRef.current(id); + if (isPersistentTabType(type)) { + addOpenTab({ + id, + tabType: type, + hostId: null, + label, + tabOrder: 0, + }).catch(() => {}); + } + return id; + }, + [t], + ); + + const openSingletonTabRef = useRef(openSingletonTab); + openSingletonTabRef.current = openSingletonTab; + + useEffect(() => { + const handler = () => openSingletonTabRef.current("plugins"); + window.addEventListener(OPEN_PLUGINS_EVENT, handler); + return () => window.removeEventListener(OPEN_PLUGINS_EVENT, handler); + }, []); + + useEffect(() => { + const handler = (event: Event) => { + const pluginId = (event as CustomEvent).detail; + if (typeof pluginId !== "string") return; + openSingletonTabRef.current("settings", undefined, undefined, { + section: `plugin:${pluginId}`, + revealAt: Date.now(), + }); + }; + window.addEventListener(OPEN_PLUGIN_SETTINGS_EVENT, handler); + return () => + window.removeEventListener(OPEN_PLUGIN_SETTINGS_EVENT, handler); + }, []); + + const getTabCloseLabel = useCallback((tab: Tab) => { + return tab.customLabel || tab.label || tab.host?.name || String(tab.id); + }, []); + + const isActiveConnectionTab = useCallback((tab: Tab) => { + if (!isSessionTabType(tab.type)) return false; + return tab.terminalRef?.current?.isConnected?.() === true; + }, []); + + const hasActiveConnection = useCallback(() => { + return tabsRef.current.some(isActiveConnectionTab); + }, [isActiveConnectionTab]); + + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!hasActiveConnection()) return; + + event.preventDefault(); + event.returnValue = ""; + return ""; + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + }; + }, [hasActiveConnection]); + + const doCloseTabRef = useRef<(id: string) => void>(() => {}); + doCloseTabRef.current = doCloseTab; + function doCloseTab(id: string) { + const tabToClose = tabs.find((t) => t.id === id); + if (tabToClose?.terminalRef?.current?.disconnect) { + tabToClose.terminalRef.current.disconnect(); + } + if (tabToClose?.instanceId && isPersistentTabType(tabToClose.type)) { + deleteOpenTab(tabToClose.instanceId).catch(() => {}); + } + + terminalRefs.current.delete(id); + if (isSplitTab(tabToClose)) { + splitOpsRef.current.unsplit(id); + return; + } + if (id === activeTabId) { + const remaining = tabs.filter( + (tab) => tab.id !== id && !tab.parentSplitTabId, + ); + setActiveTabId( + remaining.length > 0 ? remaining[remaining.length - 1].id : "dashboard", + ); + } + setTabs((prev) => { + // A pane that held the tab stays, empty and focused, ready for the next one. + const next = removeTab(prev, id); + if (next.length === 0) return [dashboardTab(t("nav.dashboard"))]; + return next; + }); + } + + function reconnectAllDisconnected() { + const { reconnected, failed } = reconnectDisconnectedTabs(tabsRef.current); + if (reconnected || !failed) + toast( + t( + reconnected + ? "nav.reconnectingTerminals" + : "nav.noDisconnectedTerminals", + { count: reconnected }, + ), + ); + if (failed) + toast.error(t("nav.reconnectTerminalsFailed", { count: failed })); + } + const reconnectAllRef = useRef<() => void>(() => {}); + reconnectAllRef.current = reconnectAllDisconnected; + + function refreshTab(id: string) { + const tab = tabs.find((t) => t.id === id); + const handle = tab?.terminalRef?.current; + if (!handle) return; + // Session tabs expose one or the other on their handle. + if (handle.reconnect) handle.reconnect(); + else handle.refresh?.(); + } + + async function closeTab(id: string) { + const tab = tabsRef.current.find((t) => t.id === id); + const guard = tab?.terminalRef?.current?.confirmClose; + if (guard && !(await guard())) return; + if (!(await canCloseTab(id))) return; + const confirmEnabled = localStorage.getItem("confirmTabClose") === "true"; + if (tab && confirmEnabled && isActiveConnectionTab(tab)) { + const ask = confirmRef.current; + const ok = ask + ? await ask({ + title: t("nav.confirmCloseHost", { host: getTabCloseLabel(tab) }), + confirmLabel: t("nav.close"), + }) + : true; + if (!ok) return; + } + doCloseTabRef.current(id); + } + + // In a split, closing acts on the focused pane: its session, or the pane + // itself once it is empty. + closeActiveTabRef.current = () => { + const id = activeTabIdRef.current; + const active = tabsRef.current.find((tab) => tab.id === id); + if (isSplitTab(active)) { + const tabId = focusedSessionTabId(); + if (tabId) closeTab(tabId); + else + splitOpsRef.current.closePaneOf(active.id, active.split.focusedPaneId); + return; + } + if (id !== "dashboard") closeTab(id); + }; + + function renameTab(tabId: string, newLabel: string) { + setTabs((prev) => + prev.map((t) => + t.id === tabId ? { ...t, customLabel: newLabel, label: newLabel } : t, + ), + ); + const tab = tabs.find((t) => t.id === tabId); + if (tab?.instanceId && tab.type !== "split-screen") { + patchOpenTab(tab.instanceId, { label: newLabel }).catch(() => {}); + } + } + + function reorderTopLevelTabs(reordered: Tab[]) { + setTabs((prev) => [ + ...reordered, + ...prev.filter((tab) => tab.parentSplitTabId), + ]); + } + + const terminalTabs = tabs.filter((t) => getTabType(t.type)?.commandTarget); + // What command-target panels act on. Falls back to the remembered + // terminal when the active tab isn't one, and drops it once it's closed. + const targetTerminalTabId = terminalTabs.some((t) => t.id === workingTabId) + ? workingTabId + : terminalTabs.some((t) => t.id === lastTerminalTabId) + ? lastTerminalTabId + : ""; + + return { + tabs, + setTabs, + activeTabId, + setActiveTabId, + tabsReady, + backgroundTabRecords, + setBackgroundTabRecords, + tabsRef, + activeTabIdRef, + closeActiveTabRef, + reconnectAllRef, + terminalRefs, + splitOpsRef, + suppressPaneTargetRef, + pendingPaneTargetRef, + openSingletonTabRef, + hostsRef, + focusedSessionTabId, + targetPane, + openTab, + connectHost, + saveQuickConnectHost, + openMultiInstanceTab, + openSingletonTab, + doCloseTab, + closeTab, + renameTab, + refreshTab, + reconnectAllDisconnected, + reorderTopLevelTabs, + showTabNumbers, + terminalTabs, + targetTerminalTab: terminalTabs.find((t) => t.id === targetTerminalTabId), + handleFor: (id: string) => + terminalRefs.current.get(id)?.current as TabHandle | null | undefined, + }; +} + +export type TabsState = ReturnType; diff --git a/src/ui/shell/controller/use-workspace-layout.ts b/src/ui/shell/controller/use-workspace-layout.ts new file mode 100644 index 000000000..1416d0124 --- /dev/null +++ b/src/ui/shell/controller/use-workspace-layout.ts @@ -0,0 +1,165 @@ +import { useEffect } from "react"; +import type { WorkspacePayload } from "@/types/ui-types"; +import { createId } from "@/lib/create-id"; +import { rightDockableIds } from "@/sidebar/rail-items"; +import { + buildLayoutPayload, + resolveLayoutSplits, + resolveLayoutTabTarget, + snapshotData, +} from "@/shell/shell-layout"; +import { addSplitTab, makeSplitTab } from "@/shell/split/split-tabs"; +import { listPanes } from "@/shell/split/split-tree"; +import { setShellLayoutProvider } from "@/plugin-host/shell-bridge"; +import { useSplitLabel, type TabsState } from "./use-tabs"; +import type { ShellRegions } from "./types"; + +/** Saving and applying a whole arrangement, for workspaces and plugins. */ +export function useWorkspaceLayout( + tabsState: TabsState, + regions: ShellRegions, +) { + const splitLabel = useSplitLabel(); + const { + tabs, + activeTabId, + tabsReady, + tabsRef, + hostsRef, + setTabs, + setActiveTabId, + suppressPaneTargetRef, + pendingPaneTargetRef, + } = tabsState; + + function buildWorkspacePayload(): WorkspacePayload { + return buildLayoutPayload({ + tabs, + activeTabId, + sidebar: { + left: { + view: regions.left.view, + open: regions.left.open, + width: regions.left.width, + }, + right: { + view: regions.right.view, + open: regions.right.view !== null, + width: regions.right.width, + }, + }, + }); + } + + async function applyLayout( + payload: WorkspacePayload, + name: string, + ): Promise<{ skipped: string[] }> { + // Tear down the current arrangement the same way an individual tab close does. + for (const tab of [...tabsRef.current]) { + tabsState.doCloseTab(tab.id); + } + // Tabs reopened here must not be pulled into a pane of the old layout. + suppressPaneTargetRef.current = true; + pendingPaneTargetRef.current = null; + try { + const slotIdToNewTabId = new Map(); + const skippedTabs: string[] = []; + + for (const snapshot of payload.tabs) { + const target = resolveLayoutTabTarget(snapshot, hostsRef.current); + + if (target.kind === "skip") { + skippedTabs.push(snapshot.hostNameSnapshot || snapshot.label); + continue; + } + + if (target.kind === "singleton") { + const newTabId = tabsState.openSingletonTab( + snapshot.type, + undefined, + target.host, + snapshotData(snapshot), + ); + slotIdToNewTabId.set(snapshot.slotId, newTabId ?? snapshot.type); + continue; + } + + if (target.kind === "host") { + const newTabId = tabsState.openTab( + target.host, + snapshot.type, + { + instanceId: createId(), + restoredSessionId: null, + savedLabel: snapshot.customLabel ?? snapshot.label, + }, + { data: snapshotData(snapshot) }, + ); + slotIdToNewTabId.set(snapshot.slotId, newTabId); + } + } + + const splitIdBySlotId = new Map(); + const splitIdByTabId = new Map(); + let firstSplitId: string | null = null; + resolveLayoutSplits(payload, slotIdToNewTabId).forEach((saved, index) => { + const splitTab = makeSplitTab( + createId(), + saved.label || name || splitLabel(index + 1), + saved.split, + ); + if (saved.slotId) splitIdBySlotId.set(saved.slotId, splitTab.id); + for (const pane of listPanes(saved.split.root)) { + if (pane.tabId) splitIdByTabId.set(pane.tabId, splitTab.id); + } + firstSplitId ??= splitTab.id; + setTabs((prev) => addSplitTab(prev, splitTab)); + }); + + // A tab that now sits in a split is shown through its split. + const activeSlot = payload.activeSlotId; + const activeTabInLayout = activeSlot + ? slotIdToNewTabId.get(activeSlot) + : undefined; + const activeId = + (activeSlot ? splitIdBySlotId.get(activeSlot) : undefined) ?? + (activeTabInLayout + ? (splitIdByTabId.get(activeTabInLayout) ?? activeTabInLayout) + : undefined) ?? + firstSplitId ?? + "dashboard"; + setActiveTabId(activeId); + + // Older payloads predate the sidebar field, so leave the docks alone then. + const sidebar = payload.sidebar; + if (sidebar) { + if (sidebar.left.view) { + regions.left.setView( + sidebar.left.view === "split-screen" ? "hosts" : sidebar.left.view, + ); + } + regions.left.setOpen(sidebar.left.open); + if (sidebar.left.width) regions.left.setWidth(sidebar.left.width); + + const right = sidebar.right.open ? sidebar.right.view : null; + regions.right.setView( + right && rightDockableIds().includes(right) ? right : null, + ); + if (sidebar.right.width) regions.right.setWidth(sidebar.right.width); + } + + return { skipped: skippedTabs }; + } finally { + suppressPaneTargetRef.current = false; + } + } + + useEffect(() => { + setShellLayoutProvider({ + getLayout: () => (tabsReady ? buildWorkspacePayload() : null), + applyLayout: (layout, options) => + applyLayout(layout as WorkspacePayload, options?.name ?? ""), + }); + }); +} diff --git a/src/ui/shell/layout/LayoutHost.tsx b/src/ui/shell/layout/LayoutHost.tsx new file mode 100644 index 000000000..3233ebba6 --- /dev/null +++ b/src/ui/shell/layout/LayoutHost.tsx @@ -0,0 +1,118 @@ +import { Component, useMemo, useState, type ReactNode } from "react"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { isElectron } from "@/lib/electron"; +import { usePluginStore } from "@/plugin-host/plugin-store"; +import { retryPluginFrontend, syncPlugins } from "@/plugin-host/loader"; +import { chosenLayoutId, useLookState } from "@/look/look-store"; +import { + getRegisteredLayout, + useRegisteredLayouts, +} from "@/look/layout-registry"; +import { layoutStatus } from "@/look/layout-status"; +import { safeLayoutInUrl } from "@/look/resolve-look"; +import { useLookTheme } from "@/look/use-look-theme"; +import { RepairView } from "@/look/RepairView"; +import { registerBuiltinLayout } from "./builtin"; + +registerBuiltinLayout(); + +/** Catches a crash while a layout draws, so the repair view can take over. */ +class LayoutErrorBoundary extends Component< + { onCrash: (error: Error) => void; children: ReactNode }, + { crashed: boolean } +> { + state = { crashed: false }; + + static getDerivedStateFromError() { + return { crashed: true }; + } + + componentDidCatch(error: Error) { + console.error("[layout] crashed while drawing", error); + this.props.onCrash(error); + } + + render() { + return this.state.crashed ? null : this.props.children; + } +} + +interface CrashState { + layoutId: string | null; + count: number; + error: string | null; +} + +/** + * Draws the chosen layout, or the repair view when it cannot be drawn. A + * crash remounts the layout once; a second hands over to the repair view. + */ +export function LayoutHost() { + const look = useLookState(); + const store = usePluginStore(); + useRegisteredLayouts(); + const isMobile = useIsMobile(); + const [crash, setCrash] = useState({ + layoutId: null, + count: 0, + error: null, + }); + const [attempt, setAttempt] = useState(0); + // The look store changes identity whenever the choice does. + // eslint-disable-next-line react-hooks/exhaustive-deps + const layoutId = useMemo(() => chosenLayoutId(), [look]); + useLookTheme(layoutId); + + const crashes = crash.layoutId === layoutId ? crash.count : 0; + const owner = [...store.records.values()].find((record) => + record.summary.contributes?.layouts?.some((l) => l.id === layoutId), + ); + const status = layoutStatus({ + layoutId, + safeFromUrl: safeLayoutInUrl(window.location.search), + safeFromEnv: look.response?.safeLayout === true, + ready: look.loaded && store.settled, + registered: getRegisteredLayout(layoutId), + owner, + unavailable: look.response?.unavailable.find((plugin) => + plugin.layouts.includes(layoutId), + ), + crashes, + lastCrash: crash.layoutId === layoutId ? crash.error : null, + }); + + const retry = () => { + setCrash({ layoutId: null, count: 0, error: null }); + setAttempt((value) => value + 1); + if (owner) void retryPluginFrontend(owner.summary.id); + else void syncPlugins(); + }; + + if (status.kind === "loading") { + return
; + } + if (status.kind === "repair") { + return ; + } + + const { layout } = status; + // The desktop app always gets the desktop layout. + const Draw = + isMobile && !isElectron() && layout.mobileComponent + ? layout.mobileComponent + : layout.component; + return ( + + setCrash((previous) => ({ + layoutId, + count: (previous.layoutId === layoutId ? previous.count : 0) + 1, + error: error.message, + })) + } + > + + + ); +} diff --git a/src/ui/shell/layout/builtin/DefaultLayout.tsx b/src/ui/shell/layout/builtin/DefaultLayout.tsx new file mode 100644 index 000000000..2e7003e12 --- /dev/null +++ b/src/ui/shell/layout/builtin/DefaultLayout.tsx @@ -0,0 +1,396 @@ +import { lazy, Suspense } from "react"; +import { useTranslation } from "react-i18next"; +import { Sheet, SheetContent } from "@/components/sheet"; +import { SurfaceScope } from "@/components/surface/surface-scope"; +import { MobileBar } from "@/shell/MobileBar"; +import { AppRail } from "@/sidebar/AppRail"; +import { ComponentSlot } from "@/shell/ActionSlot"; +import { + isCoreRailView, + railItemLabel, + promotableIds, + rightDockableIds, +} from "@/sidebar/rail-items"; +import { DockPanel, DockReopenStrip } from "@/shell/DockPanel"; +import { TabBar } from "@/shell/TabBar"; +import { requestManage, type ManageMode } from "@/manage/manage-requests"; +import { getTabType } from "@/shell/tab-registry"; +import { getPanel, usePanels } from "@/shell/panel-registry"; +import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder"; +import type { TabType } from "@/types/ui-types"; +import { + ShellConfirmCapture, + useShellController, +} from "@/shell/controller/ShellControllerProvider"; +import { TabStage } from "@/shell/stage/TabStage"; + +const HostsPanel = lazy(() => + import("@/sidebar/HostsPanel").then((m) => ({ default: m.HostsPanel })), +); +const QuickConnectPanel = lazy(() => + import("@/sidebar/QuickConnectPanel").then((m) => ({ + default: m.QuickConnectPanel, + })), +); +const SyncPanel = lazy(() => + import("@/settings/sync/SyncPanel").then((m) => ({ + default: m.SyncPanel, + })), +); +const CredentialsPanel = lazy(() => + import("@/sidebar/CredentialsPanel").then((m) => ({ + default: m.CredentialsPanel, + })), +); +const ConnectionsPanel = lazy(() => + import("@/sidebar/ConnectionsPanel").then((m) => ({ + default: m.ConnectionsPanel, + })), +); + +function SidebarPanelFallback() { + return ( +
+
+
+ ); +} + +/** + * The look Termix has always had: icon rail, left sidebar, tab bar, right + * dock and the mobile bottom bar. Built in until it moves to its own plugin. + */ +export function DefaultLayout() { + const { t } = useTranslation(); + const shell = useShellController(); + const registeredPanels = usePanels(); + const { tabs, regions, hosts, isMobile } = shell; + const { left, right } = regions; + const closeOnMobile = () => { + if (isMobile) left.setOpen(false); + }; + + // Every panel gets its own surface, so its inline views and confirms stay + // inside it. Only the left dock widens for them. + const panelScope = ( + view: string, + owned: boolean, + shown: boolean, + children: React.ReactNode, + className = "", + ) => ( + + {children} + + ); + + /** + * Sidebar panel content, shared between the desktop sidebar, the mobile + * sheet and the right dock. Takes the view rather than reading the left + * dock's so both docks can render from the same code. + * + * `owned` marks the dock responsible for the panels that stay mounted while + * hidden (hosts, credentials, fleets). Only one dock may own them, otherwise + * two live instances fight over the shared editing state. + */ + const renderSidebarPanels = (railView: string, owned = true) => ( + }> +
+ {owned && ( + <> + {panelScope( + "hosts", + owned, + railView === "hosts", + { + tabs.connectHost(host, type, options); + closeOnMobile(); + }} + onEditHost={hosts.edit} + onCloseSidebar={() => left.setOpen(false)} + hostTree={hosts.tree ?? undefined} + loading={hosts.loading} + onEditingChange={regions.panelEditingSetter("hosts")} + active={railView === "hosts"} + />, + )} + {panelScope( + "credentials", + owned, + railView === "credentials", + , + )} + + )} + + {railView === "quick-connect" && + panelScope( + "quick-connect", + owned, + true, + { + tabs.open(host, type); + closeOnMobile(); + }} + />, + )} + + {registeredPanels.map((panel) => { + const shown = railView === panel.id; + // A kept-mounted panel lives in the owning dock only, so two live + // copies never fight over the same state. + if (panel.keepMounted ? !owned : !shown) return null; + const Panel = panel.component; + return panelScope( + panel.id, + owned, + shown, +
+ undefined + } + activeTabType={tabs.active?.type ?? undefined} + placement={owned ? "left" : "right"} + /> +
, + ); + })} + + {!isCoreRailView(railView) && !getPanel(railView) && ( + + )} + + {railView === "connections" && + panelScope( + "connections", + owned, + true, +
+ { + tabs.setActive(tabId); + closeOnMobile(); + }} + onCloseTab={tabs.close} + onReopenTab={(record, restoredSessionId) => { + const host = record.hostId + ? hosts.list.find((h) => h.id === String(record.hostId)) + : undefined; + if (!host && !getTabType(record.tabType)?.hostless) return; + tabs.setBackground((prev) => + prev.filter((r) => r.id !== record.id), + ); + if (host) { + const effectiveSessionId = + restoredSessionId ?? record.backendSessionId ?? null; + tabs.open(host, record.tabType as TabType, { + instanceId: record.id, + restoredSessionId: effectiveSessionId, + savedLabel: record.label, + }); + } else { + tabs.openSingleton(record.tabType as TabType); + } + closeOnMobile(); + }} + onForgetBackground={(recordId) => { + tabs.setBackground((prev) => + prev.filter((r) => r.id !== recordId), + ); + }} + onRenameTab={tabs.rename} + onReorderTabs={tabs.setList} + /> +
, + )} + + {railView === "sync" && + panelScope( + "sync", + owned, + true, +
+ +
, + )} +
+
+ ); + + const sidebarPanelContent = renderSidebarPanels(left.view); + + const dockProps = (view: string) => ({ + // Titles come from the shared rail definitions so they stay translated + // and in step with the rail itself. + title: railItemLabel(view, t), + onOpenAsTab: isMobile + ? undefined + : view === "hosts" || view === "credentials" + ? () => requestManage({ kind: "browse", mode: view as ManageMode }) + : promotableIds().includes(view) + ? () => tabs.openSingleton(view as TabType) + : undefined, + }); + + return ( +
+
+ {/* Skinny icon rail, desktop only */} + tabs.openSingleton("settings")} + onOpenNavigationSettings={() => + tabs.openSingleton("settings", undefined, undefined, { + section: "appearance", + reveal: "settings-navigation", + revealAt: Date.now(), + }) + } + onOpenPalette={shell.palette.open} + onLogout={shell.logout} + /> + + {/* Desktop: inline resizable sidebar */} + {!isMobile && ( + left.setOpen(false)} + onMoveToRightDock={ + rightDockableIds().includes(left.view) + ? () => regions.openInRightDock(left.view) + : undefined + } + onResetWidth={left.resetWidth} + > + {sidebarPanelContent} + + )} + + {/* Mobile: sidebar as overlay sheet */} + {isMobile && ( + + + left.setOpen(false)} + > + {sidebarPanelContent} + + + + )} + + {/* Main content area */} +
+ {!isMobile && !left.open && ( + left.setOpen(true)} /> + )} + + +
+ + +
+
+ + {/* Bottom nav bar, mobile only. The desktop app keeps the rail. */} + {!shell.isElectron && ( + tabs.openSingleton(type)} + onOpenSettings={() => tabs.openSingleton("settings")} + onOpenPalette={shell.palette.open} + onLogout={() => shell.logout({ manual: true })} + /> + )} +
+ + {/* Right dock, desktop only, holds a second reference panel */} + {!isMobile && right.view && ( + right.setView(null)} + onResetWidth={right.resetWidth} + > + {renderSidebarPanels(right.view, false)} + + )} + + {/* Plugin panels pinned to the right edge, sized by the plugin */} + +
+
+ ); +} diff --git a/src/ui/shell/layout/builtin/index.ts b/src/ui/shell/layout/builtin/index.ts new file mode 100644 index 000000000..adcf3ee54 --- /dev/null +++ b/src/ui/shell/layout/builtin/index.ts @@ -0,0 +1,11 @@ +import { BUILTIN_LAYOUT_ID, registerLayout } from "@/look/layout-registry"; +import { DefaultLayout } from "./DefaultLayout"; + +let registered = false; + +/** Core's own layout, there whatever plugins are installed. */ +export function registerBuiltinLayout(): void { + if (registered) return; + registered = true; + registerLayout({ id: BUILTIN_LAYOUT_ID, component: DefaultLayout }); +} diff --git a/src/ui/shell/stage/TabPortals.tsx b/src/ui/shell/stage/TabPortals.tsx new file mode 100644 index 000000000..79fd3f393 --- /dev/null +++ b/src/ui/shell/stage/TabPortals.tsx @@ -0,0 +1,66 @@ +import { useLayoutEffect, useSyncExternalStore } from "react"; +import { createPortal } from "react-dom"; +import { SurfaceScope } from "@/components/surface/surface-scope"; +import { getTabType } from "@/shell/tab-registry"; +import { renderTabContent } from "@/shell/tabUtils"; +import type { Tab } from "@/types/ui-types"; +import { + useShellController, + useTabStageStore, +} from "@/shell/controller/ShellControllerProvider"; + +const ownBackground = (tab: Tab) => !!getTabType(tab.type)?.ownBackground; + +/** + * Renders every open tab into its own stable node. Core mounts this outside + * the layout, so a layout that moves, hides or crashes never remounts a tab; + * a TabStage only decides where the nodes sit. + */ +export function TabPortals() { + const shell = useShellController(); + const store = useTabStageStore(); + // Pane elements and the stage's container come and go with the layout. + useSyncExternalStore(store.subscribe, store.version, store.version); + const { list: tabs, activeId, targetTerminal } = shell.tabs; + const { paneIdByTabId, focusedPaneId } = shell.split; + + // useLayoutEffect (not useEffect) so visibility/display are corrected before + // the browser paints, otherwise the previous tab's node can flash on screen + // for a frame while still visible. + useLayoutEffect(() => { + store.place(tabs, activeId, paneIdByTabId, ownBackground); + }); + + return ( + <> + {store.portalOrder(tabs).map((tab) => { + const paneId = paneIdByTabId.get(tab.id); + const inPane = !!paneId; + const activeInline = !inPane && tab.id === activeId; + const isFocusedPane = inPane ? paneId === focusedPaneId : activeInline; + return createPortal( + shell.tabs.close(tab.id) + } + > + {renderTabContent(tab, { + shell: shell.callbacks, + settings: shell.settings, + panelTargetTab: targetTerminal, + isVisible: inPane || activeInline, + isFocusedPane, + inSplit: inPane, + })} + , + store.getNode(tab.id, ownBackground(tab)), + tab.id, + ); + })} + + ); +} diff --git a/src/ui/shell/stage/TabStage.tsx b/src/ui/shell/stage/TabStage.tsx new file mode 100644 index 000000000..a0bbd0b5d --- /dev/null +++ b/src/ui/shell/stage/TabStage.tsx @@ -0,0 +1,73 @@ +import { useRef } from "react"; +import { cn } from "@/lib/utils"; +import { SplitView } from "@/shell/split/SplitView"; +import { SplitDropOverlay } from "@/shell/split/SplitDropOverlay"; +import { EmptyPanePicker } from "@/shell/split/EmptyPanePicker"; +import { + useShellController, + useTabStageStore, +} from "@/shell/controller/ShellControllerProvider"; + +/** + * The area tabs show in: the active tab, or the active split's panes, plus + * the drop zones for dragging a tab or pane into a split. A layout places it + * wherever its main area is. Tabs are never mounted here, only moved in, so + * moving or remounting the stage keeps every session alive. + */ +export function TabStage({ className }: { className?: string }) { + const shell = useShellController(); + const store = useTabStageStore(); + // The area below the tab bar, where tabs and panes are dropped to split. + const mainAreaRef = useRef(null); + const activeSplit = shell.split.active; + const empty = shell.split.emptyPane; + + return ( +
+ {activeSplit && ( +
+ ( + + empty.pickTab(activeSplit.id, paneId, tabId) + } + onPickHost={(host, pick) => + empty.pickHost(activeSplit.id, paneId, host, pick) + } + onOpenType={(type) => + empty.openType(activeSplit.id, paneId, type) + } + onClosePane={() => empty.closePane(activeSplit.id, paneId)} + /> + )} + /> +
+ )} + + + {/* Normal-view container. Tab nodes are moved in here (or into pane + elements) by TabPortals. Hidden while a split is active. */} +
+
+ ); +} diff --git a/src/ui/shell/stage/tab-stage-store.ts b/src/ui/shell/stage/tab-stage-store.ts new file mode 100644 index 000000000..e29d2ee77 --- /dev/null +++ b/src/ui/shell/stage/tab-stage-store.ts @@ -0,0 +1,194 @@ +import type { Tab } from "@/types/ui-types"; + +/** + * Where tab content lives in the DOM. + * + * Every tab gets one stable node, created once and kept while the tab lives. + * React portals the tab into that node and never changes the portal target, + * since changing it remounts the tab (and drops a live terminal). The node is + * moved with plain DOM between the stage's normal view and pane elements, or + * left detached while no stage is mounted, e.g. while a layout is swapped. + */ +export interface TabStageStore { + getNode: (tabId: string, ownBackground: boolean) => HTMLDivElement; + /** Tab ids in the order their portals render: append only, never reordered. */ + portalOrder: (tabs: Tab[]) => Tab[]; + paneEls: Map; + setPaneEl: (paneId: string, el: HTMLDivElement | null) => void; + normalView: () => HTMLDivElement | null; + setNormalView: (el: HTMLDivElement | null) => void; + /** Moves every node where it belongs and sets its visibility. */ + place: ( + tabs: Tab[], + activeTabId: string, + paneIdByTabId: Map, + ownBackground: (tab: Tab) => boolean, + ) => void; + subscribe: (listener: () => void) => () => void; + version: () => number; +} + +export function createTabStageStore(): TabStageStore { + const nodes = new Map(); + const paneEls = new Map(); + const listeners = new Set<() => void>(); + let normalView: HTMLDivElement | null = null; + let version = 0; + let order: string[] = []; + // Tab id the enter animation has already played for, so a re-render while + // the tab stays active (there can be several right after a switch) doesn't + // replay it, only a genuine switch to a different tab should. + let lastAnimatedTabId: string | null = null; + + const bump = () => { + version += 1; + for (const listener of listeners) listener(); + }; + + function getNode(tabId: string, ownBackground: boolean) { + let el = nodes.get(tabId); + if (!el) { + el = document.createElement("div"); + el.style.position = "absolute"; + el.style.inset = "0"; + el.style.overflow = "hidden"; + if (!ownBackground) el.classList.add("bg-background"); + nodes.set(tabId, el); + } + return el; + } + + // Portal render order for tab content, kept independent of the tab bar's + // visual order. Reordering tabs in the bar reorders `tabs`, and mapping + // that array directly to portals reshuffles the Suspense-wrapped portal + // children's sibling order in the fiber tree, React then runs its + // Offscreen disconnect/reconnect pass on the ones that moved, which tears + // down and rebuilds every passive effect underneath (including + // react-xtermjs's terminal-creation effect), dropping the live terminal + // and its WebSocket. Portal position doesn't need to track tab order at + // all, so we only ever append new ids and drop closed ones here. + function portalOrder(tabs: Tab[]) { + const liveIds = new Set(tabs.map((t) => t.id)); + order = order.filter((id) => liveIds.has(id)); + const known = new Set(order); + for (const tab of tabs) { + if (!known.has(tab.id)) order.push(tab.id); + } + return order + .map((id) => tabs.find((t) => t.id === id)) + .filter((t): t is Tab => t !== undefined); + } + + function setPaneEl(paneId: string, el: HTMLDivElement | null) { + if (el) { + if (paneEls.get(paneId) === el) return; + paneEls.set(paneId, el); + } else { + if (!paneEls.has(paneId)) return; + paneEls.delete(paneId); + } + bump(); + } + + function setNormalView(el: HTMLDivElement | null) { + if (normalView === el) return; + normalView = el; + bump(); + } + + function place( + tabs: Tab[], + activeTabId: string, + paneIdByTabId: Map, + ownBackground: (tab: Tab) => boolean, + ) { + const tabIds = new Set(tabs.map((t) => t.id)); + + // Remove nodes for closed tabs + for (const [id, node] of nodes) { + if (!tabIds.has(id)) { + node.remove(); + nodes.delete(id); + } + } + + for (const tab of tabs) { + const isTerminal = ownBackground(tab); + const node = getNode(tab.id, isTerminal); + const paneId = paneIdByTabId.get(tab.id); + const paneEl = paneId ? paneEls.get(paneId) : undefined; + const inPane = !!paneId; + const activeInline = !inPane && tab.id === activeTabId; + + if (inPane && paneEl) { + if (node.parentElement !== paneEl) paneEl.appendChild(node); + node.style.visibility = "visible"; + node.style.pointerEvents = "auto"; + node.style.display = ""; + node.style.zIndex = ""; + node.style.contentVisibility = ""; + continue; + } + // With no stage mounted the node stays where it is, still rendered. + if (normalView && node.parentElement !== normalView) { + normalView.appendChild(node); + } + if (isTerminal) { + node.style.display = ""; + node.style.visibility = activeInline ? "visible" : "hidden"; + node.style.pointerEvents = activeInline ? "auto" : "none"; + node.style.zIndex = activeInline ? "1" : "0"; + // xterm renders to a ; visibility:hidden alone can still let + // a stale composited frame flash through for a tick when switching + // to/from a non-terminal tab. content-visibility:hidden fully skips + // painting the subtree while keeping its layout box intact, so + // fitAddon.fit() still sees correct dimensions once it's shown again. + node.style.contentVisibility = activeInline ? "" : "hidden"; + } else { + // Plays a quick opacity fade-in on the tab that just became active. + // Only play it on an actual switch into this tab -- gating on the + // class alone replayed the animation on every unrelated re-render + // that happened while the tab was still active (any render after + // animationend had stripped the class), which looked like the + // panel kept growing for up to a second after switching. + if (activeInline && lastAnimatedTabId !== tab.id) { + lastAnimatedTabId = tab.id; + node.classList.remove("motion-workspace-enter"); + // Force a reflow so re-adding the class restarts the animation + // instead of no-oping because it was already removed this tick. + void node.offsetWidth; + node.classList.add("motion-workspace-enter"); + node.addEventListener( + "animationend", + () => node.classList.remove("motion-workspace-enter"), + { once: true }, + ); + } else if (!activeInline) { + node.classList.remove("motion-workspace-enter"); + if (lastAnimatedTabId === tab.id) lastAnimatedTabId = null; + } + node.style.visibility = ""; + node.style.pointerEvents = ""; + node.style.zIndex = activeInline ? "2" : ""; + node.style.display = activeInline ? "" : "none"; + } + } + } + + return { + getNode, + portalOrder, + paneEls, + setPaneEl, + normalView: () => normalView, + setNormalView, + place, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + version: () => version, + }; +} diff --git a/src/ui/sidebar/UserProfilePanel.tsx b/src/ui/sidebar/UserProfilePanel.tsx index 576246da7..71e2cb327 100644 --- a/src/ui/sidebar/UserProfilePanel.tsx +++ b/src/ui/sidebar/UserProfilePanel.tsx @@ -66,7 +66,9 @@ import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types"; import { toast } from "sonner"; import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n"; import { Select2 } from "@/components/select2"; -import { THEMES } from "@/settings/appearance/appearance-controls"; +import { useThemeOptions } from "@/settings/appearance/appearance-controls"; +import { LookSettings } from "@/settings/appearance/LookSettings"; +import { useChooseTheme } from "@/look/use-look-theme"; import { LANGUAGES } from "@/i18n/languages"; import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences"; import { reportBetaFeedbackUrl } from "@/lib/issue-url"; @@ -446,18 +448,6 @@ export function UserProfilePanel({ }) { const { t } = useTranslation(); const confirm = useConfirm(); - const themeLabel: Record = { - system: t("newUi.sidebar.userProfile.themeSystem"), - light: t("newUi.sidebar.userProfile.themeLight"), - dark: t("newUi.sidebar.userProfile.themeDark"), - dracula: t("newUi.sidebar.userProfile.themeDracula"), - catppuccin: t("newUi.sidebar.userProfile.themeCatppuccin"), - nord: t("newUi.sidebar.userProfile.themeNord"), - solarized: t("newUi.sidebar.userProfile.themeSolarized"), - "tokyo-night": t("newUi.sidebar.userProfile.themeTokyoNight"), - "one-dark": t("newUi.sidebar.userProfile.themeOneDark"), - gruvbox: t("newUi.sidebar.userProfile.themeGruvbox"), - }; const [openSections, setOpenSections] = useState>( () => new Set(["account"]), ); @@ -492,7 +482,9 @@ export function UserProfilePanel({ const [showPassword, setShowPassword] = useState(false); const [newKeyOpen, setNewKeyOpen] = useState(false); const colorInputRef = useRef(null); - const { theme, setTheme } = useTheme(); + const { theme, setTheme, setThemePreview } = useTheme(); + const themeOptions = useThemeOptions(); + const chooseTheme = useChooseTheme(); const localSnapshot = useRef>({}); // Appearance state, initialized from localStorage @@ -956,8 +948,8 @@ export function UserProfilePanel({ } function handleThemeChange(id: ThemeId) { - setTheme(id); - if (storageMode === "cloud") saveToCloud({ theme: id }); + setThemePreview(null); + chooseTheme(id); } function handleAccentChange(value: string) { @@ -1439,26 +1431,39 @@ export function UserProfilePanel({ onChange={(e) => handleThemeChange(e.target.value as ThemeId)} className="w-full px-2.5 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring appearance-none pr-7" > - {THEMES.map((th) => ( + {themeOptions.map((th) => ( ))}
- {THEMES.filter((th) => th.id !== "system").map((th) => ( -
+
+ + {t("looks.layoutLabel")} + + +
+
diff --git a/src/ui/tests/components/theme-provider.test.tsx b/src/ui/tests/components/theme-provider.test.tsx new file mode 100644 index 000000000..6cb2e5d51 --- /dev/null +++ b/src/ui/tests/components/theme-provider.test.tsx @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { act, cleanup, render } from "@testing-library/react"; +import { ThemeProvider, useTheme } from "@/components/theme-provider"; +import { resetPluginThemes, setPluginThemes } from "@/look/theme-registry"; + +let api: ReturnType | null = null; +function Capture() { + api = useTheme(); + return null; +} + +function mount() { + return render( + + + , + ); +} + +const classes = () => [...document.documentElement.classList]; + +afterEach(() => { + cleanup(); + resetPluginThemes(); + localStorage.clear(); + document.documentElement.className = ""; + document.getElementById("termix-theme-css")?.remove(); +}); + +describe("ThemeProvider", () => { + it("puts exactly one theme class on and keeps the choice", () => { + mount(); + expect(classes()).toEqual(["dark"]); + act(() => api!.setTheme("nord")); + expect(classes()).toEqual(["nord"]); + expect(localStorage.getItem("test-theme")).toBe("nord"); + }); + + it("can show a theme without keeping it", () => { + mount(); + act(() => api!.setTheme("gruvbox", false)); + expect(classes()).toEqual(["gruvbox"]); + expect(localStorage.getItem("test-theme")).toBeNull(); + }); + + it("previews a theme and goes back", () => { + mount(); + act(() => api!.setThemePreview("light")); + expect(classes()).toEqual(["light"]); + expect(api!.theme).toBe("dark"); + act(() => api!.setThemePreview(null)); + expect(classes()).toEqual(["dark"]); + }); + + it("applies a plugin theme's class and stylesheet, and drops them after", () => { + mount(); + act(() => + setPluginThemes([ + { + id: "paper", + label: "Paper", + mode: "light", + swatch: { background: "#fff", panel: "#eee", text: "#111" }, + pluginId: "paper-themes", + cssHref: "/plugin-assets/paper-themes/_themes/paper.css?v=1", + }, + ]), + ); + act(() => api!.setTheme("paper")); + expect(classes()).toEqual(["paper"]); + expect( + (document.getElementById("termix-theme-css") as HTMLLinkElement).href, + ).toContain("paper.css"); + act(() => api!.setTheme("dark")); + expect(classes()).toEqual(["dark"]); + expect(document.getElementById("termix-theme-css")).toBeNull(); + }); +}); diff --git a/src/ui/tests/look/RepairView.test.tsx b/src/ui/tests/look/RepairView.test.tsx new file mode 100644 index 000000000..8e9e51158 --- /dev/null +++ b/src/ui/tests/look/RepairView.test.tsx @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => + options && typeof options === "object" && !("defaultValue" in options) + ? `${key}:${JSON.stringify(options)}` + : ((options?.defaultValue as string | undefined) ?? key), + i18n: { language: "en" }, + }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); + +const permissions = vi.hoisted(() => ({ isAdmin: true })); +vi.mock("@/hooks/use-permissions", () => ({ + usePermissions: () => ({ + isAdmin: permissions.isAdmin, + permissions: [], + loaded: true, + has: () => permissions.isAdmin, + }), +})); + +const shell = vi.hoisted(() => ({ openSingleton: vi.fn() })); +vi.mock("@/shell/controller/ShellControllerProvider", () => ({ + useShellController: () => ({ tabs: { openSingleton: shell.openSingleton } }), + ShellConfirmCapture: () => null, +})); +vi.mock("@/shell/stage/TabStage", () => ({ + TabStage: () =>
, +})); + +const look = vi.hoisted(() => ({ + chooseLayout: vi.fn(async () => {}), + chooseThemeForAccount: vi.fn(async () => {}), + chooseDefaultLook: vi.fn(async () => {}), + chooseTheme: vi.fn(), +})); +vi.mock("@/look/look-store", () => ({ + chooseLayout: look.chooseLayout, + chooseThemeForAccount: look.chooseThemeForAccount, + chooseDefaultLook: look.chooseDefaultLook, +})); +vi.mock("@/look/use-look-theme", () => ({ + useChooseTheme: () => look.chooseTheme, +})); + +const manager = vi.hoisted(() => ({ + requestUpdate: vi.fn(), + requestInstall: vi.fn(), + toggle: vi.fn(), + setConsent: vi.fn(), + confirmConsent: vi.fn(), +})); +vi.mock("@/plugins/use-plugins-manager", () => ({ + usePluginsManager: () => ({ + ...manager, + loading: false, + registryError: null, + consent: null, + busy: new Set(), + plugins: [ + { + id: "fancy-layout", + name: "Fancy", + installed: true, + enabled: true, + updateAvailable: true, + latestVersion: "1.1.0", + managedByServer: false, + versions: [], + }, + { + id: "paper-themes", + name: "Paper themes", + description: "Light themes", + installed: false, + enabled: false, + updateAvailable: false, + latestVersion: "1.0.0", + versions: [{ version: "1.0.0", capabilities: ["ui:theme"] }], + }, + { + id: "docker", + name: "Docker", + installed: false, + versions: [{ version: "1.0.0", capabilities: ["ui:surface"] }], + }, + ], + }), +})); + +import { RepairView } from "@/look/RepairView"; +import { setPluginLayouts } from "@/look/layout-registry"; + +const status = { + kind: "repair" as const, + reason: "failed" as const, + layoutId: "fancy", + pluginId: "fancy-layout", + version: "1.0.0", + error: "activate threw", +}; + +const onRetry = vi.fn(); + +beforeEach(() => { + permissions.isAdmin = true; + for (const fn of [ + ...Object.values(look), + ...Object.values(manager), + shell.openSingleton, + onRetry, + ]) { + fn.mockClear(); + } + setPluginLayouts([{ id: "fancy", label: "Fancy", pluginId: "fancy-layout" }]); +}); +afterEach(cleanup); + +describe("RepairView", () => { + it("names the layout and the error, and copies the details", async () => { + const writeText = vi.fn(async () => {}); + Object.assign(navigator, { clipboard: { writeText } }); + render(); + expect(screen.getByText("activate threw")).toBeTruthy(); + expect(screen.getByText("looks.repair.reasons.failed")).toBeTruthy(); + fireEvent.click(screen.getByText("looks.repair.copy")); + await waitFor(() => expect(writeText).toHaveBeenCalled()); + const copied = String((writeText.mock.calls[0] as unknown[])[0]); + expect(copied).toContain("fancy"); + expect(copied).toContain("activate threw"); + }); + + it("retries", () => { + render(); + fireEvent.click(screen.getByText("looks.repair.retry")); + expect(onRetry).toHaveBeenCalled(); + }); + + it("switches to another layout or theme", () => { + render(); + fireEvent.click(screen.getByText("looks.builtinLayout")); + expect(look.chooseLayout).toHaveBeenCalledWith("default"); + fireEvent.click( + screen.getAllByTitle("newUi.sidebar.userProfile.themeNord")[0], + ); + expect(look.chooseTheme).toHaveBeenCalledWith("nord"); + }); + + it("resets to the default look", async () => { + localStorage.setItem("vite-ui-theme", "dracula"); + render(); + fireEvent.click(screen.getByText("looks.repair.reset")); + await waitFor(() => expect(look.chooseLayout).toHaveBeenCalledWith(null)); + expect(look.chooseThemeForAccount).toHaveBeenCalledWith(null); + expect(localStorage.getItem("vite-ui-theme")).toBeNull(); + fireEvent.click(screen.getByText("looks.repair.resetEveryone")); + expect(look.chooseDefaultLook).toHaveBeenCalledWith({ + layout: null, + theme: null, + }); + }); + + it("opens Settings in place and comes back", () => { + render(); + fireEvent.click(screen.getByText("looks.repair.settings")); + expect(shell.openSingleton).toHaveBeenCalledWith( + "settings", + undefined, + undefined, + { section: "appearance" }, + ); + expect(screen.getByTestId("stage")).toBeTruthy(); + fireEvent.click(screen.getByText("looks.repair.back")); + expect(screen.queryByTestId("stage")).toBeNull(); + }); + + it("lets an admin update or turn off the plugin", () => { + render(); + fireEvent.click( + screen.getByText( + 'looks.repair.update:{"name":"Fancy","version":"1.1.0"}', + ), + ); + expect(manager.requestUpdate).toHaveBeenCalledWith( + expect.objectContaining({ id: "fancy-layout" }), + ); + fireEvent.click(screen.getByText('looks.repair.disable:{"name":"Fancy"}')); + expect(manager.toggle).toHaveBeenCalledWith( + expect.objectContaining({ id: "fancy-layout" }), + ); + }); + + it("lets an admin install a layout or theme from the registries", () => { + render(); + fireEvent.click(screen.getByText("looks.repair.install")); + expect(screen.getByText("Paper themes")).toBeTruthy(); + expect(screen.queryByText("Docker")).toBeNull(); + fireEvent.click(screen.getByText("looks.repair.installButton")); + expect(manager.requestInstall).toHaveBeenCalledWith( + expect.objectContaining({ id: "paper-themes" }), + ); + }); + + it("gives everyone else switch and reset, and a note to ask an admin", () => { + permissions.isAdmin = false; + render(); + expect(screen.getByText("looks.repair.askAdmin")).toBeTruthy(); + expect(screen.getByText("looks.repair.reset")).toBeTruthy(); + expect(screen.getByText("looks.repair.switchLayout")).toBeTruthy(); + expect(screen.queryByText("looks.repair.resetEveryone")).toBeNull(); + expect(screen.queryByText("looks.repair.install")).toBeNull(); + expect(screen.queryByText(/looks\.repair\.update/)).toBeNull(); + expect(screen.queryByText(/looks\.repair\.disable/)).toBeNull(); + }); + + it("offers no retry in safe mode", () => { + render( + , + ); + expect(screen.getByText("looks.repair.safeTitle")).toBeTruthy(); + expect(screen.queryByText("looks.repair.retry")).toBeNull(); + }); +}); diff --git a/src/ui/tests/look/layout-status.test.ts b/src/ui/tests/look/layout-status.test.ts new file mode 100644 index 000000000..bf9c75962 --- /dev/null +++ b/src/ui/tests/look/layout-status.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import type { PluginSummary } from "@/api/plugins-api"; +import type { PluginRecord } from "@/plugin-host/plugin-store"; +import { + layoutStatus, + MAX_LAYOUT_CRASHES, + type LayoutStatusInput, +} from "@/look/layout-status"; + +const Layout = () => null; +const registered = { id: "fancy", pluginId: "fancy-layout", component: Layout }; + +function owner( + patch: Partial = {}, + frontend: PluginRecord["frontend"] = "active", + error?: string, +): PluginRecord { + return { + summary: { + id: "fancy-layout", + name: "Fancy", + version: "1.2.0", + enabled: true, + state: "active", + contributes: { layouts: [{ id: "fancy", label: "Fancy" }] }, + frontend: true, + ...patch, + }, + frontend, + error, + }; +} + +function input(patch: Partial = {}): LayoutStatusInput { + return { + layoutId: "fancy", + safeFromUrl: false, + safeFromEnv: false, + ready: true, + registered: undefined, + owner: undefined, + unavailable: undefined, + crashes: 0, + lastCrash: null, + ...patch, + }; +} + +describe("layoutStatus", () => { + it("draws a registered layout", () => { + expect(layoutStatus(input({ registered }))).toEqual({ + kind: "ok", + layout: registered, + }); + }); + + it("opens the repair view for ?safe-layout=1 and the env var first", () => { + expect( + layoutStatus(input({ registered, safeFromUrl: true })), + ).toMatchObject({ kind: "repair", reason: "safe-url" }); + expect( + layoutStatus(input({ registered, safeFromEnv: true })), + ).toMatchObject({ kind: "repair", reason: "safe-env" }); + }); + + it("gives up after the boundary trips twice, not once", () => { + expect(layoutStatus(input({ registered, crashes: 1 }))).toMatchObject({ + kind: "ok", + }); + expect( + layoutStatus( + input({ + registered, + owner: owner(), + crashes: MAX_LAYOUT_CRASHES, + lastCrash: "boom", + }), + ), + ).toEqual({ + kind: "repair", + reason: "crashed", + layoutId: "fancy", + pluginId: "fancy-layout", + version: "1.2.0", + error: "boom", + }); + }); + + it("waits until the look and plugins are known", () => { + expect(layoutStatus(input({ ready: false }))).toEqual({ kind: "loading" }); + }); + + it("says missing when nothing provides the layout", () => { + expect(layoutStatus(input())).toMatchObject({ + kind: "repair", + reason: "missing", + }); + // Running, but it never registered what it declares. + expect(layoutStatus(input({ owner: owner() }))).toMatchObject({ + reason: "missing", + }); + }); + + it("says disabled, blocked or failed from the plugin's state", () => { + expect( + layoutStatus(input({ owner: owner({ enabled: false }) })), + ).toMatchObject({ reason: "disabled", pluginId: "fancy-layout" }); + expect( + layoutStatus(input({ owner: owner({}, "blocked", "needs x") })), + ).toMatchObject({ reason: "blocked", error: "needs x" }); + expect( + layoutStatus(input({ owner: owner({}, "failed", "activate threw") })), + ).toMatchObject({ reason: "failed", error: "activate threw" }); + expect( + layoutStatus( + input({ + owner: owner({ state: "failed", lastError: "server" }, "none"), + }), + ), + ).toMatchObject({ reason: "failed", error: "server" }); + }); + + it("says incompatible when the server skipped its plugin", () => { + expect( + layoutStatus( + input({ + unavailable: { + id: "fancy-layout", + version: "2.0.0", + layouts: ["fancy"], + themes: [], + error: "needs Termix >=27", + }, + }), + ), + ).toEqual({ + kind: "repair", + reason: "incompatible", + layoutId: "fancy", + pluginId: "fancy-layout", + version: "2.0.0", + error: "needs Termix >=27", + }); + }); + + it("waits for a layout plugin that is about to load", () => { + expect(layoutStatus(input({ owner: owner({}, "loading") }))).toEqual({ + kind: "loading", + }); + expect(layoutStatus(input({ owner: owner({}, "inactive") }))).toEqual({ + kind: "loading", + }); + }); +}); diff --git a/src/ui/tests/look/look-store.test.ts b/src/ui/tests/look/look-store.test.ts new file mode 100644 index 000000000..c31569d09 --- /dev/null +++ b/src/ui/tests/look/look-store.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const api = vi.hoisted(() => ({ + getLook: vi.fn(), + saveUserLook: vi.fn(async () => {}), + saveDefaultLook: vi.fn( + async (d: { layout?: string | null; theme?: string | null }) => ({ + layout: d.layout ?? null, + theme: d.theme ?? null, + }), + ), +})); +vi.mock("@/api/look-api", () => api); + +import { + chooseDefaultLook, + chooseLayout, + chooseThemeForAccount, + chosenLayoutId, + getLookState, + loadLook, + resetLook, + safeLayoutRequested, +} from "@/look/look-store"; + +const response = { + layoutId: null, + themeId: null, + defaults: { layout: "admin-pick", theme: null }, + defaultLook: { layout: null, theme: null }, + safeLayout: false, + unavailable: [], +}; + +beforeEach(() => { + localStorage.clear(); + resetLook(); + api.getLook.mockReset(); + api.saveUserLook.mockClear(); +}); +afterEach(() => window.history.replaceState(null, "", "/")); + +describe("look store", () => { + it("fetches once and resolves the layout", async () => { + api.getLook.mockResolvedValue(response); + await Promise.all([loadLook(), loadLook()]); + expect(api.getLook).toHaveBeenCalledTimes(1); + expect(chosenLayoutId()).toBe("admin-pick"); + expect(localStorage.getItem("termix-layout")).toBe("admin-pick"); + }); + + it("never rejects, and remembers the last layout when the server fails", async () => { + localStorage.setItem("termix-layout", "remembered"); + api.getLook.mockRejectedValue(new Error("offline")); + await expect(loadLook()).resolves.toBeNull(); + expect(getLookState().loaded).toBe(true); + expect(chosenLayoutId()).toBe("remembered"); + }); + + it("saves choices and asks plugins to reload for a new layout", async () => { + api.getLook.mockResolvedValue(response); + await loadLook(); + const changed = vi.fn(); + window.addEventListener("termix:plugins-changed", changed); + await chooseLayout("mine"); + expect(api.saveUserLook).toHaveBeenCalledWith({ layoutId: "mine" }); + expect(chosenLayoutId()).toBe("mine"); + expect(changed).toHaveBeenCalled(); + await chooseThemeForAccount("nord"); + expect(getLookState().response?.themeId).toBe("nord"); + await chooseDefaultLook({ theme: "dracula" }); + expect(getLookState().response?.defaults.theme).toBe("dracula"); + window.removeEventListener("termix:plugins-changed", changed); + }); + + it("asks for the repair view from the address or the server", async () => { + api.getLook.mockResolvedValue({ ...response, safeLayout: true }); + expect(safeLayoutRequested()).toBe(false); + await loadLook(); + expect(safeLayoutRequested()).toBe(true); + resetLook(); + window.history.replaceState(null, "", "/?safe-layout=1"); + expect(safeLayoutRequested()).toBe(true); + }); +}); diff --git a/src/ui/tests/look/resolve-look.test.ts b/src/ui/tests/look/resolve-look.test.ts new file mode 100644 index 000000000..b02736bfb --- /dev/null +++ b/src/ui/tests/look/resolve-look.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { LookResponse } from "@/api/look-api"; +import { + FALLBACK_THEME, + resolveLayoutId, + resolveThemeId, + safeLayoutInUrl, +} from "@/look/resolve-look"; +import { BUILTIN_LAYOUT_ID } from "@/look/layout-registry"; + +function look(patch: Partial = {}): LookResponse { + return { + layoutId: null, + themeId: null, + defaults: { layout: null, theme: null }, + defaultLook: { layout: null, theme: null }, + safeLayout: false, + unavailable: [], + ...patch, + }; +} + +describe("resolveLayoutId", () => { + it("prefers the user, then the admin default, then the default look", () => { + const full = look({ + layoutId: "mine", + defaults: { layout: "admin", theme: null }, + defaultLook: { layout: "shipped", theme: null }, + }); + expect(resolveLayoutId(full)).toBe("mine"); + expect(resolveLayoutId({ ...full, layoutId: null })).toBe("admin"); + expect( + resolveLayoutId({ + ...full, + layoutId: null, + defaults: { layout: null, theme: null }, + }), + ).toBe("shipped"); + expect(resolveLayoutId(look())).toBe(BUILTIN_LAYOUT_ID); + }); + + it("falls back to the last layout shown when the server did not answer", () => { + expect(resolveLayoutId(null, "remembered")).toBe("remembered"); + expect(resolveLayoutId(null)).toBe(BUILTIN_LAYOUT_ID); + }); +}); + +describe("resolveThemeId", () => { + it("keeps today's per-browser theme ahead of the defaults", () => { + const defaults = look({ + defaults: { layout: null, theme: "admin" }, + defaultLook: { layout: null, theme: "shipped" }, + }); + expect(resolveThemeId(defaults, "local")).toBe("local"); + expect(resolveThemeId({ ...defaults, themeId: "mine" }, "local")).toBe( + "mine", + ); + expect(resolveThemeId(defaults, null, "layout")).toBe("admin"); + expect( + resolveThemeId( + { ...defaults, defaults: { layout: null, theme: null } }, + null, + "layout", + ), + ).toBe("layout"); + expect( + resolveThemeId( + { ...defaults, defaults: { layout: null, theme: null } }, + null, + ), + ).toBe("shipped"); + expect(resolveThemeId(null, null)).toBe(FALLBACK_THEME); + }); +}); + +describe("safeLayoutInUrl", () => { + it("reads ?safe-layout=1", () => { + expect(safeLayoutInUrl("?safe-layout=1")).toBe(true); + expect(safeLayoutInUrl("?x=1&safe-layout=true")).toBe(true); + expect(safeLayoutInUrl("?safe-layout=0")).toBe(false); + expect(safeLayoutInUrl("")).toBe(false); + }); +}); diff --git a/src/ui/tests/look/theme-css.test.ts b/src/ui/tests/look/theme-css.test.ts new file mode 100644 index 000000000..433e7e05f --- /dev/null +++ b/src/ui/tests/look/theme-css.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { applyThemeCss } from "@/look/theme-css"; +import { + getTheme, + resetPluginThemes, + setPluginThemes, +} from "@/look/theme-registry"; + +const swatch = { background: "#000000", panel: "#111111", text: "#ffffff" }; +const link = () => + document.getElementById("termix-theme-css") as HTMLLinkElement | null; + +afterEach(() => { + resetPluginThemes(); + link()?.remove(); + localStorage.clear(); +}); + +describe("applyThemeCss", () => { + it("loads only the shown plugin theme's stylesheet", () => { + setPluginThemes([ + { + id: "paper", + label: "Paper", + mode: "light", + swatch, + pluginId: "x", + cssHref: "/plugin-assets/x/_themes/paper.css?v=1", + }, + ]); + applyThemeCss("paper", getTheme("paper")); + expect(link()?.href).toContain("/plugin-assets/x/_themes/paper.css?v=1"); + + applyThemeCss("dark", getTheme("dark")); + expect(link()).toBeNull(); + }); + + it("keeps the last plugin theme's stylesheet until plugins are known", () => { + setPluginThemes([ + { + id: "paper", + label: "Paper", + mode: "light", + swatch, + pluginId: "x", + cssHref: "/plugin-assets/x/_themes/paper.css?v=1", + }, + ]); + applyThemeCss("paper", getTheme("paper")); + link()?.remove(); + + // A reload: no plugin themes yet. + resetPluginThemes(); + applyThemeCss("paper", getTheme("paper")); + expect(link()?.href).toContain("paper.css"); + + // Plugins answered and the theme is gone. + setPluginThemes([]); + applyThemeCss("paper", getTheme("paper")); + expect(link()).toBeNull(); + expect(localStorage.getItem("termix-theme-css")).toBeNull(); + }); +}); diff --git a/src/ui/tests/look/theme-registry.test.ts b/src/ui/tests/look/theme-registry.test.ts new file mode 100644 index 000000000..4a4f0f517 --- /dev/null +++ b/src/ui/tests/look/theme-registry.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + BUILTIN_THEMES, + arePluginThemesKnown, + getTheme, + listThemes, + resetPluginThemes, + setPluginThemes, + themeLabel, + themeMode, +} from "@/look/theme-registry"; + +afterEach(() => resetPluginThemes()); + +const swatch = { background: "#000000", panel: "#111111", text: "#ffffff" }; + +describe("theme registry", () => { + it("lists the built-ins in their order", () => { + expect(listThemes().map((theme) => theme.id)).toEqual( + BUILTIN_THEMES.map((theme) => theme.id), + ); + expect(arePluginThemesKnown()).toBe(false); + }); + + it("lets a plugin theme take a built-in's place and adds new ones after", () => { + setPluginThemes([ + { + id: "nord", + label: "themes.nord", + mode: "dark", + swatch, + pluginId: "more-themes", + cssHref: "/plugin-assets/more-themes/_themes/nord.css", + }, + { + id: "paper", + label: "Paper", + mode: "light", + swatch, + pluginId: "more-themes", + }, + ]); + const ids = listThemes().map((theme) => theme.id); + expect(ids.indexOf("nord")).toBe( + BUILTIN_THEMES.findIndex((theme) => theme.id === "nord"), + ); + expect(ids[ids.length - 1]).toBe("paper"); + expect(getTheme("nord")?.pluginId).toBe("more-themes"); + expect(arePluginThemesKnown()).toBe(true); + + setPluginThemes([]); + expect(getTheme("nord")?.pluginId).toBeUndefined(); + expect(getTheme("paper")).toBeUndefined(); + }); + + it("reports each theme's mode, following the OS for system", () => { + expect(themeMode("light")).toBe("light"); + expect(themeMode("dracula")).toBe("dark"); + expect(["light", "dark"]).toContain(themeMode("system")); + setPluginThemes([ + { id: "paper", label: "Paper", mode: "light", swatch, pluginId: "x" }, + ]); + expect(themeMode("paper")).toBe("light"); + }); + + it("translates core labels in core and plugin labels in the plugin", () => { + const t = ((key: string, options?: { defaultValue?: string }) => + key.includes(":") + ? (options?.defaultValue ?? key) + : `t(${key})`) as never; + expect(themeLabel(BUILTIN_THEMES[0], t)).toBe( + `t(${BUILTIN_THEMES[0].label})`, + ); + expect( + themeLabel( + { id: "paper", label: "Paper", mode: "light", swatch, pluginId: "x" }, + t, + ), + ).toBe("Paper"); + }); +}); diff --git a/src/ui/tests/plugin-host/bridge.test.ts b/src/ui/tests/plugin-host/bridge.test.ts index c533df669..c4fda23fe 100644 --- a/src/ui/tests/plugin-host/bridge.test.ts +++ b/src/ui/tests/plugin-host/bridge.test.ts @@ -75,3 +75,54 @@ describe("toHostRecord", () => { expect(loose).not.toHaveProperty("enableDocker"); }); }); + +describe("useTheme", () => { + it("reports the mode of the shown theme, plugin themes included", async () => { + const { ThemeProvider, useTheme: useAppTheme } = + await import("@/components/theme-provider"); + const { setPluginThemes, resetPluginThemes } = + await import("@/look/theme-registry"); + const { createElement } = await import("react"); + let setTheme: ((id: string) => void) | null = null; + const { result } = renderHook( + () => { + setTheme = useAppTheme().setTheme; + return pluginHostBridge.useTheme(); + }, + { + wrapper: ({ children }: { children: React.ReactNode }) => + createElement(ThemeProvider, { + storageKey: "bridge-theme", + children, + }), + }, + ); + expect(result.current.theme).toBe("dark"); + act(() => setTheme!("light")); + expect(result.current.theme).toBe("light"); + act(() => + setPluginThemes([ + { + id: "paper", + label: "Paper", + mode: "light", + swatch: { background: "#fff", panel: "#eee", text: "#111" }, + pluginId: "x", + }, + ]), + ); + act(() => setTheme!("paper")); + expect(result.current.theme).toBe("light"); + act(() => setTheme!("dracula")); + expect(result.current.theme).toBe("dark"); + resetPluginThemes(); + localStorage.removeItem("bridge-theme"); + }); +}); + +describe("useShell", () => { + it("is null outside the signed-in shell", () => { + const { result } = renderHook(() => pluginHostBridge.useShell()); + expect(result.current).toBeNull(); + }); +}); diff --git a/src/ui/tests/plugin-host/loader.test.tsx b/src/ui/tests/plugin-host/loader.test.tsx index 69f168d97..421d7d401 100644 --- a/src/ui/tests/plugin-host/loader.test.tsx +++ b/src/ui/tests/plugin-host/loader.test.tsx @@ -1,4 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const lookApi = vi.hoisted(() => ({ + getLook: vi.fn(async (): Promise => { + throw new Error("no server"); + }), + saveUserLook: vi.fn(), + saveDefaultLook: vi.fn(), +})); +vi.mock("@/api/look-api", () => lookApi); import { Puzzle } from "lucide-react"; import type { FrontendModule, @@ -25,6 +34,14 @@ import { } from "@/sidebar/rail-items"; import { getTabType, resetTabTypes } from "@/shell/tab-registry"; import i18n from "@/i18n/i18n"; +import { resetLook } from "@/look/look-store"; +import { + getRegisteredLayout, + listLayouts, + resetPluginLayouts, + resetRegisteredLayouts, +} from "@/look/layout-registry"; +import { getTheme, resetPluginThemes } from "@/look/theme-registry"; function summary( id: string, @@ -84,6 +101,11 @@ beforeEach(() => { afterEach(async () => { await resetPluginLoader(); + resetLook(); + resetRegisteredLayouts(); + resetPluginLayouts(); + resetPluginThemes(); + window.history.replaceState(null, "", "/"); resetPluginStore(); resetRegisteredRailItems(); resetTabTypes(); @@ -393,3 +415,103 @@ describe("activation order", () => { expect(railIds()).toContain("beta-panel"); }); }); + +describe("layouts and themes", () => { + function look(layoutId: string | null) { + return { + layoutId, + themeId: null, + defaults: { layout: null, theme: null }, + defaultLook: { layout: null, theme: null }, + safeLayout: false, + unavailable: [], + }; + } + + function layoutPlugin(id: string, layout: string): FrontendModule { + return { + activate(app: TermixApp) { + app.registerLayout({ + id: layout, + label: layout, + component: () => null, + }); + }, + }; + } + + function layoutSummary(id: string, layout: string) { + return summary(id, { + contributes: { layouts: [{ id: layout, label: layout }] }, + }); + } + + it("loads only the chosen layout's plugin", async () => { + lookApi.getLook.mockResolvedValueOnce(look("two")); + modules["one-layout"] = layoutPlugin("one-layout", "one"); + modules["two-layout"] = layoutPlugin("two-layout", "two"); + modules.alpha = railPlugin("alpha"); + plugins = [ + layoutSummary("one-layout", "one"), + layoutSummary("two-layout", "two"), + summary("alpha"), + ]; + + await startPluginRuntime(); + expect(getPluginRecord("one-layout")?.frontend).toBe("inactive"); + expect(getPluginRecord("two-layout")?.frontend).toBe("active"); + expect(getPluginRecord("alpha")?.frontend).toBe("active"); + expect(getRegisteredLayout("two")).toBeDefined(); + expect(getRegisteredLayout("one")).toBeUndefined(); + // Both can still be chosen. + expect(listLayouts().map((layout) => layout.id)).toEqual( + expect.arrayContaining(["one", "two"]), + ); + }); + + it("loads no layout plugin in safe mode", async () => { + window.history.replaceState(null, "", "/?safe-layout=1"); + lookApi.getLook.mockResolvedValueOnce(look("two")); + modules["two-layout"] = layoutPlugin("two-layout", "two"); + plugins = [layoutSummary("two-layout", "two")]; + await startPluginRuntime(); + expect(getPluginRecord("two-layout")?.frontend).toBe("inactive"); + }); + + it("refuses a layout the manifest does not declare", async () => { + lookApi.getLook.mockResolvedValueOnce(look("two")); + vi.spyOn(console, "error").mockImplementation(() => {}); + modules["two-layout"] = layoutPlugin("two-layout", "undeclared"); + plugins = [layoutSummary("two-layout", "two")]; + await startPluginRuntime(); + expect(getPluginRecord("two-layout")?.frontend).toBe("failed"); + expect(getPluginRecord("two-layout")?.error).toContain( + "contributes.layouts", + ); + }); + + it("offers a running plugin's themes with their stylesheet", async () => { + plugins = [ + summary("paper", { + frontend: false, + themeVersion: "t1", + contributes: { + themes: [ + { + id: "paper", + label: "Paper", + mode: "light", + css: "themes/paper.css", + }, + ], + }, + }), + ]; + await syncPlugins(); + const theme = getTheme("paper"); + expect(theme?.mode).toBe("light"); + expect(theme?.cssHref).toContain( + "/plugin-assets/paper/_themes/paper.css?v=t1", + ); + }); +}); diff --git a/src/ui/tests/settings/appearance/LookSettings.test.tsx b/src/ui/tests/settings/appearance/LookSettings.test.tsx new file mode 100644 index 000000000..2c315055b --- /dev/null +++ b/src/ui/tests/settings/appearance/LookSettings.test.tsx @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: { defaultValue?: string }) => + options?.defaultValue ?? key, + }), +})); + +const look = vi.hoisted(() => ({ + chooseLayout: vi.fn(async () => {}), + chooseDefaultLook: vi.fn(async () => {}), + state: { + loaded: true, + response: { + layoutId: null as string | null, + themeId: null, + defaults: { layout: null, theme: null }, + defaultLook: { layout: null, theme: null }, + safeLayout: false, + unavailable: [], + }, + }, +})); +vi.mock("@/look/look-store", () => ({ + chooseLayout: look.chooseLayout, + chooseDefaultLook: look.chooseDefaultLook, + useLookState: () => look.state, +})); + +import { LookSettings } from "@/settings/appearance/LookSettings"; +import { resetPluginLayouts, setPluginLayouts } from "@/look/layout-registry"; + +beforeEach(() => { + look.chooseLayout.mockClear(); + look.chooseDefaultLook.mockClear(); + setPluginLayouts([{ id: "fancy", label: "Fancy", pluginId: "fancy-layout" }]); +}); +afterEach(() => { + cleanup(); + resetPluginLayouts(); +}); + +describe("LookSettings", () => { + it("lists every layout and saves the user's pick", () => { + render(); + const select = screen.getAllByRole("combobox")[0]; + expect(screen.getAllByText("Fancy").length).toBeGreaterThan(0); + fireEvent.change(select, { target: { value: "fancy" } }); + expect(look.chooseLayout).toHaveBeenCalledWith("fancy"); + fireEvent.change(select, { target: { value: "" } }); + expect(look.chooseLayout).toHaveBeenCalledWith(null); + expect(screen.queryByText("looks.defaultsLabel")).toBeNull(); + }); + + it("lets an admin set the default layout and theme", () => { + render(); + expect(screen.getByText("looks.defaultsLabel")).toBeTruthy(); + const [, layoutDefault, themeDefault] = screen.getAllByRole("combobox"); + fireEvent.change(layoutDefault, { target: { value: "fancy" } }); + expect(look.chooseDefaultLook).toHaveBeenCalledWith({ layout: "fancy" }); + fireEvent.change(themeDefault, { target: { value: "nord" } }); + expect(look.chooseDefaultLook).toHaveBeenCalledWith({ theme: "nord" }); + }); +}); diff --git a/src/ui/tests/settings/appearance/appearance-controls.test.tsx b/src/ui/tests/settings/appearance/appearance-controls.test.tsx index a9d3c6468..d76c7e7cd 100644 --- a/src/ui/tests/settings/appearance/appearance-controls.test.tsx +++ b/src/ui/tests/settings/appearance/appearance-controls.test.tsx @@ -5,6 +5,7 @@ import { render, renderHook, screen, + waitFor, } from "@testing-library/react"; const prefsApi = vi.hoisted(() => ({ @@ -13,9 +14,24 @@ const prefsApi = vi.hoisted(() => ({ })); vi.mock("@/api/open-tabs-api", () => prefsApi); -const theme = vi.hoisted(() => ({ value: "dark", setTheme: vi.fn() })); +const lookApi = vi.hoisted(() => ({ + getLook: vi.fn(), + saveUserLook: vi.fn(async () => {}), + saveDefaultLook: vi.fn(), +})); +vi.mock("@/api/look-api", () => lookApi); + +const theme = vi.hoisted(() => ({ + value: "dark", + setTheme: vi.fn(), + setThemePreview: vi.fn(), +})); vi.mock("@/components/theme-provider", () => ({ - useTheme: () => ({ theme: theme.value, setTheme: theme.setTheme }), + useTheme: () => ({ + theme: theme.value, + setTheme: theme.setTheme, + setThemePreview: theme.setThemePreview, + }), })); import { @@ -31,6 +47,8 @@ beforeEach(() => { prefsApi.getUserPreferences.mockReset(); prefsApi.saveUserPreferences.mockClear(); theme.setTheme.mockClear(); + theme.setThemePreview.mockClear(); + lookApi.saveUserLook.mockClear(); }); describe("isHexColor", () => { @@ -59,9 +77,12 @@ describe("useAppearanceSettings", () => { result.current.setFontSize("lg"); }); expect(theme.setTheme).toHaveBeenCalledWith("nord"); - expect(prefsApi.saveUserPreferences).toHaveBeenCalledWith({ - theme: "nord", - }); + await waitFor(() => + expect(prefsApi.saveUserPreferences).toHaveBeenCalledWith({ + theme: "nord", + }), + ); + expect(lookApi.saveUserLook).toHaveBeenCalledWith({ themeId: "nord" }); expect(prefsApi.saveUserPreferences).toHaveBeenCalledWith({ fontSize: "lg", }); @@ -77,6 +98,32 @@ describe("ThemeGrid", () => { fireEvent.click(buttons[THEMES.findIndex((t) => t.id === "gruvbox")]); expect(onChange).toHaveBeenCalledWith("gruvbox"); }); + + it("previews a theme while it is hovered", () => { + render(); + const nord = + screen.getAllByRole("button")[THEMES.findIndex((t) => t.id === "nord")]; + fireEvent.mouseEnter(nord); + expect(theme.setThemePreview).toHaveBeenLastCalledWith("nord"); + fireEvent.mouseLeave(nord); + expect(theme.setThemePreview).toHaveBeenLastCalledWith(null); + }); +}); + +describe("useAppearanceSettings in local mode", () => { + it("keeps the theme off the account", async () => { + prefsApi.getUserPreferences.mockResolvedValue({ storageMode: "local" }); + const { result } = renderHook(() => useAppearanceSettings()); + await act(async () => { + result.current.setTheme("nord"); + }); + await waitFor(() => + expect(lookApi.saveUserLook).toHaveBeenCalledWith({ themeId: null }), + ); + expect(prefsApi.saveUserPreferences).not.toHaveBeenCalledWith({ + theme: "nord", + }); + }); }); describe("AccentPicker", () => { diff --git a/src/ui/tests/shell/controller/ShellControllerProvider.test.tsx b/src/ui/tests/shell/controller/ShellControllerProvider.test.tsx new file mode 100644 index 000000000..fb13820cb --- /dev/null +++ b/src/ui/tests/shell/controller/ShellControllerProvider.test.tsx @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; + +const api = vi.hoisted(() => ({ + getUserInfo: vi.fn(async () => ({ is_admin: true })), + getUserPreferences: vi.fn(async () => ({ reopenTabsOnLogin: false })), + saveUserPreferences: vi.fn(async () => {}), + getSSHHosts: vi.fn(async () => [] as unknown[]), + getSSHFolders: vi.fn(async () => []), + getOpenTabs: vi.fn(async () => []), + getActiveSessions: vi.fn(async () => []), + addOpenTab: vi.fn(async () => {}), + deleteOpenTab: vi.fn(async () => {}), + patchOpenTab: vi.fn(async () => {}), + createSSHHost: vi.fn(async () => ({})), +})); +vi.mock("@/main-axios", () => api); +vi.mock("@/hooks/use-sync-status", () => ({ + useSyncStatus: () => undefined, + useSyncAttentionCount: () => 0, +})); + +import { + ShellControllerProvider, + useShellController, +} from "@/shell/controller/ShellControllerProvider"; +import { getShellController } from "@/shell/controller/shell-store"; +import type { ShellController } from "@/shell/controller/types"; +import { registerTabType, resetTabTypes } from "@/shell/tab-registry"; +import { + resetShellBridge, + shell as bridgeShell, +} from "@/plugin-host/shell-bridge"; + +let shell: ShellController | null = null; +function Capture() { + shell = useShellController(); + return null; +} + +const logout = vi.fn(); + +function mount() { + return render( + + + , + ); +} + +beforeEach(() => { + localStorage.clear(); + shell = null; + logout.mockClear(); + registerTabType({ + id: "notes", + component: () => null, + hostless: true, + multiInstance: true, + }); +}); + +afterEach(() => { + cleanup(); + resetTabTypes(); + resetShellBridge(); +}); + +describe("ShellControllerProvider", () => { + it("starts on the dashboard and knows the user", async () => { + mount(); + await waitFor(() => expect(shell?.isAdmin).toBe(true)); + expect(shell!.username).toBe("ada"); + expect(shell!.tabs.list.map((tab) => tab.id)).toEqual(["dashboard"]); + expect(shell!.tabs.activeId).toBe("dashboard"); + }); + + it("opens, renames and closes tabs, back to the dashboard", async () => { + mount(); + await waitFor(() => expect(shell).not.toBeNull()); + let id = ""; + act(() => { + id = shell!.tabs.openMultiInstance("notes"); + }); + expect(shell!.tabs.activeId).toBe(id); + act(() => shell!.tabs.rename(id, "Mine")); + expect(shell!.tabs.list.find((tab) => tab.id === id)?.label).toBe("Mine"); + await act(async () => { + await shell!.tabs.close(id); + }); + expect(shell!.tabs.list.map((tab) => tab.id)).toEqual(["dashboard"]); + expect(shell!.tabs.activeId).toBe("dashboard"); + }); + + it("opens the settings singleton once and passes the section on", async () => { + mount(); + await waitFor(() => expect(shell).not.toBeNull()); + act(() => { + shell!.tabs.openSingleton("settings"); + }); + act(() => { + shell!.tabs.openSingleton("admin-settings"); + }); + const settings = shell!.tabs.list.filter((tab) => tab.id === "settings"); + expect(settings).toHaveLength(1); + expect(settings[0].data).toMatchObject({ section: "admin-general" }); + }); + + it("toggles the palette from the keyboard and the controller", async () => { + mount(); + await waitFor(() => expect(shell).not.toBeNull()); + act(() => { + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "k", code: "KeyK", ctrlKey: true }), + ); + }); + expect(shell!.palette.isOpen).toBe(true); + act(() => shell!.palette.close()); + expect(shell!.palette.isOpen).toBe(false); + }); + + it("publishes itself for plugins and the old bridge", async () => { + mount(); + await waitFor(() => expect(getShellController()).not.toBeNull()); + act(() => bridgeShell.openSingletonTab("plugins")); + await waitFor(() => + expect(shell!.tabs.list.some((tab) => tab.id === "plugins")).toBe(true), + ); + act(() => bridgeShell.openRailView("credentials")); + expect(shell!.regions.left).toMatchObject({ + view: "credentials", + open: true, + }); + }); + + it("logs out on the logout event", async () => { + mount(); + await waitFor(() => expect(shell).not.toBeNull()); + act(() => { + window.dispatchEvent( + new CustomEvent("termix:logout", { detail: { manual: true } }), + ); + }); + expect(logout).toHaveBeenCalledWith({ manual: true }); + }); + + it("splits the active tab and closes the split", async () => { + mount(); + await waitFor(() => expect(shell).not.toBeNull()); + let id = ""; + act(() => { + id = shell!.tabs.openMultiInstance("notes"); + }); + act(() => + shell!.split.handleAction({ kind: "splitActive", edge: "right" }), + ); + expect(shell!.split.active).not.toBeNull(); + expect(shell!.split.paneIdByTabId.has(id)).toBe(true); + const splitId = shell!.split.active!.id; + act(() => + shell!.split.handleAction({ kind: "unsplit", splitTabId: splitId }), + ); + expect(shell!.split.active).toBeNull(); + expect(shell!.tabs.activeId).toBe(id); + }); +}); diff --git a/src/ui/tests/shell/controller/plugin-shell.test.tsx b/src/ui/tests/shell/controller/plugin-shell.test.tsx new file mode 100644 index 000000000..4a2fdbab0 --- /dev/null +++ b/src/ui/tests/shell/controller/plugin-shell.test.tsx @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import type { ShellController as PluginShell } from "@termix-ssh/plugin-sdk/frontend"; + +vi.mock("@/main-axios", () => ({ + getUserInfo: vi.fn(async () => ({ is_admin: false })), + getUserPreferences: vi.fn(async () => ({ reopenTabsOnLogin: false })), + saveUserPreferences: vi.fn(async () => {}), + getSSHHosts: vi.fn(async () => []), + getSSHFolders: vi.fn(async () => []), + getOpenTabs: vi.fn(async () => []), + getActiveSessions: vi.fn(async () => []), + addOpenTab: vi.fn(async () => {}), + deleteOpenTab: vi.fn(async () => {}), + patchOpenTab: vi.fn(async () => {}), + createSSHHost: vi.fn(async () => ({})), +})); +vi.mock("@/hooks/use-sync-status", () => ({ + useSyncStatus: () => undefined, + useSyncAttentionCount: () => 0, +})); +vi.mock("@/hooks/use-permissions", () => ({ + usePermissions: () => ({ + permissions: ["hosts.view"], + isAdmin: false, + loaded: true, + has: () => true, + }), +})); + +import { ShellControllerProvider } from "@/shell/controller/ShellControllerProvider"; +import { usePluginShell } from "@/shell/controller/plugin-shell"; + +afterEach(cleanup); + +let shell: PluginShell | null = null; +function Capture() { + shell = usePluginShell("my-layout"); + return null; +} + +describe("usePluginShell", () => { + it("gives a layout the live shell", async () => { + render( + {}}> + + , + ); + await waitFor(() => expect(shell).not.toBeNull()); + expect(shell!.env).toMatchObject({ + user: { username: "ada", isAdmin: false }, + permissions: ["hosts.view"], + isElectron: false, + }); + expect(shell!.tabs.activeId).toBe("dashboard"); + expect(typeof shell!.registries.useRailItems).toBe("function"); + + act(() => shell!.actions.openSingletonTab("settings")); + await waitFor(() => expect(shell!.tabs.activeId).toBe("settings")); + + act(() => shell!.regions.railClick("credentials")); + expect(shell!.regions.left).toMatchObject({ + view: "credentials", + open: true, + }); + + act(() => shell!.palette.open()); + expect(shell!.palette.isOpen).toBe(true); + }); +}); diff --git a/src/ui/tests/shell/controller/use-regions.test.ts b/src/ui/tests/shell/controller/use-regions.test.ts new file mode 100644 index 000000000..906bc14d6 --- /dev/null +++ b/src/ui/tests/shell/controller/use-regions.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +vi.mock("@/sidebar/rail-items", () => ({ + rightDockableIds: () => ["history", "alerts"], +})); + +import { useRegions } from "@/shell/controller/use-regions"; +import { DOCK_DEFAULT_WIDTH } from "@/shell/DockPanel"; + +beforeEach(() => localStorage.clear()); + +describe("useRegions", () => { + it("toggles the left dock on a second click and switches on another", () => { + const { result } = renderHook(() => useRegions(false)); + expect(result.current.left).toMatchObject({ view: "hosts", open: true }); + + act(() => result.current.railClick("hosts")); + expect(result.current.left.open).toBe(false); + + act(() => result.current.railClick("credentials")); + expect(result.current.left).toMatchObject({ + view: "credentials", + open: true, + }); + expect(result.current.previousView()).toBe("hosts"); + }); + + it("moves a panel between docks instead of copying it", () => { + const { result } = renderHook(() => useRegions(false)); + act(() => result.current.railClick("history")); + act(() => result.current.openInRightDock("history")); + expect(result.current.right.view).toBe("history"); + expect(result.current.left.open).toBe(false); + + act(() => result.current.railClick("history")); + expect(result.current.right.view).toBeNull(); + expect(result.current.left).toMatchObject({ view: "history", open: true }); + }); + + it("brings back the last right dock panel and remembers sizes", () => { + const { result, unmount } = renderHook(() => useRegions(false)); + act(() => result.current.right.setView("alerts")); + act(() => result.current.toggleRightDock()); + expect(result.current.right.view).toBeNull(); + act(() => result.current.toggleRightDock()); + expect(result.current.right.view).toBe("alerts"); + + act(() => result.current.left.setWidth(400)); + unmount(); + const again = renderHook(() => useRegions(false)); + expect(again.result.current.left.width).toBe(400); + expect(again.result.current.right.view).toBe("alerts"); + act(() => again.result.current.left.resetWidth()); + expect(again.result.current.left.width).toBe(DOCK_DEFAULT_WIDTH); + }); + + it("closes the sidebar on mobile and restores it after", () => { + const { result, rerender } = renderHook( + ({ mobile }) => useRegions(mobile), + { initialProps: { mobile: false } }, + ); + rerender({ mobile: true }); + expect(result.current.left.open).toBe(false); + rerender({ mobile: false }); + expect(result.current.left.open).toBe(true); + }); + + it("widens for the widest editing panel or inline view", () => { + const { result } = renderHook(() => useRegions(false)); + act(() => result.current.panelEditingSetter("hosts")(true)); + expect(result.current.left.editing).toBe(true); + act(() => result.current.scopeEditingSetter("hosts")("wide")); + expect(result.current.left.editing).toBe("wide"); + expect(result.current.panelEditingSetter("hosts")).toBe( + result.current.panelEditingSetter("hosts"), + ); + }); +}); diff --git a/src/ui/tests/shell/layout/LayoutHost.test.tsx b/src/ui/tests/shell/layout/LayoutHost.test.tsx new file mode 100644 index 000000000..0eb5f31d7 --- /dev/null +++ b/src/ui/tests/shell/layout/LayoutHost.test.tsx @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; + +const lookApi = vi.hoisted(() => ({ + getLook: vi.fn(), + saveUserLook: vi.fn(async () => {}), + saveDefaultLook: vi.fn(), +})); +vi.mock("@/api/look-api", () => lookApi); + +const loader = vi.hoisted(() => ({ + retryPluginFrontend: vi.fn(async () => {}), + syncPlugins: vi.fn(async () => {}), +})); +vi.mock("@/plugin-host/loader", () => loader); + +const mobile = vi.hoisted(() => ({ value: false })); +vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => mobile.value })); + +// The built-in layout pulls in the whole classic shell; these tests register their own. +vi.mock("@/shell/layout/builtin", () => ({ registerBuiltinLayout: () => {} })); + +vi.mock("@/look/RepairView", () => ({ + RepairView: ({ + status, + onRetry, + }: { + status: { reason: string; error?: string | null }; + onRetry: () => void; + }) => ( +
+ {status.error} + +
+ ), +})); + +import { LayoutHost } from "@/shell/layout/LayoutHost"; +import { registerLayout, resetRegisteredLayouts } from "@/look/layout-registry"; +import { loadLook, resetLook } from "@/look/look-store"; +import { + markPluginsSettled, + resetPluginStore, + setFrontendState, + setPluginSummaries, +} from "@/plugin-host/plugin-store"; +import type { PluginSummary } from "@/api/plugins-api"; + +function lookFor(layoutId: string, safeLayout = false) { + return { + layoutId, + themeId: null, + defaults: { layout: null, theme: null }, + defaultLook: { layout: null, theme: null }, + safeLayout, + unavailable: [] as unknown[], + }; +} + +function plugin(patch: Partial = {}): PluginSummary { + return { + id: "fancy-layout", + name: "Fancy", + version: "1.0.0", + enabled: true, + state: "active", + frontend: true, + contributes: { layouts: [{ id: "fancy", label: "Fancy" }] }, + ...patch, + }; +} + +async function start( + layoutId: string, + summaries: PluginSummary[] = [], + safe = false, +) { + lookApi.getLook.mockResolvedValue(lookFor(layoutId, safe)); + await loadLook(); + setPluginSummaries(summaries); + markPluginsSettled(); +} + +beforeEach(() => { + resetLook(); + resetPluginStore(); + resetRegisteredLayouts(); + mobile.value = false; + loader.retryPluginFrontend.mockClear(); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); + vi.restoreAllMocks(); +}); + +describe("LayoutHost", () => { + it("draws the chosen layout", async () => { + registerLayout({ id: "fancy", component: () =>

fancy layout

}); + await start("fancy"); + render(); + expect(await screen.findByText("fancy layout")).toBeTruthy(); + }); + + it("draws the mobile component on a phone", async () => { + mobile.value = true; + registerLayout({ + id: "fancy", + component: () =>

desktop

, + mobileComponent: () =>

phone

, + }); + await start("fancy"); + render(); + expect(await screen.findByText("phone")).toBeTruthy(); + }); + + it("remounts after one crash and repairs after the second", async () => { + // React retries a failed render once by itself before a boundary sees it. + let throwsLeft = 2; + function Flaky() { + if (throwsLeft > 0) { + throwsLeft -= 1; + throw new Error("draw failed"); + } + return

recovered

; + } + // React reports the crash it recovered from; that report is expected. + const expected = (event: ErrorEvent) => { + if (String(event.error?.cause ?? event.message).includes("draw failed")) + event.preventDefault(); + }; + window.addEventListener("error", expected); + registerLayout({ id: "fancy", component: Flaky }); + await start("fancy"); + render(); + expect(await screen.findByText("recovered")).toBeTruthy(); + window.removeEventListener("error", expected); + + cleanup(); + resetRegisteredLayouts(); + function Broken(): never { + throw new Error("always broken"); + } + registerLayout({ id: "fancy", component: Broken }); + render(); + const repair = await screen.findByTestId("repair"); + expect(repair.dataset.reason).toBe("crashed"); + expect(repair.textContent).toContain("always broken"); + }); + + it("repairs with ?safe-layout=1 even when the layout works", async () => { + window.history.replaceState(null, "", "/?safe-layout=1"); + registerLayout({ id: "fancy", component: () =>

fancy layout

}); + await start("fancy"); + render(); + expect((await screen.findByTestId("repair")).dataset.reason).toBe( + "safe-url", + ); + }); + + it("repairs when the server sets TERMIX_SAFE_LAYOUT", async () => { + registerLayout({ id: "fancy", component: () =>

fancy layout

}); + await start("fancy", [], true); + render(); + expect((await screen.findByTestId("repair")).dataset.reason).toBe( + "safe-env", + ); + }); + + it("repairs a missing layout", async () => { + await start("ghost"); + render(); + expect((await screen.findByTestId("repair")).dataset.reason).toBe( + "missing", + ); + }); + + it("repairs a layout whose plugin is disabled", async () => { + await start("fancy", [plugin({ enabled: false, state: "disabled" })]); + render(); + expect((await screen.findByTestId("repair")).dataset.reason).toBe( + "disabled", + ); + }); + + it("repairs a layout that throws on activate, and retries it", async () => { + await start("fancy", [plugin()]); + setFrontendState("fancy-layout", "failed", "activate threw"); + render(); + const repair = await screen.findByTestId("repair"); + expect(repair.dataset.reason).toBe("failed"); + expect(repair.textContent).toContain("activate threw"); + screen.getByText("retry").click(); + await waitFor(() => + expect(loader.retryPluginFrontend).toHaveBeenCalledWith("fancy-layout"), + ); + }); +}); diff --git a/src/ui/tests/shell/stage/TabStage.test.tsx b/src/ui/tests/shell/stage/TabStage.test.tsx new file mode 100644 index 000000000..d22bedba4 --- /dev/null +++ b/src/ui/tests/shell/stage/TabStage.test.tsx @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { useEffect, useState } from "react"; + +vi.mock("@/main-axios", () => ({ + getUserInfo: vi.fn(async () => ({ is_admin: false })), + getUserPreferences: vi.fn(async () => ({ reopenTabsOnLogin: false })), + saveUserPreferences: vi.fn(async () => {}), + getSSHHosts: vi.fn(async () => []), + getSSHFolders: vi.fn(async () => []), + getOpenTabs: vi.fn(async () => []), + getActiveSessions: vi.fn(async () => []), + addOpenTab: vi.fn(async () => {}), + deleteOpenTab: vi.fn(async () => {}), + patchOpenTab: vi.fn(async () => {}), + createSSHHost: vi.fn(async () => ({})), +})); +vi.mock("@/hooks/use-sync-status", () => ({ + useSyncStatus: () => undefined, + useSyncAttentionCount: () => 0, +})); + +import type { TabRenderProps } from "@/shell/tab-registry"; +import { registerTabType } from "@/shell/tab-registry"; +import { + ShellControllerProvider, + useShellController, +} from "@/shell/controller/ShellControllerProvider"; +import type { ShellController } from "@/shell/controller/types"; +import { TabPortals } from "@/shell/stage/TabPortals"; +import { TabStage } from "@/shell/stage/TabStage"; + +afterEach(cleanup); + +const mounts = { count: 0 }; + +function Probe({ tab }: TabRenderProps) { + useEffect(() => { + mounts.count += 1; + }, []); + return
probe
; +} + +registerTabType({ + id: "probe", + component: Probe, + hostless: true, + multiInstance: true, +}); + +let shell: ShellController | null = null; + +function Capture() { + shell = useShellController(); + return null; +} + +function Harness({ stage }: { stage: boolean }) { + return ( + {}}> + + {stage && } + + + ); +} + +function Toggle() { + const [stage, setStage] = useState(true); + return ( + <> + + + + ); +} + +describe("TabStage", () => { + it("never remounts a tab moved between inline, a pane and a new stage", async () => { + mounts.count = 0; + render(); + await waitFor(() => expect(shell).not.toBeNull()); + + let tabId = ""; + act(() => { + tabId = shell!.tabs.openMultiInstance("probe"); + }); + const element = await screen.findByTestId(`probe-${tabId}`); + const node = element.closest("div[style]")!; + expect(mounts.count).toBe(1); + expect(node.isConnected).toBe(true); + + // Into a split pane. + act(() => { + shell!.split.handleAction({ kind: "split", tabId, edge: "right" }); + }); + await waitFor(() => + expect(element.closest("[data-split-pane-id]")).not.toBeNull(), + ); + expect(screen.getByTestId(`probe-${tabId}`)).toBe(element); + + // Back to the normal view. + const splitId = shell!.split.active!.id; + act(() => { + shell!.split.handleAction({ kind: "unsplit", splitTabId: splitId }); + }); + await waitFor(() => + expect(element.closest("[data-split-pane-id]")).toBeNull(), + ); + expect(screen.getByTestId(`probe-${tabId}`)).toBe(element); + + // The stage goes away (a layout swap) and comes back. + act(() => screen.getByText("toggle").click()); + expect(element.isConnected).toBe(false); + act(() => screen.getByText("toggle").click()); + await waitFor(() => expect(element.isConnected).toBe(true)); + + expect(screen.getByTestId(`probe-${tabId}`)).toBe(element); + expect(mounts.count).toBe(1); + }); + + it("shows only the active inline tab", async () => { + render(); + await waitFor(() => expect(shell).not.toBeNull()); + let first = ""; + let second = ""; + act(() => { + first = shell!.tabs.openMultiInstance("probe"); + }); + act(() => { + second = shell!.tabs.openMultiInstance("probe"); + }); + const a = await screen.findByTestId(`probe-${first}`); + const b = await screen.findByTestId(`probe-${second}`); + const nodeOf = (el: HTMLElement) => el.closest("div[style]")!; + expect(nodeOf(b).style.display).toBe(""); + expect(nodeOf(a).style.display).toBe("none"); + }); +});