diff --git a/README.md b/README.md index 9039790..8fb2948 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Web SSH Terminal is a self-hosted web application that provides secure SSH acces - **Session Restoration** - Restore live sessions after a page refresh without injecting terminal input - **Persistent tmux Sessions** - Keep remote shells and running commands alive across browser closes and WebSSH restarts, then reattach later - **Manual Reconnect** - Reconnect from a session tab; SSH-key and Tailscale sessions can reconnect directly, while password sessions reopen the pre-filled connection form +- **Profile Launcher** - Empty terminal panes show saved profiles; key and Tailscale profiles connect immediately when no password is needed, while password-dependent profiles open pre-filled at the required field - **Post-Connect Command Sets** - Build named, ordered command sequences and assign one to a connection or saved profile - **Persistent Session Names** - Custom tab names are retained for persistent sessions across browsers - **Configurable Scrollback** - Set 50 to 10,000 terminal lines and navigate them with the custom scrollbar diff --git a/app/key_manager.py b/app/key_manager.py index 17b00dc..6fcf0b0 100644 --- a/app/key_manager.py +++ b/app/key_manager.py @@ -148,6 +148,48 @@ def read_key_content(user_id, key_id): log_error(f"Error reading key content", user_id=user_id, key_id=key_id, error=str(e)) return None, f"Failed to read key: {str(e)}" + +def load_key_summaries(user_id): + """Return key metadata with a transient usability status for clients.""" + keys = load_keys(user_id) + keys_dir = get_user_keys_dir(user_id) + if not keys_dir: + return [{**key, 'usable': False} for key in keys] + + fernet = None + summaries = [] + for key in keys: + if not isinstance(key, dict): + continue + usable = False + try: + filename = key.get('filename') + if not filename or Path(filename).name != filename: + raise ValueError('invalid key filename') + + raw = (keys_dir / filename).read_bytes() + if key_encryption.is_encrypted(raw): + if fernet is None: + fernet = key_encryption.get_user_fernet(str(user_id)) + raw = fernet.decrypt(raw) + + identify_private_key(raw.decode('utf-8')) + usable = True + except ( + OSError, + UnicodeDecodeError, + key_encryption.InvalidToken, + paramiko.PasswordRequiredException, + paramiko.SSHException, + UnsupportedPrivateKeyError, + ValueError, + TypeError, + ): + pass + summaries.append({**key, 'usable': usable}) + return summaries + + def delete_key(user_id, key_id): """Delete an SSH key and its metadata for a specific user.""" try: diff --git a/app/socket_events.py b/app/socket_events.py index 9c6e884..8cade2d 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -11,7 +11,10 @@ log_file_upload, log_file_download, log_key_upload, log_key_delete, log_tailscale_ssh_usage) -from .tailscale_ssh import validate_tailscale_ssh_access +from .tailscale_ssh import ( + profile_is_authorized_for_launch, + validate_tailscale_ssh_access, +) from .startup_commands import normalize_startup_commands from .storage_utils import storage_lock from . import binary_transfer, connection_pool @@ -577,7 +580,14 @@ def handle_ssh_disconnect(data, current_user=None): def handle_list_profiles(current_user=None): """Return list of saved connection profiles for this user.""" try: - profiles = profile_manager.load_profiles(current_user.id) + profiles = [] + for stored_profile in profile_manager.load_profiles(current_user.id): + profile = dict(stored_profile) + if profile.get('auth_type') == 'tailscale': + profile['tailscale_authorized'] = ( + profile_is_authorized_for_launch(current_user, profile) + ) + profiles.append(profile) emit('profiles_list', {'profiles': profiles}) except Exception as e: log_error("Failed to load profiles", error=str(e)) @@ -704,7 +714,7 @@ def handle_delete_jump_host(data, current_user=None): def handle_list_keys(current_user=None): """Return list of stored SSH keys for this user.""" try: - keys = key_manager.load_keys(current_user.id) + keys = key_manager.load_key_summaries(current_user.id) emit('keys_list', {'keys': keys}) except Exception as e: log_error("Failed to load keys", error=str(e)) diff --git a/app/tailscale_ssh.py b/app/tailscale_ssh.py index 100e4a3..32e2430 100644 --- a/app/tailscale_ssh.py +++ b/app/tailscale_ssh.py @@ -27,3 +27,14 @@ def validate_tailscale_ssh_access(user, host, remote_username): return 'Tailscale SSH remote username is not allowed' return None + + +def profile_is_authorized_for_launch(user, profile): + """Return whether a saved profile is still allowed by current policy.""" + if not isinstance(profile, dict) or profile.get('auth_type') != 'tailscale': + return True + return validate_tailscale_ssh_access( + user, + profile.get('host'), + profile.get('username'), + ) is None diff --git a/docs/screenshots/02_main_empty.png b/docs/screenshots/02_main_empty.png index 6f88713..3eabe7a 100644 Binary files a/docs/screenshots/02_main_empty.png and b/docs/screenshots/02_main_empty.png differ diff --git a/static/css/style.css b/static/css/style.css index 418d819..47017d8 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1737,31 +1737,134 @@ body.broadcast-active .terminal-wrapper:not(.unassigned) { .pane-empty { flex: 1; + min-width: 0; + min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; - gap: 12px; + gap: 10px; color: var(--text-muted); - font-size: 14px; padding: 24px; text-align: center; } .pane-empty-icon { - font-size: 48px; + flex: 0 0 auto; + font-size: 42px; + line-height: 1; opacity: 0.4; - margin-bottom: 8px; } -.pane-empty-text { +.profile-launcher-title { + color: var(--text-primary); + font-size: 18px; + font-weight: 700; +} + +.profile-launcher-hint { color: var(--text-secondary); - font-weight: 500; + font-size: 13px; } -.pane-empty button { - padding: 10px 18px; - margin-top: 8px; +.profile-launcher-list { + width: min(760px, 100%); + max-height: min(48vh, 420px); + min-height: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; + padding: 4px; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.profile-launcher-card { + min-width: 0; + min-height: var(--touch-target-min); + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 10px; + padding: 12px 14px; + border: 1px solid var(--border-color); + border-radius: 9px; + background: var(--bg-secondary); + color: var(--text-primary); + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease; +} + +.profile-launcher-card:hover { + border-color: var(--accent-primary); + background: var(--bg-hover); + transform: translateY(-1px); +} + +.profile-launcher-card:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + +.profile-launcher-name, +.profile-launcher-endpoint { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.profile-launcher-name { + font-weight: 700; +} + +.profile-launcher-endpoint { + grid-column: 1; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 11px; +} + +.profile-launcher-action { + grid-column: 2; + grid-row: 1 / span 2; + align-self: center; + color: var(--text-secondary); + font-size: 11px; + white-space: nowrap; +} + +.profile-launcher-action.mode-connect { + color: var(--success-color); +} + +.profile-launcher-action.mode-password, +.profile-launcher-action.mode-jump-host-password { + color: var(--warning-color); +} + +.profile-launcher-new { + flex: 0 0 auto; + margin-top: 2px; +} + +@media (max-width: 767px) { + .profile-launcher { + justify-content: flex-start; + padding: 18px 12px; + overflow: hidden; + } + + .profile-launcher-list { + width: 100%; + max-height: none; + grid-template-columns: 1fr; + } + + .profile-launcher-card { + padding: 12px; + } } .terminal-wrapper { @@ -1802,39 +1905,6 @@ body.broadcast-active .terminal-wrapper:not(.unassigned) { } } -.no-sessions { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - color: var(--text-secondary); -} - -.no-sessions.hidden { - display: none; -} - -.no-sessions-content { - text-align: center; - animation: fadeIn 0.5s ease; -} - -.no-sessions-content h2 { - font-size: 28px; - font-weight: 700; - margin-bottom: 12px; - color: var(--text-primary); - background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -.no-sessions-content p { - font-size: 15px; - color: var(--text-secondary); -} - .btn { padding: 10px 20px; border: none; diff --git a/static/js/app.js b/static/js/app.js index 6a863a6..5c73674 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1095,6 +1095,111 @@ setConnectLoading(false); } + function clearSaveProfileState() { + const saveProfileCheck = document.getElementById('saveProfileCheck'); + const profileNameInput = document.getElementById('profileNameInput'); + const profileNameGroup = document.getElementById('profileNameGroup'); + const profileHint = document.getElementById('profileHint'); + + if (saveProfileCheck) { + saveProfileCheck.checked = false; + } + if (profileNameInput) { + profileNameInput.value = ''; + profileNameInput.required = false; + setFieldState(profileNameInput, profileHint, '', null); + } + if (profileNameGroup) { + profileNameGroup.classList.add('hidden'); + } + } + + function selectConnectionProfile(profileId) { + const profileSelect = document.getElementById('profileSelect'); + const deleteBtn = document.getElementById('deleteProfileBtn'); + if (profileSelect) { + profileSelect.value = profileId || ''; + } + + if (!profileId) { + ProfileManager.clearLegacyCommands(); + CommandSetManager.selectForConnection(''); + deleteBtn.style.display = 'none'; + delete deleteBtn.dataset.profileId; + return null; + } + + const profile = ProfileManager.getProfile(profileId); + if (!profile) { + if (profileSelect) { + profileSelect.value = ''; + } + deleteBtn.style.display = 'none'; + delete deleteBtn.dataset.profileId; + return null; + } + ProfileManager.selectProfile(profileId); + deleteBtn.style.display = 'block'; + deleteBtn.dataset.profileId = profileId; + return profile; + } + + window.selectConnectionProfile = selectConnectionProfile; + + function isSelectedProfileReady(profile) { + const authTypeSelect = document.getElementById('authTypeSelect'); + const keySelect = document.getElementById('keySelect'); + const jumpHostSelect = document.getElementById('jumpHostSelect'); + if (!profile || authTypeSelect.value !== profile.auth_type) { + return false; + } + if (profile.auth_type === 'key' && keySelect.value !== profile.key_id) { + return false; + } + if (jumpHostSelect.value !== (profile.jump_host_id || '')) { + return false; + } + return true; + } + + function launchProfileForPane(profileId, paneIndex) { + const profile = ProfileManager.getProfile(profileId); + if (!profile) { + showNotification( + window.i18n + ? i18n.t('connection.profileUnavailable') + : 'This profile is no longer available.', + 'warning', + ); + ProfileManager.loadProfiles(); + return; + } + + openConnectionModalForPane(paneIndex); + const selected = selectConnectionProfile(profileId); + if (!selected) { + return; + } + + const mode = ProfileManager.getLaunchMode(selected); + const form = document.getElementById('connectionForm'); + if (mode === 'connect' && isSelectedProfileReady(selected)) { + clearSaveProfileState(); + form.requestSubmit(); + return; + } + + let focusTarget = document.getElementById('connectBtn'); + if (mode === 'password') { + focusTarget = document.getElementById('passwordInput'); + } else if (mode === 'jump-host-password') { + focusTarget = document.getElementById('jumpHostPasswordInput'); + } + window.requestAnimationFrame(() => focusTarget?.focus()); + } + + window.launchProfileForPane = launchProfileForPane; + function queuePaneConnection(paneIndex) { if (paneIndex === null || paneIndex === undefined) { return; @@ -1859,7 +1964,6 @@ ? '' : ProfileManager.getLegacyStartupCommands(); const targetPane = pendingPaneIndex; - pendingPaneIndex = null; if (!host || !username) { showNotification('Host and username are required', 'error'); @@ -1868,6 +1972,7 @@ if (authType === 'password' && !password) { showNotification('Password is required', 'error'); + document.getElementById('passwordInput').focus(); return; } @@ -1895,6 +2000,7 @@ const jhPass = document.getElementById('jumpHostPasswordInput').value; if (!jhPass) { showNotification('Jump host password is required', 'error'); + document.getElementById('jumpHostPasswordInput').focus(); return; } proxyJump.password = jhPass; @@ -1903,6 +2009,8 @@ } } + pendingPaneIndex = null; + if (saveProfile && profileName) { const profilePayload = { name: profileName, @@ -1993,19 +2101,7 @@ }); document.getElementById('profileSelect').addEventListener('change', (e) => { - const profileId = e.target.value; - const deleteBtn = document.getElementById('deleteProfileBtn'); - - if (profileId) { - ProfileManager.selectProfile(profileId); - deleteBtn.style.display = 'block'; - deleteBtn.dataset.profileId = profileId; - } else { - ProfileManager.clearLegacyCommands(); - CommandSetManager.selectForConnection(''); - deleteBtn.style.display = 'none'; - delete deleteBtn.dataset.profileId; - } + selectConnectionProfile(e.target.value); }); document.getElementById('deleteProfileBtn').addEventListener('click', (e) => { diff --git a/static/js/i18n.js b/static/js/i18n.js index 47383c0..fbc3a71 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -38,6 +38,12 @@ const translations = { 'connection.newSSHConnection': 'New SSH Connection', 'connection.noActiveSessions': 'No Active Sessions', 'connection.clickToStart': 'Click "New Connection" to start an SSH session', + 'connection.savedProfiles': 'Saved Profiles', + 'connection.savedProfilesHint': 'Choose a profile to connect', + 'connection.connectNow': 'Connect now', + 'connection.passwordRequired': 'Password required', + 'connection.reviewConnection': 'Review connection', + 'connection.profileUnavailable': 'This profile is no longer available.', 'connection.loadProfile': 'Load Profile (Optional)', 'connection.selectProfile': '-- Select Profile --', 'connection.host': 'Host *', @@ -390,6 +396,12 @@ const translations = { 'connection.newSSHConnection': 'Kết nối SSH mới', 'connection.noActiveSessions': 'Không có phiên hoạt động', 'connection.clickToStart': 'Nhấp vào "Kết nối mới" để bắt đầu một phiên SSH', + 'connection.savedProfiles': 'Cấu hình đã lưu', + 'connection.savedProfilesHint': 'Chọn một cấu hình để kết nối', + 'connection.connectNow': 'Kết nối ngay', + 'connection.passwordRequired': 'Yêu cầu mật khẩu', + 'connection.reviewConnection': 'Kiểm tra kết nối', + 'connection.profileUnavailable': 'Cấu hình này không còn khả dụng.', 'connection.loadProfile': 'Tải cấu hình (Tùy chọn)', 'connection.selectProfile': '-- Chọn cấu hình --', 'connection.host': 'Máy chủ *', @@ -740,6 +752,12 @@ const translations = { 'connection.newSSHConnection': 'Neue SSH-Verbindung', 'connection.noActiveSessions': 'Keine aktiven Sitzungen', 'connection.clickToStart': 'Klicken Sie auf "Neue Verbindung", um eine SSH-Sitzung zu starten', + 'connection.savedProfiles': 'Gespeicherte Profile', + 'connection.savedProfilesHint': 'Profil auswählen und verbinden', + 'connection.connectNow': 'Jetzt verbinden', + 'connection.passwordRequired': 'Kennwort erforderlich', + 'connection.reviewConnection': 'Verbindung prüfen', + 'connection.profileUnavailable': 'Dieses Profil ist nicht mehr verfügbar.', 'connection.loadProfile': 'Profil laden (Optional)', 'connection.selectProfile': '-- Profil auswählen --', 'connection.host': 'Host *', @@ -1072,6 +1090,12 @@ const translations = { 'connection.newSSHConnection': 'Nouvelle connexion SSH', 'connection.noActiveSessions': 'Aucune session active', 'connection.clickToStart': 'Cliquez sur "Nouvelle connexion" pour démarrer une session SSH', + 'connection.savedProfiles': 'Profils enregistrés', + 'connection.savedProfilesHint': 'Choisissez un profil pour vous connecter', + 'connection.connectNow': 'Se connecter', + 'connection.passwordRequired': 'Mot de passe requis', + 'connection.reviewConnection': 'Vérifier la connexion', + 'connection.profileUnavailable': 'Ce profil n’est plus disponible.', 'connection.loadProfile': 'Charger le profil (Optionnel)', 'connection.selectProfile': '-- Sélectionner un profil --', 'connection.host': 'Hôte *', @@ -1391,6 +1415,12 @@ const translations = { 'connection.newSSHConnection': 'Nueva conexión SSH', 'connection.noActiveSessions': 'Sin sesiones activas', 'connection.clickToStart': 'Haz clic en "Nueva conexión" para iniciar una sesión SSH', + 'connection.savedProfiles': 'Perfiles guardados', + 'connection.savedProfilesHint': 'Elige un perfil para conectarte', + 'connection.connectNow': 'Conectar ahora', + 'connection.passwordRequired': 'Se requiere contraseña', + 'connection.reviewConnection': 'Revisar conexión', + 'connection.profileUnavailable': 'Este perfil ya no está disponible.', 'connection.loadProfile': 'Cargar perfil (Opcional)', 'connection.selectProfile': '-- Seleccionar perfil --', 'connection.host': 'Host *', @@ -1710,6 +1740,12 @@ const translations = { 'connection.newSSHConnection': '新建 SSH 连接', 'connection.noActiveSessions': '当前没有活动会话', 'connection.clickToStart': '点击“新建连接”开始一个 SSH 会话', + 'connection.savedProfiles': '已保存的配置', + 'connection.savedProfilesHint': '选择配置以连接', + 'connection.connectNow': '立即连接', + 'connection.passwordRequired': '需要密码', + 'connection.reviewConnection': '检查连接', + 'connection.profileUnavailable': '此配置已不可用。', 'connection.loadProfile': '加载配置(可选)', 'connection.selectProfile': '-- 选择配置 --', 'connection.host': '主机 *', diff --git a/static/js/jump-host-manager.js b/static/js/jump-host-manager.js index 3d3a832..9765885 100644 --- a/static/js/jump-host-manager.js +++ b/static/js/jump-host-manager.js @@ -16,6 +16,9 @@ window.JumpHostManager = { this.jumpHosts = Array.isArray(list) ? list : []; this.renderSelect(); this.renderList(); + if (typeof SessionManager !== 'undefined') { + SessionManager.refreshEmptyPanes(); + } }, getById(id) { diff --git a/static/js/profile-launcher-utils.js b/static/js/profile-launcher-utils.js new file mode 100644 index 0000000..e775be1 --- /dev/null +++ b/static/js/profile-launcher-utils.js @@ -0,0 +1,67 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) { + module.exports = api; + } + if (root) { + root.ProfileLauncherUtils = api; + } +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + function hasKey(keys, keyId) { + return Boolean(keyId) && (Array.isArray(keys) ? keys : []) + .some(key => key && key.id === keyId && key.usable === true); + } + + function determineLaunchMode(profile, context = {}) { + if (!profile || typeof profile !== 'object') { + return 'review'; + } + + const keys = Array.isArray(context.keys) ? context.keys : []; + const jumpHosts = Array.isArray(context.jumpHosts) ? context.jumpHosts : []; + const needsTargetPassword = profile.auth_type === 'password'; + + if (profile.auth_type === 'key') { + if (!hasKey(keys, profile.key_id)) { + return 'review'; + } + } else if ( + profile.auth_type === 'tailscale' + && profile.tailscale_authorized !== true + ) { + return 'review'; + } else if (!needsTargetPassword && profile.auth_type !== 'tailscale') { + return 'review'; + } + + if (!profile.jump_host_id) { + return needsTargetPassword ? 'password' : 'connect'; + } + + const jumpHost = jumpHosts.find(item => ( + item && item.id === profile.jump_host_id + )); + if (!jumpHost) { + return 'review'; + } + if (jumpHost.auth_type === 'password') { + return needsTargetPassword ? 'password' : 'jump-host-password'; + } + if (jumpHost.auth_type !== 'key' || !hasKey(keys, jumpHost.key_id)) { + return 'review'; + } + return needsTargetPassword ? 'password' : 'connect'; + } + + function formatEndpoint(profile) { + const value = profile && typeof profile === 'object' ? profile : {}; + const username = String(value.username || ''); + const host = String(value.host || ''); + const port = Number(value.port) || 22; + return `${username}@${host}:${port}`; + } + + return { determineLaunchMode, formatEndpoint }; +})); diff --git a/static/js/profile-manager.js b/static/js/profile-manager.js index b10f9c7..bc9dc7c 100644 --- a/static/js/profile-manager.js +++ b/static/js/profile-manager.js @@ -1,6 +1,7 @@ const ProfileManager = { profiles: [], keys: [], + profilesLoaded: false, selectedLegacyStartupCommands: '', loadProfiles() { @@ -16,14 +17,119 @@ const ProfileManager = { }, setProfiles(profiles) { - this.profiles = profiles; + this.profiles = Array.isArray(profiles) ? profiles : []; + this.profilesLoaded = true; this.renderProfileSelect(); + this.refreshEmptyPanes(); }, setKeys(keys) { - this.keys = keys; + this.keys = Array.isArray(keys) ? keys : []; this.renderKeySelect(); this.renderKeysList(); + this.refreshEmptyPanes(); + }, + + refreshEmptyPanes() { + if (typeof SessionManager !== 'undefined') { + SessionManager.refreshEmptyPanes(); + } + }, + + getProfile(profileId) { + return this.profiles.find(profile => profile && profile.id === profileId) || null; + }, + + getLaunchMode(profile) { + return ProfileLauncherUtils.determineLaunchMode(profile, { + keys: this.keys, + jumpHosts: window.JumpHostManager?.jumpHosts || [], + }); + }, + + createEmptyPaneContent(paneIndex) { + const empty = document.createElement('div'); + empty.className = 'pane-empty profile-launcher'; + + const icon = document.createElement('div'); + icon.className = 'pane-empty-icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = '💻'; + empty.appendChild(icon); + + const profiles = this.profilesLoaded ? this.profiles : []; + const title = document.createElement('div'); + title.className = 'profile-launcher-title'; + title.textContent = profiles.length + ? (window.i18n ? i18n.t('connection.savedProfiles') : 'Saved Profiles') + : (window.i18n ? i18n.t('panes.emptyPane') : 'Empty pane'); + empty.appendChild(title); + + const hint = document.createElement('div'); + hint.className = 'profile-launcher-hint'; + hint.textContent = profiles.length + ? (window.i18n ? i18n.t('connection.savedProfilesHint') : 'Choose a profile to connect') + : (window.i18n ? i18n.t('panes.selectSession') : 'Select a session or open a connection'); + empty.appendChild(hint); + + if (profiles.length) { + const list = document.createElement('div'); + list.className = 'profile-launcher-list'; + profiles.forEach(profile => { + if (!profile || !profile.id) return; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'profile-launcher-card'; + button.dataset.profileId = profile.id; + + const name = document.createElement('span'); + name.className = 'profile-launcher-name'; + name.textContent = profile.name; + + const endpoint = document.createElement('span'); + endpoint.className = 'profile-launcher-endpoint'; + endpoint.textContent = ProfileLauncherUtils.formatEndpoint(profile); + + const mode = this.getLaunchMode(profile); + const action = document.createElement('span'); + action.className = `profile-launcher-action mode-${mode}`; + action.textContent = mode === 'connect' + ? (window.i18n ? i18n.t('connection.connectNow') : 'Connect now') + : (mode === 'password' || mode === 'jump-host-password' + ? (window.i18n ? i18n.t('connection.passwordRequired') : 'Password required') + : (window.i18n ? i18n.t('connection.reviewConnection') : 'Review connection')); + + button.setAttribute( + 'aria-label', + `${String(profile.name || '')}, ${ProfileLauncherUtils.formatEndpoint(profile)}, ${action.textContent}`, + ); + button.append(name, endpoint, action); + button.addEventListener('click', event => { + event.stopPropagation(); + if (window.launchProfileForPane) { + window.launchProfileForPane(profile.id, paneIndex); + } + }); + list.appendChild(button); + }); + empty.appendChild(list); + } + + const newConnection = document.createElement('button'); + newConnection.type = 'button'; + newConnection.className = profiles.length + ? 'btn btn-secondary profile-launcher-new' + : 'btn btn-primary profile-launcher-new'; + newConnection.textContent = window.i18n + ? i18n.t('connection.newConnection') + : 'New Connection'; + newConnection.addEventListener('click', event => { + event.stopPropagation(); + window.openConnectionModalForPane?.(paneIndex); + }); + empty.appendChild(newConnection); + return empty; }, renderProfileSelect() { diff --git a/static/js/session-manager.js b/static/js/session-manager.js index af5f17f..8232c98 100644 --- a/static/js/session-manager.js +++ b/static/js/session-manager.js @@ -17,6 +17,9 @@ const SessionManager = { } this.ensureTerminalGrid(); this.setSplitLayout(1); + window.addEventListener('languageChanged', () => { + this.refreshEmptyPanes(); + }); const sessionBar = document.getElementById('sessionBar'); if (sessionBar) { sessionBar.classList.remove('hidden'); @@ -77,7 +80,6 @@ const SessionManager = { terminalContainer.className = 'terminal-wrapper unassigned'; document.getElementById('terminalsContainer').appendChild(terminalContainer); - document.getElementById('noSessions').classList.add('hidden'); const sessionBar = document.getElementById('sessionBar'); if (sessionBar) { sessionBar.classList.remove('hidden'); @@ -145,7 +147,6 @@ const SessionManager = { } }); - document.getElementById('noSessions').classList.add('hidden'); const sessionBar = document.getElementById('sessionBar'); if (sessionBar) { sessionBar.classList.remove('hidden'); @@ -339,7 +340,6 @@ const SessionManager = { } } else { this.activeSessionId = null; - document.getElementById('noSessions').classList.remove('hidden'); this.updateSessionMeta(null); } }, @@ -759,6 +759,14 @@ const SessionManager = { this.updateSplitControls(); }, + refreshEmptyPanes() { + this.paneAssignments.forEach((sessionId, index) => { + if (!this.paneAssignments[index]) { + this.renderPane(index); + } + }); + }, + renderPane(paneIndex) { const grid = this.ensureTerminalGrid(); if (!grid) { @@ -785,28 +793,13 @@ const SessionManager = { return; } - const empty = document.createElement('div'); - empty.className = 'pane-empty'; - - const icon = document.createElement('div'); - icon.className = 'pane-empty-icon'; - icon.textContent = '💻'; - empty.appendChild(icon); - - const emptyText = document.createElement('div'); - emptyText.className = 'pane-empty-text'; - emptyText.textContent = window.i18n ? window.i18n.t('panes.emptyPane') : 'Empty pane'; - empty.appendChild(emptyText); - - const button = document.createElement('button'); - button.className = 'btn btn-primary'; - button.textContent = window.i18n ? window.i18n.t('connection.newConnection') : 'New connection'; - button.addEventListener('click', () => { - if (window.openConnectionModalForPane) { - window.openConnectionModalForPane(paneIndex); - } - }); - empty.appendChild(button); + const empty = typeof ProfileManager !== 'undefined' + ? ProfileManager.createEmptyPaneContent(paneIndex) + : document.createElement('div'); + if (!empty.className) { + empty.className = 'pane-empty'; + empty.textContent = window.i18n ? i18n.t('panes.emptyPane') : 'Empty pane'; + } pane.appendChild(empty); }, diff --git a/templates/index.html b/templates/index.html index 33d6008..c2d83e0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -17,7 +17,7 @@ - +
@@ -142,13 +142,6 @@Click "New Connection" to start an SSH session
-