diff --git a/frontend/create-pull-sheet.js b/frontend/create-pull-sheet.js new file mode 100644 index 0000000..bbf1f4c --- /dev/null +++ b/frontend/create-pull-sheet.js @@ -0,0 +1,203 @@ +function createController({request, storage, login, createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now())}) { + const storageKey = 'stackchain.create-pull.v1.' + String(login || '').toLowerCase(); + let operationId = createId(); + let current = { + repository:'', branches:[], head:'', base:'', expected_head_sha:'', + title:'', body:'', draft:true, + }; + try { + const saved = JSON.parse(storage?.getItem?.(storageKey) || 'null'); + if (saved && typeof saved === 'object') { + for (const field of ['repository', 'head', 'base', 'title', 'body']) { + if (typeof saved[field] === 'string') current[field] = saved[field]; + } + if (typeof saved.draft === 'boolean') current.draft = saved.draft; + } + } catch (_) {} + + function snapshot() { + return JSON.parse(JSON.stringify(current)); + } + + function persist() { + storage?.setItem?.(storageKey, JSON.stringify({ + repository:current.repository, head:current.head, base:current.base, + title:current.title, body:current.body, draft:current.draft, + })); + } + + async function selectRepository(repository) { + const options = await request('api/v1/repos/' + repository + '/pull-creation-options'); + const branches = Array.isArray(options?.branches) ? options.branches : []; + const base = branches.some(branch => branch.name === options.default_branch) ? options.default_branch : (branches[0]?.name || ''); + const headBranch = branches.find(branch => branch.name !== base) || branches[0] || {}; + current = { + ...current, repository, branches, base, + head:headBranch.name || '', expected_head_sha:headBranch.sha || '', + }; + persist(); + return snapshot(); + } + + function update(values) { + current = {...current, ...values}; + if (Object.prototype.hasOwnProperty.call(values, 'head')) { + current.expected_head_sha = current.branches.find(branch => branch.name === values.head)?.sha || ''; + } + persist(); + return snapshot(); + } + + async function submit() { + const result = await request('api/v1/repos/' + current.repository + '/pulls', { + method:'POST', + headers:{'Idempotency-Key':operationId}, + body:{ + head:current.head, base:current.base, title:current.title, body:current.body, + draft:current.draft, expected_head_sha:current.expected_head_sha, + }, + }); + operationId = createId(); + return result; + } + + return { + state:snapshot, + selectRepository, + update, + submit, + }; +} + +function createBinding({controller, render, status, onCreated}) { + return { + async repositoryChanged(repository) { + status('Loading branches…'); + try { + const state = await controller.selectRepository(repository); + render(state); + status(state.branches.length ? 'Choose the source and base branches.' : 'No branches are available.'); + return state; + } catch (error) { + status(error.message || 'Branches could not be loaded.'); + throw error; + } + }, + changed(values) { + const state = controller.update(values); + render(state); + return state; + }, + async submit() { + status('Creating pull request…'); + try { + const result = await controller.submit(); + status(result.existing ? `Pull request #${result.number} is already open.` : `Pull request #${result.number} created.`); + onCreated(result); + return result; + } catch (error) { + status(error.message || 'The pull request could not be created. Your draft is safe.'); + throw error; + } + }, + }; +} + +async function bindDashboard() { + const qs = selector => document.querySelector(selector); + const switchButton = qs('#switch-to-create-pull'); + switchButton.disabled = true; + const identityResponse = await fetch('api/v1/background-identity').catch(()=>null); + const identity = await identityResponse?.json().catch(()=>({})); + if (!identityResponse?.ok || !identity?.login) { + switchButton.title = 'Account identity is unavailable.'; + return null; + } + const login = identity.login; + const root = qs('#create-pull-sheet'); + const repository = qs('#create-pull-repository'); + const head = qs('#create-pull-head'); + const base = qs('#create-pull-base'); + const title = qs('#create-pull-title'); + const body = qs('#create-pull-body'); + const submit = qs('#submit-create-pull'); + const request = async (url, options) => { + const response = await fetch(url, options ? { + method:options.method, + headers:{'Content-Type':'application/json', ...options.headers}, + body:JSON.stringify(options.body), + } : undefined); + const payload = await response.json().catch(()=>({})); + if (!response.ok) throw new Error(payload.error || payload.detail || 'Request failed.'); + return payload; + }; + const controller = createController({request, storage:localStorage, login}); + const setOptions = (select, branches) => { + select.replaceChildren(...branches.map(branch => { + const option = document.createElement('option'); + option.value = option.textContent = branch.name; + return option; + })); + }; + const render = state => { + setOptions(head, state.branches); + setOptions(base, state.branches); + head.value = state.head; + base.value = state.base; + head.disabled = base.disabled = !state.branches.length; + qs('#create-pull-head-receipt').textContent = state.expected_head_sha ? 'Source at ' + state.expected_head_sha.slice(0, 12) : ''; + submit.disabled = !(state.repository && state.head && state.base && state.head !== state.base && title.value.trim()); + }; + const binding = createBinding({ + controller, render, + status:message=>{ qs('#create-pull-status').textContent = message; }, + onCreated:result=>{ + root.hidden = true; + qs('main').inert = false; + location.hash = '#/my-work/pull/' + result.repository + '/' + result.number; + location.reload(); + }, + }); + const update = () => binding.changed({ + head:head.value, base:base.value, title:title.value, body:body.value, + draft:document.querySelector('input[name="create-pull-mode"]:checked')?.value !== 'ready', + }); + repository.addEventListener('change', () => binding.repositoryChanged(repository.value).catch(()=>{})); + [head,base,title,body].forEach(field => field.addEventListener('input', update)); + document.querySelectorAll('input[name="create-pull-mode"]').forEach(field => field.addEventListener('change', () => { + update(); + submit.textContent = field.value === 'ready' && field.checked ? 'Create ready pull request' : 'Create draft pull request'; + })); + qs('#create-pull-form').addEventListener('submit', event => { + event.preventDefault(); + update(); + binding.submit().catch(()=>{}); + }); + const close = () => { + root.hidden = true; + qs('main').inert = false; + qs('#new-issue').focus(); + }; + qs('#cancel-create-pull').addEventListener('click', close); + switchButton.addEventListener('click', () => { + qs('#cancel-new-issue').click(); + repository.replaceChildren(...Array.from(qs('#create-issue-repository').options).map(source => { + const option = document.createElement('option'); + option.value = source.value; + option.textContent = source.textContent; + return option; + })); + root.hidden = false; + qs('main').inert = true; + qs('#cancel-create-pull').focus(); + }); + root.addEventListener('keydown', event => { + if (event.key === 'Escape') { event.preventDefault(); close(); } + }); + switchButton.disabled = false; + return binding; +} + +const cp = {createController, createBinding, b:bindDashboard}; +if (typeof module !== 'undefined') module.exports = cp; +else cp.b(); diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 16e4f39..da104b7 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1539,3 +1539,16 @@ textarea { resize: vertical; min-height: 120px; } transition: transform 120ms ease, border-color 120ms ease; } } + +.create-pull-sheet{position:fixed;inset:0;z-index:75;background:rgba(3,7,18,.72);display:grid;place-items:end center;padding:16px} +.create-pull-sheet[hidden]{display:none} +.create-pull-panel{width:min(100%,680px);max-height:calc(100dvh - 32px);overflow:auto;background:var(--panel);border:1px solid var(--border);border-radius:20px;padding:20px;padding-bottom:max(20px,env(safe-area-inset-bottom))} +.create-pull-header{display:flex;align-items:center;justify-content:space-between;gap:12px} +.create-pull-header h3{margin:2px 0 0} +.create-pull-panel form,.create-pull-panel label{display:grid;gap:6px} +.create-pull-panel form{gap:14px} +.create-pull-branches{display:grid;grid-template-columns:1fr 1fr;gap:12px} +.create-pull-mode{display:flex;gap:18px;border:1px solid var(--border);border-radius:12px;padding:10px 12px} +.create-pull-mode label{display:flex;align-items:center;gap:7px} +.create-pull-panel button,.create-pull-panel select,.create-pull-panel input{min-height:44px} +@media(max-width:600px){.create-pull-sheet{padding:0}.create-pull-panel{width:100%;max-height:100dvh;border-radius:18px 18px 0 0}.create-pull-branches{grid-template-columns:1fr}} diff --git a/frontend/index.html b/frontend/index.html index 1737fee..1c6abab 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1281,6 +1281,7 @@

Capture work

+