@hanzlaa/rcode 4.1.2 → 4.3.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/cli/install.js +176 -13
- package/cli/lib/config.cjs +4 -2
- package/cli/lib/fsutil.cjs +13 -2
- package/cli/lib/homedir.cjs +21 -0
- package/cli/lib/schemas.cjs +6 -1
- package/cli/nuke.js +13 -8
- package/cli/postinstall.js +14 -4
- package/cli/rcode-slash-router.cjs +118 -0
- package/cli/uninstall.js +59 -1
- package/cli/update.js +10 -5
- package/dist/rcode.js +234 -230
- package/package.json +1 -1
- package/rcode/references/auto-init-guard.md +2 -2
- package/rcode/references/output-format.md +5 -5
- package/rcode/skills/actions/2-plan/rcode-create-milestone/steps/step-10-complete.md +1 -1
- package/server/dashboard.js +33 -13
- package/server/lib/api.js +62 -4
- package/server/lib/html/client/agents-data.js +22 -18
- package/server/lib/html/client/app.js +3 -0
- package/server/lib/html/client/components/AgentCard.js +127 -0
- package/server/lib/html/client/components/App.js +104 -39
- package/server/lib/html/client/components/CommandPalette.js +133 -0
- package/server/lib/html/client/components/FileReader.js +116 -0
- package/server/lib/html/client/components/FilterChips.js +94 -0
- package/server/lib/html/client/components/NotifyCenter.js +117 -0
- package/server/lib/html/client/components/OrchPanel.js +80 -52
- package/server/lib/html/client/components/PhaseGraph.js +300 -0
- package/server/lib/html/client/components/RejectDialog.js +78 -0
- package/server/lib/html/client/components/RunnerPicker.js +190 -0
- package/server/lib/html/client/components/Sidebar.js +106 -61
- package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
- package/server/lib/html/client/components/TaskPipeline.js +83 -0
- package/server/lib/html/client/components/Topbar.js +86 -39
- package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
- package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
- package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
- package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
- package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
- package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
- package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
- package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
- package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
- package/server/lib/html/client/components/shared.js +47 -11
- package/server/lib/html/client/filter-state.js +72 -0
- package/server/lib/html/client/icons-client.js +7 -0
- package/server/lib/html/client/notify.js +75 -0
- package/server/lib/html/client/orchestrator.js +168 -41
- package/server/lib/html/client/preact.js +13 -8
- package/server/lib/html/client/store.js +70 -6
- package/server/lib/html/client/util.js +78 -0
- package/server/lib/html/client/vendor/htm.js +1 -0
- package/server/lib/html/client/vendor/preact-hooks.js +2 -0
- package/server/lib/html/client/vendor/preact.js +2 -0
- package/server/lib/html/client/views/AgentsView.js +144 -51
- package/server/lib/html/client/views/FilesView.js +20 -103
- package/server/lib/html/client/views/KanbanView.js +40 -21
- package/server/lib/html/client/views/MemoryView.js +26 -9
- package/server/lib/html/client/views/MilestonesView.js +4 -4
- package/server/lib/html/client/views/OrchestrationView.js +154 -19
- package/server/lib/html/client/views/OverviewView.js +47 -239
- package/server/lib/html/client/views/PhasesView.js +50 -6
- package/server/lib/html/client/views/RoadmapView.js +6 -3
- package/server/lib/html/client/views/SprintsView.js +50 -6
- package/server/lib/html/client/views/TasksView.js +4 -3
- package/server/lib/html/client.js +21 -4
- package/server/lib/html/css.js +2761 -8
- package/server/lib/html/icons.js +7 -0
- package/server/lib/html/shell.js +10 -3
- package/server/lib/scanner.js +376 -39
- package/server/orchestrator.js +346 -7
|
@@ -13,12 +13,16 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { html, useState, useEffect, useRef, useCallback } from '../preact.js';
|
|
16
|
+
import { parseFilters } from '../filter-state.js';
|
|
16
17
|
import { getState, setState, subscribe, registerRefresh } from '../store.js';
|
|
17
18
|
import { startSessionsPoll, refreshOrchToken } from '../orchestrator.js';
|
|
18
19
|
import { Sidebar } from './Sidebar.js';
|
|
19
20
|
import { Topbar } from './Topbar.js';
|
|
20
21
|
import { XtermPanel } from './XtermPanel.js';
|
|
21
22
|
import { OrchPanel } from './OrchPanel.js';
|
|
23
|
+
import { RunnerPicker } from './RunnerPicker.js';
|
|
24
|
+
import { CommandPalette } from './CommandPalette.js';
|
|
25
|
+
import { BlockedToasts } from './NotifyCenter.js';
|
|
22
26
|
import { OverviewView } from '../views/OverviewView.js';
|
|
23
27
|
import { DecisionsView } from '../views/DecisionsView.js';
|
|
24
28
|
import { RoadmapView } from '../views/RoadmapView.js';
|
|
@@ -54,41 +58,59 @@ const LEGACY_VIEWS = [];
|
|
|
54
58
|
|
|
55
59
|
const ALL_VIEWS = Object.keys(PREACT_VIEWS).concat(LEGACY_VIEWS);
|
|
56
60
|
|
|
57
|
-
/** Parse location.hash into { view, subId } — port of client-main.js:45-49. */
|
|
61
|
+
/** Parse location.hash into { view, subId, filters } — port of client-main.js:45-49. */
|
|
58
62
|
function parseHash() {
|
|
59
63
|
const raw = location.hash.slice(1) || 'overview';
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
const
|
|
64
|
+
// Strip ?query suffix before routing so it never leaks into view/subId.
|
|
65
|
+
const qIdx = raw.indexOf('?');
|
|
66
|
+
const path = qIdx === -1 ? raw : raw.slice(0, qIdx);
|
|
67
|
+
const slash = path.indexOf('/');
|
|
68
|
+
const view = slash === -1 ? path : path.slice(0, slash);
|
|
69
|
+
// subId must not include the ?query portion.
|
|
70
|
+
const subId = slash === -1 ? null : path.slice(slash + 1);
|
|
63
71
|
// #263: unknown hash falls back to overview
|
|
64
72
|
const resolvedView = ALL_VIEWS.includes(view) ? view : 'overview';
|
|
65
|
-
|
|
73
|
+
const filters = parseFilters(location.hash);
|
|
74
|
+
return { view: resolvedView, subId, filters };
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
/** Full-width banner shown when /api/state polling is failing. */
|
|
69
78
|
function OfflineBanner({ offline }) {
|
|
70
79
|
if (!offline) return null;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
return html`<div class="offline-banner" role="alert">⚠ Dashboard offline — retrying every 30s…</div>`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Dismissible banner shown when .rcode/state.json failed to parse. */
|
|
84
|
+
function ParseErrorBanner({ error, dismissed }) {
|
|
85
|
+
if (!error || dismissed) return null;
|
|
86
|
+
return html`
|
|
87
|
+
<div class="parse-error-banner" role="alert">
|
|
88
|
+
<span>⚠ .rcode/state.json is corrupted — data shown may be stale or empty (${error})</span>
|
|
89
|
+
<button class="banner-dismiss" aria-label="Dismiss"
|
|
90
|
+
onClick=${() => setState({ parseErrorDismissed: true })}>✕</button>
|
|
91
|
+
</div>
|
|
92
|
+
`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Close the mobile slide-in sidebar (no-op on desktop where it is static). */
|
|
96
|
+
function closeMobileSidebar() {
|
|
97
|
+
const sidebar = document.querySelector('.sidebar');
|
|
98
|
+
const backdrop = document.getElementById('sidebar-backdrop');
|
|
99
|
+
if (sidebar) sidebar.classList.remove('open');
|
|
100
|
+
if (backdrop) backdrop.classList.remove('show');
|
|
75
101
|
}
|
|
76
102
|
|
|
77
103
|
/** Thin IDE-style status bar: project path · rcode version · last refresh. */
|
|
78
104
|
function StatusBar({ projectRoot, projectName, version, updatedAgo, offline, refreshing }) {
|
|
79
|
-
const bar = 'display:flex;align-items:center;gap:var(--space-4);height:24px;'
|
|
80
|
-
+ 'padding:0 var(--space-4);background:var(--bg-elev-1);'
|
|
81
|
-
+ 'border-top:1px solid var(--border-subtle);font-family:var(--font-mono);'
|
|
82
|
-
+ 'font-size:var(--text-2xs);color:var(--text-muted);white-space:nowrap;overflow:hidden;';
|
|
83
|
-
const dot = 'width:6px;height:6px;border-radius:50%;flex-shrink:0;background:'
|
|
84
|
-
+ (offline ? 'var(--red,#eb5757)' : 'var(--accent-green)') + ';'
|
|
85
|
-
+ (refreshing ? 'animation:pulse-dot 1s ease-in-out infinite;' : '');
|
|
86
105
|
const path = projectRoot || projectName || 'no project';
|
|
106
|
+
const dotCls = 'statusbar-dot'
|
|
107
|
+
+ (offline ? ' statusbar-dot--offline' : '')
|
|
108
|
+
+ (refreshing ? ' statusbar-dot--busy' : '');
|
|
87
109
|
return html`
|
|
88
|
-
<footer
|
|
89
|
-
<span
|
|
90
|
-
<span
|
|
91
|
-
<span
|
|
110
|
+
<footer class="statusbar">
|
|
111
|
+
<span class=${dotCls}></span>
|
|
112
|
+
<span class="statusbar-path" title=${path}>${path}</span>
|
|
113
|
+
<span class="statusbar-version">rcode v${version || '?'}</span>
|
|
92
114
|
<span>${offline ? 'offline' : refreshing ? 'syncing…' : 'updated ' + updatedAgo}</span>
|
|
93
115
|
</footer>
|
|
94
116
|
`;
|
|
@@ -97,10 +119,13 @@ function StatusBar({ projectRoot, projectName, version, updatedAgo, offline, ref
|
|
|
97
119
|
/** Root App component. No props needed — reads everything from the store. */
|
|
98
120
|
export function App() {
|
|
99
121
|
// ---- Router state ----
|
|
100
|
-
const [{ view, subId }, setRoute] = useState(parseHash);
|
|
122
|
+
const [{ view, subId, filters }, setRoute] = useState(parseHash);
|
|
101
123
|
|
|
102
124
|
useEffect(() => {
|
|
103
|
-
function onHashChange() {
|
|
125
|
+
function onHashChange() {
|
|
126
|
+
setRoute(parseHash());
|
|
127
|
+
closeMobileSidebar(); // navigating from the mobile nav should reveal the view
|
|
128
|
+
}
|
|
104
129
|
window.addEventListener('hashchange', onHashChange);
|
|
105
130
|
return () => window.removeEventListener('hashchange', onHashChange);
|
|
106
131
|
}, []);
|
|
@@ -129,13 +154,13 @@ export function App() {
|
|
|
129
154
|
}, [theme]);
|
|
130
155
|
|
|
131
156
|
// ---- Sidebar collapse ----
|
|
157
|
+
// Class names match the mobile CSS contract: .sidebar.open + #sidebar-backdrop.show
|
|
132
158
|
const toggleSidebar = useCallback(() => {
|
|
133
159
|
const sidebar = document.querySelector('.sidebar');
|
|
134
160
|
const backdrop = document.getElementById('sidebar-backdrop');
|
|
135
161
|
if (!sidebar) return;
|
|
136
|
-
const open = sidebar.classList.toggle('
|
|
137
|
-
if (backdrop) backdrop.classList.toggle('
|
|
138
|
-
document.body.classList.toggle('sidebar-visible', open);
|
|
162
|
+
const open = sidebar.classList.toggle('open');
|
|
163
|
+
if (backdrop) backdrop.classList.toggle('show', open);
|
|
139
164
|
}, []);
|
|
140
165
|
|
|
141
166
|
// ---- Updated-ago display ----
|
|
@@ -159,18 +184,43 @@ export function App() {
|
|
|
159
184
|
const r = await fetch('/api/state');
|
|
160
185
|
if (!r.ok) { setState({ refreshing: false, offline: true }); return; }
|
|
161
186
|
const newState = await r.json();
|
|
187
|
+
// The server's scan cache keeps lastScanned stable while nothing on
|
|
188
|
+
// disk changed — same stamp means identical data, so skip the patch
|
|
189
|
+
// entirely instead of committing fresh object identities that would
|
|
190
|
+
// re-render every subscribed component.
|
|
191
|
+
if (lastScannedRef.current && lastScannedRef.current === newState.lastScanned) {
|
|
192
|
+
scanTimeRef.current = Date.now();
|
|
193
|
+
setUpdatedAgo('just now');
|
|
194
|
+
setState({ refreshing: false, offline: false });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
162
197
|
lastScannedRef.current = newState.lastScanned;
|
|
163
198
|
scanTimeRef.current = Date.now();
|
|
164
199
|
setUpdatedAgo('just now');
|
|
165
|
-
const patch = {
|
|
200
|
+
const patch = {
|
|
201
|
+
refreshing: false, offline: false, lastRefresh: Date.now(),
|
|
202
|
+
// Surface state.json corruption (§1.4) — also clears the banner once fixed.
|
|
203
|
+
rawParseError: newState.rawParseError || null,
|
|
204
|
+
};
|
|
205
|
+
// Redesign contract slices (DATA-CONTRACT.md) — derived server-side and
|
|
206
|
+
// returned under newState.dashboard. Keep them fresh on every poll.
|
|
207
|
+
const d = newState.dashboard || {};
|
|
208
|
+
Object.assign(patch, {
|
|
209
|
+
initialized: newState.exists !== false,
|
|
210
|
+
project: d.project || null,
|
|
211
|
+
progress: d.progress || null,
|
|
212
|
+
timeline: d.timeline || null,
|
|
213
|
+
tasks: d.tasks || null,
|
|
214
|
+
health: d.health || null,
|
|
215
|
+
});
|
|
166
216
|
if (newState.raw) {
|
|
167
217
|
Object.assign(patch, {
|
|
168
|
-
phases: newState.phaseTree
|
|
218
|
+
phases: d.phases || newState.phaseTree || newState.raw.phases || [],
|
|
169
219
|
milestone: newState.raw.milestone || '',
|
|
170
|
-
currentPhase: newState.raw.current_phase
|
|
220
|
+
currentPhase: d.currentPhase || newState.raw.current_phase || null,
|
|
171
221
|
currentSprint: newState.raw.current_sprint || null,
|
|
172
|
-
decisions: newState.raw.decisions
|
|
173
|
-
blockers: newState.raw.blockers
|
|
222
|
+
decisions: d.decisions || newState.raw.decisions || [],
|
|
223
|
+
blockers: d.blockers || newState.raw.blockers || [],
|
|
174
224
|
council_sessions: newState.raw.council_sessions || [],
|
|
175
225
|
last_session: newState.raw.last_session || null,
|
|
176
226
|
});
|
|
@@ -200,6 +250,20 @@ export function App() {
|
|
|
200
250
|
startSessionsPoll();
|
|
201
251
|
}, []);
|
|
202
252
|
|
|
253
|
+
// ---- Command palette ----
|
|
254
|
+
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
255
|
+
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
function onKeyDown(e) {
|
|
258
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
|
259
|
+
e.preventDefault();
|
|
260
|
+
setPaletteOpen(o => !o);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
window.addEventListener('keydown', onKeyDown);
|
|
264
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
203
267
|
// ---- View rendering ----
|
|
204
268
|
const PreactView = PREACT_VIEWS[view] || null;
|
|
205
269
|
|
|
@@ -207,15 +271,9 @@ export function App() {
|
|
|
207
271
|
<div class="app-shell">
|
|
208
272
|
<${Sidebar} activeView=${view} projectName=${storeState.projectName || ''} />
|
|
209
273
|
|
|
210
|
-
<div id="sidebar-backdrop" onClick=${
|
|
211
|
-
const sidebar = document.querySelector('.sidebar');
|
|
212
|
-
const backdrop = document.getElementById('sidebar-backdrop');
|
|
213
|
-
if (sidebar) sidebar.classList.remove('sidebar-open');
|
|
214
|
-
if (backdrop) backdrop.classList.remove('active');
|
|
215
|
-
document.body.classList.remove('sidebar-visible');
|
|
216
|
-
}}></div>
|
|
274
|
+
<div id="sidebar-backdrop" onClick=${closeMobileSidebar}></div>
|
|
217
275
|
|
|
218
|
-
<div class="content-area" id="main-content"
|
|
276
|
+
<div class="content-area" id="main-content">
|
|
219
277
|
<${Topbar}
|
|
220
278
|
projectName=${storeState.projectName || ''}
|
|
221
279
|
updatedAgo=${updatedAgo}
|
|
@@ -228,7 +286,11 @@ export function App() {
|
|
|
228
286
|
|
|
229
287
|
<div class="main-scroll" id="main-scroll">
|
|
230
288
|
<${OfflineBanner} offline=${storeState.offline} />
|
|
231
|
-
|
|
289
|
+
<${ParseErrorBanner}
|
|
290
|
+
error=${storeState.rawParseError}
|
|
291
|
+
dismissed=${storeState.parseErrorDismissed}
|
|
292
|
+
/>
|
|
293
|
+
${PreactView ? html`<${PreactView} subId=${subId} filters=${filters} />` : null}
|
|
232
294
|
</div>
|
|
233
295
|
|
|
234
296
|
<${StatusBar}
|
|
@@ -243,6 +305,9 @@ export function App() {
|
|
|
243
305
|
|
|
244
306
|
<${XtermPanel} />
|
|
245
307
|
<${OrchPanel} />
|
|
308
|
+
<${BlockedToasts} />
|
|
309
|
+
<${RunnerPicker} />
|
|
310
|
+
<${CommandPalette} open=${paletteOpen} onClose=${() => setPaletteOpen(false)} />
|
|
246
311
|
</div>
|
|
247
312
|
`;
|
|
248
313
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommandPalette — Cmd+K / Ctrl+K searchable command overlay.
|
|
3
|
+
*
|
|
4
|
+
* Reads the allowlisted commands directly from ALLOWED_COMMANDS (orchestrator.js)
|
|
5
|
+
* and executes selections through runCommandFromUI — no second command list.
|
|
6
|
+
*
|
|
7
|
+
* Props:
|
|
8
|
+
* open {boolean} — whether the palette is visible
|
|
9
|
+
* onClose {function} — called when the palette should close (Escape, backdrop click)
|
|
10
|
+
*
|
|
11
|
+
* Added in sprint 36.1 — DSH-4 command palette.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { html, useState, useEffect, useRef, useMemo } from '../preact.js';
|
|
15
|
+
import { ALLOWED_COMMANDS, runCommandFromUI } from '../orchestrator.js';
|
|
16
|
+
import { Icon } from '../icons-client.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build an ordered group list and a flat navigation list from a filtered
|
|
20
|
+
* commands array. Group order matches first-seen category order.
|
|
21
|
+
*
|
|
22
|
+
* @param {Array<{cmd,label,category}>} items
|
|
23
|
+
* @returns {{ groups: Array<{category, items}>, flat: Array<{cmd,label,category}> }}
|
|
24
|
+
*/
|
|
25
|
+
function groupCommands(items) {
|
|
26
|
+
const seen = [];
|
|
27
|
+
const map = {};
|
|
28
|
+
for (const item of items) {
|
|
29
|
+
if (!map[item.category]) {
|
|
30
|
+
map[item.category] = [];
|
|
31
|
+
seen.push(item.category);
|
|
32
|
+
}
|
|
33
|
+
map[item.category].push(item);
|
|
34
|
+
}
|
|
35
|
+
const groups = seen.map(cat => ({ category: cat, items: map[cat] }));
|
|
36
|
+
const flat = groups.flatMap(g => g.items);
|
|
37
|
+
return { groups, flat };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function CommandPalette({ open, onClose }) {
|
|
41
|
+
const [query, setQuery] = useState('');
|
|
42
|
+
const [activeIdx, setActiveIdx] = useState(0);
|
|
43
|
+
const inputRef = useRef(null);
|
|
44
|
+
|
|
45
|
+
// Focus and reset when opened.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (open) {
|
|
48
|
+
setQuery('');
|
|
49
|
+
setActiveIdx(0);
|
|
50
|
+
// Defer by one tick so the element is in the DOM and visible.
|
|
51
|
+
requestAnimationFrame(() => {
|
|
52
|
+
if (inputRef.current) inputRef.current.focus();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}, [open]);
|
|
56
|
+
|
|
57
|
+
// Filter commands by query substring (label or cmd).
|
|
58
|
+
const results = useMemo(() => {
|
|
59
|
+
const q = query.trim().toLowerCase();
|
|
60
|
+
if (!q) return ALLOWED_COMMANDS;
|
|
61
|
+
return ALLOWED_COMMANDS.filter(
|
|
62
|
+
({ cmd, label }) =>
|
|
63
|
+
cmd.toLowerCase().includes(q) || label.toLowerCase().includes(q)
|
|
64
|
+
);
|
|
65
|
+
}, [query]);
|
|
66
|
+
|
|
67
|
+
const { groups, flat } = useMemo(() => groupCommands(results), [results]);
|
|
68
|
+
|
|
69
|
+
function choose(cmd) {
|
|
70
|
+
runCommandFromUI(cmd);
|
|
71
|
+
onClose();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function handleKeyDown(e) {
|
|
75
|
+
if (e.key === 'ArrowDown') {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
setActiveIdx(i => Math.min(i + 1, flat.length - 1));
|
|
78
|
+
} else if (e.key === 'ArrowUp') {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
setActiveIdx(i => Math.max(i - 1, 0));
|
|
81
|
+
} else if (e.key === 'Enter') {
|
|
82
|
+
if (flat[activeIdx]) choose(flat[activeIdx].cmd);
|
|
83
|
+
} else if (e.key === 'Escape') {
|
|
84
|
+
onClose();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!open) return null;
|
|
89
|
+
|
|
90
|
+
// Running flat index counter across groups so activeIdx maps correctly.
|
|
91
|
+
let flatIdx = 0;
|
|
92
|
+
|
|
93
|
+
return html`
|
|
94
|
+
<div class="cmd-palette-overlay" onClick=${onClose}>
|
|
95
|
+
<div class="cmd-palette" onClick=${e => e.stopPropagation()} onKeyDown=${handleKeyDown}>
|
|
96
|
+
|
|
97
|
+
<div class="cmd-palette-search">
|
|
98
|
+
<${Icon} name="search" size=${16} cls="cmd-palette-search-icon" />
|
|
99
|
+
<input
|
|
100
|
+
class="cmd-palette-input"
|
|
101
|
+
ref=${inputRef}
|
|
102
|
+
value=${query}
|
|
103
|
+
onInput=${e => { setQuery(e.target.value); setActiveIdx(0); }}
|
|
104
|
+
placeholder="Search commands…"
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div class="cmd-palette-list">
|
|
109
|
+
${flat.length === 0
|
|
110
|
+
? html`<div class="cmd-palette-empty">No commands match</div>`
|
|
111
|
+
: groups.map(({ category, items }) => html`
|
|
112
|
+
<div class="cmd-palette-group" key=${category}>${category}</div>
|
|
113
|
+
${items.map(item => {
|
|
114
|
+
const idx = flatIdx++;
|
|
115
|
+
return html`
|
|
116
|
+
<button
|
|
117
|
+
class=${'cmd-palette-item' + (idx === activeIdx ? ' active' : '')}
|
|
118
|
+
key=${item.cmd}
|
|
119
|
+
onClick=${() => choose(item.cmd)}
|
|
120
|
+
>
|
|
121
|
+
<span>${item.label}</span>
|
|
122
|
+
<span class="cmd-palette-cmd">${item.cmd}</span>
|
|
123
|
+
</button>
|
|
124
|
+
`;
|
|
125
|
+
})}
|
|
126
|
+
`)
|
|
127
|
+
}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
`;
|
|
133
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileReader — shared markdown reader used by FilesView and MemoryView.
|
|
3
|
+
*
|
|
4
|
+
* Renders as a right-side slide-over (backdrop + panel) so it works on top
|
|
5
|
+
* of any list layout. Fetches /api/file?path=... itself whenever `path`
|
|
6
|
+
* changes, so callers only manage which file is open. Markdown renders via
|
|
7
|
+
* the global `marked` CDN lib with the same sanitizer the legacy Files view
|
|
8
|
+
* used; falls back to escaped <pre> when marked is unavailable.
|
|
9
|
+
*
|
|
10
|
+
* Props:
|
|
11
|
+
* path — project-relative file path to fetch (required; null hides)
|
|
12
|
+
* title — display name shown in the header (falls back to basename)
|
|
13
|
+
* onClose — called when the user dismisses the reader (backdrop, ×, Esc)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { html, useState, useEffect, useCallback } from '../preact.js';
|
|
17
|
+
import { showToast } from './shared.js';
|
|
18
|
+
|
|
19
|
+
// ---- Markdown helpers (moved from FilesView so both views share one copy) ----
|
|
20
|
+
function stripFrontmatter(md) {
|
|
21
|
+
if (!md.startsWith('---')) return md;
|
|
22
|
+
const end = md.indexOf('\n---', 3);
|
|
23
|
+
return end === -1 ? md : md.slice(end + 4).trimStart();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Minimal HTML sanitizer for rendered markdown. No DOMPurify dependency on the
|
|
27
|
+
// client, so we strip the dangerous primitives via regex after marked emits
|
|
28
|
+
// HTML: script/iframe/object/embed tags, inline event handlers, and
|
|
29
|
+
// javascript:/data: URLs in href/src. Markdown content comes from the project
|
|
30
|
+
// dir (semi-trusted) but may include attacker-controlled text checked into a
|
|
31
|
+
// repo, so we cannot trust raw HTML passthrough.
|
|
32
|
+
function sanitizeHtml(raw) {
|
|
33
|
+
return String(raw)
|
|
34
|
+
.replace(/<\s*(script|iframe|object|embed|link|meta|style)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
|
|
35
|
+
.replace(/<\s*(script|iframe|object|embed|link|meta|style)\b[^>]*\/?>/gi, '')
|
|
36
|
+
.replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
|
37
|
+
.replace(/(href|src|xlink:href)\s*=\s*(["'])\s*(?:javascript|data|vbscript):[^"']*\2/gi, '$1=$2#blocked$2');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function renderMd(md) {
|
|
41
|
+
const clean = stripFrontmatter(md);
|
|
42
|
+
if (typeof marked === 'undefined') {
|
|
43
|
+
return '<pre>' + clean.replace(/</g, '<') + '</pre>';
|
|
44
|
+
}
|
|
45
|
+
return sanitizeHtml(marked.parse(clean));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function FileReader({ path, title, onClose }) {
|
|
49
|
+
const [content, setContent] = useState({ html: null, loading: true, error: null });
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!path) return;
|
|
53
|
+
let cancelled = false;
|
|
54
|
+
setContent({ html: null, loading: true, error: null });
|
|
55
|
+
fetch('/api/file?path=' + encodeURIComponent(path))
|
|
56
|
+
.then(async resp => {
|
|
57
|
+
if (cancelled) return;
|
|
58
|
+
if (!resp.ok) {
|
|
59
|
+
const msg = resp.status === 404
|
|
60
|
+
? 'File not found: ' + path
|
|
61
|
+
: 'Failed to load file (HTTP ' + resp.status + ').';
|
|
62
|
+
setContent({ html: null, loading: false, error: msg });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const text = await resp.text();
|
|
66
|
+
if (!cancelled) setContent({ html: renderMd(text), loading: false, error: null });
|
|
67
|
+
})
|
|
68
|
+
.catch(() => {
|
|
69
|
+
if (!cancelled) setContent({ html: null, loading: false, error: 'Network error.' });
|
|
70
|
+
});
|
|
71
|
+
return () => { cancelled = true; };
|
|
72
|
+
}, [path]);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
function onKey(e) {
|
|
76
|
+
if (e.key === 'Escape' && onClose) onClose();
|
|
77
|
+
}
|
|
78
|
+
document.addEventListener('keydown', onKey);
|
|
79
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
80
|
+
}, [onClose]);
|
|
81
|
+
|
|
82
|
+
const copyPath = useCallback(() => {
|
|
83
|
+
navigator.clipboard.writeText(path).then(() => {
|
|
84
|
+
showToast('Path copied!');
|
|
85
|
+
}).catch(() => {});
|
|
86
|
+
}, [path]);
|
|
87
|
+
|
|
88
|
+
if (!path) return null;
|
|
89
|
+
const name = title || path.split('/').pop();
|
|
90
|
+
|
|
91
|
+
return html`
|
|
92
|
+
<div class="reader-backdrop" onClick=${onClose}></div>
|
|
93
|
+
<div class="reader-panel" role="dialog" aria-label=${name}>
|
|
94
|
+
<div class="reader-header">
|
|
95
|
+
<div class="reader-heading">
|
|
96
|
+
<div class="reader-title">${name}</div>
|
|
97
|
+
<div class="reader-path">${path}</div>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="reader-actions">
|
|
100
|
+
<button class="reader-copy" onClick=${copyPath}>Copy path</button>
|
|
101
|
+
<button class="reader-close" aria-label="Close reader" onClick=${onClose}>×</button>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="reader-body">
|
|
105
|
+
${content.loading && html`
|
|
106
|
+
<div class="skeleton reader-skel-line"></div>
|
|
107
|
+
<div class="skeleton reader-skel-block"></div>
|
|
108
|
+
`}
|
|
109
|
+
${content.error && html`<div class="reader-error">${content.error}</div>`}
|
|
110
|
+
${!content.loading && !content.error && content.html != null && html`
|
|
111
|
+
<div class="md-render" dangerouslySetInnerHTML=${{ __html: content.html }} />
|
|
112
|
+
`}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterChips — interactive filter chip component.
|
|
3
|
+
*
|
|
4
|
+
* Renders three groups of toggle chips (status / milestone / date).
|
|
5
|
+
* Clicking a chip writes the updated filter set into location.hash via
|
|
6
|
+
* applyFilters() from filter-state.js. The App.js hashchange listener then
|
|
7
|
+
* re-renders the active view with the new filters prop.
|
|
8
|
+
*
|
|
9
|
+
* Props:
|
|
10
|
+
* filters — route filter object { status, milestone, date }
|
|
11
|
+
* statusOptions — Array<{ value, label }>
|
|
12
|
+
* milestoneOptions — Array<{ value, label }>
|
|
13
|
+
* dateOptions — Array<{ value, label }>
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { html } from '../preact.js';
|
|
17
|
+
import { applyFilters } from '../filter-state.js';
|
|
18
|
+
|
|
19
|
+
/** @returns {string} — current view path segment from location.hash */
|
|
20
|
+
function viewPath() {
|
|
21
|
+
return location.hash.slice(1).split('?')[0] || 'overview';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A single group of chips for one filter dimension.
|
|
26
|
+
*
|
|
27
|
+
* @param {{ label: string, dimension: string, options: Array<{value,label}>, active: string, filters: object }} props
|
|
28
|
+
*/
|
|
29
|
+
function ChipGroup({ dimension, options, active, filters }) {
|
|
30
|
+
if (!options || options.length === 0) return null;
|
|
31
|
+
|
|
32
|
+
function handleClick(value) {
|
|
33
|
+
const next = Object.assign({}, filters, {
|
|
34
|
+
[dimension]: active === value ? '' : value,
|
|
35
|
+
});
|
|
36
|
+
location.hash = applyFilters(viewPath(), next);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return html`
|
|
40
|
+
<div class="filter-chip-group">
|
|
41
|
+
${options.map(opt => {
|
|
42
|
+
const isActive = opt.value === active;
|
|
43
|
+
return html`
|
|
44
|
+
<button
|
|
45
|
+
key=${opt.value}
|
|
46
|
+
class=${'filter-chip' + (isActive ? ' active' : '')}
|
|
47
|
+
onClick=${() => handleClick(opt.value)}
|
|
48
|
+
>${opt.label}</button>
|
|
49
|
+
`;
|
|
50
|
+
})}
|
|
51
|
+
</div>
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* FilterChips — interactive filter chip row with a clear button.
|
|
57
|
+
*/
|
|
58
|
+
export function FilterChips({ filters, statusOptions, milestoneOptions, dateOptions }) {
|
|
59
|
+
const f = filters || { status: '', milestone: '', date: '' };
|
|
60
|
+
|
|
61
|
+
const hasActive = f.status !== '' || f.milestone !== '' || f.date !== '';
|
|
62
|
+
|
|
63
|
+
function handleClear() {
|
|
64
|
+
location.hash = applyFilters(viewPath(), { status: '', milestone: '', date: '' });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return html`
|
|
68
|
+
<div class="filter-chips">
|
|
69
|
+
<${ChipGroup}
|
|
70
|
+
dimension="status"
|
|
71
|
+
options=${statusOptions}
|
|
72
|
+
active=${f.status}
|
|
73
|
+
filters=${f}
|
|
74
|
+
/>
|
|
75
|
+
<${ChipGroup}
|
|
76
|
+
dimension="milestone"
|
|
77
|
+
options=${milestoneOptions}
|
|
78
|
+
active=${f.milestone}
|
|
79
|
+
filters=${f}
|
|
80
|
+
/>
|
|
81
|
+
<${ChipGroup}
|
|
82
|
+
dimension="date"
|
|
83
|
+
options=${dateOptions}
|
|
84
|
+
active=${f.date}
|
|
85
|
+
filters=${f}
|
|
86
|
+
/>
|
|
87
|
+
<button
|
|
88
|
+
class="filter-chip-clear"
|
|
89
|
+
disabled=${!hasActive}
|
|
90
|
+
onClick=${hasActive ? handleClear : undefined}
|
|
91
|
+
>Clear</button>
|
|
92
|
+
</div>
|
|
93
|
+
`;
|
|
94
|
+
}
|