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:
@@ -85,6 +85,12 @@ type State = {
|
||||
expandedPaths: Set<string>;
|
||||
loadingChildrenPaths: Set<string>;
|
||||
loadedChildPaths: Set<string>;
|
||||
// Per-parent-path script cache. Keys are user-relative parent paths;
|
||||
// values are scripts whose storage path lives directly under that parent.
|
||||
// The flat `scripts` array above is the union (deduped by script_id) of
|
||||
// every cache entry that has been loaded in this session.
|
||||
loadedScriptPaths: Set<string>;
|
||||
loadingScriptPaths: Set<string>;
|
||||
|
||||
// 只读内容刷新版本号(用于触发已打开标签页的内容刷新)
|
||||
readOnlyRefreshVersion: number;
|
||||
@@ -122,6 +128,9 @@ type State = {
|
||||
deleteDirectory: (path: string) => Promise<void>;
|
||||
toggleExpanded: (path: string, loadPath?: string) => Promise<void>;
|
||||
loadChildren: (parentPath: string) => Promise<void>;
|
||||
// Lazy-load scripts directly under `parentPath`. Idempotent — repeated
|
||||
// calls for an already-loaded path are no-ops; in-flight calls dedupe.
|
||||
loadScripts: (parentPath: string) => Promise<void>;
|
||||
toggleScriptLock: (script: ScriptItem) => Promise<void>;
|
||||
openPublishDialog: (script: ScriptItem) => void;
|
||||
submitPublish: (releaseNote: string, visibility: Visibility) => Promise<void>;
|
||||
@@ -203,6 +212,8 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
expandedPaths: new Set<string>(),
|
||||
loadingChildrenPaths: new Set<string>(),
|
||||
loadedChildPaths: new Set<string>(),
|
||||
loadedScriptPaths: new Set<string>(),
|
||||
loadingScriptPaths: new Set<string>(),
|
||||
|
||||
readOnlyRefreshVersion: 0,
|
||||
|
||||
@@ -242,6 +253,8 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
expandedPaths: new Set<string>(),
|
||||
loadingChildrenPaths: new Set<string>(),
|
||||
loadedChildPaths: new Set<string>(),
|
||||
loadedScriptPaths: new Set<string>(),
|
||||
loadingScriptPaths: new Set<string>(),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -250,17 +263,23 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
if (!silent) set({ loading: true });
|
||||
set({ refreshing: silent });
|
||||
try {
|
||||
// Fetch root-level scripts (parent_path="") and root directories in
|
||||
// parallel. Deeper folders are lazy-fetched on expand via loadScripts
|
||||
// / loadChildren.
|
||||
const [items, folderItems] = await Promise.all([
|
||||
api.listScripts(),
|
||||
api.listScripts(""),
|
||||
api.listWorkspaceDirectories(""),
|
||||
]);
|
||||
const nextLoaded = new Set(get().loadedChildPaths);
|
||||
nextLoaded.add("");
|
||||
const nextLoadedChildren = new Set(get().loadedChildPaths);
|
||||
nextLoadedChildren.add("");
|
||||
const nextLoadedScripts = new Set(get().loadedScriptPaths);
|
||||
nextLoadedScripts.add("");
|
||||
set({
|
||||
scripts: items,
|
||||
directories: folderItems,
|
||||
apiOnline: true,
|
||||
loadedChildPaths: nextLoaded,
|
||||
loadedChildPaths: nextLoadedChildren,
|
||||
loadedScriptPaths: nextLoadedScripts,
|
||||
});
|
||||
const validIds = new Set(items.map((item) => item.script_id));
|
||||
const currentSelected = get().selectedId;
|
||||
@@ -284,6 +303,42 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
loadScripts: async (parentPath) => {
|
||||
const api = requireApi();
|
||||
if (get().loadedScriptPaths.has(parentPath)) return;
|
||||
// Dedupe in-flight requests for the same path.
|
||||
if (get().loadingScriptPaths.has(parentPath)) return;
|
||||
const next = new Set(get().loadingScriptPaths);
|
||||
next.add(parentPath);
|
||||
set({ loadingScriptPaths: next });
|
||||
try {
|
||||
const items = await api.listScripts(parentPath);
|
||||
set((state) => {
|
||||
const existingIds = new Set(state.scripts.map((s) => s.script_id));
|
||||
const fresh = items.filter((s) => !existingIds.has(s.script_id));
|
||||
const nextLoaded = new Set(state.loadedScriptPaths);
|
||||
nextLoaded.add(parentPath);
|
||||
return {
|
||||
scripts: [...state.scripts, ...fresh],
|
||||
loadedScriptPaths: nextLoaded,
|
||||
loadingScriptPaths: new Set(
|
||||
[...state.loadingScriptPaths].filter((p) => p !== parentPath),
|
||||
),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
set((state) => ({
|
||||
loadingScriptPaths: new Set(
|
||||
[...state.loadingScriptPaths].filter((p) => p !== parentPath),
|
||||
),
|
||||
}));
|
||||
pushToast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "脚本列表加载失败",
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
loadDataResources: async () => {
|
||||
const api = requireApi();
|
||||
set({ dataResourcesLoading: true });
|
||||
@@ -340,10 +395,17 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
// group key (`__group__<owner_id>`) 不是 workspace 路径,不调 loadChildren
|
||||
// group key (`__group__<owner_id>`) 不是 workspace 路径,不调 loadChildren / loadScripts
|
||||
const actualLoadPath = loadPath ?? path;
|
||||
if (!actualLoadPath.startsWith("__group__") && !state.loadedChildPaths.has(actualLoadPath)) {
|
||||
void get().loadChildren(actualLoadPath);
|
||||
if (!actualLoadPath.startsWith("__group__")) {
|
||||
// Load both sub-directories and scripts directly under this folder
|
||||
// in parallel. Both are idempotent + cached; cheap when already loaded.
|
||||
if (!state.loadedChildPaths.has(actualLoadPath)) {
|
||||
void get().loadChildren(actualLoadPath);
|
||||
}
|
||||
if (!state.loadedScriptPaths.has(actualLoadPath)) {
|
||||
void get().loadScripts(actualLoadPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
set({ expandedPaths: next });
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
Reference in New Issue
Block a user