feat: update kanban view, dashboard view, add backlog status

This commit is contained in:
2026-07-15 03:14:03 +05:00
parent 84f64af62d
commit e46437a512
16 changed files with 451 additions and 141 deletions
+12
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import { err } from "neverthrow";
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
import { appError, AppErrorVariant } from "@/error";
import { TaskStatus } from "@/model/taskStatus";
import { FsTaskRepository } from "@/storage/FsTaskRepository";
import { createTask } from "./createTask";
@@ -58,4 +59,15 @@ describe("createTask", () => {
});
expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER)));
});
it("creates with explicit status", async () => {
const result = await createTask(repo, {
folderPath: folder,
title: "In column",
status: TaskStatus.IN_PROGRESS,
});
expect(result.isOk()).toBe(true);
if (result.isErr()) return;
expect(result.value.status).toBe(TaskStatus.IN_PROGRESS);
});
});
+11 -2
View File
@@ -2,12 +2,14 @@ import { errAsync, ResultAsync } from "neverthrow";
import { appError, AppError, AppErrorVariant } from "@/error";
import { Task } from "@/model/task";
import { TaskPriority } from "@/model/taskPriority";
import { TaskStatus } from "@/model/taskStatus";
import { isTaskStatus, TaskStatus } from "@/model/taskStatus";
import { ITaskRepository } from "@/ports";
export type CreateTaskInput = {
folderPath?: string;
title?: string;
/** Defaults to TODO when omitted. */
status?: TaskStatus;
};
export function createTask(
@@ -23,11 +25,18 @@ export function createTask(
return errAsync(appError(AppErrorVariant.EMPTY_TITLE));
}
const status =
input.status === undefined
? TaskStatus.TODO
: isTaskStatus(input.status)
? input.status
: TaskStatus.TODO;
return ResultAsync.fromSafePromise(
repo.create(input.folderPath, {
title,
description: "",
status: TaskStatus.TODO,
status,
priority: TaskPriority.MEDIUM,
tags: [],
assignee: "",
+3 -3
View File
@@ -1,4 +1,4 @@
import { TaskLocation } from "@/model/taskLocation";
import { TaskLocation, TaskScope } from "@/model/taskLocation";
import { IConfigProvider } from "@/ports";
/** Active task folders from config (project first, then global). */
@@ -7,12 +7,12 @@ export function resolveTaskLocations(config: IConfigProvider): TaskLocation[] {
const projectPath = config.getProjectTaskPath();
if (projectPath) {
locations.push({ scope: "project", folderPath: projectPath });
locations.push({ scope: TaskScope.PROJECT, folderPath: projectPath });
}
const globalPath = config.getGlobalTaskPath();
if (globalPath) {
locations.push({ scope: "global", folderPath: globalPath });
locations.push({ scope: TaskScope.GLOBAL, folderPath: globalPath });
}
return locations;
+18 -25
View File
@@ -9,25 +9,23 @@ import {
describe("parseDashboardInboundMessage", () => {
it("parses ready", () => {
expect(parseDashboardInboundMessage({ type: "ready" })).toEqual(
ok({ type: "ready" } satisfies DashboardInboundMessage),
expect(parseDashboardInboundMessage({ variant: "ready" })).toEqual(
ok({ variant: "ready" } satisfies DashboardInboundMessage),
);
});
it("parses selectTask", () => {
expect(
parseDashboardInboundMessage({ type: "selectTask", id: "abc" }),
).toEqual(ok({ type: "selectTask", id: "abc" }));
parseDashboardInboundMessage({ variant: "selectTask", id: "abc" }),
).toEqual(ok({ variant: "selectTask", id: "abc" }));
});
it("parses setFilter", () => {
const raw = {
type: "setFilter",
const raw = { variant: "setFilter",
filter: { statuses: ["todo"], query: "login" },
};
expect(parseDashboardInboundMessage(raw)).toEqual(
ok({
type: "setFilter",
ok({ variant: "setFilter",
filter: { statuses: ["todo"], query: "login" },
}),
);
@@ -35,23 +33,20 @@ describe("parseDashboardInboundMessage", () => {
it("parses setGroupBy", () => {
expect(
parseDashboardInboundMessage({
type: "setGroupBy",
parseDashboardInboundMessage({ variant: "setGroupBy",
groupBy: GroupBy.STATUS,
}),
).toEqual(ok({ type: "setGroupBy", groupBy: GroupBy.STATUS }));
).toEqual(ok({ variant: "setGroupBy", groupBy: GroupBy.STATUS }));
});
it("parses saveDescription", () => {
expect(
parseDashboardInboundMessage({
type: "saveDescription",
parseDashboardInboundMessage({ variant: "saveDescription",
id: "abc",
description: "updated body",
}),
).toEqual(
ok({
type: "saveDescription",
ok({ variant: "saveDescription",
id: "abc",
description: "updated body",
}),
@@ -59,14 +54,14 @@ describe("parseDashboardInboundMessage", () => {
});
it("parses refresh", () => {
expect(parseDashboardInboundMessage({ type: "refresh" })).toEqual(
ok({ type: "refresh" }),
expect(parseDashboardInboundMessage({ variant: "refresh" })).toEqual(
ok({ variant: "refresh" }),
);
});
it("parses createTask", () => {
expect(parseDashboardInboundMessage({ type: "createTask" })).toEqual(
ok({ type: "createTask" } satisfies DashboardInboundMessage),
expect(parseDashboardInboundMessage({ variant: "createTask" })).toEqual(
ok({ variant: "createTask" } satisfies DashboardInboundMessage),
);
});
@@ -78,22 +73,21 @@ describe("parseDashboardInboundMessage", () => {
});
it("rejects unknown type", () => {
const result = parseDashboardInboundMessage({ type: "explode" });
const result = parseDashboardInboundMessage({ variant: "explode" });
expect(result.isErr()).toBe(true);
if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
});
it("rejects selectTask without id", () => {
const result = parseDashboardInboundMessage({ type: "selectTask" });
const result = parseDashboardInboundMessage({ variant: "selectTask" });
expect(result.isErr()).toBe(true);
if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
});
it("rejects setGroupBy with invalid groupBy", () => {
const result = parseDashboardInboundMessage({
type: "setGroupBy",
const result = parseDashboardInboundMessage({ variant: "setGroupBy",
groupBy: "__not_a_group_by__",
});
expect(result.isErr()).toBe(true);
@@ -102,8 +96,7 @@ describe("parseDashboardInboundMessage", () => {
});
it("rejects saveDescription with non-string description", () => {
const result = parseDashboardInboundMessage({
type: "saveDescription",
const result = parseDashboardInboundMessage({ variant: "saveDescription",
id: "abc",
description: 42,
});
+21 -23
View File
@@ -4,13 +4,13 @@ import { TaskFilter } from "./filterTasks";
import { GroupBy, isGroupBy } from "./groupBy";
export type DashboardInboundMessage =
| { type: "ready" }
| { type: "selectTask"; id: string }
| { type: "setFilter"; filter: TaskFilter }
| { type: "setGroupBy"; groupBy: GroupBy }
| { type: "saveDescription"; id: string; description: string }
| { type: "refresh" }
| { type: "createTask" };
| { variant: "ready" }
| { variant: "selectTask"; id: string }
| { variant: "setFilter"; filter: TaskFilter }
| { variant: "setGroupBy"; groupBy: GroupBy }
| { variant: "saveDescription"; id: string; description: string }
| { variant: "refresh" }
| { variant: "createTask" };
function invalid(detail?: string): Result<DashboardInboundMessage, AppError> {
return err(
@@ -31,56 +31,54 @@ export function parseDashboardInboundMessage(
return invalid();
}
const type = raw.type;
if (typeof type !== "string") {
const variant = raw.variant;
if (typeof variant !== "string") {
return invalid();
}
switch (type) {
switch (variant) {
case "ready":
case "refresh":
case "createTask":
return ok({ type });
return ok({ variant });
case "selectTask": {
if (typeof raw.id !== "string" || raw.id.length === 0) {
return invalid(type);
return invalid(variant);
}
return ok({ type: "selectTask", id: raw.id });
return ok({ variant: "selectTask", id: raw.id });
}
case "setFilter": {
if (!isRecord(raw.filter)) {
return invalid(type);
return invalid(variant);
}
return ok({
type: "setFilter",
return ok({ variant: "setFilter",
filter: raw.filter as TaskFilter,
});
}
case "setGroupBy": {
if (!isGroupBy(raw.groupBy)) {
return invalid(type);
return invalid(variant);
}
return ok({ type: "setGroupBy", groupBy: raw.groupBy });
return ok({ variant: "setGroupBy", groupBy: raw.groupBy });
}
case "saveDescription": {
if (typeof raw.id !== "string" || raw.id.length === 0) {
return invalid(type);
return invalid(variant);
}
if (typeof raw.description !== "string") {
return invalid(type);
return invalid(variant);
}
return ok({
type: "saveDescription",
return ok({ variant: "saveDescription",
id: raw.id,
description: raw.description,
});
}
default:
return invalid(type);
return invalid(variant);
}
}
+3 -4
View File
@@ -5,13 +5,12 @@ import { GroupBy, TaskGroup } from "./groupTasks";
/** Host → webview messages (presentation payload only). */
export type DashboardOutboundMessage =
| {
type: "state";
| { variant: "state";
filter: TaskFilter;
groupBy: GroupBy;
selectedId: string | undefined;
groups: TaskGroup[];
selected: Task | null;
}
| { type: "error"; error: AppError }
| { type: "saved"; task: Task };
| { variant: "error"; error: AppError }
| { variant: "saved"; task: Task };
+51 -12
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import { ok } from "neverthrow";
import { AppErrorVariant } from "@/error";
import { TaskScope } from "@/model/taskLocation";
import { TaskStatus } from "@/model/taskStatus";
import {
parseKanbanInboundMessage,
type KanbanInboundMessage,
@@ -8,33 +10,60 @@ import {
describe("parseKanbanInboundMessage", () => {
it("parses ready", () => {
expect(parseKanbanInboundMessage({ type: "ready" })).toEqual(
ok({ type: "ready" } satisfies KanbanInboundMessage),
expect(parseKanbanInboundMessage({ variant: "ready" })).toEqual(
ok({ variant: "ready" } satisfies KanbanInboundMessage),
);
});
it("parses refresh", () => {
expect(parseKanbanInboundMessage({ type: "refresh" })).toEqual(
ok({ type: "refresh" }),
expect(parseKanbanInboundMessage({ variant: "refresh" })).toEqual(
ok({ variant: "refresh" }),
);
});
it("parses moveTask", () => {
expect(
parseKanbanInboundMessage({
type: "moveTask",
variant: "moveTask",
id: "abc",
status: "in-progress",
status: TaskStatus.IN_PROGRESS,
}),
).toEqual(
ok({
type: "moveTask",
variant: "moveTask",
id: "abc",
status: "in-progress",
status: TaskStatus.IN_PROGRESS,
} satisfies KanbanInboundMessage),
);
});
it("parses setScopeFilter", () => {
expect(
parseKanbanInboundMessage({
variant: "setScopeFilter",
scope: TaskScope.GLOBAL,
}),
).toEqual(ok({ variant: "setScopeFilter", scope: TaskScope.GLOBAL }));
expect(
parseKanbanInboundMessage({ variant: "setScopeFilter", scope: "all" }),
).toEqual(ok({ variant: "setScopeFilter", scope: "all" }));
});
it("parses setShowHidden", () => {
expect(
parseKanbanInboundMessage({ variant: "setShowHidden", showHidden: true }),
).toEqual(ok({ variant: "setShowHidden", showHidden: true }));
});
it("parses createTask", () => {
expect(
parseKanbanInboundMessage({
variant: "createTask",
status: TaskStatus.BACKLOG,
}),
).toEqual(ok({ variant: "createTask", status: TaskStatus.BACKLOG }));
});
it("rejects non-objects", () => {
const result = parseKanbanInboundMessage(null);
expect(result.isErr()).toBe(true);
@@ -43,7 +72,7 @@ describe("parseKanbanInboundMessage", () => {
});
it("rejects unknown type", () => {
const result = parseKanbanInboundMessage({ type: "explode" });
const result = parseKanbanInboundMessage({ variant: "explode" });
expect(result.isErr()).toBe(true);
if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
@@ -51,8 +80,8 @@ describe("parseKanbanInboundMessage", () => {
it("rejects moveTask without id", () => {
const result = parseKanbanInboundMessage({
type: "moveTask",
status: "done",
variant: "moveTask",
status: TaskStatus.DONE,
});
expect(result.isErr()).toBe(true);
if (result.isOk()) return;
@@ -61,7 +90,7 @@ describe("parseKanbanInboundMessage", () => {
it("rejects moveTask with invalid status", () => {
const result = parseKanbanInboundMessage({
type: "moveTask",
variant: "moveTask",
id: "abc",
status: "__not_a_status__",
});
@@ -69,4 +98,14 @@ describe("parseKanbanInboundMessage", () => {
if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
});
it("rejects setScopeFilter with invalid scope", () => {
const result = parseKanbanInboundMessage({
variant: "setScopeFilter",
scope: "__not_a_scope__",
});
expect(result.isErr()).toBe(true);
if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
});
});
+45 -15
View File
@@ -1,11 +1,24 @@
import { err, ok, Result } from "neverthrow";
import { appError, AppError, AppErrorVariant } from "@/error";
import { isTaskScope, type TaskScope } from "@/model/taskLocation";
import { isTaskStatus, type TaskStatus } from "@/model/taskStatus";
/** Filter which scope's tasks are shown on the board. */
export type KanbanScopeFilter = "all" | TaskScope;
export type KanbanInboundMessage =
| { type: "ready" }
| { type: "refresh" }
| { type: "moveTask"; id: string; status: TaskStatus };
| { variant: "ready" }
| { variant: "refresh" }
| { variant: "moveTask"; id: string; status: TaskStatus }
| { variant: "setScopeFilter"; scope: KanbanScopeFilter }
| { variant: "setShowHidden"; showHidden: boolean }
| { variant: "createTask"; status: TaskStatus };
export function isKanbanScopeFilter(
value: unknown,
): value is KanbanScopeFilter {
return value === "all" || isTaskScope(value);
}
function invalid(detail?: string): Result<KanbanInboundMessage, AppError> {
return err(
@@ -26,31 +39,48 @@ export function parseKanbanInboundMessage(
return invalid();
}
const type = raw.type;
if (typeof type !== "string") {
const variant = raw.variant;
if (typeof variant !== "string") {
return invalid();
}
switch (type) {
switch (variant) {
case "ready":
case "refresh":
return ok({ type });
return ok({ variant });
case "moveTask": {
if (typeof raw.id !== "string" || raw.id.length === 0) {
return invalid(type);
return invalid(variant);
}
if (!isTaskStatus(raw.status)) {
return invalid(type);
return invalid(variant);
}
return ok({
type: "moveTask",
id: raw.id,
status: raw.status,
});
return ok({ variant: "moveTask", id: raw.id, status: raw.status });
}
case "setScopeFilter": {
if (!isKanbanScopeFilter(raw.scope)) {
return invalid(variant);
}
return ok({ variant: "setScopeFilter", scope: raw.scope });
}
case "setShowHidden": {
if (typeof raw.showHidden !== "boolean") {
return invalid(variant);
}
return ok({ variant: "setShowHidden", showHidden: raw.showHidden });
}
case "createTask": {
if (!isTaskStatus(raw.status)) {
return invalid(variant);
}
return ok({ variant: "createTask", status: raw.status });
}
default:
return invalid(type);
return invalid(variant);
}
}
+10 -1
View File
@@ -1,6 +1,15 @@
import { AppError } from "@/error";
import { TaskScope } from "@/model/taskLocation";
import { KanbanBoard } from "./buildKanbanBoard";
import { KanbanScopeFilter } from "./messages";
/** Host → webview. */
export type KanbanOutboundMessage =
{ type: "state"; board: KanbanBoard } | { type: "error"; error: AppError };
| {
variant: "state";
board: KanbanBoard;
scopeFilter: KanbanScopeFilter;
showHidden: boolean;
availableScopes: TaskScope[];
}
| { variant: "error"; error: AppError };
+17 -1
View File
@@ -1,6 +1,22 @@
import { Task } from "./task";
export type TaskScope = "project" | "global";
export const TaskScope = {
PROJECT: "project",
GLOBAL: "global",
} as const;
export type TaskScope = (typeof TaskScope)[keyof typeof TaskScope];
export const TASK_SCOPE_ORDER: TaskScope[] = [
TaskScope.PROJECT,
TaskScope.GLOBAL,
];
export function isTaskScope(value: unknown): value is TaskScope {
return (
typeof value === "string" && (TASK_SCOPE_ORDER as string[]).includes(value)
);
}
/** Where a task lives on disk (runtime; not stored in frontmatter). */
export type TaskLocation = {
+3 -3
View File
@@ -1,3 +1,4 @@
import { TaskScope } from "@/model/taskLocation";
import {
TASK_PRIORITY_META,
TASK_PRIORITY_ORDER,
@@ -8,7 +9,6 @@ import {
TASK_STATUS_ORDER,
type TaskStatus,
} from "@/model/taskStatus";
import { TaskScope } from "@/model/taskLocation";
export { TASK_STATUS_ORDER, TASK_STATUS_META } from "@/model/taskStatus";
export { TASK_PRIORITY_ORDER, TASK_PRIORITY_META } from "@/model/taskPriority";
@@ -27,6 +27,6 @@ export const TASK_PRIORITY_LABELS: Record<TaskPriority, string> =
) as Record<TaskPriority, string>;
export const TASK_SCOPE_LABELS: Record<TaskScope, string> = {
project: "Project",
global: "Global",
[TaskScope.PROJECT]: "Project",
[TaskScope.GLOBAL]: "Global",
};
+12 -13
View File
@@ -300,7 +300,7 @@ export function getTaskDashboardHtml(
}
function postFilter() {
post({ type: "setFilter", filter: normalizeFilter(state.filter) });
post({ variant: "setFilter", filter: normalizeFilter(state.filter) });
}
function renderChipRow(container, options, selectedValues, key) {
@@ -341,8 +341,7 @@ export function getTaskDashboardHtml(
function saveDescription() {
if (!state.selected) return;
post({
type: "saveDescription",
post({ variant: "saveDescription",
id: state.selected.id,
description: el.description.value,
});
@@ -354,8 +353,8 @@ export function getTaskDashboardHtml(
el.query.value = "";
el.groupBy.value = GROUP_BY_NONE;
renderFilterChips();
post({ type: "setFilter", filter: {} });
post({ type: "setGroupBy", groupBy: GROUP_BY_NONE });
post({ variant: "setFilter", filter: {} });
post({ variant: "setGroupBy", groupBy: GROUP_BY_NONE });
}
function renderList() {
@@ -387,7 +386,7 @@ export function getTaskDashboardHtml(
if (!okLeave) return;
state.dirty = false;
}
post({ type: "selectTask", id: task.id });
post({ variant: "selectTask", id: task.id });
});
el.list.appendChild(btn);
}
@@ -447,16 +446,16 @@ export function getTaskDashboardHtml(
});
el.groupBy.addEventListener("change", () => {
post({ type: "setGroupBy", groupBy: el.groupBy.value });
post({ variant: "setGroupBy", groupBy: el.groupBy.value });
});
el.refresh.addEventListener("click", () => {
state.dirty = false;
post({ type: "refresh" });
post({ variant: "refresh" });
});
el.createTask.addEventListener("click", () => {
post({ type: "createTask" });
post({ variant: "createTask" });
});
el.resetFilters.addEventListener("click", () => {
@@ -482,11 +481,11 @@ export function getTaskDashboardHtml(
window.addEventListener("message", (event) => {
const message = event.data;
if (!message || typeof message !== "object") return;
if (message.type === "state") {
if (message.variant === "state") {
applyState(message);
} else if (message.type === "error") {
} else if (message.variant === "error") {
setStatus((message.error && message.error.message) || "Error", true);
} else if (message.type === "saved") {
} else if (message.variant === "saved") {
state.dirty = false;
state.selected = message.task;
setStatus("Saved");
@@ -495,7 +494,7 @@ export function getTaskDashboardHtml(
});
renderFilterChips();
post({ type: "ready" });
post({ variant: "ready" });
</script>
</body>
</html>`;
+7 -9
View File
@@ -110,7 +110,7 @@ export class TaskDashboardPanel {
const result = await listLocatedTasks(this.#deps.repo, locations);
if (result.isErr()) {
this.#post({ type: "error", error: result.error });
this.#post({ variant: "error", error: result.error });
if (result.error.variant === AppErrorVariant.NO_FOLDER) {
presentError(result.error);
}
@@ -130,14 +130,14 @@ export class TaskDashboardPanel {
async #onMessage(raw: unknown): Promise<void> {
const parsed = parseDashboardInboundMessage(raw);
if (parsed.isErr()) {
this.#post({ type: "error", error: parsed.error });
this.#post({ variant: "error", error: parsed.error });
return;
}
await this.#handleInbound(parsed.value);
}
async #handleInbound(message: DashboardInboundMessage): Promise<void> {
switch (message.type) {
switch (message.variant) {
case "ready":
case "refresh":
await this.reloadTasks();
@@ -172,8 +172,7 @@ export class TaskDashboardPanel {
async #saveDescription(id: string, description: string): Promise<void> {
const located = this.#tasks.find((item) => item.task.id === id);
if (!located) {
this.#post({
type: "error",
this.#post({ variant: "error",
error: appError(AppErrorVariant.NOT_FOUND, { id }),
});
return;
@@ -186,7 +185,7 @@ export class TaskDashboardPanel {
});
if (result.isErr()) {
this.#post({ type: "error", error: result.error });
this.#post({ variant: "error", error: result.error });
return;
}
@@ -197,7 +196,7 @@ export class TaskDashboardPanel {
: item,
);
this.#selectedId = saved.id;
this.#post({ type: "saved", task: saved });
this.#post({ variant: "saved", task: saved });
this.#postState();
this.#deps.onTasksMutated?.();
}
@@ -210,8 +209,7 @@ export class TaskDashboardPanel {
selectedId: this.#selectedId,
});
this.#post({
type: "state",
this.#post({ variant: "state",
filter: this.#filter,
groupBy: this.#groupBy,
selectedId: this.#selectedId,
+109 -14
View File
@@ -1,4 +1,6 @@
import * as vscode from "vscode";
import { TaskScope } from "@/model/taskLocation";
import { TASK_SCOPE_LABELS } from "@/ui/taskLabels";
const NONCE_LENGTH = 32;
@@ -13,6 +15,11 @@ export function getTaskKanbanHtml(
`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>
@@ -33,6 +40,7 @@ export function getTaskKanbanHtml(
}
header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 12px;
@@ -42,21 +50,32 @@ export function getTaskKanbanHtml(
margin: 0;
font-size: 1em;
font-weight: 600;
flex: 1;
}
button {
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: none;
border-radius: 2px;
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;
}
#status-line {
font-size: 0.85em;
opacity: 0.85;
@@ -65,10 +84,10 @@ export function getTaskKanbanHtml(
#status-line.error { color: var(--vscode-errorForeground); }
#board {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
padding: 10px;
height: calc(100% - 48px);
height: calc(100% - 52px);
overflow: auto;
align-items: stretch;
}
@@ -89,9 +108,11 @@ export function getTaskKanbanHtml(
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;
@@ -124,17 +145,31 @@ export function getTaskKanbanHtml(
<body>
<header>
<h1>Kanban</h1>
<label for="scopeFilter">Scope</label>
<select id="scopeFilter" title="Project / Global filter"></select>
<button id="toggleHidden" type="button" class="secondary" title="Show or hide backlog and cancelled">Show hidden</button>
<span class="spacer"></span>
<span id="status-line"></span>
<button id="refresh" type="button" class="secondary" title="Refresh">↻</button>
</header>
<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 toggleHiddenBtn = document.getElementById("toggleHidden");
let board = { columns: [] };
let scopeFilter = "all";
let showHidden = false;
let availableScopes = [];
let dragTaskId = null;
function post(message) {
@@ -154,7 +189,38 @@ export function getTaskKanbanHtml(
.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 renderToggleHidden() {
toggleHiddenBtn.textContent = showHidden ? "Hide backlog/cancelled" : "Show hidden";
}
function render() {
renderScopeFilter();
renderToggleHidden();
boardEl.innerHTML = "";
for (const column of board.columns || []) {
const col = document.createElement("section");
@@ -163,9 +229,27 @@ export function getTaskKanbanHtml(
const header = document.createElement("div");
header.className = "column-header";
header.innerHTML =
"<span>" + escapeHtml(column.label) + "</span>" +
'<span class="count">' + column.tasks.length + "</span>";
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");
@@ -185,7 +269,7 @@ export function getTaskKanbanHtml(
const id = event.dataTransfer.getData("text/task-id") || dragTaskId;
const status = column.status;
if (!id) return;
post({ type: "moveTask", id: id, status: status });
post({ variant: "moveTask", id: id, status: status });
dragTaskId = null;
});
@@ -217,22 +301,33 @@ export function getTaskKanbanHtml(
}
refreshBtn.addEventListener("click", () => {
post({ type: "refresh" });
post({ variant: "refresh" });
});
scopeFilterEl.addEventListener("change", () => {
post({ variant: "setScopeFilter", scope: scopeFilterEl.value });
});
toggleHiddenBtn.addEventListener("click", () => {
post({ variant: "setShowHidden", showHidden: !showHidden });
});
window.addEventListener("message", (event) => {
const message = event.data;
if (!message || typeof message !== "object") return;
if (message.type === "state") {
if (message.variant === "state") {
board = message.board || { columns: [] };
scopeFilter = message.scopeFilter || "all";
showHidden = Boolean(message.showHidden);
availableScopes = message.availableScopes || [];
setStatus("");
render();
} else if (message.type === "error") {
} else if (message.variant === "error") {
setStatus((message.error && message.error.message) || "Error", true);
}
});
post({ type: "ready" });
post({ variant: "ready" });
</script>
</body>
</html>`;
+114 -9
View File
@@ -1,18 +1,21 @@
import * as vscode from "vscode";
import { changeStatus } from "@/commands/changeStatus";
import { createTask } from "@/commands/createTask";
import { listLocatedTasks } from "@/commands/listLocatedTasks";
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
import { buildKanbanBoard } from "@/kanban/buildKanbanBoard";
import {
parseKanbanInboundMessage,
type KanbanInboundMessage,
type KanbanScopeFilter,
} from "@/kanban/messages";
import { KanbanOutboundMessage } from "@/kanban/outboundMessages";
import { appError, AppErrorVariant } from "@/error";
import { LocatedTask } from "@/model/taskLocation";
import { TaskStatus } from "@/model/task";
import { LocatedTask, TaskLocation, TaskScope } from "@/model/taskLocation";
import { TaskStatus } from "@/model/taskStatus";
import { IConfigProvider, ITaskRepository } from "@/ports";
import { presentError } from "@/ui/presentError";
import { TASK_SCOPE_LABELS } from "@/ui/taskLabels";
import { createKanbanNonce, getTaskKanbanHtml } from "./taskKanbanHtml";
export type TaskKanbanDeps = {
@@ -56,6 +59,8 @@ export class TaskKanbanPanel {
readonly #deps: TaskKanbanDeps;
#tasks: LocatedTask[] = [];
#scopeFilter: KanbanScopeFilter = "all";
#showHidden = false;
#disposed = false;
constructor(panel: vscode.WebviewPanel, deps: TaskKanbanDeps) {
@@ -84,7 +89,7 @@ export class TaskKanbanPanel {
const result = await listLocatedTasks(this.#deps.repo, locations);
if (result.isErr()) {
this.#post({ type: "error", error: result.error });
this.#post({ variant: "error", error: result.error });
if (result.error.variant === AppErrorVariant.NO_FOLDER) {
presentError(result.error);
}
@@ -98,14 +103,14 @@ export class TaskKanbanPanel {
async #onMessage(raw: unknown): Promise<void> {
const parsed = parseKanbanInboundMessage(raw);
if (parsed.isErr()) {
this.#post({ type: "error", error: parsed.error });
this.#post({ variant: "error", error: parsed.error });
return;
}
await this.#handleInbound(parsed.value);
}
async #handleInbound(message: KanbanInboundMessage): Promise<void> {
switch (message.type) {
switch (message.variant) {
case "ready":
case "refresh":
await this.reloadTasks();
@@ -114,6 +119,20 @@ export class TaskKanbanPanel {
case "moveTask":
await this.#moveTask(message.id, message.status);
return;
case "setScopeFilter":
this.#scopeFilter = message.scope;
this.#postState();
return;
case "setShowHidden":
this.#showHidden = message.showHidden;
this.#postState();
return;
case "createTask":
await this.#createInColumn(message.status);
return;
}
}
@@ -121,7 +140,7 @@ export class TaskKanbanPanel {
const located = this.#tasks.find((item) => item.task.id === id);
if (!located) {
this.#post({
type: "error",
variant: "error",
error: appError(AppErrorVariant.NOT_FOUND, { id }),
});
return;
@@ -138,7 +157,7 @@ export class TaskKanbanPanel {
});
if (result.isErr()) {
this.#post({ type: "error", error: result.error });
this.#post({ variant: "error", error: result.error });
return;
}
@@ -152,9 +171,95 @@ export class TaskKanbanPanel {
this.#deps.onTasksMutated?.();
}
async #createInColumn(status: TaskStatus): Promise<void> {
const location = await this.#pickLocationForCreate();
if (location === undefined) {
return;
}
const title = await vscode.window.showInputBox({
prompt: "Task title",
placeHolder: "Enter task title...",
});
if (title === undefined) {
return;
}
const result = await createTask(this.#deps.repo, {
folderPath: location.folderPath,
title,
status,
});
if (result.isErr()) {
presentError(result.error);
this.#post({ variant: "error", error: result.error });
return;
}
await this.reloadTasks();
this.#deps.onTasksMutated?.();
}
async #pickLocationForCreate(): Promise<TaskLocation | undefined> {
const locations = resolveTaskLocations(this.#deps.config);
if (locations.length === 0) {
presentError(appError(AppErrorVariant.NO_FOLDER));
return undefined;
}
if (this.#scopeFilter !== "all") {
const match = locations.find((loc) => loc.scope === this.#scopeFilter);
if (match) {
return match;
}
}
if (locations.length === 1) {
return locations[0];
}
const picked = await vscode.window.showQuickPick(
locations.map((location) => ({
label: TASK_SCOPE_LABELS[location.scope],
description: location.folderPath,
location,
})),
{ placeHolder: "Create task in…" },
);
return picked?.location;
}
#filteredTasks(): LocatedTask[] {
if (this.#scopeFilter === "all") {
return this.#tasks;
}
return this.#tasks.filter(
(item) => item.location.scope === this.#scopeFilter,
);
}
#availableScopes(): TaskScope[] {
const scopes = new Set(this.#tasks.map((item) => item.location.scope));
// Also expose configured locations even if empty (for filter + create).
for (const loc of resolveTaskLocations(this.#deps.config)) {
scopes.add(loc.scope);
}
return [...scopes];
}
#postState(): void {
const board = buildKanbanBoard(this.#tasks.map((item) => item.task));
this.#post({ type: "state", board });
const board = buildKanbanBoard(
this.#filteredTasks().map((item) => item.task),
{ showHiddenStatuses: this.#showHidden },
);
this.#post({
variant: "state",
board,
scopeFilter: this.#scopeFilter,
showHidden: this.#showHidden,
availableScopes: this.#availableScopes(),
});
}
#post(message: KanbanOutboundMessage): void {