CRITICAL:
- DocsTab XSS: DOMPurify sanitization on all {@html} output
- File RPC path traversal: guardPath() validates against project CWDs
HIGH:
- SSH injection: spawn /usr/bin/ssh via PTY args, no shell string
- Search XSS: strip HTML, highlight matches client-side with <mark>
- Terminal listener leak: cleanup functions stored + called in onDestroy
- FileBrowser race: request token, discard stale responses
- SearchOverlay race: same request token pattern
- App startup ordering: groups.list chains into active_group restore
- PtyClient timeout: 5-second auth timeout on connect()
- Rule 55: 6 {#if} patterns converted to style:display toggle
MEDIUM:
- Agent persistence: only persist NEW messages (lastPersistedIndex)
- Search errors: typed error response, "Invalid query" UI
- Health store wired: agent events call recordActivity/setProjectStatus
- index.ts SRP: split into 8 domain handler modules (298 lines)
- App.svelte: extracted workspace-store.svelte.ts
- rpc.ts: typed AppRpcHandle, removed `any`
LOW:
- CommandPalette listener wired in App.svelte
- Dead code removed (removeGroup, onDragStart, plugin loaded)
240 lines
7.3 KiB
Svelte
240 lines
7.3 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import DOMPurify from 'dompurify';
|
|
import { appRpc } from './rpc.ts';
|
|
|
|
interface Props {
|
|
cwd: string;
|
|
}
|
|
|
|
let { cwd }: Props = $props();
|
|
|
|
interface DocFile {
|
|
name: string;
|
|
path: string;
|
|
}
|
|
|
|
let files = $state<DocFile[]>([]);
|
|
let selectedFile = $state<DocFile | null>(null);
|
|
let content = $state('');
|
|
let renderedHtml = $state('');
|
|
let loading = $state(false);
|
|
|
|
// Fix #1: Configure DOMPurify with safe tag whitelist
|
|
const PURIFY_CONFIG = {
|
|
ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'p', 'a', 'strong', 'em', 'code', 'pre', 'ul', 'li', 'br'],
|
|
ALLOWED_ATTR: ['href', 'target', 'class'],
|
|
};
|
|
|
|
function expandHome(p: string): string {
|
|
if (p.startsWith('~/')) return p.replace('~', '/home/' + (typeof process !== 'undefined' ? process.env.USER : 'user'));
|
|
return p;
|
|
}
|
|
|
|
async function loadFiles() {
|
|
try {
|
|
const resolved = expandHome(cwd);
|
|
const res = await appRpc.request['files.list']({ path: resolved });
|
|
if (res.error) return;
|
|
files = res.entries
|
|
.filter((e: { name: string; type: string }) => e.type === 'file' && e.name.endsWith('.md'))
|
|
.map((e: { name: string }) => ({ name: e.name, path: `${resolved}/${e.name}` }));
|
|
} catch (err) {
|
|
console.error('[DocsTab] list error:', err);
|
|
}
|
|
}
|
|
|
|
async function selectFile(file: DocFile) {
|
|
selectedFile = file;
|
|
loading = true;
|
|
try {
|
|
const res = await appRpc.request['files.read']({ path: file.path });
|
|
if (res.error || !res.content) {
|
|
content = '';
|
|
renderedHtml = DOMPurify.sanitize(
|
|
`<p class="doc-error">${escapeHtml(res.error ?? 'Empty file')}</p>`,
|
|
PURIFY_CONFIG,
|
|
);
|
|
} else {
|
|
content = res.content;
|
|
renderedHtml = DOMPurify.sanitize(renderMarkdown(content), PURIFY_CONFIG);
|
|
}
|
|
} catch (err) {
|
|
console.error('[DocsTab] read error:', err);
|
|
renderedHtml = DOMPurify.sanitize('<p class="doc-error">Failed to read file</p>', PURIFY_CONFIG);
|
|
}
|
|
loading = false;
|
|
}
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
/** Simple markdown-to-HTML. All output is sanitized by DOMPurify before rendering. */
|
|
function renderMarkdown(md: string): string {
|
|
let html = md
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
|
|
// Code blocks
|
|
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) =>
|
|
`<pre class="doc-code"><code>${code.trim()}</code></pre>`
|
|
);
|
|
// Inline code
|
|
html = html.replace(/`([^`]+)`/g, '<code class="doc-inline-code">$1</code>');
|
|
// Headers
|
|
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
|
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
|
// Bold + italic
|
|
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
// Links
|
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
|
|
// Unordered lists
|
|
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
|
|
html = html.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>');
|
|
html = html.replace(/<\/ul>\s*<ul>/g, '');
|
|
// Paragraphs (lines not already wrapped)
|
|
html = html.replace(/^(?!<[huplo])((?!\s*$).+)$/gm, '<p>$1</p>');
|
|
|
|
return html;
|
|
}
|
|
|
|
onMount(() => { loadFiles(); });
|
|
</script>
|
|
|
|
<div class="docs-tab">
|
|
<div class="docs-sidebar">
|
|
<div class="docs-header">Markdown files</div>
|
|
{#if files.length === 0}
|
|
<div class="docs-empty">No .md files in project</div>
|
|
{:else}
|
|
{#each files as file}
|
|
<button
|
|
class="docs-file"
|
|
class:active={selectedFile?.path === file.path}
|
|
onclick={() => selectFile(file)}
|
|
>{file.name}</button>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="docs-content">
|
|
{#if loading}
|
|
<div class="docs-placeholder">Loading...</div>
|
|
{:else if !selectedFile}
|
|
<div class="docs-placeholder">Select a file to view</div>
|
|
{:else}
|
|
<div class="docs-rendered">
|
|
{@html renderedHtml}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.docs-tab { display: flex; height: 100%; overflow: hidden; }
|
|
|
|
.docs-sidebar {
|
|
width: 10rem;
|
|
flex-shrink: 0;
|
|
background: var(--ctp-mantle);
|
|
border-right: 1px solid var(--ctp-surface0);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.docs-header {
|
|
padding: 0.375rem 0.5rem;
|
|
font-size: 0.625rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--ctp-overlay0);
|
|
border-bottom: 1px solid var(--ctp-surface0);
|
|
}
|
|
|
|
.docs-empty {
|
|
padding: 1rem 0.5rem;
|
|
font-size: 0.75rem;
|
|
color: var(--ctp-overlay0);
|
|
font-style: italic;
|
|
text-align: center;
|
|
}
|
|
|
|
.docs-file {
|
|
display: block;
|
|
width: 100%;
|
|
text-align: left;
|
|
padding: 0.375rem 0.5rem;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--ctp-subtext0);
|
|
font-size: 0.75rem;
|
|
cursor: pointer;
|
|
font-family: var(--term-font-family);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
transition: background 0.1s, color 0.1s;
|
|
}
|
|
|
|
.docs-file:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
|
.docs-file.active { background: color-mix(in srgb, var(--ctp-blue) 12%, transparent); color: var(--ctp-blue); }
|
|
|
|
.docs-content { flex: 1; overflow-y: auto; padding: 0.75rem; min-width: 0; }
|
|
|
|
.docs-placeholder {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
color: var(--ctp-overlay0);
|
|
font-size: 0.8125rem;
|
|
font-style: italic;
|
|
}
|
|
|
|
.docs-rendered {
|
|
font-family: var(--ui-font-family);
|
|
font-size: 0.8125rem;
|
|
color: var(--ctp-text);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.docs-rendered :global(h1) { font-size: 1.25rem; color: var(--ctp-text); margin: 0.75rem 0 0.375rem; }
|
|
.docs-rendered :global(h2) { font-size: 1.0625rem; color: var(--ctp-text); margin: 0.625rem 0 0.3rem; }
|
|
.docs-rendered :global(h3) { font-size: 0.9375rem; color: var(--ctp-text); margin: 0.5rem 0 0.25rem; }
|
|
.docs-rendered :global(p) { margin: 0.25rem 0; }
|
|
.docs-rendered :global(a) { color: var(--ctp-blue); text-decoration: none; }
|
|
.docs-rendered :global(a:hover) { text-decoration: underline; }
|
|
.docs-rendered :global(strong) { color: var(--ctp-text); }
|
|
.docs-rendered :global(ul) { padding-left: 1.25rem; margin: 0.25rem 0; }
|
|
.docs-rendered :global(li) { margin: 0.125rem 0; }
|
|
|
|
.docs-rendered :global(.doc-code) {
|
|
background: var(--ctp-surface0);
|
|
border: 1px solid var(--ctp-surface1);
|
|
border-radius: 0.25rem;
|
|
padding: 0.5rem 0.625rem;
|
|
overflow-x: auto;
|
|
font-family: var(--term-font-family);
|
|
font-size: 0.75rem;
|
|
line-height: 1.5;
|
|
margin: 0.375rem 0;
|
|
}
|
|
|
|
.docs-rendered :global(.doc-inline-code) {
|
|
background: var(--ctp-surface0);
|
|
padding: 0.1rem 0.25rem;
|
|
border-radius: 0.2rem;
|
|
font-family: var(--term-font-family);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.docs-rendered :global(.doc-error) { color: var(--ctp-red); font-style: italic; }
|
|
</style>
|