feat(scripts): listScripts parent_path filter + lazy load by directory

Backend — backend/src/backend/scripts.py
- list_scripts accepts optional parent_path Query (default "").
- Extracts _build_list_scripts_descendant_prefix helper for the
  "direct children of parent_path" prefix.
- WHERE clause now adds:
    StorageObjects.relative_path LIKE '<prefix>/%'
    AND NOT LIKE '<prefix>/%/%'
  so deeper descendants and prefix-siblings (foo/bar vs foo/bar2)
  are excluded. Empty parent_path filters to root-level only —
  this is the symmetric, intent-aligned behavior the lazy-load
  frontend relies on.
- EXPLAIN confirms idx_scripts_workspace drives the scripts table;
  storage_objects PK lookup applies the LIKE filter per row.

Frontend — frontend/app/services/api.ts + scriptWorkspaceStore.ts
- listScripts accepts optional parentPath; built URL preserves
  the new filter param.
- Store gains loadedScriptPaths / loadingScriptPaths Sets and a
  loadScripts(parentPath) action: idempotent, in-flight dedupe,
  dedup-by-id when merging into the flat scripts array so existing
  find() callers keep working.
- load() now uses listScripts("") instead of bulk fetch — root
  only on first paint.
- toggleExpanded() triggers loadScripts(parentPath) in parallel
  with loadChildren(parentPath) so folder expansion loads both
  sub-directories and direct-child scripts.

Tests — backend/tests/test_list_scripts_parent_path.py
- 7 unit tests: prefix construction, normalization, traversal
  rejection, and SQL compilation contract (LIKE prefix + NOT LIKE
  prefix + scripts.status filter).

Verified:
- pytest backend/tests: 50 passed (43 existing + 7 new)
- pnpm typecheck: clean
- alembic: no schema changes (migration-less feature)
- EXPLAIN with real workspace: idx_scripts_workspace → PK lookup
This commit is contained in:
tao.chen
2026-08-21 10:27:40 +08:00
parent 0ea6456ed1
commit 02c25fcc8e
4 changed files with 254 additions and 10 deletions
+13 -3
View File
@@ -284,8 +284,16 @@ async function apiRequest<T>(
return (payload as ApiEnvelope<T>).data;
}
export async function listScripts(workspaceId: string): Promise<ScriptItem[]> {
return apiRequest<ScriptItem[]>("/api/v1/scripts", {}, workspaceId);
export async function listScripts(
workspaceId: string,
parentPath: string = "",
): Promise<ScriptItem[]> {
// Empty parentPath omits the query string entirely so the backend's
// root-level filter is applied symmetrically with non-empty paths.
const query = parentPath
? `?parent_path=${encodeURIComponent(parentPath)}`
: "";
return apiRequest<ScriptItem[]>(`/api/v1/scripts${query}`, {}, workspaceId);
}
function initialContent(scriptType: ScriptType): string {
@@ -1455,7 +1463,9 @@ export async function getScheduleNodeRunArtifacts(
// references all the exports above.
// ----------------------------------------------------------------------------
export type WorkspaceBoundApi = {
listScripts: () => Promise<ScriptItem[]>;
listScripts: (
parentPath?: Parameters<typeof listScripts>[1],
) => Promise<ScriptItem[]>;
listResources: (
opts?: Parameters<typeof listResources>[1],
) => Promise<ResourceItem[]>;