@hanzlaa/rcode 4.1.1 → 4.3.0

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.
Files changed (105) hide show
  1. package/AGENTS.md +1 -1
  2. package/CONTRIBUTING.md +3 -0
  3. package/README.md +3 -0
  4. package/cli/agent.js +3 -1
  5. package/cli/index.js +29 -0
  6. package/cli/install.js +233 -15
  7. package/cli/lib/config.cjs +4 -2
  8. package/cli/lib/fsutil.cjs +13 -2
  9. package/cli/lib/homedir.cjs +21 -0
  10. package/cli/lib/schemas.cjs +6 -1
  11. package/cli/nuke.js +13 -8
  12. package/cli/postinstall.js +14 -4
  13. package/cli/rcode-slash-router.cjs +118 -0
  14. package/cli/uninstall.js +59 -1
  15. package/cli/update.js +10 -5
  16. package/cli/workflow.js +3 -1
  17. package/dist/rcode.js +241 -227
  18. package/package.json +1 -1
  19. package/rcode/bin/rcode-tools.cjs +15 -6
  20. package/rcode/commands/scaffold-project.md +2 -2
  21. package/rcode/skills/actions/2-plan/rcode-create-epics-and-stories/steps/step-04-final-validation.md +1 -1
  22. package/rcode/skills/actions/2-plan/rcode-create-milestone/steps/README.md +2 -2
  23. package/rcode/skills/actions/2-plan/rcode-create-milestone/steps/step-09-state-sync.md +1 -1
  24. package/rcode/skills/actions/4-implementation/rcode-code-review/steps/step-02-review.md +1 -1
  25. package/rcode/skills/actions/4-implementation/rcode-git-flow/SKILL.md +1 -1
  26. package/rcode/skills/actions/4-implementation/rcode-scaffold-project/SKILL.md +39 -12
  27. package/rcode/skills/actions/4-implementation/rcode-scaffold-project/steps/step-01-target.md +18 -3
  28. package/rcode/skills/actions/4-implementation/rcode-scaffold-project/steps/step-02-safety.md +27 -3
  29. package/rcode/skills/actions/4-implementation/rcode-scaffold-project/steps/step-03-brownfield.md +57 -0
  30. package/rcode/skills/actions/4-implementation/rcode-scaffold-project/steps/step-03-clone.md +4 -1
  31. package/rcode/skills/actions/4-implementation/rcode-scaffold-project/steps/step-04-post-setup.md +15 -1
  32. package/rcode/skills/actions/4-implementation/rcode-trim/SKILL.md +1 -1
  33. package/rcode/workflows/audit-milestone.md +1 -1
  34. package/rcode/workflows/discuss-phase.md +1 -1
  35. package/rcode/workflows/execute-milestone.md +1 -1
  36. package/rcode/workflows/execute-regression-gates.md +3 -0
  37. package/rcode/workflows/execute-sprint.md +27 -1
  38. package/rcode/workflows/execute-waves.md +6 -0
  39. package/rcode/workflows/execute.md +13 -3
  40. package/rcode/workflows/new-milestone.md +2 -2
  41. package/rcode/workflows/new-project.md +4 -0
  42. package/rcode/workflows/plan-research-validation.md +1 -1
  43. package/rcode/workflows/plan-spawn-planner.md +2 -2
  44. package/rcode/workflows/plan.md +34 -15
  45. package/rcode/workflows/review.md +2 -0
  46. package/rcode/workflows/scaffold-project.md +5 -1
  47. package/rcode/workflows/session-report.md +1 -1
  48. package/rcode/workflows/ship.md +39 -0
  49. package/rcode/workflows/sprint-planning.md +27 -0
  50. package/rcode/workflows/status.md +3 -3
  51. package/server/dashboard.js +26 -7
  52. package/server/lib/api.js +62 -4
  53. package/server/lib/html/client/agents-data.js +22 -18
  54. package/server/lib/html/client/app.js +3 -0
  55. package/server/lib/html/client/components/AgentCard.js +127 -0
  56. package/server/lib/html/client/components/App.js +104 -39
  57. package/server/lib/html/client/components/CommandPalette.js +133 -0
  58. package/server/lib/html/client/components/FileReader.js +116 -0
  59. package/server/lib/html/client/components/FilterChips.js +94 -0
  60. package/server/lib/html/client/components/NotifyCenter.js +117 -0
  61. package/server/lib/html/client/components/OrchPanel.js +80 -52
  62. package/server/lib/html/client/components/PhaseGraph.js +300 -0
  63. package/server/lib/html/client/components/RejectDialog.js +78 -0
  64. package/server/lib/html/client/components/RunnerPicker.js +190 -0
  65. package/server/lib/html/client/components/Sidebar.js +106 -61
  66. package/server/lib/html/client/components/StatusSummaryBar.js +76 -0
  67. package/server/lib/html/client/components/TaskPipeline.js +83 -0
  68. package/server/lib/html/client/components/Topbar.js +86 -39
  69. package/server/lib/html/client/components/dashboard/Blockers.js +57 -0
  70. package/server/lib/html/client/components/dashboard/CompletedTasks.js +47 -0
  71. package/server/lib/html/client/components/dashboard/CurrentPhase.js +107 -0
  72. package/server/lib/html/client/components/dashboard/InProgress.js +72 -0
  73. package/server/lib/html/client/components/dashboard/ProgressDonut.js +101 -0
  74. package/server/lib/html/client/components/dashboard/ProgressTimeline.js +101 -0
  75. package/server/lib/html/client/components/dashboard/ProjectHealth.js +80 -0
  76. package/server/lib/html/client/components/dashboard/RecentDecisions.js +57 -0
  77. package/server/lib/html/client/components/dashboard/Timeline.js +143 -0
  78. package/server/lib/html/client/components/shared.js +47 -11
  79. package/server/lib/html/client/filter-state.js +72 -0
  80. package/server/lib/html/client/icons-client.js +7 -0
  81. package/server/lib/html/client/notify.js +75 -0
  82. package/server/lib/html/client/orchestrator.js +168 -41
  83. package/server/lib/html/client/preact.js +13 -8
  84. package/server/lib/html/client/store.js +70 -6
  85. package/server/lib/html/client/util.js +78 -0
  86. package/server/lib/html/client/vendor/htm.js +1 -0
  87. package/server/lib/html/client/vendor/preact-hooks.js +2 -0
  88. package/server/lib/html/client/vendor/preact.js +2 -0
  89. package/server/lib/html/client/views/AgentsView.js +144 -51
  90. package/server/lib/html/client/views/FilesView.js +20 -103
  91. package/server/lib/html/client/views/KanbanView.js +40 -21
  92. package/server/lib/html/client/views/MemoryView.js +26 -9
  93. package/server/lib/html/client/views/MilestonesView.js +4 -4
  94. package/server/lib/html/client/views/OrchestrationView.js +154 -19
  95. package/server/lib/html/client/views/OverviewView.js +47 -239
  96. package/server/lib/html/client/views/PhasesView.js +50 -6
  97. package/server/lib/html/client/views/RoadmapView.js +6 -3
  98. package/server/lib/html/client/views/SprintsView.js +50 -6
  99. package/server/lib/html/client/views/TasksView.js +4 -3
  100. package/server/lib/html/client.js +21 -4
  101. package/server/lib/html/css.js +2761 -8
  102. package/server/lib/html/icons.js +7 -0
  103. package/server/lib/html/shell.js +10 -3
  104. package/server/lib/scanner.js +376 -39
  105. package/server/orchestrator.js +329 -5
