From c98ace239696688194402bd373a65e494f1f9c7e Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 16 Apr 2026 00:00:00 +0100 Subject: [PATCH] =?UTF-8?q?Add=20GitHub=20connection=20type=20=E2=80=94=20?= =?UTF-8?q?repo=20viewer=20with=20org=20grouping=20and=20auto-refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New protocol type 'github' for browsing repositories via the GitHub REST API. Creates a non-terminal panel showing repos grouped by owner/org, sorted by last commit date (newest first), with search filtering and auto-refresh. Backend: - ProtocolGitHub constant and GitHubConfig/GitHubRepo model structs - New internal/githubclient package (REST API, pagination, token validation) - Session manager connectGitHub() with periodic refresh goroutine - API surface: RefreshGitHubRepos() for on-demand refresh - Token stored via existing credential infrastructure (keychain/vault) Frontend: - GitHub tab in profile editor with API URL and token storage fields - New github.js viewer component (grouped repos, badges, language dots) - github.css with charcoal-themed repo cards and org headers - Protocol colour tokens, tab badge, sidebar indicator for GitHub - Event wiring for session:github:repos and session:github:error - Cleanup on tab close Supports GitHub Enterprise via configurable API base URL. --- frontend/index.html | 2 + frontend/src/css/components.css | 4 + frontend/src/css/forms.css | 6 +- frontend/src/css/github.css | 266 ++++++++++++++++++ frontend/src/css/sidebar.css | 1 + frontend/src/css/tabs.css | 4 + frontend/src/css/tokens.css | 1 + frontend/src/js/app.js | 21 ++ frontend/src/js/github.js | 385 ++++++++++++++++++++++++++ frontend/src/js/profiles.js | 79 +++++- frontend/src/js/tabs.js | 7 +- frontend/src/js/utils.js | 31 ++- frontend/wailsjs/go/api/Service.d.ts | 2 + frontend/wailsjs/go/api/Service.js | 4 + frontend/wailsjs/go/models.ts | 52 ++++ internal/api/api.go | 6 + internal/githubclient/githubclient.go | 163 +++++++++++ internal/models/defaults.go | 9 + internal/models/models.go | 34 +++ internal/sessions/manager.go | 123 +++++++- 20 files changed, 1179 insertions(+), 21 deletions(-) create mode 100644 frontend/src/css/github.css create mode 100644 frontend/src/js/github.js create mode 100644 internal/githubclient/githubclient.go diff --git a/frontend/index.html b/frontend/index.html index 31d2229..fa995ab 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,6 +18,7 @@ +
@@ -170,6 +171,7 @@ + diff --git a/frontend/src/css/components.css b/frontend/src/css/components.css index 22f368b..ee7ff80 100644 --- a/frontend/src/css/components.css +++ b/frontend/src/css/components.css @@ -419,6 +419,10 @@ background: rgba(171, 71, 188, 0.15); color: var(--protocol-raw); } +.badge-github { + background: rgba(124, 138, 255, 0.15); + color: var(--protocol-github); +} /* Status badges */ .badge-status { diff --git a/frontend/src/css/forms.css b/frontend/src/css/forms.css index 20f2431..d5ed6fe 100644 --- a/frontend/src/css/forms.css +++ b/frontend/src/css/forms.css @@ -148,14 +148,16 @@ .protocol-ssh .proto-only-ssh, .protocol-telnet .proto-only-telnet, -.protocol-raw .proto-only-raw { +.protocol-raw .proto-only-raw, +.protocol-github .proto-only-github { display: flex; } /* For block-level protocol fields */ .protocol-ssh .proto-only-ssh.form-section, .protocol-telnet .proto-only-telnet.form-section, -.protocol-raw .proto-only-raw.form-section { +.protocol-raw .proto-only-raw.form-section, +.protocol-github .proto-only-github.form-section { display: block; } diff --git a/frontend/src/css/github.css b/frontend/src/css/github.css new file mode 100644 index 0000000..56d4f52 --- /dev/null +++ b/frontend/src/css/github.css @@ -0,0 +1,266 @@ +/* ========================================================================= + GitHub Repository Viewer + Styles for the non-terminal GitHub connection panel. + ========================================================================= */ + +.github-viewer { + 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); + 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; +} + +.github-viewer-icon { + 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; +} + +.github-viewer-search { + 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; +} + +.github-viewer-search-input:focus { + border-color: var(--protocol-github); +} + +.github-viewer-search-input::placeholder { + 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; +} + +.github-viewer-refresh:hover { + 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); + 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); +} + +/* ── Repo list ─────────────────────────────────────────────────────────── */ + +.github-viewer-list { + flex: 1; + overflow-y: auto; + padding: var(--space-3) var(--space-4); +} + +.github-viewer-empty { + 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); +} + +.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); +} + +.github-group-name { + 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); +} + +/* ── 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; +} + +.github-repo-card:hover { + background: var(--bg-hover); +} + +.github-repo-top { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: 2px; +} + +.github-repo-name { + 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; +} + +.github-repo-private { + background: rgba(255, 167, 38, 0.12); + color: #ffa726; +} + +.github-repo-fork { + background: rgba(78, 205, 196, 0.12); + color: #4ecdc4; +} + +.github-repo-archived { + 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; +} + +.github-repo-meta { + 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; +} + +.github-lang-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} + +.github-repo-stars { + /* Star colour is inherent in the text */ +} + +.github-repo-pushed { + /* Relative time text */ +} + +/* ── Scrollbar styling ─────────────────────────────────────────────────── */ + +.github-viewer-list::-webkit-scrollbar { + width: 6px; +} + +.github-viewer-list::-webkit-scrollbar-track { + background: transparent; +} + +.github-viewer-list::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 3px; +} + +.github-viewer-list::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} diff --git a/frontend/src/css/sidebar.css b/frontend/src/css/sidebar.css index df3831e..89e1436 100644 --- a/frontend/src/css/sidebar.css +++ b/frontend/src/css/sidebar.css @@ -185,6 +185,7 @@ .sidebar-item .protocol-indicator.proto-ssh { background: var(--protocol-ssh); } .sidebar-item .protocol-indicator.proto-telnet { background: var(--protocol-telnet); } .sidebar-item .protocol-indicator.proto-raw { background: var(--protocol-raw); } +.sidebar-item .protocol-indicator.proto-github { background: var(--protocol-github); } /* Text block (name + host) */ .sidebar-item-text { diff --git a/frontend/src/css/tabs.css b/frontend/src/css/tabs.css index a9cbba1..dab622d 100644 --- a/frontend/src/css/tabs.css +++ b/frontend/src/css/tabs.css @@ -107,6 +107,10 @@ background: rgba(171, 71, 188, 0.15); color: var(--protocol-raw); } +.tab-badge.badge-github { + background: rgba(124, 138, 255, 0.15); + color: var(--protocol-github); +} /* ----------------------------------------------------------------------- Status dot (connection state) diff --git a/frontend/src/css/tokens.css b/frontend/src/css/tokens.css index 40c4c45..e98a741 100644 --- a/frontend/src/css/tokens.css +++ b/frontend/src/css/tokens.css @@ -55,6 +55,7 @@ --protocol-ssh: #4ecdc4; --protocol-telnet: #ffa726; --protocol-raw: #ab47bc; + --protocol-github: #7c8aff; /* ----------------------------------------------------------------------- Spacing scale (4 px base unit) diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index 81dcb02..ea6c8d5 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -166,6 +166,20 @@ class App { this.rawViewer.handleData(payload); }); + // GitHub repository data + window.runtime.EventsOn('session:github:repos', (payload) => { + window.NexusTerminal.GitHubViewer.updateRepos( + payload.sessionID, payload.login, payload.repos + ); + }); + + // GitHub refresh errors + window.runtime.EventsOn('session:github:error', (payload) => { + window.NexusTerminal.GitHubViewer.showError( + payload.sessionID, payload.error + ); + }); + // Telnet negotiation debug messages window.runtime.EventsOn('session:debug', (payload) => { console.debug('[session:debug]', payload); @@ -341,6 +355,13 @@ class App { const panelId = `panel-${sessionInfo.id}`; if (profile.protocol === 'raw') { this.rawViewer.createViewer(sessionInfo.id, panelId, profile); + } else if (profile.protocol === 'github') { + const container = document.getElementById(panelId); + if (container) { + window.NexusTerminal.GitHubViewer.createViewer( + sessionInfo.id, container, profile.displayName || profile.host + ); + } } else { this.terminals.createTerminal(sessionInfo.id, panelId, profile); } diff --git a/frontend/src/js/github.js b/frontend/src/js/github.js new file mode 100644 index 0000000..fd23c97 --- /dev/null +++ b/frontend/src/js/github.js @@ -0,0 +1,385 @@ +(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; + +class GitHubViewer { + constructor() { + /** @type {Map} sessionID → viewer */ + this._viewers = new Map(); + } + + /** + * Creates a viewer panel for a GitHub session inside the given container. + * @param {string} sessionID + * @param {HTMLElement} container - The .session-panel element. + * @param {string} profileName + */ + createViewer(sessionID, container, profileName) { + if (this._viewers.has(sessionID)) return; + + const viewer = new GitHubViewerInstance(sessionID, container, profileName); + this._viewers.set(sessionID, viewer); + viewer.render(); + } + + /** + * Destroys a viewer for the given session. + * @param {string} sessionID + */ + destroyViewer(sessionID) { + const viewer = this._viewers.get(sessionID); + if (viewer) { + viewer.destroy(); + this._viewers.delete(sessionID); + } + } + + /** + * Handles incoming repo data from the backend event. + * @param {string} sessionID + * @param {string} login - The authenticated GitHub username. + * @param {Array} repos + */ + updateRepos(sessionID, login, repos) { + const viewer = this._viewers.get(sessionID); + if (viewer) { + viewer.setData(login, repos); + } + } + + /** + * Handles an error event for a session. + * @param {string} sessionID + * @param {string} message + */ + showError(sessionID, message) { + const viewer = this._viewers.get(sessionID); + if (viewer) { + viewer.showError(message); + } + } +} + +class GitHubViewerInstance { + /** + * @param {string} sessionID + * @param {HTMLElement} container + * @param {string} profileName + */ + constructor(sessionID, container, profileName) { + this.sessionID = sessionID; + this.container = container; + this.profileName = profileName; + + /** @type {string} */ + this._login = ''; + + /** @type {Array} */ + this._repos = []; + + /** @type {string} */ + this._filter = ''; + + /** @type {HTMLElement|null} */ + this._el = null; + + /** @type {HTMLElement|null} */ + this._listEl = null; + + /** @type {HTMLElement|null} */ + this._statusEl = null; + + /** @type {number|null} */ + this._refreshTimer = null; + } + + render() { + this.container.innerHTML = ''; + + const wrapper = createElement('div', 'github-viewer'); + + // ── 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 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); + + // 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); + + // ── 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); + + this.container.appendChild(wrapper); + this._el = wrapper; + } + + /** + * Updates the viewer with fresh data. + * @param {string} login + * @param {Array} repos + */ + setData(login, repos) { + this._login = login; + this._repos = repos || []; + this._updateStatus(); + this._renderList(); + } + + /** + * Displays an error message in the status bar. + * @param {string} message + */ + showError(message) { + 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; + } + } + + // ── Private ────────────────────────────────────────────────── + + _updateStatus() { + 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}`; + } + + _renderList() { + 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) + ); + } + + 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); + } + + // 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); + }); + + 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); + + // Repo cards + for (const repo of repos) { + groupEl.appendChild(this._renderRepoCard(repo)); + } + + this._listEl.appendChild(groupEl); + } + } + + /** + * Renders a single repository card. + * @param {Object} repo + * @returns {HTMLElement} + */ + _renderRepoCard(repo) { + 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); + + 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); + } + + card.appendChild(topRow); + + // Description + if (repo.description) { + const desc = createElement('div', 'github-repo-desc'); + desc.textContent = repo.description; + card.appendChild(desc); + } + + // Meta row: language, stars, pushed + const meta = createElement('div', 'github-repo-meta'); + + if (repo.language) { + const lang = createElement('span', 'github-repo-lang'); + lang.innerHTML = ` ${escapeHtml(repo.language)}`; + meta.appendChild(lang); + } + + 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; + } + + 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'); + } + } + + /** + * Returns a colour for the given programming language. + * Uses a subset of GitHub's linguist colours. + * @param {string} lang + * @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'; + } + + /** + * GitHub Invertocat SVG icon. + * @returns {string} + */ + _githubSVG() { + return ''; + } +} + +window.NexusTerminal = window.NexusTerminal || {}; +window.NexusTerminal.GitHubViewer = new GitHubViewer(); + +})(); diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js index 8b2c016..2bbfb14 100644 --- a/frontend/src/js/profiles.js +++ b/frontend/src/js/profiles.js @@ -52,6 +52,16 @@ const DEFAULTS = { payloadPresets: [], }, }, + github: { + port: 443, + protocol: 'github', + host: 'github.com', + github: { + baseURL: 'https://api.github.com', + secretMode: 'saved', + refreshIntervalSecs: 300, + }, + }, }; class ProfileEditor { @@ -119,11 +129,12 @@ class ProfileEditor { drawer.appendChild(header); // ── Protocol selector tabs ────────────────────────────── - const protocols = ['ssh', 'telnet', 'raw']; + 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: proto.toUpperCase(), + textContent: label, dataset: { proto }, }); btn.addEventListener('click', () => this.handleProtocolChange(proto)); @@ -148,10 +159,13 @@ class ProfileEditor { 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') { + if (profile.protocol !== 'raw' && profile.protocol !== 'github') { body.appendChild(this._renderTerminalSection(profile)); } @@ -237,14 +251,24 @@ class ProfileEditor { /** @private */ _renderGeneralSection(profile) { - return this._renderSection('General', [ + const fields = [ this._field('text', 'displayName', 'Display Name', profile.displayName, { placeholder: 'My Server' }), - this._field('text', 'host', 'Host', profile.host, { placeholder: '192.168.1.1 or example.com', required: true }), - this._field('number', 'port', 'Port', profile.port, { min: 1, max: 65535, required: true }), - this._field('text', 'username', 'Username', profile.username, { placeholder: 'root' }), - this._renderGroupField(profile), - this._field('text', 'description', 'Description', profile.description, { placeholder: 'Optional description' }), - ]); + 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' })); + } + + fields.push(this._renderGroupField(profile)); + fields.push(this._field('text', 'description', 'Description', profile.description, { placeholder: 'Optional description' })); + + return this._renderSection('General', fields); } /** @@ -451,6 +475,33 @@ class ProfileEditor { return sections; } + /** @private */ + _renderGitHubSections(profile) { + 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('Refresh', [ + this._field('number', 'github.refreshIntervalSecs', 'Refresh Interval (seconds)', gh.refreshIntervalSecs, { + min: 60, max: 3600, + }), + ])); + + return sections; + } + /** @private */ _renderTerminalSection(profile) { return this._renderSection('Terminal', [ @@ -843,6 +894,14 @@ class ProfileEditor { } 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 ─────────────────────────────────────────────────── diff --git a/frontend/src/js/tabs.js b/frontend/src/js/tabs.js index ff7630c..4d31e35 100644 --- a/frontend/src/js/tabs.js +++ b/frontend/src/js/tabs.js @@ -141,6 +141,7 @@ class TabManager { this.app.terminals.destroyTerminal(sessionId); this.app.rawViewer.destroyViewer(sessionId); + window.NexusTerminal.GitHubViewer.destroyViewer(sessionId); this.app.state.removeSession(sessionId); this.removeTab(sessionId); } @@ -394,8 +395,9 @@ class TabManager { }); // Protocol badge - const badge = createElement('span', `tab-badge ${protocolColour(sessionInfo.protocol)}`, { - textContent: sessionInfo.protocol.toUpperCase(), + const protoLabel = sessionInfo.protocol === 'github' ? 'GitHub' : sessionInfo.protocol.toUpperCase(); + const badge = createElement('span', `tab-badge badge-${sessionInfo.protocol}`, { + textContent: protoLabel, }); tab.appendChild(badge); @@ -515,6 +517,7 @@ class TabManager { // Close the current tab, then open a fresh connection this.app.terminals.destroyTerminal(sessionId); this.app.rawViewer.destroyViewer(sessionId); + window.NexusTerminal.GitHubViewer.destroyViewer(sessionId); this.app.state.removeSession(sessionId); this.removeTab(sessionId); diff --git a/frontend/src/js/utils.js b/frontend/src/js/utils.js index f950a86..23afdc4 100644 --- a/frontend/src/js/utils.js +++ b/frontend/src/js/utils.js @@ -202,7 +202,7 @@ function dismissContextMenus() { /** * Returns the protocol badge colour class. - * @param {string} protocol - 'ssh', 'telnet', or 'raw'. + * @param {string} protocol - 'ssh', 'telnet', 'raw', or 'github'. * @returns {string} */ function protocolColour(protocol) { @@ -210,10 +210,37 @@ function protocolColour(protocol) { case 'ssh': return 'proto-ssh'; case 'telnet': return 'proto-telnet'; case 'raw': return 'proto-raw'; + case 'github': return 'proto-github'; default: return ''; } } +/** + * Returns a human-friendly relative time string for an ISO date. + * @param {string} isoDate - ISO 8601 date string. + * @returns {string} e.g. "3 hours ago", "2 days ago". + */ +function relativeTime(isoDate) { + if (!isoDate) return ''; + const d = new Date(isoDate); + if (isNaN(d.getTime())) return ''; + + const now = Date.now(); + const diff = now - d.getTime(); + const secs = Math.floor(diff / 1000); + if (secs < 60) return 'just now'; + const mins = Math.floor(secs / 60); + if (mins < 60) return mins === 1 ? '1 minute ago' : `${mins} minutes ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`; + const days = Math.floor(hrs / 24); + if (days < 30) return days === 1 ? '1 day ago' : `${days} days ago`; + const months = Math.floor(days / 30); + if (months < 12) return months === 1 ? '1 month ago' : `${months} months ago`; + const years = Math.floor(months / 12); + return years === 1 ? '1 year ago' : `${years} years ago`; +} + /** * Returns a human-friendly label for a session state. * @param {string} state @@ -230,6 +257,6 @@ function stateLabel(state) { } window.NexusTerminal = window.NexusTerminal || {}; -Object.assign(window.NexusTerminal, { $, $$, createElement, escapeHtml, formatTimestamp, debounce, fuzzyMatch, base64Encode, base64Decode, generateId, formatBytes, truncate, dismissContextMenus, protocolColour, stateLabel }); +Object.assign(window.NexusTerminal, { $, $$, createElement, escapeHtml, formatTimestamp, debounce, fuzzyMatch, base64Encode, base64Decode, generateId, formatBytes, truncate, dismissContextMenus, protocolColour, stateLabel, relativeTime }); })(); diff --git a/frontend/wailsjs/go/api/Service.d.ts b/frontend/wailsjs/go/api/Service.d.ts index 59e7014..e119c55 100755 --- a/frontend/wailsjs/go/api/Service.d.ts +++ b/frontend/wailsjs/go/api/Service.d.ts @@ -65,6 +65,8 @@ export function PinSession(arg1:string,arg2:boolean):Promise; export function ReconnectSession(arg1:string):Promise; +export function RefreshGitHubRepos(arg1:string):Promise>; + export function RegisterSecretResponseListener():Promise; export function RenameSession(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/api/Service.js b/frontend/wailsjs/go/api/Service.js index 25414b9..20b5570 100755 --- a/frontend/wailsjs/go/api/Service.js +++ b/frontend/wailsjs/go/api/Service.js @@ -126,6 +126,10 @@ export function ReconnectSession(arg1) { return window['go']['api']['Service']['ReconnectSession'](arg1); } +export function RefreshGitHubRepos(arg1) { + return window['go']['api']['Service']['RefreshGitHubRepos'](arg1); +} + export function RegisterSecretResponseListener() { return window['go']['api']['Service']['RegisterSecretResponseListener'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index a02f709..b4a2b11 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -71,6 +71,56 @@ export namespace models { return a; } } + export class GitHubConfig { + baseURL: string; + secretMode: string; + refreshIntervalSecs: number; + + static createFrom(source: any = {}) { + return new GitHubConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.baseURL = source["baseURL"]; + this.secretMode = source["secretMode"]; + this.refreshIntervalSecs = source["refreshIntervalSecs"]; + } + } + export class GitHubRepo { + fullName: string; + name: string; + owner: string; + description: string; + htmlURL: string; + private: boolean; + fork: boolean; + archived: boolean; + language: string; + stars: number; + pushedAt: string; + updatedAt: string; + + static createFrom(source: any = {}) { + return new GitHubRepo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.fullName = source["fullName"]; + this.name = source["name"]; + this.owner = source["owner"]; + this.description = source["description"]; + this.htmlURL = source["htmlURL"]; + this.private = source["private"]; + this.fork = source["fork"]; + this.archived = source["archived"]; + this.language = source["language"]; + this.stars = source["stars"]; + this.pushedAt = source["pushedAt"]; + this.updatedAt = source["updatedAt"]; + } + } export class JumpHost { host: string; port: number; @@ -301,6 +351,7 @@ export namespace models { ssh?: SSHConfig; telnet?: TelnetConfig; raw?: RAWConfig; + github?: GitHubConfig; static createFrom(source: any = {}) { return new Profile(source); @@ -332,6 +383,7 @@ export namespace models { this.ssh = this.convertValues(source["ssh"], SSHConfig); this.telnet = this.convertValues(source["telnet"], TelnetConfig); this.raw = this.convertValues(source["raw"], RAWConfig); + this.github = this.convertValues(source["github"], GitHubConfig); } convertValues(a: any, classs: any, asMap: boolean = false): any { diff --git a/internal/api/api.go b/internal/api/api.go index 2ee1094..f91d2f7 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -618,6 +618,12 @@ func (s *Service) GetAppInfo() map[string]string { } } +// RefreshGitHubRepos triggers an on-demand refresh of GitHub repositories for +// the given session. The updated list is also emitted as a Wails event. +func (s *Service) RefreshGitHubRepos(sessionID string) ([]models.GitHubRepo, error) { + return s.sessions.FetchGitHubRepos(sessionID) +} + // TestConnection performs a basic TCP reachability check to the given host and port. // This verifies the network path without establishing a full protocol session. func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) { diff --git a/internal/githubclient/githubclient.go b/internal/githubclient/githubclient.go new file mode 100644 index 0000000..37e36eb --- /dev/null +++ b/internal/githubclient/githubclient.go @@ -0,0 +1,163 @@ +// Package githubclient provides a lightweight GitHub REST API client for +// fetching repository metadata. It supports both github.com and GitHub +// Enterprise instances via a configurable base URL. +package githubclient + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "github.com/copilot-ssh-client/app/internal/models" +) + +// Client wraps the GitHub REST API for repository listing. +type Client struct { + baseURL string + token string + httpClient *http.Client +} + +// New creates a Client for the given API base URL and personal access token. +// baseURL should be the API root, e.g. "https://api.github.com" or +// "https://ghe.example.com/api/v3". +func New(baseURL, token string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// apiRepo is the subset of the GitHub API response we care about. +type apiRepo struct { + FullName string `json:"full_name"` + Name string `json:"name"` + Description string `json:"description"` + HTMLURL string `json:"html_url"` + Private bool `json:"private"` + Fork bool `json:"fork"` + Archived bool `json:"archived"` + Language string `json:"language"` + Stars int `json:"stargazers_count"` + PushedAt time.Time `json:"pushed_at"` + UpdatedAt time.Time `json:"updated_at"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` +} + +// FetchRepos retrieves all repositories visible to the authenticated user, +// paginating through the API. Results are sorted by pushed_at descending +// (most recently active first). +func (c *Client) FetchRepos(ctx context.Context) ([]models.GitHubRepo, error) { + var all []apiRepo + + page := 1 + for { + url := fmt.Sprintf("%s/user/repos?per_page=100&page=%d&sort=pushed&direction=desc", c.baseURL, page) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "NexusTerminal/1.0") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching repos page %d: %w", page, err) + } + + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return nil, fmt.Errorf("reading response body: %w", readErr) + } + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return nil, fmt.Errorf("authentication failed (HTTP %d) — check your access token", resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, string(body)) + } + + var batch []apiRepo + if err := json.Unmarshal(body, &batch); err != nil { + return nil, fmt.Errorf("decoding repos: %w", err) + } + + if len(batch) == 0 { + break + } + all = append(all, batch...) + + // Safety cap — 5000 repos should be more than enough. + if len(all) >= 5000 || len(batch) < 100 { + break + } + page++ + } + + // Sort by pushed_at descending (newest first). + sort.Slice(all, func(i, j int) bool { + return all[i].PushedAt.After(all[j].PushedAt) + }) + + result := make([]models.GitHubRepo, len(all)) + for i, r := range all { + result[i] = models.GitHubRepo{ + FullName: r.FullName, + Name: r.Name, + Owner: r.Owner.Login, + Description: r.Description, + HTMLURL: r.HTMLURL, + Private: r.Private, + Fork: r.Fork, + Archived: r.Archived, + Language: r.Language, + Stars: r.Stars, + PushedAt: r.PushedAt.Format(time.RFC3339), + UpdatedAt: r.UpdatedAt.Format(time.RFC3339), + } + } + return result, nil +} + +// ValidateToken makes a lightweight call to /user to confirm the token works. +func (c *Client) ValidateToken(ctx context.Context) (string, error) { + url := c.baseURL + "/user" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("building request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "NexusTerminal/1.0") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("contacting GitHub API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("token validation failed (HTTP %d)", resp.StatusCode) + } + + var user struct { + Login string `json:"login"` + } + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return "", fmt.Errorf("decoding user: %w", err) + } + return user.Login, nil +} diff --git a/internal/models/defaults.go b/internal/models/defaults.go index f5931eb..d86d504 100644 --- a/internal/models/defaults.go +++ b/internal/models/defaults.go @@ -11,6 +11,7 @@ const ( DefaultSSHPort = 22 DefaultTelnetPort = 23 DefaultRAWPort = 23 + DefaultGitHubPort = 443 ) // Default timeout values (in seconds). @@ -96,6 +97,14 @@ func NewProfile(protocol Protocol) Profile { Encoding: "utf-8", PayloadPresets: []PayloadPreset{}, } + case ProtocolGitHub: + p.Port = DefaultGitHubPort + p.Host = "github.com" + p.GitHub = &GitHubConfig{ + BaseURL: "https://api.github.com", + SecretMode: SecretModeSaved, + RefreshIntervalSecs: 300, + } } return p diff --git a/internal/models/models.go b/internal/models/models.go index c3792b1..6d33ab4 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -11,6 +11,7 @@ const ( ProtocolSSH Protocol = "ssh" ProtocolTelnet Protocol = "telnet" ProtocolRAW Protocol = "raw" + ProtocolGitHub Protocol = "github" ) // HostKeyPolicy determines how unknown or changed SSH host keys are handled. @@ -76,6 +77,9 @@ type Profile struct { // RAW-specific configuration RAW *RAWConfig `json:"raw,omitempty"` + + // GitHub-specific configuration + GitHub *GitHubConfig `json:"github,omitempty"` } // SSHConfig holds SSH-specific settings for a connection profile. @@ -132,6 +136,36 @@ type PayloadPreset struct { Format string `json:"format"` // text, hex, escaped } +// GitHubConfig holds GitHub API-specific settings for a connection profile. +type GitHubConfig struct { + // BaseURL is the GitHub API base (e.g. "https://api.github.com" for + // github.com, or "https://github.example.com/api/v3" for GHE). + BaseURL string `json:"baseURL"` + + // SecretMode controls how the personal access token is stored. + SecretMode SecretMode `json:"secretMode"` + + // RefreshIntervalSecs controls the auto-refresh period in seconds. + // Defaults to 300 (5 minutes). + RefreshIntervalSecs int `json:"refreshIntervalSecs"` +} + +// GitHubRepo is a single repository returned to the frontend for display. +type GitHubRepo struct { + FullName string `json:"fullName"` + Name string `json:"name"` + Owner string `json:"owner"` + Description string `json:"description"` + HTMLURL string `json:"htmlURL"` + Private bool `json:"private"` + Fork bool `json:"fork"` + Archived bool `json:"archived"` + Language string `json:"language"` + Stars int `json:"stars"` + PushedAt string `json:"pushedAt"` // ISO 8601 + UpdatedAt string `json:"updatedAt"` +} + // SessionState represents the lifecycle state of a connection session. type SessionState string diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index 276bb44..e7e099f 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -1,9 +1,10 @@ -// Package sessions orchestrates multiple simultaneous SSH, Telnet, and RAW TCP -// sessions. It coordinates between protocol clients, the storage layer, the -// secret store, and the Wails runtime for event emission. +// Package sessions orchestrates multiple simultaneous SSH, Telnet, RAW TCP, +// and GitHub sessions. It coordinates between protocol clients, the storage +// layer, the secret store, and the Wails runtime for event emission. package sessions import ( + "context" "encoding/base64" "fmt" "sync" @@ -13,14 +14,13 @@ import ( wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" "github.com/copilot-ssh-client/app/internal/audit" + "github.com/copilot-ssh-client/app/internal/githubclient" "github.com/copilot-ssh-client/app/internal/models" "github.com/copilot-ssh-client/app/internal/rawclient" "github.com/copilot-ssh-client/app/internal/secrets" "github.com/copilot-ssh-client/app/internal/sshclient" "github.com/copilot-ssh-client/app/internal/storage" "github.com/copilot-ssh-client/app/internal/telnetclient" - - "context" ) // Session wraps a protocol client with metadata and state. @@ -29,6 +29,8 @@ type Session struct { sshClient *sshclient.Client telnetClient *telnetclient.Client rawClient *rawclient.Client + githubClient *githubclient.Client + githubCancel context.CancelFunc // stops the auto-refresh goroutine profile *models.Profile } @@ -105,6 +107,8 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string) err = m.connectTelnet(sess) case models.ProtocolRAW: err = m.connectRAW(sess) + case models.ProtocolGitHub: + err = m.connectGitHub(sess, getSecret) default: err = fmt.Errorf("unsupported protocol %q", profile.Protocol) } @@ -558,6 +562,112 @@ func (m *Manager) connectRAW(sess *Session) error { return nil } +// connectGitHub validates the token and performs the initial repository fetch. +// It also starts a background goroutine for periodic refresh. +func (m *Manager) connectGitHub(sess *Session, getSecret func(prompt string) (string, error)) error { + profile := sess.profile + ghConf := profile.GitHub + if ghConf == nil { + return fmt.Errorf("GitHub configuration missing on profile %q", profile.ID) + } + + // Resolve the access token using the standard secret flow. + getToken := m.buildSecretCallback(profile.ID, "github-token", ghConf.SecretMode, getSecret) + token, err := getToken() + if err != nil { + return fmt.Errorf("obtaining GitHub token: %w", err) + } + if token == "" { + return fmt.Errorf("GitHub token cannot be empty") + } + + client := githubclient.New(ghConf.BaseURL, token) + + // Validate the token. + login, err := client.ValidateToken(context.Background()) + if err != nil { + return fmt.Errorf("GitHub authentication failed: %w", err) + } + + sess.githubClient = client + + // Perform the initial fetch. + repos, err := client.FetchRepos(context.Background()) + if err != nil { + return fmt.Errorf("fetching repositories: %w", err) + } + + // Emit the initial repo list. + m.emitEvent("session:github:repos", map[string]interface{}{ + "sessionID": sess.Info.ID, + "login": login, + "repos": repos, + }) + + // Start the periodic refresh goroutine. + interval := time.Duration(ghConf.RefreshIntervalSecs) * time.Second + if interval < 60*time.Second { + interval = 5 * time.Minute + } + + ctx, cancel := context.WithCancel(context.Background()) + sess.githubCancel = cancel + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + updated, fetchErr := client.FetchRepos(ctx) + if fetchErr != nil { + m.emitEvent("session:github:error", map[string]interface{}{ + "sessionID": sess.Info.ID, + "error": fetchErr.Error(), + }) + continue + } + m.emitEvent("session:github:repos", map[string]interface{}{ + "sessionID": sess.Info.ID, + "login": login, + "repos": updated, + }) + } + } + }() + + return nil +} + +// FetchGitHubRepos triggers an on-demand refresh of GitHub repos for a session. +func (m *Manager) FetchGitHubRepos(sessionID string) ([]models.GitHubRepo, error) { + m.mu.RLock() + sess, ok := m.sessions[sessionID] + m.mu.RUnlock() + + if !ok { + return nil, fmt.Errorf("sessions: session %q not found", sessionID) + } + if sess.githubClient == nil { + return nil, fmt.Errorf("sessions: session %q is not a GitHub connection", sessionID) + } + + repos, err := sess.githubClient.FetchRepos(context.Background()) + if err != nil { + return nil, fmt.Errorf("fetching repos: %w", err) + } + + m.emitEvent("session:github:repos", map[string]interface{}{ + "sessionID": sessionID, + "repos": repos, + }) + + return repos, nil +} + // --------------------------------------------------------------------------- // Internal: helpers // --------------------------------------------------------------------------- @@ -645,6 +755,9 @@ func (m *Manager) closeSession(sess *Session) { if sess.rawClient != nil { sess.rawClient.Close() } + if sess.githubCancel != nil { + sess.githubCancel() + } m.mu.Lock() sess.Info.State = models.SessionDisconnected