runlist 0.0.0 → 0.94.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1112 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +296 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/public-inspection.mjs +19 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +795 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- package/src/yardstick.mjs +112 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {semanticSearch} from './semantic-search.mjs';
|
|
2
|
+
export function quickNavigation({$,api,state,library,open,jump,actions,notice}) {
|
|
3
|
+
const dialog=$('quick-dialog');let sequence=0,timer,running=false,rerun=false,items=[],active=0,semantic=null,submitted='',keyboardChosen=false;
|
|
4
|
+
const mode=()=>$('quick-mode').value;
|
|
5
|
+
function cancel(){semantic?.abort();semantic=null;submitted='';$('quick-cancel').hidden=true;}
|
|
6
|
+
function choose(index){active=Math.max(0,Math.min(items.length-1,index));for(const [i,row] of [...$('quick-results').children].entries())row.setAttribute('aria-selected',String(i===active));if(items.length)$('quick-query').setAttribute('aria-activedescendant',`quick-result-${active}`);else $('quick-query').removeAttribute('aria-activedescendant');}
|
|
7
|
+
function render(results) {
|
|
8
|
+
keyboardChosen=false;items=results.slice(0,80);$('quick-results').replaceChildren(...items.map((item,index)=>{
|
|
9
|
+
const row=document.createElement('button');row.id=`quick-result-${index}`;row.className='quick-result quiet';row.setAttribute('role','option');row.tabIndex=-1;
|
|
10
|
+
const title=document.createElement('strong');title.textContent=item.title;
|
|
11
|
+
const detail=document.createElement('small');detail.textContent=item.detail??item.path??'';row.append(title,detail);row.onclick=()=>activate(index);return row;
|
|
12
|
+
}));choose(0);
|
|
13
|
+
}
|
|
14
|
+
async function activate(index){if(state.busy)return;const item=items[index];if(!item)return;close();try{if(item.run)await item.run();else{await open(item.path);if(item.line){if(item.revision&&state.doc?.revision!==item.revision)notice('The document changed. Search again to verify its section.',true);else jump(item);}}}catch(error){notice(error.message,true);}}
|
|
15
|
+
const matchingActions=query=>actions().filter(action=>!query||`${action.title} ${action.detail??''}`.toLowerCase().includes(query)).map(a=>({...a,detail:a.detail??'Action'}));
|
|
16
|
+
async function semanticQuery(){
|
|
17
|
+
cancel();const query=$('quick-query').value.trim(),own=++sequence;if(!query){$('quick-status').textContent='Enter a question, then press Search. Uses optional local gmax.';return;}
|
|
18
|
+
const controller=new AbortController();semantic=controller;$('quick-cancel').hidden=false;render(matchingActions(query.toLowerCase()));$('quick-status').textContent='Searching the existing local gmax index…';
|
|
19
|
+
try{
|
|
20
|
+
const result=await semanticSearch(api,{query,archived:$('quick-archived').checked},controller.signal);
|
|
21
|
+
if(own!==sequence||!dialog.open||query!==$('quick-query').value.trim()||mode()!=='semantic')return;
|
|
22
|
+
if(result.state!=='ready'){$('quick-status').textContent=result.message??'Semantic search is unavailable. Exact search is available.';return;}
|
|
23
|
+
render([...matchingActions(query.toLowerCase()),...result.documents.map(doc=>({...doc,detail:`${doc.path} · ${doc.excerpt}${doc.location==='changed'?' · Location changed':''}`})),...result.sections.map(section=>({...section,detail:`Verified section · ${section.path}`}))]);submitted=query;
|
|
24
|
+
$('quick-status').textContent=`${result.documents.length} top semantic matches${result.coverage.partial?' · Index coverage is incomplete':''}${result.indexUpdating?' · Index is updating':''}${result.stale?' · Changed sources open at the document':''}. Exact search is also available.`;
|
|
25
|
+
}catch(error){if(own===sequence&&dialog.open&&!controller.signal.aborted)$('quick-status').textContent=error.message;}
|
|
26
|
+
finally{if(semantic===controller){semantic=null;$('quick-cancel').hidden=true;}}
|
|
27
|
+
}
|
|
28
|
+
async function search(){
|
|
29
|
+
if(!dialog.open)return;if(mode()==='semantic')return semanticQuery();
|
|
30
|
+
if(running){rerun=true;return;}running=true;
|
|
31
|
+
const own=++sequence,query=$('quick-query').value.trim();render(matchingActions(query.toLowerCase()));$('quick-status').textContent=query?'Searching local documents…':'Choose an action or recent document.';
|
|
32
|
+
try{
|
|
33
|
+
if(!query){render([...matchingActions(''),...(library.quickDocuments?.()??[]).map(doc=>({...doc,detail:`Recent or pinned · ${doc.path}`}))]);return;}
|
|
34
|
+
const result=await api(`search?${new URLSearchParams({q:query,archived:$('quick-archived').checked?'1':'0',limit:'30'})}`);
|
|
35
|
+
if(own!==sequence||!dialog.open||query!==$('quick-query').value.trim()||mode()!=='text')return;
|
|
36
|
+
render([...matchingActions(query.toLowerCase()),...result.documents.map(doc=>({...doc,detail:doc.excerpt?`${doc.path} · ${doc.excerpt}`:doc.path})),...result.sections.map(section=>({...section,detail:`Section · ${section.path}`}))]);
|
|
37
|
+
$('quick-status').textContent=`${result.total} matching documents · ${result.sections.length} matching sections${result.partial?` · Partial search: scanned ${result.scanned} of ${result.inventoryTotal} documents; narrow the query or folder in the library.`:''}${result.unavailable?` · ${result.unavailable} unavailable files`:''}${!items.length?' · No matches. Try another search.':''}`;
|
|
38
|
+
}catch(error){if(own===sequence&&dialog.open)$('quick-status').textContent=`Search unavailable. ${error.message}`;}
|
|
39
|
+
finally{running=false;if(rerun){rerun=false;if(mode()==='text')await search();}}
|
|
40
|
+
}
|
|
41
|
+
async function show(){if(state.busy||$('workspace').hidden)return;if(document.querySelector('dialog[open]'))return;dialog.showModal();$('quick-query').value='';$('quick-mode').value='text';modeChanged(false);$('quick-query').focus();await search();}
|
|
42
|
+
function close(){sequence++;cancel();clearTimeout(timer);dialog.close();$('quick-query').removeAttribute('aria-activedescendant');}
|
|
43
|
+
function modeChanged(reload=true){sequence++;cancel();clearTimeout(timer);render(matchingActions($('quick-query').value.trim().toLowerCase()));const semanticMode=mode()==='semantic';$('quick-search').hidden=!semanticMode;$('quick-tool-settings').hidden=!semanticMode;$('quick-hint').textContent=semanticMode?'Enter to search, then ↑ ↓ and Enter to open · Esc to close. Experimental semantic search · optional local gmax; existing index only. Text search is available.':'↑ ↓ to choose · Enter to open · Esc to close. Searches local document contents on demand.';$('quick-status').textContent=semanticMode?'Enter a question, then press Search.': 'Choose an action or recent document.';if(reload&&!semanticMode&&dialog.open)void search();}
|
|
44
|
+
$('quick-mode').onchange=modeChanged;
|
|
45
|
+
$('quick-query').oninput=()=>{sequence++;cancel();render(matchingActions($('quick-query').value.trim().toLowerCase()));clearTimeout(timer);if(mode()==='semantic')$('quick-status').textContent='Press Enter or Search to query the existing index.';else{$('quick-status').textContent='Searching local documents…';timer=setTimeout(search,200);}};
|
|
46
|
+
$('quick-archived').onchange=()=>{sequence++;cancel();if(mode()==='text')return search();render(matchingActions($('quick-query').value.trim().toLowerCase()));$('quick-status').textContent='Filters changed. Press Search.';};
|
|
47
|
+
$('quick-query').onkeydown=event=>{if(['ArrowDown','ArrowUp','Home','End','Enter'].includes(event.key)){event.preventDefault();if(event.key==='Enter'){if(mode()==='semantic'&&submitted!==$('quick-query').value.trim()&&!(keyboardChosen&&items[active]?.run))void semanticQuery();else void activate(active);}else{choose(event.key==='Home'?0:event.key==='End'?items.length-1:active+(event.key==='ArrowDown'?1:-1));keyboardChosen=true;}$('quick-results').children[active]?.scrollIntoView({block:'nearest'});}};
|
|
48
|
+
$('quick-search').onclick=semanticQuery;$('quick-cancel').onclick=()=>{sequence++;cancel();$('quick-status').textContent='Semantic search cancelled.';};$('quick-tool-settings').onclick=()=>{close();$('open-settings').click();};
|
|
49
|
+
$('quick-close').onclick=close;dialog.addEventListener('cancel',()=>{sequence++;cancel();clearTimeout(timer);});$('quick-open').onclick=()=>show().catch(error=>notice(error.message,true));
|
|
50
|
+
document.addEventListener('keydown',event=>{if((event.metaKey||event.ctrlKey)&&event.key.toLowerCase()==='k'){if(document.querySelector('dialog[open]')&&!dialog.open)return;event.preventDefault();if(dialog.open)close();else show().catch(error=>notice(error.message,true));}});
|
|
51
|
+
return {show,search};
|
|
52
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {escapeHtml as esc,markdownHtml,diffHtml} from './shared.mjs';
|
|
2
|
+
|
|
3
|
+
export function decisionContent(body) {
|
|
4
|
+
const outcomes=[];
|
|
5
|
+
let narrative=body.replace(/\*\*Recorded outcome:\*\*\s*(Disposition:[\s\S]*?)\s*\*\*Original record:\*\*/g,(_,outcome)=>{outcomes.push(outcome);return '';});
|
|
6
|
+
narrative=narrative.replace(/^Disposition:[^\r\n]*/gim,outcome=>{outcomes.push(outcome);return '';});
|
|
7
|
+
const latest=outcomes[0];
|
|
8
|
+
const format=outcome=>{const m=outcome.match(/^Disposition:\s*(\w+)\s+(\d{4}-\d\d-\d\dT[\d:.]+Z)\. Recorded by ([\s\S]*?) \((human|agent)\)\. (?:Selected answer: ([\s\S]*?)\. )?Reason: ([\s\S]*)\.$/);return m?`<strong>${esc(m[1][0]+m[1].slice(1).toLowerCase())}${m[5]?` · ${esc(m[5])}`:''}</strong><p>${esc(m[6])}</p><small class="muted">${esc(m[3])} · ${esc(m[4])} · ${esc(new Date(m[2]).toLocaleString())}</small>`:esc(outcome);};
|
|
9
|
+
narrative=narrative.replace(/^(Question|Alternatives|Consequences|Evidence):\s*/gim,'\n\n**$1:** ');
|
|
10
|
+
return `${latest?`<section class="decision-outcome"><h2>Latest recorded outcome</h2>${format(latest)}</section>`:''}<h2>Decision record</h2><article class="markdown record-body">${markdownHtml(narrative)}</article>${outcomes.length>1?`<details class="decision-history"><summary>Earlier outcomes · ${outcomes.length-1}</summary><ol class="record-history">${outcomes.slice(1).map(o=>`<li>${format(o)}</li>`).join('')}</ol></details>`:''}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function nativeContent(record) {
|
|
14
|
+
const data=record.data??{},array=value=>Array.isArray(value)?value.filter(v=>v&&typeof v==='object'):[];
|
|
15
|
+
const provenance=event=>`${esc(event.by?.label??event.by?.id??'')} · ${esc(event.at??'')}`;
|
|
16
|
+
const options=array(data.options),rulings=array(data.rulings),history=array(data.history),evidence=array(data.evidence);
|
|
17
|
+
const active=rulings.find(r=>r.id===data.active_ruling_id),resolution=array(data.resolutions).find(r=>r.id===data.active_resolution_id);
|
|
18
|
+
const outcome=active?`<section class="decision-outcome"><h2>Current ruling</h2><strong>${esc(active.kind==='withdraw'?'Closed · Ruling withdrawn':options.find(o=>o.id===active.option_id)?.label??active.option_id)}</strong><p>${esc(active.reason)}</p><small class="muted">${provenance(active)}</small></section>`:data.hold?`<section class="decision-outcome"><h2>On hold</h2><p>${esc(data.hold.reason)}</p><small class="muted">${provenance(data.hold)}</small></section>`:resolution?`<section class="decision-outcome"><h2>Manual resolution</h2><p>${esc(resolution.reason)}</p><small class="muted">${provenance(resolution)}</small></section>`:'';
|
|
19
|
+
|
|
20
|
+
return `${outcome}${record.diagnostics?.length?'<p class="inline-note">This record needs format repair. Open its connected source to inspect it.</p>':''}${options.length?`<h2>Alternatives and consequences</h2><div class="native-options">${options.map(option=>`<section><h3>${esc(option.label)}</h3><p>${esc(option.description)}</p><ul>${array(option.consequences).map(c=>`<li><strong>${esc(c.kind)}</strong>: ${esc(c.text)}</li>`).join('')}</ul></section>`).join('')}</div>`:''}${rulings.length?`<h2>Ruling history</h2><ol class="record-history">${[...rulings].reverse().map(r=>`<li><strong>${esc(r.kind==='withdraw'?'Ruling withdrawn':options.find(o=>o.id===r.option_id)?.label??r.option_id)}</strong><p>${esc(r.reason)}</p><small class="muted">${provenance(r)}</small></li>`).join('')}</ol>`:''}${data.triage?`<h2>Triage</h2><p>${esc(data.triage.disposition)}${data.triage.reason?` · ${esc(data.triage.reason)}`:''}</p><small class="muted">${data.triage.by?provenance(data.triage):''}</small>`:''}${evidence.length?`<h2>Observed evidence</h2>${evidence.map(e=>`<section class="native-evidence"><p>${esc(e.path??e.uri??e.kind)}${e.anchor?.start_line?`:${e.anchor.start_line}`:''}</p><pre>${esc(e.anchor?.quote??e.note??'No quote recorded.')}</pre><small class="muted">${esc(e.revision?.kind==='unknown'?e.revision.reason:e.revision?.value??'')} · ${provenance({by:e.by,at:e.observed_at})}</small></section>`).join('')}`:''}${history.length?`<h2>Record history</h2><ol class="record-history">${[...history].reverse().map(h=>`<li><strong>${esc(h.event??h.kind??'Recorded')}</strong><p>${esc(h.reason??h.note??'')}</p><small class="muted">${provenance(h)}</small></li>`).join('')}</ol>`:''}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function recordNavigation({state,$,api,open,showLibrary,checkout,preferences=()=>({records:'pending'}),notice,create=async()=>{}}) {
|
|
24
|
+
const root=$('records-home');let kind='flags',selected=null,offset=0,sequence=0,timer,filters={q:'',status:'pending',severity:'',scope:''},pending=null;
|
|
25
|
+
const title=()=>kind==='flags'?'Flags':'Decisions';
|
|
26
|
+
const recoveryKey=()=>`runlist:record-action:${checkout()}:${kind}:${selected?.key}`;
|
|
27
|
+
const url=key=>{const u=new URL(location.href);u.searchParams.set('view',kind);if(key)u.searchParams.set('record',key);else u.searchParams.delete('record');history.replaceState(null,'',u);};
|
|
28
|
+
const run=fn=>async()=>{try{await fn();}catch(error){notice(error.message,true);}};
|
|
29
|
+
function deactivate(){for(const k of ['flags','decisions'])$('library-'+k).setAttribute('aria-pressed','false');}
|
|
30
|
+
function activate(){state.mode='records';$('empty').hidden=true;$('document').hidden=true;root.hidden=false;for(const k of ['flags','decisions'])$('library-'+k).setAttribute('aria-pressed',String(kind===k));for(const k of ['all','hubs','plans','documents'])$('library-'+k).setAttribute('aria-pressed','false');$('doc-title').textContent=title();}
|
|
31
|
+
function setCounts(counts){for(const k of ['flags','decisions'])$('library-'+k+'-count').textContent=counts[k].toLocaleString();}
|
|
32
|
+
async function counts(){const result=await api('records?kind=flags&status=pending');setCounts(result.counts);}
|
|
33
|
+
async function show(next,key){await showLibrary();kind=next;selected=null;offset=0;filters={q:'',status:preferences().records??'pending',severity:'',scope:''};activate();url(key);if(key)await detail(key);else await load();}
|
|
34
|
+
function shell(){root.innerHTML=`<div class="library-heading"><div><h1>${title()}</h1><p>${kind==='flags'?'Findings to triage, with original evidence and a history of what happened.':'Questions and outcomes across plans and registers, connected to their source.'}</p></div><div class="record-heading-actions"><button id="records-new" class="primary">New ${kind==='flags'?'flag':'decision'}</button><button id="records-refresh" class="quiet small">↻ Refresh</button></div></div><div class="record-controls"><label class="sr-only" for="record-search">Search ${title()}</label><input id="record-search" class="search" type="search" placeholder="Search ID, question, source…" value="${esc(filters.q)}"><label>Status<select id="record-status"><option value="pending">Needs attention</option><option value="">All statuses</option>${(kind==='flags'?['open','closed','resolved']:['open','held','ruled','closed','unknown']).map(s=>`<option value="${s}">${s==='closed'&&kind==='flags'?'Rejected':s[0].toUpperCase()+s.slice(1)}</option>`).join('')}</select></label>${kind==='flags'?'<label>Severity<select id="record-severity"><option value="">All severities</option><option>problem</option><option>warn</option><option>info</option></select></label>':''}<label>Plan or hub<select id="record-scope"><option value="">All sources</option></select></label></div><div class="library-pagination"><span id="record-range" role="status">Loading records…</span><button id="record-previous" class="quiet small">← Previous</button><button id="record-next" class="quiet small">Next →</button></div><nav id="record-results" aria-label="${title()} list" aria-busy="true"></nav><p id="record-unavailable" class="muted"></p>`;
|
|
35
|
+
$('record-status').value=filters.status;if(kind==='flags')$('record-severity').value=filters.severity;
|
|
36
|
+
$('record-search').oninput=()=>{filters.q=$('record-search').value;clearTimeout(timer);timer=setTimeout(()=>{offset=0;run(()=>load(false,false))();},180);};
|
|
37
|
+
for(const [id,key] of [['record-status','status'],['record-severity','severity'],['record-scope','scope']])if($(id))$(id).onchange=()=>{filters[key]=$(id).value;offset=0;run(()=>load(false,false))();};
|
|
38
|
+
$('records-new').onclick=run(()=>create(kind==='flags'?'flag':'decision'));$('records-refresh').onclick=run(()=>load(true,false));$('record-previous').onclick=run(()=>{offset=Math.max(0,offset-50);return load(false,false);});$('record-next').onclick=run(()=>{offset+=50;return load(false,false);});
|
|
39
|
+
}
|
|
40
|
+
async function load(refresh=false,rebuild=true){if(state.busy)throw new Error('Wait for the current save before navigating.');selected=null;pending=null;if(rebuild)shell();activate();url();const own=++sequence;$('record-results').setAttribute('aria-busy','true');$('record-range').textContent='Loading records…';
|
|
41
|
+
try{const params=new URLSearchParams({kind,...filters,offset:String(offset)});if(refresh)params.set('refresh','1');const result=await api(`records?${params}`);if(own!==sequence||state.mode!=='records'||selected)return;setCounts(result.counts);offset=result.offset;
|
|
42
|
+
$('record-scope').replaceChildren(new Option('All sources',''),...result.scopes.map(s=>new Option(s.title,s.path)));$('record-scope').value=filters.scope;
|
|
43
|
+
$('record-range').textContent=result.total?`${offset+1}–${offset+result.records.length} of ${result.total.toLocaleString()}`:'0 records';$('record-previous').disabled=!offset;$('record-next').disabled=!result.hasMore;
|
|
44
|
+
$('record-results').replaceChildren(...result.records.map(row=>{const b=document.createElement('button');b.className='record-row quiet';b.innerHTML=`<span class="record-row-label"><strong>${esc(row.title||row.id||'Unnamed decision')}</strong><small>${esc(row.path)}${row.line?`:${row.line}`:''}</small></span><span class="record-row-meta"><span class="record-id">${esc(row.id??'No ID')}</span><span class="status-badge">${esc(row.status)}</span>${row.severity?`<span class="severity ${esc(row.severity)}">${esc(row.severity)}</span>`:''}${row.triage==='accepted'?'<small>Accepted</small>':''}</span>`;b.onclick=run(()=>detail(row.key));return b;}));
|
|
45
|
+
if(!result.records.length)$('record-results').innerHTML='<p class="library-no-results">No records match these filters. Choose All statuses or try another search.</p>';
|
|
46
|
+
$('record-unavailable').textContent=result.unavailable?`${result.unavailable} unavailable sources. Refresh to retry.`:'';notice(`${title()} ready.`);
|
|
47
|
+
}catch(error){if(own===sequence&&$('record-range'))$('record-range').textContent='Records unavailable · try Refresh';throw error;}finally{if(own===sequence&&$('record-results'))$('record-results').setAttribute('aria-busy','false');}
|
|
48
|
+
}
|
|
49
|
+
function connectionHtml(links){return links.length?`<div class="record-connections">${links.map((link,i)=>`<button class="quiet small" data-connection="${i}" title="${esc(link.path)}">${esc(link.title)} <span class="muted">${esc(link.kind==='hub'?'Hub':link.type??'Doc')}</span></button>`).join('')}</div>`:'<p class="muted">No linked documents were found in this library.</p>';}
|
|
50
|
+
function historyHtml(history){return history?.length?`<ol class="record-history">${[...history].reverse().map(h=>`<li><strong>${esc(h.event??h.kind??'Recorded')}</strong> <span class="muted">${esc(h.by?.name??h.by?.label??h.by?.id??'')} · ${esc(h.at??'')}</span><p>${esc(h.note??h.reason??'')}</p></li>`).join('')}</ol>`:'<p class="muted">No later actions have been recorded.</p>';}
|
|
51
|
+
async function detail(key,{allowBusy=false}={}){if(state.busy&&!allowBusy)throw new Error('Wait for the current save before navigating.');const own=++sequence,wantedKind=kind;clearTimeout(timer);notice('Opening record…');const row=await api(`records?${new URLSearchParams({kind,key})}`);if(own!==sequence||kind!==wantedKind||state.mode!=='records')return;selected=row;pending=null;try{pending=JSON.parse(localStorage.getItem(recoveryKey())??'null');}catch{}activate();url(key);renderDetail();window.scrollTo(0,0);notice(pending?'An interrupted review is retained. Retry its exact request or keep it while reloading the record.':`${title()} record ready.`);}
|
|
52
|
+
function renderDetail(){const r=selected;
|
|
53
|
+
let content;
|
|
54
|
+
if(r.native)content=nativeContent(r)+`<article class="markdown record-body">${markdownHtml(r.body)}</article><details class="record-structured"><summary>Structured record details</summary><pre>${esc(JSON.stringify(r.data??{},null,2))}</pre></details>`;
|
|
55
|
+
else if(kind==='flags')content=`<div class="record-evidence"><section><h2>Original observation</h2><p class="muted">${esc(r.original.file)}${r.original.line?`:${r.original.line}`:''} · ${esc(r.original.at)}</p><pre>${esc(r.original.quote||'File-level finding; no quote was recorded.')}</pre><p class="muted">${esc(r.original.revisionReason)}</p></section><section><h2>Current evidence <span class="status-badge">${esc(r.current.status)}</span></h2><p class="muted">${r.current.line?`Line ${r.current.line}`:'Location unavailable'}</p><pre>${esc(r.current.quote??'Evidence is unavailable or ambiguous.')}</pre><p class="muted">The original observation stays in the record.</p></section></div><h2>History</h2>${historyHtml(r.history)}`;
|
|
56
|
+
else content=decisionContent(r.body)+`${r.missing?.length?`<p class="inline-note">Record gaps: ${esc(r.missing.join(', '))}. Open the source to fill them in.</p>`:''}${r.blocks?.length?`<h2>Blocking work</h2><ul>${r.blocks.map(b=>`<li>${esc(typeof b==='string'?b:b.text)}${b.doc?` <small class="muted">${esc(b.doc)}:${b.line}</small>`:''}</li>`).join('')}</ul>`:''}${r.peers?.length?`<h2>Related records</h2>${r.peers.map(p=>`<details><summary>${esc(p.id)} · ${esc(p.status)} · ${esc(p.path)}</summary><article class="markdown">${markdownHtml(p.body)}</article></details>`).join('')}`:''}`;
|
|
57
|
+
root.innerHTML=`<div class="record-back"><button id="records-back" class="quiet small">← ${title()}</button><button id="record-reload" class="quiet small">↻ Reload record</button></div><div class="record-heading"><div class="record-eyebrow">${esc(r.id??'Unnamed decision')} · <span class="status-badge">${esc(r.status)}</span> ${r.severity?`· ${esc(r.severity)}`:''}</div><h1>${esc(r.title)}</h1><p class="muted">${esc(r.path)}${r.line?`:${r.line}`:''}</p></div><section class="record-links"><h2>Connected work</h2>${connectionHtml(r.connections??[])}</section>${content}<section class="record-action"><h2>${kind==='flags'?'Triage this flag':'Record an outcome'}</h2>${r.editable?`<form id="record-action-form"><div class="record-action-fields"><label>${kind==='flags'?'Action':'Disposition'}<select id="record-action">${(kind==='flags'?(r.native&&r.status!=='open'?['reopen']:['accept','reject','resolve']):['held','ruled','closed','open']).map(v=>`<option value="${v}">${v[0].toUpperCase()+v.slice(1)}</option>`).join('')}</select></label><label id="record-choice-label" hidden>Selected answer${r.native&&kind==='decisions'?`<select id="record-choice"><option value="">Choose an existing alternative</option>${(r.data?.options??[]).map(o=>`<option value="${esc(o.id)}">${esc(o.label)}</option>`).join('')}</select>`:'<input id="record-choice" maxlength="4000" placeholder="Which answer was chosen?">'}</label></div><label>Reason<textarea id="record-note" required maxlength="4000" rows="3" placeholder="Why is this the right next step?"></textarea></label><p class="muted">${kind==='flags'?'Accept keeps the flag open. Reject closes it. Resolve records a manual resolution; it does not claim a passing check. Reopen retains earlier history.':'The new outcome is recorded with your identity and date. Previous outcomes and the original record are retained.'}</p><button id="record-review-button" class="primary">Review ${kind==='flags'?'triage':'outcome'}</button></form>`:`<p class="muted">${r.readOnlyReason?esc(r.readOnlyReason):r.native?'Repair the native record diagnostics before recording an action.':kind==='flags'?'This flag has already been closed. Its observation and history are retained.':'This record is read only or has no stable ID. Open its source to inspect or complete it.'}</p>`}<div id="record-review" hidden></div></section>`;
|
|
58
|
+
$('records-back').onclick=run(()=>load());$('record-reload').onclick=run(async()=>{await api(`records?kind=${kind}&refresh=1`);await detail(r.key);});
|
|
59
|
+
for(const b of root.querySelectorAll('[data-connection]'))b.onclick=run(()=>open(r.connections[Number(b.dataset.connection)].path));
|
|
60
|
+
if(r.editable){$('record-action').onchange=()=>{const ruled=$('record-action').value==='ruled';$('record-choice-label').hidden=!ruled;$('record-choice').required=ruled;};$('record-action-form').onsubmit=e=>{e.preventDefault();run(review)();};}
|
|
61
|
+
if(pending)renderReview();
|
|
62
|
+
}
|
|
63
|
+
async function review(){if(state.busy)return;const action=$('record-action').value,note=$('record-note').value,choice=$('record-choice').value;
|
|
64
|
+
if(selected.native){const preview=await api('native/preview',{path:selected.path,expectedRevision:selected.revision,operationId:crypto.randomUUID(),action,note,optionId:choice});pending={form:{action,note,choice},route:'native/action',request:preview.request,before:preview.before,label:`Record ${action}`,note};}
|
|
65
|
+
else if(kind==='flags')pending={form:{action,note,choice},route:'flags/triage',request:{key:selected.key,action,note,expectedRevision:selected.revision,operationId:crypto.randomUUID()},label:`${action[0].toUpperCase()+action.slice(1)} ${selected.id}`,note};
|
|
66
|
+
else {const preview=await api('decisions/preview',{key:selected.key,expectedRevision:selected.revision,disposition:action,note,choice});pending={form:{action,note,choice},route:'save',request:{path:preview.path,source:preview.source,expectedRevision:preview.expectedRevision,operationId:crypto.randomUUID()},before:preview.before,label:`Record ${action} for ${selected.id}`,note};}
|
|
67
|
+
// Retain exactly the reviewed request before any write, including its UUID.
|
|
68
|
+
try{localStorage.setItem(recoveryKey(),JSON.stringify(pending));}catch{pending=null;throw new Error('The review could not be retained in local editor storage. Free some storage and try again.');}renderReview();
|
|
69
|
+
}
|
|
70
|
+
function renderReview(){if($('record-action-form'))$('record-action-form').hidden=true;$('record-review').hidden=false;$('record-review').innerHTML=`<h3>${esc(pending.label)}</h3><p>${esc(pending.note)}</p>${pending.before?`<div class="diff">${diffHtml(pending.before,pending.request.source)}</div>`:'<p class="muted">This appends a dated action to the flag history. The original evidence is retained.</p>'}<div class="record-review-actions"><button id="record-confirm" class="primary">Confirm ${kind==='flags'?'triage':'outcome'}</button><button id="record-change" class="quiet">Back to form</button></div><p id="record-action-feedback" role="status"></p>`;$('record-confirm').onclick=run(confirm);$('record-change').onclick=()=>{localStorage.removeItem(recoveryKey());const form=pending.form;pending=null;$('record-review').hidden=true;if($('record-action-form')){$('record-action-form').hidden=false;if(form){$('record-action').value=form.action;$('record-note').value=form.note;$('record-choice').value=form.choice;$('record-action').onchange();}}};}
|
|
71
|
+
async function confirm(){if(state.busy)return;state.busy=true;$('record-confirm').disabled=true;$('record-change').disabled=true;
|
|
72
|
+
try{await api(pending.route,pending.request);localStorage.removeItem(recoveryKey());pending=null;await counts();await detail(selected.key,{allowBusy:true});notice('Saved. The source and history are available through the CLI.');}
|
|
73
|
+
catch(error){if($('record-action-feedback'))$('record-action-feedback').textContent=`${error.message} Your reviewed request is retained. Reload the record to inspect it, or retry this exact request.`;throw error;}
|
|
74
|
+
finally{state.busy=false;if($('record-confirm'))$('record-confirm').disabled=false;if($('record-change'))$('record-change').disabled=false;}
|
|
75
|
+
}
|
|
76
|
+
for(const k of ['flags','decisions'])$('library-'+k).onclick=run(()=>show(k));
|
|
77
|
+
return {show,counts,deactivate};
|
|
78
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export function recoveryCenter({$,api,checkout,beforeNavigate,resume,notice}) {
|
|
2
|
+
const dialog=$('recovery-dialog');let rows=[],offset=0,hasMore=false,sequence=0;
|
|
3
|
+
function localItems(){
|
|
4
|
+
const items=[];let unreadable=0;
|
|
5
|
+
try { for(let i=0;i<localStorage.length;i++) {
|
|
6
|
+
const key=localStorage.key(i),prefix=`runlist:recovery:${checkout()}:`,lifecycle=`runlist:lifecycle:${checkout()}:`,record=`runlist:record-action:${checkout()}:`,yardstick=`runlist:yardstick:${checkout()}:`;
|
|
7
|
+
if(!key.startsWith(prefix)&&!key.startsWith(lifecycle)&&!key.startsWith(record)&&!key.startsWith(yardstick)&&key!==`runlist:create:${checkout()}`)continue;
|
|
8
|
+
try {
|
|
9
|
+
const value=JSON.parse(localStorage.getItem(key));if(!value)continue;
|
|
10
|
+
if(key.startsWith(prefix)&&typeof value.path==='string'&&typeof value.source==='string'&&typeof value.baseSource==='string'&&(value.source!==value.baseSource||value.pending))items.push({kind:'local-draft',path:value.path,location:key,draftId:value.draft?.id,operationId:value.pending?.operationId,at:new Date(value.at).toISOString(),stale:!!value.conflicted,state:value.pending?'interrupted':value.conflicted?'conflict':'draft',available:true});
|
|
11
|
+
else if(key.startsWith(yardstick))items.push({kind:'yardstick',path:value.path,operationId:value.operationId,at:'',available:true,state:value.state??'retained'});
|
|
12
|
+
else if(key.startsWith(lifecycle))items.push({kind:'lifecycle',path:value.path,operationId:value.operationId,at:'',available:true,state:'retained'});
|
|
13
|
+
else if(key.startsWith(record)){const suffix=key.slice(record.length),colon=suffix.indexOf(':');items.push({kind:'record',recordKind:suffix.slice(0,colon),recordKey:suffix.slice(colon+1),path:value.request?.path??'Flag or decision',at:'',available:true,state:'retained'});}
|
|
14
|
+
else if(key===`runlist:create:${checkout()}`&&(value.form?.title||value.form?.body||value.review))items.push({kind:'creation',operationId:value.review?.operationId,path:value.review?.path??`${value.form?.folder??''}/${value.form?.filename??'New document'}`,state:value.review?.state??'draft',at:new Date(value.at).toISOString(),available:true});
|
|
15
|
+
}catch{unreadable++;}
|
|
16
|
+
}
|
|
17
|
+
} catch {unreadable++;}
|
|
18
|
+
return {items,unreadable};
|
|
19
|
+
}
|
|
20
|
+
const label=row=>row.kind==='git-commit'?`Local Git commit · ${row.state}`:row.kind==='creation'?'New document draft':row.kind==='yardstick'?`Product goal assessment · ${row.state}`:row.kind==='lifecycle'?`Status change · ${row.state}`:row.kind==='record'?'Flag or decision review':row.kind==='operation'?'Interrupted save · inspect outcome':row.operationId||row.pendingOperation?'Interrupted save · draft preserved locally':row.stale?'File changed · compare draft':'Draft preserved locally';
|
|
21
|
+
function render(){
|
|
22
|
+
const query=$('recovery-filter').value.toLowerCase(),visible=rows.filter(row=>`${row.path} ${label(row)}`.toLowerCase().includes(query));
|
|
23
|
+
$('recovery-items').replaceChildren(...visible.map(row=>{
|
|
24
|
+
const section=document.createElement('section');section.className='recovery-item';
|
|
25
|
+
const info=document.createElement('div'),title=document.createElement('strong'),detail=document.createElement('p'),date=document.createElement('small');title.textContent=row.path;detail.textContent=label(row);date.textContent=row.at?new Date(row.at).toLocaleString():'';info.append(title,detail,date);
|
|
26
|
+
const button=document.createElement('button');button.className='quiet';button.textContent=!row.available?'File unavailable':row.kind==='creation'?'Resume creation':row.kind==='git-commit'||row.kind==='yardstick'||row.kind==='lifecycle'||row.kind==='operation'?'Inspect operation':row.kind==='record'?'Resume review':'Resume draft';button.disabled=!row.available;
|
|
27
|
+
button.onclick=async()=>{try{dialog.close();await resume(row);}catch(error){notice(error.message,true);}};section.append(info,button);return section;
|
|
28
|
+
}));
|
|
29
|
+
if(!visible.length){const message=document.createElement('p');message.className='library-no-results';message.textContent=query?'No recovery items match this filter.':'No unfinished work in this checkout.';$('recovery-items').append(message);}
|
|
30
|
+
$('recovery-more').hidden=!hasMore;
|
|
31
|
+
}
|
|
32
|
+
async function load(more=false){
|
|
33
|
+
const own=++sequence;$('recovery-status').textContent='Reading retained work…';$('recovery-refresh').disabled=true;
|
|
34
|
+
try {
|
|
35
|
+
if(!more)offset=0;
|
|
36
|
+
const result=await api(`recovery?offset=${offset}&limit=50`);if(own!==sequence)return;
|
|
37
|
+
const local=localItems(),disk=more?rows.filter(row=>row.disk):[];
|
|
38
|
+
const localDrafts=new Set(local.items.map(row=>row.draftId).filter(Boolean)),localOperations=new Set(local.items.map(row=>row.operationId).filter(Boolean));
|
|
39
|
+
const diskRows=[...disk,...result.items.map(row=>({...row,disk:true}))];
|
|
40
|
+
const pendingDrafts=new Set(diskRows.filter(row=>row.kind==='draft').map(row=>row.pendingOperation?.operationId).filter(Boolean));
|
|
41
|
+
const entries=[...local.items,...diskRows.filter(row=>!localDrafts.has(row.draftId)&&!localOperations.has(row.operationId)&&!(row.kind==='operation'&&pendingDrafts.has(row.operationId)))],seen=new Set();rows=entries.filter(row=>{
|
|
42
|
+
const key=row.location??(row.operationId?`${row.kind}:${row.operationId}`:row.draftId?`draft:${row.draftId}`:`${row.kind}:${row.path}`);
|
|
43
|
+
if(seen.has(key))return false;seen.add(key);return true;
|
|
44
|
+
});
|
|
45
|
+
rows.sort((a,b)=>String(b.at).localeCompare(String(a.at)));offset=result.offset+result.items.length;hasMore=result.hasMore;render();
|
|
46
|
+
$('recovery-status').textContent=`${rows.length} items shown${hasMore?' · More retained items available':''}${result.unavailable||local.unreadable?` · ${result.unavailable+local.unreadable} entries need manual inspection; their stored data was kept.`:''}`;
|
|
47
|
+
}catch(error){$('recovery-status').textContent=`Recovery unavailable. ${error.message} Retained data has been kept.`;}
|
|
48
|
+
finally{if(own===sequence)$('recovery-refresh').disabled=false;}
|
|
49
|
+
}
|
|
50
|
+
async function show(){await beforeNavigate();$('recovery-filter').value='';dialog.showModal();$('recovery-filter').focus();await load();}
|
|
51
|
+
$('open-recovery').onclick=()=>show().catch(error=>notice(error.message,true));$('recovery-close').onclick=()=>dialog.close();$('recovery-refresh').onclick=()=>load();$('recovery-more').onclick=()=>load(true);$('recovery-filter').oninput=render;
|
|
52
|
+
return {show,load};
|
|
53
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Background jobs keep the native helper responsive to Cancel and navigation.
|
|
2
|
+
export async function semanticSearch(api,body,signal){
|
|
3
|
+
let id,complete=false;
|
|
4
|
+
try{
|
|
5
|
+
signal.throwIfAborted();const job=await api('semantic/start',body);id=job.id;
|
|
6
|
+
if(typeof id!=='string')throw new Error('Semantic search returned an invalid request.');
|
|
7
|
+
for(let i=0;i<80;i++){
|
|
8
|
+
signal.throwIfAborted();const result=await api(`semantic/result?${new URLSearchParams({id})}`);signal.throwIfAborted();
|
|
9
|
+
if(result.state!=='running'){complete=true;return result;}
|
|
10
|
+
await new Promise((resolve,reject)=>{const abort=()=>{clearTimeout(timer);reject(new Error('Cancelled'));};const timer=setTimeout(()=>{signal.removeEventListener('abort',abort);resolve();},150);signal.addEventListener('abort',abort,{once:true});});
|
|
11
|
+
}
|
|
12
|
+
throw new Error('Semantic search timed out. Exact search is available.');
|
|
13
|
+
}finally{if(id&&!complete)await api('semantic/cancel',{id}).catch(()=>{});}
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {desktop} from './transport.mjs';
|
|
2
|
+
import {escapeHtml as esc} from './shared.mjs';
|
|
3
|
+
export function appSettings({$,api,checkout,library,notice}){
|
|
4
|
+
const key=()=>`runlist:preferences:${checkout()}`;
|
|
5
|
+
const defaults={sort:'updated',archived:false,records:'pending'};
|
|
6
|
+
const read=()=>{try{const p=JSON.parse(localStorage.getItem(key())??'{}');return {sort:['updated','title','path'].includes(p.sort)?p.sort:'updated',archived:p.archived===true,records:p.records===''?'':'pending'};}catch{return {...defaults};}};
|
|
7
|
+
async function show(){const info=await api('settings'),tool=await api('semantic/settings'),prefs=read();$('settings-checkout').textContent=info.checkout;$('settings-content').innerHTML=`<section class="settings-section"><h3>${desktop?'This computer':'This browser'}</h3><p class="muted">These preferences stay ${desktop?'in Runlist on this computer':'in this browser'}. They apply when you open this checkout.</p><form id="settings-preferences"><div class="settings-fields"><label>Appearance<select id="settings-theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></label><label>Default document order<select id="settings-sort"><option value="updated">Recently updated</option><option value="title">Title</option><option value="path">File path</option></select></label><label>Default records view<select id="settings-records"><option value="pending">Needs attention</option><option value="">All statuses</option></select></label><label class="settings-check"><input id="settings-archived" type="checkbox"> Include archived documents by default</label></div><button class="primary">Save preferences</button><span id="settings-feedback" role="status"></span></form></section><section class="settings-section"><h3>Semantic search · experimental</h3><p class="muted">${esc(tool.message)} No gmax dependency is bundled with Runlist. Leave both paths blank to use automatic discovery.</p><form id="settings-semantic"><div class="settings-fields"><label>External Node executable<input id="settings-semantic-node" autocomplete="off" placeholder="Absolute path to node"></label><label>gmax entry file<input id="settings-semantic-entry" autocomplete="off" placeholder="Absolute path to grepmax/dist/bin.js"></label></div><button class="quiet">Save tool paths</button><span id="settings-semantic-feedback" role="status"></span></form></section><section class="settings-section"><h3>Templates</h3><p class="muted">View, customize and review the scaffolds for new documents. Saved templates are repository files and can be shared through Git.</p><button id="settings-templates" class="quiet">Open templates</button></section><section class="settings-section"><h3>Checkout rules</h3><p class="muted">Loaded from ${esc(info.configFile??'built-in defaults')}. Edit repository configuration through the CLI or its source; this view shows the effective rules.</p><dl class="settings-paths"><dt>Document roots</dt><dd>${info.roots.map(p=>`<code>${esc(p)}</code>`).join(' ')}</dd><dt>Excluded folders</dt><dd>${info.exclusions.map(p=>`<code>${esc(p)}</code>`).join(' ')}</dd></dl>${info.statuses.typeOrder.map(type=>`<details class="settings-rules"><summary>${esc(type)} · ${info.statuses.byType[type].length} statuses</summary><div class="settings-table"><table><thead><tr><th>Status</th><th>Context</th><th>Behavior</th></tr></thead><tbody>${info.statuses.byType[type].map(s=>`<tr><td>${esc(s.name)}</td><td>${esc(s.context)}</td><td>${esc([s.startable?'Can start':null,s.terminal?'Terminal':null,s.archive?'Archives':null,s.filed?`Files in ${s.filed}`:null,s.staleDays?`Stale after ${s.staleDays} days`:null].filter(Boolean).join(' · ')||'Ordinary')}</td></tr>`).join('')}</tbody></table></div></details>`).join('')}</section><section class="settings-section"><h3>Where changes live</h3><dl class="settings-paths">${Object.entries(info.sharing).map(([name,value])=>`<dt>${esc(name)}</dt><dd>${esc(value)}</dd>`).join('')}</dl></section>`;
|
|
8
|
+
$('settings-semantic-node').value=tool.nodePath;$('settings-semantic-entry').value=tool.entryPath;
|
|
9
|
+
$('settings-semantic').onsubmit=async event=>{event.preventDefault();try{const saved=await api('semantic/settings',{nodePath:$('settings-semantic-node').value.trim(),entryPath:$('settings-semantic-entry').value.trim()});$('settings-semantic-feedback').textContent=saved.message;}catch(error){$('settings-semantic-feedback').textContent=error.message;}};
|
|
10
|
+
$('settings-theme').value=$('theme-mode').value;$('settings-sort').value=prefs.sort;$('settings-archived').checked=prefs.archived;$('settings-records').value=prefs.records;
|
|
11
|
+
$('settings-preferences').onsubmit=async event=>{event.preventDefault();try{const preferences={sort:$('settings-sort').value,archived:$('settings-archived').checked,records:$('settings-records').value};localStorage.setItem(key(),JSON.stringify(preferences));$('theme-mode').value=$('settings-theme').value;$('theme-mode').dispatchEvent(new Event('change'));await library.preferences(preferences);$('settings-feedback').textContent='Preferences saved.';}catch(error){notice(error.message,true);$('settings-feedback').textContent='Preferences could not be saved.';}};
|
|
12
|
+
$('settings-templates').onclick=()=>{$('settings-dialog').close();$('open-templates').click();};$('settings-dialog').showModal();
|
|
13
|
+
}
|
|
14
|
+
$('open-settings').onclick=()=>show().catch(error=>notice(error.message,true));$('settings-close').onclick=()=>$('settings-dialog').close();return {read};
|
|
15
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Pure browser/server helpers. Source is always escaped; authored HTML is text.
|
|
2
|
+
export {readSourceStage, replaceSourceStage, rebaseSourceStage} from './stage-source.mjs';
|
|
3
|
+
export const escapeHtml = value => String(value).replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
|
|
4
|
+
export function splitSource(source) {
|
|
5
|
+
const envelope = source.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/)?.[0] ?? '';
|
|
6
|
+
return { envelope, body: source.slice(envelope.length) };
|
|
7
|
+
}
|
|
8
|
+
export function lineDiff(before, after) {
|
|
9
|
+
const a = before.replaceAll('\r\n', '\n').split('\n'), b = after.replaceAll('\r\n', '\n').split('\n');
|
|
10
|
+
let first = 0, lastA = a.length, lastB = b.length;
|
|
11
|
+
const result = [];
|
|
12
|
+
while (first < lastA && first < lastB && a[first] === b[first]) result.push({ kind:'same', text:a[first], oldIndex:first++ });
|
|
13
|
+
while (lastA > first && lastB > first && a[lastA - 1] === b[lastB - 1]) { lastA--; lastB--; }
|
|
14
|
+
const n = lastA - first, m = lastB - first;
|
|
15
|
+
if (n * m > 1_000_000) throw new Error('This diff is too large to review safely. Make a smaller edit or review it through the CLI.');
|
|
16
|
+
const rows = Array.from({ length:n + 1 }, () => new Uint32Array(m + 1));
|
|
17
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) rows[i][j] = a[first + i] === b[first + j] ? rows[i + 1][j + 1] + 1 : Math.max(rows[i + 1][j], rows[i][j + 1]);
|
|
18
|
+
let i = 0, j = 0;
|
|
19
|
+
while (i < n || j < m) {
|
|
20
|
+
if (i < n && j < m && a[first + i] === b[first + j]) { result.push({ kind:'same', text:a[first + i], oldIndex:first + i }); i++; j++; }
|
|
21
|
+
else if (j < m && (i === n || rows[i][j + 1] > rows[i + 1][j])) result.push({ kind:'add', text:b[first + j++] });
|
|
22
|
+
else { result.push({ kind:'remove', text:a[first + i], oldIndex:first + i }); i++; }
|
|
23
|
+
}
|
|
24
|
+
while (lastA < a.length) { result.push({ kind:'same', text:a[lastA], oldIndex:lastA }); lastA++; }
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
// Textareas normalize line endings. Reuse exact raw lines for every unchanged
|
|
28
|
+
// line so an edit does not normalize unrelated CRLF or mixed-ending content.
|
|
29
|
+
export function editedSource(base, body) {
|
|
30
|
+
const parts = splitSource(base), lines = parts.body.split('\n');
|
|
31
|
+
const eol = parts.body.includes('\r\n') || parts.envelope.includes('\r\n') ? '\r' : '';
|
|
32
|
+
const diff = lineDiff(parts.body, body);
|
|
33
|
+
const retained = diff.filter(row => row.kind !== 'remove');
|
|
34
|
+
return parts.envelope + retained.map((row, i) => row.kind === 'same' ? (i === retained.length - 1 && row.oldIndex < lines.length - 1 ? lines[row.oldIndex].replace(/\r$/, '') : lines[row.oldIndex]) : row.text + (i < retained.length - 1 ? eol : '')).join('\n');
|
|
35
|
+
}
|
|
36
|
+
function safeLink(href, resolve) {
|
|
37
|
+
if (/^(https?:|mailto:)/i.test(href) || /^#[^\s]*$/.test(href)) return href;
|
|
38
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\\)/i.test(href)) return null;
|
|
39
|
+
return resolve?.(href) ?? null;
|
|
40
|
+
}
|
|
41
|
+
function inline(text, resolve) {
|
|
42
|
+
// Tokenize before escaping so authored markup cannot break attributes, and
|
|
43
|
+
// code spans never run through emphasis/link replacements.
|
|
44
|
+
const token = /`([^`]+)`|!?\[([^\]]+)\]\(([^\s)]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*/g;
|
|
45
|
+
let html = '', cursor = 0;
|
|
46
|
+
for (const match of text.matchAll(token)) {
|
|
47
|
+
html += escapeHtml(text.slice(cursor, match.index));
|
|
48
|
+
if (match[1] !== undefined) html += `<code>${escapeHtml(match[1])}</code>`;
|
|
49
|
+
else if (match[2] !== undefined) {
|
|
50
|
+
const href = safeLink(match[3], resolve);
|
|
51
|
+
html += href ? `<a href="${escapeHtml(href)}" rel="noreferrer">${escapeHtml(match[2])}</a>` : escapeHtml(match[2]);
|
|
52
|
+
} else if (match[4] !== undefined) html += `<strong>${escapeHtml(match[4])}</strong>`;
|
|
53
|
+
else html += `<em>${escapeHtml(match[5])}</em>`;
|
|
54
|
+
cursor = match.index + match[0].length;
|
|
55
|
+
}
|
|
56
|
+
return html + escapeHtml(text.slice(cursor));
|
|
57
|
+
}
|
|
58
|
+
export function markdownHtml(source, resolve) {
|
|
59
|
+
const lines = splitSource(source).body.replaceAll('\r\n', '\n').split('\n');
|
|
60
|
+
const out = []; let i = 0;
|
|
61
|
+
const cells = line => line.trim().replace(/^\||\|$/g, '').split('|').map(c => inline(c.trim(), resolve));
|
|
62
|
+
while (i < lines.length) {
|
|
63
|
+
const line = lines[i];
|
|
64
|
+
if (!line.trim()) { i++; continue; }
|
|
65
|
+
const fence = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
|
|
66
|
+
if (fence) {
|
|
67
|
+
const code = []; i++;
|
|
68
|
+
while (i < lines.length && !new RegExp(`^ {0,3}${fence[1][0]}{${fence[1].length},}\\s*$`).test(lines[i])) code.push(lines[i++]);
|
|
69
|
+
i++; out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`); continue;
|
|
70
|
+
}
|
|
71
|
+
if (/^\s*<!--/.test(line)) { while (i < lines.length && !lines[i++].includes('-->')) { /* comments are retained in source */ } continue; }
|
|
72
|
+
const heading = line.match(/^ {0,3}(#{1,6})\s+(.+?)\s*#*$/);
|
|
73
|
+
if (heading) { out.push(`<h${heading[1].length}>${inline(heading[2], resolve)}</h${heading[1].length}>`); i++; continue; }
|
|
74
|
+
if (line.includes('|') && /^\s*\|?\s*:?-{3,}/.test(lines[i + 1] ?? '')) {
|
|
75
|
+
const header = cells(line); i += 2; const rows = [];
|
|
76
|
+
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) rows.push(`<tr>${cells(lines[i++]).map(c => `<td>${c}</td>`).join('')}</tr>`);
|
|
77
|
+
out.push(`<div class="table-wrap"><table><thead><tr>${header.map(c => `<th>${c}</th>`).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table></div>`); continue;
|
|
78
|
+
}
|
|
79
|
+
if (/^ {0,3}(?:[-*+] |\d+\. )/.test(line)) {
|
|
80
|
+
const ordered = /^ {0,3}\d+\. /.test(line), items = [];
|
|
81
|
+
while (i < lines.length && /^ {0,3}(?:[-*+] |\d+\. )/.test(lines[i])) {
|
|
82
|
+
const value = lines[i++].replace(/^ {0,3}(?:[-*+] |\d+\. )/, ''), task = value.match(/^\[([ xX])\] (.*)/);
|
|
83
|
+
items.push(task ? `<li class="task"><span aria-label="${task[1] === ' ' ? 'Incomplete' : 'Complete'}">${task[1] === ' ' ? '□' : '✓'}</span><div>${inline(task[2], resolve)}</div></li>` : `<li>${inline(value, resolve)}</li>`);
|
|
84
|
+
}
|
|
85
|
+
out.push(`<${ordered ? 'ol' : 'ul'}>${items.join('')}</${ordered ? 'ol' : 'ul'}>`); continue;
|
|
86
|
+
}
|
|
87
|
+
if (/^>\s?/.test(line)) { out.push(`<blockquote>${inline(line.replace(/^>\s?/, ''), resolve)}</blockquote>`); i++; continue; }
|
|
88
|
+
if (/^\s*(?:---+|\*\*\*+)\s*$/.test(line)) { out.push('<hr>'); i++; continue; }
|
|
89
|
+
const paragraph = [line]; i++;
|
|
90
|
+
while (i < lines.length && lines[i].trim() && !/^(?: {0,3}(?:#{1,6}\s|[-*+] |\d+\. |`{3,}|~{3,})|>|\s*<!--)/.test(lines[i]) && !(lines[i].includes('|') && /^\s*\|?\s*:?-{3,}/.test(lines[i + 1] ?? ''))) paragraph.push(lines[i++]);
|
|
91
|
+
out.push(`<p>${inline(paragraph.join('\n'), resolve)}</p>`);
|
|
92
|
+
}
|
|
93
|
+
return out.join('\n');
|
|
94
|
+
}
|
|
95
|
+
export function diffHtml(before, after) {
|
|
96
|
+
const rows = lineDiff(before, after), visible = new Set();
|
|
97
|
+
rows.forEach((r, i) => { if (r.kind !== 'same') for (let j = Math.max(0, i - 3); j <= Math.min(rows.length - 1, i + 3); j++) visible.add(j); });
|
|
98
|
+
if (!visible.size) return '<p class="muted">No source changes.</p>';
|
|
99
|
+
let previous = -2;
|
|
100
|
+
return [...visible].map(i => {
|
|
101
|
+
const row = rows[i], gap = i > previous + 1 ? '<div class="diff-gap">···</div>' : ''; previous = i;
|
|
102
|
+
return gap + `<div class="diff-line ${row.kind}"><span>${row.kind === 'add' ? '+' : row.kind === 'remove' ? '−' : ' '}</span><code>${escapeHtml(row.text) || ' '}</code></div>`;
|
|
103
|
+
}).join('');
|
|
104
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// A stage draft changes only the plan's ships field. Keep all other source bytes.
|
|
2
|
+
function field(source) {
|
|
3
|
+
const envelope = source.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/)?.[0];
|
|
4
|
+
if (!envelope) throw new Error('Stage editing requires document metadata.');
|
|
5
|
+
const lines = envelope.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
6
|
+
const matches = lines.map((line, i) => /^ships:/.test(line) ? i : -1).filter(i => i >= 0);
|
|
7
|
+
if (matches.length > 1) throw new Error('Repair duplicate ships fields before changing the stage.');
|
|
8
|
+
const start = matches[0] ?? -1;
|
|
9
|
+
let end = start + 1;
|
|
10
|
+
const raw = start < 0 ? '' : lines[start].replace(/^ships:[ \t]*/, '').trim();
|
|
11
|
+
if (start >= 0 && (!raw || /^[>|][-+]?$/.test(raw))) {
|
|
12
|
+
let indent=null;
|
|
13
|
+
while (end < lines.length - 1) {
|
|
14
|
+
if (lines[end].trim() && /^[ \t]/.test(lines[end])) {
|
|
15
|
+
const current=lines[end].match(/^[ \t]*/)[0].length;
|
|
16
|
+
if(indent!==null&¤t<indent)break;
|
|
17
|
+
indent??=current;end++;continue;
|
|
18
|
+
}
|
|
19
|
+
if (!lines[end].trim()) {
|
|
20
|
+
let next=end+1;while(next<lines.length-1&&!lines[next].trim())next++;
|
|
21
|
+
if (/^[ \t]/.test(lines[next]??'')) {end=next;continue;}
|
|
22
|
+
}
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return {envelope, lines, start, end};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function withoutSourceStage(source) {
|
|
30
|
+
const {envelope,lines,start,end} = field(source);
|
|
31
|
+
if (start < 0) return source;
|
|
32
|
+
return lines.slice(0,start).join('') + lines.slice(end).join('') + source.slice(envelope.length);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function replaceSourceStage(source, word) {
|
|
36
|
+
if (word !== null && (typeof word !== 'string' || !word.trim() || /[\r\n\0]/.test(word))) throw new Error('Choose a single stage from this checkout.');
|
|
37
|
+
const {envelope,lines,start,end} = field(source), newline = lines[0].endsWith('\r\n') ? '\r\n' : '\n';
|
|
38
|
+
const next = word === null ? '' : `ships: ${JSON.stringify(word)}${newline}`;
|
|
39
|
+
if (start >= 0) return lines.slice(0,start).join('') + next + lines.slice(end).join('') + source.slice(envelope.length);
|
|
40
|
+
return lines.slice(0,-1).join('') + next + lines.at(-1) + source.slice(envelope.length);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readSourceStage(source) {
|
|
44
|
+
try {
|
|
45
|
+
const {lines,start,end} = field(source);
|
|
46
|
+
if (start < 0) return {word:null,invalid:false};
|
|
47
|
+
let value = lines[start].replace(/^ships:\s*/, '').trim();
|
|
48
|
+
if (!value && lines.slice(start+1,end).every(line=>!line.trim()||line.trimStart().startsWith('#'))) return {word:null,invalid:false};
|
|
49
|
+
if (/^[>|][-+]?$/.test(value)) {
|
|
50
|
+
const raw = lines.slice(start + 1,end).map(line=>line.replace(/\r?\n$/,''));
|
|
51
|
+
const indent = raw.find(line=>line.trim())?.match(/^[ \t]*/)[0].length ?? 0;
|
|
52
|
+
const contents=raw.map(line=>line.trim()?line.slice(indent):'');
|
|
53
|
+
const keep=value.endsWith('+');
|
|
54
|
+
if(value.startsWith('|'))value=contents.join('\n');
|
|
55
|
+
else {value='';let hasContent=false,blank=false;for(const line of contents){if(!line){if(hasContent&&!blank)value+='\n';blank=true;}else{if(hasContent&&!blank)value+=' ';value+=line;hasContent=true;blank=false;}}}
|
|
56
|
+
if(keep&&!value.endsWith('\n'))value+='\n';
|
|
57
|
+
} else if (end > start + 1 || value.startsWith('[') || value.startsWith('{')) return {word:null,invalid:true};
|
|
58
|
+
else if (value.startsWith('"')) {try {value = JSON.parse(value);} catch {return {word:null,invalid:true};}}
|
|
59
|
+
else if (value.startsWith("'")) {if (!value.endsWith("'")) return {word:null,invalid:true};value = value.slice(1,-1).replaceAll("''", "'");}
|
|
60
|
+
else if (value === 'true' || value === 'false') return {word:null,invalid:true};
|
|
61
|
+
const word=typeof value==='string'?value.trim()||null:null;
|
|
62
|
+
return typeof value === 'string' && !(word && /[\r\n\0]/.test(value)) ? {word,invalid:false} : {word:null,invalid:true};
|
|
63
|
+
} catch {return {word:null,invalid:true};}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function rebaseSourceStage(base, draft, current) {
|
|
67
|
+
// Metadata refreshed by another writer is authoritative except for the
|
|
68
|
+
// explicitly drafted stage. The resulting change still requires review.
|
|
69
|
+
const baseStage = field(base), draftStage = field(draft);
|
|
70
|
+
const bytes = value => value.start < 0 ? '' : value.lines.slice(value.start,value.end).join('');
|
|
71
|
+
if (bytes(baseStage) === bytes(draftStage)) return current;
|
|
72
|
+
const {word,invalid} = readSourceStage(draft);
|
|
73
|
+
if (invalid) throw new Error('Repair the stage draft before merging it.');
|
|
74
|
+
return replaceSourceStage(current,word);
|
|
75
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { markdownHtml, diffHtml } from './shared.mjs';
|
|
2
|
+
export function templateEditor({api,$,checkout,notice}) {
|
|
3
|
+
let catalog, selected, busy=false, preview=false;
|
|
4
|
+
const drafts=new Map();
|
|
5
|
+
const key=name=>`runlist:template-draft:${checkout()}:${name}`;
|
|
6
|
+
const current=()=>drafts.get(selected.name);
|
|
7
|
+
function keep() {if(!selected?.editable)return;try{localStorage.setItem(key(selected.name),JSON.stringify(current()));}catch{feedback('Template draft could not be saved in local editor storage. Keep this dialog open.',true);}}
|
|
8
|
+
function feedback(text,error=false){$('template-feedback').textContent=text;$('template-feedback').classList.toggle('error',error);}
|
|
9
|
+
function dirty(){const d=current();return selected?.editable&&(d.source!==d.base||d.reset);}
|
|
10
|
+
function render() {
|
|
11
|
+
if(!selected)return;
|
|
12
|
+
const d=current();$('template-name').textContent=selected.name;$('template-origin').textContent=selected.origin;$('template-description').textContent=selected.description;
|
|
13
|
+
$('template-config-note').hidden=!selected.configuredSource;$('template-config').hidden=!selected.configuredSource;$('template-config-source').textContent=selected.configuredSource??'';
|
|
14
|
+
$('template-source').value=d.source;$('template-source').readOnly=!selected.editable;$('template-source').hidden=preview;
|
|
15
|
+
$('template-preview').hidden=!preview;
|
|
16
|
+
$('template-preview-tab').disabled=!selected.editable;
|
|
17
|
+
$('template-edit-tab').setAttribute('aria-pressed',String(!preview));$('template-preview-tab').setAttribute('aria-pressed',String(preview));
|
|
18
|
+
const example=d.source.replace(/\{\{(title|status|date|body|version)\}\}/g,(_,token)=>({title:'Example document',status:selected.name==='prompt'?'pending':selected.name==='plan'?'planned':'active',date:new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),body:'Authored content goes here.',version:catalog.version})[token]);
|
|
19
|
+
$('template-preview').innerHTML=markdownHtml(example);
|
|
20
|
+
$('template-review-button').disabled=!dirty()||busy;$('template-reset').hidden=!selected.editable||(!selected.overridden&&!selected.configuredSource);
|
|
21
|
+
$('template-discard').disabled=!dirty()||busy;$('template-tokens').hidden=!selected.editable;
|
|
22
|
+
$('template-review-button').hidden=!selected.editable;$('template-save').hidden=true;$('template-review').hidden=true;
|
|
23
|
+
$('template-save').disabled=busy;$('template-source').disabled=busy;
|
|
24
|
+
$('template-reset').disabled=busy;$('close-templates').disabled=busy;
|
|
25
|
+
for(const button of $('template-list').children){button.classList.toggle('active',button.dataset.name===selected.name);button.disabled=busy;}
|
|
26
|
+
}
|
|
27
|
+
function select(template) {
|
|
28
|
+
selected=template;preview=false;$('template-reload').hidden=true;
|
|
29
|
+
if(!drafts.has(template.name)){
|
|
30
|
+
let saved;try{saved=JSON.parse(localStorage.getItem(key(template.name)));}catch{}
|
|
31
|
+
drafts.set(template.name,saved&&typeof saved.source==='string'&&typeof saved.base==='string'&&('revision'in saved)?saved:{source:template.source,base:template.source,revision:catalog.revision,reset:false});
|
|
32
|
+
}
|
|
33
|
+
render();feedback(!template.editable?'JavaScript template · edit this source in runlist.config.mjs.':dirty()?'Your template draft is kept. Review it before saving.':template.configuredSource&&!template.overridden?'The editor contains a Markdown override starter; the JavaScript template stays active until you save.':'Choose Edit Markdown or Preview example.');
|
|
34
|
+
}
|
|
35
|
+
function list(){ $('template-list').replaceChildren(...catalog.templates.map(template=>{const button=document.createElement('button');button.className='quiet';button.dataset.name=template.name;button.textContent=template.name;button.onclick=()=>select(template);return button;})); }
|
|
36
|
+
$('open-templates').onclick=async()=>{try{catalog=await api('templates');list();select(catalog.templates.find(t=>t.name===selected?.name)??catalog.templates.find(t=>t.name==='plan')??catalog.templates[0]);$('template-dialog').showModal();}catch(error){notice(error.message,true);}};
|
|
37
|
+
$('close-templates').onclick=()=>{$('template-dialog').close();};
|
|
38
|
+
$('template-dialog').addEventListener('cancel',event=>{if(busy)event.preventDefault();});
|
|
39
|
+
$('template-source').oninput=()=>{const d=current();d.source=$('template-source').value;d.reset=false;keep();$('template-save').hidden=true;$('template-review').hidden=true;$('template-review-button').disabled=!dirty();feedback('Template draft kept in local editor storage.');};
|
|
40
|
+
$('template-edit-tab').onclick=()=>{preview=false;render();};$('template-preview-tab').onclick=()=>{preview=true;render();};
|
|
41
|
+
$('template-review-button').onclick=()=>{const d=current();$('template-diff').innerHTML=diffHtml(d.base,d.source);$('template-review').hidden=false;$('template-review').open=true;$('template-save').hidden=false;$('template-save').disabled=!dirty();feedback(d.reset?'Save will remove the Markdown override and use the original template.':'Review this template before saving it for future documents.');};
|
|
42
|
+
$('template-reset').onclick=()=>{const d=current();d.source=selected.defaultSource;d.reset=true;keep();render();feedback('Review and save to remove the Markdown override and use the original template.');};
|
|
43
|
+
$('template-discard').onclick=()=>{drafts.set(selected.name,{source:selected.source,base:selected.source,revision:catalog.revision,reset:false});localStorage.removeItem(key(selected.name));render();feedback('Template draft discarded.');};
|
|
44
|
+
$('template-reload').onclick=async()=>{try{catalog=await api('templates');const name=selected.name;selected=catalog.templates.find(t=>t.name===name);const d=current();d.base=selected.source;d.revision=catalog.revision;keep();list();render();$('template-reload').hidden=true;feedback('Saved template reloaded. Your draft is kept; compare and review it before saving.');}catch(error){feedback(error.message,true);}};
|
|
45
|
+
$('template-save').onclick=async()=>{if(busy||!dirty())return;const name=selected.name,d=current();busy=true;$('template-save').disabled=true;$('template-dialog').querySelector('.template-layout').inert=true;$('close-templates').disabled=true;try{catalog=await api('templates/save',{name,source:d.source,expectedRevision:d.revision,reset:d.reset});selected=catalog.templates.find(t=>t.name===name);drafts.delete(name);localStorage.removeItem(key(name));busy=false;list();select(selected);feedback('Template saved to runlist.templates.json. New documents use it.');}catch(error){feedback(error.message,true);keep();if(error.code==='template-conflict')$('template-reload').hidden=false;}finally{busy=false;$('template-dialog').querySelector('.template-layout').inert=false;$('template-save').disabled=false;$('close-templates').disabled=false;}};
|
|
46
|
+
document.addEventListener('keydown',event=>{if($('template-dialog').open&&(event.metaKey||event.ctrlKey)&&event.key==='s'){event.preventDefault();$('template-review-button').click();}});
|
|
47
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Run before the stylesheet so a saved theme also applies to the first paint.
|
|
2
|
+
(() => {
|
|
3
|
+
const key = 'runlist:appearance', system = matchMedia('(prefers-color-scheme: dark)');
|
|
4
|
+
const valid = value => ['system', 'light', 'dark'].includes(value) ? value : 'system';
|
|
5
|
+
let preference = 'system';
|
|
6
|
+
try { preference = valid(localStorage.getItem(key)); } catch { /* A theme still works without storage. */ }
|
|
7
|
+
function apply() {
|
|
8
|
+
document.documentElement.dataset.theme = preference === 'system' ? system.matches ? 'dark' : 'light' : preference;
|
|
9
|
+
const control = document.getElementById('theme-mode');
|
|
10
|
+
if (control) control.value = preference;
|
|
11
|
+
}
|
|
12
|
+
apply();
|
|
13
|
+
system.addEventListener('change', apply);
|
|
14
|
+
window.addEventListener('storage', event => {
|
|
15
|
+
if (event.key === key || event.key === null) { preference = valid(event.newValue); apply(); }
|
|
16
|
+
});
|
|
17
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
18
|
+
const control = document.getElementById('theme-mode');
|
|
19
|
+
apply();
|
|
20
|
+
control.addEventListener('change', () => {
|
|
21
|
+
preference = valid(control.value); apply();
|
|
22
|
+
try { localStorage.setItem(key, preference); } catch { /* Keep the selected appearance for this tab. */ }
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
})();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const desktop=typeof window!=='undefined'&&!!window.__TAURI__?.core;
|
|
2
|
+
export function isDesktopAssetLink(url, current) {
|
|
3
|
+
const asset=value=>!value.username&&!value.password&&!value.port&&((value.protocol==='tauri:'&&value.hostname==='localhost')||(value.protocol==='http:'&&value.hostname==='tauri.localhost'));
|
|
4
|
+
return asset(current)&&asset(url)&&url.protocol===current.protocol&&url.host===current.host;
|
|
5
|
+
}
|
|
6
|
+
const invoke=(command,args)=>window.__TAURI__.core.invoke(command,args).catch(value=>{const error=new Error(value.message??'The native operation failed.');Object.assign(error,value);throw error;});
|
|
7
|
+
let handle=null;
|
|
8
|
+
export async function request(route,body,csrf) {
|
|
9
|
+
if(desktop){
|
|
10
|
+
if(route==='session'){const state=await invoke('desktop_status');handle=state.handle;if(!handle)throw new Error('Choose a checkout folder to get started.');return {actor:state.actor,csrf:null};}
|
|
11
|
+
if(route==='logout'){const result=await invoke('close_checkout',{handle});handle=null;return result;}
|
|
12
|
+
if(!handle)throw new Error('Open a checkout folder.');
|
|
13
|
+
return invoke('checkout_request',{handle,route,...(body===undefined?{}:{body})});
|
|
14
|
+
}
|
|
15
|
+
const response=await fetch(`/api/${route}`,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Runlist-CSRF':csrf??''},body:JSON.stringify(body)});
|
|
16
|
+
const value=await response.json();if(!response.ok){const error=new Error(value.message);Object.assign(error,value);throw error;}return value;
|
|
17
|
+
}
|
|
18
|
+
export const copyText=text=>desktop?invoke('copy_text',{text}):navigator.clipboard.writeText(text);
|
|
19
|
+
export async function mountDesktop({beforeLeave,bootstrap,notice}) {
|
|
20
|
+
if(!desktop)return false;
|
|
21
|
+
const $=id=>document.getElementById(id);
|
|
22
|
+
$('connect-panel').hidden=true;$('desktop-welcome').hidden=false;$('desktop-open').hidden=false;
|
|
23
|
+
$('disconnect').textContent='Close folder';
|
|
24
|
+
$('bookmark-empty').textContent='Star a heading to keep it here. Bookmarks stay in Runlist on this computer.';
|
|
25
|
+
const footer=document.querySelector('.sidebar-footer');footer.replaceChildren(document.createTextNode('Local files · This computer only'),document.createElement('br'));const label=document.createElement('span');label.textContent='Save here. Review local commits in Changes.';footer.append(label);
|
|
26
|
+
async function recent(){const state=await invoke('desktop_status');const root=$('desktop-recents');root.replaceChildren();for(const item of state.recent){const button=document.createElement('button');button.className='quiet recent-checkout';button.textContent=item.path;button.onclick=()=>choose(item.id);root.append(button);}return state;}
|
|
27
|
+
let moving=false;
|
|
28
|
+
async function choose(id){if(moving)return;moving=true;try{
|
|
29
|
+
await beforeLeave();notice('Choose a trusted local checkout…');
|
|
30
|
+
const result=await invoke('choose_checkout',id===undefined?{}:{recent:id});
|
|
31
|
+
if(result.cancelled){notice('Folder opening cancelled.');return;}
|
|
32
|
+
// A fresh renderer clears controller and document authority from the old checkout.
|
|
33
|
+
location.replace(location.pathname);
|
|
34
|
+
}catch(error){notice(error.message,true);}finally{moving=false;}}
|
|
35
|
+
$('desktop-open').onclick=()=>choose();$('desktop-choose').onclick=()=>choose();
|
|
36
|
+
await window.__TAURI__.event.listen('runlist:open',()=>choose());
|
|
37
|
+
await window.__TAURI__.event.listen('runlist:edit',event=>{if(['undo','redo'].includes(event.payload))document.execCommand(event.payload);});
|
|
38
|
+
await window.__TAURI__.event.listen('runlist:quit',async()=>{if(moving)return;moving=true;try{await beforeLeave();notice('Preserving drafts and finishing local operations…');await invoke('finish_quit');}catch(error){notice(error.message,true);moving=false;}});
|
|
39
|
+
document.addEventListener('click',event=>{const anchor=event.target.closest('a');if(!anchor)return;const url=new URL(anchor.href,location.href);if(isDesktopAssetLink(url,new URL(location.href)))return;event.preventDefault();if(['http:','https:','mailto:'].includes(url.protocol))invoke('open_external',{url:url.href}).catch(error=>notice(error.message,true));},true);
|
|
40
|
+
const state=await recent();if(state.handle){$('desktop-welcome').hidden=true;$('desktop-location').hidden=false;$('desktop-location').textContent=state.checkoutPath;await bootstrap();}else notice('Local files · This computer only. Open a checkout folder.');
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
export async function desktopDisconnected(){if(!desktop)return;location.replace(location.pathname);}
|