@@ -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
- const slash = raw.indexOf('/');
61
- const view = slash === -1 ? raw : raw.slice(0, slash);
62
- const subId = slash === -1 ? null : raw.slice(slash + 1);
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
- return { view: resolvedView, subId };
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
- const s = 'display:flex;align-items:center;gap:var(--space-2);'
72
- + 'padding:var(--space-2) var(--space-4);background:var(--red,#eb5757);'
73
- + 'color:#fff;font-size:var(--text-sm);font-weight:600;';
74
- return html`<div style=${s}>⚠ Dashboard offline retrying every 30s…</div>`;
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 style=${bar}>
89
- <span style=${dot}></span>
90
- <span style="overflow:hidden;text-overflow:ellipsis;" title=${path}>${path}</span>
91
- <span style="margin-left:auto;">rcode v${version || '?'}</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() { setRoute(parseHash()); }
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('sidebar-open');
137
- if (backdrop) backdrop.classList.toggle('active', open);
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 = { refreshing: false, offline: false, lastRefresh: Date.now() };
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 || newState.raw.phases || [],
218
+ phases: d.phases || newState.phaseTree || newState.raw.phases || [],
169
219
  milestone: newState.raw.milestone || '',
170
- currentPhase: newState.raw.current_phase || null,
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" style="grid-template-rows:44px 1fr auto;">
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
- ${PreactView ? html`<${PreactView} subId=${subId} />` : null}
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, '&lt;') + '</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
+ }