Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 175 additions & 4 deletions static/panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -1239,17 +1239,188 @@ function _kanbanRenderSidebar(columns){
}


/**
* Render inline markdown (bold, italic, code, links, strikethrough).
* Input is already HTML-escaped.
*/
function _kanbanRenderMarkdownInline(escaped){
return String(escaped || '')
.replace(/~~([^~\n]+)~~/g, (_m, text) => `<del>${text}</del>`)
.replace(/`([^`\n]+)`/g, (_m, code) => `<code>${code}</code>`)
.replace(/\*\*([^*\n]+)\*\*/g, (_m, text) => `<strong>${text}</strong>`)
.replace(/(^|[^*])\*([^*\n]+)\*/g, (_m, prefix, text) => `${prefix}<em>${text}</em>`)
.replace(/(^|[^*a-zA-Z0-9])\*([^*\n]+)\*/g, (_m, prefix, text) => `${prefix}<em>${text}</em>`)
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g, (_m, text, href) => `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`);
}

/**
* Render full markdown block content: headings, code blocks, lists, tables,
* task lists, blockquotes, horizontal rules, paragraphs + inline formatting.
*/
function _kanbanRenderMarkdown(source){
if (!source) return '';
return `<div class="hermes-kanban-md">${esc(source).split(/\r?\n/).map(line => line.trim() ? `<p>${_kanbanRenderMarkdownInline(line)}</p>` : '').join('')}</div>`;
const lines = esc(source).split(/\r?\n/);
const out = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();

// ── Code block ──
if (/^```/.test(trimmed)) {
const lang = trimmed.slice(3).trim();
const codeLines = [];
i++;
while (i < lines.length && !/^```/.test(lines[i].trim())) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
const codeHtml = codeLines.join('\n');
out.push(lang
? `<pre class="hermes-kanban-code"><code class="language-${_kanbanRenderMarkdownInline(lang)}">${codeHtml}</code></pre>`
: `<pre class="hermes-kanban-code"><code>${codeHtml}</code></pre>`);
continue;
}

// ── Horizontal rule ──
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmed)) {
out.push('<hr>');
i++;
continue;
}

// ── Heading ──
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
out.push(`<h${level}>${_kanbanRenderMarkdownInline(headingMatch[2])}</h${level}>`);
i++;
continue;
}

// ── Blockquote ──
if (/^>\s?/.test(trimmed)) {
const quoteLines = [];
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
quoteLines.push(lines[i].trim().replace(/^>\s?/, ''));
i++;
}
out.push(`<blockquote>${_kanbanRenderMarkdownInline(quoteLines.join('<br>'))}</blockquote>`);
continue;
}

// ── Table row ──
if (/^\|.+\|$/.test(trimmed)) {
const tableRows = [];
const tableAligns = [];
while (i < lines.length && /^\|.+\|$/.test(lines[i].trim())) {
const row = lines[i].trim();
// Detect alignment separator row
if (/^\|[\s:]*-{3,}[\s:]*\|/.test(row)) {
const cells = row.split('|').filter(c => c.trim().length > 0);
cells.forEach(c => {
const t = c.trim();
if (t.startsWith(':') && t.endsWith(':')) tableAligns.push('center');
else if (t.endsWith(':')) tableAligns.push('right');
else tableAligns.push('left');
});
} else {
const cells = row.split('|').filter(c => c.trim().length > 0);
tableRows.push(cells.map((c, ci) => {
const align = tableAligns[ci] ? ` style="text-align:${tableAligns[ci]}"` : '';
return `<td${align}>${_kanbanRenderMarkdownInline(c.trim())}</td>`;
}).join(''));
}
i++;
}
if (tableRows.length) {
out.push(`<table><tbody>${tableRows.map(r => `<tr>${r}</tr>`).join('')}</tbody></table>`);
}
continue;
}

// ── Task list item ──
const taskMatch = trimmed.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/);
if (taskMatch) {
const checked = taskMatch[1] !== ' ';
const text = taskMatch[2];
const items = [];
items.push(`<li class="hermes-kanban-task${checked ? ' checked' : ''}"><input type="checkbox"${checked ? ' checked' : ''} disabled> ${_kanbanRenderMarkdownInline(text)}</li>`);
i++;
// Collect continuation items
while (i < lines.length) {
const next = lines[i].trim();
const nextTask = next.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/);
const nextLi = next.match(/^[-*+]\s+(.+)$/);
if (nextTask) {
const c = nextTask[1] !== ' ';
items.push(`<li class="hermes-kanban-task${c ? ' checked' : ''}"><input type="checkbox"${c ? ' checked' : ''} disabled> ${_kanbanRenderMarkdownInline(nextTask[2])}</li>`);
i++;
} else if (nextLi) {
items.push(`<li>${_kanbanRenderMarkdownInline(nextLi[1])}</li>`);
i++;
} else {
break;
}
}
out.push(`<ul>${items.join('')}</ul>`);
continue;
}

// ── Unordered list item ──
const ulMatch = trimmed.match(/^[-*+]\s+(.+)$/);
if (ulMatch) {
const items = [];
items.push(`<li>${_kanbanRenderMarkdownInline(ulMatch[1])}</li>`);
i++;
while (i < lines.length) {
const next = lines[i].trim();
const nextUl = next.match(/^[-*+]\s+(.+)$/);
const nextTask = next.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/);
if (nextTask) break; // let task list handler get it
if (nextUl) {
items.push(`<li>${_kanbanRenderMarkdownInline(nextUl[1])}</li>`);
i++;
} else {
break;
}
}
out.push(`<ul>${items.join('')}</ul>`);
continue;
}

// ── Ordered list item ──
const olMatch = trimmed.match(/^\d+\.\s+(.+)$/);
if (olMatch) {
const items = [];
items.push(`<li>${_kanbanRenderMarkdownInline(olMatch[1])}</li>`);
i++;
while (i < lines.length) {
const next = lines[i].trim();
const nextOl = next.match(/^\d+\.\s+(.+)$/);
if (nextOl) {
items.push(`<li>${_kanbanRenderMarkdownInline(nextOl[1])}</li>`);
i++;
} else {
break;
}
}
out.push(`<ol>${items.join('')}</ol>`);
continue;
}

// ── Empty line ──
if (!trimmed) {
out.push('');
i++;
continue;
}

// ── Paragraph ──
out.push(`<p>${_kanbanRenderMarkdownInline(trimmed)}</p>`);
i++;
}
return `<div class="hermes-kanban-md">${out.join('\n')}</div>`;
}

function _kanbanFormatDuration(seconds){
Expand Down Expand Up @@ -1816,7 +1987,7 @@ function _kanbanCommentHtml(comment){
const by = comment.author || comment.created_by || comment.actor || '';
const at = _kanbanFormatTimestamp(comment.created_at || comment.ts || '');
return `<div class="kanban-detail-row">
<div class="kanban-detail-row-main">${esc(body)}</div>
<div class="kanban-detail-row-main">${_kanbanRenderMarkdown(body)}</div>
<div class="kanban-detail-row-meta">${esc([by, at].filter(Boolean).join(' · '))}</div>
</div>`;
}
Expand Down Expand Up @@ -2368,7 +2539,7 @@ function _kanbanRenderTaskDetail(data){
<div class="kanban-task-preview-title">${esc(title)}</div>
<button class="btn secondary kanban-edit-btn" onclick="openKanbanEdit('${esc(task.id)}')" data-i18n="kanban_edit_task" title="${esc(t('kanban_edit_task') || 'Edit task')}">${esc(t('kanban_edit_task') || 'Edit task')}</button>
</div>
<div class="kanban-task-preview-body">${esc(body)}</div>
<div class="kanban-task-preview-body">${_kanbanRenderMarkdown(body)}</div>
${meta.length ? `<div class="kanban-meta">${esc(meta.join(' · '))}</div>` : ''}
<div class="kanban-status-actions">${statusButtons}</div>
<div class="kanban-detail-grid">
Expand Down
6 changes: 3 additions & 3 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -4167,7 +4167,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-run-dispatch-btn:hover{
background:color-mix(in srgb,var(--accent,#FFD700) 24%,transparent);
}
.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;white-space:pre-wrap;margin-bottom:6px;}
.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;margin-bottom:6px;}
.kanban-status-actions{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 4px;}
.kanban-status-actions .btn{font-size:11px;padding:4px 8px;}
/* Generic styled buttons used throughout the Kanban panel. The Kanban PR
Expand Down Expand Up @@ -4237,7 +4237,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-detail-section h3{font-size:12px;font-weight:650;color:var(--text);margin:0 0 8px;}
.kanban-detail-row{padding:8px 0;border-top:1px solid var(--border);}
.kanban-detail-row:first-of-type{border-top:0;padding-top:0;}
.kanban-detail-row-main{font-size:12px;color:var(--text);line-height:1.45;white-space:pre-wrap;}
.kanban-detail-row-main{font-size:12px;color:var(--text);line-height:1.45;}.kanban-detail-row-main .hermes-kanban-md p:last-child{margin-bottom:0;}
.kanban-detail-row-meta{font-size:10px;color:var(--muted);margin-top:4px;}
.kanban-detail-pre{font-size:11px;line-height:1.4;white-space:pre-wrap;word-break:break-word;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:6px;margin:6px 0 0;color:var(--muted);}
.kanban-detail-empty{font-size:12px;color:var(--muted);}
Expand Down Expand Up @@ -4269,7 +4269,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-card-stale-amber{border-color:rgba(245,197,66,.55)}
.kanban-card-stale-red{border-color:rgba(255,95,95,.65)}
.kanban-column.drop-target{outline:2px solid var(--accent);outline-offset:-2px}
.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em}
.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em}.hermes-kanban-md h1,.hermes-kanban-md h2,.hermes-kanban-md h3,.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{margin:10px 0 4px;font-weight:650;color:var(--text)}.hermes-kanban-md h1{font-size:15px}.hermes-kanban-md h2{font-size:14px}.hermes-kanban-md h3{font-size:13px}.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{font-size:12px}.hermes-kanban-md ul,.hermes-kanban-md ol{margin:4px 0;padding-left:20px}.hermes-kanban-md li{margin:2px 0}.hermes-kanban-md li.checked{opacity:.6}.hermes-kanban-md li input[type=checkbox]{margin:0 4px 0 0;vertical-align:middle}.hermes-kanban-md table{border-collapse:collapse;margin:6px 0;font-size:11px;width:100%}.hermes-kanban-md td{border:1px solid var(--border);padding:4px 6px;vertical-align:top}.hermes-kanban-md blockquote{margin:4px 0;padding:2px 8px;border-left:3px solid var(--accent);color:var(--muted);font-size:12px}.hermes-kanban-md pre{background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:8px;margin:6px 0;overflow-x:auto;font-size:11px;line-height:1.4;color:var(--text)}.hermes-kanban-md hr{border:none;border-top:1px solid var(--border);margin:8px 0}

@media (max-width: 640px){
.kanban-board{scroll-snap-type:x mandatory;}
Expand Down