refactor: uiStore.ts
This commit is contained in:
@@ -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 } })),
|
||||
}));
|
||||
Reference in New Issue
Block a user