diff --git a/frontend/src/css/github.css b/frontend/src/css/github.css index d7ab2e8..05c4b9b 100644 --- a/frontend/src/css/github.css +++ b/frontend/src/css/github.css @@ -4,298 +4,301 @@ ========================================================================= */ .github-viewer { - display: flex; - flex-direction: column; - height: 100%; - background: var(--bg-primary); - color: var(--text-primary); - font-family: var(--font-family); + display: flex; + flex-direction: column; + height: 100%; + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-family); } /* ── Header bar ────────────────────────────────────────────────────────── */ .github-viewer-header { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5); - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; } .github-viewer-title-area { - display: flex; - align-items: center; - gap: var(--space-2); - flex-shrink: 0; + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; } .github-viewer-icon { - display: flex; - align-items: center; - color: var(--protocol-github); - opacity: 0.9; + display: flex; + align-items: center; + color: var(--protocol-github); + opacity: 0.9; } .github-viewer-title { - font-size: var(--font-size-lg); - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; } .github-viewer-search { - flex: 1; - max-width: 360px; + flex: 1; + max-width: 360px; } .github-viewer-search-input { - width: 100%; - padding: var(--space-1) var(--space-3); - background: var(--bg-tertiary); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-sm); - color: var(--text-primary); - font-size: var(--font-size-sm); - outline: none; - transition: border-color 0.15s; + width: 100%; + padding: var(--space-1) var(--space-3); + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: var(--font-size-sm); + outline: none; + transition: border-color 0.15s; } .github-viewer-search-input:focus { - border-color: var(--protocol-github); + border-color: var(--protocol-github); } .github-viewer-search-input::placeholder { - color: var(--text-muted); + color: var(--text-muted); } .github-viewer-refresh { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - background: var(--bg-tertiary); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-sm); - color: var(--text-secondary); - font-size: 16px; - cursor: pointer; - transition: all 0.15s; - flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 16px; + cursor: pointer; + transition: all 0.15s; + flex-shrink: 0; } .github-viewer-refresh:hover { - background: var(--bg-hover); - color: var(--protocol-github); - border-color: var(--protocol-github); + background: var(--bg-hover); + color: var(--protocol-github); + border-color: var(--protocol-github); } /* ── Status bar ────────────────────────────────────────────────────────── */ .github-viewer-status { - padding: var(--space-1) var(--space-4) var(--space-1) var(--space-5); - font-size: var(--font-size-xs); - color: var(--text-muted); - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; + padding: var(--space-1) var(--space-4) var(--space-1) var(--space-5); + font-size: var(--font-size-xs); + color: var(--text-muted); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; } .github-viewer-status-error { - color: var(--red); + color: var(--red); } /* ── Repo list ─────────────────────────────────────────────────────────── */ .github-viewer-list { - flex: 1; - overflow-y: auto; - padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5); + flex: 1; + overflow-y: auto; + padding: var(--space-3) var(--space-4) var(--space-3) var(--space-5); } .github-viewer-empty { - padding: var(--space-8) var(--space-4); - text-align: center; - color: var(--text-muted); - font-size: var(--font-size-sm); + padding: var(--space-8) var(--space-4); + text-align: center; + color: var(--text-muted); + font-size: var(--font-size-sm); } /* ── Group ─────────────────────────────────────────────────────────────── */ .github-group { - margin-bottom: var(--space-4); + margin-bottom: var(--space-4); } .github-group-header { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-1) 0; - margin-bottom: var(--space-2); - border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1) 0; + margin-bottom: var(--space-2); + border-bottom: 1px solid var(--border-subtle); } .github-group-name { - font-size: var(--font-size-sm); - font-weight: 600; - color: var(--text-secondary); - text-transform: none; - letter-spacing: 0.01em; + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--text-secondary); + text-transform: none; + letter-spacing: 0.01em; } .github-group-count { - font-size: var(--font-size-xs); - color: var(--text-muted); - background: var(--bg-tertiary); - padding: 1px 6px; - border-radius: var(--radius-full); + font-size: var(--font-size-xs); + color: var(--text-muted); + background: var(--bg-tertiary); + padding: 1px 6px; + border-radius: var(--radius-full); } /* ── Repo card ─────────────────────────────────────────────────────────── */ .github-repo-card { - padding: var(--space-2) var(--space-3); - margin-bottom: 2px; - border-radius: var(--radius-sm); - cursor: pointer; - transition: background 0.12s; + padding: var(--space-2) var(--space-3); + margin-bottom: 2px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.12s; } .github-repo-card:hover { - background: var(--bg-hover); + background: var(--bg-hover); } .github-repo-top { - display: flex; - align-items: center; - gap: var(--space-2); - margin-bottom: 2px; + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: 2px; } .github-repo-commit { - font-size: var(--font-size-xs); - color: var(--text-muted); - margin-bottom: 4px; + font-size: var(--font-size-xs); + color: var(--text-muted); + margin-bottom: 4px; } .github-repo-name { - font-size: var(--font-size-sm); - font-weight: 600; - color: var(--protocol-github); + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--protocol-github); } .github-repo-badge { - font-size: 10px; - padding: 0 5px; - border-radius: var(--radius-full); - line-height: 16px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.03em; + font-size: 10px; + padding: 0 5px; + border-radius: var(--radius-full); + line-height: 16px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; } .github-repo-actions { - margin-left: auto; - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 4px; + margin-left: auto; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; } .github-repo-download, .github-repo-visit { - padding: 0 6px; - height: 18px; - border-radius: var(--radius-full); - border: 1px solid var(--border-subtle); - background: var(--bg-tertiary); - color: var(--text-secondary); - font-size: 10px; - letter-spacing: 0.04em; - cursor: pointer; - transition: background 0.15s, color 0.15s, border-color 0.15s; + padding: 0 6px; + height: 18px; + border-radius: var(--radius-full); + border: 1px solid var(--border-subtle); + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 10px; + letter-spacing: 0.04em; + cursor: pointer; + transition: + background 0.15s, + color 0.15s, + border-color 0.15s; } .github-repo-download:hover, .github-repo-visit:hover { - background: var(--bg-hover); - color: var(--protocol-github); - border-color: var(--protocol-github); + background: var(--bg-hover); + color: var(--protocol-github); + border-color: var(--protocol-github); } .github-repo-private { - background: rgba(255, 167, 38, 0.12); - color: #ffa726; + background: rgba(255, 167, 38, 0.12); + color: #ffa726; } .github-repo-fork { - background: rgba(78, 205, 196, 0.12); - color: #4ecdc4; + background: rgba(78, 205, 196, 0.12); + color: #4ecdc4; } .github-repo-archived { - background: rgba(255, 255, 255, 0.06); - color: var(--text-muted); + background: rgba(255, 255, 255, 0.06); + color: var(--text-muted); } .github-repo-desc { - font-size: var(--font-size-xs); - color: var(--text-secondary); - line-height: 1.4; - margin-bottom: 4px; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; + font-size: var(--font-size-xs); + color: var(--text-secondary); + line-height: 1.4; + margin-bottom: 4px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } .github-repo-meta { - display: flex; - align-items: center; - gap: var(--space-3); - font-size: 11px; - color: var(--text-muted); + display: flex; + align-items: center; + gap: var(--space-3); + font-size: 11px; + color: var(--text-muted); } .github-repo-lang { - display: flex; - align-items: center; - gap: 4px; + display: flex; + align-items: center; + gap: 4px; } .github-lang-dot { - width: 8px; - height: 8px; - border-radius: 50%; - display: inline-block; - flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; } .github-repo-stars { - /* Star colour is inherent in the text */ + /* Star colour is inherent in the text */ } .github-repo-pushed { - /* Relative time text */ + /* Relative time text */ } /* ── Scrollbar styling ─────────────────────────────────────────────────── */ .github-viewer-list::-webkit-scrollbar { - width: 6px; + width: 6px; } .github-viewer-list::-webkit-scrollbar-track { - background: transparent; + background: transparent; } .github-viewer-list::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb); - border-radius: 3px; + background: var(--scrollbar-thumb); + border-radius: 3px; } .github-viewer-list::-webkit-scrollbar-thumb:hover { - background: var(--scrollbar-thumb-hover); + background: var(--scrollbar-thumb-hover); } diff --git a/frontend/src/css/sidebar.css b/frontend/src/css/sidebar.css index 75bd12b..e4d5b26 100644 --- a/frontend/src/css/sidebar.css +++ b/frontend/src/css/sidebar.css @@ -7,387 +7,409 @@ Sidebar header (logo / title / drag region) ----------------------------------------------------------------------- */ .sidebar-header { - display: flex; - align-items: center; - padding: var(--space-3) var(--space-4); - min-height: var(--tab-bar-height); - border-bottom: 1px solid var(--border-subtle); - flex-shrink: 0; + display: flex; + align-items: center; + padding: var(--space-3) var(--space-4); + min-height: var(--tab-bar-height); + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; } .sidebar-logo { - display: flex; - align-items: center; - gap: var(--space-2); + display: flex; + align-items: center; + gap: var(--space-2); } .sidebar-logo svg { - width: 18px; - height: 18px; - color: var(--accent); - flex-shrink: 0; + width: 18px; + height: 18px; + color: var(--accent); + flex-shrink: 0; } .sidebar-title { - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--text-primary); - letter-spacing: 0.02em; + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--text-primary); + letter-spacing: 0.02em; } /* ----------------------------------------------------------------------- Sidebar search ----------------------------------------------------------------------- */ .sidebar-search { - padding: var(--space-2) var(--space-3); - flex-shrink: 0; + padding: var(--space-2) var(--space-3); + flex-shrink: 0; } /* ----------------------------------------------------------------------- Sidebar navigation (scrollable area) ----------------------------------------------------------------------- */ .sidebar-nav { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - padding-bottom: var(--space-2); + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding-bottom: var(--space-2); } /* ----------------------------------------------------------------------- Bulk actions bar (multi-select) ----------------------------------------------------------------------- */ .sidebar-bulk-actions { - position: sticky; - top: 0; - z-index: 2; - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-2); - padding: var(--space-2) var(--space-3); - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-subtle); + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-subtle); } .sidebar-bulk-count { - font-size: var(--text-xs); - color: var(--text-secondary); - font-weight: var(--weight-semibold); + font-size: var(--text-xs); + color: var(--text-secondary); + font-weight: var(--weight-semibold); } .sidebar-bulk-buttons { - display: flex; - flex-wrap: wrap; - gap: var(--space-1); + display: flex; + flex-wrap: wrap; + gap: var(--space-1); } /* ----------------------------------------------------------------------- Sections (Favourites, Recent, All Connections) ----------------------------------------------------------------------- */ .sidebar-section { - /* Each section can be collapsed */ + /* Each section can be collapsed */ } .sidebar-section + .sidebar-section { - border-top: 1px solid var(--border-subtle); + border-top: 1px solid var(--border-subtle); } .sidebar-section-header { - display: flex; - align-items: center; - gap: var(--space-2); - width: 100%; - padding: var(--space-2) var(--space-3); - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - color: var(--text-tertiary); - text-transform: uppercase; - letter-spacing: 0.06em; - cursor: pointer; - transition: color var(--transition-fast), background var(--transition-fast); - -webkit-user-select: none; - user-select: none; - text-align: left; - background: none; - border: none; + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) var(--space-3); + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.06em; + cursor: pointer; + transition: + color var(--transition-fast), + background var(--transition-fast); + -webkit-user-select: none; + user-select: none; + text-align: left; + background: none; + border: none; } .sidebar-section-header.sidebar-section-header-group .section-title { - color: var(--sidebar-group-title); + color: var(--sidebar-group-title); } .sidebar-section-header:hover { - color: var(--text-secondary); - background: var(--bg-hover); + color: var(--text-secondary); + background: var(--bg-hover); } .sidebar-section-header.sidebar-section-header-group:hover .section-title { - color: var(--sidebar-group-title); + color: var(--sidebar-group-title); } .sidebar-section-header .section-icon { - width: 14px; - text-align: center; - flex-shrink: 0; - font-size: var(--text-sm); + width: 14px; + text-align: center; + flex-shrink: 0; + font-size: var(--text-sm); } .sidebar-section-header .section-chevron { - width: 12px; - height: 12px; - margin-right: var(--space-1); - flex-shrink: 0; - transition: transform var(--transition-normal); - color: var(--text-disabled); + width: 12px; + height: 12px; + margin-right: var(--space-1); + flex-shrink: 0; + transition: transform var(--transition-normal); + color: var(--text-disabled); } .sidebar-section.is-collapsed .section-chevron { - transform: rotate(-90deg); + transform: rotate(-90deg); } .section-count { - margin-left: auto; - font-size: 10px; - font-weight: var(--weight-normal); - color: var(--text-disabled); - background: var(--bg-overlay); - padding: 0 var(--space-1); - border-radius: var(--radius-sm); - min-width: 18px; - text-align: center; - line-height: 16px; + margin-left: auto; + font-size: 10px; + font-weight: var(--weight-normal); + color: var(--text-disabled); + background: var(--bg-overlay); + padding: 0 var(--space-1); + border-radius: var(--radius-sm); + min-width: 18px; + text-align: center; + line-height: 16px; } /* ----------------------------------------------------------------------- Sidebar list (container for items) ----------------------------------------------------------------------- */ .sidebar-list { - padding: 0 var(--space-2); + padding: 0 var(--space-2); } .sidebar-section.is-collapsed .sidebar-list { - display: none; + display: none; } /* ----------------------------------------------------------------------- Sidebar item (individual connection profile) ----------------------------------------------------------------------- */ .sidebar-item { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-2); - margin-bottom: 1px; - border-radius: var(--radius-md); - cursor: pointer; - transition: - background var(--transition-fast), - box-shadow var(--transition-fast); - user-select: none; - position: relative; + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-2); + margin-bottom: 1px; + border-radius: var(--radius-md); + cursor: pointer; + transition: + background var(--transition-fast), + box-shadow var(--transition-fast); + user-select: none; + position: relative; } .sidebar-item:hover { - background: var(--bg-hover); + background: var(--bg-hover); } .sidebar-item:active { - background: var(--bg-active); + background: var(--bg-active); } .sidebar-item-selected { - background: var(--accent-muted); - box-shadow: inset 0 0 0 1px var(--accent); + background: var(--accent-muted); + box-shadow: inset 0 0 0 1px var(--accent); } .sidebar-item-selected .sidebar-item-name { - color: var(--text-primary); + color: var(--text-primary); } /* Active / selected state */ .sidebar-item.active { - background: var(--accent-muted); + background: var(--accent-muted); } .sidebar-item.active::before { - content: ''; - position: absolute; - left: 0; - top: 4px; - bottom: 4px; - width: 2px; - border-radius: var(--radius-full); - background: var(--accent); + content: ""; + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 2px; + border-radius: var(--radius-full); + background: var(--accent); } /* Protocol indicator icon */ .sidebar-item .protocol-indicator { - width: 14px; - height: 14px; - border-radius: var(--radius-sm); - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 8px; - font-weight: var(--weight-semibold); - line-height: 1; - background: var(--bg-overlay); - color: var(--text-tertiary); + width: 14px; + height: 14px; + border-radius: var(--radius-sm); + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 8px; + font-weight: var(--weight-semibold); + line-height: 1; + background: var(--bg-overlay); + color: var(--text-tertiary); } .sidebar-item .protocol-indicator::before { - content: '?'; + content: "?"; } -.sidebar-item .protocol-indicator.proto-ssh::before { content: '⌨'; } -.sidebar-item .protocol-indicator.proto-telnet::before { content: '⇄'; } -.sidebar-item .protocol-indicator.proto-raw::before { content: '≋'; } -.sidebar-item .protocol-indicator.proto-github::before { content: 'GH'; } +.sidebar-item .protocol-indicator.proto-ssh::before { + content: "⌨"; +} +.sidebar-item .protocol-indicator.proto-telnet::before { + content: "⇄"; +} +.sidebar-item .protocol-indicator.proto-raw::before { + content: "≋"; +} +.sidebar-item .protocol-indicator.proto-github::before { + content: "GH"; +} -.sidebar-item .protocol-indicator.proto-ssh { - background: rgba(78, 205, 196, 0.18); - color: var(--protocol-ssh); +.sidebar-item .protocol-indicator.proto-ssh { + background: rgba(78, 205, 196, 0.18); + color: var(--protocol-ssh); } .sidebar-item .protocol-indicator.proto-telnet { - background: rgba(255, 167, 38, 0.18); - color: var(--protocol-telnet); + background: rgba(255, 167, 38, 0.18); + color: var(--protocol-telnet); } .sidebar-item .protocol-indicator.proto-raw { - background: rgba(171, 71, 188, 0.18); - color: var(--protocol-raw); + background: rgba(171, 71, 188, 0.18); + color: var(--protocol-raw); } .sidebar-item .protocol-indicator.proto-github { - background: rgba(124, 138, 255, 0.18); - color: var(--protocol-github); + background: rgba(124, 138, 255, 0.18); + color: var(--protocol-github); } /* Text block (name + host) */ .sidebar-item-text { - flex: 1; - min-width: 0; + flex: 1; + min-width: 0; } .sidebar-item-name { - font-size: var(--text-sm); - font-weight: var(--weight-medium); - color: var(--text-primary); - line-height: var(--leading-tight); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--text-primary); + line-height: var(--leading-tight); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .sidebar-item-host { - font-size: var(--text-xs); - color: var(--text-tertiary); - font-family: var(--font-mono); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - line-height: var(--leading-tight); - margin-top: 1px; + font-size: var(--text-xs); + color: var(--text-tertiary); + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: var(--leading-tight); + margin-top: 1px; } /* Edit button (appears on hover, beside name) */ .sidebar-item-edit { - flex-shrink: 0; - width: 22px; - height: 22px; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - color: var(--text-disabled); - background: none; - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - opacity: 0; - transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast); + flex-shrink: 0; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: var(--text-disabled); + background: none; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + opacity: 0; + transition: + opacity var(--transition-fast), + color var(--transition-fast), + background var(--transition-fast); } .sidebar-item:hover .sidebar-item-edit { - opacity: 1; + opacity: 1; } .sidebar-item-edit:hover { - color: var(--accent); - background: var(--bg-hover); + color: var(--accent); + background: var(--bg-hover); } /* Favourite star */ .sidebar-item-star { - flex-shrink: 0; - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - color: var(--text-disabled); - border-radius: var(--radius-sm); - opacity: 0; - transition: opacity var(--transition-fast), color var(--transition-fast); + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + color: var(--text-disabled); + border-radius: var(--radius-sm); + opacity: 0; + transition: + opacity var(--transition-fast), + color var(--transition-fast); } .sidebar-item:hover .sidebar-item-star { - opacity: 1; + opacity: 1; } .sidebar-item-star.is-favourite { - opacity: 1; - color: var(--warning); + opacity: 1; + color: var(--warning); } .sidebar-item-star:hover { - color: var(--warning); + color: var(--warning); } /* Inline status dot (right side, tiny) */ .sidebar-item-status { - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; - margin-right: var(--space-1); + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + margin-right: var(--space-1); +} +.sidebar-item-status.is-connected { + background: var(--success); +} +.sidebar-item-status.is-connecting { + background: var(--warning); + animation: pulse-dot 1.2s ease infinite; +} +.sidebar-item-status.is-error { + background: var(--error); } -.sidebar-item-status.is-connected { background: var(--success); } -.sidebar-item-status.is-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; } -.sidebar-item-status.is-error { background: var(--error); } /* ----------------------------------------------------------------------- Sidebar empty list placeholder ----------------------------------------------------------------------- */ .sidebar-list-empty { - padding: var(--space-3) var(--space-2); - font-size: var(--text-xs); - color: var(--text-disabled); - text-align: center; - font-style: italic; + padding: var(--space-3) var(--space-2); + font-size: var(--text-xs); + color: var(--text-disabled); + text-align: center; + font-style: italic; } /* ----------------------------------------------------------------------- Sidebar footer (actions) ----------------------------------------------------------------------- */ .sidebar-footer { - display: flex; - align-items: center; - gap: var(--space-1); - padding: var(--space-2) var(--space-3); - border-top: 1px solid var(--border-subtle); - flex-shrink: 0; + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-2) var(--space-3); + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; } .sidebar-footer .btn:first-child { - flex: 1; + flex: 1; } /* ----------------------------------------------------------------------- Sidebar collapsed state ----------------------------------------------------------------------- */ .sidebar-collapsed .sidebar-header { - justify-content: center; - padding: var(--space-3) var(--space-2); + justify-content: center; + padding: var(--space-3) var(--space-2); } .sidebar-collapsed .sidebar-title, .sidebar-collapsed .sidebar-search, @@ -396,19 +418,19 @@ .sidebar-collapsed .sidebar-item-star, .sidebar-collapsed .section-count, .sidebar-collapsed .sidebar-footer .btn:first-child span { - display: none; + display: none; } .sidebar-collapsed .sidebar-item { - justify-content: center; - padding: var(--space-2); + justify-content: center; + padding: var(--space-2); } .sidebar-collapsed .sidebar-section-header { - justify-content: center; - padding: var(--space-2); + justify-content: center; + padding: var(--space-2); } .sidebar-collapsed .sidebar-footer { - flex-direction: column; + flex-direction: column; } diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index 2aef9b0..feb03a4 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -918,10 +918,7 @@ "permission denied", "no supported methods", ]; - const hostKeyPatterns = [ - "host key", - "fingerprint", - ]; + const hostKeyPatterns = ["host key", "fingerprint"]; if (authPatterns.some((p) => msg.includes(p))) { return { @@ -959,17 +956,23 @@ const session = this.state.getSession(sessionId); const label = session?.tabName || session?.host || "Session"; const body = `${info.message}\n\n${message}`; - this._showGuidedErrorModal(info.title, label, body, (action) => { - if (action === "edit" && session?.profileId) { - this.profileEditor.openEdit(session.profileId); - } - if (action === "retry") { - this.tabs._reconnect(sessionId); - } - if (action === "knownhosts") { - this.settings.openTab("knownhosts"); - } - }, info.actions); + this._showGuidedErrorModal( + info.title, + label, + body, + (action) => { + if (action === "edit" && session?.profileId) { + this.profileEditor.openEdit(session.profileId); + } + if (action === "retry") { + this.tabs._reconnect(sessionId); + } + if (action === "knownhosts") { + this.settings.openTab("knownhosts"); + } + }, + info.actions, + ); } /** @@ -1005,15 +1008,20 @@ const footer = modal.querySelector(".modal-footer"); const addActionBtn = (label, action, primary) => { - const btn = createElement("button", `btn btn-sm${primary ? " btn-primary" : ""}`, { - textContent: label, - }); + const btn = createElement( + "button", + `btn btn-sm${primary ? " btn-primary" : ""}`, + { + textContent: label, + }, + ); btn.addEventListener("click", () => close(action)); footer.appendChild(btn); }; addActionBtn("Close", null, false); - if (actions.includes("knownhosts")) addActionBtn("Known Hosts", "knownhosts", false); + if (actions.includes("knownhosts")) + addActionBtn("Known Hosts", "knownhosts", false); if (actions.includes("edit")) addActionBtn("Edit Profile", "edit", false); if (actions.includes("retry")) addActionBtn("Retry", "retry", true); @@ -1024,7 +1032,9 @@ if (action) onAction(action); }; - modal.querySelector(".modal-close").addEventListener("click", () => close(null)); + modal + .querySelector(".modal-close") + .addEventListener("click", () => close(null)); backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(null); }); diff --git a/frontend/src/js/github.js b/frontend/src/js/github.js index 7aa800a..59072b0 100644 --- a/frontend/src/js/github.js +++ b/frontend/src/js/github.js @@ -1,17 +1,17 @@ -(function() { -/** - * Nexus Terminal — GitHub repository viewer. - * Renders a scrollable list of repositories grouped by owner/organisation, - * sorted by last commit date (newest first). Automatically refreshes via - * Wails events emitted by the Go backend. - */ +(function () { + /** + * Nexus Terminal — GitHub repository viewer. + * Renders a scrollable list of repositories grouped by owner/organisation, + * sorted by last commit date (newest first). Automatically refreshes via + * Wails events emitted by the Go backend. + */ -const { createElement, escapeHtml, relativeTime, $ } = window.NexusTerminal; + const { createElement, escapeHtml, relativeTime, $ } = window.NexusTerminal; -class GitHubViewer { + class GitHubViewer { constructor() { - /** @type {Map} sessionID → viewer */ - this._viewers = new Map(); + /** @type {Map} sessionID → viewer */ + this._viewers = new Map(); } /** @@ -21,11 +21,15 @@ class GitHubViewer { * @param {string} profileName */ createViewer(sessionID, container, profileName) { - if (this._viewers.has(sessionID)) return; + if (this._viewers.has(sessionID)) return; - const viewer = new GitHubViewerInstance(sessionID, container, profileName); - this._viewers.set(sessionID, viewer); - viewer.render(); + const viewer = new GitHubViewerInstance( + sessionID, + container, + profileName, + ); + this._viewers.set(sessionID, viewer); + viewer.render(); } /** @@ -33,11 +37,11 @@ class GitHubViewer { * @param {string} sessionID */ destroyViewer(sessionID) { - const viewer = this._viewers.get(sessionID); - if (viewer) { - viewer.destroy(); - this._viewers.delete(sessionID); - } + const viewer = this._viewers.get(sessionID); + if (viewer) { + viewer.destroy(); + this._viewers.delete(sessionID); + } } /** @@ -47,10 +51,10 @@ class GitHubViewer { * @param {Array} repos */ updateRepos(sessionID, login, repos) { - const viewer = this._viewers.get(sessionID); - if (viewer) { - viewer.setData(login, repos); - } + const viewer = this._viewers.get(sessionID); + if (viewer) { + viewer.setData(login, repos); + } } /** @@ -59,10 +63,10 @@ class GitHubViewer { * @param {string} message */ setStatus(sessionID, message) { - const viewer = this._viewers.get(sessionID); - if (viewer) { - viewer.setStatus(message); - } + const viewer = this._viewers.get(sessionID); + if (viewer) { + viewer.setStatus(message); + } } /** @@ -71,98 +75,98 @@ class GitHubViewer { * @param {string} message */ showError(sessionID, message) { - const viewer = this._viewers.get(sessionID); - if (viewer) { - viewer.showError(message); - } + const viewer = this._viewers.get(sessionID); + if (viewer) { + viewer.showError(message); + } } -} + } -class GitHubViewerInstance { + class GitHubViewerInstance { /** * @param {string} sessionID * @param {HTMLElement} container * @param {string} profileName */ constructor(sessionID, container, profileName) { - this.sessionID = sessionID; - this.container = container; - this.profileName = profileName; + this.sessionID = sessionID; + this.container = container; + this.profileName = profileName; - /** @type {string} */ - this._login = ''; + /** @type {string} */ + this._login = ""; - /** @type {Array} */ - this._repos = []; + /** @type {Array} */ + this._repos = []; - /** @type {string} */ - this._filter = ''; + /** @type {string} */ + this._filter = ""; - /** @type {HTMLElement|null} */ - this._el = null; + /** @type {HTMLElement|null} */ + this._el = null; - /** @type {HTMLElement|null} */ - this._listEl = null; + /** @type {HTMLElement|null} */ + this._listEl = null; - /** @type {HTMLElement|null} */ - this._statusEl = null; + /** @type {HTMLElement|null} */ + this._statusEl = null; - /** @type {number|null} */ - this._refreshTimer = null; + /** @type {number|null} */ + this._refreshTimer = null; } render() { - this.container.innerHTML = ''; + this.container.innerHTML = ""; - const wrapper = createElement('div', 'github-viewer'); + const wrapper = createElement("div", "github-viewer"); - // ── Header bar ────────────────────────────────────────── - const header = createElement('div', 'github-viewer-header'); + // ── Header bar ────────────────────────────────────────── + const header = createElement("div", "github-viewer-header"); - const titleArea = createElement('div', 'github-viewer-title-area'); - const icon = createElement('span', 'github-viewer-icon'); - icon.innerHTML = this._githubSVG(); - titleArea.appendChild(icon); + const titleArea = createElement("div", "github-viewer-title-area"); + const icon = createElement("span", "github-viewer-icon"); + icon.innerHTML = this._githubSVG(); + titleArea.appendChild(icon); - const titleText = createElement('span', 'github-viewer-title'); - titleText.textContent = this.profileName || 'GitHub'; - titleArea.appendChild(titleText); - header.appendChild(titleArea); + const titleText = createElement("span", "github-viewer-title"); + titleText.textContent = this.profileName || "GitHub"; + titleArea.appendChild(titleText); + header.appendChild(titleArea); - // Search / filter - const searchWrap = createElement('div', 'github-viewer-search'); - const searchInput = createElement('input', 'github-viewer-search-input', { - type: 'text', - placeholder: 'Filter repositories…', - }); - searchInput.addEventListener('input', (e) => { - this._filter = e.target.value.toLowerCase(); - this._renderList(); - }); - searchWrap.appendChild(searchInput); - header.appendChild(searchWrap); + // Search / filter + const searchWrap = createElement("div", "github-viewer-search"); + const searchInput = createElement("input", "github-viewer-search-input", { + type: "text", + placeholder: "Filter repositories…", + }); + searchInput.addEventListener("input", (e) => { + this._filter = e.target.value.toLowerCase(); + this._renderList(); + }); + searchWrap.appendChild(searchInput); + header.appendChild(searchWrap); - // Refresh button - const refreshBtn = createElement('button', 'github-viewer-refresh', { - title: 'Refresh now', - }); - refreshBtn.innerHTML = '↻'; - refreshBtn.addEventListener('click', () => this._manualRefresh()); - header.appendChild(refreshBtn); + // Refresh button + const refreshBtn = createElement("button", "github-viewer-refresh", { + title: "Refresh now", + }); + refreshBtn.innerHTML = "↻"; + refreshBtn.addEventListener("click", () => this._manualRefresh()); + header.appendChild(refreshBtn); - wrapper.appendChild(header); + wrapper.appendChild(header); - // ── Status bar ────────────────────────────────────────── - this._statusEl = createElement('div', 'github-viewer-status'); - this._statusEl.textContent = 'Connecting…'; - wrapper.appendChild(this._statusEl); + // ── Status bar ────────────────────────────────────────── + this._statusEl = createElement("div", "github-viewer-status"); + this._statusEl.textContent = "Connecting…"; + wrapper.appendChild(this._statusEl); - // ── Repo list ─────────────────────────────────────────── - this._listEl = createElement('div', 'github-viewer-list'); - wrapper.appendChild(this._listEl); + // ── Repo list ─────────────────────────────────────────── + this._listEl = createElement("div", "github-viewer-list"); + wrapper.appendChild(this._listEl); - this.container.appendChild(wrapper); - this._el = wrapper; + this.container.appendChild(wrapper); + this._el = wrapper; } /** @@ -171,10 +175,10 @@ class GitHubViewerInstance { * @param {Array} repos */ setData(login, repos) { - this._login = login; - this._repos = repos || []; - this._updateStatus(); - this._renderList(); + this._login = login; + this._repos = repos || []; + this._updateStatus(); + this._renderList(); } /** @@ -182,9 +186,9 @@ class GitHubViewerInstance { * @param {string} message */ setStatus(message) { - if (!this._statusEl) return; - this._statusEl.classList.remove('github-viewer-status-error'); - this._statusEl.textContent = message || ''; + if (!this._statusEl) return; + this._statusEl.classList.remove("github-viewer-status-error"); + this._statusEl.textContent = message || ""; } /** @@ -192,89 +196,108 @@ class GitHubViewerInstance { * @param {string} message */ showError(message) { - if (this._statusEl) { - this._statusEl.textContent = `⚠ ${message}`; - this._statusEl.classList.add('github-viewer-status-error'); - } + if (this._statusEl) { + this._statusEl.textContent = `⚠ ${message}`; + this._statusEl.classList.add("github-viewer-status-error"); + } } destroy() { - if (this._refreshTimer) { - clearInterval(this._refreshTimer); - this._refreshTimer = null; - } - if (this._el) { - this._el.remove(); - this._el = null; - } + if (this._refreshTimer) { + clearInterval(this._refreshTimer); + this._refreshTimer = null; + } + if (this._el) { + this._el.remove(); + this._el = null; + } } // ── Private ────────────────────────────────────────────────── _updateStatus() { - if (!this._statusEl) return; - this._statusEl.classList.remove('github-viewer-status-error'); + if (!this._statusEl) return; + this._statusEl.classList.remove("github-viewer-status-error"); - const count = this._repos.length; - const now = new Date().toLocaleTimeString(); - this._statusEl.textContent = `${this._login} · ${count} repositor${count === 1 ? 'y' : 'ies'} · updated ${now}`; + const count = this._repos.length; + const now = new Date().toLocaleTimeString(); + this._statusEl.textContent = `${this._login} · ${count} repositor${count === 1 ? "y" : "ies"} · updated ${now}`; } _renderList() { - if (!this._listEl) return; - this._listEl.innerHTML = ''; + if (!this._listEl) return; + this._listEl.innerHTML = ""; - // Filter - let filtered = this._repos; - if (this._filter) { - filtered = this._repos.filter((r) => - r.fullName.toLowerCase().includes(this._filter) || - (r.description || '').toLowerCase().includes(this._filter) || - (r.language || '').toLowerCase().includes(this._filter) - ); - } + // Filter + let filtered = this._repos; + if (this._filter) { + filtered = this._repos.filter( + (r) => + r.fullName.toLowerCase().includes(this._filter) || + (r.description || "").toLowerCase().includes(this._filter) || + (r.language || "").toLowerCase().includes(this._filter), + ); + } - if (filtered.length === 0) { - const empty = createElement('div', 'github-viewer-empty'); - empty.textContent = this._filter ? 'No matching repositories.' : 'No repositories found.'; - this._listEl.appendChild(empty); - return; - } + if (filtered.length === 0) { + const empty = createElement("div", "github-viewer-empty"); + empty.textContent = this._filter + ? "No matching repositories." + : "No repositories found."; + this._listEl.appendChild(empty); + return; + } - // Group by owner - const groups = new Map(); - for (const repo of filtered) { - const owner = repo.owner || 'unknown'; - if (!groups.has(owner)) groups.set(owner, []); - groups.get(owner).push(repo); - } + const commitTimestamp = (repo) => { + const raw = repo.latestCommitAt || repo.pushedAt || repo.updatedAt || ""; + const ts = new Date(raw).getTime(); + return Number.isNaN(ts) ? 0 : ts; + }; - // Sort groups alphabetically (user's own repos first) - const sortedKeys = [...groups.keys()].sort((a, b) => { - if (a === this._login) return -1; - if (b === this._login) return 1; - return a.localeCompare(b); - }); + // Group by owner + const groups = new Map(); + for (const repo of filtered) { + const owner = repo.owner || "unknown"; + if (!groups.has(owner)) groups.set(owner, []); + groups.get(owner).push(repo); + } - for (const owner of sortedKeys) { - const repos = groups.get(owner); + // Sort repos within each group by latest commit (newest first) + for (const repos of groups.values()) { + repos.sort((a, b) => commitTimestamp(b) - commitTimestamp(a)); + } - // Group header - const groupEl = createElement('div', 'github-group'); - const groupHeader = createElement('div', 'github-group-header'); - groupHeader.innerHTML = ` + // Sort groups by most recent commit in each group (login first on ties) + const sortedKeys = [...groups.keys()].sort((a, b) => { + const aRepos = groups.get(a) || []; + const bRepos = groups.get(b) || []; + const aMax = aRepos.length > 0 ? commitTimestamp(aRepos[0]) : 0; + const bMax = bRepos.length > 0 ? commitTimestamp(bRepos[0]) : 0; + if (aMax !== bMax) return bMax - aMax; + if (a === this._login) return -1; + if (b === this._login) return 1; + return a.localeCompare(b); + }); + + for (const owner of sortedKeys) { + const repos = groups.get(owner); + + // Group header + const groupEl = createElement("div", "github-group"); + const groupHeader = createElement("div", "github-group-header"); + groupHeader.innerHTML = ` ${escapeHtml(owner)} ${repos.length} `; - groupEl.appendChild(groupHeader); + groupEl.appendChild(groupHeader); - // Repo cards - for (const repo of repos) { - groupEl.appendChild(this._renderRepoCard(repo)); - } - - this._listEl.appendChild(groupEl); + // Repo cards + for (const repo of repos) { + groupEl.appendChild(this._renderRepoCard(repo)); } + + this._listEl.appendChild(groupEl); + } } /** @@ -283,135 +306,145 @@ class GitHubViewerInstance { * @returns {HTMLElement} */ _renderRepoCard(repo) { - const card = createElement('div', 'github-repo-card'); - card.addEventListener('click', () => { - if (repo.htmlURL) window.open(repo.htmlURL, '_blank'); - }); + const card = createElement("div", "github-repo-card"); + card.addEventListener("click", () => { + if (repo.htmlURL) window.open(repo.htmlURL, "_blank"); + }); - // Top row: name + badges - const topRow = createElement('div', 'github-repo-top'); - const nameEl = createElement('span', 'github-repo-name'); - nameEl.textContent = repo.name; - topRow.appendChild(nameEl); + // Top row: name + badges + const topRow = createElement("div", "github-repo-top"); + const nameEl = createElement("span", "github-repo-name"); + nameEl.textContent = repo.name; + topRow.appendChild(nameEl); + if (repo.private) { + const badge = createElement( + "span", + "github-repo-badge github-repo-private", + ); + badge.textContent = "Private"; + topRow.appendChild(badge); + } + if (repo.fork) { + const badge = createElement( + "span", + "github-repo-badge github-repo-fork", + ); + badge.textContent = "Fork"; + topRow.appendChild(badge); + } + if (repo.archived) { + const badge = createElement( + "span", + "github-repo-badge github-repo-archived", + ); + badge.textContent = "Archived"; + topRow.appendChild(badge); + } - if (repo.private) { - const badge = createElement('span', 'github-repo-badge github-repo-private'); - badge.textContent = 'Private'; - topRow.appendChild(badge); - } - if (repo.fork) { - const badge = createElement('span', 'github-repo-badge github-repo-fork'); - badge.textContent = 'Fork'; - topRow.appendChild(badge); - } - if (repo.archived) { - const badge = createElement('span', 'github-repo-badge github-repo-archived'); - badge.textContent = 'Archived'; - topRow.appendChild(badge); + const actions = createElement("div", "github-repo-actions"); + + const downloadBtn = createElement("button", "github-repo-download", { + textContent: "ZIP", + title: "Download repository as zip", + }); + downloadBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this._downloadRepo(repo); + }); + actions.appendChild(downloadBtn); + + const visitLink = createElement("button", "github-repo-visit", { + textContent: "Visit", + title: "Open repository in browser", + }); + visitLink.addEventListener("click", (e) => { + e.stopPropagation(); + if (repo.htmlURL) { + if (window.runtime && window.runtime.BrowserOpenURL) { + window.runtime.BrowserOpenURL(repo.htmlURL); + } else { + window.open(repo.htmlURL, "_blank"); + } } + }); + actions.appendChild(visitLink); - const actions = createElement('div', 'github-repo-actions'); + topRow.appendChild(actions); - const downloadBtn = createElement('button', 'github-repo-download', { - textContent: 'ZIP', - title: 'Download repository as zip', - }); - downloadBtn.addEventListener('click', (e) => { - e.stopPropagation(); - this._downloadRepo(repo); - }); - actions.appendChild(downloadBtn); + card.appendChild(topRow); - const visitLink = createElement('button', 'github-repo-visit', { - textContent: 'Visit', - title: 'Open repository in browser', - }); - visitLink.addEventListener('click', (e) => { - e.stopPropagation(); - if (repo.htmlURL) { - if (window.runtime && window.runtime.BrowserOpenURL) { - window.runtime.BrowserOpenURL(repo.htmlURL); - } else { - window.open(repo.htmlURL, '_blank'); - } - } - }); - actions.appendChild(visitLink); + if (repo.latestCommitter && repo.latestCommitAt) { + const commitLine = createElement("div", "github-repo-commit"); + const when = new Date(repo.latestCommitAt); + const whenText = isNaN(when.getTime()) + ? repo.latestCommitAt + : when.toLocaleString(); + commitLine.textContent = `Last commit: ${whenText} by ${repo.latestCommitter}`; + card.appendChild(commitLine); + } - topRow.appendChild(actions); + // Description + if (repo.description) { + const desc = createElement("div", "github-repo-desc"); + desc.textContent = repo.description; + card.appendChild(desc); + } - card.appendChild(topRow); + // Meta row: language, stars, pushed + const meta = createElement("div", "github-repo-meta"); - if (repo.latestCommitter && repo.latestCommitAt) { - const commitLine = createElement('div', 'github-repo-commit'); - const when = new Date(repo.latestCommitAt); - const whenText = isNaN(when.getTime()) ? repo.latestCommitAt : when.toLocaleString(); - commitLine.textContent = `Last commit: ${whenText} by ${repo.latestCommitter}`; - card.appendChild(commitLine); - } + if (repo.language) { + const lang = createElement("span", "github-repo-lang"); + lang.innerHTML = ` ${escapeHtml(repo.language)}`; + meta.appendChild(lang); + } - // Description - if (repo.description) { - const desc = createElement('div', 'github-repo-desc'); - desc.textContent = repo.description; - card.appendChild(desc); - } + if (repo.stars > 0) { + const stars = createElement("span", "github-repo-stars"); + stars.textContent = `★ ${repo.stars.toLocaleString()}`; + meta.appendChild(stars); + } - // Meta row: language, stars, pushed - const meta = createElement('div', 'github-repo-meta'); + if (repo.pushedAt) { + const pushed = createElement("span", "github-repo-pushed"); + pushed.textContent = relativeTime(repo.pushedAt); + pushed.title = new Date(repo.pushedAt).toLocaleString(); + meta.appendChild(pushed); + } - if (repo.language) { - const lang = createElement('span', 'github-repo-lang'); - lang.innerHTML = ` ${escapeHtml(repo.language)}`; - meta.appendChild(lang); - } + card.appendChild(meta); - if (repo.stars > 0) { - const stars = createElement('span', 'github-repo-stars'); - stars.textContent = `★ ${repo.stars.toLocaleString()}`; - meta.appendChild(stars); - } - - if (repo.pushedAt) { - const pushed = createElement('span', 'github-repo-pushed'); - pushed.textContent = relativeTime(repo.pushedAt); - pushed.title = new Date(repo.pushedAt).toLocaleString(); - meta.appendChild(pushed); - } - - card.appendChild(meta); - - return card; + return card; } async _downloadRepo(repo) { - if (!this._statusEl) return; - const ref = repo.defaultBranch || ''; - this._statusEl.classList.remove('github-viewer-status-error'); - this._statusEl.textContent = `Downloading ${repo.fullName}...`; + if (!this._statusEl) return; + const ref = repo.defaultBranch || ""; + this._statusEl.classList.remove("github-viewer-status-error"); + this._statusEl.textContent = `Downloading ${repo.fullName}...`; - try { - await window.go.api.Service.DownloadGitHubRepoArchive( - this.sessionID, - repo.fullName, - ref, - ); - this._updateStatus(); - } catch (err) { - this._statusEl.textContent = `⚠ Download failed: ${err}`; - this._statusEl.classList.add('github-viewer-status-error'); - } + try { + await window.go.api.Service.DownloadGitHubRepoArchive( + this.sessionID, + repo.fullName, + ref, + ); + this._updateStatus(); + } catch (err) { + this._statusEl.textContent = `⚠ Download failed: ${err}`; + this._statusEl.classList.add("github-viewer-status-error"); + } } async _manualRefresh() { - if (!this._statusEl) return; - this._statusEl.textContent = 'Refreshing…'; - try { - await window.go.api.Service.RefreshGitHubRepos(this.sessionID); - } catch (err) { - this.showError(err.message || 'Refresh failed'); - } + if (!this._statusEl) return; + this._statusEl.textContent = "Refreshing…"; + try { + await window.go.api.Service.RefreshGitHubRepos(this.sessionID); + } catch (err) { + this.showError(err.message || "Refresh failed"); + } } /** @@ -421,33 +454,33 @@ class GitHubViewerInstance { * @returns {string} CSS colour value. */ _langColour(lang) { - const colours = { - 'JavaScript': '#f1e05a', - 'TypeScript': '#3178c6', - 'Python': '#3572A5', - 'Go': '#00ADD8', - 'Rust': '#dea584', - 'Java': '#b07219', - 'C': '#555555', - 'C++': '#f34b7d', - 'C#': '#178600', - 'Ruby': '#701516', - 'PHP': '#4F5D95', - 'Swift': '#F05138', - 'Kotlin': '#A97BFF', - 'Shell': '#89e051', - 'HTML': '#e34c26', - 'CSS': '#563d7c', - 'Dart': '#00B4AB', - 'Lua': '#000080', - 'Scala': '#c22d40', - 'Elixir': '#6e4a7e', - 'Haskell': '#5e5086', - 'Zig': '#ec915c', - 'Vue': '#41b883', - 'SCSS': '#c6538c', - }; - return colours[lang] || '#8b949e'; + const colours = { + JavaScript: "#f1e05a", + TypeScript: "#3178c6", + Python: "#3572A5", + Go: "#00ADD8", + Rust: "#dea584", + Java: "#b07219", + C: "#555555", + "C++": "#f34b7d", + "C#": "#178600", + Ruby: "#701516", + PHP: "#4F5D95", + Swift: "#F05138", + Kotlin: "#A97BFF", + Shell: "#89e051", + HTML: "#e34c26", + CSS: "#563d7c", + Dart: "#00B4AB", + Lua: "#000080", + Scala: "#c22d40", + Elixir: "#6e4a7e", + Haskell: "#5e5086", + Zig: "#ec915c", + Vue: "#41b883", + SCSS: "#c6538c", + }; + return colours[lang] || "#8b949e"; } /** @@ -455,11 +488,10 @@ class GitHubViewerInstance { * @returns {string} */ _githubSVG() { - return ''; + return ''; } -} - -window.NexusTerminal = window.NexusTerminal || {}; -window.NexusTerminal.GitHubViewer = new GitHubViewer(); + } + window.NexusTerminal = window.NexusTerminal || {}; + window.NexusTerminal.GitHubViewer = new GitHubViewer(); })(); diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js index e0cc5c3..793e4e6 100644 --- a/frontend/src/js/profiles.js +++ b/frontend/src/js/profiles.js @@ -1,87 +1,87 @@ -(function() { -/** - * Nexus Terminal — connection profile editor. - * Renders a slide-out drawer for creating and editing connection profiles. - * Dynamically switches form sections based on the selected protocol. - */ +(function () { + /** + * Nexus Terminal — connection profile editor. + * Renders a slide-out drawer for creating and editing connection profiles. + * Dynamically switches form sections based on the selected protocol. + */ -const { createElement, escapeHtml, generateId, $ } = window.NexusTerminal; + const { createElement, escapeHtml, generateId, $ } = window.NexusTerminal; -/** - * Default values mirroring the Go backend defaults. - */ -const DEFAULTS = { + /** + * Default values mirroring the Go backend defaults. + */ + const DEFAULTS = { ssh: { - port: 22, - protocol: 'ssh', - ssh: { - authMethods: ['publickey', 'password', 'keyboard-interactive'], - secretMode: 'saved', - keySecretMode: 'saved', - hostKeyPolicy: 'ask', - terminalType: 'xterm-256color', - keepaliveInterval: 30, - keepaliveCountMax: 3, - compression: false, - jumpHosts: [], - portForwards: [], - startupCommand: '', - envVars: {}, - loginScripts: [], - }, + port: 22, + protocol: "ssh", + ssh: { + authMethods: ["publickey", "password", "keyboard-interactive"], + secretMode: "saved", + keySecretMode: "saved", + hostKeyPolicy: "ask", + terminalType: "xterm-256color", + keepaliveInterval: 30, + keepaliveCountMax: 3, + compression: false, + jumpHosts: [], + portForwards: [], + startupCommand: "", + envVars: {}, + loginScripts: [], + }, }, telnet: { - port: 23, - protocol: 'telnet', - telnet: { - localEcho: false, - lineEnding: 'crlf', - terminalType: 'xterm-256color', - encoding: 'utf-8', - negotiationDebug: false, - lineMode: false, - loginScripts: [], - }, + port: 23, + protocol: "telnet", + telnet: { + localEcho: false, + lineEnding: "crlf", + terminalType: "xterm-256color", + encoding: "utf-8", + negotiationDebug: false, + lineMode: false, + loginScripts: [], + }, }, raw: { - port: 23, - protocol: 'raw', - raw: { - autoReconnect: false, - lineEnding: 'crlf', - encoding: 'utf-8', - showTimestamps: false, - showDirection: false, - payloadPresets: [], - }, + port: 23, + protocol: "raw", + raw: { + autoReconnect: false, + lineEnding: "crlf", + encoding: "utf-8", + showTimestamps: false, + showDirection: false, + payloadPresets: [], + }, }, github: { - port: 443, - protocol: 'github', - host: 'github.com', - github: { - baseURL: 'https://api.github.com', - secretMode: 'saved', - refreshIntervalSecs: 300, - }, + port: 443, + protocol: "github", + host: "github.com", + github: { + baseURL: "https://api.github.com", + secretMode: "saved", + refreshIntervalSecs: 300, + }, }, -}; + }; -class ProfileEditor { + class ProfileEditor { /** * @param {App} app */ constructor(app) { - this.app = app; + this.app = app; - /** @type {HTMLElement|null} */ - this.drawer = null; + /** @type {HTMLElement|null} */ + this.drawer = null; - /** @type {Object|null} The profile currently being edited. */ - this._profile = null; + /** @type {Object|null} The profile currently being edited. */ + this._profile = null; - /** @type {boolean} */ - this._isNew = false; + /** @type {boolean} */ + this._isNew = false; } // ── Public API ─────────────────────────────────────────────────── @@ -90,11 +90,11 @@ class ProfileEditor { * Opens the editor with a blank profile of the given protocol. * @param {string} [protocol='ssh'] */ - openNew(protocol = 'ssh') { - const profile = this._defaultProfile(protocol); - this._isNew = true; - this._profile = profile; - this.render(profile, true); + openNew(protocol = "ssh") { + const profile = this._defaultProfile(protocol); + this._isNew = true; + this._profile = profile; + this.render(profile, true); } /** @@ -102,14 +102,14 @@ class ProfileEditor { * @param {string} profileId */ openEdit(profileId) { - const profile = this.app.state.getProfile(profileId); - if (!profile) { - this.app.notifications.error('Profile not found'); - return; - } - this._isNew = false; - this._profile = JSON.parse(JSON.stringify(profile)); // deep clone - this.render(this._profile, false); + const profile = this.app.state.getProfile(profileId); + if (!profile) { + this.app.notifications.error("Profile not found"); + return; + } + this._isNew = false; + this._profile = JSON.parse(JSON.stringify(profile)); // deep clone + this.render(this._profile, false); } /** @@ -118,120 +118,138 @@ class ProfileEditor { * @param {boolean} isNew */ render(profile, isNew) { - this.close(); + this.close(); - const overlay = createElement('div', 'drawer-backdrop'); - const drawer = createElement('div', 'drawer'); + const overlay = createElement("div", "drawer-backdrop"); + const drawer = createElement("div", "drawer"); - // ── Header ────────────────────────────────────────────── - const header = createElement('div', 'drawer-header'); - header.innerHTML = ` -

${isNew ? 'New Connection' : 'Edit Connection'}

+ // ── Header ────────────────────────────────────────────── + const header = createElement("div", "drawer-header"); + header.innerHTML = ` +

${isNew ? "New Connection" : "Edit Connection"}

`; - drawer.appendChild(header); + drawer.appendChild(header); - // ── Protocol selector tabs ────────────────────────────── - const protocols = ['ssh', 'telnet', 'raw', 'github']; - const tabBar = createElement('div', 'drawer-protocol-tabs'); - for (const proto of protocols) { - const label = proto === 'github' ? 'GitHub' : proto.toUpperCase(); - const btn = createElement('button', `drawer-proto-tab ${profile.protocol === proto ? 'active' : ''}`, { - textContent: label, - dataset: { proto }, - }); - btn.addEventListener('click', () => this.handleProtocolChange(proto)); - tabBar.appendChild(btn); - } - drawer.appendChild(tabBar); + // ── Protocol selector tabs ────────────────────────────── + const protocols = ["ssh", "telnet", "raw", "github"]; + const tabBar = createElement("div", "drawer-protocol-tabs"); + for (const proto of protocols) { + const label = proto === "github" ? "GitHub" : proto.toUpperCase(); + const btn = createElement( + "button", + `drawer-proto-tab ${profile.protocol === proto ? "active" : ""}`, + { + textContent: label, + dataset: { proto }, + }, + ); + btn.addEventListener("click", () => this.handleProtocolChange(proto)); + tabBar.appendChild(btn); + } + drawer.appendChild(tabBar); - // ── Scrollable body ───────────────────────────────────── - const body = createElement('div', 'drawer-body'); + // ── Scrollable body ───────────────────────────────────── + const body = createElement("div", "drawer-body"); - // General section (common to all protocols) - body.appendChild(this._renderGeneralSection(profile)); + // General section (common to all protocols) + body.appendChild(this._renderGeneralSection(profile)); - // Protocol-specific sections - switch (profile.protocol) { - case 'ssh': - this._renderSSHSections(profile).forEach((s) => body.appendChild(s)); - break; - case 'telnet': - this._renderTelnetSections(profile).forEach((s) => body.appendChild(s)); - break; - case 'raw': - this._renderRAWSections(profile).forEach((s) => body.appendChild(s)); - break; - case 'github': - this._renderGitHubSections(profile).forEach((s) => body.appendChild(s)); - break; - } + // Protocol-specific sections + switch (profile.protocol) { + case "ssh": + this._renderSSHSections(profile).forEach((s) => body.appendChild(s)); + break; + case "telnet": + this._renderTelnetSections(profile).forEach((s) => + body.appendChild(s), + ); + break; + case "raw": + this._renderRAWSections(profile).forEach((s) => body.appendChild(s)); + break; + case "github": + this._renderGitHubSections(profile).forEach((s) => + body.appendChild(s), + ); + break; + } - // Terminal section (SSH and Telnet only) - if (profile.protocol !== 'raw' && profile.protocol !== 'github') { - body.appendChild(this._renderTerminalSection(profile)); - } + // Terminal section (SSH and Telnet only) + if (profile.protocol !== "raw" && profile.protocol !== "github") { + body.appendChild(this._renderTerminalSection(profile)); + } - // Notes & Tags section (common) - body.appendChild(this._renderNotesSection(profile)); + // Notes & Tags section (common) + body.appendChild(this._renderNotesSection(profile)); - drawer.appendChild(body); + drawer.appendChild(body); - // ── Footer ────────────────────────────────────────────── - const footer = createElement('div', 'drawer-footer'); - const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' }); - const testBtn = createElement('button', 'btn btn-outline', { textContent: 'Test' }); - const saveBtn = createElement('button', 'btn btn-primary', { textContent: isNew ? 'Create' : 'Save' }); - const connectBtn = createElement('button', 'btn btn-accent', { textContent: isNew ? 'Create & Connect' : 'Save & Connect' }); - const deleteBtn = !isNew - ? createElement('button', 'btn btn-danger', { textContent: 'Delete' }) - : null; + // ── Footer ────────────────────────────────────────────── + const footer = createElement("div", "drawer-footer"); + const cancelBtn = createElement("button", "btn btn-secondary", { + textContent: "Cancel", + }); + const testBtn = createElement("button", "btn btn-outline", { + textContent: "Test", + }); + const saveBtn = createElement("button", "btn btn-primary", { + textContent: isNew ? "Create" : "Save", + }); + const connectBtn = createElement("button", "btn btn-accent", { + textContent: isNew ? "Create & Connect" : "Save & Connect", + }); + const deleteBtn = !isNew + ? createElement("button", "btn btn-danger", { textContent: "Delete" }) + : null; - cancelBtn.addEventListener('click', () => this.close()); - testBtn.addEventListener('click', () => this._testConnection()); - saveBtn.addEventListener('click', () => this._handleSave(false)); - connectBtn.addEventListener('click', () => this._handleSave(true)); - if (deleteBtn) { - deleteBtn.addEventListener('click', () => this._handleDelete()); - } + cancelBtn.addEventListener("click", () => this.close()); + testBtn.addEventListener("click", () => this._testConnection()); + saveBtn.addEventListener("click", () => this._handleSave(false)); + connectBtn.addEventListener("click", () => this._handleSave(true)); + if (deleteBtn) { + deleteBtn.addEventListener("click", () => this._handleDelete()); + } - if (deleteBtn) footer.appendChild(deleteBtn); - footer.appendChild(cancelBtn); - footer.appendChild(testBtn); - footer.appendChild(saveBtn); - footer.appendChild(connectBtn); - drawer.appendChild(footer); + if (deleteBtn) footer.appendChild(deleteBtn); + footer.appendChild(cancelBtn); + footer.appendChild(testBtn); + footer.appendChild(saveBtn); + footer.appendChild(connectBtn); + drawer.appendChild(footer); - // ── Events ────────────────────────────────────────────── - header.querySelector('.drawer-close').addEventListener('click', () => this.close()); - overlay.addEventListener('click', () => this.close()); + // ── Events ────────────────────────────────────────────── + header + .querySelector(".drawer-close") + .addEventListener("click", () => this.close()); + overlay.addEventListener("click", () => this.close()); - overlay.appendChild(drawer); - document.body.appendChild(overlay); - this.drawer = overlay; + overlay.appendChild(drawer); + document.body.appendChild(overlay); + this.drawer = overlay; - // Trigger visibility after append so CSS transitions animate - requestAnimationFrame(() => { - overlay.classList.add('is-visible'); - drawer.classList.add('is-visible'); - }); + // Trigger visibility after append so CSS transitions animate + requestAnimationFrame(() => { + overlay.classList.add("is-visible"); + drawer.classList.add("is-visible"); + }); - // Prevent overlay click from reaching through the drawer - drawer.addEventListener('click', (e) => e.stopPropagation()); + // Prevent overlay click from reaching through the drawer + drawer.addEventListener("click", (e) => e.stopPropagation()); - // Focus the first input - const firstInput = drawer.querySelector('input, select, textarea'); - if (firstInput) firstInput.focus(); + // Focus the first input + const firstInput = drawer.querySelector("input, select, textarea"); + if (firstInput) firstInput.focus(); } /** * Closes the editor drawer. */ close() { - if (this.drawer) { - this.drawer.remove(); - this.drawer = null; - } + if (this.drawer) { + this.drawer.remove(); + this.drawer = null; + } } /** @@ -239,46 +257,69 @@ class ProfileEditor { * @param {string} protocol */ handleProtocolChange(protocol) { - if (this._profile.protocol === protocol) return; + if (this._profile.protocol === protocol) return; - // Preserve common fields, reset protocol-specific ones - this._collectFormValues(); - const base = this._defaultProfile(protocol); - base.id = this._profile.id; - base.displayName = this._profile.displayName; - base.description = this._profile.description; - base.host = this._profile.host; - base.username = this._profile.username; - base.tags = this._profile.tags; - base.notes = this._profile.notes; - base.favourite = this._profile.favourite; + // Preserve common fields, reset protocol-specific ones + this._collectFormValues(); + const base = this._defaultProfile(protocol); + base.id = this._profile.id; + base.displayName = this._profile.displayName; + base.description = this._profile.description; + base.host = this._profile.host; + base.username = this._profile.username; + base.tags = this._profile.tags; + base.notes = this._profile.notes; + base.favourite = this._profile.favourite; - this._profile = base; - this.render(this._profile, this._isNew); + this._profile = base; + this.render(this._profile, this._isNew); } // ── Section renderers ──────────────────────────────────────────── /** @private */ _renderGeneralSection(profile) { - const fields = [ - this._field('text', 'displayName', 'Display Name', profile.displayName, { placeholder: 'My Server' }), - this._field('text', 'host', 'Host', profile.host, { - placeholder: profile.protocol === 'github' ? 'github.com' : '192.168.1.1 or example.com', - required: true, - }), - ]; + const fields = [ + this._field( + "text", + "displayName", + "Display Name", + profile.displayName, + { placeholder: "My Server" }, + ), + this._field("text", "host", "Host", profile.host, { + placeholder: + profile.protocol === "github" + ? "github.com" + : "192.168.1.1 or example.com", + required: true, + }), + ]; - // Port and username not relevant for GitHub connections - if (profile.protocol !== 'github') { - fields.push(this._field('number', 'port', 'Port', profile.port, { min: 1, max: 65535, required: true })); - fields.push(this._field('text', 'username', 'Username', profile.username, { placeholder: 'root' })); - } + // Port and username not relevant for GitHub connections + if (profile.protocol !== "github") { + fields.push( + this._field("number", "port", "Port", profile.port, { + min: 1, + max: 65535, + required: true, + }), + ); + fields.push( + this._field("text", "username", "Username", profile.username, { + placeholder: "root", + }), + ); + } - fields.push(this._renderGroupField(profile)); - fields.push(this._field('text', 'description', 'Description', profile.description, { placeholder: 'Optional description' })); + fields.push(this._renderGroupField(profile)); + fields.push( + this._field("text", "description", "Description", profile.description, { + placeholder: "Optional description", + }), + ); - return this._renderSection('General', fields); + return this._renderSection("General", fields); } /** @@ -289,315 +330,611 @@ class ProfileEditor { * @private */ _renderGroupField(profile) { - const group = createElement('div', 'form-group'); - const label = createElement('label', 'form-label', { - textContent: 'Group', - for: 'field-folder', - }); - group.appendChild(label); + const group = createElement("div", "form-group"); + const label = createElement("label", "form-label", { + textContent: "Group", + for: "field-folder", + }); + group.appendChild(label); - // Collect distinct group names from all profiles - const existingGroups = [...new Set( - this.app.state.profiles - .map((p) => (p.folder || '').trim()) - .filter(Boolean) - )].sort((a, b) => a.localeCompare(b)); + // Collect distinct group names from all profiles + const existingGroups = [ + ...new Set( + this.app.state.profiles + .map((p) => (p.folder || "").trim()) + .filter(Boolean), + ), + ].sort((a, b) => a.localeCompare(b)); - // Create the datalist so the browser offers autocomplete - const listId = 'folder-suggestions'; - const datalist = document.createElement('datalist'); - datalist.id = listId; - for (const g of existingGroups) { - const opt = document.createElement('option'); - opt.value = g; - datalist.appendChild(opt); - } - group.appendChild(datalist); + // Create the datalist so the browser offers autocomplete + const listId = "folder-suggestions"; + const datalist = document.createElement("datalist"); + datalist.id = listId; + for (const g of existingGroups) { + const opt = document.createElement("option"); + opt.value = g; + datalist.appendChild(opt); + } + group.appendChild(datalist); - const input = createElement('input', 'form-input', { - type: 'text', - id: 'field-folder', - name: 'folder', - value: profile.folder || '', - placeholder: 'e.g. Production, Staging, Home Lab', - }); - input.setAttribute('list', listId); - input.setAttribute('autocomplete', 'off'); - group.appendChild(input); + const input = createElement("input", "form-input", { + type: "text", + id: "field-folder", + name: "folder", + value: profile.folder || "", + placeholder: "e.g. Production, Staging, Home Lab", + }); + input.setAttribute("list", listId); + input.setAttribute("autocomplete", "off"); + group.appendChild(input); - const hint = createElement('span', 'form-hint', { - textContent: 'Choose an existing group or type a new name to create one', - }); - group.appendChild(hint); + const hint = createElement("span", "form-hint", { + textContent: + "Choose an existing group or type a new name to create one", + }); + group.appendChild(hint); - return group; + return group; } /** @private */ _renderSSHSections(profile) { - const ssh = profile.ssh || {}; - const sections = []; + const ssh = profile.ssh || {}; + const sections = []; - // Authentication - sections.push(this._renderSection('Authentication', [ - this._field('select', 'ssh.secretMode', 'Password Mode', ssh.secretMode, { + // Authentication + sections.push( + this._renderSection("Authentication", [ + this._field( + "select", + "ssh.secretMode", + "Password Mode", + ssh.secretMode, + { + options: [ + { value: "saved", label: "Saved in vault" }, + { value: "session-only", label: "Session only" }, + { value: "ask-every-time", label: "Ask every time" }, + ], + }, + ), + this._field( + "tags", + "ssh.authMethods", + "Auth Methods", + (ssh.authMethods || []).join(", "), + { + help: "Comma-separated: publickey, password, keyboard-interactive", + }, + ), + ]), + ); + + // Key & Passphrase + sections.push( + this._renderSection("Key & Passphrase", [ + this._field( + "text", + "ssh.privateKeyPath", + "Private Key Path", + ssh.privateKeyPath, + { + placeholder: "~/.ssh/id_ed25519", + }, + ), + this._field( + "select", + "ssh.keySecretMode", + "Passphrase Mode", + ssh.keySecretMode, + { + options: [ + { value: "saved", label: "Saved in vault" }, + { value: "session-only", label: "Session only" }, + { value: "ask-every-time", label: "Ask every time" }, + ], + }, + ), + ]), + ); + + // Host Verification + sections.push( + this._renderSection("Host Verification", [ + this._field( + "select", + "ssh.hostKeyPolicy", + "Host Key Policy", + ssh.hostKeyPolicy, + { + options: [ + { value: "strict", label: "Strict (reject unknown)" }, + { value: "ask", label: "Ask (prompt on unknown)" }, + { value: "accept-new", label: "Accept new keys" }, + { value: "insecure", label: "Insecure (accept all)" }, + ], + }, + ), + ]), + ); + + // Jump Hosts + sections.push( + this._renderSection( + "Jump Hosts", + [this._renderJumpHostsList(ssh.jumpHosts || [])], + true, + ), + ); + + // Port Forwarding + sections.push( + this._renderSection( + "Port Forwarding", + [this._renderPortForwardsList(ssh.portForwards || [])], + true, + ), + ); + + // Startup + sections.push( + this._renderSection("Startup", [ + this._field( + "text", + "ssh.startupCommand", + "Startup Command", + ssh.startupCommand, + { + placeholder: "Optional command to run after connecting", + }, + ), + this._field( + "textarea", + "ssh.envVarsText", + "Environment Variables", + this._envVarsToText(ssh.envVars), + { + placeholder: "KEY=VALUE (one per line)", + rows: 3, + }, + ), + ]), + ); + + // Login Scripts (expect-style) + sections.push( + this._renderLoginScriptSection("ssh", ssh.loginScripts || []), + ); + + // SSH Agent + sections.push( + this._renderSection( + "SSH Agent", + [ + this._field( + "toggle", + "ssh.agentAuth", + "Use SSH Agent for Authentication", + ssh.agentAuth, + { + help: "Authenticate using keys from the local SSH agent (ssh-agent)", + }, + ), + this._field( + "toggle", + "ssh.agentForwarding", + "Forward Agent to Remote", + ssh.agentForwarding, + { + help: "Allow the remote host to use your local SSH agent keys (equivalent to ssh -A)", + }, + ), + ], + true, + ), + ); + + // TOTP / 2FA + sections.push(this._renderTOTPSection(profile)); + + // Proxy + sections.push( + this._renderSection( + "Proxy", + [ + this._field( + "select", + "ssh.proxyType", + "Proxy Type", + ssh.proxyType || "none", + { options: [ - { value: 'saved', label: 'Saved in vault' }, - { value: 'session-only', label: 'Session only' }, - { value: 'ask-every-time', label: 'Ask every time' }, + { value: "none", label: "None (direct connection)" }, + { value: "socks5", label: "SOCKS5" }, + { value: "http", label: "HTTP CONNECT" }, ], - }), - this._field('tags', 'ssh.authMethods', 'Auth Methods', (ssh.authMethods || []).join(', '), { - help: 'Comma-separated: publickey, password, keyboard-interactive', - }), - ])); + }, + ), + this._field( + "text", + "ssh.proxyHost", + "Proxy Host", + ssh.proxyHost || "", + { + placeholder: "proxy.example.com", + }, + ), + this._field( + "number", + "ssh.proxyPort", + "Proxy Port", + ssh.proxyPort || 1080, + { min: 1, max: 65535 }, + ), + this._field( + "text", + "ssh.proxyUsername", + "Proxy Username", + ssh.proxyUsername || "", + { + placeholder: "Optional", + }, + ), + this._field( + "text", + "ssh.proxyPassword", + "Proxy Password", + ssh.proxyPassword || "", + { + placeholder: "Optional", + }, + ), + ], + true, + ), + ); - // Key & Passphrase - sections.push(this._renderSection('Key & Passphrase', [ - this._field('text', 'ssh.privateKeyPath', 'Private Key Path', ssh.privateKeyPath, { - placeholder: '~/.ssh/id_ed25519', - }), - this._field('select', 'ssh.keySecretMode', 'Passphrase Mode', ssh.keySecretMode, { - options: [ - { value: 'saved', label: 'Saved in vault' }, - { value: 'session-only', label: 'Session only' }, - { value: 'ask-every-time', label: 'Ask every time' }, - ], - }), - ])); + // Advanced + sections.push( + this._renderSection( + "Advanced", + [ + this._field( + "text", + "ssh.terminalType", + "Terminal Type", + ssh.terminalType, + { placeholder: "xterm-256color" }, + ), + this._field( + "number", + "ssh.keepaliveInterval", + "Keepalive Interval (s)", + ssh.keepaliveInterval, + { min: 0 }, + ), + this._field( + "number", + "ssh.keepaliveCountMax", + "Max Missed Keepalives", + ssh.keepaliveCountMax, + { min: 0 }, + ), + this._field( + "toggle", + "ssh.compression", + "Compression", + ssh.compression, + ), + this._field( + "toggle", + "ssh.autoReconnect", + "Auto-Reconnect", + ssh.autoReconnect, + { + help: "Automatically reconnect with exponential backoff when the connection drops", + }, + ), + this._field( + "number", + "ssh.maxReconnectTries", + "Max Reconnect Attempts", + ssh.maxReconnectTries || 5, + { min: 1, max: 20 }, + ), + ], + true, + ), + ); - // Host Verification - sections.push(this._renderSection('Host Verification', [ - this._field('select', 'ssh.hostKeyPolicy', 'Host Key Policy', ssh.hostKeyPolicy, { - options: [ - { value: 'strict', label: 'Strict (reject unknown)' }, - { value: 'ask', label: 'Ask (prompt on unknown)' }, - { value: 'accept-new', label: 'Accept new keys' }, - { value: 'insecure', label: 'Insecure (accept all)' }, - ], - }), - ])); - - // Jump Hosts - sections.push(this._renderSection('Jump Hosts', [ - this._renderJumpHostsList(ssh.jumpHosts || []), - ], true)); - - // Port Forwarding - sections.push(this._renderSection('Port Forwarding', [ - this._renderPortForwardsList(ssh.portForwards || []), - ], true)); - - // Startup - sections.push(this._renderSection('Startup', [ - this._field('text', 'ssh.startupCommand', 'Startup Command', ssh.startupCommand, { - placeholder: 'Optional command to run after connecting', - }), - this._field('textarea', 'ssh.envVarsText', 'Environment Variables', this._envVarsToText(ssh.envVars), { - placeholder: 'KEY=VALUE (one per line)', - rows: 3, - }), - ])); - - // Login Scripts (expect-style) - sections.push(this._renderLoginScriptSection('ssh', ssh.loginScripts || [])); - - // SSH Agent - sections.push(this._renderSection('SSH Agent', [ - this._field('toggle', 'ssh.agentAuth', 'Use SSH Agent for Authentication', ssh.agentAuth, { - help: 'Authenticate using keys from the local SSH agent (ssh-agent)', - }), - this._field('toggle', 'ssh.agentForwarding', 'Forward Agent to Remote', ssh.agentForwarding, { - help: 'Allow the remote host to use your local SSH agent keys (equivalent to ssh -A)', - }), - ], true)); - - // TOTP / 2FA - sections.push(this._renderTOTPSection(profile)); - - // Proxy - sections.push(this._renderSection('Proxy', [ - this._field('select', 'ssh.proxyType', 'Proxy Type', ssh.proxyType || 'none', { - options: [ - { value: 'none', label: 'None (direct connection)' }, - { value: 'socks5', label: 'SOCKS5' }, - { value: 'http', label: 'HTTP CONNECT' }, - ], - }), - this._field('text', 'ssh.proxyHost', 'Proxy Host', ssh.proxyHost || '', { - placeholder: 'proxy.example.com', - }), - this._field('number', 'ssh.proxyPort', 'Proxy Port', ssh.proxyPort || 1080, { min: 1, max: 65535 }), - this._field('text', 'ssh.proxyUsername', 'Proxy Username', ssh.proxyUsername || '', { - placeholder: 'Optional', - }), - this._field('text', 'ssh.proxyPassword', 'Proxy Password', ssh.proxyPassword || '', { - placeholder: 'Optional', - }), - ], true)); - - // Advanced - sections.push(this._renderSection('Advanced', [ - this._field('text', 'ssh.terminalType', 'Terminal Type', ssh.terminalType, { placeholder: 'xterm-256color' }), - this._field('number', 'ssh.keepaliveInterval', 'Keepalive Interval (s)', ssh.keepaliveInterval, { min: 0 }), - this._field('number', 'ssh.keepaliveCountMax', 'Max Missed Keepalives', ssh.keepaliveCountMax, { min: 0 }), - this._field('toggle', 'ssh.compression', 'Compression', ssh.compression), - this._field('toggle', 'ssh.autoReconnect', 'Auto-Reconnect', ssh.autoReconnect, { - help: 'Automatically reconnect with exponential backoff when the connection drops', - }), - this._field('number', 'ssh.maxReconnectTries', 'Max Reconnect Attempts', ssh.maxReconnectTries || 5, { min: 1, max: 20 }), - ], true)); - - return sections; + return sections; } /** @private */ _renderTelnetSections(profile) { - const tel = profile.telnet || {}; - const sections = []; + const tel = profile.telnet || {}; + const sections = []; - sections.push(this._renderSection('Login', [ - this._field('toggle', 'telnet.localEcho', 'Local Echo', tel.localEcho), - this._field('toggle', 'telnet.lineMode', 'Line Mode', tel.lineMode), - ])); + sections.push( + this._renderSection("Login", [ + this._field( + "toggle", + "telnet.localEcho", + "Local Echo", + tel.localEcho, + ), + this._field("toggle", "telnet.lineMode", "Line Mode", tel.lineMode), + ]), + ); - sections.push(this._renderSection('Terminal & Encoding', [ - this._field('text', 'telnet.terminalType', 'Terminal Type', tel.terminalType, { placeholder: 'xterm-256color' }), - this._field('select', 'telnet.encoding', 'Encoding', tel.encoding, { - options: [ - { value: 'utf-8', label: 'UTF-8' }, - { value: 'ascii', label: 'ASCII' }, - { value: 'iso-8859-1', label: 'ISO 8859-1' }, - ], - }), - ])); + sections.push( + this._renderSection("Terminal & Encoding", [ + this._field( + "text", + "telnet.terminalType", + "Terminal Type", + tel.terminalType, + { placeholder: "xterm-256color" }, + ), + this._field("select", "telnet.encoding", "Encoding", tel.encoding, { + options: [ + { value: "utf-8", label: "UTF-8" }, + { value: "ascii", label: "ASCII" }, + { value: "iso-8859-1", label: "ISO 8859-1" }, + ], + }), + ]), + ); - sections.push(this._renderSection('Echo & Line Endings', [ - this._field('select', 'telnet.lineEnding', 'Line Ending', tel.lineEnding, { - options: [ - { value: 'crlf', label: 'CR+LF (\\r\\n)' }, - { value: 'lf', label: 'LF (\\n)' }, - { value: 'cr', label: 'CR (\\r)' }, - { value: 'none', label: 'None' }, - ], - }), - ])); + sections.push( + this._renderSection("Echo & Line Endings", [ + this._field( + "select", + "telnet.lineEnding", + "Line Ending", + tel.lineEnding, + { + options: [ + { value: "crlf", label: "CR+LF (\\r\\n)" }, + { value: "lf", label: "LF (\\n)" }, + { value: "cr", label: "CR (\\r)" }, + { value: "none", label: "None" }, + ], + }, + ), + ]), + ); - sections.push(this._renderSection('Negotiation', [ - this._field('toggle', 'telnet.negotiationDebug', 'Debug Negotiation', tel.negotiationDebug), - ], true)); + sections.push( + this._renderSection( + "Negotiation", + [ + this._field( + "toggle", + "telnet.negotiationDebug", + "Debug Negotiation", + tel.negotiationDebug, + ), + ], + true, + ), + ); - // Login Scripts (expect-style) - sections.push(this._renderLoginScriptSection('telnet', tel.loginScripts || [])); + // Login Scripts (expect-style) + sections.push( + this._renderLoginScriptSection("telnet", tel.loginScripts || []), + ); - sections.push(this._renderSection('Reconnection', [ - this._field('toggle', 'telnet.autoReconnect', 'Auto-Reconnect', tel.autoReconnect, { - help: 'Automatically reconnect with exponential backoff when the connection drops', - }), - this._field('number', 'telnet.maxReconnectTries', 'Max Reconnect Attempts', tel.maxReconnectTries || 5, { min: 1, max: 20 }), - ], true)); + sections.push( + this._renderSection( + "Reconnection", + [ + this._field( + "toggle", + "telnet.autoReconnect", + "Auto-Reconnect", + tel.autoReconnect, + { + help: "Automatically reconnect with exponential backoff when the connection drops", + }, + ), + this._field( + "number", + "telnet.maxReconnectTries", + "Max Reconnect Attempts", + tel.maxReconnectTries || 5, + { min: 1, max: 20 }, + ), + ], + true, + ), + ); - return sections; + return sections; } /** @private */ _renderRAWSections(profile) { - const raw = profile.raw || {}; - const sections = []; + const raw = profile.raw || {}; + const sections = []; - sections.push(this._renderSection('Encoding & Framing', [ - this._field('select', 'raw.encoding', 'Encoding', raw.encoding, { - options: [ - { value: 'utf-8', label: 'UTF-8' }, - { value: 'ascii', label: 'ASCII' }, - { value: 'hex', label: 'Hex' }, - ], - }), - this._field('select', 'raw.lineEnding', 'Line Ending', raw.lineEnding, { - options: [ - { value: 'crlf', label: 'CR+LF (\\r\\n)' }, - { value: 'lf', label: 'LF (\\n)' }, - { value: 'cr', label: 'CR (\\r)' }, - { value: 'none', label: 'None' }, - ], - }), - ])); + sections.push( + this._renderSection("Encoding & Framing", [ + this._field("select", "raw.encoding", "Encoding", raw.encoding, { + options: [ + { value: "utf-8", label: "UTF-8" }, + { value: "ascii", label: "ASCII" }, + { value: "hex", label: "Hex" }, + ], + }), + this._field( + "select", + "raw.lineEnding", + "Line Ending", + raw.lineEnding, + { + options: [ + { value: "crlf", label: "CR+LF (\\r\\n)" }, + { value: "lf", label: "LF (\\n)" }, + { value: "cr", label: "CR (\\r)" }, + { value: "none", label: "None" }, + ], + }, + ), + ]), + ); - sections.push(this._renderSection('Display', [ - this._field('toggle', 'raw.showTimestamps', 'Show Timestamps', raw.showTimestamps), - this._field('toggle', 'raw.showDirection', 'Show Direction', raw.showDirection), - ])); + sections.push( + this._renderSection("Display", [ + this._field( + "toggle", + "raw.showTimestamps", + "Show Timestamps", + raw.showTimestamps, + ), + this._field( + "toggle", + "raw.showDirection", + "Show Direction", + raw.showDirection, + ), + ]), + ); - sections.push(this._renderSection('Payload Presets', [ - this._renderPayloadPresetsList(raw.payloadPresets || []), - ], true)); + sections.push( + this._renderSection( + "Payload Presets", + [this._renderPayloadPresetsList(raw.payloadPresets || [])], + true, + ), + ); - sections.push(this._renderSection('Timeouts & Reconnect', [ - this._field('number', 'connectTimeout', 'Connect Timeout (s)', profile.connectTimeout, { min: 0 }), - this._field('number', 'readTimeout', 'Read Timeout (s)', profile.readTimeout, { min: 0 }), - this._field('toggle', 'raw.autoReconnect', 'Auto-reconnect', raw.autoReconnect), - ])); + sections.push( + this._renderSection("Timeouts & Reconnect", [ + this._field( + "number", + "connectTimeout", + "Connect Timeout (s)", + profile.connectTimeout, + { min: 0 }, + ), + this._field( + "number", + "readTimeout", + "Read Timeout (s)", + profile.readTimeout, + { min: 0 }, + ), + this._field( + "toggle", + "raw.autoReconnect", + "Auto-reconnect", + raw.autoReconnect, + ), + ]), + ); - return sections; + return sections; } /** @private */ _renderGitHubSections(profile) { - const sections = []; - const gh = profile.github || DEFAULTS.github.github; + const sections = []; + const gh = profile.github || DEFAULTS.github.github; - sections.push(this._renderSection('API', [ - this._field('text', 'github.baseURL', 'API Base URL', gh.baseURL, { - placeholder: 'https://api.github.com', - }), - this._field('select', 'github.secretMode', 'Token Storage', gh.secretMode, { - options: [ - { value: 'saved', label: 'Save to keychain' }, - { value: 'session-only', label: 'Remember for session' }, - { value: 'ask-every-time', label: 'Ask every time' }, - ], - }), - ])); + sections.push( + this._renderSection("API", [ + this._field("text", "github.baseURL", "API Base URL", gh.baseURL, { + placeholder: "https://api.github.com", + }), + this._field( + "select", + "github.secretMode", + "Token Storage", + gh.secretMode, + { + options: [ + { value: "saved", label: "Save to keychain" }, + { value: "session-only", label: "Remember for session" }, + { value: "ask-every-time", label: "Ask every time" }, + ], + }, + ), + ]), + ); - sections.push(this._renderSection('Refresh', [ - this._field('number', 'github.refreshIntervalSecs', 'Refresh Interval (seconds)', gh.refreshIntervalSecs, { - min: 60, max: 3600, - }), - ])); + sections.push( + this._renderSection("Refresh", [ + this._field( + "number", + "github.refreshIntervalSecs", + "Refresh Interval (seconds)", + gh.refreshIntervalSecs, + { + min: 60, + max: 3600, + }, + ), + ]), + ); - return sections; + return sections; } /** @private */ _renderTerminalSection(profile) { - return this._renderSection('Terminal', [ - this._field('text', 'terminalFontFamily', 'Font Family', profile.terminalFontFamily, { - placeholder: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace', - }), - this._field('number', 'terminalFontSize', 'Font Size', profile.terminalFontSize, { min: 8, max: 36 }), - this._field('number', 'terminalScrollback', 'Scrollback Lines', profile.terminalScrollback, { min: 0, max: 100000 }), - this._field('select', 'terminalCursorStyle', 'Cursor Style', profile.terminalCursorStyle, { - options: [ - { value: 'block', label: 'Block' }, - { value: 'underline', label: 'Underline' }, - { value: 'bar', label: 'Bar' }, - ], - }), - ]); + return this._renderSection("Terminal", [ + this._field( + "text", + "terminalFontFamily", + "Font Family", + profile.terminalFontFamily, + { + placeholder: "JetBrains Mono, Menlo, Monaco, Consolas, monospace", + }, + ), + this._field( + "number", + "terminalFontSize", + "Font Size", + profile.terminalFontSize, + { min: 8, max: 36 }, + ), + this._field( + "number", + "terminalScrollback", + "Scrollback Lines", + profile.terminalScrollback, + { min: 0, max: 100000 }, + ), + this._field( + "select", + "terminalCursorStyle", + "Cursor Style", + profile.terminalCursorStyle, + { + options: [ + { value: "block", label: "Block" }, + { value: "underline", label: "Underline" }, + { value: "bar", label: "Bar" }, + ], + }, + ), + ]); } /** @private */ _renderNotesSection(profile) { - return this._renderSection('Notes & Tags', [ - this._field('tags', 'tags', 'Tags', (profile.tags || []).join(', '), { - placeholder: 'production, london, vpn', - help: 'Comma-separated tags for filtering', - }), - this._field('textarea', 'notes', 'Notes', profile.notes, { - placeholder: 'Free-form notes…', - rows: 4, - }), - ]); + return this._renderSection("Notes & Tags", [ + this._field("tags", "tags", "Tags", (profile.tags || []).join(", "), { + placeholder: "production, london, vpn", + help: "Comma-separated tags for filtering", + }), + this._field("textarea", "notes", "Notes", profile.notes, { + placeholder: "Free-form notes…", + rows: 4, + }), + ]); } // ── Form helpers ───────────────────────────────────────────────── @@ -617,158 +954,201 @@ class ProfileEditor { * @private */ _renderTOTPSection(profile) { - const section = createElement('div', 'form-section'); - const header = createElement('div', 'form-section-header'); - let collapsed = true; + const section = createElement("div", "form-section"); + const header = createElement("div", "form-section-header"); + let collapsed = true; - header.innerHTML = ` + header.innerHTML = ` ▸ TOTP / 2FA `; - const content = createElement('div', 'form-section-content'); - content.style.display = 'none'; + const content = createElement("div", "form-section-content"); + content.style.display = "none"; - const helpText = createElement('div', 'form-help', { - textContent: 'Configure a TOTP secret to auto-fill 2FA codes during SSH keyboard-interactive authentication. The secret is stored encrypted.', - }); - content.appendChild(helpText); + const helpText = createElement("div", "form-help", { + textContent: + "Configure a TOTP secret to auto-fill 2FA codes during SSH keyboard-interactive authentication. The secret is stored encrypted.", + }); + content.appendChild(helpText); - // TOTP secret input - const group = createElement('div', 'form-group'); - const label = createElement('label', 'form-label', { textContent: 'TOTP Secret' }); - group.appendChild(label); + // TOTP secret input + const group = createElement("div", "form-group"); + const label = createElement("label", "form-label", { + textContent: "TOTP Secret", + }); + group.appendChild(label); - const row = createElement('div', 'totp-input-row'); + const row = createElement("div", "totp-input-row"); - const secretInput = createElement('input', 'form-input', { - type: 'password', - placeholder: 'Base32-encoded secret (e.g. from authenticator app setup)', - id: 'field-ssh.totpSecret', - dataset: { field: 'ssh.totpSecret' }, - autocapitalize: 'off', - autocorrect: 'off', - spellcheck: false, - }); + const secretInput = createElement("input", "form-input", { + type: "password", + placeholder: + "Base32-encoded secret (e.g. from authenticator app setup)", + id: "field-ssh.totpSecret", + dataset: { field: "ssh.totpSecret" }, + autocapitalize: "off", + autocorrect: "off", + spellcheck: false, + }); - // Show/hide toggle - const toggleBtn = createElement('button', 'btn btn-small btn-secondary totp-toggle', { - textContent: '👁', - title: 'Show/hide secret', - type: 'button', - }); - toggleBtn.addEventListener('mousedown', e => e.preventDefault()); - toggleBtn.addEventListener('click', () => { - secretInput.type = secretInput.type === 'password' ? 'text' : 'password'; - }); + // Show/hide toggle + const toggleBtn = createElement( + "button", + "btn btn-small btn-secondary totp-toggle", + { + textContent: "👁", + title: "Show/hide secret", + type: "button", + }, + ); + toggleBtn.addEventListener("mousedown", (e) => e.preventDefault()); + toggleBtn.addEventListener("click", () => { + secretInput.type = + secretInput.type === "password" ? "text" : "password"; + }); - // Test button — generates a live code - const testBtn = createElement('button', 'btn btn-small btn-accent totp-test', { - textContent: 'Test', - title: 'Generate a code with this secret', - type: 'button', - }); - testBtn.addEventListener('mousedown', e => e.preventDefault()); - testBtn.addEventListener('click', async () => { - const profileId = profile.id; - if (profileId) { - try { - const code = await window.go.api.Service.GetTOTPCode(profileId); - testBtn.textContent = code; - setTimeout(() => { testBtn.textContent = 'Test'; }, 5000); - } catch (err) { - // If no saved secret yet, try computing locally using the input value - testBtn.textContent = 'Save first'; - setTimeout(() => { testBtn.textContent = 'Test'; }, 2000); - } - } - }); - - // Save button - const saveBtn = createElement('button', 'btn btn-small btn-primary totp-save', { - textContent: 'Save', - title: 'Encrypt and save the TOTP secret', - type: 'button', - }); - saveBtn.addEventListener('mousedown', e => e.preventDefault()); - saveBtn.addEventListener('click', async () => { - const profileId = profile.id; - const secret = secretInput.value.trim(); - if (!profileId) { - NexusTerminal.Notifications.show('Save the profile first, then set the TOTP secret.', 'warning'); - return; - } - if (!secret) { - // Delete existing secret - try { - await window.go.api.Service.DeleteTOTPSecret(profileId); - NexusTerminal.Notifications.show('TOTP secret removed.', 'info'); - } catch (err) { - NexusTerminal.Notifications.show('Failed to remove TOTP secret.', 'error'); - } - return; - } - try { - await window.go.api.Service.SetTOTPSecret(profileId, secret); - NexusTerminal.Notifications.show('TOTP secret saved securely.', 'success'); - secretInput.value = ''; - secretInput.placeholder = '••••••••• (secret saved)'; - } catch (err) { - NexusTerminal.Notifications.show('Failed to save TOTP secret: ' + err, 'error'); - } - }); - - // Delete button - const deleteBtn = createElement('button', 'btn btn-small btn-danger totp-delete', { - textContent: 'Delete', - title: 'Remove the TOTP secret', - type: 'button', - }); - deleteBtn.addEventListener('mousedown', e => e.preventDefault()); - deleteBtn.addEventListener('click', async () => { - const profileId = profile.id; - if (!profileId) return; - try { - await window.go.api.Service.DeleteTOTPSecret(profileId); - NexusTerminal.Notifications.show('TOTP secret removed.', 'info'); - secretInput.placeholder = 'Base32-encoded secret (e.g. from authenticator app setup)'; - } catch (err) { - NexusTerminal.Notifications.show('Failed to remove TOTP secret.', 'error'); - } - }); - - row.appendChild(secretInput); - row.appendChild(toggleBtn); - row.appendChild(testBtn); - row.appendChild(saveBtn); - row.appendChild(deleteBtn); - group.appendChild(row); - content.appendChild(group); - - // Check if secret is already saved and show placeholder - if (profile.id) { - (async () => { - try { - const has = await window.go.api.Service.HasTOTPSecret(profile.id); - if (has) { - secretInput.placeholder = '••••••••• (secret saved)'; - } - } catch (_) { - // Ignore — might not be unlocked. - } - })(); + // Test button — generates a live code + const testBtn = createElement( + "button", + "btn btn-small btn-accent totp-test", + { + textContent: "Test", + title: "Generate a code with this secret", + type: "button", + }, + ); + testBtn.addEventListener("mousedown", (e) => e.preventDefault()); + testBtn.addEventListener("click", async () => { + const profileId = profile.id; + if (profileId) { + try { + const code = await window.go.api.Service.GetTOTPCode(profileId); + testBtn.textContent = code; + setTimeout(() => { + testBtn.textContent = "Test"; + }, 5000); + } catch (err) { + // If no saved secret yet, try computing locally using the input value + testBtn.textContent = "Save first"; + setTimeout(() => { + testBtn.textContent = "Test"; + }, 2000); + } } + }); - header.addEventListener('mousedown', (e) => e.preventDefault()); - header.addEventListener('click', () => { - collapsed = !collapsed; - content.style.display = collapsed ? 'none' : 'block'; - header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾'; - }); + // Save button + const saveBtn = createElement( + "button", + "btn btn-small btn-primary totp-save", + { + textContent: "Save", + title: "Encrypt and save the TOTP secret", + type: "button", + }, + ); + saveBtn.addEventListener("mousedown", (e) => e.preventDefault()); + saveBtn.addEventListener("click", async () => { + const profileId = profile.id; + const secret = secretInput.value.trim(); + if (!profileId) { + NexusTerminal.Notifications.show( + "Save the profile first, then set the TOTP secret.", + "warning", + ); + return; + } + if (!secret) { + // Delete existing secret + try { + await window.go.api.Service.DeleteTOTPSecret(profileId); + NexusTerminal.Notifications.show("TOTP secret removed.", "info"); + } catch (err) { + NexusTerminal.Notifications.show( + "Failed to remove TOTP secret.", + "error", + ); + } + return; + } + try { + await window.go.api.Service.SetTOTPSecret(profileId, secret); + NexusTerminal.Notifications.show( + "TOTP secret saved securely.", + "success", + ); + secretInput.value = ""; + secretInput.placeholder = "••••••••• (secret saved)"; + } catch (err) { + NexusTerminal.Notifications.show( + "Failed to save TOTP secret: " + err, + "error", + ); + } + }); - section.appendChild(header); - section.appendChild(content); - return section; + // Delete button + const deleteBtn = createElement( + "button", + "btn btn-small btn-danger totp-delete", + { + textContent: "Delete", + title: "Remove the TOTP secret", + type: "button", + }, + ); + deleteBtn.addEventListener("mousedown", (e) => e.preventDefault()); + deleteBtn.addEventListener("click", async () => { + const profileId = profile.id; + if (!profileId) return; + try { + await window.go.api.Service.DeleteTOTPSecret(profileId); + NexusTerminal.Notifications.show("TOTP secret removed.", "info"); + secretInput.placeholder = + "Base32-encoded secret (e.g. from authenticator app setup)"; + } catch (err) { + NexusTerminal.Notifications.show( + "Failed to remove TOTP secret.", + "error", + ); + } + }); + + row.appendChild(secretInput); + row.appendChild(toggleBtn); + row.appendChild(testBtn); + row.appendChild(saveBtn); + row.appendChild(deleteBtn); + group.appendChild(row); + content.appendChild(group); + + // Check if secret is already saved and show placeholder + if (profile.id) { + (async () => { + try { + const has = await window.go.api.Service.HasTOTPSecret(profile.id); + if (has) { + secretInput.placeholder = "••••••••• (secret saved)"; + } + } catch (_) { + // Ignore — might not be unlocked. + } + })(); + } + + header.addEventListener("mousedown", (e) => e.preventDefault()); + header.addEventListener("click", () => { + collapsed = !collapsed; + content.style.display = collapsed ? "none" : "block"; + header.querySelector(".form-section-chevron").textContent = collapsed + ? "▸" + : "▾"; + }); + + section.appendChild(header); + section.appendChild(content); + return section; } /** @@ -779,198 +1159,249 @@ class ProfileEditor { * @private */ _renderLoginScriptSection(prefix, steps) { - const section = createElement('div', 'form-section'); - const header = createElement('div', 'form-section-header'); - let collapsed = true; + const section = createElement("div", "form-section"); + const header = createElement("div", "form-section-header"); + let collapsed = true; - header.innerHTML = ` + header.innerHTML = ` ▸ Login Script `; - const content = createElement('div', 'form-section-content'); - content.style.display = 'none'; + const content = createElement("div", "form-section-content"); + content.style.display = "none"; - const helpText = createElement('div', 'form-help', { - textContent: 'Automate login sequences: wait for text from the server, then send a response. Steps execute in order after connection.', - }); - content.appendChild(helpText); + const helpText = createElement("div", "form-help", { + textContent: + "Automate login sequences: wait for text from the server, then send a response. Steps execute in order after connection.", + }); + content.appendChild(helpText); - // Hidden input to hold JSON - const hiddenInput = createElement('input', '', { - type: 'hidden', - id: `field-${prefix}.loginScripts`, - dataset: { field: `${prefix}.loginScripts` }, - }); - content.appendChild(hiddenInput); + // Hidden input to hold JSON + const hiddenInput = createElement("input", "", { + type: "hidden", + id: `field-${prefix}.loginScripts`, + dataset: { field: `${prefix}.loginScripts` }, + }); + content.appendChild(hiddenInput); - // List container - const listContainer = createElement('div', 'login-script-list'); - content.appendChild(listContainer); + // List container + const listContainer = createElement("div", "login-script-list"); + content.appendChild(listContainer); - // Working copy - let currentSteps = JSON.parse(JSON.stringify(steps || [])); + // Working copy + let currentSteps = JSON.parse(JSON.stringify(steps || [])); - const renderSteps = () => { - listContainer.innerHTML = ''; - hiddenInput.value = JSON.stringify(currentSteps); + const renderSteps = () => { + listContainer.innerHTML = ""; + hiddenInput.value = JSON.stringify(currentSteps); - if (currentSteps.length === 0) { - const empty = createElement('div', 'login-script-empty', { - textContent: 'No login script steps configured.', - }); - listContainer.appendChild(empty); - } + if (currentSteps.length === 0) { + const empty = createElement("div", "login-script-empty", { + textContent: "No login script steps configured.", + }); + listContainer.appendChild(empty); + } - currentSteps.forEach((step, idx) => { - const row = createElement('div', 'login-script-row'); + currentSteps.forEach((step, idx) => { + const row = createElement("div", "login-script-row"); - const typeSelect = createElement('select', 'form-input login-script-type'); - ['wait', 'send'].forEach(t => { - const opt = createElement('option', '', { value: t, textContent: t === 'wait' ? 'Wait for' : 'Send' }); - if (t === step.type) opt.selected = true; - typeSelect.appendChild(opt); - }); - typeSelect.addEventListener('change', () => { - currentSteps[idx].type = typeSelect.value; - hiddenInput.value = JSON.stringify(currentSteps); - renderSteps(); - }); - - const valueInput = createElement('input', 'form-input login-script-value', { - type: 'text', - value: step.value || '', - placeholder: step.type === 'wait' ? 'Text to match...' : 'Text to send...', - autocapitalize: 'off', - autocorrect: 'off', - spellcheck: false, - }); - valueInput.addEventListener('input', () => { - currentSteps[idx].value = valueInput.value; - hiddenInput.value = JSON.stringify(currentSteps); - }); - - row.appendChild(typeSelect); - row.appendChild(valueInput); - - // Timeout field for wait steps - if (step.type === 'wait') { - const timeoutInput = createElement('input', 'form-input login-script-timeout', { - type: 'number', - value: step.timeout || 10, - min: 1, - max: 120, - title: 'Timeout (seconds)', - }); - timeoutInput.style.width = '60px'; - timeoutInput.addEventListener('input', () => { - currentSteps[idx].timeout = parseInt(timeoutInput.value, 10) || 10; - hiddenInput.value = JSON.stringify(currentSteps); - }); - const timeoutLabel = createElement('span', 'login-script-timeout-label', { textContent: 's' }); - row.appendChild(timeoutInput); - row.appendChild(timeoutLabel); - } - - // Move up/down/remove buttons - const actions = createElement('div', 'login-script-actions'); - - if (idx > 0) { - const upBtn = createElement('button', 'login-script-btn', { textContent: '▲', title: 'Move up', type: 'button' }); - upBtn.addEventListener('mousedown', e => e.preventDefault()); - upBtn.addEventListener('click', () => { - [currentSteps[idx - 1], currentSteps[idx]] = [currentSteps[idx], currentSteps[idx - 1]]; - renderSteps(); - }); - actions.appendChild(upBtn); - } - - if (idx < currentSteps.length - 1) { - const downBtn = createElement('button', 'login-script-btn', { textContent: '▼', title: 'Move down', type: 'button' }); - downBtn.addEventListener('mousedown', e => e.preventDefault()); - downBtn.addEventListener('click', () => { - [currentSteps[idx], currentSteps[idx + 1]] = [currentSteps[idx + 1], currentSteps[idx]]; - renderSteps(); - }); - actions.appendChild(downBtn); - } - - const removeBtn = createElement('button', 'login-script-btn login-script-btn-remove', { textContent: '✕', title: 'Remove', type: 'button' }); - removeBtn.addEventListener('mousedown', e => e.preventDefault()); - removeBtn.addEventListener('click', () => { - currentSteps.splice(idx, 1); - renderSteps(); - }); - actions.appendChild(removeBtn); - - row.appendChild(actions); - listContainer.appendChild(row); + const typeSelect = createElement( + "select", + "form-input login-script-type", + ); + ["wait", "send"].forEach((t) => { + const opt = createElement("option", "", { + value: t, + textContent: t === "wait" ? "Wait for" : "Send", }); - }; + if (t === step.type) opt.selected = true; + typeSelect.appendChild(opt); + }); + typeSelect.addEventListener("change", () => { + currentSteps[idx].type = typeSelect.value; + hiddenInput.value = JSON.stringify(currentSteps); + renderSteps(); + }); + const valueInput = createElement( + "input", + "form-input login-script-value", + { + type: "text", + value: step.value || "", + placeholder: + step.type === "wait" ? "Text to match..." : "Text to send...", + autocapitalize: "off", + autocorrect: "off", + spellcheck: false, + }, + ); + valueInput.addEventListener("input", () => { + currentSteps[idx].value = valueInput.value; + hiddenInput.value = JSON.stringify(currentSteps); + }); + + row.appendChild(typeSelect); + row.appendChild(valueInput); + + // Timeout field for wait steps + if (step.type === "wait") { + const timeoutInput = createElement( + "input", + "form-input login-script-timeout", + { + type: "number", + value: step.timeout || 10, + min: 1, + max: 120, + title: "Timeout (seconds)", + }, + ); + timeoutInput.style.width = "60px"; + timeoutInput.addEventListener("input", () => { + currentSteps[idx].timeout = + parseInt(timeoutInput.value, 10) || 10; + hiddenInput.value = JSON.stringify(currentSteps); + }); + const timeoutLabel = createElement( + "span", + "login-script-timeout-label", + { textContent: "s" }, + ); + row.appendChild(timeoutInput); + row.appendChild(timeoutLabel); + } + + // Move up/down/remove buttons + const actions = createElement("div", "login-script-actions"); + + if (idx > 0) { + const upBtn = createElement("button", "login-script-btn", { + textContent: "▲", + title: "Move up", + type: "button", + }); + upBtn.addEventListener("mousedown", (e) => e.preventDefault()); + upBtn.addEventListener("click", () => { + [currentSteps[idx - 1], currentSteps[idx]] = [ + currentSteps[idx], + currentSteps[idx - 1], + ]; + renderSteps(); + }); + actions.appendChild(upBtn); + } + + if (idx < currentSteps.length - 1) { + const downBtn = createElement("button", "login-script-btn", { + textContent: "▼", + title: "Move down", + type: "button", + }); + downBtn.addEventListener("mousedown", (e) => e.preventDefault()); + downBtn.addEventListener("click", () => { + [currentSteps[idx], currentSteps[idx + 1]] = [ + currentSteps[idx + 1], + currentSteps[idx], + ]; + renderSteps(); + }); + actions.appendChild(downBtn); + } + + const removeBtn = createElement( + "button", + "login-script-btn login-script-btn-remove", + { textContent: "✕", title: "Remove", type: "button" }, + ); + removeBtn.addEventListener("mousedown", (e) => e.preventDefault()); + removeBtn.addEventListener("click", () => { + currentSteps.splice(idx, 1); + renderSteps(); + }); + actions.appendChild(removeBtn); + + row.appendChild(actions); + listContainer.appendChild(row); + }); + }; + + renderSteps(); + + // Add step buttons + const addRow = createElement("div", "login-script-add-row"); + const addWaitBtn = createElement( + "button", + "btn btn-small btn-secondary", + { textContent: "+ Wait", type: "button" }, + ); + addWaitBtn.addEventListener("mousedown", (e) => e.preventDefault()); + addWaitBtn.addEventListener("click", () => { + currentSteps.push({ type: "wait", value: "", timeout: 10 }); renderSteps(); + }); + const addSendBtn = createElement( + "button", + "btn btn-small btn-secondary", + { textContent: "+ Send", type: "button" }, + ); + addSendBtn.addEventListener("mousedown", (e) => e.preventDefault()); + addSendBtn.addEventListener("click", () => { + currentSteps.push({ type: "send", value: "", timeout: 0 }); + renderSteps(); + }); + addRow.appendChild(addWaitBtn); + addRow.appendChild(addSendBtn); + content.appendChild(addRow); - // Add step buttons - const addRow = createElement('div', 'login-script-add-row'); - const addWaitBtn = createElement('button', 'btn btn-small btn-secondary', { textContent: '+ Wait', type: 'button' }); - addWaitBtn.addEventListener('mousedown', e => e.preventDefault()); - addWaitBtn.addEventListener('click', () => { - currentSteps.push({ type: 'wait', value: '', timeout: 10 }); - renderSteps(); - }); - const addSendBtn = createElement('button', 'btn btn-small btn-secondary', { textContent: '+ Send', type: 'button' }); - addSendBtn.addEventListener('mousedown', e => e.preventDefault()); - addSendBtn.addEventListener('click', () => { - currentSteps.push({ type: 'send', value: '', timeout: 0 }); - renderSteps(); - }); - addRow.appendChild(addWaitBtn); - addRow.appendChild(addSendBtn); - content.appendChild(addRow); + header.addEventListener("mousedown", (e) => e.preventDefault()); + header.addEventListener("click", () => { + collapsed = !collapsed; + content.style.display = collapsed ? "none" : "block"; + header.querySelector(".form-section-chevron").textContent = collapsed + ? "▸" + : "▾"; + }); - header.addEventListener('mousedown', (e) => e.preventDefault()); - header.addEventListener('click', () => { - collapsed = !collapsed; - content.style.display = collapsed ? 'none' : 'block'; - header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾'; - }); - - section.appendChild(header); - section.appendChild(content); - return section; + section.appendChild(header); + section.appendChild(content); + return section; } _renderSection(title, fields, startCollapsed = false) { - const section = createElement('div', 'form-section'); - const header = createElement('div', 'form-section-header'); - let collapsed = startCollapsed; + const section = createElement("div", "form-section"); + const header = createElement("div", "form-section-header"); + let collapsed = startCollapsed; - header.innerHTML = ` - ${collapsed ? '▸' : '▾'} + header.innerHTML = ` + ${collapsed ? "▸" : "▾"} ${escapeHtml(title)} `; - const content = createElement('div', 'form-section-content'); - if (collapsed) content.style.display = 'none'; + const content = createElement("div", "form-section-content"); + if (collapsed) content.style.display = "none"; - for (const field of fields) { - if (field instanceof HTMLElement) { - content.appendChild(field); - } + for (const field of fields) { + if (field instanceof HTMLElement) { + content.appendChild(field); } + } - // Prevent mousedown from starting a text selection gesture in WKWebView, - // which would swallow the first click and require a double-click. - header.addEventListener('mousedown', (e) => e.preventDefault()); - header.addEventListener('click', () => { - collapsed = !collapsed; - content.style.display = collapsed ? 'none' : 'block'; - header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾'; - }); + // Prevent mousedown from starting a text selection gesture in WKWebView, + // which would swallow the first click and require a double-click. + header.addEventListener("mousedown", (e) => e.preventDefault()); + header.addEventListener("click", () => { + collapsed = !collapsed; + content.style.display = collapsed ? "none" : "block"; + header.querySelector(".form-section-chevron").textContent = collapsed + ? "▸" + : "▾"; + }); - section.appendChild(header); - section.appendChild(content); - return section; + section.appendChild(header); + section.appendChild(content); + return section; } /** @@ -984,115 +1415,121 @@ class ProfileEditor { * @private */ _field(type, name, label, value, opts = {}) { - const group = createElement('div', 'form-group'); + const group = createElement("div", "form-group"); - // Label - const labelEl = createElement('label', 'form-label', { - textContent: label, + // Label + const labelEl = createElement("label", "form-label", { + textContent: label, + for: `field-${name}`, + }); + group.appendChild(labelEl); + + // Input element based on type + let input; + + switch (type) { + case "text": + case "file": + input = createElement("input", "form-input", { + type: type === "file" ? "text" : "text", + id: `field-${name}`, + name, + value: value || "", + placeholder: opts.placeholder || "", + }); + if (opts.required) input.required = true; + break; + + case "number": + input = createElement("input", "form-input", { + type: "number", + id: `field-${name}`, + name, + value: value != null ? String(value) : "", + }); + if (opts.min != null) input.min = opts.min; + if (opts.max != null) input.max = opts.max; + if (opts.required) input.required = true; + break; + + case "select": { + input = createElement("select", "form-select", { + id: `field-${name}`, + name, + }); + for (const opt of opts.options || []) { + const option = createElement("option", "", { + value: opt.value, + textContent: opt.label, + }); + if (opt.value === value) option.selected = true; + input.appendChild(option); + } + break; + } + + case "toggle": { + const wrapper = createElement("div", "form-toggle-wrapper"); + input = createElement("input", "form-toggle", { + type: "checkbox", + id: `field-${name}`, + name, + }); + input.checked = !!value; + const slider = createElement("label", "form-toggle-label", { for: `field-${name}`, - }); - group.appendChild(labelEl); - - // Input element based on type - let input; - - switch (type) { - case 'text': - case 'file': - input = createElement('input', 'form-input', { - type: type === 'file' ? 'text' : 'text', - id: `field-${name}`, - name, - value: value || '', - placeholder: opts.placeholder || '', - }); - if (opts.required) input.required = true; - break; - - case 'number': - input = createElement('input', 'form-input', { - type: 'number', - id: `field-${name}`, - name, - value: value != null ? String(value) : '', - }); - if (opts.min != null) input.min = opts.min; - if (opts.max != null) input.max = opts.max; - if (opts.required) input.required = true; - break; - - case 'select': { - input = createElement('select', 'form-select', { - id: `field-${name}`, - name, - }); - for (const opt of (opts.options || [])) { - const option = createElement('option', '', { - value: opt.value, - textContent: opt.label, - }); - if (opt.value === value) option.selected = true; - input.appendChild(option); - } - break; - } - - case 'toggle': { - const wrapper = createElement('div', 'form-toggle-wrapper'); - input = createElement('input', 'form-toggle', { - type: 'checkbox', - id: `field-${name}`, - name, - }); - input.checked = !!value; - const slider = createElement('label', 'form-toggle-label', { for: `field-${name}` }); - wrapper.appendChild(input); - wrapper.appendChild(slider); - group.appendChild(wrapper); - // Help text - if (opts.help) { - group.appendChild(createElement('span', 'form-hint', { textContent: opts.help })); - } - return group; - } - - case 'textarea': - input = createElement('textarea', 'form-textarea', { - id: `field-${name}`, - name, - placeholder: opts.placeholder || '', - }); - input.rows = opts.rows || 3; - input.textContent = value || ''; - break; - - case 'tags': - input = createElement('input', 'form-input', { - type: 'text', - id: `field-${name}`, - name, - value: value || '', - placeholder: opts.placeholder || '', - }); - break; - - default: - input = createElement('input', 'form-input', { - type: 'text', - id: `field-${name}`, - name, - value: value || '', - }); + }); + wrapper.appendChild(input); + wrapper.appendChild(slider); + group.appendChild(wrapper); + // Help text + if (opts.help) { + group.appendChild( + createElement("span", "form-hint", { textContent: opts.help }), + ); + } + return group; } - group.appendChild(input); + case "textarea": + input = createElement("textarea", "form-textarea", { + id: `field-${name}`, + name, + placeholder: opts.placeholder || "", + }); + input.rows = opts.rows || 3; + input.textContent = value || ""; + break; - // Help text - if (opts.help) { - group.appendChild(createElement('span', 'form-hint', { textContent: opts.help })); - } + case "tags": + input = createElement("input", "form-input", { + type: "text", + id: `field-${name}`, + name, + value: value || "", + placeholder: opts.placeholder || "", + }); + break; - return group; + default: + input = createElement("input", "form-input", { + type: "text", + id: `field-${name}`, + name, + value: value || "", + }); + } + + group.appendChild(input); + + // Help text + if (opts.help) { + group.appendChild( + createElement("span", "form-hint", { textContent: opts.help }), + ); + } + + return group; } /** @@ -1102,37 +1539,46 @@ class ProfileEditor { * @private */ _renderJumpHostsList(jumpHosts) { - const wrapper = createElement('div', 'jump-hosts-list'); + const wrapper = createElement("div", "jump-hosts-list"); - for (let i = 0; i < jumpHosts.length; i++) { - const jh = jumpHosts[i]; - const row = createElement('div', 'jump-host-row'); - row.innerHTML = ` + for (let i = 0; i < jumpHosts.length; i++) { + const jh = jumpHosts[i]; + const row = createElement("div", "jump-host-row"); + row.innerHTML = `
- + - +
- +
`; - row.querySelector('.jump-host-remove').addEventListener('click', () => { - jumpHosts.splice(i, 1); - this.render(this._profile, this._isNew); - }); - wrapper.appendChild(row); - } - - const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Jump Host' }); - addBtn.addEventListener('click', () => { - jumpHosts.push({ host: '', port: 22, username: '', authMethod: 'publickey', secretMode: 'ask-every-time', privateKeyPath: '' }); - this.render(this._profile, this._isNew); + row.querySelector(".jump-host-remove").addEventListener("click", () => { + jumpHosts.splice(i, 1); + this.render(this._profile, this._isNew); }); - wrapper.appendChild(addBtn); + wrapper.appendChild(row); + } - return wrapper; + const addBtn = createElement("button", "btn btn-sm btn-outline", { + textContent: "+ Add Jump Host", + }); + addBtn.addEventListener("click", () => { + jumpHosts.push({ + host: "", + port: 22, + username: "", + authMethod: "publickey", + secretMode: "ask-every-time", + privateKeyPath: "", + }); + this.render(this._profile, this._isNew); + }); + wrapper.appendChild(addBtn); + + return wrapper; } /** @@ -1142,56 +1588,60 @@ class ProfileEditor { * @private */ _renderPortForwardsList(portForwards) { - const wrapper = createElement('div', 'port-forwards-list'); + const wrapper = createElement("div", "port-forwards-list"); - for (let i = 0; i < portForwards.length; i++) { - const pf = portForwards[i]; - const row = createElement('div', 'port-forward-row'); - row.innerHTML = ` + for (let i = 0; i < portForwards.length; i++) { + const pf = portForwards[i]; + const row = createElement("div", "port-forward-row"); + row.innerHTML = `
- + ⟶ - +
- +
`; - row.querySelector('.port-forward-remove').addEventListener('click', () => { - portForwards.splice(i, 1); - this.render(this._profile, this._isNew); - }); - wrapper.appendChild(row); - } - - const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Port Forward' }); - addBtn.addEventListener('click', () => { - portForwards.push({ - id: generateId(), - direction: 'local', - bindHost: '127.0.0.1', - bindPort: 0, - destHost: '', - destPort: 0, - enabled: true, - label: '', - }); + row + .querySelector(".port-forward-remove") + .addEventListener("click", () => { + portForwards.splice(i, 1); this.render(this._profile, this._isNew); - }); - wrapper.appendChild(addBtn); + }); + wrapper.appendChild(row); + } - return wrapper; + const addBtn = createElement("button", "btn btn-sm btn-outline", { + textContent: "+ Add Port Forward", + }); + addBtn.addEventListener("click", () => { + portForwards.push({ + id: generateId(), + direction: "local", + bindHost: "127.0.0.1", + bindPort: 0, + destHost: "", + destPort: 0, + enabled: true, + label: "", + }); + this.render(this._profile, this._isNew); + }); + wrapper.appendChild(addBtn); + + return wrapper; } /** @@ -1201,36 +1651,43 @@ class ProfileEditor { * @private */ _renderPayloadPresetsList(presets) { - const wrapper = createElement('div', 'payload-presets-list'); + const wrapper = createElement("div", "payload-presets-list"); - for (let i = 0; i < presets.length; i++) { - const ps = presets[i]; - const row = createElement('div', 'preset-row'); - row.innerHTML = ` - - + for (let i = 0; i < presets.length; i++) { + const ps = presets[i]; + const row = createElement("div", "preset-row"); + row.innerHTML = ` + + `; - row.querySelector('.preset-remove').addEventListener('click', () => { - presets.splice(i, 1); - this.render(this._profile, this._isNew); - }); - wrapper.appendChild(row); - } - - const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Preset' }); - addBtn.addEventListener('click', () => { - presets.push({ id: generateId(), name: '', payload: '', format: 'text' }); - this.render(this._profile, this._isNew); + row.querySelector(".preset-remove").addEventListener("click", () => { + presets.splice(i, 1); + this.render(this._profile, this._isNew); }); - wrapper.appendChild(addBtn); + wrapper.appendChild(row); + } - return wrapper; + const addBtn = createElement("button", "btn btn-sm btn-outline", { + textContent: "+ Add Preset", + }); + addBtn.addEventListener("click", () => { + presets.push({ + id: generateId(), + name: "", + payload: "", + format: "text", + }); + this.render(this._profile, this._isNew); + }); + wrapper.appendChild(addBtn); + + return wrapper; } // ── Form data collection ───────────────────────────────────────── @@ -1240,183 +1697,195 @@ class ProfileEditor { * @private */ _collectFormValues() { - if (!this.drawer) return; + if (!this.drawer) return; - const val = (name) => { - const el = this.drawer.querySelector(`[name="${name}"]`); - if (!el) return undefined; - if (el.type === 'checkbox') return el.checked; - if (el.type === 'number') return el.value !== '' ? Number(el.value) : 0; - return el.value; - }; + const val = (name) => { + const el = this.drawer.querySelector(`[name="${name}"]`); + if (!el) return undefined; + if (el.type === "checkbox") return el.checked; + if (el.type === "number") return el.value !== "" ? Number(el.value) : 0; + return el.value; + }; - const p = this._profile; + const p = this._profile; - // General - p.displayName = val('displayName') || ''; - p.host = val('host') || ''; - p.port = val('port') || p.port; - p.username = val('username') || ''; - p.description = val('description') || ''; - p.folder = (val('folder') || '').trim(); - p.terminalFontFamily = val('terminalFontFamily') || ''; - p.terminalFontSize = val('terminalFontSize') || 14; - p.terminalScrollback = val('terminalScrollback') || 10000; - p.terminalCursorStyle = val('terminalCursorStyle') || 'block'; - p.connectTimeout = val('connectTimeout') || 30; - p.readTimeout = val('readTimeout') || 0; + // General + p.displayName = val("displayName") || ""; + p.host = val("host") || ""; + p.port = val("port") || p.port; + p.username = val("username") || ""; + p.description = val("description") || ""; + p.folder = (val("folder") || "").trim(); + p.terminalFontFamily = val("terminalFontFamily") || ""; + p.terminalFontSize = val("terminalFontSize") || 14; + p.terminalScrollback = val("terminalScrollback") || 10000; + p.terminalCursorStyle = val("terminalCursorStyle") || "block"; + p.connectTimeout = val("connectTimeout") || 30; + p.readTimeout = val("readTimeout") || 0; - // Tags - const tagsStr = val('tags'); - if (tagsStr !== undefined) { - p.tags = tagsStr.split(',').map((t) => t.trim()).filter(Boolean); + // Tags + const tagsStr = val("tags"); + if (tagsStr !== undefined) { + p.tags = tagsStr + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + } + + // Notes + const notes = val("notes"); + if (notes !== undefined) p.notes = notes; + + // SSH-specific + if (p.protocol === "ssh" && p.ssh) { + p.ssh.secretMode = val("ssh.secretMode") || p.ssh.secretMode; + p.ssh.privateKeyPath = val("ssh.privateKeyPath") || ""; + p.ssh.keySecretMode = val("ssh.keySecretMode") || p.ssh.keySecretMode; + p.ssh.hostKeyPolicy = val("ssh.hostKeyPolicy") || p.ssh.hostKeyPolicy; + p.ssh.terminalType = val("ssh.terminalType") || "xterm-256color"; + p.ssh.keepaliveInterval = val("ssh.keepaliveInterval") ?? 30; + p.ssh.keepaliveCountMax = val("ssh.keepaliveCountMax") ?? 3; + p.ssh.compression = val("ssh.compression") || false; + p.ssh.agentAuth = val("ssh.agentAuth") || false; + p.ssh.agentForwarding = val("ssh.agentForwarding") || false; + p.ssh.autoReconnect = val("ssh.autoReconnect") || false; + p.ssh.maxReconnectTries = val("ssh.maxReconnectTries") ?? 5; + p.ssh.startupCommand = val("ssh.startupCommand") || ""; + + // Proxy settings + p.ssh.proxyType = val("ssh.proxyType") || "none"; + p.ssh.proxyHost = val("ssh.proxyHost") || ""; + p.ssh.proxyPort = val("ssh.proxyPort") || 1080; + p.ssh.proxyUsername = val("ssh.proxyUsername") || ""; + p.ssh.proxyPassword = val("ssh.proxyPassword") || ""; + + const authStr = val("ssh.authMethods"); + if (authStr !== undefined) { + p.ssh.authMethods = authStr + .split(",") + .map((s) => s.trim()) + .filter(Boolean); } - // Notes - const notes = val('notes'); - if (notes !== undefined) p.notes = notes; - - // SSH-specific - if (p.protocol === 'ssh' && p.ssh) { - p.ssh.secretMode = val('ssh.secretMode') || p.ssh.secretMode; - p.ssh.privateKeyPath = val('ssh.privateKeyPath') || ''; - p.ssh.keySecretMode = val('ssh.keySecretMode') || p.ssh.keySecretMode; - p.ssh.hostKeyPolicy = val('ssh.hostKeyPolicy') || p.ssh.hostKeyPolicy; - p.ssh.terminalType = val('ssh.terminalType') || 'xterm-256color'; - p.ssh.keepaliveInterval = val('ssh.keepaliveInterval') ?? 30; - p.ssh.keepaliveCountMax = val('ssh.keepaliveCountMax') ?? 3; - p.ssh.compression = val('ssh.compression') || false; - p.ssh.agentAuth = val('ssh.agentAuth') || false; - p.ssh.agentForwarding = val('ssh.agentForwarding') || false; - p.ssh.autoReconnect = val('ssh.autoReconnect') || false; - p.ssh.maxReconnectTries = val('ssh.maxReconnectTries') ?? 5; - p.ssh.startupCommand = val('ssh.startupCommand') || ''; - - // Proxy settings - p.ssh.proxyType = val('ssh.proxyType') || 'none'; - p.ssh.proxyHost = val('ssh.proxyHost') || ''; - p.ssh.proxyPort = val('ssh.proxyPort') || 1080; - p.ssh.proxyUsername = val('ssh.proxyUsername') || ''; - p.ssh.proxyPassword = val('ssh.proxyPassword') || ''; - - const authStr = val('ssh.authMethods'); - if (authStr !== undefined) { - p.ssh.authMethods = authStr.split(',').map((s) => s.trim()).filter(Boolean); - } - - // Env vars from textarea - const envText = val('ssh.envVarsText'); - if (envText !== undefined) { - p.ssh.envVars = {}; - envText.split('\n').forEach((line) => { - const eq = line.indexOf('='); - if (eq > 0) { - p.ssh.envVars[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); - } - }); - } - - // Jump hosts - const jh = []; - let ji = 0; - while (this.drawer.querySelector(`[name="jumpHost-${ji}-host"]`)) { - jh.push({ - host: val(`jumpHost-${ji}-host`) || '', - port: val(`jumpHost-${ji}-port`) || 22, - username: val(`jumpHost-${ji}-username`) || '', - authMethod: 'publickey', - secretMode: 'ask-every-time', - privateKeyPath: val(`jumpHost-${ji}-privateKeyPath`) || '', - }); - ji++; - } - p.ssh.jumpHosts = jh; - - // Port forwards - const pfs = []; - let fi = 0; - while (this.drawer.querySelector(`[name="portFwd-${fi}-direction"]`)) { - pfs.push({ - id: (p.ssh.portForwards && p.ssh.portForwards[fi]?.id) || generateId(), - direction: val(`portFwd-${fi}-direction`) || 'local', - bindHost: val(`portFwd-${fi}-bindHost`) || '127.0.0.1', - bindPort: val(`portFwd-${fi}-bindPort`) || 0, - destHost: val(`portFwd-${fi}-destHost`) || '', - destPort: val(`portFwd-${fi}-destPort`) || 0, - enabled: val(`portFwd-${fi}-enabled`) !== false, - label: val(`portFwd-${fi}-label`) || '', - }); - fi++; - } - p.ssh.portForwards = pfs; - - // Login scripts - const loginScriptsRaw = val('ssh.loginScripts'); - if (loginScriptsRaw) { - try { - p.ssh.loginScripts = JSON.parse(loginScriptsRaw); - } catch (_) { - p.ssh.loginScripts = []; - } - } else { - p.ssh.loginScripts = []; + // Env vars from textarea + const envText = val("ssh.envVarsText"); + if (envText !== undefined) { + p.ssh.envVars = {}; + envText.split("\n").forEach((line) => { + const eq = line.indexOf("="); + if (eq > 0) { + p.ssh.envVars[line.slice(0, eq).trim()] = line + .slice(eq + 1) + .trim(); } + }); } - // Telnet-specific - if (p.protocol === 'telnet' && p.telnet) { - p.telnet.localEcho = val('telnet.localEcho') || false; - p.telnet.lineMode = val('telnet.lineMode') || false; - p.telnet.terminalType = val('telnet.terminalType') || 'xterm-256color'; - p.telnet.encoding = val('telnet.encoding') || 'utf-8'; - p.telnet.lineEnding = val('telnet.lineEnding') || 'crlf'; - p.telnet.negotiationDebug = val('telnet.negotiationDebug') || false; - p.telnet.autoReconnect = val('telnet.autoReconnect') || false; - p.telnet.maxReconnectTries = val('telnet.maxReconnectTries') ?? 5; - - // Login scripts - const telLoginScriptsRaw = val('telnet.loginScripts'); - if (telLoginScriptsRaw) { - try { - p.telnet.loginScripts = JSON.parse(telLoginScriptsRaw); - } catch (_) { - p.telnet.loginScripts = []; - } - } else { - p.telnet.loginScripts = []; - } + // Jump hosts + const jh = []; + let ji = 0; + while (this.drawer.querySelector(`[name="jumpHost-${ji}-host"]`)) { + jh.push({ + host: val(`jumpHost-${ji}-host`) || "", + port: val(`jumpHost-${ji}-port`) || 22, + username: val(`jumpHost-${ji}-username`) || "", + authMethod: "publickey", + secretMode: "ask-every-time", + privateKeyPath: val(`jumpHost-${ji}-privateKeyPath`) || "", + }); + ji++; } + p.ssh.jumpHosts = jh; - // RAW-specific - if (p.protocol === 'raw' && p.raw) { - p.raw.encoding = val('raw.encoding') || 'utf-8'; - p.raw.lineEnding = val('raw.lineEnding') || 'crlf'; - p.raw.showTimestamps = val('raw.showTimestamps') || false; - p.raw.showDirection = val('raw.showDirection') || false; - p.raw.autoReconnect = val('raw.autoReconnect') || false; - - // Presets - const presets = []; - let pi = 0; - while (this.drawer.querySelector(`[name="preset-${pi}-name"]`)) { - presets.push({ - id: (p.raw.payloadPresets && p.raw.payloadPresets[pi]?.id) || generateId(), - name: val(`preset-${pi}-name`) || '', - payload: val(`preset-${pi}-payload`) || '', - format: val(`preset-${pi}-format`) || 'text', - }); - pi++; - } - p.raw.payloadPresets = presets; + // Port forwards + const pfs = []; + let fi = 0; + while (this.drawer.querySelector(`[name="portFwd-${fi}-direction"]`)) { + pfs.push({ + id: + (p.ssh.portForwards && p.ssh.portForwards[fi]?.id) || + generateId(), + direction: val(`portFwd-${fi}-direction`) || "local", + bindHost: val(`portFwd-${fi}-bindHost`) || "127.0.0.1", + bindPort: val(`portFwd-${fi}-bindPort`) || 0, + destHost: val(`portFwd-${fi}-destHost`) || "", + destPort: val(`portFwd-${fi}-destPort`) || 0, + enabled: val(`portFwd-${fi}-enabled`) !== false, + label: val(`portFwd-${fi}-label`) || "", + }); + fi++; } + p.ssh.portForwards = pfs; - // GitHub-specific - if (p.protocol === 'github') { - if (!p.github) p.github = {}; - p.github.baseURL = val('github.baseURL') || 'https://api.github.com'; - p.github.secretMode = val('github.secretMode') || 'saved'; - p.github.refreshIntervalSecs = val('github.refreshIntervalSecs') || 300; + // Login scripts + const loginScriptsRaw = val("ssh.loginScripts"); + if (loginScriptsRaw) { + try { + p.ssh.loginScripts = JSON.parse(loginScriptsRaw); + } catch (_) { + p.ssh.loginScripts = []; + } + } else { + p.ssh.loginScripts = []; } + } + + // Telnet-specific + if (p.protocol === "telnet" && p.telnet) { + p.telnet.localEcho = val("telnet.localEcho") || false; + p.telnet.lineMode = val("telnet.lineMode") || false; + p.telnet.terminalType = val("telnet.terminalType") || "xterm-256color"; + p.telnet.encoding = val("telnet.encoding") || "utf-8"; + p.telnet.lineEnding = val("telnet.lineEnding") || "crlf"; + p.telnet.negotiationDebug = val("telnet.negotiationDebug") || false; + p.telnet.autoReconnect = val("telnet.autoReconnect") || false; + p.telnet.maxReconnectTries = val("telnet.maxReconnectTries") ?? 5; + + // Login scripts + const telLoginScriptsRaw = val("telnet.loginScripts"); + if (telLoginScriptsRaw) { + try { + p.telnet.loginScripts = JSON.parse(telLoginScriptsRaw); + } catch (_) { + p.telnet.loginScripts = []; + } + } else { + p.telnet.loginScripts = []; + } + } + + // RAW-specific + if (p.protocol === "raw" && p.raw) { + p.raw.encoding = val("raw.encoding") || "utf-8"; + p.raw.lineEnding = val("raw.lineEnding") || "crlf"; + p.raw.showTimestamps = val("raw.showTimestamps") || false; + p.raw.showDirection = val("raw.showDirection") || false; + p.raw.autoReconnect = val("raw.autoReconnect") || false; + + // Presets + const presets = []; + let pi = 0; + while (this.drawer.querySelector(`[name="preset-${pi}-name"]`)) { + presets.push({ + id: + (p.raw.payloadPresets && p.raw.payloadPresets[pi]?.id) || + generateId(), + name: val(`preset-${pi}-name`) || "", + payload: val(`preset-${pi}-payload`) || "", + format: val(`preset-${pi}-format`) || "text", + }); + pi++; + } + p.raw.payloadPresets = presets; + } + + // GitHub-specific + if (p.protocol === "github") { + if (!p.github) p.github = {}; + p.github.baseURL = val("github.baseURL") || "https://api.github.com"; + p.github.secretMode = val("github.secretMode") || "saved"; + p.github.refreshIntervalSecs = val("github.refreshIntervalSecs") || 300; + } } // ── Validation ─────────────────────────────────────────────────── @@ -1428,84 +1897,86 @@ class ProfileEditor { * @private */ _validate(profile) { - const errors = []; - if (!profile.host || !profile.host.trim()) { - errors.push('Host is required.'); - } - if (!profile.port || profile.port < 1 || profile.port > 65535) { - errors.push('Port must be between 1 and 65535.'); - } - return errors; + const errors = []; + if (!profile.host || !profile.host.trim()) { + errors.push("Host is required."); + } + if (!profile.port || profile.port < 1 || profile.port > 65535) { + errors.push("Port must be between 1 and 65535."); + } + return errors; } // ── Actions ────────────────────────────────────────────────────── /** @private */ async _handleSave(andConnect = false) { - this._collectFormValues(); - const errors = this._validate(this._profile); - if (errors.length > 0) { - this.app.notifications.warning(errors.join(' ')); - return; - } + this._collectFormValues(); + const errors = this._validate(this._profile); + if (errors.length > 0) { + this.app.notifications.warning(errors.join(" ")); + return; + } - try { - let saved; - if (this._isNew) { - saved = await window.go.api.Service.CreateProfile(this._profile); - this.app.state.addProfile(saved); - this.app.notifications.success('Profile created'); - } else { - saved = await window.go.api.Service.UpdateProfile(this._profile); - this.app.state.updateProfile(saved); - this.app.notifications.success('Profile updated'); - } - this.app.sidebar.render(); - this.close(); - - if (andConnect && saved && saved.id) { - this.app.connectProfile(saved.id); - } - } catch (err) { - this.app.notifications.error('Save failed: ' + err); + try { + let saved; + if (this._isNew) { + saved = await window.go.api.Service.CreateProfile(this._profile); + this.app.state.addProfile(saved); + this.app.notifications.success("Profile created"); + } else { + saved = await window.go.api.Service.UpdateProfile(this._profile); + this.app.state.updateProfile(saved); + this.app.notifications.success("Profile updated"); } + this.app.sidebar.render(); + this.close(); + + if (andConnect && saved && saved.id) { + this.app.connectProfile(saved.id); + } + } catch (err) { + this.app.notifications.error("Save failed: " + err); + } } /** @private */ async _handleDelete() { - const ok = await this._confirmDelete(); - if (!ok) return; + const ok = await this._confirmDelete(); + if (!ok) return; - try { - await window.go.api.Service.DeleteProfile(this._profile.id); - this.app.state.removeProfile(this._profile.id); - this.app.sidebar.render(); - this.app.notifications.success('Profile deleted'); - this.close(); - } catch (err) { - this.app.notifications.error('Failed to delete profile: ' + err); - } + try { + await window.go.api.Service.DeleteProfile(this._profile.id); + this.app.state.removeProfile(this._profile.id); + this.app.sidebar.render(); + this.app.notifications.success("Profile deleted"); + this.close(); + } catch (err) { + this.app.notifications.error("Failed to delete profile: " + err); + } } /** @private */ async _testConnection() { - this._collectFormValues(); - const errors = this._validate(this._profile); - if (errors.length > 0) { - this.app.notifications.warning(errors.join(' ')); - return; - } + this._collectFormValues(); + const errors = this._validate(this._profile); + if (errors.length > 0) { + this.app.notifications.warning(errors.join(" ")); + return; + } - const host = this._profile.host; - const port = this._profile.port; - this.app.notifications.info(`Testing connection to ${host}:${port}…`); + const host = this._profile.host; + const port = this._profile.port; + this.app.notifications.info(`Testing connection to ${host}:${port}…`); - try { - const result = await window.go.api.Service.TestConnection(host, port); - this.app.notifications.success(`Reachable — ${result.latencyMs}ms latency`); - } catch (err) { - this.app.notifications.error('Test failed: ' + err); - } + try { + const result = await window.go.api.Service.TestConnection(host, port); + this.app.notifications.success( + `Reachable — ${result.latencyMs}ms latency`, + ); + } catch (err) { + this.app.notifications.error("Test failed: " + err); + } } /** @@ -1514,11 +1985,13 @@ class ProfileEditor { * @private */ _confirmDelete() { - return new Promise((resolve) => { - const backdrop = createElement('div', 'modal-backdrop'); - const modal = createElement('div', 'modal modal-sm'); - const label = escapeHtml(this._profile.displayName || this._profile.host || 'Untitled'); - modal.innerHTML = ` + return new Promise((resolve) => { + const backdrop = createElement("div", "modal-backdrop"); + const modal = createElement("div", "modal modal-sm"); + const label = escapeHtml( + this._profile.displayName || this._profile.host || "Untitled", + ); + modal.innerHTML = ` `; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add('is-visible'); - modal.classList.add('is-visible'); - }); - - const close = (val) => { - backdrop.classList.remove('is-visible'); - modal.classList.remove('is-visible'); - setTimeout(() => backdrop.remove(), 200); - resolve(val); - }; - modal.querySelector('.modal-close').addEventListener('click', () => close(false)); - modal.querySelector('.confirm-cancel').addEventListener('click', () => close(false)); - modal.querySelector('.confirm-ok').addEventListener('click', () => close(true)); - backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(false); }); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + requestAnimationFrame(() => { + backdrop.classList.add("is-visible"); + modal.classList.add("is-visible"); }); + + const close = (val) => { + backdrop.classList.remove("is-visible"); + modal.classList.remove("is-visible"); + setTimeout(() => backdrop.remove(), 200); + resolve(val); + }; + modal + .querySelector(".modal-close") + .addEventListener("click", () => close(false)); + modal + .querySelector(".confirm-cancel") + .addEventListener("click", () => close(false)); + modal + .querySelector(".confirm-ok") + .addEventListener("click", () => close(true)); + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) close(false); + }); + }); } // ── Utilities ──────────────────────────────────────────────────── @@ -1562,33 +2043,34 @@ class ProfileEditor { * @private */ _defaultProfile(protocol) { - const base = { - id: generateId(), - displayName: '', - description: '', - protocol, - host: '', - port: 0, - username: '', - tags: [], - folder: '', - environment: '', - notes: '', - favourite: false, - lastConnected: null, - connectTimeout: 30, - readTimeout: 0, - terminalFontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace', - terminalFontSize: 14, - terminalScrollback: 10000, - terminalCursorStyle: 'block', - ssh: null, - telnet: null, - raw: null, - }; + const base = { + id: generateId(), + displayName: "", + description: "", + protocol, + host: "", + port: 0, + username: "", + tags: [], + folder: "", + environment: "", + notes: "", + favourite: false, + lastConnected: null, + connectTimeout: 30, + readTimeout: 0, + terminalFontFamily: + "JetBrains Mono, Menlo, Monaco, Consolas, monospace", + terminalFontSize: 14, + terminalScrollback: 10000, + terminalCursorStyle: "block", + ssh: null, + telnet: null, + raw: null, + }; - const defaults = DEFAULTS[protocol] || {}; - return { ...base, ...defaults }; + const defaults = DEFAULTS[protocol] || {}; + return { ...base, ...defaults }; } /** @@ -1598,12 +2080,13 @@ class ProfileEditor { * @private */ _envVarsToText(envVars) { - if (!envVars) return ''; - return Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join('\n'); + if (!envVars) return ""; + return Object.entries(envVars) + .map(([k, v]) => `${k}=${v}`) + .join("\n"); } -} - -window.NexusTerminal = window.NexusTerminal || {}; -window.NexusTerminal.ProfileEditor = ProfileEditor; + } + window.NexusTerminal = window.NexusTerminal || {}; + window.NexusTerminal.ProfileEditor = ProfileEditor; })(); diff --git a/frontend/src/js/settings.js b/frontend/src/js/settings.js index aacaa6a..c8c9e9e 100644 --- a/frontend/src/js/settings.js +++ b/frontend/src/js/settings.js @@ -1,40 +1,41 @@ -(function() { -/** - * Nexus Terminal — settings modal. - * Renders a tabbed settings dialog for application-wide preferences, - * terminal defaults, security, known-hosts management, and about info. - */ +(function () { + /** + * Nexus Terminal — settings modal. + * Renders a tabbed settings dialog for application-wide preferences, + * terminal defaults, security, known-hosts management, and about info. + */ -const { createElement, escapeHtml, formatTimestamp, $ } = window.NexusTerminal; + const { createElement, escapeHtml, formatTimestamp, $ } = + window.NexusTerminal; -/** Settings tab definitions. */ -const TABS = [ - { key: 'general', label: 'General' }, - { key: 'terminal', label: 'Terminal' }, - { key: 'security', label: 'Security' }, - { key: 'knownhosts', label: 'Known Hosts' }, - { key: 'help', label: 'Help' }, - { key: 'about', label: 'About' }, -]; + /** Settings tab definitions. */ + const TABS = [ + { key: "general", label: "General" }, + { key: "terminal", label: "Terminal" }, + { key: "security", label: "Security" }, + { key: "knownhosts", label: "Known Hosts" }, + { key: "help", label: "Help" }, + { key: "about", label: "About" }, + ]; -class Settings { + class Settings { /** * @param {App} app */ constructor(app) { - this.app = app; + this.app = app; - /** @type {HTMLElement|null} */ - this._overlay = null; + /** @type {HTMLElement|null} */ + this._overlay = null; - /** @type {string} Currently active tab key. */ - this._activeTab = 'general'; + /** @type {string} Currently active tab key. */ + this._activeTab = "general"; - /** @type {Object} Working copy of preferences. */ - this._prefs = {}; + /** @type {Object} Working copy of preferences. */ + this._prefs = {}; - /** @type {Array} Cached known hosts list. */ - this._knownHosts = []; + /** @type {Array} Cached known hosts list. */ + this._knownHosts = []; } // ── Public API ─────────────────────────────────────────────────── @@ -43,16 +44,16 @@ class Settings { * Opens the settings modal. */ async open(tabKey) { - this._prefs = JSON.parse(JSON.stringify(this.app.state.preferences)); - this._activeTab = tabKey || this._activeTab || 'general'; + this._prefs = JSON.parse(JSON.stringify(this.app.state.preferences)); + this._activeTab = tabKey || this._activeTab || "general"; - try { - this._knownHosts = await window.go.api.Service.ListKnownHosts() || []; - } catch (_) { - this._knownHosts = []; - } + try { + this._knownHosts = (await window.go.api.Service.ListKnownHosts()) || []; + } catch (_) { + this._knownHosts = []; + } - this._render(); + this._render(); } /** @@ -60,107 +61,119 @@ class Settings { * @param {string} tabKey */ async openTab(tabKey) { - this._activeTab = tabKey || 'general'; - return this.open(this._activeTab); + this._activeTab = tabKey || "general"; + return this.open(this._activeTab); } /** * Closes the settings modal. */ close() { - if (this._overlay) { - this._overlay.remove(); - this._overlay = null; - } + if (this._overlay) { + this._overlay.remove(); + this._overlay = null; + } } /** * Saves the modified preferences to the backend. */ async save() { - this._collectValues(); + this._collectValues(); - try { - await window.go.api.Service.SavePreferences(this._prefs); - this.app.state.setPreferences(this._prefs); + try { + await window.go.api.Service.SavePreferences(this._prefs); + this.app.state.setPreferences(this._prefs); - // Apply terminal colour theme change immediately. - if (this.app.terminals && this._prefs.terminalTheme) { - this.app.terminals.applyTheme(this._prefs.terminalTheme); - } - if (this.app && typeof this.app._applyPreferences === 'function') { - this.app._applyPreferences(); - } - - this.app.notifications.success('Settings saved'); - this.close(); - } catch (err) { - this.app.notifications.error('Failed to save settings: ' + err); + // Apply terminal colour theme change immediately. + if (this.app.terminals && this._prefs.terminalTheme) { + this.app.terminals.applyTheme(this._prefs.terminalTheme); } + if (this.app && typeof this.app._applyPreferences === "function") { + this.app._applyPreferences(); + } + + this.app.notifications.success("Settings saved"); + this.close(); + } catch (err) { + this.app.notifications.error("Failed to save settings: " + err); + } } // ── Rendering ──────────────────────────────────────────────────── /** @private */ _render() { - this.close(); + this.close(); - const overlay = createElement('div', 'modal-backdrop'); - const modal = createElement('div', 'modal modal-settings'); + const overlay = createElement("div", "modal-backdrop"); + const modal = createElement("div", "modal modal-settings"); - // Header - const header = createElement('div', 'modal-header'); - header.innerHTML = ` + // Header + const header = createElement("div", "modal-header"); + header.innerHTML = ` `; - header.querySelector('.modal-close').addEventListener('click', () => this.close()); - modal.appendChild(header); + header + .querySelector(".modal-close") + .addEventListener("click", () => this.close()); + modal.appendChild(header); - // Tab bar - const tabBar = createElement('div', 'settings-tab-bar'); - for (const tab of TABS) { - const btn = createElement('button', `settings-tab ${tab.key === this._activeTab ? 'active' : ''}`, { - textContent: tab.label, - dataset: { tab: tab.key }, - }); - btn.addEventListener('click', () => { - this._activeTab = tab.key; - this._renderBody(modal); - tabBar.querySelectorAll('.settings-tab').forEach((t) => { - t.classList.toggle('active', t.dataset.tab === tab.key); - }); - }); - tabBar.appendChild(btn); - } - modal.appendChild(tabBar); - - // Body (tab content) - const body = createElement('div', 'modal-body settings-body'); - modal.appendChild(body); - this._renderTabContent(body); - - // Footer - const footer = createElement('div', 'modal-footer'); - const cancelBtn = createElement('button', 'btn btn-secondary', { textContent: 'Cancel' }); - const saveBtn = createElement('button', 'btn btn-primary', { textContent: 'Save' }); - cancelBtn.addEventListener('click', () => this.close()); - saveBtn.addEventListener('click', () => this.save()); - footer.appendChild(cancelBtn); - footer.appendChild(saveBtn); - modal.appendChild(footer); - - overlay.appendChild(modal); - overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); }); - modal.addEventListener('click', (e) => e.stopPropagation()); - document.body.appendChild(overlay); - this._overlay = overlay; - - // Trigger visibility after append so CSS transitions animate - requestAnimationFrame(() => { - overlay.classList.add('is-visible'); - modal.classList.add('is-visible'); + // Tab bar + const tabBar = createElement("div", "settings-tab-bar"); + for (const tab of TABS) { + const btn = createElement( + "button", + `settings-tab ${tab.key === this._activeTab ? "active" : ""}`, + { + textContent: tab.label, + dataset: { tab: tab.key }, + }, + ); + btn.addEventListener("click", () => { + this._activeTab = tab.key; + this._renderBody(modal); + tabBar.querySelectorAll(".settings-tab").forEach((t) => { + t.classList.toggle("active", t.dataset.tab === tab.key); + }); }); + tabBar.appendChild(btn); + } + modal.appendChild(tabBar); + + // Body (tab content) + const body = createElement("div", "modal-body settings-body"); + modal.appendChild(body); + this._renderTabContent(body); + + // Footer + const footer = createElement("div", "modal-footer"); + const cancelBtn = createElement("button", "btn btn-secondary", { + textContent: "Cancel", + }); + const saveBtn = createElement("button", "btn btn-primary", { + textContent: "Save", + }); + cancelBtn.addEventListener("click", () => this.close()); + saveBtn.addEventListener("click", () => this.save()); + footer.appendChild(cancelBtn); + footer.appendChild(saveBtn); + modal.appendChild(footer); + + overlay.appendChild(modal); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) this.close(); + }); + modal.addEventListener("click", (e) => e.stopPropagation()); + document.body.appendChild(overlay); + this._overlay = overlay; + + // Trigger visibility after append so CSS transitions animate + requestAnimationFrame(() => { + overlay.classList.add("is-visible"); + modal.classList.add("is-visible"); + }); } /** @@ -169,8 +182,8 @@ class Settings { * @private */ _renderBody(modal) { - const body = modal.querySelector('.settings-body'); - if (body) this._renderTabContent(body); + const body = modal.querySelector(".settings-body"); + if (body) this._renderTabContent(body); } /** @@ -179,16 +192,28 @@ class Settings { * @private */ _renderTabContent(body) { - body.innerHTML = ''; - body.classList.remove('help-layout'); - switch (this._activeTab) { - case 'general': this._renderGeneralTab(body); break; - case 'terminal': this._renderTerminalTab(body); break; - case 'security': this._renderSecurityTab(body); break; - case 'knownhosts': this._renderKnownHostsTab(body); break; - case 'help': this._renderHelpTab(body); break; - case 'about': this._renderAboutTab(body); break; - } + body.innerHTML = ""; + body.classList.remove("help-layout"); + switch (this._activeTab) { + case "general": + this._renderGeneralTab(body); + break; + case "terminal": + this._renderTerminalTab(body); + break; + case "security": + this._renderSecurityTab(body); + break; + case "knownhosts": + this._renderKnownHostsTab(body); + break; + case "help": + this._renderHelpTab(body); + break; + case "about": + this._renderAboutTab(body); + break; + } } // ── Tab content renderers ──────────────────────────────────────── @@ -199,24 +224,63 @@ class Settings { * @private */ _renderGeneralTab(body) { - const p = this._prefs; - body.appendChild(this._settingField('select', 'theme', 'Theme', p.theme, { + const p = this._prefs; + body.appendChild( + this._settingField("select", "theme", "Theme", p.theme, { + options: [ + { value: "dark", label: "Dark" }, + { value: "light", label: "Light" }, + ], + }), + ); + body.appendChild( + this._settingField( + "select", + "defaultProtocol", + "Default Protocol", + p.defaultProtocol, + { options: [ - { value: 'dark', label: 'Dark' }, - { value: 'light', label: 'Light' }, + { value: "ssh", label: "SSH" }, + { value: "telnet", label: "Telnet" }, + { value: "raw", label: "RAW" }, ], - })); - body.appendChild(this._settingField('select', 'defaultProtocol', 'Default Protocol', p.defaultProtocol, { - options: [ - { value: 'ssh', label: 'SSH' }, - { value: 'telnet', label: 'Telnet' }, - { value: 'raw', label: 'RAW' }, - ], - })); - body.appendChild(this._settingField('toggle', 'confirmOnClose', 'Confirm on close', p.confirmOnClose)); - body.appendChild(this._settingField('toggle', 'confirmOnDisconnect', 'Confirm on disconnect', p.confirmOnDisconnect)); - body.appendChild(this._settingField('toggle', 'showStatusBar', 'Show status bar', p.showStatusBar)); - body.appendChild(this._settingField('number', 'sidebarWidth', 'Sidebar width (px)', p.sidebarWidth, { min: 180, max: 500 })); + }, + ), + ); + body.appendChild( + this._settingField( + "toggle", + "confirmOnClose", + "Confirm on close", + p.confirmOnClose, + ), + ); + body.appendChild( + this._settingField( + "toggle", + "confirmOnDisconnect", + "Confirm on disconnect", + p.confirmOnDisconnect, + ), + ); + body.appendChild( + this._settingField( + "toggle", + "showStatusBar", + "Show status bar", + p.showStatusBar, + ), + ); + body.appendChild( + this._settingField( + "number", + "sidebarWidth", + "Sidebar width (px)", + p.sidebarWidth, + { min: 180, max: 500 }, + ), + ); } /** @@ -225,26 +289,66 @@ class Settings { * @private */ _renderTerminalTab(body) { - const p = this._prefs; + const p = this._prefs; - // Terminal colour theme selector - const themes = window.NexusTerminal.listTerminalThemes(); - body.appendChild(this._settingField('select', 'terminalTheme', 'Colour scheme', p.terminalTheme || 'charcoal', { + // Terminal colour theme selector + const themes = window.NexusTerminal.listTerminalThemes(); + body.appendChild( + this._settingField( + "select", + "terminalTheme", + "Colour scheme", + p.terminalTheme || "charcoal", + { options: themes, - })); + }, + ), + ); - body.appendChild(this._settingField('text', 'defaultFontFamily', 'Font family', p.defaultFontFamily, { - placeholder: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace', - })); - body.appendChild(this._settingField('number', 'defaultFontSize', 'Font size', p.defaultFontSize, { min: 8, max: 36 })); - body.appendChild(this._settingField('number', 'defaultScrollback', 'Scrollback lines', p.defaultScrollback, { min: 0, max: 100000 })); - body.appendChild(this._settingField('select', 'defaultCursorStyle', 'Cursor style', p.defaultCursorStyle, { + body.appendChild( + this._settingField( + "text", + "defaultFontFamily", + "Font family", + p.defaultFontFamily, + { + placeholder: "JetBrains Mono, Menlo, Monaco, Consolas, monospace", + }, + ), + ); + body.appendChild( + this._settingField( + "number", + "defaultFontSize", + "Font size", + p.defaultFontSize, + { min: 8, max: 36 }, + ), + ); + body.appendChild( + this._settingField( + "number", + "defaultScrollback", + "Scrollback lines", + p.defaultScrollback, + { min: 0, max: 100000 }, + ), + ); + body.appendChild( + this._settingField( + "select", + "defaultCursorStyle", + "Cursor style", + p.defaultCursorStyle, + { options: [ - { value: 'block', label: 'Block' }, - { value: 'underline', label: 'Underline' }, - { value: 'bar', label: 'Bar' }, + { value: "block", label: "Block" }, + { value: "underline", label: "Underline" }, + { value: "bar", label: "Bar" }, ], - })); + }, + ), + ); } /** @@ -253,63 +357,71 @@ class Settings { * @private */ _renderSecurityTab(body) { - const storeState = this.app.state.secretStoreUnlocked ? 'Unlocked' : 'Locked'; - const storeInit = this.app.state.secretStoreInitialised; + const storeState = this.app.state.secretStoreUnlocked + ? "Unlocked" + : "Locked"; + const storeInit = this.app.state.secretStoreInitialised; - const statusRow = createElement('div', 'settings-row'); - statusRow.innerHTML = ` + const statusRow = createElement("div", "settings-row"); + statusRow.innerHTML = ` Secret store - ${storeState} + ${storeState} `; - body.appendChild(statusRow); + body.appendChild(statusRow); - if (!storeInit) { - const initBtn = createElement('button', 'btn btn-primary btn-sm', { textContent: 'Initialise Secret Store' }); - initBtn.addEventListener('click', async () => { - try { - const pw = await this.app.secretPrompt.promptMasterPassword(true); - await window.go.api.Service.InitialiseSecretStore(pw); - this.app.state.setSecretStoreInitialised(true); - this.app.state.setSecretStoreState(true); - this.app.notifications.success('Secret store created'); - this._renderTabContent(body); - } catch (err) { - if (err.message !== 'User cancelled') { - this.app.notifications.error('Failed to initialise: ' + err); - } - } - }); - body.appendChild(initBtn); - } else if (!this.app.state.secretStoreUnlocked) { - const unlockBtn = createElement('button', 'btn btn-primary btn-sm', { textContent: 'Unlock' }); - unlockBtn.addEventListener('click', async () => { - try { - const pw = await this.app.secretPrompt.promptMasterPassword(false); - await window.go.api.Service.UnlockSecretStore(pw); - this.app.state.setSecretStoreState(true); - this.app.notifications.success('Secret store unlocked'); - this._renderTabContent(body); - } catch (err) { - if (err.message !== 'User cancelled') { - this.app.notifications.error('Unlock failed: ' + err); - } - } - }); - body.appendChild(unlockBtn); - } else { - const lockBtn = createElement('button', 'btn btn-outline btn-sm', { textContent: 'Lock' }); - lockBtn.addEventListener('click', async () => { - try { - await window.go.api.Service.LockSecretStore(); - this.app.state.setSecretStoreState(false); - this.app.notifications.info('Secret store locked'); - this._renderTabContent(body); - } catch (err) { - this.app.notifications.error('Lock failed: ' + err); - } - }); - body.appendChild(lockBtn); - } + if (!storeInit) { + const initBtn = createElement("button", "btn btn-primary btn-sm", { + textContent: "Initialise Secret Store", + }); + initBtn.addEventListener("click", async () => { + try { + const pw = await this.app.secretPrompt.promptMasterPassword(true); + await window.go.api.Service.InitialiseSecretStore(pw); + this.app.state.setSecretStoreInitialised(true); + this.app.state.setSecretStoreState(true); + this.app.notifications.success("Secret store created"); + this._renderTabContent(body); + } catch (err) { + if (err.message !== "User cancelled") { + this.app.notifications.error("Failed to initialise: " + err); + } + } + }); + body.appendChild(initBtn); + } else if (!this.app.state.secretStoreUnlocked) { + const unlockBtn = createElement("button", "btn btn-primary btn-sm", { + textContent: "Unlock", + }); + unlockBtn.addEventListener("click", async () => { + try { + const pw = await this.app.secretPrompt.promptMasterPassword(false); + await window.go.api.Service.UnlockSecretStore(pw); + this.app.state.setSecretStoreState(true); + this.app.notifications.success("Secret store unlocked"); + this._renderTabContent(body); + } catch (err) { + if (err.message !== "User cancelled") { + this.app.notifications.error("Unlock failed: " + err); + } + } + }); + body.appendChild(unlockBtn); + } else { + const lockBtn = createElement("button", "btn btn-outline btn-sm", { + textContent: "Lock", + }); + lockBtn.addEventListener("click", async () => { + try { + await window.go.api.Service.LockSecretStore(); + this.app.state.setSecretStoreState(false); + this.app.notifications.info("Secret store locked"); + this._renderTabContent(body); + } catch (err) { + this.app.notifications.error("Lock failed: " + err); + } + }); + body.appendChild(lockBtn); + } } /** @@ -318,13 +430,17 @@ class Settings { * @private */ _renderKnownHostsTab(body) { - if (this._knownHosts.length === 0) { - body.appendChild(createElement('p', 'text-muted', { textContent: 'No known hosts recorded.' })); - return; - } + if (this._knownHosts.length === 0) { + body.appendChild( + createElement("p", "text-muted", { + textContent: "No known hosts recorded.", + }), + ); + return; + } - const table = createElement('table', 'settings-table'); - table.innerHTML = ` + const table = createElement("table", "settings-table"); + table.innerHTML = ` Host @@ -336,11 +452,11 @@ class Settings { `; - const tbody = createElement('tbody'); + const tbody = createElement("tbody"); - for (const kh of this._knownHosts) { - const tr = createElement('tr'); - tr.innerHTML = ` + for (const kh of this._knownHosts) { + const tr = createElement("tr"); + tr.innerHTML = ` ${escapeHtml(kh.host)} ${kh.port} ${escapeHtml(kh.keyType)} @@ -348,23 +464,25 @@ class Settings { ${formatTimestamp(kh.firstSeen)} `; - const deleteBtn = createElement('button', 'btn btn-xs btn-danger', { textContent: 'Remove' }); - deleteBtn.addEventListener('click', async () => { - try { - await window.go.api.Service.DeleteKnownHost(kh.id); - this._knownHosts = this._knownHosts.filter((h) => h.id !== kh.id); - this._renderTabContent(body); - this.app.notifications.success('Known host removed'); - } catch (err) { - this.app.notifications.error('Failed to remove: ' + err); - } - }); - tr.querySelector('td:last-child').appendChild(deleteBtn); - tbody.appendChild(tr); - } + const deleteBtn = createElement("button", "btn btn-xs btn-danger", { + textContent: "Remove", + }); + deleteBtn.addEventListener("click", async () => { + try { + await window.go.api.Service.DeleteKnownHost(kh.id); + this._knownHosts = this._knownHosts.filter((h) => h.id !== kh.id); + this._renderTabContent(body); + this.app.notifications.success("Known host removed"); + } catch (err) { + this.app.notifications.error("Failed to remove: " + err); + } + }); + tr.querySelector("td:last-child").appendChild(deleteBtn); + tbody.appendChild(tr); + } - table.appendChild(tbody); - body.appendChild(table); + table.appendChild(tbody); + body.appendChild(table); } /** @@ -373,8 +491,11 @@ class Settings { * @private */ _renderHelpTab(body) { - const HELP_SECTIONS = [ - { id: 'getting-started', title: 'Getting Started', content: ` + const HELP_SECTIONS = [ + { + id: "getting-started", + title: "Getting Started", + content: `

