diff --git a/app/profile_manager.py b/app/profile_manager.py index d4065db..47a741c 100644 --- a/app/profile_manager.py +++ b/app/profile_manager.py @@ -26,6 +26,44 @@ def _normalize_group(value): return normalized or None, None +def _group_key(value): + return str(value or '').strip().casefold() + + +def _valid_sort_order(value): + return type(value) is int and value >= 0 + + +def _ordered_group(profiles, group, exclude_id=None): + """Return one real group in its persisted order with stable legacy fallbacks.""" + key = _group_key(group) + indexed = [ + (index, profile) + for index, profile in enumerate(profiles) + if _group_key(profile.get('group')) == key + and profile.get('id') != exclude_id + ] + if not all(_valid_sort_order(profile.get('sort_order')) for _, profile in indexed): + return [profile for _, profile in indexed] + return [ + profile + for _, profile in sorted( + indexed, + key=lambda item: ( + item[1]['sort_order'], + item[0], + str(item[1].get('name', '')).casefold(), + str(item[1].get('host', '')).casefold(), + str(item[1].get('id', '')), + ), + ) + ] + + +def _next_sort_order(profiles, group, exclude_id=None): + return len(_ordered_group(profiles, group, exclude_id=exclude_id)) + + def _is_valid_host(host_str): """Validate host is a valid hostname or IP address.""" if not host_str or not isinstance(host_str, str): @@ -86,6 +124,8 @@ def _valid_profile(item): for field in ('use_tmux', 'tailscale_authorized', 'favorite'): if field in item and type(item[field]) is not bool: return False + if 'sort_order' in item and not _valid_sort_order(item['sort_order']): + return False return True @@ -103,6 +143,7 @@ def _valid_profile_document(value): 'jump_host_id', 'startup_mode', 'startup_commands', 'command_id', 'command_set_id', 'parameters_override', 'use_tmux', 'tailscale_authorized', 'group', 'favorite', 'created_at', 'updated_at', + 'sort_order', } @@ -273,6 +314,12 @@ def upsert_profile(user_id, payload, preserve_legacy_fallback=False): if profile_id: for index, existing in enumerate(profiles): if existing.get('id') == profile_id: + existing_group = existing.get('group') + target_group = ( + validated.get('group') + if 'group' in payload + else existing_group + ) unknown = { key: value for key, value in existing.items() @@ -292,6 +339,17 @@ def upsert_profile(user_id, payload, preserve_legacy_fallback=False): and existing.get('favorite') is True ): result['favorite'] = True + if _group_key(existing_group) == _group_key(target_group): + result['sort_order'] = ( + existing['sort_order'] + if _valid_sort_order(existing.get('sort_order')) + else _ordered_group(profiles, existing_group) + .index(existing) + ) + else: + result['sort_order'] = _next_sort_order( + profiles, target_group, exclude_id=profile_id + ) profiles[index] = result break else: @@ -300,6 +358,9 @@ def upsert_profile(user_id, payload, preserve_legacy_fallback=False): result = { **validated, 'id': str(uuid.uuid4()), + 'sort_order': _next_sort_order( + profiles, validated.get('group') + ), 'created_at': now, 'updated_at': now, } @@ -395,6 +456,105 @@ def update_profile_organization(user_id, profile_id, patch): ) return None, 'Failed to save profile' + +def move_profile( + user_id, + profile_id, + expected_source_group, + target_group, + target_index, + confirm_source_group_removal=False, +): + """Atomically move one profile to an exact position in a flat group.""" + if not isinstance(profile_id, str) or not profile_id: + return None, 'Profile ID required' + expected_source_group, error = _normalize_group(expected_source_group) + if error: + return None, error + target_group, error = _normalize_group(target_group) + if error: + return None, error + if type(target_index) is not int or target_index < 0: + return None, 'Invalid target index' + if type(confirm_source_group_removal) is not bool: + return None, 'Invalid confirmation value' + + try: + with storage_lock(f'command-config:{user_id}'): + with storage_lock(f'profiles:{user_id}'): + profiles, error = _load_profiles_for_write(user_id) + if error: + return None, error + profile = next( + (item for item in profiles if item.get('id') == profile_id), + None, + ) + if profile is None: + return None, 'Profile not found' + + source_group = profile.get('group') + if _group_key(source_group) != _group_key(expected_source_group): + return { + 'profiles': profiles, + 'requires_confirmation': False, + }, 'Profile group changed; retry move' + + source_members = _ordered_group(profiles, source_group) + changes_group = _group_key(source_group) != _group_key(target_group) + removes_source_group = ( + bool(_group_key(source_group)) + and changes_group + and len(source_members) == 1 + ) + if removes_source_group and not confirm_source_group_removal: + return { + 'profiles': profiles, + 'requires_confirmation': True, + 'profile_id': profile_id, + 'profile_name': profile.get('name', ''), + 'source_group': source_group, + }, None + + target_members = _ordered_group( + profiles, + target_group, + exclude_id=profile_id, + ) + insert_at = min(target_index, len(target_members)) + target_members.insert(insert_at, profile) + + if changes_group: + if target_group: + profile['group'] = target_group + else: + profile.pop('group', None) + for index, member in enumerate( + _ordered_group(profiles, source_group, exclude_id=profile_id) + ): + member['sort_order'] = index + + now = datetime.now(timezone.utc).isoformat() + for index, member in enumerate(target_members): + member['sort_order'] = index + if member.get('id') == profile_id: + member['updated_at'] = now + + if not save_profiles(user_id, profiles): + return None, 'Failed to save profile' + return { + 'profiles': profiles, + 'requires_confirmation': False, + }, None + except StorageCorruptionError: + raise + except Exception as exc: + log_error( + 'Error moving profile', + user_id=user_id, + error=str(exc), + ) + return None, 'Failed to move profile' + def delete_profile(user_id, profile_id): """Delete a profile by ID for a specific user.""" try: diff --git a/app/socket_events.py b/app/socket_events.py index 6724953..338d5f8 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -771,6 +771,63 @@ def handle_update_profile_organization(data, current_user=None): 'error': 'Failed to update profile organization', } + +@socketio.on('move_profile') +@socket_login_required +def handle_move_profile(data, current_user=None): + """Move one profile atomically within the user's flat group structure.""" + try: + data = data if isinstance(data, dict) else {} + profile_id = data.get('profile_id') + if not isinstance(profile_id, str) or not profile_id: + return {'success': False, 'error': 'Profile ID required'} + + source_group = data.get('expected_source_group') + if not isinstance(source_group, str): + return {'success': False, 'error': 'Invalid source group'} + target_group = data.get('target_group') + if not isinstance(target_group, str): + return {'success': False, 'error': 'Invalid target group'} + if len(source_group.strip()) > 64 or len(target_group.strip()) > 64: + return { + 'success': False, + 'error': 'Group must not exceed 64 characters', + } + + target_index = data.get('target_index') + if type(target_index) is not int or target_index < 0: + return {'success': False, 'error': 'Invalid target index'} + confirmed = data.get('confirm_source_group_removal', False) + if type(confirmed) is not bool: + return {'success': False, 'error': 'Invalid confirmation value'} + + result, error = profile_manager.move_profile( + current_user.id, + profile_id, + source_group, + target_group, + target_index, + confirm_source_group_removal=confirmed, + ) + if error: + return { + 'success': False, + 'error': error, + **(result or {}), + } + if result.get('requires_confirmation'): + return {'success': False, **result} + + payload = {'success': True, **result} + emit('profile_organization_updated', payload) + handle_list_profiles(current_user=current_user) + return payload + except StorageCorruptionError as error: + return _emit_storage_error(error, current_user) + except Exception as exc: + log_error('Failed to move profile', error=str(exc)) + return {'success': False, 'error': 'Failed to move profile'} + @socketio.on('list_jump_hosts') @socket_login_required def handle_list_jump_hosts(current_user=None): diff --git a/static/css/style.css b/static/css/style.css index 1d8e32f..02d2933 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -4732,6 +4732,31 @@ body.keyboard-open.notepad-focused .notepad-panel { color: var(--accent-primary); } +.connection-advanced-settings { + margin-bottom: 18px; + padding: 0 14px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); +} + +.connection-advanced-settings > summary { + padding: 13px 0; + color: var(--text-primary); + font-weight: 600; + cursor: pointer; +} + +.connection-advanced-settings > summary:hover, +.connection-advanced-settings > summary:focus-visible { + color: var(--accent-primary); +} + +.connection-advanced-settings[open] > summary { + margin-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} + /* Reusable post-connect command sets */ .post-connect-config { display: flex; @@ -4852,15 +4877,13 @@ body.keyboard-open.notepad-focused .notepad-panel { } .profile-management-toolbar, -.profile-management-item, .profile-management-actions { display: flex; align-items: center; gap: 12px; } -.profile-management-toolbar, -.profile-management-item { +.profile-management-toolbar { justify-content: space-between; } @@ -4884,13 +4907,67 @@ body.keyboard-open.notepad-focused .notepad-panel { .profile-management-list { display: grid; - gap: 10px; - margin-top: 16px; + gap: 12px; + margin-top: 18px; } .profile-management-section { display: grid; - gap: 10px; + gap: 8px; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); +} + +.profile-management-section-toggle { + display: flex; + align-items: center; + gap: 7px; + width: 100%; + min-height: 34px; + padding: 5px 7px; + border: 0; + border-radius: 6px; + background: transparent; + color: inherit; + font: inherit; + letter-spacing: inherit; + text-transform: inherit; + cursor: pointer; +} + +.profile-management-section-toggle:hover, +.profile-management-section-toggle:focus-visible { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.profile-management-section-toggle .material-icons { + font-size: 18px; +} + +.profile-management-section-count { + min-width: 24px; + margin-left: auto; + padding: 2px 7px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-primary); + color: var(--text-secondary); + font-size: 11px; + font-weight: 600; + line-height: 1.35; + text-align: center; +} + +.profile-management-section-items { + display: grid; + gap: 0; +} + +.profile-management-section-items[hidden] { + display: none; } .profile-management-section + .profile-management-section { @@ -4898,10 +4975,136 @@ body.keyboard-open.notepad-focused .notepad-panel { } .profile-management-item { - padding: 13px; + display: grid; + grid-template-columns: 38px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + min-height: 72px; + padding: 12px 14px 12px 8px; border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-primary); + box-shadow: 0 1px 2px color-mix(in srgb, black 9%, transparent); + transition: border-color 140ms ease, box-shadow 140ms ease, + opacity 140ms ease, transform 140ms ease; +} + +.profile-management-item:hover { + border-color: color-mix(in srgb, var(--accent-primary) 45%, var(--border-color)); + box-shadow: 0 5px 18px color-mix(in srgb, black 12%, transparent); +} + +.profile-management-item.is-derived-favorite { + grid-template-columns: minmax(0, 1fr) auto; + padding-left: 14px; +} + +.profile-management-item.is-dragging { + opacity: 0.36; + transform: scale(0.995); +} + +.profile-management-item.is-pending { + opacity: 0.68; + pointer-events: none; +} + +.profile-drag-handle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 44px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + cursor: grab; + touch-action: none; +} + +.profile-drag-handle:hover:not(:disabled), +.profile-drag-handle:focus-visible { + background: var(--bg-hover); + color: var(--accent-primary); + outline: 2px solid var(--accent-primary); + outline-offset: 1px; +} + +.profile-drag-handle:active:not(:disabled) { + cursor: grabbing; +} + +.profile-drag-handle:disabled { + color: color-mix(in srgb, var(--text-secondary) 42%, transparent); + cursor: not-allowed; +} + +.profile-drag-handle .material-icons { + font-size: 21px; +} + +.profile-drop-slot { + position: relative; + height: 10px; + margin: 0 8px; +} + +.profile-drop-slot::before { + content: ''; + position: absolute; + top: 50%; + right: 6px; + left: 6px; + height: 2px; + border-radius: 999px; + background: var(--accent-primary); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-primary) 18%, transparent), + 0 0 12px var(--accent-primary-glow); + opacity: 0; + transform: scaleX(0.96); + transition: opacity 100ms ease, transform 100ms ease; +} + +.profile-drop-slot::after { + content: ''; + position: absolute; + top: calc(50% - 4px); + left: 2px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-primary); + opacity: 0; + transition: opacity 100ms ease; +} + +.profile-drop-slot.is-active::before, +.profile-drop-slot.is-active::after { + opacity: 1; +} + +.profile-drop-slot.is-active::before { + transform: scaleX(1); +} + +.profile-sort-notice { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--accent-primary) 35%, var(--border-color)); border-radius: 9px; - background: var(--bg-secondary); + background: color-mix(in srgb, var(--accent-primary) 8%, var(--bg-primary)); + color: var(--text-secondary); + font-size: 12px; +} + +.profile-sort-notice .material-icons { + color: var(--accent-primary); + font-size: 18px; } .profile-management-info { @@ -4943,6 +5146,82 @@ body.keyboard-open.notepad-focused .notepad-panel { outline-offset: 1px; } +.profile-move-confirmation-content .modal-header { + align-items: center; +} + +.profile-confirmation-heading { + display: flex; + align-items: center; + gap: 10px; +} + +.profile-confirmation-heading > .material-icons { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 10px; + background: color-mix(in srgb, var(--warning-color) 14%, transparent); + color: var(--warning-color); + font-size: 21px; +} + +.profile-confirmation-close { + border: 0; + background: transparent; +} + +.profile-confirmation-lead { + margin: 0 0 16px; + color: var(--text-primary); + font-weight: 600; +} + +.profile-confirmation-summary { + display: grid; + gap: 1px; + margin: 0 0 16px; + overflow: hidden; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--border-color); +} + +.profile-confirmation-summary > div { + display: grid; + grid-template-columns: 110px minmax(0, 1fr); + gap: 12px; + padding: 10px 12px; + background: var(--bg-secondary); +} + +.profile-confirmation-summary dt { + color: var(--text-secondary); + font-size: 12px; +} + +.profile-confirmation-summary dd { + min-width: 0; + margin: 0; + color: var(--text-primary); + font-size: 13px; + font-weight: 600; + overflow-wrap: anywhere; +} + +.profile-confirmation-hint { + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} + +.profile-confirmation-actions { + margin-top: 22px; +} + .command-set-selector-row { display: flex; gap: 10px; @@ -5272,15 +5551,27 @@ body.keyboard-open.notepad-focused .notepad-panel { .command-set-management-item, .command-set-management-toolbar, - .profile-management-item, .profile-management-toolbar { align-items: stretch; flex-direction: column; } + .profile-management-item { + grid-template-columns: 38px minmax(0, 1fr); + } + + .profile-management-item.is-derived-favorite { + grid-template-columns: minmax(0, 1fr); + } + .profile-management-actions { + grid-column: 2; flex-wrap: wrap; } + + .profile-management-item.is-derived-favorite .profile-management-actions { + grid-column: 1; + } } @media (max-width: 520px) { diff --git a/static/js/app.js b/static/js/app.js index eb296d2..c557296 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -121,9 +121,18 @@ } window.openConnectionAssetManager = openConnectionAssetManager; + window.setConnectionAdvancedExpanded = expanded => { + const advanced = document.getElementById('connectionAdvancedSettings'); + if (advanced) advanced.open = expanded === true; + }; + window.clearConnectionProfileState = () => { window.ConnectionCommandManager?.clear(); ProfileManager.clearLegacyCommands(); + window.setConnectionAdvancedExpanded(false); + + const useTmuxCheck = document.getElementById('useTmuxCheck'); + if (useTmuxCheck) useTmuxCheck.checked = useTmuxCheck.defaultChecked; const profileSelect = document.getElementById('profileSelect'); if (profileSelect) { @@ -1152,6 +1161,7 @@ if (!profileId) { ProfileManager.clearLegacyCommands(); ConnectionCommandManager.clear(); + window.setConnectionAdvancedExpanded(false); deleteBtn.style.display = 'none'; delete deleteBtn.dataset.profileId; return null; diff --git a/static/js/i18n.js b/static/js/i18n.js index 6e2f67a..be9c559 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -58,6 +58,7 @@ const translations = { 'connection.jumpHostHint': 'Manage jump hosts in the account menu.', 'connection.jumpHostPassword': 'Jump Host Password', 'connection.jumpHostPasswordHint': 'This bastion uses password auth — enter its password (never stored).', + 'connection.advancedSettings': 'Advanced settings', 'connection.commandSet': 'Commands after connecting (optional)', 'connection.commandSetHint': 'Runs on the remote host after a successful connection, not in WebSSH. Not run again when reconnecting to an existing tmux session.', 'commandSets.manage': 'Command Sets', @@ -102,6 +103,15 @@ const translations = { 'profiles.favorite': 'Add {name} to favorites', 'profiles.unfavorite': 'Remove {name} from favorites', 'profiles.noMatches': 'No saved connections match this search.', + 'profiles.sortSearchDisabled': 'Clear the search to reorder connections.', + 'profiles.reorder': 'Reorder {name}', + 'profiles.favoriteReorderHint': 'Remove {name} from favorites to reorder it', + 'profiles.updatePending': 'Update in progress', + 'profiles.groupRemovalTitle': 'Remove empty group?', + 'profiles.groupRemovalLead': 'This is the last connection in the group.', + 'profiles.connectionLabel': 'Connection', + 'profiles.groupRemovalHint': 'Moving it will remove the empty group. The connection itself is not deleted.', + 'profiles.moveAndRemoveGroup': 'Move connection', 'profiles.saveFailed': 'Could not save the connection.', 'profiles.saved': 'Connection saved.', 'commandSets.none': 'None', @@ -673,6 +683,7 @@ const translations = { 'connection.jumpHostHint': 'Quản lý máy chủ trung chuyển trong menu tài khoản.', 'connection.jumpHostPassword': 'Mật khẩu máy chủ trung chuyển', 'connection.jumpHostPasswordHint': 'Bastion này sử dụng xác thực bằng mật khẩu — nhập mật khẩu (không được lưu lại).', + 'connection.advancedSettings': 'Cài đặt nâng cao', 'connection.commandSet': 'Lệnh sau khi kết nối (tùy chọn)', 'connection.commandSetHint': 'Chạy trên máy chủ từ xa sau khi kết nối thành công, không chạy trong WebSSH. Không chạy lại khi kết nối lại với một phiên tmux hiện có.', 'commandSets.manage': 'Bộ lệnh', @@ -717,6 +728,15 @@ const translations = { 'profiles.favorite': 'Thêm {name} vào mục yêu thích', 'profiles.unfavorite': 'Xóa {name} khỏi mục yêu thích', 'profiles.noMatches': 'Không có kết nối đã lưu nào khớp với tìm kiếm.', + 'profiles.sortSearchDisabled': 'Xóa nội dung tìm kiếm để sắp xếp lại các kết nối.', + 'profiles.reorder': 'Sắp xếp lại {name}', + 'profiles.favoriteReorderHint': 'Bỏ {name} khỏi mục yêu thích để sắp xếp lại', + 'profiles.updatePending': 'Đang cập nhật', + 'profiles.groupRemovalTitle': 'Xóa nhóm trống?', + 'profiles.groupRemovalLead': 'Đây là kết nối cuối cùng trong nhóm.', + 'profiles.connectionLabel': 'Kết nối', + 'profiles.groupRemovalHint': 'Việc di chuyển sẽ xóa nhóm trống. Bản thân kết nối không bị xóa.', + 'profiles.moveAndRemoveGroup': 'Di chuyển kết nối', 'profiles.saveFailed': 'Không thể lưu kết nối.', 'profiles.saved': 'Đã lưu kết nối.', 'commandSets.none': 'Không dùng', @@ -1287,6 +1307,7 @@ const translations = { 'connection.jumpHostHint': 'Jump Hosts verwaltest du im Account-Menü.', 'connection.jumpHostPassword': 'Jump-Host-Passwort', 'connection.jumpHostPasswordHint': 'Diese Bastion nutzt Passwort-Auth — Passwort eingeben (wird nicht gespeichert).', + 'connection.advancedSettings': 'Erweiterte Einstellungen', 'connection.commandSet': 'Befehle nach dem Verbinden (optional)', 'connection.commandSetHint': 'Wird nach erfolgreicher Verbindung auf dem Remote-Host ausgeführt, nicht in WebSSH. Bei der Wiederverbindung mit einer bestehenden tmux-Sitzung nicht erneut ausgeführt.', 'commandSets.manage': 'Befehlssätze', @@ -1331,6 +1352,15 @@ const translations = { 'profiles.favorite': '{name} zu Favoriten hinzufügen', 'profiles.unfavorite': '{name} aus Favoriten entfernen', 'profiles.noMatches': 'Keine gespeicherte Verbindung entspricht der Suche.', + 'profiles.sortSearchDisabled': 'Suche leeren, um Verbindungen neu anzuordnen.', + 'profiles.reorder': '{name} neu anordnen', + 'profiles.favoriteReorderHint': '{name} zum Sortieren aus den Favoriten entfernen', + 'profiles.updatePending': 'Aktualisierung läuft', + 'profiles.groupRemovalTitle': 'Leere Gruppe entfernen?', + 'profiles.groupRemovalLead': 'Dies ist die letzte Verbindung in dieser Gruppe.', + 'profiles.connectionLabel': 'Verbindung', + 'profiles.groupRemovalHint': 'Beim Verschieben wird die leere Gruppe entfernt. Die Verbindung selbst wird nicht gelöscht.', + 'profiles.moveAndRemoveGroup': 'Verbindung verschieben', 'profiles.saveFailed': 'Die Verbindung konnte nicht gespeichert werden.', 'profiles.saved': 'Verbindung gespeichert.', 'commandSets.none': 'Keiner', @@ -1900,6 +1930,7 @@ const translations = { 'connection.jumpHostHint': 'Gérez les hôtes de rebond dans le menu du compte.', 'connection.jumpHostPassword': 'Mot de passe du rebond', 'connection.jumpHostPasswordHint': "Ce bastion utilise un mot de passe — saisissez-le (jamais enregistré).", + 'connection.advancedSettings': 'Paramètres avancés', 'connection.commandSet': 'Commandes après la connexion (facultatif)', 'connection.commandSetHint': "Exécutées sur l'hôte distant après une connexion réussie, et non dans WebSSH. Elles ne sont pas réexécutées lors de la reconnexion à une session tmux existante.", 'commandSets.manage': 'Ensembles de commandes', @@ -1944,6 +1975,15 @@ const translations = { 'profiles.favorite': 'Ajouter {name} aux favoris', 'profiles.unfavorite': 'Retirer {name} des favoris', 'profiles.noMatches': 'Aucune connexion enregistrée ne correspond à la recherche.', + 'profiles.sortSearchDisabled': 'Effacez la recherche pour réorganiser les connexions.', + 'profiles.reorder': 'Réorganiser {name}', + 'profiles.favoriteReorderHint': 'Retirez {name} des favoris pour le réorganiser', + 'profiles.updatePending': 'Mise à jour en cours', + 'profiles.groupRemovalTitle': 'Supprimer le groupe vide ?', + 'profiles.groupRemovalLead': 'Il s’agit de la dernière connexion de ce groupe.', + 'profiles.connectionLabel': 'Connexion', + 'profiles.groupRemovalHint': 'Le déplacement supprimera le groupe vide. La connexion elle-même ne sera pas supprimée.', + 'profiles.moveAndRemoveGroup': 'Déplacer la connexion', 'profiles.saveFailed': 'Impossible d’enregistrer la connexion.', 'profiles.saved': 'Connexion enregistrée.', 'commandSets.none': 'Aucun', @@ -2513,6 +2553,7 @@ const translations = { 'connection.jumpHostHint': 'Gestiona los hosts de salto en el menú de la cuenta.', 'connection.jumpHostPassword': 'Contraseña del host de salto', 'connection.jumpHostPasswordHint': 'Este bastión usa contraseña — introdúcela (nunca se guarda).', + 'connection.advancedSettings': 'Configuración avanzada', 'connection.commandSet': 'Comandos después de conectar (opcional)', 'connection.commandSetHint': 'Se ejecutan en el host remoto después de una conexión correcta, no en WebSSH. No se vuelven a ejecutar al reconectar con una sesión tmux existente.', 'commandSets.manage': 'Conjuntos de comandos', @@ -2557,6 +2598,15 @@ const translations = { 'profiles.favorite': 'Añadir {name} a favoritos', 'profiles.unfavorite': 'Quitar {name} de favoritos', 'profiles.noMatches': 'Ninguna conexión guardada coincide con la búsqueda.', + 'profiles.sortSearchDisabled': 'Borra la búsqueda para reordenar las conexiones.', + 'profiles.reorder': 'Reordenar {name}', + 'profiles.favoriteReorderHint': 'Quita {name} de favoritos para reordenarla', + 'profiles.updatePending': 'Actualización en curso', + 'profiles.groupRemovalTitle': '¿Eliminar el grupo vacío?', + 'profiles.groupRemovalLead': 'Esta es la última conexión del grupo.', + 'profiles.connectionLabel': 'Conexión', + 'profiles.groupRemovalHint': 'Al moverla se eliminará el grupo vacío. La conexión no se eliminará.', + 'profiles.moveAndRemoveGroup': 'Mover conexión', 'profiles.saveFailed': 'No se pudo guardar la conexión.', 'profiles.saved': 'Conexión guardada.', 'commandSets.none': 'Ninguno', @@ -3126,6 +3176,7 @@ const translations = { 'connection.jumpHostHint': '在账户菜单中管理跳板机。', 'connection.jumpHostPassword': '跳板机密码', 'connection.jumpHostPasswordHint': '该堡垒机使用密码认证 — 请输入密码(不会保存)。', + 'connection.advancedSettings': '高级设置', 'connection.commandSet': '连接后运行的命令(可选)', 'connection.commandSetHint': '连接成功后在远程主机上运行,而不是在 WebSSH 中运行。重新连接到现有 tmux 会话时不会再次运行。', 'commandSets.manage': '命令集', @@ -3170,6 +3221,15 @@ const translations = { 'profiles.favorite': '将 {name} 添加到收藏', 'profiles.unfavorite': '从收藏中移除 {name}', 'profiles.noMatches': '没有已保存的连接符合搜索条件。', + 'profiles.sortSearchDisabled': '清除搜索内容后即可重新排列连接。', + 'profiles.reorder': '重新排列 {name}', + 'profiles.favoriteReorderHint': '将 {name} 移出收藏后即可重新排列', + 'profiles.updatePending': '正在更新', + 'profiles.groupRemovalTitle': '移除空分组?', + 'profiles.groupRemovalLead': '这是该分组中的最后一个连接。', + 'profiles.connectionLabel': '连接', + 'profiles.groupRemovalHint': '移动后将移除空分组,连接本身不会被删除。', + 'profiles.moveAndRemoveGroup': '移动连接', 'profiles.saveFailed': '无法保存连接。', 'profiles.saved': '连接已保存。', 'commandSets.none': '无', diff --git a/static/js/profile-launcher-utils.js b/static/js/profile-launcher-utils.js index e7d1ff6..644bcba 100644 --- a/static/js/profile-launcher-utils.js +++ b/static/js/profile-launcher-utils.js @@ -165,6 +165,18 @@ return String(profile?.group || '').trim(); } + function usesAdvancedConnectionSettings(profile) { + const value = profile && typeof profile === 'object' ? profile : {}; + const startupMode = String(value.startup_mode || '').trim(); + const hasPostConnect = ( + (startupMode && startupMode !== 'none') + || Boolean(value.command_id) + || Boolean(value.command_set_id) + || Boolean(String(value.startup_commands || '').trim()) + ); + return Boolean(value.jump_host_id || value.use_tmux === true || hasPostConnect); + } + function compareText(left, right) { return String(left).localeCompare(String(right), undefined, { numeric: true, @@ -172,9 +184,46 @@ }); } + function validSortOrder(profile) { + return Number.isInteger(profile?.sort_order) + && profile.sort_order >= 0; + } + + function compareProfileOrder( + left, + right, + fallbackIndexes = new Map(), + usePersistedOrder = true, + ) { + if (usePersistedOrder && left.sort_order !== right.sort_order) { + return left.sort_order - right.sort_order; + } + + const leftIndex = fallbackIndexes.get(left); + const rightIndex = fallbackIndexes.get(right); + if (Number.isInteger(leftIndex) && Number.isInteger(rightIndex)) { + return leftIndex - rightIndex; + } + return compareText(left?.name || '', right?.name || '') + || compareText(left?.host || '', right?.host || '') + || compareText(left?.id || '', right?.id || ''); + } + function filterAndSortProfiles(profiles, query = '') { const needle = String(query || '').trim().toLocaleLowerCase(); - return (Array.isArray(profiles) ? profiles : []) + const source = Array.isArray(profiles) ? profiles : []; + const fallbackIndexes = new Map(source.map((profile, index) => ( + [profile, index] + ))); + const completeOrderGroups = new Map(); + source.forEach(profile => { + const key = normalizedGroup(profile).toLocaleLowerCase(); + completeOrderGroups.set( + key, + (completeOrderGroups.get(key) ?? true) && validSortOrder(profile), + ); + }); + return source .filter(profile => profile && profile.id) .filter(profile => ( !needle @@ -192,11 +241,101 @@ Number(right.favorite === true) - Number(left.favorite === true) || compareText(normalizedGroup(left), normalizedGroup(right)) - || compareText(left.name || '', right.name || '') - || compareText(left.host || '', right.host || '') + || compareProfileOrder( + left, + right, + fallbackIndexes, + completeOrderGroups.get( + normalizedGroup(left).toLocaleLowerCase(), + ) === true, + ) )); } + function resolveProfileDrop( + profiles, + profileId, + targetGroup, + targetBoundaryIndex, + ) { + const source = (Array.isArray(profiles) ? profiles : []) + .filter(profile => profile && profile.id); + const movedProfile = source.find(profile => profile.id === profileId); + if (!movedProfile || !Number.isInteger(targetBoundaryIndex) + || targetBoundaryIndex < 0) { + return null; + } + + const normalizedTarget = String(targetGroup || '').trim(); + const expectedSourceGroup = normalizedGroup(movedProfile); + const fallbackIndexes = new Map(source.map((profile, index) => ( + [profile, index] + ))); + const targetProfiles = source + .filter(profile => ( + normalizedGroup(profile).toLocaleLowerCase() + === normalizedTarget.toLocaleLowerCase() + )); + const targetHasCompleteOrder = targetProfiles.every(validSortOrder); + targetProfiles + .sort((left, right) => ( + compareProfileOrder( + left, + right, + fallbackIndexes, + targetHasCompleteOrder, + ) + )); + const visibleTargets = targetProfiles.filter(profile => ( + profile.favorite !== true + )); + let visibleBoundary = Math.min( + targetBoundaryIndex, + visibleTargets.length, + ); + + if (expectedSourceGroup.toLocaleLowerCase() + === normalizedTarget.toLocaleLowerCase()) { + const sourceIndex = visibleTargets.findIndex(profile => ( + profile.id === profileId + )); + if (sourceIndex < 0) return null; + if (sourceIndex < visibleBoundary) visibleBoundary -= 1; + if (sourceIndex === visibleBoundary) return null; + } + + const remainingTargets = targetProfiles.filter(profile => ( + profile.id !== profileId + )); + const remainingVisibleTargets = remainingTargets.filter(profile => ( + profile.favorite !== true + )); + visibleBoundary = Math.min( + visibleBoundary, + remainingVisibleTargets.length, + ); + let targetIndex = remainingTargets.length; + if (remainingVisibleTargets.length && visibleBoundary === 0) { + targetIndex = remainingTargets.indexOf(remainingVisibleTargets[0]); + } else if (remainingVisibleTargets.length + && visibleBoundary < remainingVisibleTargets.length) { + targetIndex = remainingTargets.indexOf( + remainingVisibleTargets[visibleBoundary], + ); + } else if (remainingVisibleTargets.length) { + targetIndex = remainingTargets.indexOf( + remainingVisibleTargets.at(-1), + ) + 1; + } + + return { + profileId, + expectedSourceGroup, + targetGroup: normalizedTarget, + targetIndex, + }; + } + function buildProfileSections(profiles, query = '', labels = {}) { const sorted = filterAndSortProfiles(profiles, query); const favorites = sorted.filter(profile => profile.favorite === true); @@ -252,5 +391,7 @@ determineLaunchMode, filterAndSortProfiles, formatEndpoint, + resolveProfileDrop, + usesAdvancedConnectionSettings, }; })); diff --git a/static/js/profile-manager.js b/static/js/profile-manager.js index a113a17..c40578e 100644 --- a/static/js/profile-manager.js +++ b/static/js/profile-manager.js @@ -14,6 +14,10 @@ const ProfileManager = { inlineKeyUploadPending: false, profileSearchQuery: '', organizationPending: new Set(), + collapsedGroups: new Set(), + activeProfileDragId: null, + activeProfileDropSlot: null, + pendingProfileMove: null, init() { document.getElementById('manageProfilesBtn')?.addEventListener('click', () => { @@ -59,6 +63,11 @@ const ProfileManager = { }); }); document.getElementById('profileManagementList')?.addEventListener('click', event => { + const groupToggle = event.target.closest('[data-profile-group-toggle]'); + if (groupToggle) { + this.toggleGroupCollapsed(groupToggle.dataset.profileGroupToggle); + return; + } const button = event.target.closest('[data-profile-action]'); if (!button) return; const profileId = button.dataset.profileId; @@ -67,6 +76,72 @@ const ProfileManager = { if (button.dataset.profileAction === 'edit') this.openEditor(profileId); if (button.dataset.profileAction === 'delete') this.deleteProfile(profileId); }); + document.getElementById('profileManagementList')?.addEventListener('dragstart', event => { + const handle = event.target.closest('[data-profile-drag-handle]'); + const card = handle?.closest('[data-profile-card-id]'); + const profileId = card?.dataset.profileCardId; + if (!handle || !card || !profileId || !this.isProfileSortingEnabled() + || this.organizationPending.has(profileId)) { + event.preventDefault(); + return; + } + this.activeProfileDragId = profileId; + event.dataTransfer?.setData('application/x-webssh-profile-id', profileId); + if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'; + card.classList.add('is-dragging'); + }); + document.getElementById('profileManagementList')?.addEventListener('dragover', event => { + const target = this.profileDropSlotForEvent(event); + if (!target || !this.activeProfileDragId) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; + if (this.activeProfileDropSlot !== target) { + this.activeProfileDropSlot?.classList.remove('is-active'); + this.activeProfileDropSlot = target; + target.classList.add('is-active'); + } + }); + document.getElementById('profileManagementList')?.addEventListener('drop', event => { + const target = this.profileDropSlotForEvent(event) + || this.activeProfileDropSlot; + if (!target || !this.activeProfileDragId) return; + event.preventDefault(); + const profileId = event.dataTransfer?.getData( + 'application/x-webssh-profile-id' + ) || this.activeProfileDragId; + const move = window.ProfileLauncherUtils?.resolveProfileDrop( + this.profiles, + profileId, + target.dataset.profileDropGroup, + Number(target.dataset.profileDropIndex), + ); + this.clearProfileDragState(event.currentTarget); + if (move) this.requestProfileMove(move); + }); + document.getElementById('profileManagementList')?.addEventListener('dragend', event => { + this.clearProfileDragState(event.currentTarget); + }); + document.getElementById('cancelProfileMoveBtn')?.addEventListener('click', () => { + this.cancelPendingProfileMove(); + }); + document.getElementById('confirmProfileMoveBtn')?.addEventListener('click', () => { + this.confirmPendingProfileMove(); + }); + document.getElementById('closeProfileMoveConfirmation')?.addEventListener('click', () => { + this.cancelPendingProfileMove(); + }); + document.getElementById('profileMoveConfirmationModal')?.addEventListener('click', event => { + if (event.target !== event.currentTarget) return; + event.stopPropagation(); + this.cancelPendingProfileMove(); + }); + document.addEventListener('keydown', event => { + const modal = document.getElementById('profileMoveConfirmationModal'); + if (event.key !== 'Escape' || !modal?.classList.contains('show')) return; + event.preventDefault(); + event.stopImmediatePropagation(); + this.cancelPendingProfileMove(); + }, true); document.getElementById('profileEditorAddKeyBtn')?.addEventListener('click', () => { this.setInlineKeyPanelExpanded(true); document.getElementById('profileEditorNewKeyName')?.focus(); @@ -612,6 +687,11 @@ const ProfileManager = { window.JumpHostManager.updatePasswordVisibility(); } } + const useTmuxCheck = document.getElementById('useTmuxCheck'); + if (useTmuxCheck) useTmuxCheck.checked = profile.use_tmux === true; + window.setConnectionAdvancedExpanded?.( + ProfileLauncherUtils.usesAdvancedConnectionSettings(profile) + ); }, getLegacyStartupCommands() { @@ -675,6 +755,47 @@ const ProfileManager = { this.renderManagementList(); }, + isGroupCollapsed(sectionKey) { + if (String(this.profileSearchQuery || '').trim()) return false; + return this.collapsedGroups.has(sectionKey); + }, + + toggleGroupCollapsed(sectionKey) { + if (!sectionKey) return; + if (this.collapsedGroups.has(sectionKey)) { + this.collapsedGroups.delete(sectionKey); + } else { + this.collapsedGroups.add(sectionKey); + } + this.renderManagementList(); + }, + + isProfileSortingEnabled() { + return !String(this.profileSearchQuery || '').trim(); + }, + + profileDropSlotForEvent(event) { + const explicit = event.target.closest?.('[data-profile-drop-index]'); + if (explicit) return explicit; + + const card = event.target.closest?.('[data-profile-position]'); + if (!card) return null; + const bounds = card.getBoundingClientRect(); + const after = Number(event.clientY) >= bounds.top + (bounds.height / 2); + const index = Number(card.dataset.profilePosition) + Number(after); + return card.parentElement?.querySelector( + `[data-profile-drop-index="${index}"]`, + ) || null; + }, + + clearProfileDragState(container) { + container?.querySelectorAll('.is-dragging, .profile-drop-slot.is-active').forEach(element => { + element.classList.remove('is-dragging', 'is-active'); + }); + this.activeProfileDragId = null; + this.activeProfileDropSlot = null; + }, + renderManagementList() { const container = document.getElementById('profileManagementList'); if (!container) return; @@ -706,17 +827,111 @@ const ProfileManager = { return; } + if (!this.isProfileSortingEnabled()) { + const notice = document.createElement('p'); + notice.className = 'profile-sort-notice'; + const noticeIcon = document.createElement('span'); + noticeIcon.className = 'material-icons'; + noticeIcon.setAttribute('aria-hidden', 'true'); + noticeIcon.textContent = 'info'; + const noticeText = document.createElement('span'); + noticeText.textContent = this.t( + 'profiles.sortSearchDisabled', + 'Clear the search to reorder connections.', + ); + notice.append(noticeIcon, noticeText); + container.appendChild(notice); + } + sections.forEach(section => { const sectionElement = document.createElement('section'); sectionElement.className = 'profile-management-section'; + const targetGroup = section.key === 'ungrouped' ? '' : section.label; + const acceptsDrop = ( + section.key !== 'favorites' + && this.isProfileSortingEnabled() + ); + const collapsed = this.isGroupCollapsed(section.key); const heading = document.createElement('h3'); heading.className = 'profile-management-section-title'; - heading.textContent = section.label; + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'profile-management-section-toggle'; + toggle.dataset.profileGroupToggle = section.key; + toggle.setAttribute('aria-expanded', String(!collapsed)); + const icon = document.createElement('span'); + icon.className = 'material-icons'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = collapsed ? 'chevron_right' : 'expand_more'; + const label = document.createElement('span'); + label.textContent = section.label; + const count = document.createElement('span'); + count.className = 'profile-management-section-count'; + count.textContent = String(section.profiles.length); + toggle.append(icon, label, count); + heading.appendChild(toggle); sectionElement.appendChild(heading); + const items = document.createElement('div'); + items.className = 'profile-management-section-items'; + items.hidden = collapsed; + sectionElement.appendChild(items); + + const appendDropSlot = index => { + if (!acceptsDrop) return; + const slot = document.createElement('div'); + slot.className = 'profile-drop-slot'; + slot.dataset.profileDropGroup = targetGroup; + slot.dataset.profileDropIndex = String(index); + slot.setAttribute('aria-hidden', 'true'); + items.appendChild(slot); + }; - section.profiles.forEach(profile => { + section.profiles.forEach((profile, profileIndex) => { + appendDropSlot(profileIndex); const card = document.createElement('article'); card.className = 'profile-management-item'; + if (this.organizationPending.has(profile.id)) { + card.classList.add('is-pending'); + } + const canDrag = ( + section.key !== 'favorites' + && profile.favorite !== true + && this.isProfileSortingEnabled() + && !this.organizationPending.has(profile.id) + ); + if (section.key !== 'favorites') { + card.dataset.profileCardId = profile.id; + card.dataset.profilePosition = String(profileIndex); + } + const dragHandle = document.createElement('button'); + dragHandle.type = 'button'; + dragHandle.className = 'profile-drag-handle'; + dragHandle.dataset.profileDragHandle = ''; + dragHandle.draggable = canDrag; + dragHandle.disabled = !canDrag; + const dragLabel = canDrag + ? this.t('profiles.reorder', 'Reorder {name}') + : this.organizationPending.has(profile.id) + ? this.t('profiles.updatePending', 'Update in progress') + : profile.favorite === true + ? this.t( + 'profiles.favoriteReorderHint', + 'Remove from favorites to reorder {name}', + ) + : this.t( + 'profiles.sortSearchDisabled', + 'Clear the search to reorder connections.', + ); + dragHandle.setAttribute( + 'aria-label', + dragLabel.replace('{name}', profile.name || ''), + ); + dragHandle.title = dragHandle.getAttribute('aria-label'); + const dragIcon = document.createElement('span'); + dragIcon.className = 'material-icons'; + dragIcon.setAttribute('aria-hidden', 'true'); + dragIcon.textContent = 'drag_indicator'; + dragHandle.appendChild(dragIcon); const info = document.createElement('div'); info.className = 'profile-management-info'; const name = document.createElement('strong'); @@ -793,9 +1008,15 @@ const ProfileManager = { button.textContent = label; actions.appendChild(button); }); - card.append(info, actions); - sectionElement.appendChild(card); + if (section.key === 'favorites') { + card.classList.add('is-derived-favorite'); + card.append(info, actions); + } else { + card.append(dragHandle, info, actions); + } + items.appendChild(card); }); + appendDropSlot(section.profiles.length); container.appendChild(sectionElement); }); }, @@ -1053,6 +1274,115 @@ const ProfileManager = { }); }, + adoptAuthoritativeProfiles(profiles) { + if (!Array.isArray(profiles)) return false; + const transientAuthorization = new Map(this.profiles.map(profile => ( + [profile.id, profile.tailscale_authorized] + ))); + this.profiles = profiles.map(profile => { + const authorization = transientAuthorization.get(profile.id); + return { + ...profile, + ...(authorization === undefined + ? {} + : {tailscale_authorized: authorization}), + }; + }); + return true; + }, + + requestProfileMove(move, emit = null, confirmed = false) { + const profile = this.profiles.find(item => item.id === move?.profileId); + if (!profile || this.organizationPending.has(profile.id)) return false; + if (!Number.isInteger(move.targetIndex) || move.targetIndex < 0) return false; + if (!emit && !window.socket) return false; + + const payload = { + profile_id: profile.id, + expected_source_group: String(move.expectedSourceGroup || '').trim(), + target_group: String(move.targetGroup || '').trim(), + target_index: move.targetIndex, + confirm_source_group_removal: confirmed === true, + }; + const send = emit || ((data, acknowledge) => window.socket.emit( + 'move_profile', + data, + acknowledge, + )); + + this.organizationPending.add(profile.id); + this.renderManagementList(); + send(payload, acknowledgement => { + this.organizationPending.delete(profile.id); + if (Array.isArray(acknowledgement?.profiles)) { + this.adoptAuthoritativeProfiles(acknowledgement.profiles); + } + if (acknowledgement?.requires_confirmation === true) { + this.pendingProfileMove = { + move, + emit, + profileName: acknowledgement.profile_name || profile.name || '', + sourceGroup: acknowledgement.source_group + || move.expectedSourceGroup, + }; + this.renderManagementList(); + this.openProfileMoveConfirmation(); + return; + } + if (!acknowledgement?.success || !Array.isArray(acknowledgement.profiles)) { + window.showNotification?.( + acknowledgement?.error || this.t( + 'profiles.saveFailed', 'Failed to save connection' + ), + 'error', + ); + this.renderManagementList(); + return; + } + this.renderProfileSelect(); + this.renderManagementList(); + this.refreshEmptyPanes(); + }); + return true; + }, + + openProfileMoveConfirmation() { + if (!this.pendingProfileMove) return; + const name = document.getElementById('profileMoveProfileName'); + const group = document.getElementById('profileMoveSourceGroup'); + if (name) name.textContent = this.pendingProfileMove.profileName; + if (group) group.textContent = this.pendingProfileMove.sourceGroup; + window.ModalManager?.open( + document.getElementById('profileMoveConfirmationModal'), + ); + }, + + closeProfileMoveConfirmation() { + window.ModalManager?.close( + document.getElementById('profileMoveConfirmationModal'), + ); + const managementModal = document.getElementById('profileManagementModal'); + if (managementModal?.classList.contains('show') && window.ModalManager) { + window.ModalManager.activeModal = managementModal; + } + }, + + cancelPendingProfileMove() { + if (!this.pendingProfileMove) return false; + this.pendingProfileMove = null; + this.closeProfileMoveConfirmation(); + this.renderManagementList(); + return true; + }, + + confirmPendingProfileMove() { + const pending = this.pendingProfileMove; + if (!pending) return false; + this.pendingProfileMove = null; + this.closeProfileMoveConfirmation(); + return this.requestProfileMove(pending.move, pending.emit, true); + }, + saveProfile(profileData) { if (window.socket) { window.socket.emit('save_profile', profileData); diff --git a/templates/index.html b/templates/index.html index 92a1efc..13682b6 100644 --- a/templates/index.html +++ b/templates/index.html @@ -17,7 +17,7 @@ - + @@ -476,6 +476,9 @@

Quick Conn
+
+ Advanced settings +