fix: bound mdtodo task windows

This commit is contained in:
lyon
2026-06-26 18:05:05 +08:00
parent 9a367582d5
commit 034b4c8ac1
8 changed files with 226 additions and 53 deletions
+2
View File
@@ -807,6 +807,8 @@ lanes:
HWLAB_PROJECT_MANAGEMENT_SOURCE_ID: hwlab-v03-mdtodo
HWLAB_PROJECT_MANAGEMENT_SOURCE_NAME: HWLAB v0.3 MDTODO
HWLAB_PROJECT_MANAGEMENT_PROJECT_ID: project_hwlab_v03
HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_DEFAULT_LIMIT: "200"
HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_MAX_LIMIT: "500"
HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_URL: http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops
HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_API_KEY: secretRef:hwlab-v03-master-server-admin-api-key/api-key
HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_PATH: /etc/hwlab/project-management/sources.json
@@ -36,6 +36,11 @@ test("project management app exposes MDTODO read model and Workbench link writes
expect(tasks.tasks.map((task) => task.taskId)).toEqual(["R1", "R2"]);
expect(tasks.tasks.map((task) => task.status)).toEqual(["open", "done"]);
expect(tasks.tasks.every((task) => task.projectId === "project_test")).toBe(true);
expect(tasks.page).toMatchObject({ offset: 0, limit: 200, total: 2, hasMore: false });
const taskWindow = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=test-mdtodo&limit=1&offset=1");
expect(taskWindow.tasks.map((task) => task.taskId)).toEqual(["R2"]);
expect(taskWindow.page).toMatchObject({ offset: 1, limit: 1, total: 2, hasMore: false });
const links = await json(app, "/v1/project-management/workbench-links?projectId=project_test");
expect(links.links).toEqual([]);
@@ -64,6 +69,39 @@ test("project management app exposes MDTODO read model and Workbench link writes
}
});
test("project management task list defaults to a bounded window", async () => {
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-task-window-"));
try {
const body = ["# Demo", "", ...Array.from({ length: 5 }, (_, index) => `## R${index + 1} Task ${index + 1}`)].join("\n");
await writeFile(join(root, "MDTODO.md"), `${body}\n`, "utf8");
const app = createProjectManagementApp({
env: {
HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_DEFAULT_LIMIT: "2",
HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_MAX_LIMIT: "3"
},
store: createProjectManagementStore({ kind: "memory" }),
sourceRoot: root,
sourceId: "bounded-mdtodo",
projectId: "project_test"
});
const ready = await app.fetch(new Request("http://service/health/ready"));
expect(ready.status).toBe(200);
const defaultWindow = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=bounded-mdtodo");
expect(defaultWindow.tasks.map((task) => task.taskId)).toEqual(["R1", "R2"]);
expect(defaultWindow.page).toMatchObject({ offset: 0, limit: 2, maxLimit: 3, total: 5, hasMore: true });
const cappedWindow = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=bounded-mdtodo&limit=99");
expect(cappedWindow.tasks.map((task) => task.taskId)).toEqual(["R1", "R2", "R3"]);
expect(cappedWindow.page).toMatchObject({ offset: 0, limit: 3, maxLimit: 3, total: 5, hasMore: true });
await app.close();
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("project management app configures HWPOD source and writes MDTODO content", async () => {
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-hwpod-"));
const defaultRoot = join(root, "default");
+40 -6
View File
@@ -17,6 +17,8 @@ export function createProjectManagementApp(options = {}) {
const projectId = safeToken(options.projectId ?? env.HWLAB_PROJECT_MANAGEMENT_PROJECT_ID, "project_hwlab_v03");
const displayName = String(options.displayName ?? env.HWLAB_PROJECT_MANAGEMENT_SOURCE_NAME ?? "HWLAB MDTODO").trim() || "HWLAB MDTODO";
const maxFiles = positiveInteger(options.maxFiles ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_MAX_FILES, 300);
const taskMaxLimit = positiveInteger(options.taskMaxLimit ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_MAX_LIMIT, 500);
const taskDefaultLimit = Math.min(taskMaxLimit, positiveInteger(options.taskDefaultLimit ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_DEFAULT_LIMIT, 200));
const sourceAdapter = options.sourceAdapter ?? createMdtodoSourceAdapter({ env, hwpodNodeOpsHandler: options.hwpodNodeOpsHandler, fetchImpl: options.fetchImpl });
const defaultSource = normalizeProjectSourceInput({
sourceId,
@@ -146,12 +148,22 @@ export function createProjectManagementApp(options = {}) {
return await handleReadMdtodoFile(url, store, sourceAdapter, fileContent.fileRef);
}
if (url.pathname === "/v1/project-management/mdtodo/tasks") {
const taskFilters = {
sourceId: queryToken(url, "sourceId"),
fileRef: queryToken(url, "fileRef"),
projectId: queryToken(url, "projectId"),
parentTaskRef: queryOpaque(url, "parentTaskRef"),
status: queryToken(url, "status"),
search: querySearch(url, "search")
};
const page = taskListPage(url, { defaultLimit: taskDefaultLimit, maxLimit: taskMaxLimit });
const [total, tasks] = await Promise.all([
store.countTasks(taskFilters),
store.listTasks({ ...taskFilters, limit: page.limit, offset: page.offset })
]);
return json(200, envelope({
tasks: await store.listTasks({
sourceId: queryToken(url, "sourceId"),
fileRef: queryToken(url, "fileRef"),
projectId: queryToken(url, "projectId")
})
tasks,
page: { ...page, total, hasMore: page.offset + tasks.length < total }
}));
}
if (url.pathname === "/v1/project-management/workbench-links") {
@@ -458,7 +470,8 @@ async function mutateTaskDocument({ store, sourceAdapter, sourceId, fileRef, exp
async function getTaskByRef(store, taskRef) {
const safeTaskRef = safeOpaqueValue(taskRef);
if (!safeTaskRef) return null;
return (await store.listTasks({})).find((task) => task.taskRef === safeTaskRef) ?? null;
if (typeof store.getTask === "function") return store.getTask(safeTaskRef);
return (await store.listTasks({ taskRef: safeTaskRef, limit: 1 }))[0] ?? null;
}
function fileSummary(file) {
@@ -608,6 +621,13 @@ function queryOpaque(url, name) {
return /^[A-Za-z0-9_.:/#-]{1,320}$/u.test(String(value ?? "")) ? value : null;
}
function querySearch(url, name) {
const value = url.searchParams.get(name);
if (value === null) return null;
const text = shortText(value, 120);
return text && /^[\p{L}\p{N}\s_.:/#-]{1,120}$/u.test(text) ? text : null;
}
function safeToken(value, fallback) {
const text = String(value ?? "").trim();
return /^[A-Za-z0-9_.:-]{1,96}$/u.test(text) ? text : fallback;
@@ -617,3 +637,17 @@ function positiveInteger(value, fallback) {
const number = Number(value);
return Number.isInteger(number) && number > 0 ? number : fallback;
}
function nonNegativeInteger(value, fallback) {
const number = Number(value);
return Number.isInteger(number) && number >= 0 ? number : fallback;
}
function taskListPage(url, options) {
const limit = Math.min(options.maxLimit, positiveInteger(url.searchParams.get("limit"), options.defaultLimit));
return {
offset: nonNegativeInteger(url.searchParams.get("offset"), 0),
limit,
maxLimit: options.maxLimit
};
}
+99 -25
View File
@@ -261,34 +261,39 @@ export class PostgresProjectManagementStore {
await this.ensureSchema();
const params = [];
const clauses = [];
for (const [column, value] of [["source_id", filters.sourceId], ["file_ref", filters.fileRef], ["project_id", filters.projectId]]) {
if (!value) continue;
params.push(value);
clauses.push(`${column} = $${params.length}`);
appendTaskFilterClauses(params, clauses, filters);
let sql = `SELECT task_ref, project_id, source_id, file_ref, task_id, title, status, parent_task_ref, depth, line_number, ordinal, link_count, source_fingerprint, task_json, updated_at
FROM mdtodo_task_projection ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
ORDER BY project_id, source_id, file_ref, ordinal`;
if (Number.isInteger(filters.limit) && filters.limit > 0) {
params.push(filters.limit);
sql += ` LIMIT $${params.length}`;
}
if (Number.isInteger(filters.offset) && filters.offset > 0) {
params.push(filters.offset);
sql += ` OFFSET $${params.length}`;
}
const result = await this.pool.query(
`SELECT task_ref, project_id, source_id, file_ref, task_id, title, status, parent_task_ref, depth, line_number, ordinal, link_count, source_fingerprint, task_json, updated_at
FROM mdtodo_task_projection ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
ORDER BY project_id, source_id, file_ref, ordinal`,
sql,
params
);
return result.rows.map((row) => ({
taskRef: row.task_ref,
projectId: row.project_id,
sourceId: row.source_id,
fileRef: row.file_ref,
taskId: row.task_id,
title: row.title,
status: row.status,
parentTaskRef: row.parent_task_ref,
depth: row.depth,
lineNumber: row.line_number,
ordinal: row.ordinal,
linkCount: row.link_count,
sourceFingerprint: row.source_fingerprint,
updatedAt: row.updated_at,
task: row.task_json ?? {}
}));
return result.rows.map(taskFromRow);
}
async countTasks(filters = {}) {
await this.ensureSchema();
const params = [];
const clauses = [];
appendTaskFilterClauses(params, clauses, filters);
const result = await this.pool.query(
`SELECT COUNT(*)::int AS total FROM mdtodo_task_projection ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}`,
params
);
return Number(result.rows[0]?.total ?? 0);
}
async getTask(taskRef) {
return (await this.listTasks({ taskRef, limit: 1 }))[0] ?? null;
}
async listWorkbenchLinks(filters = {}) {
@@ -378,7 +383,16 @@ class MemoryProjectManagementStore {
async getSource(sourceId) { return this.sources.get(sourceId) ?? null; }
async listDocuments(filters = {}) { return this.documents.filter((item) => !filters.sourceId || item.sourceId === filters.sourceId); }
async listTasks(filters = {}) {
return this.tasks.filter((item) => (!filters.sourceId || item.sourceId === filters.sourceId) && (!filters.fileRef || item.fileRef === filters.fileRef) && (!filters.projectId || item.projectId === filters.projectId));
const offset = Number.isInteger(filters.offset) && filters.offset > 0 ? filters.offset : 0;
const limit = Number.isInteger(filters.limit) && filters.limit > 0 ? filters.limit : null;
const rows = filterMemoryTasks(this.tasks, filters).sort(compareProjectTasks);
return limit ? rows.slice(offset, offset + limit) : rows.slice(offset);
}
async countTasks(filters = {}) {
return filterMemoryTasks(this.tasks, filters).length;
}
async getTask(taskRef) {
return this.tasks.find((task) => task.taskRef === taskRef) ?? null;
}
async listWorkbenchLinks(filters = {}) {
return this.links.filter((item) => (!filters.taskRef || item.taskRef === filters.taskRef) && (!filters.projectId || item.projectId === filters.projectId) && (!filters.sessionId || item.sessionId === filters.sessionId));
@@ -419,6 +433,8 @@ class UnconfiguredProjectManagementStore {
async listSources() { await this.ensureSchema(); }
async listDocuments() { await this.ensureSchema(); }
async listTasks() { await this.ensureSchema(); }
async countTasks() { await this.ensureSchema(); }
async getTask() { await this.ensureSchema(); }
async listWorkbenchLinks() { await this.ensureSchema(); }
async upsertWorkbenchLink() { await this.ensureSchema(); }
async recordAuditEvent() { await this.ensureSchema(); }
@@ -438,3 +454,61 @@ function workbenchLinkFromRow(row) {
link: row.link_json ?? {}
};
}
function appendTaskFilterClauses(params, clauses, filters = {}) {
for (const [column, value] of [["task_ref", filters.taskRef], ["source_id", filters.sourceId], ["file_ref", filters.fileRef], ["project_id", filters.projectId], ["parent_task_ref", filters.parentTaskRef], ["status", filters.status]]) {
if (!value) continue;
params.push(value);
clauses.push(`${column} = $${params.length}`);
}
const search = String(filters.search ?? "").trim();
if (search) {
params.push(`%${escapeSqlLike(search)}%`);
clauses.push(`(task_id ILIKE $${params.length} ESCAPE '\\' OR title ILIKE $${params.length} ESCAPE '\\' OR task_ref ILIKE $${params.length} ESCAPE '\\')`);
}
}
function taskFromRow(row) {
return {
taskRef: row.task_ref,
projectId: row.project_id,
sourceId: row.source_id,
fileRef: row.file_ref,
taskId: row.task_id,
title: row.title,
status: row.status,
parentTaskRef: row.parent_task_ref,
depth: row.depth,
lineNumber: row.line_number,
ordinal: row.ordinal,
linkCount: row.link_count,
sourceFingerprint: row.source_fingerprint,
updatedAt: row.updated_at,
task: row.task_json ?? {}
};
}
function filterMemoryTasks(tasks, filters = {}) {
const search = String(filters.search ?? "").trim().toLowerCase();
return tasks.filter((item) => {
if (filters.taskRef && item.taskRef !== filters.taskRef) return false;
if (filters.sourceId && item.sourceId !== filters.sourceId) return false;
if (filters.fileRef && item.fileRef !== filters.fileRef) return false;
if (filters.projectId && item.projectId !== filters.projectId) return false;
if (filters.parentTaskRef && item.parentTaskRef !== filters.parentTaskRef) return false;
if (filters.status && item.status !== filters.status) return false;
if (search && ![item.taskId, item.title, item.taskRef].some((value) => String(value ?? "").toLowerCase().includes(search))) return false;
return true;
});
}
function compareProjectTasks(a, b) {
return String(a.projectId ?? "").localeCompare(String(b.projectId ?? ""))
|| String(a.sourceId ?? "").localeCompare(String(b.sourceId ?? ""))
|| String(a.fileRef ?? "").localeCompare(String(b.fileRef ?? ""))
|| Number(a.ordinal ?? 0) - Number(b.ordinal ?? 0);
}
function escapeSqlLike(value) {
return String(value).replace(/[\\%_]/gu, "\\$&");
}
+1 -1
View File
@@ -14,7 +14,7 @@ export { apiKeysAPI } from "./apiKeys";
export { usageAPI } from "./usage";
export { billingAPI } from "./billing";
export { systemAPI, type SkillUploadFileInput } from "./system";
export { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement";
export { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement";
import { fetchJson } from "./client";
import { accessAPI } from "./access";
@@ -135,7 +135,14 @@ export interface ProjectSourceMutationResponse extends ProjectManagementEnvelope
export interface ProjectSourceProbeResponse extends ProjectManagementEnvelope { probe?: { ok?: boolean; status?: string; sourceId?: string; sourceKind?: string; entries?: number; valuesRedacted?: boolean } }
export interface ProjectSourceReindexResponse extends ProjectManagementEnvelope { projection?: ProjectProjectionSummary & { sourceKind?: string; rootRef?: string; valuesRedacted?: boolean } }
export interface MdtodoFileListResponse extends ProjectManagementEnvelope { files?: MdtodoFileRecord[] }
export interface MdtodoTaskListResponse extends ProjectManagementEnvelope { tasks?: MdtodoTaskRecord[] }
export interface MdtodoTaskPage {
offset?: number;
limit?: number;
maxLimit?: number;
total?: number;
hasMore?: boolean;
}
export interface MdtodoTaskListResponse extends ProjectManagementEnvelope { tasks?: MdtodoTaskRecord[]; page?: MdtodoTaskPage }
export interface MdtodoTaskMutationInput {
sourceId?: string;
fileRef?: string;
@@ -160,6 +167,11 @@ export interface MdtodoTaskQuery {
sourceId?: string | null;
fileRef?: string | null;
projectId?: string | null;
parentTaskRef?: string | null;
status?: string | null;
search?: string | null;
limit?: number | null;
offset?: number | null;
}
export interface ProjectWorkbenchLinkQuery {
@@ -177,17 +189,17 @@ export const projectManagementAPI = {
probeSource: (sourceId: string): Promise<ApiResult<ProjectSourceProbeResponse>> => fetchJson(`/v1/project-management/mdtodo/sources/${encodeURIComponent(sourceId)}/probe`, { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "project mdtodo source probe" }),
reindexSource: (sourceId: string): Promise<ApiResult<ProjectSourceReindexResponse>> => fetchJson(`/v1/project-management/mdtodo/sources/${encodeURIComponent(sourceId)}/reindex`, { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "project mdtodo source reindex" }),
files: (sourceId?: string | null): Promise<ApiResult<MdtodoFileListResponse>> => fetchJson(`/v1/project-management/mdtodo/files${queryString({ sourceId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo files" }),
tasks: (query: MdtodoTaskQuery = {}): Promise<ApiResult<MdtodoTaskListResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks${queryString({ sourceId: query.sourceId, fileRef: query.fileRef, projectId: query.projectId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo tasks" }),
tasks: (query: MdtodoTaskQuery = {}): Promise<ApiResult<MdtodoTaskListResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks${queryString({ sourceId: query.sourceId, fileRef: query.fileRef, projectId: query.projectId, parentTaskRef: query.parentTaskRef, status: query.status, search: query.search, limit: query.limit, offset: query.offset })}`, { timeoutMs: 12000, timeoutName: "project mdtodo tasks" }),
updateTask: (taskRef: string, input: MdtodoTaskMutationInput): Promise<ApiResult<MdtodoTaskMutationResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(taskRef)}`, { method: "PATCH", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task update" }),
createTask: (input: MdtodoTaskMutationInput): Promise<ApiResult<MdtodoTaskMutationResponse>> => fetchJson("/v1/project-management/mdtodo/tasks", { method: "POST", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task create" }),
deleteTask: (taskRef: string, input: MdtodoTaskMutationInput): Promise<ApiResult<MdtodoTaskMutationResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(taskRef)}`, { method: "DELETE", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task delete" }),
workbenchLinks: (query: ProjectWorkbenchLinkQuery = {}): Promise<ApiResult<ProjectWorkbenchLinkListResponse>> => fetchJson(`/v1/project-management/workbench-links${queryString({ taskRef: query.taskRef, projectId: query.projectId, sessionId: query.sessionId })}`, { timeoutMs: 12000, timeoutName: "project workbench links" })
};
function queryString(values: Record<string, string | null | undefined>): string {
function queryString(values: Record<string, string | number | null | undefined>): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(values)) {
if (value) params.set(key, value);
if (value !== null && value !== undefined && value !== "") params.set(key, String(value));
}
const text = params.toString();
return text ? `?${text}` : "";
@@ -4,7 +4,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { projectManagementAPI, workbenchAPI, type MdtodoFileRecord, type MdtodoTaskMutationInput, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "@/api";
import { projectManagementAPI, workbenchAPI, type MdtodoFileRecord, type MdtodoTaskMutationInput, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "@/api";
import EmptyState from "@/components/common/EmptyState.vue";
import LoadingState from "@/components/common/LoadingState.vue";
import PageHeader from "@/components/common/PageHeader.vue";
@@ -33,6 +33,7 @@ const navigation = ref<ProjectNavigationResponse | null>(null);
const sources = ref<ProjectSource[]>([]);
const files = ref<MdtodoFileRecord[]>([]);
const tasks = ref<MdtodoTaskRecord[]>([]);
const taskPage = ref<MdtodoTaskPage | null>(null);
const links = ref<ProjectWorkbenchLinkRecord[]>([]);
const selectedSourceId = ref<string | null>(null);
const selectedFileRef = ref<string | null>(null);
@@ -59,6 +60,8 @@ const sourceForm = reactive<ProjectSourceInput>({
maxFiles: 120
});
const taskWindowLimit = 200;
const selectedSource = computed(() => sources.value.find((source) => source.sourceId === selectedSourceId.value) ?? null);
const selectedFile = computed(() => files.value.find((file) => file.fileRef === selectedFileRef.value) ?? null);
const selectedTask = computed(() => tasks.value.find((task) => task.taskRef === selectedTaskRef.value) ?? null);
@@ -140,19 +143,11 @@ async function loadPage(): Promise<void> {
async function loadFilesAndTasks(sourceId: string): Promise<void> {
taskLoading.value = true;
try {
const [fileResponse, taskResponse] = await Promise.all([
projectManagementAPI.files(sourceId),
projectManagementAPI.tasks({ sourceId })
]);
const fileResponse = await projectManagementAPI.files(sourceId);
if (!fileResponse.ok) throw fileResponse;
if (!taskResponse.ok) throw taskResponse;
files.value = fileResponse.data?.files ?? [];
tasks.value = taskResponse.data?.tasks ?? [];
selectedFileRef.value = selectedFileRef.value && files.value.some((file) => file.fileRef === selectedFileRef.value) ? selectedFileRef.value : files.value[0]?.fileRef ?? null;
const firstVisibleTask = selectedFileRef.value ? tasks.value.find((task) => task.fileRef === selectedFileRef.value) : tasks.value[0];
selectedTaskRef.value = firstVisibleTask?.taskRef ?? null;
collapsedTaskRefs.value = new Set();
if (!selectedTaskRef.value) await loadLinks(null);
await loadTaskWindow(sourceId, selectedFileRef.value, selectedTaskRef.value);
} catch (err) {
setError(err);
} finally {
@@ -178,13 +173,20 @@ function onSourceSelect(event: Event): void {
}
function onFileSelect(event: Event): void {
selectFile((event.target as HTMLSelectElement).value);
void selectFile((event.target as HTMLSelectElement).value);
}
function selectFile(fileRef: string): void {
async function selectFile(fileRef: string): Promise<void> {
selectedFileRef.value = fileRef || null;
const nextTask = tasks.value.find((task) => task.fileRef === fileRef) ?? null;
selectedTaskRef.value = nextTask?.taskRef ?? null;
if (!selectedSourceId.value) return;
taskLoading.value = true;
try {
await loadTaskWindow(selectedSourceId.value, selectedFileRef.value, null);
} catch (err) {
setError(err);
} finally {
taskLoading.value = false;
}
}
function selectTask(taskRef: string): void {
@@ -357,6 +359,17 @@ async function runTaskMutation(action: () => ReturnType<typeof projectManagement
}
}
async function loadTaskWindow(sourceId: string, fileRef: string | null, preferredTaskRef?: string | null): Promise<void> {
const taskResponse = await projectManagementAPI.tasks({ sourceId, fileRef, limit: taskWindowLimit });
if (!taskResponse.ok) throw taskResponse;
tasks.value = taskResponse.data?.tasks ?? [];
taskPage.value = taskResponse.data?.page ?? null;
const preferredTask = preferredTaskRef ? tasks.value.find((task) => task.taskRef === preferredTaskRef) : null;
selectedTaskRef.value = preferredTask?.taskRef ?? tasks.value[0]?.taskRef ?? null;
collapsedTaskRefs.value = new Set();
if (!selectedTaskRef.value) await loadLinks(null);
}
function taskFingerprint(): string | undefined {
return selectedTask.value?.sourceFingerprint || selectedFile.value?.fingerprint || undefined;
}
@@ -479,7 +492,7 @@ function displayDate(value?: string | null): string {
<section class="mdtodo-workspace">
<section class="data-panel mdtodo-task-panel" data-testid="mdtodo-task-tree">
<header class="mdtodo-panel-header">
<div><h2>任务树</h2><p>{{ filteredTasks.length }} / {{ fileScopedTasks.length }} tasks</p></div>
<div><h2>任务树</h2><p>{{ filteredTasks.length }} / {{ taskPage?.total ?? fileScopedTasks.length }} tasks<span v-if="taskPage?.hasMore"> · window {{ taskPage.limit }}</span></p></div>
<div class="task-tools">
<input v-model="taskSearch" data-testid="mdtodo-task-search" type="search" placeholder="R1 / title" aria-label="搜索任务" />
<select v-model="taskStatusFilter" data-testid="mdtodo-task-status-filter" aria-label="筛选状态">
@@ -37,7 +37,7 @@ async function loadProjects(): Promise<void> {
projectManagementAPI.navigation(),
projectManagementAPI.projects(),
projectManagementAPI.sources(),
projectManagementAPI.tasks(),
projectManagementAPI.tasks({ limit: 6 }),
projectManagementAPI.workbenchLinks()
]);
for (const response of [navigationResponse, projectResponse, sourceResponse, taskResponse, linkResponse]) {