refactor: uiStore.ts

This commit is contained in:
tao.chen
2026-08-07 12:23:50 +08:00
parent 2272e1f390
commit d7934a42ab
10 changed files with 1405 additions and 989 deletions
@@ -0,0 +1,669 @@
import { create } from "zustand";
import type {
ActiveEditSession,
LatestVersion,
ScriptItem,
StableVersion,
Visibility,
WorkspaceBoundApi,
WorkspaceDirectory,
} from "../../../services/api";
import type { NewScriptForm } from "./uiStore";
import { useUiStore } from "./uiStore";
// 缓存的会话类型(多 iframe 共存方案)
type CachedSession = {
session: ActiveEditSession;
jupyterUrl: string;
lastActiveTime: number;
};
// 模块级可变 holder(非响应式,避免 React 重渲)
const sessionCache = new Map<string, CachedSession>();
let _selectedId: string | null = null;
let _editSession: ActiveEditSession | null = null;
let _editorOpening = false;
let _editorOpenRequest = 0;
let _api: WorkspaceBoundApi | null = null;
export const bindScriptWorkspaceApi = (api: WorkspaceBoundApi | null) => {
_api = api;
};
export const getSessionCache = () => sessionCache;
export const clearSessionCache = () => {
sessionCache.clear();
};
// handle ref 给 Sidebar 用,避免订阅 store
export const editSessionHandle: { current: ActiveEditSession | null } = {
current: null,
};
type State = {
scripts: ScriptItem[];
directories: WorkspaceDirectory[];
selectedId: string | null;
openTabIds: string[];
keyword: string;
loading: boolean;
refreshing: boolean;
apiOnline: boolean;
editSession: ActiveEditSession | null;
embeddedJupyterUrl: string | null;
editBusy: boolean;
editorOpenError: { scriptId: string; message: string } | null;
latestVersion: LatestVersion | null;
latestVersionLoading: boolean;
// actions
setApiOnline: (online: boolean) => void;
setKeyword: (keyword: string) => void;
reset: () => void;
load: (silent?: boolean) => Promise<void>;
selectScript: (id: string | null) => void;
openTab: (id: string) => void;
closeTab: (id: string, event?: { stopPropagation: () => void }) => Promise<void>;
switchTab: (id: string) => void;
openScriptEditor: (script: ScriptItem, showToast?: boolean) => Promise<void>;
endEditing: (closeTabFlag?: boolean, showToast?: boolean) => Promise<void>;
loadLatestVersion: (scriptId: string) => Promise<void>;
createScript: (form: NewScriptForm) => Promise<ScriptItem | null>;
uploadScripts: (files: File[], parentPath: string) => Promise<void>;
createFolder: (name: string, parentPath: string) => Promise<void>;
deleteScript: (script: ScriptItem) => Promise<void>;
deleteDirectory: (path: string) => Promise<void>;
openPublishDialog: (script: ScriptItem) => void;
submitPublish: (releaseNote: string, visibility: Visibility) => Promise<void>;
// 生命周期 tick(由 layout 层 useEditSessionLifecycle 周期性调用)
tickHeartbeats: () => Promise<void>;
tickCleanup: () => void;
releaseActiveOnUnload: () => void;
};
function requireApi(): WorkspaceBoundApi {
if (!_api) {
throw new Error("script workspace API 未绑定");
}
return _api;
}
function ownedScriptPath(item: ScriptItem) {
return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
}
function pushToast(tone: "success" | "error" | "info", message: string) {
useUiStore.getState().pushToast({ tone, message });
}
export const useScriptWorkspaceStore = create<State>((set, get) => {
const setEditSessionState = (
next: ActiveEditSession | null,
nextJupyterUrl: string | null,
) => {
_editSession = next;
editSessionHandle.current = next;
set({
editSession: next,
embeddedJupyterUrl: next ? nextJupyterUrl : null,
});
};
return {
scripts: [],
directories: [],
selectedId: null,
openTabIds: [],
keyword: "",
loading: true,
refreshing: false,
apiOnline: false,
editSession: null,
embeddedJupyterUrl: null,
editBusy: false,
editorOpenError: null,
latestVersion: null,
latestVersionLoading: false,
setApiOnline: (online) => set({ apiOnline: online }),
setKeyword: (keyword) => set({ keyword }),
reset: () => {
_selectedId = null;
_editSession = null;
editSessionHandle.current = null;
sessionCache.clear();
set({
scripts: [],
directories: [],
selectedId: null,
openTabIds: [],
editSession: null,
embeddedJupyterUrl: null,
editorOpenError: null,
latestVersion: null,
latestVersionLoading: false,
});
},
load: async (silent = false) => {
const api = requireApi();
if (!silent) set({ loading: true });
set({ refreshing: silent });
try {
const [items, folderItems] = await Promise.all([
api.listScripts(),
api.listWorkspaceDirectories(),
]);
set({
scripts: items,
directories: folderItems,
apiOnline: true,
});
const validIds = new Set(items.map((item) => item.script_id));
const currentSelected = get().selectedId;
if (!currentSelected || !validIds.has(currentSelected)) {
_selectedId = null;
set({ selectedId: null });
} else {
_selectedId = currentSelected;
}
set((state) => ({
openTabIds: state.openTabIds.filter((id) => validIds.has(id)),
}));
} catch (error) {
set({ apiOnline: false });
pushToast(
"error",
error instanceof Error ? error.message : "脚本列表加载失败",
);
} finally {
set({ loading: false, refreshing: false });
}
},
selectScript: (id) => {
_selectedId = id;
set({ selectedId: id });
},
openTab: (id) => {
if (_selectedId !== id) {
_editorOpenRequest += 1;
set({ editorOpenError: null });
}
_selectedId = id;
set((state) => ({
selectedId: id,
openTabIds: state.openTabIds.includes(id)
? state.openTabIds
: [...state.openTabIds, id],
}));
},
closeTab: async (id, event) => {
event?.stopPropagation();
if (_editSession?.script_id === id) {
await get().endEditing(false, false);
}
sessionCache.delete(id);
set((state) => {
const index = state.openTabIds.indexOf(id);
if (index === -1) return {};
const newTabs = state.openTabIds.filter((tabId) => tabId !== id);
let nextSelected = state.selectedId;
if (state.selectedId === id) {
const nextId = newTabs[index] ?? newTabs[index - 1] ?? null;
_selectedId = nextId;
nextSelected = nextId;
}
return { openTabIds: newTabs, selectedId: nextSelected };
});
},
switchTab: (id) => {
if (_selectedId !== id) {
_editorOpenRequest += 1;
set({ editorOpenError: null });
}
_selectedId = id;
set({ selectedId: id });
const cached = sessionCache.get(id);
if (cached) {
cached.lastActiveTime = Date.now();
setEditSessionState(cached.session, cached.jupyterUrl);
}
},
openScriptEditor: async (script, showToast = true) => {
if (!script) return;
if (_editorOpening) return;
_editorOpening = true;
const api = requireApi();
const requestId = _editorOpenRequest + 1;
_editorOpenRequest = requestId;
const requestIsCurrent = () =>
_editorOpenRequest === requestId && _selectedId === script.script_id;
const clearSessionIfActive = (session: ActiveEditSession) => {
if (_editSession?.edit_session_id === session.edit_session_id) {
setEditSessionState(null, null);
}
};
set({ editBusy: true });
set((state) => ({
editorOpenError:
state.editorOpenError?.scriptId === script.script_id
? null
: state.editorOpenError,
}));
try {
const cached = sessionCache.get(script.script_id);
if (cached) {
if (!requestIsCurrent()) return;
setEditSessionState(cached.session, cached.jupyterUrl);
if (showToast) {
pushToast(
"success",
`${script.script_name} 已打开,Jupyter 已嵌入当前工作区`,
);
}
return;
}
let active = _editSession;
let newlyAcquired = false;
if (active && active.script_id !== script.script_id) {
await api.releaseFileLock(active);
setEditSessionState(null, null);
active = null;
}
if (!requestIsCurrent()) return;
if (!active) {
active = await api.acquireFileLock(script);
newlyAcquired = true;
}
if (!requestIsCurrent()) {
if (active) {
await api.releaseFileLock(active);
clearSessionIfActive(active);
}
return;
}
const ticket = await api.createJupyterAccessTicket(active);
if (!requestIsCurrent()) {
if (newlyAcquired) {
await api.releaseFileLock(active);
clearSessionIfActive(active);
}
return;
}
const readySession = {
...active,
ticket_expires_at: ticket.expires_at,
};
setEditSessionState(readySession, ticket.jupyter_url);
sessionCache.set(script.script_id, {
session: readySession,
jupyterUrl: ticket.jupyter_url,
lastActiveTime: Date.now(),
});
if (showToast) {
pushToast(
"success",
`${script.script_name} 已打开,Jupyter 已嵌入当前工作区`,
);
}
} catch (error) {
setEditSessionState(null, null);
if (requestIsCurrent()) {
const message =
error instanceof Error ? error.message : "打开编辑器失败";
set({ editorOpenError: { scriptId: script.script_id, message } });
if (showToast) {
pushToast("error", message);
}
}
} finally {
_editorOpening = false;
set({ editBusy: false });
}
},
endEditing: async (closeTabFlag = true, showToast = true) => {
const api = requireApi();
_editorOpenRequest += 1;
set({ editorOpenError: null });
const active = _editSession;
const scriptId = active?.script_id;
if (!active) {
if (closeTabFlag && scriptId) {
await get().closeTab(scriptId);
}
return;
}
set({ editBusy: true });
try {
await api.releaseFileLock(active);
setEditSessionState(null, null);
if (scriptId) sessionCache.delete(scriptId);
if (closeTabFlag && scriptId) {
await get().closeTab(scriptId);
}
if (showToast) {
pushToast("success", `${active.script_name} 的编辑锁已释放`);
}
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "释放编辑锁失败",
);
} finally {
set({ editBusy: false });
}
},
loadLatestVersion: async (scriptId) => {
const api = requireApi();
set({ latestVersionLoading: true });
try {
const item = await api.getLatestScriptVersion(scriptId);
set({ latestVersion: item });
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "最新版本加载失败",
);
} finally {
set({ latestVersionLoading: false });
}
},
createScript: async (form) => {
const api = requireApi();
const suffix = form.scriptType === "notebook" ? ".ipynb" : ".py";
const requestedName = form.name.trim();
const normalizedName = requestedName.toLocaleLowerCase().endsWith(suffix)
? requestedName
: `${requestedName}${suffix}`;
const duplicate = get().scripts.some(
(script) =>
script.script_type === form.scriptType
&& script.script_name.toLocaleLowerCase()
=== normalizedName.toLocaleLowerCase(),
);
if (duplicate) {
pushToast("error", `${normalizedName} 已存在,请更换名称`);
return null;
}
const ui = useUiStore.getState();
ui.setCreating(true);
try {
const created = await api.createScript(form);
set((state) => ({ scripts: [created, ...state.scripts] }));
get().openTab(created.script_id);
ui.closeCreateDialog();
pushToast("success", `${created.script_name} 已创建`);
return created;
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "创建失败",
);
return null;
} finally {
ui.setCreating(false);
}
},
uploadScripts: async (files, parentPath) => {
const api = requireApi();
const ui = useUiStore.getState();
ui.setUploading(true);
let lastCreated: ScriptItem | null = null;
try {
for (const file of files) {
lastCreated = await api.uploadScript(file, parentPath, "workspace");
}
set((state) => ({ scripts: [lastCreated!, ...state.scripts] }));
if (lastCreated) {
get().openTab(lastCreated.script_id);
}
pushToast(
"success",
`${files.length} 个文件已上传到${parentPath ? ` ${parentPath}` : "当前目录"}`,
);
} catch (error) {
await get().load(true);
pushToast(
"error",
error instanceof Error ? error.message : "文件上传失败",
);
} finally {
ui.setUploading(false);
}
},
createFolder: async (name, parentPath) => {
const api = requireApi();
const trimmed = name.trim();
if (!trimmed) return;
const ui = useUiStore.getState();
ui.setFolderBusy(true);
try {
await api.createWorkspaceDirectory(trimmed, parentPath);
await get().load(true);
ui.closeFolderDialog();
pushToast("success", `${trimmed} 文件夹已创建`);
} catch (error) {
ui.setFolderBusy(false);
pushToast(
"error",
error instanceof Error ? error.message : "文件夹创建失败",
);
}
},
deleteScript: async (script) => {
const api = requireApi();
useUiStore.getState().closeContextMenu();
if (
!window.confirm(`确定删除文件"${script.script_name}"吗?稳定版本会保留。`)
) {
return;
}
if (_editSession?.script_id === script.script_id) {
await get().endEditing(false, false);
if (_editSession?.script_id === script.script_id) return;
}
try {
await api.deleteScript(script.script_id);
set((state) => {
const index = state.openTabIds.indexOf(script.script_id);
const newTabs = state.openTabIds.filter(
(id) => id !== script.script_id,
);
if (_selectedId === script.script_id) {
const nextId = newTabs[index] ?? newTabs[index - 1] ?? null;
_selectedId = nextId;
return { openTabIds: newTabs, selectedId: nextId };
}
return { openTabIds: newTabs };
});
await get().load(true);
pushToast("success", `${script.script_name} 已删除`);
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "文件删除失败",
);
}
},
deleteDirectory: async (path) => {
const api = requireApi();
useUiStore.getState().closeContextMenu();
if (
!window.confirm(`确定递归删除文件夹"${path}"及其内容吗?稳定版本会保留。`)
) {
return;
}
const activeScript = get().scripts.find(
(item) => item.script_id === _editSession?.script_id,
);
if (
activeScript
&& (ownedScriptPath(activeScript) === path
|| ownedScriptPath(activeScript).startsWith(`${path}/`))
) {
await get().endEditing(false, false);
if (_editSession?.script_id === activeScript.script_id) return;
}
try {
const result = await api.deleteWorkspaceDirectory(path);
const selectedScript = get().scripts.find(
(item) => item.script_id === _selectedId,
);
if (
selectedScript
&& ownedScriptPath(selectedScript).startsWith(`${path}/`)
) {
get().selectScript(null);
}
await get().load(true);
pushToast(
"success",
`${path} 已删除(含 ${result.deleted_scripts} 个脚本)`,
);
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "文件夹删除失败",
);
}
},
openPublishDialog: (script) => {
useUiStore.getState().openPublishDialog(script);
},
submitPublish: async (releaseNote, visibility) => {
const api = requireApi();
const ui = useUiStore.getState();
const target = ui.publish.target;
if (!target) return;
ui.setPublishing(true);
try {
const version: StableVersion = await api.publishScriptVersion({
script: target,
releaseNote,
visibility,
});
ui.setPublishedVersion(version);
pushToast("success", `${version.version_label} 稳定版本发布成功`);
} catch (error) {
pushToast(
"error",
error instanceof Error ? error.message : "稳定版本发布失败",
);
} finally {
ui.setPublishing(false);
}
},
tickHeartbeats: async () => {
if (!_api) return;
// 1) 当前 active session 心跳
const active = _editSession;
if (active) {
try {
const updated = await _api.heartbeatFileLock(active);
if (
_editSession
&& _editSession.edit_session_id === updated.edit_session_id
) {
const merged = {
..._editSession,
session_status: updated.session_status,
expires_at: updated.expires_at,
};
_editSession = merged;
editSessionHandle.current = merged;
set({ editSession: merged });
}
} catch (error) {
_editSession = null;
editSessionHandle.current = null;
set({ editSession: null, embeddedJupyterUrl: null });
pushToast(
"error",
`编辑锁心跳已中断:${
error instanceof Error ? error.message : "请重新打开文件"
}`,
);
}
}
// 2) 缓存会话心跳(不更新 React state,只更新缓存对象本身)
const promises: Promise<void>[] = [];
for (const cached of sessionCache.values()) {
promises.push(
_api.heartbeatFileLock(cached.session)
.then((updated) => {
cached.session.session_status = updated.session_status;
cached.session.expires_at = updated.expires_at;
})
.catch(() => {
// 静默失败:等用户切回来时再处理
}),
);
}
await Promise.allSettled(promises);
},
tickCleanup: () => {
if (!_api) return;
const TEN_MINUTES = 10 * 60 * 1000;
const now = Date.now();
const toCleanup: string[] = [];
for (const [scriptId, cached] of sessionCache.entries()) {
if (scriptId !== _selectedId) {
const inactiveTime = now - cached.lastActiveTime;
if (inactiveTime > TEN_MINUTES) {
toCleanup.push(scriptId);
}
}
}
if (toCleanup.length === 0) return;
for (const scriptId of toCleanup) {
const cached = sessionCache.get(scriptId);
if (cached) {
_api.releaseFileLock(cached.session).catch(console.warn);
sessionCache.delete(scriptId);
}
}
pushToast(
"info",
`已清理 ${toCleanup.length} 个长时间未活动的编辑会话`,
);
},
releaseActiveOnUnload: () => {
if (!_api) return;
const current = _editSession;
if (current) {
_api.releaseFileLockOnUnload(current);
}
},
};
});
@@ -0,0 +1,233 @@
import { create } from "zustand";
import type {
ScriptItem,
ScriptType,
StableVersion,
Visibility,
} from "../../../services/api";
export type ToastTone = "success" | "error" | "info";
export type ToastState = {
tone: ToastTone;
message: string;
};
export type NewScriptForm = {
name: string;
scriptType: ScriptType;
visibility: Visibility;
parentPath: string;
};
export type ContextMenuState = {
x: number;
y: number;
kind: "root" | "directory" | "file";
path: string;
script?: ScriptItem;
};
export type FolderDialogState = {
open: boolean;
parentPath: string;
name: string;
busy: boolean;
};
export type PublishDialogState = {
target: ScriptItem | null;
releaseNote: string;
visibility: Visibility;
publishing: boolean;
publishedVersion: StableVersion | null;
};
export type UploadState = {
parentPath: string;
uploading: boolean;
};
export const initialCreateForm: NewScriptForm = {
name: "",
scriptType: "notebook",
visibility: "workspace",
parentPath: "",
};
type UiState = {
toast: ToastState | null;
createDialog: {
open: boolean;
creating: boolean;
form: NewScriptForm;
};
folderDialog: FolderDialogState;
contextMenu: ContextMenuState | null;
workspaceMenuOpen: boolean;
upload: UploadState;
publish: PublishDialogState;
// toast
pushToast: (toast: ToastState) => void;
dismissToast: () => void;
// create dialog
openCreateDialog: (parentPath?: string, scriptType?: ScriptType) => void;
closeCreateDialog: () => void;
setCreateForm: (form: NewScriptForm) => void;
setCreating: (creating: boolean) => void;
// folder dialog
openFolderDialog: (parentPath?: string) => void;
closeFolderDialog: () => void;
setFolderName: (name: string) => void;
setFolderBusy: (busy: boolean) => void;
// context menu
showContextMenu: (
event: { clientX: number; clientY: number; preventDefault: () => void; stopPropagation: () => void },
target: Omit<ContextMenuState, "x" | "y">,
) => void;
closeContextMenu: () => void;
// workspace menu (topbar)
setWorkspaceMenuOpen: (open: boolean) => void;
// upload
chooseUpload: (parentPath?: string) => void;
setUploading: (uploading: boolean) => void;
setUploadParentPath: (parentPath: string) => void;
// publish dialog
openPublishDialog: (script: ScriptItem) => void;
closePublishDialog: () => void;
setReleaseNote: (note: string) => void;
setPublishVisibility: (visibility: Visibility) => void;
setPublishing: (publishing: boolean) => void;
setPublishedVersion: (version: StableVersion) => void;
clearPublishedVersion: () => void;
};
export const useUiStore = create<UiState>((set) => ({
toast: null,
createDialog: {
open: false,
creating: false,
form: initialCreateForm,
},
folderDialog: {
open: false,
parentPath: "",
name: "",
busy: false,
},
contextMenu: null,
workspaceMenuOpen: false,
upload: {
parentPath: "",
uploading: false,
},
publish: {
target: null,
releaseNote: "",
visibility: "workspace",
publishing: false,
publishedVersion: null,
},
pushToast: (toast) => set({ toast }),
dismissToast: () => set({ toast: null }),
openCreateDialog: (parentPath = "", scriptType = "notebook") =>
set((state) => ({
contextMenu: null,
createDialog: {
open: true,
creating: false,
form: { ...initialCreateForm, parentPath, scriptType },
},
})),
closeCreateDialog: () =>
set((state) => ({
createDialog: { ...state.createDialog, open: false },
})),
setCreateForm: (form) =>
set((state) => ({ createDialog: { ...state.createDialog, form } })),
setCreating: (creating) =>
set((state) => ({ createDialog: { ...state.createDialog, creating } })),
openFolderDialog: (parentPath = "") =>
set((state) => ({
contextMenu: null,
folderDialog: {
open: true,
parentPath,
name: "",
busy: false,
},
})),
closeFolderDialog: () =>
set((state) => ({
folderDialog: { ...state.folderDialog, open: false },
})),
setFolderName: (name) =>
set((state) => ({ folderDialog: { ...state.folderDialog, name } })),
setFolderBusy: (busy) =>
set((state) => ({ folderDialog: { ...state.folderDialog, busy } })),
showContextMenu: (event, target) => {
event.preventDefault();
event.stopPropagation();
const width = 188;
const height = target.kind === "file" ? 92 : 190;
set({
contextMenu: {
...target,
x: Math.min(event.clientX, window.innerWidth - width - 8),
y: Math.min(event.clientY, window.innerHeight - height - 8),
},
});
},
closeContextMenu: () => set({ contextMenu: null }),
setWorkspaceMenuOpen: (open) => set({ workspaceMenuOpen: open }),
chooseUpload: (parentPath = "") =>
set((state) => ({
contextMenu: null,
upload: { ...state.upload, parentPath },
})),
setUploading: (uploading) =>
set((state) => ({ upload: { ...state.upload, uploading } })),
setUploadParentPath: (parentPath) =>
set((state) => ({ upload: { ...state.upload, parentPath } })),
openPublishDialog: (script) =>
set((state) => ({
publish: {
...state.publish,
target: script,
releaseNote: "",
visibility: script.visibility === "private" ? "private" : "workspace",
},
})),
closePublishDialog: () =>
set((state) => ({ publish: { ...state.publish, target: null } })),
setReleaseNote: (note) =>
set((state) => ({ publish: { ...state.publish, releaseNote: note } })),
setPublishVisibility: (visibility) =>
set((state) => ({ publish: { ...state.publish, visibility } })),
setPublishing: (publishing) =>
set((state) => ({ publish: { ...state.publish, publishing } })),
setPublishedVersion: (version) =>
set((state) => ({
publish: {
...state.publish,
target: null,
publishedVersion: version,
},
})),
clearPublishedVersion: () =>
set((state) => ({ publish: { ...state.publish, publishedVersion: null } })),
}));