Files
xboct-flow/src/views/taskKanbanHtml.ts
T

400 lines
12 KiB
TypeScript

import * as vscode from "vscode";
import { TaskScope } from "@/model/taskLocation";
import { TASK_SCOPE_LABELS } from "@/ui/taskLabels";
const NONCE_LENGTH = 32;
/** Kanban board document (columns + HTML5 drag-and-drop). */
export function getTaskKanbanHtml(
webview: vscode.Webview,
nonce: string,
): string {
const csp = [
"default-src 'none'",
`style-src ${webview.cspSource} 'unsafe-inline'`,
`script-src 'nonce-${nonce}'`,
].join("; ");
const scopeProject = JSON.stringify(TaskScope.PROJECT);
const scopeGlobal = JSON.stringify(TaskScope.GLOBAL);
const labelProject = JSON.stringify(TASK_SCOPE_LABELS[TaskScope.PROJECT]);
const labelGlobal = JSON.stringify(TASK_SCOPE_LABELS[TaskScope.GLOBAL]);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-foreground);
background: var(--vscode-editor-background);
}
header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-widget-border));
}
header h1 {
margin: 0;
font-size: 1em;
font-weight: 600;
}
header .spacer { flex: 1; min-width: 8px; }
select, button {
font: inherit;
cursor: pointer;
padding: 4px 10px;
color: var(--vscode-input-foreground);
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border, transparent);
border-radius: 2px;
}
button {
color: var(--vscode-button-foreground);
background: var(--vscode-button-background);
border-color: transparent;
}
button.secondary {
background: var(--vscode-button-secondaryBackground, var(--vscode-input-background));
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
}
button.column-add {
padding: 0 6px;
font-size: 1em;
line-height: 1.2;
min-width: 24px;
}
button.eye-btn {
padding: 0 4px;
font-size: 0.85em;
line-height: 1.2;
min-width: 20px;
background: transparent;
border: 1px solid transparent;
color: var(--vscode-foreground);
opacity: 0.6;
cursor: pointer;
}
button.eye-btn:hover { opacity: 1; }
#visibility-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-widget-border));
font-size: 0.85em;
}
#visibility-bar .label {
opacity: 0.7;
margin-right: 4px;
}
#visibility-bar button {
font: inherit;
cursor: pointer;
padding: 2px 8px;
border: 1px solid var(--vscode-input-border, transparent);
border-radius: 2px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font-size: 0.85em;
}
#visibility-bar button.shown {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border-color: transparent;
}
#status-line {
font-size: 0.85em;
opacity: 0.85;
min-height: 1.2em;
}
#status-line.error { color: var(--vscode-errorForeground); }
#board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
padding: 10px;
height: calc(100% - 52px);
overflow: auto;
align-items: stretch;
}
.column {
display: flex;
flex-direction: column;
min-height: 200px;
border: 1px solid var(--vscode-panel-border, var(--vscode-widget-border));
border-radius: 4px;
background: var(--vscode-sideBar-background, transparent);
}
.column.drag-over {
outline: 2px solid var(--vscode-focusBorder);
}
.column-header {
padding: 8px 10px;
font-weight: 600;
font-size: 0.9em;
border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-widget-border));
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.column-header .title { flex: 1; }
.column-header .count {
opacity: 0.7;
font-weight: 400;
}
.column-body {
flex: 1;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
overflow: auto;
min-height: 80px;
}
.card {
padding: 8px 10px;
border-radius: 3px;
background: var(--vscode-editor-background);
border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border));
cursor: grab;
user-select: none;
}
.card:active { cursor: grabbing; }
.card .meta {
margin-top: 4px;
font-size: 0.85em;
opacity: 0.75;
}
</style>
</head>
<body>
<header>
<h1>Kanban</h1>
<label for="scopeFilter">Scope</label>
<select id="scopeFilter" title="Project / Global filter"></select>
<span class="spacer"></span>
<span id="status-line"></span>
<button id="refresh" type="button" class="secondary" title="Refresh">↻</button>
</header>
<div id="visibility-bar"></div>
<div id="board"></div>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
const SCOPE_PROJECT = ${scopeProject};
const SCOPE_GLOBAL = ${scopeGlobal};
const LABEL_PROJECT = ${labelProject};
const LABEL_GLOBAL = ${labelGlobal};
const boardEl = document.getElementById("board");
const statusLine = document.getElementById("status-line");
const refreshBtn = document.getElementById("refresh");
const scopeFilterEl = document.getElementById("scopeFilter");
const visibilityBarEl = document.getElementById("visibility-bar");
let board = { columns: [] };
let scopeFilter = "all";
let shownHiddenColumns = [];
let defaultHiddenStatuses = [];
let availableScopes = [];
let dragTaskId = null;
function post(message) {
vscode.postMessage(message);
}
function setStatus(text, isError) {
statusLine.textContent = text || "";
statusLine.classList.toggle("error", Boolean(isError));
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function scopeLabel(scope) {
if (scope === SCOPE_PROJECT) return LABEL_PROJECT;
if (scope === SCOPE_GLOBAL) return LABEL_GLOBAL;
return scope;
}
function renderScopeFilter() {
const options = [{ value: "all", label: "All" }];
for (const scope of availableScopes) {
options.push({ value: scope, label: scopeLabel(scope) });
}
scopeFilterEl.innerHTML = options
.map(
(opt) =>
'<option value="' +
escapeHtml(opt.value) +
'"' +
(opt.value === scopeFilter ? " selected" : "") +
">" +
escapeHtml(opt.label) +
"</option>",
)
.join("");
}
function renderVisibilityBar() {
visibilityBarEl.innerHTML = "";
if (defaultHiddenStatuses.length === 0) return;
const label = document.createElement("span");
label.className = "label";
label.textContent = "Columns:";
visibilityBarEl.appendChild(label);
for (const status of defaultHiddenStatuses) {
const btn = document.createElement("button");
btn.textContent = status;
btn.dataset.status = status;
if (shownHiddenColumns.indexOf(status) >= 0) {
btn.classList.add("shown");
}
btn.addEventListener("click", () => {
post({ variant: "toggleHiddenColumn", status: status });
});
visibilityBarEl.appendChild(btn);
}
}
function render() {
renderScopeFilter();
renderVisibilityBar();
boardEl.innerHTML = "";
for (const column of board.columns || []) {
const col = document.createElement("section");
col.className = "column";
col.dataset.status = column.status;
const header = document.createElement("div");
header.className = "column-header";
const title = document.createElement("span");
title.className = "title";
title.textContent = column.label;
const count = document.createElement("span");
count.className = "count";
count.textContent = String(column.tasks.length);
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "column-add secondary";
addBtn.title = "Create task in " + column.label;
addBtn.textContent = "+";
addBtn.addEventListener("click", () => {
post({ variant: "createTask", status: column.status });
});
header.appendChild(title);
header.appendChild(count);
header.appendChild(addBtn);
col.appendChild(header);
const body = document.createElement("div");
body.className = "column-body";
body.dataset.status = column.status;
body.addEventListener("dragover", (event) => {
event.preventDefault();
col.classList.add("drag-over");
});
body.addEventListener("dragleave", () => {
col.classList.remove("drag-over");
});
body.addEventListener("drop", (event) => {
event.preventDefault();
col.classList.remove("drag-over");
const id = event.dataTransfer.getData("text/task-id") || dragTaskId;
const status = column.status;
if (!id) return;
post({ variant: "moveTask", id: id, status: status });
dragTaskId = null;
});
for (const task of column.tasks) {
const card = document.createElement("div");
card.className = "card";
card.draggable = true;
card.dataset.id = task.id;
card.innerHTML =
"<div>" + escapeHtml(task.title) + "</div>" +
'<div class="meta">' + escapeHtml(task.priority) + "</div>";
card.addEventListener("dragstart", (event) => {
dragTaskId = task.id;
event.dataTransfer.setData("text/task-id", task.id);
event.dataTransfer.effectAllowed = "move";
});
card.addEventListener("dragend", () => {
dragTaskId = null;
document.querySelectorAll(".column.drag-over").forEach((el) => {
el.classList.remove("drag-over");
});
});
body.appendChild(card);
}
col.appendChild(body);
boardEl.appendChild(col);
}
}
refreshBtn.addEventListener("click", () => {
post({ variant: "refresh" });
});
scopeFilterEl.addEventListener("change", () => {
post({ variant: "setScopeFilter", scope: scopeFilterEl.value });
});
window.addEventListener("message", (event) => {
const message = event.data;
if (!message || typeof message !== "object") return;
if (message.variant === "state") {
board = message.board || { columns: [] };
scopeFilter = message.scopeFilter || "all";
shownHiddenColumns = message.shownHiddenColumns || [];
defaultHiddenStatuses = message.defaultHiddenStatuses || [];
availableScopes = message.availableScopes || [];
setStatus("");
render();
} else if (message.variant === "error") {
setStatus((message.error && message.error.message) || "Error", true);
}
});
post({ variant: "ready" });
</script>
</body>
</html>`;
}
export function createKanbanNonce(): string {
const alphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < NONCE_LENGTH; i += 1) {
result += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
}
return result;
}