runlist 0.95.6 → 0.96.1
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/README.md +162 -16
- package/assets/app/app.css +189 -3
- package/assets/app/app.mjs +49 -31
- package/assets/app/document-create.mjs +3 -2
- package/assets/app/document-lifecycle.mjs +17 -8
- package/assets/app/document-stages.mjs +3 -1
- package/assets/app/document-views.mjs +23 -1
- package/assets/app/editor-navigation.mjs +16 -23
- package/assets/app/filing-navigation.mjs +18 -16
- package/assets/app/git-changes.mjs +11 -2
- package/assets/app/index.html +34 -23
- package/assets/app/library-navigation.mjs +29 -16
- package/assets/app/record-navigation.mjs +68 -23
- package/assets/app/recovery-center.mjs +8 -7
- package/assets/app/settings.mjs +48 -10
- package/assets/app/template-editor.mjs +22 -12
- package/assets/app/transport.mjs +1 -2
- package/bin/runlist.mjs +47 -3
- package/package.json +1 -1
- package/plugins/runlist-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/runlist-codex/skills/runlist/SKILL.md +3 -0
- package/src/app.mjs +9 -4
- package/src/baton.mjs +80 -11
- package/src/commands.mjs +3 -2
- package/src/config.mjs +16 -26
- package/src/doctor.mjs +15 -0
- package/src/hud.mjs +3 -1
- package/src/index.mjs +2 -0
- package/src/install.mjs +6 -4
- package/src/installation.mjs +117 -0
- package/src/new.mjs +27 -7
- package/src/prompt-handoff.mjs +132 -0
- package/src/prompts.mjs +112 -16
- package/src/template-store.mjs +16 -5
- package/src/update.mjs +7 -28
package/assets/app/app.mjs
CHANGED
|
@@ -19,10 +19,10 @@ import { escapeHtml as esc, splitSource, editedSource, markdownHtml, lineDiff, d
|
|
|
19
19
|
const $ = id => document.getElementById(id);
|
|
20
20
|
const state = { doc:null, base:null, body:'', csrf:null, mode:'read', dirty:false, draft:null, recovery:null, conflict:null, pending:null, lastSave:null, busy:false, plans:[] };
|
|
21
21
|
const clientId = sessionStorage.runlistClientId ||= crypto.randomUUID();
|
|
22
|
-
let checkoutKey = '', draftTimer, persistence = Promise.resolve();
|
|
22
|
+
let checkoutKey = '', draftTimer, persistence = Promise.resolve(), noticeError = false;
|
|
23
23
|
let editorPath;
|
|
24
24
|
const blockEditor = new BlockEditor($('block-editor'),{onChange:body=>bodyChanged(body),resolveLink:linked});
|
|
25
|
-
const navigation = editorNavigation({state,editor:blockEditor
|
|
25
|
+
const navigation = editorNavigation({state,editor:blockEditor,$,view,notice});
|
|
26
26
|
templateEditor({api,$,checkout:()=>checkoutKey,notice});
|
|
27
27
|
const library = libraryNavigation({state,$,api,open,showLibrary,checkout:()=>checkoutKey,notice});
|
|
28
28
|
const settings=appSettings({$,api,checkout:()=>checkoutKey,library,notice});
|
|
@@ -30,7 +30,7 @@ const lifecycle=documentLifecycle({state,$,api,checkout:()=>checkoutKey,open,lib
|
|
|
30
30
|
const yardstick=documentYardstick({state,$,api,checkout:()=>checkoutKey,open,library,notice});
|
|
31
31
|
const filing=filingNavigation({state,$,api,open,showLibrary,checkout:()=>checkoutKey,notice});
|
|
32
32
|
const records = recordNavigation({state,$,api,open,showLibrary,checkout:()=>checkoutKey,preferences:()=>settings.read(),notice,create:template=>creation.show({template})});
|
|
33
|
-
const changes=gitChanges({state,$,api,open,showLibrary,checkout:()=>checkoutKey,notice,returnToDraft:()=>{view(state.doc.editable?'edit':'read');const url=new URL(location.href);url.searchParams.delete('view');url.searchParams.set('path',state.doc.path);history.replaceState(null,'',url);}});
|
|
33
|
+
const changes=gitChanges({state,$,api,open,showLibrary,checkout:()=>checkoutKey,notice,returnToDraft:()=>{view(state.doc.editable?'edit':'read',{focus:true});const url=new URL(location.href);url.searchParams.delete('view');url.searchParams.set('path',state.doc.path);history.replaceState(null,'',url);}});
|
|
34
34
|
const commits=gitCommit({$,api,checkout:()=>checkoutKey,selection:changes.selectedPaths,changed:()=>changes.invalidate(),notice});
|
|
35
35
|
$('changes-commit').onclick=()=>{if(!$('changes-commit').disabled)return commits.show().catch(error=>notice(error.message,true));};
|
|
36
36
|
const views = documentViews({$,view});
|
|
@@ -49,7 +49,8 @@ const quick=quickNavigation({$,api,state,library,open,jump:section=>navigation.j
|
|
|
49
49
|
{title:'Templates',run:()=>$('open-templates').click()},{title:'Settings',run:()=>$('open-settings').click()},
|
|
50
50
|
...(library.savedViews?.()??[]).map(v=>({title:`View: ${v.name}`,detail:'Saved library filters',run:()=>library.applyView(v.id)})),
|
|
51
51
|
]});
|
|
52
|
-
|
|
52
|
+
let noticeTimer;
|
|
53
|
+
function notice(message = '', error = false, {persistent=false} = {}) { clearTimeout(noticeTimer); $('notice').textContent = message; $('notice').hidden = !message; $('notice').classList.toggle('error', error); noticeError = error; if(message&&!error&&!persistent)noticeTimer=setTimeout(()=>notice(),3200); }
|
|
53
54
|
async function api(route,body) {const result=await request(route,body,state.csrf);if(body!==undefined&&['save','undo','create/commit','lifecycle/commit','yardstick/commit','native/action','flags/triage'].includes(route)){changes.invalidate();filing.invalidate();}return result;}
|
|
54
55
|
function guarded(fn) { return async () => { try { await fn(); } catch (error) { notice(error.message, true); } }; }
|
|
55
56
|
function key(path = state.doc?.path) { return `runlist:recovery:${checkoutKey}:${encodeURIComponent(path)}:${clientId}`; }
|
|
@@ -83,22 +84,29 @@ function updateDirty() {
|
|
|
83
84
|
state.dirty = candidate() !== state.base.source;
|
|
84
85
|
const rows = lineDiff(state.base.source, candidate()), adds = rows.filter(r => r.kind === 'add').length, removes = rows.filter(r => r.kind === 'remove').length;
|
|
85
86
|
$('change-count').textContent = state.dirty ? `${adds + removes}` : '';
|
|
86
|
-
$('review-summary').textContent = `${adds} added ${adds === 1 ? 'line' : 'lines'} · ${removes} removed ${removes === 1 ? 'line' : 'lines'}
|
|
87
|
+
$('review-summary').textContent = `${adds} added ${adds === 1 ? 'line' : 'lines'} · ${removes} removed ${removes === 1 ? 'line' : 'lines'}`;
|
|
87
88
|
const claimed = state.doc.claim?.state === 'owned' || state.doc.claim?.corrupt;
|
|
88
89
|
$('save').disabled = !state.dirty || !state.doc.editable || claimed || !!state.conflict || state.busy;
|
|
89
90
|
$('edit').disabled = !state.doc.editable || state.busy;
|
|
91
|
+
$('edit').textContent = state.dirty || state.pending ? 'Review changes' : 'Edit document';
|
|
92
|
+
$('edit').hidden = state.mode === 'edit' || state.mode === 'review';
|
|
93
|
+
$('document-edit-actions').hidden = !state.dirty && !state.pending;
|
|
90
94
|
$('tab-edit').disabled = !state.doc.editable || state.busy;
|
|
95
|
+
$('tab-review').disabled = (!state.dirty && !state.pending) || state.busy;
|
|
96
|
+
$('tab-review').hidden = !state.dirty && !state.pending;
|
|
91
97
|
$('undo').disabled = !state.lastSave || state.dirty || claimed || state.busy;
|
|
92
98
|
$('body-editor').readOnly = !!state.pending;
|
|
93
99
|
blockEditor.setReadOnly(!!state.pending || !state.doc.editable);
|
|
94
|
-
$('toggle-markdown').disabled = !!state.pending;
|
|
100
|
+
$('toggle-markdown').disabled = !!state.pending || !state.doc.editable || state.busy;
|
|
95
101
|
$('pending-note').hidden = !state.pending;
|
|
96
102
|
stages.update(candidate());
|
|
97
103
|
yardstick.update();
|
|
98
104
|
$('save').textContent = state.pending ? state.pending.kind === 'undo' ? 'Retry undo' : 'Retry save' : 'Save changes';
|
|
99
105
|
}
|
|
100
|
-
function view(mode) {
|
|
106
|
+
function view(mode, {focus=false} = {}) {
|
|
101
107
|
if (!state.doc) return;
|
|
108
|
+
if(mode==='review'&&!state.pending&&candidate()===state.base.source)mode=state.mode==='edit'?'edit':'read';
|
|
109
|
+
records.beforeLeave?.();
|
|
102
110
|
state.mode = mode;
|
|
103
111
|
$('records-home').hidden=true;records.deactivate();changes.deactivate();filing.deactivate();
|
|
104
112
|
$('workspace').classList.remove('library-start');$('empty').hidden=true;$('document').hidden=false;
|
|
@@ -109,10 +117,11 @@ function view(mode) {
|
|
|
109
117
|
$('full-source').textContent = state.doc.source;
|
|
110
118
|
$('diff').innerHTML = diffHtml(state.base.source, source);
|
|
111
119
|
updateDirty();
|
|
112
|
-
if (
|
|
120
|
+
if (!noticeError) notice();
|
|
113
121
|
if (mode === 'edit' && (editorPath !== state.doc.path || blockEditor.source() !== state.body)) { blockEditor.set(state.body,{readOnly:!!state.pending || !state.doc.editable}); editorPath=state.doc.path; }
|
|
114
122
|
if (mode !== 'edit') blockEditor.hideTools();
|
|
115
123
|
navigation.update();library.render();
|
|
124
|
+
if (focus) views.focus(mode);
|
|
116
125
|
}
|
|
117
126
|
function renderList() { library.render(); }
|
|
118
127
|
|
|
@@ -124,7 +133,7 @@ function renderDocumentSummary() {
|
|
|
124
133
|
function renderEvidence() {
|
|
125
134
|
const doc = state.doc;
|
|
126
135
|
renderConnections();
|
|
127
|
-
$('file-info').innerHTML = `<dl><dt>Source file</dt><dd>${esc(doc.path)}</dd><dt>Revision</dt><dd><code>${esc(doc.revision.slice(0,23))}…</code></dd><dt>Actor</dt><dd>${esc($('identity').textContent)}</dd></dl
|
|
136
|
+
$('file-info').innerHTML = `<dl><dt>Source file</dt><dd>${esc(doc.path)}</dd><dt>Revision</dt><dd><code>${esc(doc.revision.slice(0,23))}…</code></dd><dt>Actor</dt><dd>${esc($('identity').textContent)}</dd></dl>`;
|
|
128
137
|
$('flags').innerHTML = doc.flags.length ? doc.flags.map(flag => `<section class="flag"><span class="flag-tag">${esc(flag.id)} · ${esc(flag.severity)} · ${esc(flag.state)}</span><h3>${esc(flag.text)}</h3><h4>ORIGINAL OBSERVATION</h4><small>${esc(flag.original.file)}${flag.original.line ? `:${flag.original.line}` : ''}<br>${esc(flag.original.at ?? '')}</small><pre>${esc(flag.original.quote || 'File-level observation; no original quote was recorded.')}</pre><small>${esc(flag.original.revisionReason)}</small><h4>CURRENT LOCATION · ${esc(flag.current.status.toUpperCase())}</h4><pre class="current-quote">${esc(flag.current.quote ?? 'Current evidence is unavailable or ambiguous.')}</pre><small>${flag.current.line ? `Line ${flag.current.line} · ` : ''}${esc(flag.current.revision?.slice(0,23) ?? 'No current revision')}<br>Original evidence is retained.</small></section>`).join('') : '<p class="muted">No related flags recorded for this document.</p>';
|
|
129
138
|
}
|
|
130
139
|
function renderConnections() {
|
|
@@ -136,10 +145,11 @@ function renderConnections() {
|
|
|
136
145
|
}
|
|
137
146
|
async function open(path,selection={}) {
|
|
138
147
|
if (state.busy) throw new Error('Wait for the current operation before switching plans.');
|
|
148
|
+
records.beforeLeave?.();
|
|
139
149
|
await yardstick.beforeLeave();
|
|
140
150
|
clearTimeout(draftTimer);
|
|
141
151
|
if (state.doc && (state.dirty || state.pending)) { storeLocal(); await persistDraft(); if(!state.recoveryStored)throw new Error('Preserve your current draft before switching documents.'); }
|
|
142
|
-
notice(
|
|
152
|
+
notice();
|
|
143
153
|
path=await lifecycle.resolveInitial(path);
|
|
144
154
|
path=await yardstick.resolveInitial(path);
|
|
145
155
|
const doc = await api(`document?path=${encodeURIComponent(path)}`);
|
|
@@ -158,13 +168,13 @@ async function open(path,selection={}) {
|
|
|
158
168
|
if (recovery?.lastSave) state.lastSave = recovery.lastSave;
|
|
159
169
|
const recoverable = recovery && (recovery.source !== recovery.baseSource || recovery.pending);
|
|
160
170
|
$('recovery').hidden = !recoverable;
|
|
161
|
-
$('recovery-label').textContent = recoverable ? '
|
|
162
|
-
$('draft-status').textContent =
|
|
171
|
+
$('recovery-label').textContent = recoverable ? 'Unsaved draft available.' : '';
|
|
172
|
+
$('draft-status').textContent = ''; $('draft-status').title = '';
|
|
163
173
|
$('claim').hidden = !(doc.claim?.corrupt || doc.claim?.state === 'owned' || !doc.editable);
|
|
164
174
|
$('claim').textContent = doc.claim?.corrupt ? 'Ownership is corrupt. Repair it through the CLI before saving.' : doc.claim?.state === 'owned' ? `Owned by session ${doc.claim.sessionId}. You can keep a draft; coordinate or release the claim through the CLI before saving.` : !doc.editable ? 'This source is read only. Its format or metadata needs repair before saving.' : '';
|
|
165
175
|
$('conflict').hidden = true; $('operation-repair').hidden = true; $('empty').hidden = true; $('document').hidden = false;$('workspace').classList.remove('library-start');
|
|
166
176
|
$('doc-title').textContent = doc.title; $('doc-path').textContent = doc.path; $('doc-status').textContent = doc.status ?? '';
|
|
167
|
-
$('body-editor').value = state.body; $('markdown-fallback').hidden=true; $('block-editor').hidden=false; $('toggle-markdown').textContent='Edit Markdown'; $('toggle-markdown').setAttribute('aria-expanded','false'); renderList(); renderEvidence(); view(
|
|
177
|
+
$('body-editor').value = state.body; $('markdown-fallback').hidden=true; $('block-editor').hidden=false; $('toggle-markdown').textContent='Edit Markdown'; $('toggle-markdown').setAttribute('aria-expanded','false'); renderList(); renderEvidence(); view('read');
|
|
168
178
|
const url = new URL(location.href); url.searchParams.delete('ref');url.searchParams.delete('from');url.searchParams.delete('view');url.searchParams.delete('record');url.searchParams.set('path',doc.path); history.replaceState(null,'',url);
|
|
169
179
|
library.opened();
|
|
170
180
|
await yardstick.opened();
|
|
@@ -178,7 +188,7 @@ async function open(path,selection={}) {
|
|
|
178
188
|
else sourceNotice='The recorded line is unavailable. Current hub source is open.';
|
|
179
189
|
}
|
|
180
190
|
}
|
|
181
|
-
$('sidebar').classList.remove('open');$('toggle-plans').setAttribute('aria-expanded','false');if(!selection.line)window.scrollTo(0,0); notice(sourceNotice??
|
|
191
|
+
$('sidebar').classList.remove('open');$('toggle-plans').setAttribute('aria-expanded','false');if(!selection.line)window.scrollTo(0,0); notice(sourceNotice??'',!!sourceNotice);
|
|
182
192
|
}
|
|
183
193
|
async function persistDraft() {
|
|
184
194
|
if (!state.doc || !state.base || (!state.dirty && !state.pending)) return;
|
|
@@ -192,8 +202,8 @@ async function persistDraft() {
|
|
|
192
202
|
if (state.doc?.path !== path || state.draft?.id !== id) return;
|
|
193
203
|
request.expectedDraftRevision = state.draft.revision;
|
|
194
204
|
const stored = await api('draft/write', request);
|
|
195
|
-
if (state.doc?.path === path && state.draft?.id === id) { state.draft.revision = stored.revision; storeLocal(); state.recoveryStored=true; $('draft-status').textContent = '
|
|
196
|
-
} catch (error) { $('draft-status').textContent =
|
|
205
|
+
if (state.doc?.path === path && state.draft?.id === id) { state.draft.revision = stored.revision; storeLocal(); state.recoveryStored=true; $('draft-status').textContent = state.dirty ? 'Unsaved' : ''; $('draft-status').title = state.dirty ? 'Draft stored on this computer' : ''; }
|
|
206
|
+
} catch (error) { $('draft-status').textContent = 'Unsaved'; $('draft-status').title = 'Local draft storage failed'; notice(error.code === 'draft-conflict' ? 'Another editor updated the disk draft. Your text is kept locally; recover or copy it before proceeding.' : `Draft kept ${desktop?'in Runlist':'in browser'}. ${error.message}`, true); }
|
|
197
207
|
};
|
|
198
208
|
persistence = persistence.then(run,run); await persistence;
|
|
199
209
|
}
|
|
@@ -210,8 +220,9 @@ async function recover() {
|
|
|
210
220
|
state.base = { source:r.baseSource, revision:r.baseRevision }; state.draftSource = r.source; state.body = splitSource(r.source).body.replaceAll('\r\n','\n');
|
|
211
221
|
state.draft = r.location === key() ? r.draft : null; state.pending = r.pending; state.lastSave = r.lastSave;
|
|
212
222
|
$('body-editor').value = state.body; $('recovery').hidden = true; updateDirty(); storeLocal(); view('edit');
|
|
213
|
-
if (state.pending) notice('An interrupted save is retained. Review changes and retry to learn its original outcome.');
|
|
214
|
-
else notice('Draft recovered.
|
|
223
|
+
if (state.pending) notice('An interrupted save is retained. Review changes and retry to learn its original outcome.',false,{persistent:true});
|
|
224
|
+
else notice('Draft recovered.');
|
|
225
|
+
$('draft-status').textContent = 'Unsaved'; $('draft-status').title = 'Recovered draft';
|
|
215
226
|
if (state.base.revision !== state.doc.revision && !state.pending) conflict({ details:{ currentRevision:state.doc.revision, currentSource:state.doc.source } });
|
|
216
227
|
}
|
|
217
228
|
async function clearDraft() {
|
|
@@ -226,7 +237,7 @@ async function save() {
|
|
|
226
237
|
// Retain the original request identity on disk before publication, even if renderer storage is later lost.
|
|
227
238
|
await persistDraft();
|
|
228
239
|
if(!state.recoveryStored)throw new Error('Preserve the draft and operation identity before saving.');
|
|
229
|
-
storeLocal(); state.busy = true; $('workspace').inert = true; updateDirty(); notice('Saving
|
|
240
|
+
storeLocal(); state.busy = true; $('workspace').inert = true; updateDirty(); notice('Saving…',false,{persistent:true});
|
|
230
241
|
try {
|
|
231
242
|
const result = await api(state.pending.kind === 'undo' ? 'undo' : 'save',state.pending), pending = state.pending;
|
|
232
243
|
const current = await api(`document?path=${encodeURIComponent(state.doc.path)}`);
|
|
@@ -244,8 +255,8 @@ async function save() {
|
|
|
244
255
|
state.doc = current; state.draftSource = current.source; state.base = { source:current.source, revision:current.revision }; state.body = splitSource(current.source).body.replaceAll('\r\n','\n');
|
|
245
256
|
state.dirty = false; storeLocal(); let cleanupError = null;
|
|
246
257
|
try { await clearDraft(); } catch (error) { cleanupError = error; }
|
|
247
|
-
$('body-editor').value = state.body; $('draft-status').textContent = '
|
|
248
|
-
notice(cleanupError ? `Saved to Markdown. Recovery draft retained: ${cleanupError.message}` : pending.kind === 'undo' ? 'Undid the save.
|
|
258
|
+
$('body-editor').value = state.body; $('draft-status').textContent = ''; $('draft-status').title = ''; storeLocal(); renderDocumentSummary(); renderEvidence(); view('edit');
|
|
259
|
+
notice(cleanupError ? `Saved to Markdown. Recovery draft retained: ${cleanupError.message}` : pending.kind === 'undo' ? 'Undid the save.' : 'Saved to Markdown.', !!cleanupError);
|
|
249
260
|
} catch (error) {
|
|
250
261
|
if (error.code === 'revision-conflict') { state.pending = null; storeLocal(); conflict(error); }
|
|
251
262
|
else if (['repair-required','operation-pending'].includes(error.code)) {
|
|
@@ -283,51 +294,57 @@ async function bootstrap(token) {
|
|
|
283
294
|
}
|
|
284
295
|
async function showLibrary() {
|
|
285
296
|
if(state.busy)throw new Error('Wait for the current operation before returning to the library.');
|
|
297
|
+
records.beforeLeave?.();
|
|
286
298
|
await yardstick.beforeLeave();
|
|
287
299
|
clearTimeout(draftTimer);
|
|
288
300
|
if(state.doc&&(state.dirty||state.pending)){storeLocal();await persistDraft();if(!state.recoveryStored)throw new Error('Preserve your current draft before leaving the document.');}
|
|
289
301
|
state.mode='home';blockEditor.hideTools();$('records-home').hidden=true;records.deactivate();changes.deactivate();filing.deactivate();
|
|
290
|
-
$('workspace').classList.add('library-start');$('evidence').classList.remove('outline-open');
|
|
302
|
+
$('workspace').classList.add('library-start');$('evidence').classList.remove('outline-open');$('toggle-outline').setAttribute('aria-expanded','false');
|
|
291
303
|
$('document').hidden=true;$('empty').hidden=false;$('doc-path').textContent='';
|
|
292
304
|
$('sidebar').classList.remove('open');$('toggle-plans').setAttribute('aria-expanded','false');
|
|
293
305
|
const url=new URL(location.href);for(const key of ['path','ref','from','view','record'])url.searchParams.delete(key);history.replaceState(null,'',url);
|
|
294
306
|
library.render();window.scrollTo(0,0);
|
|
295
|
-
notice(
|
|
307
|
+
notice();
|
|
296
308
|
}
|
|
297
309
|
$('document-changes').onclick=guarded(()=>changes.show());
|
|
310
|
+
// Native details keeps checkout utilities available by mouse and keyboard.
|
|
311
|
+
document.addEventListener('click',event=>{const menu=$('workspace-menu');if(menu.open&&(!menu.contains(event.target)||event.target.closest('button')))menu.open=false;});
|
|
312
|
+
document.addEventListener('keydown',event=>{const menu=$('workspace-menu');if(event.key==='Escape'&&menu.open){menu.open=false;menu.querySelector('summary').focus();}});
|
|
298
313
|
$('library-home').onclick=guarded(showLibrary);
|
|
299
314
|
$('back-library').onclick=guarded(showLibrary);
|
|
300
315
|
$('toggle-plans').onclick = () => $('toggle-plans').setAttribute('aria-expanded',String($('sidebar').classList.toggle('open')));
|
|
301
316
|
for (const name of ['read','edit','review','source']) $('tab-' + name).onclick = guarded(() => view(name));
|
|
302
|
-
$('edit').onclick = guarded(() => view(state.dirty ? 'review' : 'edit')); $('review').onclick = guarded(() => view('review')); $('back-edit').onclick = () => view('edit');
|
|
317
|
+
$('edit').onclick = guarded(() => view(state.dirty || state.pending ? 'review' : 'edit',{focus:true})); $('review').onclick = guarded(() => view('review',{focus:true})); $('back-edit').onclick = guarded(() => view('edit',{focus:true}));
|
|
303
318
|
function bodyChanged(body) {
|
|
304
319
|
state.body = body;
|
|
305
320
|
navigation.update();
|
|
306
321
|
$('body-editor').value = body;
|
|
307
322
|
storeLocal();
|
|
308
|
-
try { updateDirty(); storeLocal(); $('draft-status').textContent = state.dirty ? '
|
|
323
|
+
try { updateDirty(); storeLocal(); if(!noticeError)notice(); $('draft-status').textContent = state.dirty ? 'Unsaved' : ''; $('draft-status').title = state.dirty ? 'Not saved to the file' : ''; clearTimeout(draftTimer); if(state.dirty) draftTimer = setTimeout(() => persistDraft(),500); }
|
|
309
324
|
catch (error) { $('save').disabled = true; notice(error.message,true); }
|
|
310
325
|
}
|
|
311
326
|
$('body-editor').oninput = () => bodyChanged($('body-editor').value);
|
|
312
327
|
$('toggle-markdown').onclick = () => {
|
|
328
|
+
if(state.mode!=='edit')view('edit');
|
|
313
329
|
const showing = $('markdown-fallback').hidden;
|
|
314
330
|
$('markdown-fallback').hidden = !showing; $('block-editor').hidden = showing;
|
|
315
331
|
$('toggle-markdown').textContent = showing ? 'Back to document' : 'Edit Markdown';
|
|
316
332
|
$('toggle-markdown').setAttribute('aria-expanded',String(showing));
|
|
333
|
+
$('evidence').classList.remove('outline-open');$('toggle-outline').setAttribute('aria-expanded','false');
|
|
317
334
|
if (showing) { $('body-editor').value=state.body; $('body-editor').focus(); }
|
|
318
335
|
else { blockEditor.set(state.body,{readOnly:!!state.pending}); editorPath=state.doc.path; }
|
|
319
336
|
};
|
|
320
337
|
$('save').onclick = guarded(save);
|
|
321
|
-
$('cancel').onclick = guarded(async () => { await persistDraft(); view('read');
|
|
322
|
-
$('discard').onclick = guarded(async () => { if (state.pending) throw new Error('Inspect or retry the interrupted save before discarding its recovery data.'); await clearDraft(); state.body = splitSource(state.doc.source).body.replaceAll('\r\n','\n'); state.base = { source:state.doc.source, revision:state.doc.revision }; state.draftSource = state.doc.source; state.pending = null; state.conflict = null; $('conflict').hidden = true; $('body-editor').value = state.body; storeLocal(); $('draft-status').textContent = '
|
|
338
|
+
$('cancel').onclick = guarded(async () => { await persistDraft(); view('read'); });
|
|
339
|
+
$('discard').onclick = guarded(async () => { if (state.pending) throw new Error('Inspect or retry the interrupted save before discarding its recovery data.'); await clearDraft(); state.body = splitSource(state.doc.source).body.replaceAll('\r\n','\n'); state.base = { source:state.doc.source, revision:state.doc.revision }; state.draftSource = state.doc.source; state.pending = null; state.conflict = null; $('conflict').hidden = true; $('body-editor').value = state.body; storeLocal(); $('draft-status').textContent = ''; $('draft-status').title = ''; view('edit'); notice('Changes discarded.'); });
|
|
323
340
|
$('recover').onclick = guarded(recover);
|
|
324
341
|
$('discard-recovery').onclick = guarded(async () => {
|
|
325
342
|
const r = state.recovery;
|
|
326
343
|
if (r?.pending) throw new Error('This draft contains an interrupted save. Recover it and inspect the outcome before discarding.');
|
|
327
344
|
if (r?.draft?.revision) await api('draft/discard',{ path:state.doc.path,draftId:r.draft.id,expectedDraftRevision:r.draft.revision });
|
|
328
|
-
if (r) localStorage.removeItem(r.location); state.recovery = null; $('recovery').hidden = true; notice('
|
|
345
|
+
if (r) localStorage.removeItem(r.location); state.recovery = null; $('recovery').hidden = true; notice('Draft discarded.');
|
|
329
346
|
});
|
|
330
|
-
$('review-merge').onclick = guarded(async () => { const c = state.conflict; if (!c) return; state.draftSource = rebaseSourceStage(state.base.source,state.draftSource ?? state.base.source,c.currentSource); state.base = { source:c.currentSource, revision:c.currentRevision }; state.pending = null; state.conflict = null; $('conflict').hidden = true; storeLocal(); await persistDraft(); view('review'); notice('Review the merged draft against the current file before saving.'); });
|
|
347
|
+
$('review-merge').onclick = guarded(async () => { const c = state.conflict; if (!c) return; state.draftSource = rebaseSourceStage(state.base.source,state.draftSource ?? state.base.source,c.currentSource); state.base = { source:c.currentSource, revision:c.currentRevision }; state.pending = null; state.conflict = null; $('conflict').hidden = true; storeLocal(); await persistDraft(); view('review',{focus:true}); notice('Review the merged draft against the current file before saving.'); });
|
|
331
348
|
$('use-current').onclick = guarded(async () => { await persistDraft(); state.doc = await api(`document?path=${encodeURIComponent(state.doc.path)}`); $('reading').innerHTML = markdownHtml(state.doc.source,linked); view('source'); notice('Current source is shown. Your draft and conflict review are retained.'); });
|
|
332
349
|
$('inspect-pending').onclick = guarded(() => inspectOperation(state.pending.operationId));
|
|
333
350
|
$('settle-operation').onclick = guarded(async () => {
|
|
@@ -354,13 +371,14 @@ $('undo').onclick = guarded(async () => {
|
|
|
354
371
|
} finally { state.busy = false; $('workspace').inert = false; updateDirty(); }
|
|
355
372
|
});
|
|
356
373
|
$('reading').addEventListener('click',event=>{const anchor=event.target.closest('a');if(!anchor||event.metaKey||event.ctrlKey||event.shiftKey)return;const url=new URL(anchor.href,location.href);if(url.origin!==location.origin||!url.searchParams.has('ref'))return;event.preventDefault();guarded(async()=>{const target=await api(`link?${new URLSearchParams({ref:url.searchParams.get('ref'),from:url.searchParams.get('from')??''})}`);if(!target)throw new Error('This linked document is unavailable or outside this library.');await open(target.path);})();});
|
|
357
|
-
$('copy-source').onclick = guarded(async () => { await copyText(state.doc.source); notice('
|
|
374
|
+
$('copy-source').onclick = guarded(async () => { await copyText(state.doc.source); notice('Source copied.'); });
|
|
358
375
|
$('disconnect').onclick = guarded(async () => { await beforeDesktopLeave(); await api('logout',{}); $('workspace').hidden = true; $('connect-panel').hidden = false; for(const id of ['disconnect','new-document','quick-open','open-recovery'])$(id).hidden=true; notice('Disconnected. Private drafts are retained locally.'); await desktopDisconnected(); });
|
|
359
376
|
$('connect-form').onsubmit = event => { event.preventDefault(); guarded(async () => { const value = $('access').value.trim(); let token = value; try { token = new URLSearchParams(new URL(value).hash.slice(1)).get('connect') ?? value; } catch { /* A raw token is also accepted. */ } await bootstrap(token); $('access').value = ''; })(); };
|
|
360
377
|
window.addEventListener('beforeunload', event => { if (state.dirty || state.pending) { storeLocal(); if (!state.recoveryStored) { event.preventDefault(); event.returnValue = ''; } } });
|
|
361
|
-
document.addEventListener('keydown', event => { if (document.querySelector('dialog[open]')) return; if ((event.metaKey || event.ctrlKey) && event.key === 's') { event.preventDefault(); if (state.doc && state.dirty) guarded(() => view('review'))(); } });
|
|
378
|
+
document.addEventListener('keydown', event => { if (document.querySelector('dialog[open]')) return; if ((event.metaKey || event.ctrlKey) && event.key === 's') { event.preventDefault(); if (state.doc && state.dirty) guarded(() => view('review',{focus:true}))(); } });
|
|
362
379
|
async function beforeDesktopLeave() {
|
|
363
380
|
if(state.busy)throw new Error('Wait for the current operation before switching folders or quitting.');
|
|
381
|
+
records.beforeLeave?.();
|
|
364
382
|
await yardstick.beforeLeave();
|
|
365
383
|
if($('template-dialog').open||$('lifecycle-dialog')?.open||$('create-dialog').open||$('git-commit-dialog').open||$('filter-dialog').open||$('record-review')&&!$('record-review').hidden)throw new Error('Close the current review or template editor before switching folders or quitting.');
|
|
366
384
|
clearTimeout(draftTimer);
|
|
@@ -54,7 +54,7 @@ export function documentCreation({$,api,checkout,state,beforeNavigate,open,libra
|
|
|
54
54
|
if(template&&!catalog.templates.some(t=>t.name===template))throw new Error(catalog.recordError??'This record template is unavailable.');
|
|
55
55
|
$('create-template').replaceChildren(...catalog.templates.map(t=>new Option(({hub:'Hub',plan:'Plan',flag:'Flag',decision:'Decision'})[t.name]??'Document',t.name)));
|
|
56
56
|
$('create-folders').replaceChildren(...catalog.roots.map(root=>{const option=document.createElement('option');option.value=root;return option;}));
|
|
57
|
-
$('create-form').reset();setOptions();chooseTemplate();filenameEdited=false;review=null;request=null;feedback('');$('create-diagnostics').replaceChildren();
|
|
57
|
+
$('create-form').reset();$('create-file-options').removeAttribute('open');setOptions();chooseTemplate();filenameEdited=false;review=null;request=null;feedback('');$('create-diagnostics').replaceChildren();
|
|
58
58
|
try {
|
|
59
59
|
const stored=JSON.parse(localStorage.getItem(key())??'null');
|
|
60
60
|
if(stored){if(catalog.templates.some(t=>t.name===stored.form?.template)){$('create-template').value=stored.form.template;chooseTemplate();}for(const [field,id] of Object.entries({title:'create-name',folder:'create-folder',filename:'create-filename',status:'create-status',body:'create-body',severity:'create-severity'}))if(typeof stored.form?.[field]==='string')$(id).value=stored.form[field];if(Array.isArray(stored.form?.options)){setOptions(stored.form.options);chooseTemplate();}review=stored.review;request=stored.request;filenameEdited=!!stored.form?.filename;}
|
|
@@ -65,6 +65,7 @@ export function documentCreation({$,api,checkout,state,beforeNavigate,open,libra
|
|
|
65
65
|
$('create-preview-button').disabled=!catalog.templates.length;renderReview();dialog.showModal();(review?$('create-confirm'):$('create-name')).focus();
|
|
66
66
|
if(!catalog.templates.length){$('create-preview-button').disabled=true;feedback('This checkout has no supported document templates. Configure a plan or doc type through the CLI.',true);}
|
|
67
67
|
}
|
|
68
|
+
$('create-form').addEventListener('invalid',event=>{if(event.target.closest('#create-file-options'))$('create-file-options').setAttribute('open','');},true);
|
|
68
69
|
$('create-template').onchange=()=>{chooseTemplate();keep();};
|
|
69
70
|
$('create-filename').oninput=()=>{filenameEdited=true;keep();};
|
|
70
71
|
$('create-name').oninput=()=>{if(!filenameEdited&&!selected()?.native){const slug=$('create-name').value.toLowerCase().replace(/[\s_]+/g,'-').replace(/[^a-z0-9-]/g,'').replace(/-+/g,'-').replace(/^-|-$/g,'').slice(0,120);$('create-filename').value=slug?slug+'.md':'';}keep();};
|
|
@@ -75,7 +76,7 @@ export function documentCreation({$,api,checkout,state,beforeNavigate,open,libra
|
|
|
75
76
|
$('create-inspect').onclick=()=>busy(async()=>{const inspected=await api('create/inspect',{operationId:review.operationId});current();review=inspected;keep();feedback(review?`Creation is ${review.state}.${review.currentRevision&&review.currentRevision!==review.revision?' The destination changed and will not be overwritten.':''}`:'No creation review found.');});
|
|
76
77
|
$('create-confirm').onclick=()=>busy(async()=>{
|
|
77
78
|
if(review.state!=='committed') {const committed=await api('create/commit',{operationId:review.operationId});current();review=committed;keep();}
|
|
78
|
-
current();const path=review.path,record=review.record;localStorage.removeItem(key());dialog.close();state.busy=false;await library.load({refresh:true});current();if(record&&createdRecord)await createdRecord(record);else await open(path);notice(record
|
|
79
|
+
current();const path=review.path,record=review.record;localStorage.removeItem(key());dialog.close();state.busy=false;await library.load({refresh:true});current();if(record&&createdRecord)await createdRecord(record);else await open(path);notice(record?`${record.type==='flag'?'Flag':'Decision'} created.`:'Document created.');
|
|
79
80
|
});
|
|
80
81
|
$('create-discard').onclick=()=>busy(async()=>{if(review)await api('create/discard',{operationId:review.operationId});current();localStorage.removeItem(key());review=null;request=null;dialog.close();notice('New document draft discarded.');});
|
|
81
82
|
$('create-close').onclick=()=>{if(!state.busy&&keep()){sequence++;dialog.close();}};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export function documentLifecycle({state,$,api,checkout,open,library,notice}){
|
|
2
2
|
const key=path=>`runlist:lifecycle:${checkout()}:${encodeURIComponent(path)}`,dialog=$('lifecycle-dialog');let pending=null,blocked=false,formEnabled=true;
|
|
3
|
+
const canApply=()=>!blocked&&pending?.state==='reviewed';
|
|
4
|
+
const remember=()=>localStorage.setItem(key(pending.path),JSON.stringify(pending));
|
|
3
5
|
const retained=path=>{try{const direct=JSON.parse(localStorage.getItem(key(path))??'null');if(direct)return direct;for(let i=0;i<localStorage.length;i++){const location=localStorage.key(i);if(location.startsWith(`runlist:lifecycle:${checkout()}:`)){const value=JSON.parse(localStorage.getItem(location));if(value?.newPath===path)return value;}}}catch{}return null;};
|
|
4
6
|
async function resolveInitial(initial){const old=retained(initial);if(old&&old.path===initial){const result=await api('lifecycle/inspect',{operationId:old.operationId});if(result?.state==='committed'){library.relocated(old.path,result.newPath);localStorage.removeItem(key(old.path));return result.newPath;}if(result?.current&&result.newPath!==initial)return result.newPath;}return initial;}
|
|
5
7
|
async function show(){if(!state.doc||state.busy)return;const doc=state.doc,info=await api(`lifecycle?path=${encodeURIComponent(doc.path)}`);$('lifecycle-path').textContent=`${doc.title} · ${doc.path}`;const root=$('lifecycle-content');root.replaceChildren();
|
|
@@ -8,16 +10,23 @@ export function documentLifecycle({state,$,api,checkout,open,library,notice}){
|
|
|
8
10
|
for(const status of info.statuses.filter(s=>s!==info.current))form.querySelector('select').add(new Option(status,status));root.append(form);
|
|
9
11
|
blocked=!info.enabled||state.dirty||state.pending;formEnabled=info.enabled;form.hidden=!formEnabled;const message=document.createElement('p');message.id='lifecycle-unavailable';message.className='inline-note';message.setAttribute('role','status');if(blocked){message.textContent=!info.enabled?info.reason:'Save or discard the document draft before changing its lifecycle status.';root.append(message);for(const input of form.querySelectorAll('input,select,textarea,button'))input.disabled=true;}
|
|
10
12
|
const review=document.createElement('section');review.id='lifecycle-review';review.hidden=true;root.append(review);pending=retained(doc.path);if(pending&&![pending.path,pending.newPath].includes(doc.path))pending=null;if(pending)renderReview();
|
|
11
|
-
form.onsubmit=async event=>{event.preventDefault();if(state.busy||blocked)return;state.busy=true;$('lifecycle-close').disabled=true;$('lifecycle-review-button').disabled=true;try{const request={path:doc.path,status:$('lifecycle-status').value,note:$('lifecycle-note').value,expectedRevision:doc.revision,operationId:crypto.randomUUID()};pending={...request,report:'Review not yet received.'};localStorage.setItem(key(pending.path),JSON.stringify(pending));const result=await api('lifecycle/preview',request);pending={...pending,...result};localStorage.setItem(key(pending.path),JSON.stringify(pending));renderReview();}catch(error){if(pending){renderReview();$('lifecycle-feedback').textContent=`${error.message} Inspect this operation, or return to the form to review again.`;}notice(error.message,true);}finally{state.busy=false;$('lifecycle-close').disabled=false;$('lifecycle-review-button').disabled=false;}};
|
|
13
|
+
form.onsubmit=async event=>{event.preventDefault();if(state.busy||blocked)return;state.busy=true;$('lifecycle-close').disabled=true;$('lifecycle-review-button').disabled=true;try{const request={path:doc.path,status:$('lifecycle-status').value,note:$('lifecycle-note').value,expectedRevision:doc.revision,operationId:crypto.randomUUID()};pending={...request,state:'preparing',report:'Review not yet received.'};localStorage.setItem(key(pending.path),JSON.stringify(pending));const result=await api('lifecycle/preview',request);pending={...pending,...result};localStorage.setItem(key(pending.path),JSON.stringify(pending));renderReview();}catch(error){if(pending){renderReview();$('lifecycle-feedback').textContent=`${error.message} Inspect this operation, or return to the form to review again.`;}notice(error.message,true);}finally{state.busy=false;$('lifecycle-close').disabled=false;$('lifecycle-review-button').disabled=false;}};
|
|
12
14
|
dialog.showModal();
|
|
13
15
|
}
|
|
14
|
-
function renderReview(){$('lifecycle-form').hidden=true;const root=$('lifecycle-review');root.hidden=false;root.replaceChildren();const heading=document.createElement('h3');heading.textContent=`Change to ${pending.status}`;const report=document.createElement('pre');report.className='lifecycle-report';report.textContent=pending.report;const confirm=document.createElement('button');confirm.id='lifecycle-confirm';confirm.className='primary';confirm.textContent='Apply status change';confirm.disabled
|
|
15
|
-
back.onclick=()=>{const previous=pending;localStorage.removeItem(key(pending.path));pending=null;root.hidden=true;$('lifecycle-form').hidden=!formEnabled;$('lifecycle-status').value=previous.status;$('lifecycle-note').value=previous.note;};
|
|
16
|
-
inspect.onclick=async()=>{
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
function renderReview(){$('lifecycle-form').hidden=true;const root=$('lifecycle-review');root.hidden=false;root.replaceChildren();const heading=document.createElement('h3');heading.textContent=`Change to ${pending.status}`;const report=document.createElement('pre');report.className='lifecycle-report';report.textContent=pending.report;const confirm=document.createElement('button');confirm.id='lifecycle-confirm';confirm.className='primary';confirm.textContent='Apply status change';confirm.disabled=!canApply();const back=document.createElement('button');back.className='quiet';back.textContent='Back to form';back.id='lifecycle-back';const inspect=document.createElement('button');inspect.className='quiet';inspect.textContent='Inspect operation';inspect.id='lifecycle-inspect';const feedback=document.createElement('p');feedback.id='lifecycle-feedback';feedback.setAttribute('role','status');root.append(heading,report,confirm,back,inspect,feedback);
|
|
17
|
+
back.onclick=()=>{if(state.busy)return;const previous=pending;localStorage.removeItem(key(pending.path));pending=null;root.hidden=true;$('lifecycle-form').hidden=!formEnabled;$('lifecycle-status').value=previous.status;$('lifecycle-note').value=previous.note;};
|
|
18
|
+
inspect.onclick=async()=>{
|
|
19
|
+
if(state.busy)return;state.busy=true;root.querySelector('#lifecycle-acknowledge')?.remove();for(const button of root.querySelectorAll('button'))button.disabled=true;$('lifecycle-close').disabled=true;
|
|
20
|
+
try{const result=await api('lifecycle/inspect',{operationId:pending.operationId});pending={...pending,...result,state:result?.state??'missing'};remember();report.textContent=result?.report??'No lifecycle review was retained. Return to the form and review again.';feedback.textContent=result?`Operation is ${result.state}.`:'No operation found.';
|
|
21
|
+
if(result?.state==='committed'){const old=pending.path;library.relocated(old,result.newPath);localStorage.removeItem(key(old));pending=null;state.busy=false;dialog.close();await open(result.newPath);await library.load({refresh:true});notice('The status change was already saved.');return;}
|
|
22
|
+
if(result?.failure)feedback.textContent+=` ${result.failure.message}`;if(result?.state==='running'){feedback.textContent+=' Inspect CLI transactions before starting another change.';if(result.canAcknowledge)acknowledge(result,root,feedback);else feedback.textContent+=' The runner is still active or cannot be verified as stopped.';}
|
|
23
|
+
}catch(error){if(pending){pending.state='uncertain';remember();}feedback.textContent=error.message;}
|
|
24
|
+
finally{state.busy=false;for(const button of root.querySelectorAll('button'))button.disabled=false;confirm.disabled=!canApply();$('lifecycle-close').disabled=false;}
|
|
25
|
+
};
|
|
26
|
+
confirm.onclick=async()=>{if(state.busy||!canApply())return;state.busy=true;for(const button of root.querySelectorAll('button'))button.disabled=true;$('lifecycle-close').disabled=true;
|
|
27
|
+
try{pending.state='submitting';remember();const result=await api('lifecycle/commit',{operationId:pending.operationId}),old=pending.path;library.relocated(old,result.newPath);localStorage.removeItem(key(pending.path));pending=null;state.busy=false;dialog.close();await open(result.newPath);await library.load({refresh:true});notice('Lifecycle status saved. Source, references and history follow the CLI rules.');}
|
|
28
|
+
catch(error){if(pending){pending.state='uncertain';remember();}feedback.textContent=pending?`${error.message} Your review is retained. Inspect the operation or reload the document before reviewing again.`:`Status saved, but the view could not be refreshed. ${error.message}`;notice(feedback.textContent,true);}
|
|
29
|
+
finally{state.busy=false;for(const button of root.querySelectorAll('button'))button.disabled=false;confirm.disabled=!canApply();$('lifecycle-close').disabled=false;}
|
|
21
30
|
};
|
|
22
31
|
}
|
|
23
32
|
function acknowledge(result,root,feedback){
|
|
@@ -11,12 +11,14 @@ export function documentStages({state,$,changed}) {
|
|
|
11
11
|
if(!plan)return;
|
|
12
12
|
current=readSourceStage(source);
|
|
13
13
|
const definitions=state.doc.stageDefinitions??[],known=definitions.some(stage=>stage.word===current.word);
|
|
14
|
+
$('document-stage-fields').hidden=!definitions.length&&!current.word&&!current.invalid;
|
|
14
15
|
select.replaceChildren(new Option('Unset · no stage',''),...definitions.map(stage=>new Option(stage.word,`word:${stage.word}`)));
|
|
15
16
|
if(current.word&&!known){const option=new Option(`${current.word} · unknown`,`word:${current.word}`);option.disabled=true;select.add(option);}
|
|
16
17
|
if(current.invalid){const option=new Option('Invalid metadata','@invalid');option.disabled=true;select.add(option);}
|
|
17
18
|
select.value=current.invalid?'@invalid':current.word?`word:${current.word}`:'';
|
|
18
19
|
select.disabled=!state.doc.editable||!!state.pending||state.busy;
|
|
19
20
|
const definition=definitions.find(stage=>stage.word===current.word);
|
|
20
|
-
meaning.textContent=current.invalid?'This stage has unsupported metadata. Choose a configured stage or Unset, then review the change.':current.word&&!known?'This stage is not configured in this repository. Its current value is preserved until you choose a replacement.':definition?.meaning||(!definitions.length?'No stages
|
|
21
|
+
meaning.textContent=current.invalid?'This stage has unsupported metadata. Choose a configured stage or Unset, then review the change.':current.word&&!known?'This stage is not configured in this repository. Its current value is preserved until you choose a replacement.':definition?.meaning||(!definitions.length?'No stages configured.':'');
|
|
22
|
+
meaning.hidden=!meaning.textContent;
|
|
21
23
|
}};
|
|
22
24
|
}
|
|
@@ -4,6 +4,23 @@ const modes = Object.keys(panels);
|
|
|
4
4
|
// Keep the active tab focusable, associate each tab with its panel, and provide
|
|
5
5
|
// the conventional arrow/Home/End navigation without capturing editor keys.
|
|
6
6
|
export function documentViews({$, view}) {
|
|
7
|
+
// Measure the actual chrome: titles and larger text can wrap.
|
|
8
|
+
const header = document.querySelector('.app-header'), toolbar = document.querySelector('.document-toolbar');
|
|
9
|
+
const tabs = document.querySelector('.view-tabs');
|
|
10
|
+
const measure = () => {
|
|
11
|
+
const height = node => node?.getBoundingClientRect?.().height ?? 0;
|
|
12
|
+
const style = document.documentElement?.style;
|
|
13
|
+
if (!style) return;
|
|
14
|
+
style.setProperty('--app-header-height', `${height(header)}px`);
|
|
15
|
+
style.setProperty('--workspace-top', `${height(header)}px`);
|
|
16
|
+
style.setProperty('--document-toolbar-height', `${height(toolbar)}px`);
|
|
17
|
+
style.setProperty('--document-tabs-height', `${height(tabs)}px`);
|
|
18
|
+
};
|
|
19
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
20
|
+
const observer = new ResizeObserver(measure);
|
|
21
|
+
for (const node of [header, toolbar, tabs]) if (node) observer.observe(node);
|
|
22
|
+
}
|
|
23
|
+
measure();
|
|
7
24
|
$('document-tabs').addEventListener('keydown', event => {
|
|
8
25
|
const current = modes.findIndex(mode => event.target === $('tab-' + mode));
|
|
9
26
|
if (current < 0) return;
|
|
@@ -22,7 +39,12 @@ export function documentViews({$, view}) {
|
|
|
22
39
|
next = (next + step + modes.length) % modes.length;
|
|
23
40
|
}
|
|
24
41
|
});
|
|
25
|
-
return {
|
|
42
|
+
return {focus(mode) {
|
|
43
|
+
const panel = $(panels[mode]);
|
|
44
|
+
// Only explicit primary actions move focus; rerenders preserve the caret.
|
|
45
|
+
const editor = mode === 'edit' ? (!$('markdown-fallback').hidden ? $('body-editor') : panel.querySelector('[data-editable][contenteditable="true"]')) : null;
|
|
46
|
+
(editor ?? panel).focus({preventScroll:true});
|
|
47
|
+
},update(mode) {
|
|
26
48
|
for (const name of modes) {
|
|
27
49
|
const selected = name === mode, tab = $('tab-' + name);
|
|
28
50
|
tab.setAttribute('aria-selected', String(selected));
|
|
@@ -1,50 +1,43 @@
|
|
|
1
1
|
import { documentOutline } from './outline.mjs';
|
|
2
|
-
export function editorNavigation({state,editor
|
|
3
|
-
let entries=[],
|
|
4
|
-
const storageKey=()=>`runlist:bookmarks:${checkout()}:${encodeURIComponent(state.doc.path)}`;
|
|
5
|
-
function persist() { try { localStorage.setItem(storageKey(),JSON.stringify(bookmarks)); } catch { notice('Bookmarks could not be saved in local editor storage.',true); } }
|
|
2
|
+
export function editorNavigation({state,editor,$,view,notice}) {
|
|
3
|
+
let entries=[], path='', active=null;
|
|
6
4
|
function jump(entry) {
|
|
7
5
|
if (!entry || !state.doc) return;
|
|
8
6
|
if (!['read','edit'].includes(state.mode)) view('edit');
|
|
9
7
|
let target;
|
|
10
|
-
if (state.mode==='read') target=[...$('reading').querySelectorAll('h1,h2,h3,h4,h5,h6')][entries.indexOf(entry)];
|
|
8
|
+
if (state.mode==='read') { target=[...$('reading').querySelectorAll('h1,h2,h3,h4,h5,h6')][entries.indexOf(entry)]; if(target){target.tabIndex=-1;target.focus({preventScroll:true});} }
|
|
11
9
|
else if (!$('markdown-fallback').hidden) { target=$('body-editor'); target.focus(); target.setSelectionRange(entry.offset,entry.offset); const lineHeight=parseFloat(getComputedStyle(target).lineHeight)||22; target.scrollTop=Math.max(0,(entry.line-3)*lineHeight); }
|
|
12
10
|
else { target=[...editor.root.querySelectorAll('[data-block]')].find(e=>e.dataset.block===entry.id); if (!entry.locked) editor.focus(entry.id); }
|
|
13
|
-
target?.scrollIntoView({block:'center',behavior:'smooth'}); active=entry.key; highlight();
|
|
11
|
+
target?.scrollIntoView({block:'center',behavior:'smooth'}); active=entry.key; highlight(); outline(false);
|
|
14
12
|
}
|
|
15
13
|
function highlight() { for (const button of $('outline').querySelectorAll('[data-section]')) { const selected=button.dataset.section===active; button.setAttribute('aria-current',String(selected)); } }
|
|
16
|
-
function
|
|
17
|
-
function row(entry, saved=false) {
|
|
14
|
+
function row(entry) {
|
|
18
15
|
const div=document.createElement('div'); div.className='outline-row'; div.style.setProperty('--heading-level',entry.level ?? 1);
|
|
19
16
|
const button=document.createElement('button'); button.className='section-link quiet'; button.textContent=entry.title; button.dataset.section=entry.key;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const star=document.createElement('button'); star.className='section-bookmark quiet'; const marked=bookmarks.some(b=>b.key===entry.key); star.textContent=marked?'★':'☆'; star.setAttribute('aria-label',`${marked?'Remove bookmark for':'Bookmark'} ${entry.title}`);star.setAttribute('aria-pressed',String(marked));star.onclick=()=>{if(found)toggle(found);else{bookmarks=bookmarks.filter(b=>b.key!==entry.key);persist();render();}};
|
|
23
|
-
div.append(button,star); if(saved&&!found) {const small=document.createElement('small');small.textContent='Heading unavailable';div.append(small);}return div;
|
|
17
|
+
button.title=`Jump to ${entry.title} · line ${entry.line}`;button.onclick=()=>jump(entry);
|
|
18
|
+
div.append(button);return div;
|
|
24
19
|
}
|
|
25
20
|
function render() {
|
|
26
21
|
const query=$('section-filter').value.toLowerCase();
|
|
27
22
|
$('outline').replaceChildren(...entries.filter(e=>e.title.toLowerCase().includes(query)).map(e=>row(e)));
|
|
28
23
|
if(!$('outline').children.length)$('outline').textContent=query?'No matching headings.':'Add a heading to create a section.';
|
|
29
|
-
$('bookmarks').replaceChildren(...bookmarks.map(b=>row(b,true)));$('bookmark-empty').hidden=bookmarks.length>0;
|
|
30
24
|
$('section-count').textContent=entries.length;highlight();
|
|
31
25
|
}
|
|
32
26
|
function update() {
|
|
33
27
|
if(!state.doc)return;
|
|
34
|
-
|
|
35
|
-
if(!samePath){path=state.doc.path;active=null;previous=[];try{const stored=JSON.parse(localStorage.getItem(storageKey())??'[]');bookmarks=Array.isArray(stored)?stored.filter(b=>typeof b.key==='string'&&typeof b.title==='string'):[];}catch{bookmarks=[];}$('section-filter').value='';}
|
|
28
|
+
if(path!==state.doc.path){path=state.doc.path;active=null;$('section-filter').value='';}
|
|
36
29
|
const model=editor.source()===state.body?editor.blocks:undefined;
|
|
37
|
-
entries=documentOutline(state.body,model);
|
|
38
|
-
// Inline block identity survives typing and insertions, so heading renames
|
|
39
|
-
// also update their bookmarks. Source replacements leave unmatched bookmarks explicit.
|
|
40
|
-
if(samePath&&model&&model===previousModel){let changed=false;for(const b of bookmarks){const old=previous.find(e=>e.key===b.key),fresh=old&&entries.find(e=>e.id===old.id);if(fresh&&fresh.key!==b.key){b.key=fresh.key;b.title=fresh.title;changed=true;}}if(changed)persist();}
|
|
41
|
-
previous=entries;previousModel=model;render();
|
|
30
|
+
entries=documentOutline(state.body,model);render();
|
|
42
31
|
}
|
|
43
32
|
$('section-filter').oninput=render;
|
|
44
|
-
|
|
45
|
-
|
|
33
|
+
function outline(show,restoreFocus=false){
|
|
34
|
+
$('evidence').classList.toggle('outline-open',show);$('toggle-outline').setAttribute('aria-expanded',String(show));
|
|
35
|
+
if(show)$('section-filter').focus();else if(restoreFocus)$('toggle-outline').focus();
|
|
36
|
+
}
|
|
37
|
+
$('toggle-outline').onclick=()=>outline(!$('evidence').classList.contains('outline-open'));
|
|
38
|
+
$('close-outline').onclick=()=>outline(false,true);
|
|
46
39
|
$('add-section').onclick=()=>{if(!state.doc||state.busy||state.pending||!state.doc.editable)return;view('edit');if(!$('markdown-fallback').hidden)$('toggle-markdown').click();editor.mutate(()=>{const last=editor.blocks.filter(b=>!b.locked&&!['gap','comment'].includes(b.kind)).at(-1);const fresh=editor.insertAfter(last,'h2','New section');editor.render();editor.focus(fresh.id);});update();jump(entries.find(e=>e.id===editor.activeId));};
|
|
47
|
-
document.addEventListener('keydown',event=>{if(!state.doc||['home','records','filing'].includes(state.mode)||document.querySelector('dialog[open]'))return;if((event.metaKey||event.ctrlKey)&&event.shiftKey&&event.key.toLowerCase()==='j'){event.preventDefault()
|
|
40
|
+
document.addEventListener('keydown',event=>{if(!state.doc||['home','records','filing'].includes(state.mode)||document.querySelector('dialog[open]'))return;if((event.metaKey||event.ctrlKey)&&event.shiftKey&&event.key.toLowerCase()==='j'){event.preventDefault();outline(true);}if(event.altKey&&['PageDown','PageUp'].includes(event.key)){event.preventDefault();const index=entries.findIndex(e=>e.key===active);jump(entries[Math.max(0,Math.min(entries.length-1,index+(event.key==='PageDown'?1:-1)))]);}if(event.key==='Escape'&&$('evidence').classList.contains('outline-open'))outline(false,true);});
|
|
48
41
|
$('section-filter').onkeydown=event=>{if(event.key==='Enter'){event.preventDefault();jump(entries.find(e=>e.title.toLowerCase().includes($('section-filter').value.toLowerCase())));}};
|
|
49
42
|
let scheduled=false;window.addEventListener('scroll',()=>{if(scheduled||!state.doc||!['read','edit'].includes(state.mode)||!$('markdown-fallback').hidden)return;scheduled=true;requestAnimationFrame(()=>{scheduled=false;const nodes=state.mode==='read'?[...$('reading').querySelectorAll('h1,h2,h3,h4,h5,h6')]:entries.map(e=>[...editor.root.querySelectorAll('[data-block]')].find(n=>n.dataset.block===e.id));let current=entries[0];for(let i=0;i<nodes.length;i++)if(nodes[i]?.getBoundingClientRect().top<260)current=entries[i];if(current){active=current.key;highlight();}});},{passive:true});
|
|
50
43
|
return {update,jumpTo:section=>{update();const entry=entries.find(e=>e.line===section.line&&e.title.slice(0,200)===section.title)||entries.find(e=>e.title.slice(0,200)===section.title);if(entry)jump(entry);else notice('This section changed since the search. Use the document outline to find it.',true);}};
|