fix: bound mdtodo task windows
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
Reference in New Issue
Block a user