Offer GitHub sign-in in the frontend

The load dialog shows the GitHub account state with sign-in, sign-out
and repository-selection links. A clone that needs authorisation shows
a Sign in with GitHub or Grant repository access button, and the
repository is resumed automatically on return from GitHub.
This commit is contained in:
Andy Dixon
2026-03-15 09:52:44 +00:00
parent b216b86004
commit 09b57f7afa
3 changed files with 40 additions and 7 deletions
+33 -5
View File
@@ -709,7 +709,7 @@ async function uploadLocal(name,chosen){
}
$('#githubButton').addEventListener('click',startGithub);$('#githubInput').addEventListener('keydown',event=>{if(event.key==='Enter'){event.preventDefault();startGithub();}});
async function startGithub(){const url=$('#githubInput').value.trim();$('#githubError').textContent='';if(!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?(?:\.git)?$/.test(url)){ $('#githubError').textContent='Enter a public GitHub owner/repository URL.';return;}dialog.close();showProgress('Cloning repository','Connecting to GitHub…',0,0);try{const response=await apiFetch(apiUrl('/api/jobs'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:'github',url,name:url.split('/').filter(Boolean).pop()?.replace(/\.git$/,'')})});const payload=await response.json();if(!response.ok)throw new Error(payload.error||'Could not start indexing');watchJob(payload.jobId);}catch(error){showError(error.message);}}
async function startGithub(){const url=$('#githubInput').value.trim();$('#githubError').textContent='';if(!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?(?:\.git)?$/.test(url)){ $('#githubError').textContent='Enter a GitHub owner/repository URL.';return;}lastGithubUrl=url;dialog.close();showProgress('Cloning repository','Connecting to GitHub…',0,0);try{const response=await apiFetch(apiUrl('/api/jobs'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:'github',url,name:url.split('/').filter(Boolean).pop()?.replace(/\.git$/,'')})});const payload=await response.json();if(!response.ok)throw new Error(payload.error||'Could not start indexing');watchJob(payload.jobId);}catch(error){showError(error.message);}}
let activeJobStream=null;
async function watchJob(jobId){
@@ -720,7 +720,7 @@ async function watchJob(jobId){
for await(const job of progressEvents(response)){
if(controller.signal.aborted)return;
showProgress(job.phase==='ready'?'Landscape ready':'Indexing codebase',job.message,job.completed,job.total);
if(job.error){showError(job.error);return;}
if(job.error){showError(job.error,job.authRequired);return;}
if(job.snapshotId){
const scene=await apiFetch(apiUrl(`/api/snapshots/${job.snapshotId}/scene`),{signal:controller.signal});
if(!scene.ok)throw new Error('Could not load the landscape.');
@@ -732,8 +732,36 @@ async function watchJob(jobId){
}catch(error){if(!controller.signal.aborted)showError(error.message);}
finally{if(activeJobStream===controller)activeJobStream=null;controller.abort();}
}
function showProgress(title,message,completed,total){$('#progressCard').hidden=false;$('#progressTitle').textContent=title;$('#progressMessage').textContent=message||'';const percent=total?Math.max(3,Math.round(completed/total*100)):12;$('#progressFill').style.width=`${percent}%`;$('#progressFill').style.background='';$('#progressCount').textContent=total?`${format(completed)} / ${format(total)} · ${percent}%`:'Working…';}
function showError(message){$('#progressCard').hidden=false;$('#progressTitle').textContent='Could not build landscape';$('#progressMessage').textContent=message;$('#progressFill').style.width='100%';$('#progressFill').style.background='var(--danger)';$('#progressCount').textContent='Check the repository or folder and try again.';}
function showProgress(title,message,completed,total){$('#progressCard').hidden=false;$('#progressAction').hidden=true;$('#progressTitle').textContent=title;$('#progressMessage').textContent=message||'';const percent=total?Math.max(3,Math.round(completed/total*100)):12;$('#progressFill').style.width=`${percent}%`;$('#progressFill').style.background='';$('#progressCount').textContent=total?`${format(completed)} / ${format(total)} · ${percent}%`:'Working…';}
function showError(message,authRequired){$('#progressCard').hidden=false;showAuthAction(authRequired);$('#progressTitle').textContent='Could not build landscape';$('#progressMessage').textContent=message;$('#progressFill').style.width='100%';$('#progressFill').style.background='var(--danger)';$('#progressCount').textContent='Check the repository or folder and try again.';}
// Private repositories: sign in (or grant the GitHub App access), then resume the same repository on return.
let lastGithubUrl='',githubStatus={};
function showAuthAction(authRequired){
const action=$('#progressAction'),href=authRequired==='signin'?apiUrl('/api/github/login'):authRequired==='install'?githubStatus.installUrl:'';
action.hidden=!href;if(!href)return;
action.href=href;action.textContent=authRequired==='signin'?'Sign in with GitHub':'Grant repository access';
}
$('#progressAction').addEventListener('click',()=>{try{sessionStorage.setItem('codenav.pendingRepo',lastGithubUrl);}catch{}});
$('#githubAccount').addEventListener('click',event=>{if(event.target.closest('a'))try{sessionStorage.setItem('codenav.pendingRepo',$('#githubInput').value.trim());}catch{}});
async function loadGithubStatus(){
try{const response=await apiFetch(apiUrl('/api/github/status'));if(response.ok)githubStatus=await response.json();}catch{}
const account=$('#githubAccount');account.hidden=!githubStatus.configured;account.replaceChildren();if(account.hidden)return;
if(githubStatus.connected){
const signOut=Object.assign(document.createElement('button'),{type:'button',textContent:'Sign out'});
signOut.addEventListener('click',async()=>{await apiFetch(apiUrl('/api/github/logout'),{method:'POST'}).catch(()=>{});loadGithubStatus();});
account.append(`Signed in to GitHub as ${githubStatus.login} · `,signOut);
if(githubStatus.installUrl)account.append(' · ',Object.assign(document.createElement('a'),{href:githubStatus.installUrl,textContent:'Choose repositories'}));
}else account.append('Private repository? ',Object.assign(document.createElement('a'),{href:apiUrl('/api/github/login'),textContent:'Sign in with GitHub'}));
}
async function resumeAfterGithub(){
const params=new URLSearchParams(location.search),outcome=params.get('github')||(params.get('setup_action')?'installed':'');
if(!outcome)return;
window.history.replaceState(null,'',location.pathname);
let pending='';try{pending=sessionStorage.getItem('codenav.pendingRepo')||'';sessionStorage.removeItem('codenav.pendingRepo');}catch{}
if(outcome==='denied'||outcome==='error'){showError(outcome==='denied'?'GitHub sign-in was cancelled.':'GitHub sign-in failed. Please try again.');return;}
if(pending){$('#githubInput').value=pending;startGithub();}
}
async function checkBackend(){
const indicator=$('.live-dot');
@@ -773,4 +801,4 @@ switchTab('inspector');
new ResizeObserver(()=>{dirty=true;}).observe(viewport);
$('#settingsButton').title='Workspace settings';$('#settingsButton').setAttribute('aria-label','Workspace settings');
$('#settingsButton svg').innerHTML='<circle cx="12" cy="12" r="3"/><path d="m9.5 3-.5 2-2 1-2-.5L3 9l1.5 1.5v3L3 15l2 3.5 2-.5 2 1 .5 2h5l.5-2 2-1 2 .5 2-3.5-1.5-1.5v-3L21 9l-2-3.5-2 .5-2-1-.5-2z"/>';
renderer.setData([]);fitScene();renderInspector();renderHistory();requestAnimationFrame(animate);checkBackend();
renderer.setData([]);fitScene();renderInspector();renderHistory();requestAnimationFrame(animate);checkBackend();loadGithubStatus().then(resumeAfterGithub);
+4 -2
View File
@@ -60,6 +60,7 @@
</div>
<div class="progress-track"><i id="progressFill"></i></div>
<span class="progress-count" id="progressCount"></span>
<a class="dialog-button progress-action" id="progressAction" hidden></a>
</div>
<div class="tooltip" id="tooltip" hidden></div>
<div class="camera-help" id="cameraHelp" hidden>
@@ -115,11 +116,12 @@
</button>
<div class="source-card github-card">
<span class="source-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="7" cy="5" r="2"/><circle cx="7" cy="19" r="2"/><circle cx="17" cy="5" r="2"/><path d="M7 7v10m10-10v3a4 4 0 0 1-4 4H7"/></svg></span>
<label for="githubInput"><strong>Public GitHub repository</strong><small>Explore a repository from its URL</small></label>
<label for="githubInput"><strong>GitHub repository</strong><small>Public, or private once you sign in with GitHub</small></label>
<div class="github-row"><input id="githubInput" type="url" autocomplete="off" spellcheck="false" aria-describedby="githubError" placeholder="https://github.com/owner/repo"><button type="button" id="githubButton">Explore →</button></div>
<p class="form-error" id="githubError" role="alert"></p>
<p class="github-account" id="githubAccount" hidden></p>
</div>
<p class="dialog-foot">Files are held temporarily by this server and cleared when it restarts. Do not upload private code to a public deployment you do not control.</p>
<p class="dialog-foot">Code is held temporarily by this server, visible only to this browser, and cleared after an hour of inactivity.</p>
</form>
</dialog>
+3
View File
@@ -212,6 +212,9 @@ button.source-card:hover { border-color: #787878; background: #2f2f2f; }
.github-row input { flex: 1; min-width: 0; height: 34px; padding: 0 10px; border: 1px solid #474747; border-radius: 6px; outline: none; color: #e3e3e3; background: #212121; }
.github-row input:focus { border-color: #979797; }
.github-row button { border: 1px solid #c7c7c7; border-radius: 7px; padding: 0 14px; color: #262626; background: #cdcdcd; cursor: pointer; font-weight: 600; }
.github-account { grid-column: 1 / -1; margin: 8px 0 0; color: #808080; font-size: 11px; }
.github-account a, .github-account button { padding: 0; border: 0; background: none; color: #cfcfcf; font: inherit; text-decoration: underline; cursor: pointer; }
.progress-action { display: grid; place-items: center; width: fit-content; margin-top: 12px; text-decoration: none; }
.form-error { grid-column: 1 / -1; min-height: 16px; margin: 5px 0 -5px; color: var(--danger); font-size: 11px; }
.dialog-foot { margin: 15px 2px 0; color: #707070; font-size: 10px; }
.settings-dialog { width: min(460px, calc(100vw - 30px)); }