Welcome to Nexus Terminal — a desktop application for connecting to remote servers via SSH, Telnet, and RAW TCP, as well as browsing your GitHub repositories.

Creating Your First Connection

    @@ -388,8 +509,12 @@ class Settings {

    Need to connect quickly without saving a profile? Press ⌘⇧Q / Ctrl+Shift+Q to open the quick connect dialog. Choose SSH, Telnet, or RAW, enter the host and port, and you're connected immediately.

    Managing Connections

    Right-click any connection in the sidebar to edit, duplicate, or delete it. You can drag connections between groups, and use the search box to filter by name, host, or tag.

    - ` }, - { id: 'ssh', title: 'SSH Connections', content: ` + `, + }, + { + id: "ssh", + title: "SSH Connections", + content: `

    SSH (Secure Shell) is the most common way to connect to remote servers. Nexus Terminal supports:

    • Password authentication — enter your password when prompted, or save it in the encrypted vault.
    • @@ -420,8 +545,12 @@ class Settings {

      Each wait step has a configurable timeout (default 10 seconds). Steps execute in order after the connection is established.

      Auto-Reconnect

      Enable Auto-Reconnect in the Advanced section to automatically reconnect when the connection drops unexpectedly. Reconnection uses exponential backoff (1s, 2s, 4s, 8s... up to 30s). You can configure the maximum number of attempts (default 5).

      - ` }, - { id: 'telnet', title: 'Telnet Connections', content: ` + `, + }, + { + id: "telnet", + title: "Telnet Connections", + content: `

      Telnet is an older, unencrypted protocol still used for network equipment, legacy systems, and some fun internet services.

      • Option negotiation — Nexus Terminal handles standard Telnet options (echo, terminal type, window size) automatically.
      • @@ -432,8 +561,12 @@ class Settings {
      • Auto-reconnect — automatically reconnect with exponential backoff when the connection drops.

      Note: Telnet sends everything in plain text. Avoid using it for anything sensitive over an untrusted network.

      - ` }, - { id: 'raw', title: 'RAW TCP Connections', content: ` + `, + }, + { + id: "raw", + title: "RAW TCP Connections", + content: `

      RAW TCP lets you connect to any TCP port and exchange data directly — useful for debugging network services, testing APIs, or working with custom protocols.

      • Text view — shows incoming data as readable text.
      • @@ -443,8 +576,12 @@ class Settings {
      • Line endings — choose CR, LF, or CRLF to match the remote service.
      • Auto-reconnect — automatically reconnect if the connection drops.
      - ` }, - { id: 'github', title: 'GitHub Connections', content: ` + `, + }, + { + id: "github", + title: "GitHub Connections", + content: `

      The GitHub connection type lets you browse your repositories without leaving Nexus Terminal.

      Setting Up

        @@ -454,8 +591,12 @@ class Settings {

      What You'll See

      Your repositories are listed grouped by organisation or owner, sorted by last commit date (newest first). The list refreshes automatically every few minutes.

      - ` }, - { id: 'sessions', title: 'Sessions & Tabs', content: ` + `, + }, + { + id: "sessions", + title: "Sessions & Tabs", + content: `

      Tab Management

      • Rename — right-click a tab and choose "Rename".
      • @@ -484,8 +625,12 @@ class Settings {
      • Send Snippet — quickly send a saved command from the snippet library
      • Clear / Reset — clear the terminal buffer
      - ` }, - { id: 'power', title: 'Power Features', content: ` + `, + }, + { + id: "power", + title: "Power Features", + content: `

      Snippet Library

      Press ⌘⇧S / Ctrl+Shift+S to open the snippet library. Save frequently-used commands with a name, description, and optional folder and tags. Click Send to Session to inject a command into the active terminal. You can also access snippets from the right-click context menu.

      Terminal Search

      @@ -502,8 +647,12 @@ class Settings {

      For SSH and Telnet connections, you can define expect-style login scripts — a sequence of "wait for text" and "send text" steps that execute automatically after connecting. Configure them in the Login Script section of the profile editor.

      Auto-Reconnect

      Enable auto-reconnect in a profile's settings to automatically reconnect when a connection drops. Uses exponential backoff with a configurable maximum number of attempts.

      - ` }, - { id: 'security', title: 'Security & Credentials', content: ` + `, + }, + { + id: "security", + title: "Security & Credentials", + content: `

      How Your Passwords Are Stored

      Nexus Terminal uses a two-tier approach to keep your credentials safe:

        @@ -519,8 +668,12 @@ class Settings {

        You can manage known hosts and change the verification policy in Settings → Known Hosts.

        Encrypted Export

        When exporting profiles, you can choose encrypted export (.nexus file) which protects the file with a password. This uses the same strong AES-256-GCM encryption as the vault.

        - ` }, - { id: 'shortcuts', title: 'Keyboard Shortcuts', content: ` + `, + }, + { + id: "shortcuts", + title: "Keyboard Shortcuts", + content: `

        On macOS, use ⌘ (Cmd). On Windows and Linux, use Ctrl.

        @@ -541,16 +694,24 @@ class Settings {
        F11Toggle fullscreen (Windows/Linux)
        - ` }, - { id: 'import-export', title: 'Import & Export', content: ` + `, + }, + { + id: "import-export", + title: "Import & Export", + content: `

        Exporting Profiles

        Click the ↗ export button in the sidebar footer. You can choose which profiles to include, and set a password to encrypt the file. Exported files use the .nexus extension.

        Importing Profiles

        Click the ↙ import button. Select a .nexus file and enter the password used when exporting. Imported profiles will appear in your sidebar immediately.

        Importing from OpenSSH

        If you have an existing ~/.ssh/config file, go to Settings → About and click Import ~/.ssh/config. Nexus Terminal will create profiles from your SSH Host blocks, including hostnames, usernames, ports, identity files, and jump hosts.

        - ` }, - { id: 'troubleshooting', title: 'Troubleshooting', content: ` + `, + }, + { + id: "troubleshooting", + title: "Troubleshooting", + content: `

        I can't connect to my server

        • Double-check the hostname and port — a common mistake is using port 22 for Telnet (which is usually 23) or vice versa.
        • @@ -599,8 +760,12 @@ class Settings {

          Make sure your system display scaling is set correctly. On macOS, Nexus Terminal is fully Retina-aware. On Windows, check your DPI settings in Display preferences.

          Export/import isn't working

          Ensure you're using the same password to import that was used to export. The encrypted .nexus files cannot be opened with the wrong password.

          - ` }, - { id: 'fun', title: 'Fun Things to Try', content: ` + `, + }, + { + id: "fun", + title: "Fun Things to Try", + content: `

          Nexus Terminal isn't just for work! Here are some entertaining things you can connect to:

          🌟 Star Wars in ASCII Art

          Create a Telnet connection to towel.blinkenlights.nl on port 23. Sit back and enjoy the original Star Wars film retold entirely in ASCII art.

          @@ -616,41 +781,54 @@ class Settings {

          Create a RAW TCP connection to any HTTP server on port 80, then type GET / HTTP/1.0 followed by two blank lines. You'll see the raw HTTP response — a great way to understand how the web works under the hood.

          📢 Broadcast Mode Fun

          Open two or more sessions (e.g. to different servers) and press ⌘⇧B / Ctrl+Shift+B to turn on broadcast mode. Everything you type goes to all sessions at once — try running hostname and watch them all respond with different names!

          - ` }, - ]; + `, + }, + ]; - body.innerHTML = ''; + body.innerHTML = ""; - // Navigation sidebar - body.classList.add('help-layout'); - var nav = createElement('div', 'help-nav'); - var content = createElement('div', 'help-content'); + // Navigation sidebar + body.classList.add("help-layout"); + var nav = createElement("div", "help-nav"); + var content = createElement("div", "help-content"); - for (var i = 0; i < HELP_SECTIONS.length; i++) { - var section = HELP_SECTIONS[i]; - var navItem = createElement('button', 'help-nav-item', { - textContent: section.title, - dataset: { section: section.id }, + for (var i = 0; i < HELP_SECTIONS.length; i++) { + var section = HELP_SECTIONS[i]; + var navItem = createElement("button", "help-nav-item", { + textContent: section.title, + dataset: { section: section.id }, + }); + navItem.addEventListener("mousedown", function (e) { + e.preventDefault(); + }); + (function (sec) { + navItem.addEventListener("click", function () { + nav.querySelectorAll(".help-nav-item").forEach(function (n) { + n.classList.remove("active"); }); - navItem.addEventListener('mousedown', function(e) { e.preventDefault(); }); - (function(sec) { - navItem.addEventListener('click', function() { - nav.querySelectorAll('.help-nav-item').forEach(function(n) { n.classList.remove('active'); }); - this.classList.add('active'); - content.innerHTML = '

          ' + sec.title + '

          ' + sec.content; - }); - })(section); - nav.appendChild(navItem); - } + this.classList.add("active"); + content.innerHTML = + '

          ' + + sec.title + + "

          " + + sec.content; + }); + })(section); + nav.appendChild(navItem); + } - body.appendChild(nav); - body.appendChild(content); + body.appendChild(nav); + body.appendChild(content); - // Show first section by default - if (nav.firstChild) { - nav.firstChild.classList.add('active'); - content.innerHTML = '

          ' + HELP_SECTIONS[0].title + '

          ' + HELP_SECTIONS[0].content; - } + // Show first section by default + if (nav.firstChild) { + nav.firstChild.classList.add("active"); + content.innerHTML = + '

          ' + + HELP_SECTIONS[0].title + + "

          " + + HELP_SECTIONS[0].content; + } } /** @@ -659,7 +837,7 @@ class Settings { * @private */ _renderAboutTab(body) { - body.innerHTML = ` + body.innerHTML = `

          Nexus Terminal

          Version 1.0.0

          @@ -678,47 +856,55 @@ class Settings {
          `; - const updateBtn = body.querySelector('#settings-check-update'); - if (updateBtn) { - updateBtn.addEventListener('click', async () => { - updateBtn.disabled = true; - updateBtn.textContent = 'Checking…'; - try { - const info = await window.go.api.Service.CheckForUpdates(); - const resultEl = body.querySelector('#settings-update-result'); - if (info.available) { - resultEl.innerHTML = `Update available: ${info.latestVersion}` + - (info.releaseURL ? ` — View release` : ''); - } else { - resultEl.textContent = 'You are running the latest version.'; - } - } catch (err) { - const resultEl = body.querySelector('#settings-update-result'); - resultEl.textContent = 'Update check failed: ' + err; - } finally { - updateBtn.disabled = false; - updateBtn.textContent = 'Check for Updates'; - } - }); - } + const updateBtn = body.querySelector("#settings-check-update"); + if (updateBtn) { + updateBtn.addEventListener("click", async () => { + updateBtn.disabled = true; + updateBtn.textContent = "Checking…"; + try { + const info = await window.go.api.Service.CheckForUpdates(); + const resultEl = body.querySelector("#settings-update-result"); + if (info.available) { + resultEl.innerHTML = + `Update available: ${info.latestVersion}` + + (info.releaseURL + ? ` — View release` + : ""); + } else { + resultEl.textContent = "You are running the latest version."; + } + } catch (err) { + const resultEl = body.querySelector("#settings-update-result"); + resultEl.textContent = "Update check failed: " + err; + } finally { + updateBtn.disabled = false; + updateBtn.textContent = "Check for Updates"; + } + }); + } - const importBtn = body.querySelector('#settings-import-sshconfig'); - if (importBtn) { - importBtn.addEventListener('click', async () => { - importBtn.disabled = true; - importBtn.textContent = 'Importing…'; - try { - const count = await window.go.api.Service.ImportSSHConfig('', 'SSH Config'); - this.app.notifications.success(`Imported ${count} host(s) from ~/.ssh/config`); - await this.app.loadProfiles(); - } catch (err) { - this.app.notifications.error('SSH config import failed: ' + err); - } finally { - importBtn.disabled = false; - importBtn.textContent = 'Import ~/.ssh/config'; - } - }); - } + const importBtn = body.querySelector("#settings-import-sshconfig"); + if (importBtn) { + importBtn.addEventListener("click", async () => { + importBtn.disabled = true; + importBtn.textContent = "Importing…"; + try { + const count = await window.go.api.Service.ImportSSHConfig( + "", + "SSH Config", + ); + this.app.notifications.success( + `Imported ${count} host(s) from ~/.ssh/config`, + ); + await this.app.loadProfiles(); + } catch (err) { + this.app.notifications.error("SSH config import failed: " + err); + } finally { + importBtn.disabled = false; + importBtn.textContent = "Import ~/.ssh/config"; + } + }); + } } // ── Form helpers ───────────────────────────────────────────────── @@ -734,71 +920,79 @@ class Settings { * @private */ _settingField(type, name, label, value, opts = {}) { - const group = createElement('div', 'form-group settings-field'); - const labelEl = createElement('label', 'form-label', { textContent: label, for: `setting-${name}` }); - group.appendChild(labelEl); + const group = createElement("div", "form-group settings-field"); + const labelEl = createElement("label", "form-label", { + textContent: label, + for: `setting-${name}`, + }); + group.appendChild(labelEl); - let input; - switch (type) { - case 'text': - input = createElement('input', 'form-input', { - type: 'text', - id: `setting-${name}`, - name: `setting-${name}`, - value: value || '', - placeholder: opts.placeholder || '', - }); - break; + let input; + switch (type) { + case "text": + input = createElement("input", "form-input", { + type: "text", + id: `setting-${name}`, + name: `setting-${name}`, + value: value || "", + placeholder: opts.placeholder || "", + }); + break; - case 'number': - input = createElement('input', 'form-input', { - type: 'number', - id: `setting-${name}`, - name: `setting-${name}`, - value: value != null ? String(value) : '', - }); - if (opts.min != null) input.min = opts.min; - if (opts.max != null) input.max = opts.max; - break; + case "number": + input = createElement("input", "form-input", { + type: "number", + id: `setting-${name}`, + name: `setting-${name}`, + value: value != null ? String(value) : "", + }); + if (opts.min != null) input.min = opts.min; + if (opts.max != null) input.max = opts.max; + break; - case 'select': - input = createElement('select', 'form-select', { - id: `setting-${name}`, - name: `setting-${name}`, - }); - for (const opt of (opts.options || [])) { - const option = createElement('option', '', { value: opt.value, textContent: opt.label }); - if (opt.value === value) option.selected = true; - input.appendChild(option); - } - break; + case "select": + input = createElement("select", "form-select", { + id: `setting-${name}`, + name: `setting-${name}`, + }); + for (const opt of opts.options || []) { + const option = createElement("option", "", { + value: opt.value, + textContent: opt.label, + }); + if (opt.value === value) option.selected = true; + input.appendChild(option); + } + break; - case 'toggle': { - const wrapper = createElement('div', 'form-toggle-wrapper'); - input = createElement('input', 'form-toggle', { - type: 'checkbox', - id: `setting-${name}`, - name: `setting-${name}`, - }); - input.checked = !!value; - const slider = createElement('label', 'form-toggle-label', { for: `setting-${name}` }); - wrapper.appendChild(input); - wrapper.appendChild(slider); - group.appendChild(wrapper); - return group; - } - - default: - input = createElement('input', 'form-input', { - type: 'text', - id: `setting-${name}`, - name: `setting-${name}`, - value: value || '', - }); + case "toggle": { + const wrapper = createElement("div", "form-toggle-wrapper"); + input = createElement("input", "form-toggle", { + type: "checkbox", + id: `setting-${name}`, + name: `setting-${name}`, + }); + input.checked = !!value; + const slider = createElement("label", "form-toggle-label", { + for: `setting-${name}`, + }); + wrapper.appendChild(input); + wrapper.appendChild(slider); + group.appendChild(wrapper); + return group; } - group.appendChild(input); - return group; + default: + input = createElement("input", "form-input", { + type: "text", + id: `setting-${name}`, + name: `setting-${name}`, + value: value || "", + }); + } + + group.appendChild(input); + return group; } /** @@ -806,32 +1000,39 @@ class Settings { * @private */ _collectValues() { - if (!this._overlay) return; + if (!this._overlay) return; - const val = (name) => { - const el = this._overlay.querySelector(`[name="setting-${name}"]`); - if (!el) return undefined; - if (el.type === 'checkbox') return el.checked; - if (el.type === 'number') return el.value !== '' ? Number(el.value) : 0; - return el.value; - }; + const val = (name) => { + const el = this._overlay.querySelector(`[name="setting-${name}"]`); + if (!el) return undefined; + if (el.type === "checkbox") return el.checked; + if (el.type === "number") return el.value !== "" ? Number(el.value) : 0; + return el.value; + }; - const fields = [ - 'theme', 'terminalTheme', 'defaultProtocol', 'confirmOnClose', 'confirmOnDisconnect', - 'showStatusBar', 'sidebarWidth', 'defaultFontFamily', 'defaultFontSize', - 'defaultScrollback', 'defaultCursorStyle', - ]; + const fields = [ + "theme", + "terminalTheme", + "defaultProtocol", + "confirmOnClose", + "confirmOnDisconnect", + "showStatusBar", + "sidebarWidth", + "defaultFontFamily", + "defaultFontSize", + "defaultScrollback", + "defaultCursorStyle", + ]; - for (const field of fields) { - const v = val(field); - if (v !== undefined) { - this._prefs[field] = v; - } + for (const field of fields) { + const v = val(field); + if (v !== undefined) { + this._prefs[field] = v; } + } } -} - -window.NexusTerminal = window.NexusTerminal || {}; -window.NexusTerminal.Settings = Settings; + } + window.NexusTerminal = window.NexusTerminal || {}; + window.NexusTerminal.Settings = Settings; })(); diff --git a/frontend/src/js/sidebar.js b/frontend/src/js/sidebar.js index 6ef5781..e580ff9 100644 --- a/frontend/src/js/sidebar.js +++ b/frontend/src/js/sidebar.js @@ -1,40 +1,50 @@ -(function() { -/** - * Nexus Terminal — sidebar module. - * Renders the profile list with search, sections (favourites, recent, all), - * and context menus. - */ +(function () { + /** + * Nexus Terminal — sidebar module. + * Renders the profile list with search, sections (favourites, recent, all), + * and context menus. + */ -const { createElement, escapeHtml, fuzzyMatch, protocolColour, dismissContextMenus, truncate } = window.NexusTerminal; + const { + createElement, + escapeHtml, + fuzzyMatch, + protocolColour, + dismissContextMenus, + truncate, + } = window.NexusTerminal; -/** Maximum number of profiles shown in the "Recent" section. */ -const RECENT_LIMIT = 5; + /** Maximum number of profiles shown in the "Recent" section. */ + const RECENT_LIMIT = 5; -class Sidebar { + class Sidebar { /** * @param {App} app */ constructor(app) { - this.app = app; - this.el = document.getElementById('sidebar-profiles'); - this.searchQuery = ''; + this.app = app; + this.el = document.getElementById("sidebar-profiles"); + this.searchQuery = ""; - /** @type {Set} */ - this.selectedProfiles = new Set(); + /** @type {Set} */ + this.selectedProfiles = new Set(); - // Section collapse state - this.collapsedSections = new Set(); + // Section collapse state + this.collapsedSections = new Set(); - // Dismiss context menus on any outside click - document.addEventListener('click', () => dismissContextMenus()); + // Dismiss context menus on any outside click + document.addEventListener("click", () => dismissContextMenus()); - if (this.el) { - this.el.addEventListener('click', (e) => { - if (!e.target.closest('.sidebar-item') && this.selectedProfiles.size > 0) { - this._clearSelection(); - } - }); - } + if (this.el) { + this.el.addEventListener("click", (e) => { + if ( + !e.target.closest(".sidebar-item") && + this.selectedProfiles.size > 0 + ) { + this._clearSelection(); + } + }); + } } // ── Public API ─────────────────────────────────────────────────── @@ -48,66 +58,68 @@ class Sidebar { * 4. Ungrouped (profiles with no group assigned) */ render() { - if (!this.el) return; - this.el.innerHTML = ''; + if (!this.el) return; + this.el.innerHTML = ""; - const profiles = this._filteredProfiles(); - this._syncSelection(profiles); + const profiles = this._filteredProfiles(); + this._syncSelection(profiles); - if (this.selectedProfiles.size > 0) { - this.el.appendChild(this._renderBulkActions()); + if (this.selectedProfiles.size > 0) { + this.el.appendChild(this._renderBulkActions()); + } + + // Favourites + const favourites = profiles.filter((p) => p.favourite); + if (favourites.length > 0) { + this._renderSection("★ Favourites", "favourites", favourites); + } + + // Recent + const recent = profiles + .filter((p) => p.lastConnected) + .sort((a, b) => new Date(b.lastConnected) - new Date(a.lastConnected)) + .slice(0, RECENT_LIMIT); + if (recent.length > 0) { + this._renderSection("Recent", "recent", recent); + } + + // ── Group-based sections ───────────────────────────────────── + // Collect distinct groups and sort them alphabetically. + const groupMap = new Map(); + const ungrouped = []; + + for (const p of profiles) { + const grp = (p.folder || "").trim(); + if (grp) { + if (!groupMap.has(grp)) groupMap.set(grp, []); + groupMap.get(grp).push(p); + } else { + ungrouped.push(p); } + } - // Favourites - const favourites = profiles.filter((p) => p.favourite); - if (favourites.length > 0) { - this._renderSection('★ Favourites', 'favourites', favourites); - } + // Render each group as its own collapsible section + const sortedGroups = [...groupMap.keys()].sort((a, b) => + a.localeCompare(b), + ); + for (const grp of sortedGroups) { + this._renderSection(grp, `group:${grp}`, groupMap.get(grp)); + } - // Recent - const recent = profiles - .filter((p) => p.lastConnected) - .sort((a, b) => new Date(b.lastConnected) - new Date(a.lastConnected)) - .slice(0, RECENT_LIMIT); - if (recent.length > 0) { - this._renderSection('Recent', 'recent', recent); - } + // Ungrouped connections + if (ungrouped.length > 0) { + const label = sortedGroups.length > 0 ? "Ungrouped" : "All Connections"; + this._renderSection(label, "ungrouped", ungrouped); + } - // ── Group-based sections ───────────────────────────────────── - // Collect distinct groups and sort them alphabetically. - const groupMap = new Map(); - const ungrouped = []; - - for (const p of profiles) { - const grp = (p.folder || '').trim(); - if (grp) { - if (!groupMap.has(grp)) groupMap.set(grp, []); - groupMap.get(grp).push(p); - } else { - ungrouped.push(p); - } - } - - // Render each group as its own collapsible section - const sortedGroups = [...groupMap.keys()].sort((a, b) => a.localeCompare(b)); - for (const grp of sortedGroups) { - this._renderSection(grp, `group:${grp}`, groupMap.get(grp)); - } - - // Ungrouped connections - if (ungrouped.length > 0) { - const label = sortedGroups.length > 0 ? 'Ungrouped' : 'All Connections'; - this._renderSection(label, 'ungrouped', ungrouped); - } - - // Empty state - if (profiles.length === 0) { - const empty = createElement('div', 'sidebar-empty'); - empty.innerHTML = ` + // Empty state + if (profiles.length === 0) { + const empty = createElement("div", "sidebar-empty"); + empty.innerHTML = ` `; - this.el.appendChild(empty); - } + this.el.appendChild(empty); + } } /** @@ -115,8 +127,8 @@ class Sidebar { * @param {string} query */ search(query) { - this.searchQuery = query.trim(); - this.render(); + this.searchQuery = query.trim(); + this.render(); } /** @@ -124,9 +136,12 @@ class Sidebar { * @param {string|null} profileId */ highlightActive(profileId) { - this.el.querySelectorAll('.sidebar-item').forEach((item) => { - item.classList.toggle('sidebar-item-active', item.dataset.profileId === profileId); - }); + this.el.querySelectorAll(".sidebar-item").forEach((item) => { + item.classList.toggle( + "sidebar-item-active", + item.dataset.profileId === profileId, + ); + }); } /** @@ -134,12 +149,12 @@ class Sidebar { * @param {string} sectionName */ toggleSection(sectionName) { - if (this.collapsedSections.has(sectionName)) { - this.collapsedSections.delete(sectionName); - } else { - this.collapsedSections.add(sectionName); - } - this.render(); + if (this.collapsedSections.has(sectionName)) { + this.collapsedSections.delete(sectionName); + } else { + this.collapsedSections.add(sectionName); + } + this.render(); } // ── Rendering helpers ──────────────────────────────────────────── @@ -152,36 +167,36 @@ class Sidebar { * @private */ _renderSection(title, key, profiles) { - const section = createElement('div', 'sidebar-section'); - const isCollapsed = this.collapsedSections.has(key); - const isGroupSection = key.startsWith('group:'); + const section = createElement("div", "sidebar-section"); + const isCollapsed = this.collapsedSections.has(key); + const isGroupSection = key.startsWith("group:"); - // Section header - const header = createElement( - 'div', - `sidebar-section-header ${isGroupSection ? 'sidebar-section-header-group' : ''}`.trim() - ); - header.innerHTML = ` - ${isCollapsed ? '▸' : '▾'} + // Section header + const header = createElement( + "div", + `sidebar-section-header ${isGroupSection ? "sidebar-section-header-group" : ""}`.trim(), + ); + header.innerHTML = ` + ${isCollapsed ? "▸" : "▾"} ${escapeHtml(title)} ${profiles.length} `; - // Prevent mousedown from starting a text selection gesture in WKWebView, - // which would swallow the first click and require a double-click. - header.addEventListener('mousedown', (e) => e.preventDefault()); - header.addEventListener('click', () => this.toggleSection(key)); - section.appendChild(header); + // Prevent mousedown from starting a text selection gesture in WKWebView, + // which would swallow the first click and require a double-click. + header.addEventListener("mousedown", (e) => e.preventDefault()); + header.addEventListener("click", () => this.toggleSection(key)); + section.appendChild(header); - // Items - if (!isCollapsed) { - const list = createElement('div', 'sidebar-list'); - for (const profile of profiles) { - list.appendChild(this.renderProfileItem(profile)); - } - section.appendChild(list); + // Items + if (!isCollapsed) { + const list = createElement("div", "sidebar-list"); + for (const profile of profiles) { + list.appendChild(this.renderProfileItem(profile)); } + section.appendChild(list); + } - this.el.appendChild(section); + this.el.appendChild(section); } /** @@ -190,95 +205,101 @@ class Sidebar { * @returns {HTMLElement} */ renderProfileItem(profile) { - const item = createElement('div', 'sidebar-item', { - dataset: { profileId: profile.id }, - }); + const item = createElement("div", "sidebar-item", { + dataset: { profileId: profile.id }, + }); - if (this.selectedProfiles.has(profile.id)) { - item.classList.add('sidebar-item-selected'); + if (this.selectedProfiles.has(profile.id)) { + item.classList.add("sidebar-item-selected"); + } + + // Highlight if this profile's session is active + const activeSession = this.app.state.activeSessionId + ? this.app.state.getSession(this.app.state.activeSessionId) + : null; + if (activeSession && activeSession.profileId === profile.id) { + item.classList.add("sidebar-item-active"); + } + + // Protocol icon + const protocolLabel = ""; // Seemed pretty naff to have this as well as the little icon badboy this._protocolLabel(profile.protocol); + const indicator = createElement( + "span", + `protocol-indicator ${protocolColour(profile.protocol)}`, + { + title: protocolLabel, + "aria-label": protocolLabel, + }, + ); + item.appendChild(indicator); + + // Text content + const textWrapper = createElement("div", "sidebar-item-text"); + const displayName = (profile.displayName || "").trim(); + const primaryLabel = displayName || profile.host || "Untitled"; + const nameEl = createElement("span", "sidebar-item-name", { + textContent: primaryLabel, + }); + textWrapper.appendChild(nameEl); + + const subtitle = protocolLabel; + const subEl = createElement("span", "sidebar-item-host", { + textContent: truncate(subtitle, 30), + }); + textWrapper.appendChild(subEl); + item.appendChild(textWrapper); + + // Edit button (visible on hover) + const editBtn = createElement("button", "sidebar-item-edit", { + textContent: "✎", + title: "Edit profile", + }); + editBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.app.profileEditor.openEdit(profile.id); + }); + item.appendChild(editBtn); + + // Favourite star + if (profile.favourite) { + const star = createElement("span", "sidebar-item-star is-favourite", { + textContent: "★", + }); + item.appendChild(star); + } + + // Prevent mousedown text selection in WKWebView + item.addEventListener("mousedown", (e) => e.preventDefault()); + + // Click → connect / multi-select + item.addEventListener("click", (e) => { + const multi = e.metaKey || e.ctrlKey; + if (multi) { + e.preventDefault(); + this._toggleSelection(profile.id); + return; } - // Highlight if this profile's session is active - const activeSession = this.app.state.activeSessionId - ? this.app.state.getSession(this.app.state.activeSessionId) - : null; - if (activeSession && activeSession.profileId === profile.id) { - item.classList.add('sidebar-item-active'); + if (this.selectedProfiles.size > 0) { + this._clearSelection(); } + this.app.connectProfile(profile.id); + }); - // Protocol icon - const protocolLabel = ""; // Seemed pretty naff to have this as well as the little icon badboy this._protocolLabel(profile.protocol); - const indicator = createElement('span', `protocol-indicator ${protocolColour(profile.protocol)}`, { - title: protocolLabel, - 'aria-label': protocolLabel, - }); - item.appendChild(indicator); + // Double-click → edit + item.addEventListener("dblclick", (e) => { + e.stopPropagation(); + this.app.profileEditor.openEdit(profile.id); + }); - // Text content - const textWrapper = createElement('div', 'sidebar-item-text'); - const displayName = (profile.displayName || '').trim(); - const primaryLabel = displayName || profile.host || 'Untitled'; - const nameEl = createElement('span', 'sidebar-item-name', { - textContent: primaryLabel, - }); - textWrapper.appendChild(nameEl); + // Right-click → context menu + item.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + this.renderContextMenu(profile, e.clientX, e.clientY); + }); - const subtitle = protocolLabel; - const subEl = createElement('span', 'sidebar-item-host', { - textContent: truncate(subtitle, 30), - }); - textWrapper.appendChild(subEl); - item.appendChild(textWrapper); - - // Edit button (visible on hover) - const editBtn = createElement('button', 'sidebar-item-edit', { - textContent: '✎', - title: 'Edit profile', - }); - editBtn.addEventListener('click', (e) => { - e.stopPropagation(); - this.app.profileEditor.openEdit(profile.id); - }); - item.appendChild(editBtn); - - // Favourite star - if (profile.favourite) { - const star = createElement('span', 'sidebar-item-star is-favourite', { textContent: '★' }); - item.appendChild(star); - } - - // Prevent mousedown text selection in WKWebView - item.addEventListener('mousedown', (e) => e.preventDefault()); - - // Click → connect / multi-select - item.addEventListener('click', (e) => { - const multi = e.metaKey || e.ctrlKey; - if (multi) { - e.preventDefault(); - this._toggleSelection(profile.id); - return; - } - - if (this.selectedProfiles.size > 0) { - this._clearSelection(); - } - this.app.connectProfile(profile.id); - }); - - // Double-click → edit - item.addEventListener('dblclick', (e) => { - e.stopPropagation(); - this.app.profileEditor.openEdit(profile.id); - }); - - // Right-click → context menu - item.addEventListener('contextmenu', (e) => { - e.preventDefault(); - e.stopPropagation(); - this.renderContextMenu(profile, e.clientX, e.clientY); - }); - - return item; + return item; } /** @@ -288,53 +309,62 @@ class Sidebar { * @param {number} y - Viewport Y. */ renderContextMenu(profile, x, y) { - dismissContextMenus(); + dismissContextMenus(); - const menu = createElement('div', 'context-menu'); - menu.style.left = `${x}px`; - menu.style.top = `${y}px`; + const menu = createElement("div", "context-menu"); + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; - const items = [ - { label: 'Connect', action: () => this.app.connectProfile(profile.id) }, - { label: 'Edit', action: () => this.app.profileEditor.openEdit(profile.id) }, - { label: 'Duplicate', action: () => this._duplicateProfile(profile) }, - { divider: true }, - { - label: profile.favourite ? 'Remove from Favourites' : 'Add to Favourites', - action: () => this._toggleFavourite(profile), - }, - { divider: true }, - { - label: 'Delete', - className: 'context-menu-danger', - action: () => this._deleteProfile(profile), - }, - ]; + const items = [ + { label: "Connect", action: () => this.app.connectProfile(profile.id) }, + { + label: "Edit", + action: () => this.app.profileEditor.openEdit(profile.id), + }, + { label: "Duplicate", action: () => this._duplicateProfile(profile) }, + { divider: true }, + { + label: profile.favourite + ? "Remove from Favourites" + : "Add to Favourites", + action: () => this._toggleFavourite(profile), + }, + { divider: true }, + { + label: "Delete", + className: "context-menu-danger", + action: () => this._deleteProfile(profile), + }, + ]; - for (const entry of items) { - if (entry.divider) { - menu.appendChild(createElement('div', 'context-menu-divider')); - continue; - } - const btn = createElement('button', `context-menu-item ${entry.className || ''}`, { - textContent: entry.label, - }); - btn.addEventListener('click', () => { - dismissContextMenus(); - entry.action(); - }); - menu.appendChild(btn); + for (const entry of items) { + if (entry.divider) { + menu.appendChild(createElement("div", "context-menu-divider")); + continue; } + const btn = createElement( + "button", + `context-menu-item ${entry.className || ""}`, + { + textContent: entry.label, + }, + ); + btn.addEventListener("click", () => { + dismissContextMenus(); + entry.action(); + }); + menu.appendChild(btn); + } - // Keep menu within viewport - document.body.appendChild(menu); - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) { - menu.style.left = `${window.innerWidth - rect.width - 8}px`; - } - if (rect.bottom > window.innerHeight) { - menu.style.top = `${window.innerHeight - rect.height - 8}px`; - } + // Keep menu within viewport + document.body.appendChild(menu); + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + menu.style.left = `${window.innerWidth - rect.width - 8}px`; + } + if (rect.bottom > window.innerHeight) { + menu.style.top = `${window.innerHeight - rect.height - 8}px`; + } } // ── Actions ────────────────────────────────────────────────────── @@ -345,15 +375,15 @@ class Sidebar { * @private */ async _toggleFavourite(profile) { - try { - const newVal = !profile.favourite; - await window.go.api.Service.SetFavourite(profile.id, newVal); - profile.favourite = newVal; - this.app.state.updateProfile(profile); - this.render(); - } catch (err) { - this.app.notifications.error('Failed to update favourite: ' + err); - } + try { + const newVal = !profile.favourite; + await window.go.api.Service.SetFavourite(profile.id, newVal); + profile.favourite = newVal; + this.app.state.updateProfile(profile); + this.render(); + } catch (err) { + this.app.notifications.error("Failed to update favourite: " + err); + } } /** @@ -362,14 +392,14 @@ class Sidebar { * @private */ async _duplicateProfile(profile) { - try { - const dup = await window.go.api.Service.DuplicateProfile(profile.id); - this.app.state.addProfile(dup); - this.render(); - this.app.notifications.success(`Duplicated "${profile.displayName}"`); - } catch (err) { - this.app.notifications.error('Failed to duplicate profile: ' + err); - } + try { + const dup = await window.go.api.Service.DuplicateProfile(profile.id); + this.app.state.addProfile(dup); + this.render(); + this.app.notifications.success(`Duplicated "${profile.displayName}"`); + } catch (err) { + this.app.notifications.error("Failed to duplicate profile: " + err); + } } /** @@ -378,19 +408,19 @@ class Sidebar { * @private */ async _deleteProfile(profile) { - // Use a custom confirmation modal — window.confirm() is silently - // blocked in WKWebView and always returns false. - const ok = await this._confirmDelete(profile); - if (!ok) return; + // Use a custom confirmation modal — window.confirm() is silently + // blocked in WKWebView and always returns false. + const ok = await this._confirmDelete(profile); + if (!ok) return; - try { - await window.go.api.Service.DeleteProfile(profile.id); - this.app.state.removeProfile(profile.id); - this.render(); - this.app.notifications.success('Profile deleted'); - } catch (err) { - this.app.notifications.error('Failed to delete profile: ' + err); - } + try { + await window.go.api.Service.DeleteProfile(profile.id); + this.app.state.removeProfile(profile.id); + this.render(); + this.app.notifications.success("Profile deleted"); + } catch (err) { + this.app.notifications.error("Failed to delete profile: " + err); + } } /** @@ -400,11 +430,13 @@ class Sidebar { * @private */ _confirmDelete(profile) { - return new Promise((resolve) => { - const backdrop = createElement('div', 'modal-backdrop'); - const modal = createElement('div', 'modal modal-sm'); - const label = escapeHtml(profile.displayName || profile.host || 'Untitled'); - modal.innerHTML = ` + return new Promise((resolve) => { + const backdrop = createElement("div", "modal-backdrop"); + const modal = createElement("div", "modal modal-sm"); + const label = escapeHtml( + profile.displayName || profile.host || "Untitled", + ); + modal.innerHTML = ` `; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add('is-visible'); - modal.classList.add('is-visible'); - }); - - const close = (val) => { - backdrop.classList.remove('is-visible'); - modal.classList.remove('is-visible'); - setTimeout(() => backdrop.remove(), 200); - resolve(val); - }; - modal.querySelector('.modal-close').addEventListener('click', () => close(false)); - modal.querySelector('.confirm-cancel').addEventListener('click', () => close(false)); - modal.querySelector('.confirm-ok').addEventListener('click', () => close(true)); - backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(false); }); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + requestAnimationFrame(() => { + backdrop.classList.add("is-visible"); + modal.classList.add("is-visible"); }); + + const close = (val) => { + backdrop.classList.remove("is-visible"); + modal.classList.remove("is-visible"); + setTimeout(() => backdrop.remove(), 200); + resolve(val); + }; + modal + .querySelector(".modal-close") + .addEventListener("click", () => close(false)); + modal + .querySelector(".confirm-cancel") + .addEventListener("click", () => close(false)); + modal + .querySelector(".confirm-ok") + .addEventListener("click", () => close(true)); + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) close(false); + }); + }); } // ── Multi-select + bulk actions ───────────────────────────────── @@ -447,150 +487,157 @@ class Sidebar { * @private */ _syncSelection(profiles) { - const valid = new Set(profiles.map((p) => p.id)); - for (const id of [...this.selectedProfiles]) { - if (!valid.has(id)) this.selectedProfiles.delete(id); - } + const valid = new Set(profiles.map((p) => p.id)); + for (const id of [...this.selectedProfiles]) { + if (!valid.has(id)) this.selectedProfiles.delete(id); + } } /** @private */ _toggleSelection(profileId) { - if (this.selectedProfiles.has(profileId)) { - this.selectedProfiles.delete(profileId); - } else { - this.selectedProfiles.add(profileId); - } - this.render(); + if (this.selectedProfiles.has(profileId)) { + this.selectedProfiles.delete(profileId); + } else { + this.selectedProfiles.add(profileId); + } + this.render(); } /** @private */ _clearSelection() { - if (this.selectedProfiles.size === 0) return; - this.selectedProfiles.clear(); - this.render(); + if (this.selectedProfiles.size === 0) return; + this.selectedProfiles.clear(); + this.render(); } /** @private */ _getSelectedProfiles() { - return this.app.state.profiles.filter((p) => this.selectedProfiles.has(p.id)); + return this.app.state.profiles.filter((p) => + this.selectedProfiles.has(p.id), + ); } /** @private */ _renderBulkActions() { - const bar = createElement('div', 'sidebar-bulk-actions'); - const count = createElement('span', 'sidebar-bulk-count', { - textContent: `${this.selectedProfiles.size} selected`, - }); - bar.appendChild(count); + const bar = createElement("div", "sidebar-bulk-actions"); + const count = createElement("span", "sidebar-bulk-count", { + textContent: `${this.selectedProfiles.size} selected`, + }); + bar.appendChild(count); - const actions = createElement('div', 'sidebar-bulk-buttons'); - const makeBtn = (label, handler, className) => { - const btn = createElement('button', `btn btn-xs ${className || ''}`.trim(), { - textContent: label, - }); - btn.addEventListener('click', handler); - actions.appendChild(btn); - }; + const actions = createElement("div", "sidebar-bulk-buttons"); + const makeBtn = (label, handler, className) => { + const btn = createElement( + "button", + `btn btn-xs ${className || ""}`.trim(), + { + textContent: label, + }, + ); + btn.addEventListener("click", handler); + actions.appendChild(btn); + }; - makeBtn('Connect', () => this._bulkConnect()); - makeBtn('Disconnect', () => this._bulkDisconnect(), 'btn-outline'); - makeBtn('Tags', () => this._bulkEditTags(), 'btn-outline'); - makeBtn('Move Group', () => this._bulkMoveGroup(), 'btn-outline'); - makeBtn('Clear', () => this._clearSelection(), 'btn-ghost'); + makeBtn("Connect", () => this._bulkConnect()); + makeBtn("Disconnect", () => this._bulkDisconnect(), "btn-outline"); + makeBtn("Tags", () => this._bulkEditTags(), "btn-outline"); + makeBtn("Move Group", () => this._bulkMoveGroup(), "btn-outline"); + makeBtn("Clear", () => this._clearSelection(), "btn-ghost"); - bar.appendChild(actions); - return bar; + bar.appendChild(actions); + return bar; } /** @private */ _bulkConnect() { - const profiles = this._getSelectedProfiles(); - for (const p of profiles) { - this.app.connectProfile(p.id); - } - this._clearSelection(); + const profiles = this._getSelectedProfiles(); + for (const p of profiles) { + this.app.connectProfile(p.id); + } + this._clearSelection(); } /** @private */ async _bulkDisconnect() { - const sessions = this.app.state.getAllSessions() - .filter((s) => this.selectedProfiles.has(s.profileId)); - if (sessions.length === 0) { - this.app.notifications.info('No active sessions to disconnect'); - return; - } + const sessions = this.app.state + .getAllSessions() + .filter((s) => this.selectedProfiles.has(s.profileId)); + if (sessions.length === 0) { + this.app.notifications.info("No active sessions to disconnect"); + return; + } - const ok = await this._confirmBulkDisconnect(sessions.length); - if (!ok) return; + const ok = await this._confirmBulkDisconnect(sessions.length); + if (!ok) return; - for (const s of sessions) { - await this.app.closeSessionImmediate(s.id); - } - this._clearSelection(); + for (const s of sessions) { + await this.app.closeSessionImmediate(s.id); + } + this._clearSelection(); } /** @private */ async _bulkEditTags() { - const result = await this._promptTagEdit(); - if (!result) return; + const result = await this._promptTagEdit(); + if (!result) return; - const profiles = this._getSelectedProfiles(); - for (const p of profiles) { - const updated = JSON.parse(JSON.stringify(p)); - const existing = updated.tags || []; - if (result.mode === 'replace') { - updated.tags = result.tags; - } else { - const merged = new Set([...existing, ...result.tags]); - updated.tags = [...merged]; - } - - try { - const saved = await window.go.api.Service.UpdateProfile(updated); - this.app.state.updateProfile(saved); - } catch (err) { - this.app.notifications.error('Failed to update tags: ' + err); - } + const profiles = this._getSelectedProfiles(); + for (const p of profiles) { + const updated = JSON.parse(JSON.stringify(p)); + const existing = updated.tags || []; + if (result.mode === "replace") { + updated.tags = result.tags; + } else { + const merged = new Set([...existing, ...result.tags]); + updated.tags = [...merged]; } - this.render(); - this._clearSelection(); + try { + const saved = await window.go.api.Service.UpdateProfile(updated); + this.app.state.updateProfile(saved); + } catch (err) { + this.app.notifications.error("Failed to update tags: " + err); + } + } + + this.render(); + this._clearSelection(); } /** @private */ async _bulkMoveGroup() { - const folder = await this._promptGroupMove(); - if (folder === null) return; + const folder = await this._promptGroupMove(); + if (folder === null) return; - const profiles = this._getSelectedProfiles(); - for (const p of profiles) { - const updated = JSON.parse(JSON.stringify(p)); - updated.folder = folder; - try { - const saved = await window.go.api.Service.UpdateProfile(updated); - this.app.state.updateProfile(saved); - } catch (err) { - this.app.notifications.error('Failed to move group: ' + err); - } + const profiles = this._getSelectedProfiles(); + for (const p of profiles) { + const updated = JSON.parse(JSON.stringify(p)); + updated.folder = folder; + try { + const saved = await window.go.api.Service.UpdateProfile(updated); + this.app.state.updateProfile(saved); + } catch (err) { + this.app.notifications.error("Failed to move group: " + err); } + } - this.render(); - this._clearSelection(); + this.render(); + this._clearSelection(); } /** @private */ _confirmBulkDisconnect(count) { - return new Promise((resolve) => { - const backdrop = createElement('div', 'modal-backdrop'); - const modal = createElement('div', 'modal modal-sm'); - modal.innerHTML = ` + return new Promise((resolve) => { + const backdrop = createElement("div", "modal-backdrop"); + const modal = createElement("div", "modal modal-sm"); + modal.innerHTML = ` `; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add('is-visible'); - modal.classList.add('is-visible'); - }); - - const close = (val) => { - backdrop.classList.remove('is-visible'); - modal.classList.remove('is-visible'); - setTimeout(() => backdrop.remove(), 200); - resolve(val); - }; - modal.querySelector('.modal-close').addEventListener('click', () => close(false)); - modal.querySelector('.confirm-cancel').addEventListener('click', () => close(false)); - modal.querySelector('.confirm-ok').addEventListener('click', () => close(true)); - backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(false); }); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + requestAnimationFrame(() => { + backdrop.classList.add("is-visible"); + modal.classList.add("is-visible"); }); + + const close = (val) => { + backdrop.classList.remove("is-visible"); + modal.classList.remove("is-visible"); + setTimeout(() => backdrop.remove(), 200); + resolve(val); + }; + modal + .querySelector(".modal-close") + .addEventListener("click", () => close(false)); + modal + .querySelector(".confirm-cancel") + .addEventListener("click", () => close(false)); + modal + .querySelector(".confirm-ok") + .addEventListener("click", () => close(true)); + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) close(false); + }); + }); } /** @private */ _promptTagEdit() { - return new Promise((resolve) => { - const backdrop = createElement('div', 'modal-backdrop'); - const modal = createElement('div', 'modal modal-sm'); - modal.innerHTML = ` + return new Promise((resolve) => { + const backdrop = createElement("div", "modal-backdrop"); + const modal = createElement("div", "modal modal-sm"); + modal.innerHTML = ` `; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add('is-visible'); - modal.classList.add('is-visible'); - }); - - const input = modal.querySelector('.tag-input'); - const parseTags = () => { - const raw = (input.value || '').split(','); - return raw.map((t) => t.trim()).filter((t) => t.length > 0); - }; - - const close = (val) => { - backdrop.classList.remove('is-visible'); - modal.classList.remove('is-visible'); - setTimeout(() => backdrop.remove(), 200); - resolve(val); - }; - modal.querySelector('.modal-close').addEventListener('click', () => close(null)); - modal.querySelector('.confirm-cancel').addEventListener('click', () => close(null)); - modal.querySelector('.confirm-add').addEventListener('click', () => { - close({ mode: 'add', tags: parseTags() }); - }); - modal.querySelector('.confirm-replace').addEventListener('click', () => { - close({ mode: 'replace', tags: parseTags() }); - }); - backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(null); }); - setTimeout(() => input.focus(), 50); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + requestAnimationFrame(() => { + backdrop.classList.add("is-visible"); + modal.classList.add("is-visible"); }); + + const input = modal.querySelector(".tag-input"); + const parseTags = () => { + const raw = (input.value || "").split(","); + return raw.map((t) => t.trim()).filter((t) => t.length > 0); + }; + + const close = (val) => { + backdrop.classList.remove("is-visible"); + modal.classList.remove("is-visible"); + setTimeout(() => backdrop.remove(), 200); + resolve(val); + }; + modal + .querySelector(".modal-close") + .addEventListener("click", () => close(null)); + modal + .querySelector(".confirm-cancel") + .addEventListener("click", () => close(null)); + modal.querySelector(".confirm-add").addEventListener("click", () => { + close({ mode: "add", tags: parseTags() }); + }); + modal + .querySelector(".confirm-replace") + .addEventListener("click", () => { + close({ mode: "replace", tags: parseTags() }); + }); + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) close(null); + }); + setTimeout(() => input.focus(), 50); + }); } /** @private */ _promptGroupMove() { - return new Promise((resolve) => { - const backdrop = createElement('div', 'modal-backdrop'); - const modal = createElement('div', 'modal modal-sm'); - modal.innerHTML = ` + return new Promise((resolve) => { + const backdrop = createElement("div", "modal-backdrop"); + const modal = createElement("div", "modal modal-sm"); + modal.innerHTML = ` `; - backdrop.appendChild(modal); - document.body.appendChild(backdrop); - requestAnimationFrame(() => { - backdrop.classList.add('is-visible'); - modal.classList.add('is-visible'); - }); - - const input = modal.querySelector('.group-input'); - const close = (val) => { - backdrop.classList.remove('is-visible'); - modal.classList.remove('is-visible'); - setTimeout(() => backdrop.remove(), 200); - resolve(val); - }; - modal.querySelector('.modal-close').addEventListener('click', () => close(null)); - modal.querySelector('.confirm-cancel').addEventListener('click', () => close(null)); - modal.querySelector('.confirm-ok').addEventListener('click', () => close(input.value.trim())); - backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(null); }); - setTimeout(() => input.focus(), 50); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + requestAnimationFrame(() => { + backdrop.classList.add("is-visible"); + modal.classList.add("is-visible"); }); + + const input = modal.querySelector(".group-input"); + const close = (val) => { + backdrop.classList.remove("is-visible"); + modal.classList.remove("is-visible"); + setTimeout(() => backdrop.remove(), 200); + resolve(val); + }; + modal + .querySelector(".modal-close") + .addEventListener("click", () => close(null)); + modal + .querySelector(".confirm-cancel") + .addEventListener("click", () => close(null)); + modal + .querySelector(".confirm-ok") + .addEventListener("click", () => close(input.value.trim())); + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) close(null); + }); + setTimeout(() => input.focus(), 50); + }); } // ── Filtering ──────────────────────────────────────────────────── @@ -726,23 +797,23 @@ class Sidebar { * @private */ _filteredProfiles() { - if (!this.searchQuery) return this.app.state.profiles; + if (!this.searchQuery) return this.app.state.profiles; - return this.app.state.profiles - .map((p) => { - // Match against display name, host, tags, and protocol - const fields = [ - p.displayName || '', - p.host || '', - (p.tags || []).join(' '), - p.protocol || '', - ].join(' '); - const result = fuzzyMatch(fields, this.searchQuery); - return { profile: p, ...result }; - }) - .filter((r) => r.match) - .sort((a, b) => b.score - a.score) - .map((r) => r.profile); + return this.app.state.profiles + .map((p) => { + // Match against display name, host, tags, and protocol + const fields = [ + p.displayName || "", + p.host || "", + (p.tags || []).join(" "), + p.protocol || "", + ].join(" "); + const result = fuzzyMatch(fields, this.searchQuery); + return { profile: p, ...result }; + }) + .filter((r) => r.match) + .sort((a, b) => b.score - a.score) + .map((r) => r.profile); } /** @@ -752,22 +823,21 @@ class Sidebar { * @private */ _protocolLabel(protocol) { - switch (protocol) { - case 'github': - return 'GitHub'; - case 'ssh': - return 'SSH'; - case 'telnet': - return 'TELNET'; - case 'raw': - return 'RAW'; - default: - return (protocol || '').toUpperCase() || 'UNKNOWN'; - } + switch (protocol) { + case "github": + return "GitHub"; + case "ssh": + return "SSH"; + case "telnet": + return "TELNET"; + case "raw": + return "RAW"; + default: + return (protocol || "").toUpperCase() || "UNKNOWN"; + } } -} - -window.NexusTerminal = window.NexusTerminal || {}; -window.NexusTerminal.Sidebar = Sidebar; + } + window.NexusTerminal = window.NexusTerminal || {}; + window.NexusTerminal.Sidebar = Sidebar; })(); diff --git a/frontend/src/js/terminal.js b/frontend/src/js/terminal.js index 9848db8..1cbeaea 100644 --- a/frontend/src/js/terminal.js +++ b/frontend/src/js/terminal.js @@ -214,6 +214,10 @@ searchAddon, webLinksAddon, observer, + decoder: + typeof TextDecoder !== "undefined" + ? new TextDecoder("utf-8") + : null, }); return term; @@ -230,7 +234,16 @@ try { const raw = atob(base64Data); - entry.term.write(raw); + if (entry.decoder) { + const bytes = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) { + bytes[i] = raw.charCodeAt(i); + } + const text = entry.decoder.decode(bytes, { stream: true }); + entry.term.write(text); + } else { + entry.term.write(raw); + } } catch (err) { console.error("Failed to decode session data:", err); }