spectoflow 0.21.1 → 0.22.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/bin/spectoflow.js +30 -3
- package/package.json +1 -1
- package/templates/dashboard/files.js +108 -0
- package/templates/dashboard/public/app.js +307 -14
- package/templates/dashboard/public/designs/console.css +35 -9
- package/templates/dashboard/public/designs/console.js +37 -0
- package/templates/dashboard/public/designs/orbit.css +5 -3
- package/templates/dashboard/public/designs/orbit.js +21 -1
- package/templates/dashboard/public/i18n.js +30 -30
- package/templates/dashboard/public/icons.js +8 -6
- package/templates/dashboard/public/index.html +86 -32
- package/templates/dashboard/public/styles.css +79 -25
- package/templates/dashboard/server.js +29 -18
- package/templates/dashboard/summarize.js +25 -6
- package/templates/lib/store.js +43 -0
package/bin/spectoflow.js
CHANGED
|
@@ -220,7 +220,7 @@ function init() {
|
|
|
220
220
|
console.log('');
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
function update() {
|
|
223
|
+
async function update() {
|
|
224
224
|
const root = process.cwd();
|
|
225
225
|
if (!fs.existsSync(path.join(root, '.spectoflow'))) {
|
|
226
226
|
return console.log('No spectoflow project here. Run: spectoflow init');
|
|
@@ -252,6 +252,24 @@ function update() {
|
|
|
252
252
|
else console.log(` ${changed ? c.g('✓ Done') : c.dim('Already up to date')}${changed ? c.dim(` · ${changed} file(s) changed`) : ''}`);
|
|
253
253
|
if (r.newSidecar.length && !dryRun) console.log(` ${c.y('→')} ${c.dim(`${r.newSidecar.length} *.new file(s) to review and merge — or re-run: spectoflow update --force`)}`);
|
|
254
254
|
console.log('');
|
|
255
|
+
|
|
256
|
+
// A running dashboard has the OLD framework code loaded into memory (Node caches `require()`d
|
|
257
|
+
// modules at process start) — new bytes on disk change nothing until it restarts. Do that
|
|
258
|
+
// automatically so an update always actually takes effect, instead of leaving a confusing
|
|
259
|
+
// half-updated dashboard (new static files, stale server logic) until someone thinks to restart.
|
|
260
|
+
if (!dryRun && changed) {
|
|
261
|
+
const lock = path.join(root, '.spectoflow', '.dashboard.lock');
|
|
262
|
+
let info = null;
|
|
263
|
+
try { info = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch {}
|
|
264
|
+
if (info && info.port && (await probeDashboard(info.port, 2000))) {
|
|
265
|
+
console.log(` ${c.dim('Dashboard is running — restarting it on port ' + info.port + ' to apply the update…')}`);
|
|
266
|
+
// Restart on the SAME port it was already on, not resolvePort(argv)'s default — `update`
|
|
267
|
+
// itself was never given a --port, so a naive restartDashboard() would silently move a
|
|
268
|
+
// non-default-port dashboard back to 4319.
|
|
269
|
+
argv.push(`--port=${info.port}`);
|
|
270
|
+
await restartDashboard();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
255
273
|
}
|
|
256
274
|
|
|
257
275
|
// THE launch command — routes the subcommands, then starts. Starting spawns the server DETACHED and
|
|
@@ -331,7 +349,12 @@ async function startDashboard() {
|
|
|
331
349
|
const env = Object.assign({}, process.env, { SPECTOFLOW_PORT: String(port) });
|
|
332
350
|
const child = spawn('node', [fs.existsSync(local) ? local : bundled], { detached: true, stdio: 'ignore', env });
|
|
333
351
|
child.unref(); // let this CLI exit while the server keeps running
|
|
334
|
-
|
|
352
|
+
// Confirm it actually came up (a still-releasing port from a just-stopped instance, or any other
|
|
353
|
+
// startup error, would otherwise print a false "started" while the detached process silently died).
|
|
354
|
+
let up = false;
|
|
355
|
+
for (let i = 0; i < 20 && !up; i++) { await new Promise((r) => setTimeout(r, 250)); up = await probeDashboard(port, 300); }
|
|
356
|
+
if (up) console.log(`${c.g('✓')} dashboard started → ${c.bold(url)} ${c.dim('(pid ' + child.pid + ')')}`);
|
|
357
|
+
else console.log(`${c.y('!')} spawned (pid ${child.pid}) but it isn't responding on ${url} yet — check ${c.g('spectoflow dashboard status')} in a moment, or its own output if something's wrong.`);
|
|
335
358
|
printDashboardCommands();
|
|
336
359
|
}
|
|
337
360
|
|
|
@@ -354,7 +377,11 @@ async function dashboardStatus() {
|
|
|
354
377
|
|
|
355
378
|
async function restartDashboard() {
|
|
356
379
|
await stopDashboard();
|
|
357
|
-
|
|
380
|
+
// Windows doesn't deliver real signals — process.kill() returns once the request is issued, not
|
|
381
|
+
// once the process (and the port it held) is actually gone. A short gap here, plus startDashboard()
|
|
382
|
+
// now confirming the new one actually came up, keeps a restart honest under load instead of racing
|
|
383
|
+
// a rebind against a socket the OS hasn't finished releasing yet.
|
|
384
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
358
385
|
return startDashboard();
|
|
359
386
|
}
|
|
360
387
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spectoflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.2",
|
|
4
4
|
"description": "Agent-agnostic spec-driven development framework + real-time local control plane. Markdown artifacts, intent router, workflow-by-scope.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"spec-driven-development",
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* File Explorer backend — browse, read, write and create files/folders anywhere under the project
|
|
4
|
+
* root. Kept separate from server.js (own module, like runner.js/summarize.js), since it owns a
|
|
5
|
+
* distinct concern: safe filesystem access scoped to the whole project, not just plans/specs/agents.
|
|
6
|
+
*
|
|
7
|
+
* Trust model matches the rest of this local dashboard (POST /api/run already spawns an arbitrary
|
|
8
|
+
* configured agent command): this is a single-user localhost dev tool, not a hosted multi-tenant
|
|
9
|
+
* service. The guard here exists to stop a path like "../../etc/passwd" from a buggy or malicious
|
|
10
|
+
* client, not to sandbox an untrusted operator.
|
|
11
|
+
*/
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const DENY_DIRS = new Set(['.git', 'node_modules']);
|
|
16
|
+
const MAX_READ_BYTES = 2 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
// Resolves `rel` against `root`, rejecting anything that normalizes outside it (path traversal).
|
|
19
|
+
// `root` itself is normalized first — ROOT can arrive with mixed separators (e.g. from an env var
|
|
20
|
+
// built by joining a Windows base path with a forward-slash suffix), and comparing an un-normalized
|
|
21
|
+
// root against path.resolve()'s always-normalized output would reject even legitimate children.
|
|
22
|
+
function safePath(root, rel) {
|
|
23
|
+
const rootAbs = path.resolve(root);
|
|
24
|
+
const cleaned = String(rel || '').replace(/^[/\\]+/, '');
|
|
25
|
+
const abs = path.resolve(rootAbs, cleaned);
|
|
26
|
+
if (abs !== rootAbs && !abs.startsWith(rootAbs + path.sep)) return null;
|
|
27
|
+
return abs;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Symlink guard: an existing path must REALLY resolve under root, not just syntactically.
|
|
31
|
+
// A path that doesn't exist yet (e.g. a file about to be created) is trusted as-is — nothing to
|
|
32
|
+
// resolve through.
|
|
33
|
+
function realUnderRoot(root, abs) {
|
|
34
|
+
let real;
|
|
35
|
+
try { real = fs.realpathSync(abs); } catch { return abs; }
|
|
36
|
+
const realRoot = (() => { try { return fs.realpathSync(root); } catch { return root; } })();
|
|
37
|
+
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return null;
|
|
38
|
+
return real;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isUnderGit(rel) {
|
|
42
|
+
const n = String(rel || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
43
|
+
return n === '.git' || n.startsWith('.git/');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildTree(dir, relBase) {
|
|
47
|
+
let entries;
|
|
48
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
49
|
+
entries.sort((a, b) => (a.isDirectory() === b.isDirectory()) ? a.name.localeCompare(b.name) : (a.isDirectory() ? -1 : 1));
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
if (DENY_DIRS.has(e.name)) continue;
|
|
53
|
+
const rel = relBase ? relBase + '/' + e.name : e.name;
|
|
54
|
+
if (e.isDirectory()) out.push({ name: e.name, path: rel, type: 'dir', children: buildTree(path.join(dir, e.name), rel) });
|
|
55
|
+
else out.push({ name: e.name, path: rel, type: 'file' });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A NUL byte anywhere in the first chunk means "binary" — cheap and reliable enough for a local
|
|
61
|
+
// preview tool (the same heuristic git and most editors use).
|
|
62
|
+
function isProbablyText(buf) {
|
|
63
|
+
const n = Math.min(buf.length, 8000);
|
|
64
|
+
for (let i = 0; i < n; i++) if (buf[i] === 0) return false;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function tree(root) {
|
|
69
|
+
return buildTree(root, '');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readFile(root, rel) {
|
|
73
|
+
const abs = safePath(root, rel);
|
|
74
|
+
if (!abs) return { error: 'Invalid path.' };
|
|
75
|
+
const real = realUnderRoot(root, abs);
|
|
76
|
+
if (!real) return { error: 'Invalid path.' };
|
|
77
|
+
let stat;
|
|
78
|
+
try { stat = fs.statSync(real); } catch { return { error: 'Not found.' }; }
|
|
79
|
+
if (stat.isDirectory()) return { error: 'That is a folder.' };
|
|
80
|
+
if (stat.size > MAX_READ_BYTES) return { error: 'File too large to open here (>2MB).' };
|
|
81
|
+
const buf = fs.readFileSync(real);
|
|
82
|
+
if (!isProbablyText(buf)) return { binary: true, size: stat.size };
|
|
83
|
+
return { content: buf.toString('utf8') };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeFile(root, rel, content) {
|
|
87
|
+
if (typeof content !== 'string') return { error: 'Missing content.' };
|
|
88
|
+
if (isUnderGit(rel)) return { error: 'Writes under .git are blocked.' };
|
|
89
|
+
const abs = safePath(root, rel);
|
|
90
|
+
if (!abs) return { error: 'Invalid path.' };
|
|
91
|
+
const parentDir = path.dirname(abs);
|
|
92
|
+
if (fs.existsSync(parentDir) && !realUnderRoot(root, parentDir)) return { error: 'Invalid path.' };
|
|
93
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
94
|
+
fs.writeFileSync(abs, content, 'utf8');
|
|
95
|
+
return { ok: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function mkdir(root, rel) {
|
|
99
|
+
if (isUnderGit(rel)) return { error: 'Cannot create folders under .git.' };
|
|
100
|
+
const abs = safePath(root, rel);
|
|
101
|
+
if (!abs) return { error: 'Invalid path.' };
|
|
102
|
+
const parentDir = path.dirname(abs);
|
|
103
|
+
if (fs.existsSync(parentDir) && !realUnderRoot(root, parentDir)) return { error: 'Invalid path.' };
|
|
104
|
+
fs.mkdirSync(abs, { recursive: true });
|
|
105
|
+
return { ok: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { tree, readFile, writeFile, mkdir };
|
|
@@ -7,6 +7,7 @@ function updateStatusLabels(){ for(const k of Object.keys(STATUS)) STATUS[k]=t('
|
|
|
7
7
|
let P = null, openTaskId = null;
|
|
8
8
|
let filter = { status: 'all', q: '' }; // board filter state — client-side only, read-only
|
|
9
9
|
let boardView = (()=>{ try{ return localStorage.getItem('spf-board-view')||'list'; }catch{ return 'list'; } })(); // 'list' | 'kanban'
|
|
10
|
+
let sideHidden = (()=>{ try{ return localStorage.getItem('spf-side-hidden')==='1'; }catch{ return false; } })(); // right sidebar (Journal/Specs/Running) — mainly to give Kanban's own-width columns more room
|
|
10
11
|
let backlogFilter = { status: 'open', q: '' }; // backlog defaults to open (not-done) tasks
|
|
11
12
|
let backlogSort = { col: 'id', dir: 'asc' }; // backlog sort state — client-side only
|
|
12
13
|
let backlogPage = 1; const BACKLOG_PAGE = 25; // backlog pagination — client-side only
|
|
@@ -55,19 +56,32 @@ function chatContainers(){ return [$('#chatLog'),$('#chatTabLog')].filter(Boolea
|
|
|
55
56
|
function scrollChat(container){ container.scrollTop=container.scrollHeight; }
|
|
56
57
|
function clearIdle(container){ const i=container.querySelector('.chat-idle'); if(i) i.remove(); }
|
|
57
58
|
function bubble(m){
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
let node;
|
|
60
|
+
if(m.role==='user'){ node=el('div','msg you'); node.append(el('div','bubble',m.text)); }
|
|
61
|
+
else{
|
|
62
|
+
node=el('div','msg agentmsg k-'+(m.kind||'message'));
|
|
63
|
+
node.append(el('div','msg-role', m.role + (m.agent&&m.agent!==m.role?(' · '+m.agent):'')));
|
|
64
|
+
node.append(el('div','bubble',m.text));
|
|
65
|
+
}
|
|
66
|
+
node.dataset.id=m.id; // lets renderChatLog tell a stale bubble from a genuinely new one
|
|
67
|
+
return node;
|
|
63
68
|
}
|
|
69
|
+
function idleBlock(){ const d=el('div','chat-idle'); d.innerHTML=t('chat.idle'); return d; }
|
|
64
70
|
function renderChatLog(container){
|
|
65
71
|
if(!container) return;
|
|
66
72
|
const st=stateFor(container); const msgs=(P.runtime&&P.runtime.messages)||[];
|
|
67
|
-
|
|
73
|
+
const ids=new Set(msgs.map(m=>m.id));
|
|
74
|
+
// Summarize/Clear REPLACE the server-side log (a digest that leaves the old messages sitting right
|
|
75
|
+
// below it wouldn't condense anything) — if anything we already rendered no longer exists, the log
|
|
76
|
+
// was reset under us: rebuild from scratch instead of just appending, or the stale bubbles never go
|
|
77
|
+
// away short of a full page reload.
|
|
78
|
+
const stale=[...st.rendered].some(id=>!ids.has(id));
|
|
79
|
+
if(stale){ container.innerHTML=''; st.rendered=new Set(); st.rawBlock=null; }
|
|
80
|
+
if(!msgs.length){ if(!container.querySelector('.chat-idle')) container.append(idleBlock()); renderApproval(container); return; }
|
|
81
|
+
clearIdle(container);
|
|
68
82
|
let added=false;
|
|
69
83
|
for(const m of msgs){ if(st.rendered.has(m.id)) continue; st.rendered.add(m.id); container.append(bubble(m)); added=true; }
|
|
70
|
-
if(added) scrollChat(container);
|
|
84
|
+
if(added||stale) scrollChat(container);
|
|
71
85
|
renderApproval(container);
|
|
72
86
|
}
|
|
73
87
|
function renderApproval(container){
|
|
@@ -151,7 +165,7 @@ function render(){
|
|
|
151
165
|
if(meter) meter.title=`${t('kpi.globalProgress')}: ${s.pct}% (${s.done}/${s.total} ${t('kpi.tasksLabel')})`;
|
|
152
166
|
renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
|
|
153
167
|
renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
|
|
154
|
-
renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings();
|
|
168
|
+
renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles(); applySideHidden();
|
|
155
169
|
renderCustomDashboards(); // adds/removes nav tabs + panels before applyActiveTab() below reads them
|
|
156
170
|
applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
|
|
157
171
|
applyI18nStatic(); // re-translate the static markup (nav, headers, placeholders…) for this tick's language
|
|
@@ -695,6 +709,39 @@ function editAttn(it,txtNode){
|
|
|
695
709
|
ta.addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ e.preventDefault(); save(); } if(e.key==='Escape'){ done=true; renderAttention(); } });
|
|
696
710
|
}
|
|
697
711
|
async function addAttn(text){ flash(); await fetch('/api/attention',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})}); }
|
|
712
|
+
|
|
713
|
+
// ---- Backlog "+ Add task" — a manual checkbox task, no agent involved ----
|
|
714
|
+
function openBacklogAddForm(){
|
|
715
|
+
const form=$('#backlogAddForm'); if(!form) return;
|
|
716
|
+
const list=$('#blPhaseList');
|
|
717
|
+
if(list){ list.innerHTML=''; allPhaseTitles().forEach(ti=> list.append(new Option(ti))); }
|
|
718
|
+
const err=$('#blAddError'); if(err) err.hidden=true;
|
|
719
|
+
form.hidden=false; form.classList.add('is-open');
|
|
720
|
+
$('#blAddTitle').focus();
|
|
721
|
+
}
|
|
722
|
+
function closeBacklogAddForm(){
|
|
723
|
+
const form=$('#backlogAddForm'); if(!form) return;
|
|
724
|
+
form.hidden=true; form.classList.remove('is-open');
|
|
725
|
+
['blAddTitle','blAddPhase','blAddOwner'].forEach(id=>{ const f=$('#'+id); if(f) f.value=''; });
|
|
726
|
+
const lvl=$('#blAddLevel'); if(lvl) lvl.value='standard';
|
|
727
|
+
}
|
|
728
|
+
async function submitBacklogAdd(){
|
|
729
|
+
const err=$('#blAddError');
|
|
730
|
+
const title=($('#blAddTitle').value||'').trim();
|
|
731
|
+
if(!title){ if(err){ err.textContent=t('backlog.addTitleRequired'); err.hidden=false; } $('#blAddTitle').focus(); return; }
|
|
732
|
+
const phase=($('#blAddPhase').value||'').trim();
|
|
733
|
+
const owner=($('#blAddOwner').value||'').trim();
|
|
734
|
+
const level=$('#blAddLevel').value;
|
|
735
|
+
flash();
|
|
736
|
+
const r=await fetch('/api/task',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
737
|
+
body:JSON.stringify({title, phase:phase||undefined, owner:owner||undefined, level})});
|
|
738
|
+
if(!r.ok){
|
|
739
|
+
const j=await r.json().catch(()=>({}));
|
|
740
|
+
if(err){ err.textContent=j.error||t('backlog.addFailed'); err.hidden=false; }
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
closeBacklogAddForm();
|
|
744
|
+
}
|
|
698
745
|
async function patchAttn(id,patch){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
|
|
699
746
|
async function deleteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'DELETE'}); }
|
|
700
747
|
async function promoteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id)+'/promote',{method:'POST'}); }
|
|
@@ -917,10 +964,14 @@ function czItemsFor(kind){
|
|
|
917
964
|
function renderCustomize(){
|
|
918
965
|
const root=$('#czRoot'); if(!root) return;
|
|
919
966
|
const openKind=root.dataset.open||'';
|
|
967
|
+
// a block with its form open spans the full row (see CSS) — an auto-fit grid would otherwise
|
|
968
|
+
// still reserve empty trailing cells beside it for the two collapsed blocks that no longer fill
|
|
969
|
+
// out a row, so drop to a single column for the whole grid while any block is open.
|
|
970
|
+
root.classList.toggle('has-open', !!openKind);
|
|
920
971
|
root.innerHTML='';
|
|
921
972
|
CZ_KINDS.forEach(({kind})=>{
|
|
922
973
|
const items=czItemsFor(kind);
|
|
923
|
-
const block=el('div','cz-block');
|
|
974
|
+
const block=el('div','cz-block'+(openKind===kind?' is-open':''));
|
|
924
975
|
const head=el('div','cz-head');
|
|
925
976
|
head.append(el('h3',null,t('customize.'+kind+'s')+' ('+items.length+')'));
|
|
926
977
|
const addBtn=el('button','btn cz-add',t('customize.add.'+kind));
|
|
@@ -967,11 +1018,15 @@ async function czSubmit(kind,description,agent){
|
|
|
967
1018
|
// A custom dashboard (Customize page) gets its own tab id "custom:<id>" and its own URL shape
|
|
968
1019
|
// /custom/<id> — kept out of ROUTES (a fixed list) since the set of custom ids is dynamic; recognized
|
|
969
1020
|
// by a dedicated branch in tabFromPath()/navigateTab() instead.
|
|
970
|
-
const ROUTES=['board','requests','attention','backlog','workflow','team','chat','info','docs','
|
|
1021
|
+
const ROUTES=['board','requests','attention','backlog','workflow','team','files','chat','info','docs','personalize'];
|
|
1022
|
+
// the tab used to be named/routed "settings" — old bookmarks and any localStorage value saved
|
|
1023
|
+
// under that name still land on the Personalize tab instead of a blank panel.
|
|
1024
|
+
function normalizeTab(t){ return t==='settings'?'personalize':t; }
|
|
971
1025
|
function tabFromPath(){
|
|
972
1026
|
const s=location.pathname.split('/').filter(Boolean);
|
|
973
1027
|
if(s[0]==='custom'&&s[1]) return 'custom:'+decodeURIComponent(s[1]);
|
|
974
|
-
|
|
1028
|
+
const t=normalizeTab(s[0]);
|
|
1029
|
+
return ROUTES.includes(t)?t:null;
|
|
975
1030
|
}
|
|
976
1031
|
function taskFromPath(){ const s=location.pathname.split('/').filter(Boolean); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
|
|
977
1032
|
function navigateTab(tabId,push){
|
|
@@ -986,6 +1041,7 @@ function navigateTab(tabId,push){
|
|
|
986
1041
|
// was scrolled to last (only on an actual switch INTO the tab — applyActiveTab() alone runs on
|
|
987
1042
|
// every SSE render tick too, and re-scrolling/re-focusing there would fight the user's typing).
|
|
988
1043
|
if(tabId==='chat') setTimeout(()=>{ scrollChat($('#chatTabLog')); $('#tabRunPrompt').focus(); },60);
|
|
1044
|
+
if(tabId==='files') renderFiles(); // the tree is fetched lazily — only load it on an actual switch in
|
|
989
1045
|
}
|
|
990
1046
|
function closeNav(){ document.body.classList.remove('nav-open'); const nt=$('#navToggle'); if(nt) nt.setAttribute('aria-expanded','false'); }
|
|
991
1047
|
|
|
@@ -1236,6 +1292,203 @@ function renderDocs(){
|
|
|
1236
1292
|
box.append(note);
|
|
1237
1293
|
}
|
|
1238
1294
|
|
|
1295
|
+
// ---- Files tab: browse the project tree, view/edit any text file (Markdown rendered, HTML
|
|
1296
|
+
// previewed in a sandboxed iframe, everything else as a plain monospace editor). The tree is
|
|
1297
|
+
// fetched lazily (only while this tab is active) and never refetched mid-edit — an SSE 'change'
|
|
1298
|
+
// event refreshes the TREE listing but never overwrites an open file's editor buffer, so an
|
|
1299
|
+
// unrelated agent write elsewhere can't clobber unsaved work here. ----
|
|
1300
|
+
let filesTreeData=null, filesOpenPath=null, filesOpenDirty=false;
|
|
1301
|
+
const filesOpenDirs=new Set(); // persists which tree folders are expanded across a refresh
|
|
1302
|
+
async function loadFilesTree(){
|
|
1303
|
+
try{
|
|
1304
|
+
const r=await fetch('/api/files/tree'); const d=await r.json().catch(()=>({}));
|
|
1305
|
+
filesTreeData = (r.ok && Array.isArray(d.tree)) ? d.tree : [];
|
|
1306
|
+
}catch{ filesTreeData=filesTreeData||[]; }
|
|
1307
|
+
renderFilesTree();
|
|
1308
|
+
}
|
|
1309
|
+
function renderFiles(){
|
|
1310
|
+
// Only the FIRST activation fetches — render() re-runs on every SSE 'change' (a chat message, a
|
|
1311
|
+
// task update, anything) and a full tree rebuild on each of those would yank rows out from under
|
|
1312
|
+
// an in-progress click. Fresh-after-your-own-action is handled by loadFilesTree() calls at the
|
|
1313
|
+
// point of action (filesCreate); the toolbar's Refresh button covers everything else.
|
|
1314
|
+
if(activeTab!=='files' || filesTreeData!=null) return;
|
|
1315
|
+
loadFilesTree();
|
|
1316
|
+
}
|
|
1317
|
+
function fNode(entry){
|
|
1318
|
+
const row=el('div','f-row'+(entry.type==='dir'&&filesOpenDirs.has(entry.path)?' is-open':'')+(entry.path===filesOpenPath?' is-active':''));
|
|
1319
|
+
row.tabIndex=0;
|
|
1320
|
+
if(entry.type==='dir'){
|
|
1321
|
+
const chev=document.createElementNS('http://www.w3.org/2000/svg','svg');
|
|
1322
|
+
chev.setAttribute('viewBox','0 0 18 18'); chev.setAttribute('class','f-chevron'); chev.setAttribute('fill','none'); chev.setAttribute('stroke','currentColor'); chev.setAttribute('stroke-width','1.8');
|
|
1323
|
+
chev.innerHTML='<path d="M6.5 4l6 5-6 5"/>';
|
|
1324
|
+
row.append(chev);
|
|
1325
|
+
} else row.append(el('span',null,''));
|
|
1326
|
+
row.append(el('span','f-name',entry.name));
|
|
1327
|
+
const wrap=el('div','f-node');
|
|
1328
|
+
wrap.append(row);
|
|
1329
|
+
if(entry.type==='dir'){
|
|
1330
|
+
const kids=el('div','f-children'); kids.hidden=!filesOpenDirs.has(entry.path);
|
|
1331
|
+
(entry.children||[]).forEach(c=> kids.append(fNode(c)));
|
|
1332
|
+
wrap.append(kids);
|
|
1333
|
+
row.addEventListener('click',()=>{
|
|
1334
|
+
const open=filesOpenDirs.has(entry.path);
|
|
1335
|
+
if(open) filesOpenDirs.delete(entry.path); else filesOpenDirs.add(entry.path);
|
|
1336
|
+
row.classList.toggle('is-open',!open); kids.hidden=open;
|
|
1337
|
+
});
|
|
1338
|
+
} else {
|
|
1339
|
+
row.addEventListener('click',()=> openFilesFile(entry.path));
|
|
1340
|
+
}
|
|
1341
|
+
return wrap;
|
|
1342
|
+
}
|
|
1343
|
+
function renderFilesTree(){
|
|
1344
|
+
const box=$('#filesTree'); if(!box) return;
|
|
1345
|
+
box.innerHTML='';
|
|
1346
|
+
if(!filesTreeData || !filesTreeData.length){ box.append(el('div','empty',t('files.empty'))); return; }
|
|
1347
|
+
filesTreeData.forEach(e=> box.append(fNode(e)));
|
|
1348
|
+
}
|
|
1349
|
+
function filesExt(p){ const m=/\.([a-z0-9]+)$/i.exec(p||''); return m?m[1].toLowerCase():''; }
|
|
1350
|
+
async function openFilesFile(relPath){
|
|
1351
|
+
// no native confirm() dialog (it blocks the whole tab, including our own SSE/automation) — a
|
|
1352
|
+
// dirty editor just refuses to switch until the user explicitly saves or discards.
|
|
1353
|
+
if(filesOpenDirty){
|
|
1354
|
+
const actions=$('#filesContent .files-actions');
|
|
1355
|
+
if(actions && !actions.querySelector('.files-error-tip')){
|
|
1356
|
+
const tip=el('span','files-error-tip',t('files.discardConfirm')); actions.append(tip);
|
|
1357
|
+
}
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
filesOpenPath=relPath; filesOpenDirty=false;
|
|
1361
|
+
renderFilesTree();
|
|
1362
|
+
const box=$('#filesContent'); box.innerHTML='';
|
|
1363
|
+
box.append(el('div','files-empty',t('drawer.loading')));
|
|
1364
|
+
let data;
|
|
1365
|
+
try{ const r=await fetch('/api/files/read?'+new URLSearchParams({path:relPath})); data=await r.json().catch(()=>({})); if(!r.ok) throw new Error(data.error||'error'); }
|
|
1366
|
+
catch(err){ box.innerHTML=''; box.append(el('div','files-empty',err.message||t('files.loadError'))); return; }
|
|
1367
|
+
box.innerHTML='';
|
|
1368
|
+
const bar=el('div','files-toolbar-row');
|
|
1369
|
+
bar.append(el('div','files-path',relPath));
|
|
1370
|
+
const actions=el('div','files-actions'); bar.append(actions);
|
|
1371
|
+
box.append(bar);
|
|
1372
|
+
if(data.binary){ box.append(el('div','files-binary',t('files.binary'))); return; }
|
|
1373
|
+
const ext=filesExt(relPath);
|
|
1374
|
+
const content=data.content||'';
|
|
1375
|
+
if(ext==='md'||ext==='markdown'){ renderFilesMd(box,actions,relPath,content); }
|
|
1376
|
+
else if(ext==='html'||ext==='htm'){ renderFilesHtml(box,actions,relPath,content); }
|
|
1377
|
+
else { renderFilesText(box,actions,relPath,content); }
|
|
1378
|
+
}
|
|
1379
|
+
function filesSavedTip(actions){
|
|
1380
|
+
const tip=el('span','files-saved-tip',t('files.saved')); actions.append(tip);
|
|
1381
|
+
setTimeout(()=>tip.remove(),1500);
|
|
1382
|
+
}
|
|
1383
|
+
// Explicit, non-blocking way to abandon local edits (no confirm() dialog) — re-fetches the file
|
|
1384
|
+
// fresh from disk and clears the dirty flag so switching tree files is unblocked again.
|
|
1385
|
+
function filesDiscardBtn(relPath){
|
|
1386
|
+
const btn=el('button',null,t('files.discard')); btn.type='button';
|
|
1387
|
+
btn.addEventListener('click',()=>{ filesOpenDirty=false; openFilesFile(relPath); });
|
|
1388
|
+
return btn;
|
|
1389
|
+
}
|
|
1390
|
+
async function filesSave(relPath,content,actions){
|
|
1391
|
+
try{
|
|
1392
|
+
const r=await fetch('/api/files/write',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:relPath,content})});
|
|
1393
|
+
const d=await r.json().catch(()=>({}));
|
|
1394
|
+
if(!r.ok) throw new Error(d.error||'error');
|
|
1395
|
+
filesOpenDirty=false; filesSavedTip(actions);
|
|
1396
|
+
}catch(err){
|
|
1397
|
+
const tip=el('span','files-error-tip',err.message||t('files.saveError')); actions.append(tip);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
// Markdown: rendered preview by default (via the same mdLite renderer the Agents & Skills drawer
|
|
1401
|
+
// uses), with an Edit toggle that swaps in a plain-text editor plus a tiny insert-at-cursor toolbar.
|
|
1402
|
+
function renderFilesMd(box,actions,relPath,content){
|
|
1403
|
+
let editing=false;
|
|
1404
|
+
const editBtn=el('button','btn',t('files.edit'));
|
|
1405
|
+
actions.append(editBtn);
|
|
1406
|
+
const body=el('div','files-body-col');
|
|
1407
|
+
box.append(body);
|
|
1408
|
+
const showPreview=()=>{ body.innerHTML=''; const md=el('div','files-view md-body'); md.innerHTML=mdLite(content); body.append(md); editBtn.textContent=t('files.edit'); };
|
|
1409
|
+
const showEditor=()=>{
|
|
1410
|
+
body.innerHTML='';
|
|
1411
|
+
const tb=el('div','files-md-toolbar');
|
|
1412
|
+
const ta=el('textarea','files-editor'); ta.value=content;
|
|
1413
|
+
const wrapSel=(before,after)=>{ const s=ta.selectionStart,e=ta.selectionEnd; const v=ta.value; ta.value=v.slice(0,s)+before+v.slice(s,e)+after+v.slice(e); ta.focus(); ta.selectionStart=s+before.length; ta.selectionEnd=e+before.length; content=ta.value; filesOpenDirty=true; };
|
|
1414
|
+
[['B','**','**'],['I','_','_'],['H','## ',''],['Link','[','](url)']].forEach(([label,a,b])=>{
|
|
1415
|
+
const bt=el('button',null,label); bt.type='button'; bt.addEventListener('click',()=>wrapSel(a,b)); tb.append(bt);
|
|
1416
|
+
});
|
|
1417
|
+
const saveBtn=el('button',null,t('files.save')); saveBtn.type='button'; saveBtn.addEventListener('click',()=>{ content=ta.value; filesSave(relPath,content,actions); });
|
|
1418
|
+
tb.append(saveBtn,filesDiscardBtn(relPath));
|
|
1419
|
+
ta.addEventListener('input',()=>{ content=ta.value; filesOpenDirty=true; });
|
|
1420
|
+
body.append(tb,ta); ta.focus(); editBtn.textContent=t('files.preview');
|
|
1421
|
+
};
|
|
1422
|
+
editBtn.addEventListener('click',()=>{ editing=!editing; if(editing) showEditor(); else showPreview(); });
|
|
1423
|
+
showPreview();
|
|
1424
|
+
}
|
|
1425
|
+
// HTML: sandboxed iframe preview by default, plain-text editor on toggle (no WYSIWYG — the
|
|
1426
|
+
// dashboard stays zero-dependency, no editor library is loaded).
|
|
1427
|
+
function renderFilesHtml(box,actions,relPath,content){
|
|
1428
|
+
let editing=false;
|
|
1429
|
+
const editBtn=el('button','btn',t('files.edit'));
|
|
1430
|
+
actions.append(editBtn);
|
|
1431
|
+
const body=el('div','files-body-col');
|
|
1432
|
+
box.append(body);
|
|
1433
|
+
const showPreview=()=>{
|
|
1434
|
+
body.innerHTML='';
|
|
1435
|
+
const frame=document.createElement('iframe');
|
|
1436
|
+
frame.className='files-iframe'; frame.setAttribute('sandbox',''); frame.srcdoc=content;
|
|
1437
|
+
body.append(frame); editBtn.textContent=t('files.edit');
|
|
1438
|
+
};
|
|
1439
|
+
const showEditor=()=>{
|
|
1440
|
+
body.innerHTML='';
|
|
1441
|
+
const ta=el('textarea','files-editor'); ta.value=content;
|
|
1442
|
+
ta.addEventListener('input',()=>{ content=ta.value; filesOpenDirty=true; });
|
|
1443
|
+
const saveBtn=el('button','btn primary',t('files.save')); saveBtn.type='button'; saveBtn.addEventListener('click',()=>filesSave(relPath,content,actions));
|
|
1444
|
+
const tb=el('div','files-md-toolbar'); tb.append(saveBtn,filesDiscardBtn(relPath));
|
|
1445
|
+
body.append(tb,ta); ta.focus(); editBtn.textContent=t('files.preview');
|
|
1446
|
+
};
|
|
1447
|
+
editBtn.addEventListener('click',()=>{ editing=!editing; if(editing) showEditor(); else showPreview(); });
|
|
1448
|
+
showPreview();
|
|
1449
|
+
}
|
|
1450
|
+
// Anything else that reads as text: a plain monospace editor, editable straight away.
|
|
1451
|
+
function renderFilesText(box,actions,relPath,content){
|
|
1452
|
+
const ta=el('textarea','files-editor'); ta.value=content;
|
|
1453
|
+
ta.addEventListener('input',()=>{ filesOpenDirty=true; });
|
|
1454
|
+
const saveBtn=el('button','btn primary',t('files.save')); saveBtn.type='button';
|
|
1455
|
+
saveBtn.addEventListener('click',()=>filesSave(relPath,ta.value,actions));
|
|
1456
|
+
actions.append(saveBtn,filesDiscardBtn(relPath));
|
|
1457
|
+
box.append(ta); ta.focus();
|
|
1458
|
+
}
|
|
1459
|
+
// "+ File"/"+ Folder" open the same inline form (never a native prompt()/alert() — those block the
|
|
1460
|
+
// whole tab, including our own SSE connection, until dismissed).
|
|
1461
|
+
let filesCreateKind='file';
|
|
1462
|
+
function openFilesCreateForm(kind){
|
|
1463
|
+
filesCreateKind=kind;
|
|
1464
|
+
const form=$('#filesCreateForm'); if(!form) return;
|
|
1465
|
+
const input=$('#filesCreateInput');
|
|
1466
|
+
input.placeholder = kind==='dir' ? t('files.newFolderPrompt') : t('files.newFilePrompt');
|
|
1467
|
+
input.value='';
|
|
1468
|
+
const err=$('#filesCreateError'); if(err) err.hidden=true;
|
|
1469
|
+
form.hidden=false; form.classList.add('is-open');
|
|
1470
|
+
input.focus();
|
|
1471
|
+
}
|
|
1472
|
+
function closeFilesCreateForm(){
|
|
1473
|
+
const form=$('#filesCreateForm'); if(!form) return;
|
|
1474
|
+
form.hidden=true; form.classList.remove('is-open');
|
|
1475
|
+
}
|
|
1476
|
+
async function submitFilesCreate(){
|
|
1477
|
+
const input=$('#filesCreateInput'); const err=$('#filesCreateError');
|
|
1478
|
+
const name=(input.value||'').trim();
|
|
1479
|
+
if(!name){ if(err){ err.textContent=t('backlog.addTitleRequired'); err.hidden=false; } return; }
|
|
1480
|
+
const rel=name.replace(/^\/+/,'');
|
|
1481
|
+
const kind=filesCreateKind;
|
|
1482
|
+
const endpoint = kind==='dir' ? '/api/files/mkdir' : '/api/files/write';
|
|
1483
|
+
const payload = kind==='dir' ? {path:rel} : {path:rel,content:''};
|
|
1484
|
+
const r=await fetch(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
1485
|
+
const d=await r.json().catch(()=>({}));
|
|
1486
|
+
if(!r.ok){ if(err){ err.textContent=d.error||t('files.saveError'); err.hidden=false; } return; }
|
|
1487
|
+
closeFilesCreateForm();
|
|
1488
|
+
await loadFilesTree();
|
|
1489
|
+
if(kind!=='dir') openFilesFile(rel);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1239
1492
|
function openDrawer(id,keep){
|
|
1240
1493
|
// named `task`, not `t` — `t` is the global translation function (see i18n.js) and this whole
|
|
1241
1494
|
// function calls it repeatedly below; shadowing it with a task variable would break every call.
|
|
@@ -1283,12 +1536,27 @@ const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).
|
|
|
1283
1536
|
// too instead of ever resetting to Board; this is what keeps a tab selected across a race with a
|
|
1284
1537
|
// 'change'/'message' event that lands right after a click.
|
|
1285
1538
|
// initial tab: the URL path wins (deep-link / refresh), else the persisted tab, else board
|
|
1286
|
-
let activeTab = tabFromPath() || (()=>{ try{ return localStorage.getItem('spf-tab')||'board'; }catch{ return 'board'; } })();
|
|
1539
|
+
let activeTab = tabFromPath() || (()=>{ try{ return normalizeTab(localStorage.getItem('spf-tab'))||'board'; }catch{ return 'board'; } })();
|
|
1287
1540
|
openTaskId = taskFromPath(); // deep-link straight to a task drawer
|
|
1288
1541
|
function applyActiveTab(){
|
|
1289
1542
|
$$('#tabs .tab').forEach(t=> t.classList.toggle('is-active', t.dataset.tab===activeTab));
|
|
1290
1543
|
$$('.panel').forEach(p=> p.classList.toggle('is-active', p.dataset.panel===activeTab));
|
|
1291
|
-
|
|
1544
|
+
fitTabs();
|
|
1545
|
+
}
|
|
1546
|
+
// Horizontal top-nav designs (Console's vertical rail and Orbit's hidden/radial nav manage their own
|
|
1547
|
+
// layout independently) can run out of room as tabs are added over time — a single px breakpoint
|
|
1548
|
+
// tuned for whatever tab count existed back then goes stale the moment a tab is added or removed
|
|
1549
|
+
// (exactly what happened when the Files tab pushed the row from 10 to 11 items: some tabs, including
|
|
1550
|
+
// Personalize, silently overflowed with no visible way to reach them). Measure the row's REAL
|
|
1551
|
+
// overflow instead of guessing from viewport width alone, so it's correct at any tab count.
|
|
1552
|
+
function fitTabs(){
|
|
1553
|
+
const tabsEl=$('#tabs'); if(!tabsEl) return;
|
|
1554
|
+
const design=document.documentElement.getAttribute('data-design');
|
|
1555
|
+
if(design==='console'||design==='orbit'){ tabsEl.classList.remove('tabs-compact'); return; }
|
|
1556
|
+
tabsEl.classList.remove('tabs-compact'); // measure at full (labelled) size first
|
|
1557
|
+
if(tabsEl.scrollWidth>tabsEl.clientWidth+1) tabsEl.classList.add('tabs-compact');
|
|
1558
|
+
}
|
|
1559
|
+
window.addEventListener('resize', (()=>{ let t=null; return ()=>{ clearTimeout(t); t=setTimeout(fitTabs,120); }; })());
|
|
1292
1560
|
$$('#tabs .tab').forEach(tab=> tab.addEventListener('click',()=> navigateTab(tab.dataset.tab)));
|
|
1293
1561
|
// mobile hamburger — toggles the tab dropdown (body.nav-open); closes on tab pick / outside / Esc
|
|
1294
1562
|
const navToggle=$('#navToggle');
|
|
@@ -1308,11 +1576,24 @@ window.addEventListener('popstate',()=>{
|
|
|
1308
1576
|
// brand logo → Board (SPA nav, no full reload)
|
|
1309
1577
|
const brandLogo=$('.brand-logo'); if(brandLogo) brandLogo.addEventListener('click',e=>{ e.preventDefault(); navigateTab('board'); });
|
|
1310
1578
|
applyActiveTab(); // sync to the resolved tab before the first render
|
|
1579
|
+
// an old bookmark/share to the pre-rename "/settings" URL: swap the address bar to the real
|
|
1580
|
+
// route once resolved, so the visible URL matches the "Personalize" tab it landed on.
|
|
1581
|
+
if(location.pathname.split('/').filter(Boolean)[0]==='settings') history.replaceState(null,'','/personalize');
|
|
1311
1582
|
// filters (status chips + search) — client-side only, does not write anything
|
|
1312
1583
|
$$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.status=b.dataset.status; renderBoard(); }));
|
|
1313
1584
|
$('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
|
|
1314
1585
|
// board view switch — List (phase-grouped) vs Kanban (columns by status), persisted per viewer
|
|
1315
1586
|
$$('#boardViewToggle .vt-btn').forEach(b=> b.addEventListener('click', ()=>{ boardView=b.dataset.view; try{ localStorage.setItem('spf-board-view',boardView); }catch{} renderBoard(); }));
|
|
1587
|
+
// right sidebar hide/show — Kanban's own-width columns need the room, and the toggle stays
|
|
1588
|
+
// persisted per viewer like every other layout preference here
|
|
1589
|
+
function applySideHidden(){
|
|
1590
|
+
const panel=$('.panel[data-panel="board"]'); const btn=$('#sideToggle');
|
|
1591
|
+
if(panel) panel.classList.toggle('side-hidden', sideHidden);
|
|
1592
|
+
if(btn){ btn.setAttribute('aria-pressed', String(sideHidden)); btn.title=t(sideHidden?'board.showSidebar':'board.hideSidebar'); }
|
|
1593
|
+
}
|
|
1594
|
+
const sideToggleBtn=$('#sideToggle');
|
|
1595
|
+
if(sideToggleBtn) sideToggleBtn.addEventListener('click', ()=>{ sideHidden=!sideHidden; try{ localStorage.setItem('spf-side-hidden', sideHidden?'1':'0'); }catch{} applySideHidden(); });
|
|
1596
|
+
applySideHidden();
|
|
1316
1597
|
// expand / collapse all phases (List view) — keeps a big board compact by default
|
|
1317
1598
|
const phaseToggleAllBtn=$('#phaseToggleAll');
|
|
1318
1599
|
if(phaseToggleAllBtn) phaseToggleAllBtn.addEventListener('click', ()=>{
|
|
@@ -1330,12 +1611,24 @@ $$('#backlogTable thead th').forEach(th=> th.addEventListener('click', ()=>{
|
|
|
1330
1611
|
else { backlogSort.col=col; backlogSort.dir='asc'; }
|
|
1331
1612
|
backlogPage=1; renderBacklog();
|
|
1332
1613
|
}));
|
|
1614
|
+
// files tab: create a new file/folder (path relative to the project root, e.g. "notes/todo.md")
|
|
1615
|
+
const filesNewFileBtn=$('#filesNewFile'); if(filesNewFileBtn) filesNewFileBtn.addEventListener('click',()=>openFilesCreateForm('file'));
|
|
1616
|
+
const filesNewFolderBtn=$('#filesNewFolder'); if(filesNewFolderBtn) filesNewFolderBtn.addEventListener('click',()=>openFilesCreateForm('dir'));
|
|
1617
|
+
const filesRefreshBtn=$('#filesRefresh'); if(filesRefreshBtn) filesRefreshBtn.addEventListener('click',loadFilesTree);
|
|
1618
|
+
const filesCreateGoBtn=$('#filesCreateGo'); if(filesCreateGoBtn) filesCreateGoBtn.addEventListener('click',submitFilesCreate);
|
|
1619
|
+
const filesCreateCancelBtn=$('#filesCreateCancel'); if(filesCreateCancelBtn) filesCreateCancelBtn.addEventListener('click',closeFilesCreateForm);
|
|
1620
|
+
const filesCreateInputEl=$('#filesCreateInput'); if(filesCreateInputEl) filesCreateInputEl.addEventListener('keydown',e=>{ if(e.key==='Enter') submitFilesCreate(); });
|
|
1621
|
+
// backlog: manual "+ Add task" form (title required, phase/owner/level optional)
|
|
1622
|
+
$('#backlogAddBtn').addEventListener('click', ()=>{ const form=$('#backlogAddForm'); (form&&!form.hidden)?closeBacklogAddForm():openBacklogAddForm(); });
|
|
1623
|
+
$('#blAddCancel').addEventListener('click', closeBacklogAddForm);
|
|
1624
|
+
$('#blAddSubmit').addEventListener('click', submitBacklogAdd);
|
|
1625
|
+
$('#blAddTitle').addEventListener('keydown', e=>{ if(e.key==='Enter') submitBacklogAdd(); });
|
|
1333
1626
|
// attention tab: add a note + filter chips
|
|
1334
1627
|
$('#attnAddBtn').addEventListener('click',()=>{ const t=$('#attnInput'); const v=t.value.trim(); if(v){ addAttn(v); t.value=''; } });
|
|
1335
1628
|
$('#attnInput').addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ const v=e.target.value.trim(); if(v){ addAttn(v); e.target.value=''; } } });
|
|
1336
1629
|
$$('.attn-filters .fchip').forEach(b=> b.addEventListener('click',()=>{ attnFilter=b.dataset.attn; renderAttention(); }));
|
|
1337
1630
|
// settings — the footer link opens the Settings tab; selects save on change
|
|
1338
|
-
const footerSettingsBtn=$('#footerSettings'); if(footerSettingsBtn) footerSettingsBtn.addEventListener('click',()=>navigateTab('
|
|
1631
|
+
const footerSettingsBtn=$('#footerSettings'); if(footerSettingsBtn) footerSettingsBtn.addEventListener('click',()=>navigateTab('personalize'));
|
|
1339
1632
|
const footerLogo=$('.footer-logo'); if(footerLogo) footerLogo.addEventListener('click',e=>{ e.preventDefault(); navigateTab('board'); });
|
|
1340
1633
|
function onModeSelectChange(e){ setModeSelects(e.target.value); saveSettings(); }
|
|
1341
1634
|
function onLangSelectChange(e){ setLangSelect(e.target.value); saveSettings(); }
|