import { assertMCPIdle } from "../mcpActivity"; import { defineStore } from "pinia"; import { computed, markRaw, nextTick, reactive, ref } from "vue"; import { Events } from "@wailsio/runtime"; import { ArchiveService, EditorService, FileGUIService } from "../../bindings/pvfine/services"; import type { ItemCreateRequest, ItemCreateResult, ShopEditRequest, ShopEditResult, WorldDropEditRequest, WorldDropEditResult } from "../../bindings/pvfine/services/models"; import { useFileGUIStore } from "./fileGUI"; import { createGUIModes } from "../gui/state"; import type { EditorAnnotation, FileMeta, TreeTag, ImageReference } from "../../bindings/pvfine/services/models"; import { useArchiveStore } from "./archive"; import { useExplorerStore } from "./explorer"; import { useScriptStore } from "./script"; import { startDebugTiming } from "./debugTiming"; import { hasLongLine } from "../editorWrapping"; import { useArchiveDiffStore } from "./archiveDiff"; export type EditorPaneId = string; export type SplitOrientation = "columns" | "rows"; /** 归档差异对比专属特殊 Tab 索引常量 */ export const COMPARE_TAB_INDEX = -999; export interface ArchiveSaveProgress { phase: string; done: number; total: number; unit: string; } export interface EditorTab { index: number; path: string; title: string; dataType: number; size: number; tags: TreeTag[]; editable: boolean; original: string; // 打开时的文本(脏判定基准) text: string; // 当前编辑器内容 modified: boolean; // 后端 overlay 状态 annotations: EditorAnnotation[]; annotationsHidden: boolean; // 当前打开期间临时隐藏此文件的标注 lineWrapping: boolean; icon: ImageReference | null; fieldImage: ImageReference | null; loading: boolean; loadError: string | null; } export interface EditorPaneState { id: EditorPaneId; tabIndexes: number[]; activeKey: number | null; } export interface DraggedEditorTab { paneId: EditorPaneId; index: number; } /** 待确认的关闭动作:目标标签仍含未保存的本地草稿。 */ export type PendingTabClose = | { kind: "tab"; index: number; paneId: EditorPaneId } | { kind: "others"; keepIndex: number } | { kind: "all" }; export interface EditorLayoutPane { kind: "pane"; paneId: EditorPaneId; } export interface EditorLayoutSplit { kind: "split"; id: string; orientation: SplitOrientation; ratio: number; first: EditorLayoutNode; second: EditorLayoutNode; } export type EditorLayoutNode = EditorLayoutPane | EditorLayoutSplit; interface LayoutReplacement { node: EditorLayoutNode; found: boolean; destinationPaneId?: EditorPaneId; } /** 编辑器状态:全局文件内容 + 可递归分屏的多标签窗格 */ export const useEditorStore = defineStore("editor", () => { const initialPaneId = "pane-1"; const tabs = ref([]); // 通用 GUI 的文件草稿独立于可见标签,仍参与保存和归档关闭保护。 const guiDrafts = ref([]); const allFiles = computed(() => [...tabs.value, ...guiDrafts.value]); function getFileDraft(index: number | string): EditorTab | undefined { return allFiles.value.find((file) => typeof index === "string" ? file.path === index : file.index === index); } const paneStates = reactive>({ [initialPaneId]: { id: initialPaneId, tabIndexes: [], activeKey: null, }, }); const layout = ref({ kind: "pane", paneId: initialPaneId }); const activePaneId = ref(initialPaneId); const draggingTab = ref(null); const openingPaneId = computed(() => Object.values(paneStates).find((pane) => tabs.value.some((tab) => tab.loading && pane.activeKey === tab.index) )?.id ?? null ); const saving = ref(false); const archiveSaveStage = ref<"preparing" | "saving" | "saveAs" | "finishing" | null>(null); const archiveSaveProgress = ref(null); Events.On("archive:save-progress", (event: any) => { if (archiveSaveStage.value !== "saving" && archiveSaveStage.value !== "saveAs") return; const data = event?.data ?? event; if (typeof data?.phase !== "string" || !Number.isFinite(data.done) || !Number.isFinite(data.total)) return; archiveSaveProgress.value = { phase: data.phase, done: Math.max(0, data.done), total: Math.max(0, data.total), unit: data.unit ?? "", }; }); const guiApplying = ref(false); const guiRefreshWarning = ref(""); const script = useScriptStore(); let paneSequence = 1; let splitSequence = 0; const panes = computed(() => { const ids: EditorPaneId[] = []; collectPaneIds(layout.value, ids); return ids.map((id) => paneStates[id]).filter((pane): pane is EditorPaneState => !!pane); }); const isSplit = computed(() => panes.value.length > 1); const activePane = computed( () => paneStates[activePaneId.value] ?? panes.value[0] ?? paneStates[initialPaneId] ); const activeKey = computed({ get: () => activePane.value.activeKey, set: (value) => { activePane.value.activeKey = value; }, }); const activeTab = computed( () => tabs.value.find((tab) => tab.index === activePane.value.activeKey) ?? null ); const guiDirtyCheckers = reactive(new Map boolean>>()); function registerGUIDirtyChecker(index: number, checker: () => boolean) { let set = guiDirtyCheckers.get(index); if (!set) { set = new Set(); guiDirtyCheckers.set(index, set); } set.add(checker); return () => { const current = guiDirtyCheckers.get(index); if (current) { current.delete(checker); if (current.size === 0) { guiDirtyCheckers.delete(index); } } }; } function hasGUIDirty(index: number): boolean { const checkers = guiDirtyCheckers.get(index); if (!checkers || checkers.size === 0) return false; for (const check of checkers) { if (check()) return true; } return false; } function isTextDirty(tab: EditorTab): boolean { return tab.editable && tab.text !== tab.original; } function isDirty(tab: EditorTab): boolean { if (tab.index === COMPARE_TAB_INDEX) return useArchiveDiffStore().isDraftDirty; if (!tab.editable) return false; return isTextDirty(tab) || hasGUIDirty(tab.index); } function hasGUIOnlyDirty(tab: EditorTab): boolean { return tab.editable && !isTextDirty(tab) && hasGUIDirty(tab.index); } const dirtyCount = computed(() => allFiles.value.filter(isDirty).length); const pendingClose = ref(null); function collectPaneIds(node: EditorLayoutNode, result: EditorPaneId[]): void { if (node.kind === "pane") { result.push(node.paneId); return; } collectPaneIds(node.first, result); collectPaneIds(node.second, result); } function resolvePaneId(requested?: EditorPaneId): EditorPaneId { if (requested && paneStates[requested] && panes.value.some((pane) => pane.id === requested)) { return requested; } if (paneStates[activePaneId.value] && panes.value.some((pane) => pane.id === activePaneId.value)) { return activePaneId.value; } return initialPaneId; } function activatePane(paneId: EditorPaneId): void { if (!paneStates[paneId] || !panes.value.some((pane) => pane.id === paneId)) return; activePaneId.value = paneId; } function beginTabDrag(paneId: EditorPaneId, index: number): void { draggingTab.value = { paneId, index }; } function endTabDrag(): void { draggingTab.value = null; } function activateTab(paneId: EditorPaneId, index: number): void { const pane = paneStates[paneId]; if (!pane || !pane.tabIndexes.includes(index)) return; pane.activeKey = index; activatePane(paneId); } function addTabToPane(paneId: EditorPaneId, index: number): void { const pane = paneStates[paneId]; if (!pane) return; if (!pane.tabIndexes.includes(index)) pane.tabIndexes.push(index); pane.activeKey = index; activatePane(paneId); } /** 打开文件(已在当前窗格则激活,已在其他窗格则复用内容并建立视图引用) */ async function openFile(index: number, requestedPaneId: EditorPaneId = activePaneId.value) { const targetPaneId = resolvePaneId(requestedPaneId); const targetPane = paneStates[targetPaneId]; if (targetPane.tabIndexes.includes(index)) { script.showArchiveEditor(); activateTab(targetPaneId, index); return; } const existing = tabs.value.find((tab) => tab.index === index); if (existing) { script.showArchiveEditor(); addTabToPane(targetPaneId, index); return; } // 先占用标签名额并显示加载态;重复打开复用同一请求。 if (tabs.value.length >= 20) { throw new Error("打开的标签过多,请先关闭一些(上限 20)"); } const draft = guiDrafts.value.find((file) => file.index === index); if (draft) { guiDrafts.value = guiDrafts.value.filter((file) => file !== draft); tabs.value.push(draft); script.showArchiveEditor(); addTabToPane(targetPaneId, index); if (!draft.loading && !draft.loadError) void hydrateTabMetadata(draft, draft.path, draft.text); return; } const tab = createFileDraft(index); tabs.value.push(tab); script.showArchiveEditor(); addTabToPane(targetPaneId, index); await loadTab(tab); } /** 只加载文件内容,不创建、激活或占用编辑器标签。 */ async function openGUIFile(index: number): Promise { let file = getFileDraft(index); if (file?.loading) throw new Error("文件正在加载,请稍后再试"); if (file && !file.loadError) return file; if (!file) { file = createFileDraft(index); guiDrafts.value.push(file); } else { file.loading = true; file.loadError = null; } await loadTab(file); if (!allFiles.value.includes(file)) throw new Error("归档已切换,已忽略旧技能"); if (file.loadError) throw new Error(file.loadError); return file; } function createFileDraft(index: number): EditorTab { const knownPath = useExplorerStore().getFilePath(index) ?? ""; return reactive({ index, path: knownPath, title: knownPath.split("/").pop() || `文件 ${index}`, dataType: 0, size: 0, tags: [], editable: false, original: "", text: "", modified: false, annotations: [], annotationsHidden: false, lineWrapping: true, icon: null, fieldImage: null, loading: true, loadError: null, }); } async function loadTab(tab: EditorTab): Promise { const finish = startDebugTiming("editor.basic-request", `file=${tab.index} path=${tab.path}`); let outcome = "finished"; try { // 让 Vue 先提交加载态,再开始后端调用。 await nextTick(); if (!allFiles.value.includes(tab)) return; const meta: FileMeta | null = await EditorService.GetFileBasic(tab.index); // 关闭、重新打开或切换归档后,旧请求不能写入新标签。 if (!allFiles.value.includes(tab)) return; if (!meta) throw new Error("文件不存在或无法读取"); Object.assign(tab, { path: meta.path, title: meta.path.split("/").pop() ?? meta.path, dataType: meta.dataType, size: meta.size, tags: cleanTreeTags(meta.tags), editable: meta.editable, original: meta.text, text: meta.text, lineWrapping: !hasLongLine(meta.text), modified: meta.modified, annotations: cleanEditorAnnotations(meta.annotations), icon: meta.icon ?? null, fieldImage: meta.fieldImage ?? null, }); void hydrateTabMetadata(tab, meta.path, meta.text); } catch (error) { outcome = "failed"; if (allFiles.value.includes(tab)) { tab.loadError = error instanceof Error ? error.message : String(error); } } finally { finish(outcome); tab.loading = false; } } async function hydrateTabMetadata(tab: EditorTab, path: string, baseText: string): Promise { await nextTick(); if (!tabs.value.includes(tab) || tab.path !== path) return; const finish = startDebugTiming("editor.metadata-request", `file=${tab.index} path=${path}`); let outcome = "finished"; try { const meta: FileMeta | null = await EditorService.GetFile(tab.index); const finishApply = startDebugTiming("editor.metadata-apply", `file=${tab.index} chars=${meta?.text.length ?? 0} annotations=${meta?.annotations?.length ?? 0}`); try { if (!meta || !tabs.value.includes(tab) || tab.path !== path || meta.path !== path) return; tab.tags = cleanTreeTags(meta.tags); tab.icon = meta.icon ?? null; tab.fieldImage = meta.fieldImage ?? null; if (tab.text === baseText) { tab.annotations = cleanEditorAnnotations(meta.annotations); } } finally { finishApply(); } } catch { outcome = "failed"; // Basic text stays usable if optional metadata enrichment fails. } finally { finish(outcome); } } async function retryOpenFile(index: number): Promise { const tab = tabs.value.find((item) => item.index === index); if (!tab || tab.loading || !tab.loadError) return; tab.loading = true; tab.loadError = null; await loadTab(tab); } function closeTab(index: number, requestedPaneId: EditorPaneId = activePaneId.value): void { const paneId = resolvePaneId(requestedPaneId); const pane = paneStates[paneId]; if (!pane) return; const tab = tabs.value.find((item) => item.index === index); const tabIndex = pane.tabIndexes.indexOf(index); if (tabIndex < 0) return; pane.tabIndexes.splice(tabIndex, 1); if (pane.activeKey === index) { pane.activeKey = pane.tabIndexes[Math.min(tabIndex, pane.tabIndexes.length - 1)] ?? null; } const stillUsed = Object.values(paneStates).some((item) => item.tabIndexes.includes(index)); if (!stillUsed) { const globalTabIndex = tabs.value.findIndex((tab) => tab.index === index); if (globalTabIndex >= 0) tabs.value.splice(globalTabIndex, 1); clearExplorerSelection(tab?.path); if (index === COMPARE_TAB_INDEX) useArchiveDiffStore().close(); } if (pane.tabIndexes.length === 0 && isSplit.value) { closeSplit(paneId); } } /** 关闭所有标签,并清理分屏中对这些标签的引用。 */ function closeAllTabs(): void { closeTabsExcept(null); } /** 关闭除指定标签外的所有标签,保留该标签在已有窗格中的引用。 */ function closeOtherTabs(keepIndex: number): void { closeTabsExcept(keepIndex); } function closeTabsExcept(keepIndex: number | null): void { const removedIndexes = new Set( tabs.value .map((tab) => tab.index) .filter((index) => keepIndex === null || index !== keepIndex) ); if (removedIndexes.size === 0) return; const removedPaths = tabs.value .filter((tab) => removedIndexes.has(tab.index)) .map((tab) => tab.path); for (const pane of Object.values(paneStates)) { const oldActive = pane.activeKey; pane.tabIndexes = pane.tabIndexes.filter((index) => !removedIndexes.has(index)); if (oldActive !== null && !removedIndexes.has(oldActive)) { pane.activeKey = oldActive; } else { pane.activeKey = pane.tabIndexes[pane.tabIndexes.length - 1] ?? null; } } tabs.value = tabs.value.filter((tab) => !removedIndexes.has(tab.index)); if (removedIndexes.has(COMPARE_TAB_INDEX)) useArchiveDiffStore().close(); clearExplorerSelection(removedPaths); while (isSplit.value) { const emptyPane = panes.value.find((pane) => pane.tabIndexes.length === 0); if (!emptyPane) break; closeSplit(emptyPane.id); } } /** 把标签引用移动到另一个窗格,源窗格变空时默认自动收起。 */ function moveTab( index: number, requestedSourcePaneId: EditorPaneId, requestedTargetPaneId: EditorPaneId, closeEmptySource = true ): void { const sourcePaneId = resolvePaneId(requestedSourcePaneId); const targetPaneId = resolvePaneId(requestedTargetPaneId); if (sourcePaneId === targetPaneId) { activateTab(targetPaneId, index); return; } const source = paneStates[sourcePaneId]; const target = paneStates[targetPaneId]; if (!source || !target) return; const sourceIndex = source.tabIndexes.indexOf(index); if (sourceIndex < 0) return; source.tabIndexes.splice(sourceIndex, 1); if (source.activeKey === index) { source.activeKey = source.tabIndexes[Math.min(sourceIndex, source.tabIndexes.length - 1)] ?? null; } if (!target.tabIndexes.includes(index)) target.tabIndexes.push(index); target.activeKey = index; activePaneId.value = targetPaneId; if (closeEmptySource && source.tabIndexes.length === 0 && isSplit.value) { closeSplit(sourcePaneId); if (paneStates[targetPaneId] && panes.value.some((pane) => pane.id === targetPaneId)) { activePaneId.value = targetPaneId; } } } function nextPaneId(): EditorPaneId { paneSequence += 1; return `pane-${paneSequence}`; } function nextSplitId(): string { splitSequence += 1; return `split-${splitSequence}`; } function replacePane( node: EditorLayoutNode, targetPaneId: EditorPaneId, replacement: EditorLayoutNode ): LayoutReplacement { if (node.kind === "pane") { return node.paneId === targetPaneId ? { node: replacement, found: true } : { node, found: false }; } const first = replacePane(node.first, targetPaneId, replacement); if (first.found) return { node: { ...node, first: first.node }, found: true }; const second = replacePane(node.second, targetPaneId, replacement); if (second.found) return { node: { ...node, second: second.node }, found: true }; return { node, found: false }; } function createSplit( targetPaneId: EditorPaneId, orientation: SplitOrientation, insertBefore: boolean, cloneActiveTab: boolean ): EditorPaneId | null { const targetPane = paneStates[targetPaneId]; if (!targetPane) return null; const newPaneId = nextPaneId(); const targetNode: EditorLayoutPane = { kind: "pane", paneId: targetPaneId }; const newNode: EditorLayoutPane = { kind: "pane", paneId: newPaneId }; const replacement: EditorLayoutSplit = { kind: "split", id: nextSplitId(), orientation, ratio: 0.5, first: insertBefore ? newNode : targetNode, second: insertBefore ? targetNode : newNode, }; const result = replacePane(layout.value, targetPaneId, replacement); if (!result.found) return null; const clonedTab = cloneActiveTab ? targetPane.activeKey : null; paneStates[newPaneId] = { id: newPaneId, tabIndexes: clonedTab === null ? [] : [clonedTab], activeKey: clonedTab, }; layout.value = result.node; return newPaneId; } function split(orientation: SplitOrientation, requestedPaneId: EditorPaneId = activePaneId.value): void { const targetPaneId = resolvePaneId(requestedPaneId); const newPaneId = createSplit(targetPaneId, orientation, false, true); if (!newPaneId) return; activePaneId.value = newPaneId; } function splitAndMoveTab( index: number, requestedSourcePaneId: EditorPaneId, requestedTargetPaneId: EditorPaneId, orientation: SplitOrientation, insertBefore: boolean ): void { const sourcePaneId = resolvePaneId(requestedSourcePaneId); const targetPaneId = resolvePaneId(requestedTargetPaneId); const source = paneStates[sourcePaneId]; if (!source || !source.tabIndexes.includes(index)) return; const newPaneId = createSplit(targetPaneId, orientation, insertBefore, false); if (!newPaneId) return; moveTab(index, sourcePaneId, newPaneId, false); activePaneId.value = newPaneId; } function removePane(node: EditorLayoutNode, targetPaneId: EditorPaneId): LayoutReplacement { if (node.kind === "pane") return { node, found: false }; if (node.first.kind === "pane" && node.first.paneId === targetPaneId) { return { node: node.second, found: true, destinationPaneId: firstPaneId(node.second), }; } if (node.second.kind === "pane" && node.second.paneId === targetPaneId) { return { node: node.first, found: true, destinationPaneId: firstPaneId(node.first), }; } const first = removePane(node.first, targetPaneId); if (first.found) { return { node: { ...node, first: first.node }, found: true, destinationPaneId: first.destinationPaneId, }; } const second = removePane(node.second, targetPaneId); if (second.found) { return { node: { ...node, second: second.node }, found: true, destinationPaneId: second.destinationPaneId, }; } return { node, found: false }; } function firstPaneId(node: EditorLayoutNode): EditorPaneId { return node.kind === "pane" ? node.paneId : firstPaneId(node.first); } function mergePaneTabs(fromPaneId: EditorPaneId, toPaneId: EditorPaneId): void { const from = paneStates[fromPaneId]; const to = paneStates[toPaneId]; if (!from || !to) return; const fromActive = from.activeKey; for (const index of from.tabIndexes) { if (!to.tabIndexes.includes(index)) to.tabIndexes.push(index); } if (fromActive !== null && to.tabIndexes.includes(fromActive)) to.activeKey = fromActive; } /** 关闭指定窗格,将其标签并入剩余兄弟树的最近兄弟窗格。 */ function closeSplit(requestedPaneId: EditorPaneId = activePaneId.value): void { const currentPaneId = resolvePaneId(requestedPaneId); const currentPane = paneStates[currentPaneId]; const result = removePane(layout.value, currentPaneId); if (!currentPane || !result.found) return; const destinationPaneId = result.destinationPaneId ?? firstPaneId(result.node); mergePaneTabs(currentPaneId, destinationPaneId); layout.value = result.node; delete paneStates[currentPaneId]; delete paneGUIModes[currentPaneId]; activePaneId.value = destinationPaneId; } function findSplit(node: EditorLayoutNode, splitId: string): EditorLayoutSplit | null { if (node.kind === "pane") return null; if (node.id === splitId) return node; return findSplit(node.first, splitId) ?? findSplit(node.second, splitId); } function setSplitRatio(splitId: string, value: number): void { const splitNode = findSplit(layout.value, splitId); if (!splitNode || !Number.isFinite(value)) return; splitNode.ratio = Math.min(0.8, Math.max(0.2, value)); } /** 编辑器内容变化:只更新本地文本,写入 overlay 由保存动作显式触发。 */ function updateContent(index: number, text: string) { if (guiApplying.value) return; const tab = getFileDraft(index); if (!tab || !tab.editable) return; tab.text = text; } function toggleAnnotationsHidden(index: number): void { const tab = tabs.value.find((item) => item.index === index); if (tab) tab.annotationsHidden = !tab.annotationsHidden; } const paneGUIModes = reactive>>({}); function getGUIModes(paneId: EditorPaneId) { if (!paneGUIModes[paneId]) { paneGUIModes[paneId] = createGUIModes(); } return paneGUIModes[paneId]; } function setGUIMode(index: number, mode: "text" | "gui", requestedPaneId: EditorPaneId = activePaneId.value): void { const paneId = resolvePaneId(requestedPaneId); const modes = getGUIModes(paneId); modes.set(index, mode); } function itemCreationDrafts() { return allFiles.value.filter(isTextDirty).map(tab => ({ fileIndex: tab.index, path: tab.path, text: tab.text })); } async function createItem(request: ItemCreateRequest): Promise { assertMCPIdle(); if (saving.value || guiApplying.value) throw new Error("正在保存,请稍后再试"); const gui = useFileGUIStore(), epoch = gui.epoch; saving.value = true; guiApplying.value = true; try { await nextTick(); const result = await FileGUIService.CreateItem({ ...request, drafts: itemCreationDrafts() }); if (!result) throw new Error("未收到物品创建结果"); if (gui.epoch !== epoch) throw new Error("归档已切换,已忽略旧界面的创建结果"); for (const file of result.files ?? []) { const tab = getFileDraft(file.fileIndex); if (tab?.path === file.path && (tab.text === file.beforeText || tab.text === file.text)) { tab.text = file.text; tab.original = file.text; tab.modified = true; } } const refreshed = await Promise.allSettled([ refreshBatchFiles((result.files ?? []).map(file => file.fileIndex)), useArchiveStore().refreshInfo(), ]); if (refreshed.some(r => r.status === 'rejected')) guiRefreshWarning.value = '物品已创建,部分界面刷新失败,可重新打开文件'; return result; } finally { saving.value = false; guiApplying.value = false; } } /** 提交商店 GUI 编辑:校验草稿一致性与归档代次,成功后同步受影响标签的文本。 */ async function applyShopEdit(request: ShopEditRequest): Promise { if (saving.value) throw new Error("正在保存,请稍后再试"); const source = tabs.value.find((tab) => tab.index === request.fileIndex && tab.path === request.path); if (!source || source.text !== request.text) throw new Error("商店草稿已变化,请关闭表单并重新打开"); const gui = useFileGUIStore(); const epoch = gui.epoch; request = { ...request, drafts: allFiles.value.filter(isTextDirty).map((tab) => ({ fileIndex: tab.index, path: tab.path, text: tab.text })) }; saving.value = true; guiApplying.value = true; guiRefreshWarning.value = ""; try { await nextTick(); const result = await FileGUIService.ApplyShopEdit(request); if (!result) throw new Error("未收到商店编辑结果"); if (gui.epoch !== epoch) throw new Error("归档已切换,已忽略旧界面的编辑结果"); for (const file of result.files ?? []) { const tab = tabs.value.find((item) => item.index === file.fileIndex && item.path === file.path); if (!tab) continue; if (tab.text === file.beforeText || tab.text === file.text) { tab.text = file.text; tab.original = file.text; tab.modified = true; } } const refreshes = await Promise.allSettled([refreshBatchFiles((result.files ?? []).map((file) => file.fileIndex)), useArchiveStore().refreshInfo()]); if (refreshes.some((refresh) => refresh.status === "rejected")) { guiRefreshWarning.value = "修改已应用,部分标签信息刷新失败,可重新打开相关文件"; } return result; } finally { saving.value = false; guiApplying.value = false; } } /** 提交全局掉率 GUI 编辑:校验草稿一致性与归档代次,成功后同步受影响标签的文本。 */ async function applyWorldDropEdit(request: WorldDropEditRequest): Promise { if (saving.value) throw new Error("正在保存,请稍后再试"); const source = tabs.value.find((tab) => tab.index === request.fileIndex && tab.path === request.path); if (!source || source.text !== request.text) throw new Error("全局掉率草稿已变化,请重新加载数据"); const gui = useFileGUIStore(); const epoch = gui.epoch; saving.value = true; guiApplying.value = true; guiRefreshWarning.value = ""; try { await nextTick(); const result = await FileGUIService.ApplyWorldDropEdit(request); if (!result) throw new Error("未收到全局掉率编辑结果"); if (gui.epoch !== epoch) throw new Error("归档已切换,已忽略旧界面的编辑结果"); for (const file of result.files ?? []) { const tab = tabs.value.find((item) => item.index === file.fileIndex && item.path === file.path); if (!tab) continue; if (tab.text === file.beforeText || tab.text === file.text) { tab.text = file.text; tab.original = file.text; tab.modified = true; } } const refreshes = await Promise.allSettled([ refreshBatchFiles((result.files ?? []).map((file) => file.fileIndex)), useArchiveStore().refreshInfo(), ]); if (refreshes.some((refresh) => refresh.status === "rejected")) { guiRefreshWarning.value = "修改已应用,部分标签信息刷新失败,可重新打开相关文件"; } return result; } finally { saving.value = false; guiApplying.value = false; } } /** 通过工具菜单或快捷入口打开全局掉率文件并切换到 GUI 模式 */ async function openWorldDrop(requestedPaneId?: EditorPaneId): Promise { const targetPaneId = resolvePaneId(requestedPaneId); const nodes = await ArchiveService.ResolveFiles(["etc/worlddrop.etc", "etc/(r)worlddrop.etc"]); const target = nodes?.find((n) => n && !n.isDir && n.fileIndex >= 0); if (!target) { throw new Error("归档中未找到全局掉率文件 etc/worlddrop.etc 或 etc/(r)worlddrop.etc"); } await openFile(target.fileIndex, targetPaneId); const tab = tabs.value.find((t) => t.index === target.fileIndex); if (tab?.loadError) { throw new Error(tab.loadError); } setGUIMode(target.fileIndex, "gui", targetPaneId); return true; } async function openIndependentDrop(requestedPaneId?: EditorPaneId): Promise { const targetPaneId = resolvePaneId(requestedPaneId); const nodes = await ArchiveService.ResolveFiles(["etc/independent_drop.etc"]); const target = nodes?.find(node => node && !node.isDir && node.fileIndex >= 0); if (!target) throw new Error("此归档未包含 etc/independent_drop.etc,可能不支持独立掉落"); await openFile(target.fileIndex, targetPaneId); const tab = tabs.value.find(tab => tab.index === target.fileIndex); if (!tab) return false; if (tab.loadError) throw new Error(tab.loadError); setGUIMode(target.fileIndex, "gui", targetPaneId); return true; } /** 工具菜单与文件 GUI 共用同一标签,保留已打开文件的草稿。 */ async function openUpgrade(amplify = false, requestedPaneId?: EditorPaneId): Promise { const targetPaneId = resolvePaneId(requestedPaneId); const path = amplify ? "etc/amplifyupgrade.etc" : "etc/upgrade.etc"; const nodes = await ArchiveService.ResolveFiles([path]); const target = nodes?.find((node) => node && !node.isDir && node.fileIndex >= 0); if (!target) throw new Error(`归档中未找到${amplify ? '增幅' : '强化'}文件 ${path}`); await openFile(target.fileIndex, targetPaneId); const tab = tabs.value.find((tab) => tab.index === target.fileIndex); if (!tab) return false; if (tab.loadError) throw new Error(tab.loadError); setGUIMode(target.fileIndex, "gui", targetPaneId); return true; } async function openForge(requestedPaneId?: EditorPaneId): Promise { const targetPaneId = resolvePaneId(requestedPaneId); const path = "etc/upgrade_separate.etc"; const nodes = await ArchiveService.ResolveFiles([path]); const target = nodes?.find((node) => node && !node.isDir && node.fileIndex >= 0); if (!target) throw new Error(`归档中未找到锻造文件 ${path}`); await openFile(target.fileIndex, targetPaneId); const tab = tabs.value.find((tab) => tab.index === target.fileIndex); if (!tab) return false; if (tab.loadError) throw new Error(tab.loadError); setGUIMode(target.fileIndex, "gui", targetPaneId); return true; } async function openCeraShop(requestedPaneId?: EditorPaneId): Promise { const targetPaneId = resolvePaneId(requestedPaneId); const gui = useFileGUIStore(), epoch = gui.epoch; const paths = ["etc/cerashop.etc", "etc/(r)cerashop.etc"]; const nodes = await ArchiveService.ResolveFiles(paths); if (epoch !== gui.epoch) return false; const target = paths.map(path => nodes?.find(node => node && !node.isDir && node.fileIndex >= 0 && node.path.toLowerCase() === path)).find(node => !!node); if (!target) throw new Error(`归档中未找到商城文件 ${paths.join(" 或 ")}`); await openFile(target.fileIndex, targetPaneId); if (epoch !== gui.epoch) return false; const tab = tabs.value.find(tab => tab.index === target.fileIndex); if (!tab) return false; if (tab.loadError) throw new Error(tab.loadError); setGUIMode(target.fileIndex, "gui", targetPaneId); return true; } /** 打开或激活归档对比特殊 Tab */ function openCompareTab(requestedPaneId?: EditorPaneId): void { const targetPaneId = resolvePaneId(requestedPaneId); let tab = tabs.value.find((t) => t.index === COMPARE_TAB_INDEX); if (!tab) { tab = { index: COMPARE_TAB_INDEX, path: "__compare__", title: "归档对比", dataType: 0, size: 0, tags: [], editable: false, original: "", text: "", modified: false, annotations: [], annotationsHidden: false, lineWrapping: false, icon: null, fieldImage: null, loading: false, loadError: null, }; tabs.value.push(tab); } addTabToPane(targetPaneId, COMPARE_TAB_INDEX); const pane = paneStates[targetPaneId]; if (pane) { pane.activeKey = COMPARE_TAB_INDEX; } activePaneId.value = targetPaneId; } /** 把单个标签的本地文本写入后端 overlay,成功后重置脏基线。 */ async function saveTab(index: number): Promise { const tab = getFileDraft(index); if (!tab || !isTextDirty(tab)) return false; const text = tab.text; await EditorService.SetText(tab.index, text); const annotations = (await EditorService.GetAnnotations(tab.index)) ?? []; const current = getFileDraft(tab.index); if (current !== tab) return true; // 等待期间用户继续输入时保留其草稿,下一次保存再写入。 if (current.text === text) { current.original = text; current.annotations = cleanEditorAnnotations(annotations); } await useExplorerStore().refreshTreeTags(); return true; } /** 保存指定文件,供文件 GUI 与通用 GUI 共用。 */ async function saveFileTab(index: number): Promise { assertMCPIdle(); if (saving.value) return false; if (index === COMPARE_TAB_INDEX) return saveCompareDraft(); const tab = getFileDraft(index); if (!tab || !tab.editable) return false; if (tab && tab.editable && hasGUIDirty(tab.index)) { throw new Error(`当前文件存在未应用的界面修改,请先在界面中点击【应用修改】后再保存`); } saving.value = true; try { const saved = await saveTab(index); if (saved) await useArchiveStore().refreshInfo(); return saved; } finally { saving.value = false; } } /** 保存指定窗格的活动标签(默认当前窗格)。无修改时为空操作。 */ async function saveActiveTab(requestedPaneId?: EditorPaneId): Promise { const paneId = resolvePaneId(requestedPaneId); const index = paneStates[paneId]?.activeKey ?? null; return index === null ? false : saveFileTab(index); } /** 捕获只读对比草稿,不应用表单、不改变脏基线。 */ function archiveDiffDrafts() { const pending = allFiles.value.find(tab => hasGUIDirty(tab.index)); if (pending) throw new Error(`文件 ${pending.title || pending.path} 存在未应用的界面修改,请先点击【应用修改】后再比较`); return allFiles.value.filter(isTextDirty).map(tab => ({ fileIndex: tab.index, path: tab.path, text: tab.text })); } /** 把所有本地有修改的标签写入 overlay(PVF 写盘前调用)。 */ async function saveAllDirty(): Promise { const indexes = allFiles.value.filter((tab) => isTextDirty(tab)).map((tab) => tab.index); let saved = 0; for (const index of indexes) { if (await saveTab(index)) saved += 1; } if (await saveCompareDraft()) saved += 1; if (saved > 0) await useArchiveStore().refreshInfo(); return saved; } async function saveCompareDraft(): Promise { if (!tabs.value.some(tab => tab.index === COMPARE_TAB_INDEX)) return false; const diff = useArchiveDiffStore(); if (!diff.isDraftDirty) return false; if (diff.merging || !await diff.mergeText(diff.draftPath, diff.draftText)) { throw new Error("对比草稿尚未保存,请稍后重试"); } return true; } /** * 改写某个 `<表号::键名>` 占位符背后的显示文本。 * 只改字符串表(.str)的对应条目,脚本里的占位符保持不变。 */ async function setPlaceholderText( index: number, tableIndex: number, key: string, text: string ): Promise { await EditorService.SetPlaceholderText(index, tableIndex, key, text); const annotations = (await EditorService.GetAnnotations(index)) ?? []; const current = tabs.value.find((item) => item.index === index); if (current) { current.annotations = cleanEditorAnnotations(annotations); current.modified = true; } await useExplorerStore().refreshTreeTags(); await useArchiveStore().refreshInfo(); } /** 保存到源文件 */ async function save() { assertMCPIdle(); if (saving.value) throw new Error("正在保存,请稍后再试"); const archive = useArchiveStore(); const guiDirtyTab = allFiles.value.find((tab) => tab.editable && hasGUIDirty(tab.index)); if (guiDirtyTab) { throw new Error(`文件 ${guiDirtyTab.title || guiDirtyTab.path} 存在未应用的界面修改,请先在界面中点击【应用修改】后再保存`); } saving.value = true; archiveSaveStage.value = "preparing"; archiveSaveProgress.value = null; try { await nextTick(); await saveAllDirty(); archiveSaveStage.value = "saving"; const info = await EditorService.Save(); archiveSaveStage.value = "finishing"; archive.info = info; // 保存成功后,文本与基准重置(overlay 已清空) for (const tab of allFiles.value) { tab.original = tab.text; tab.modified = false; } return info; } finally { archiveSaveStage.value = null; archiveSaveProgress.value = null; saving.value = false; } } /** 另存为新 PVF,返回保存路径(取消返回 null) */ async function saveAs() { assertMCPIdle(); if (saving.value) throw new Error("正在保存,请稍后再试"); const archive = useArchiveStore(); const guiDirtyTab = allFiles.value.find((tab) => tab.editable && hasGUIDirty(tab.index)); if (guiDirtyTab) { throw new Error(`文件 ${guiDirtyTab.title || guiDirtyTab.path} 存在未应用的界面修改,请先在界面中点击【应用修改】后再保存`); } saving.value = true; archiveSaveStage.value = "preparing"; archiveSaveProgress.value = null; try { await nextTick(); await saveAllDirty(); archiveSaveStage.value = "saveAs"; const path = await EditorService.SaveAsDialog(); if (path) { archiveSaveStage.value = "finishing"; await archive.refreshInfo(); } return path || null; } finally { archiveSaveStage.value = null; archiveSaveProgress.value = null; saving.value = false; } } function clearExplorerSelection(paths: string | string[] | undefined): void { if (!paths) return; const explorer = useExplorerStore(); const selection = explorer.selectedKey; if (!selection) return; const closedPaths = new Set(Array.isArray(paths) ? paths : [paths]); if (closedPaths.has(selection)) explorer.selectedKey = null; } /** 关闭标签:存在未保存本地编辑时先请求确认。 */ function requestCloseTab(index: number, requestedPaneId: EditorPaneId = activePaneId.value): void { if (pendingClose.value) return; const tab = tabs.value.find((item) => item.index === index); if (!tab) return; if (isDirty(tab)) { pendingClose.value = { kind: "tab", index, paneId: resolvePaneId(requestedPaneId) }; return; } closeTab(index, requestedPaneId); } /** 关闭其它标签:有待确认的脏标签时先请求确认。 */ function requestCloseOthers(keepIndex: number): void { if (pendingClose.value) return; const hasDirty = tabs.value.some((tab) => tab.index !== keepIndex && isDirty(tab)); if (hasDirty) { pendingClose.value = { kind: "others", keepIndex }; return; } closeOtherTabs(keepIndex); } /** 关闭所有标签:有待确认的脏标签时先请求确认。 */ function requestCloseAll(): void { if (pendingClose.value) return; if (tabs.value.some((tab) => isDirty(tab))) { pendingClose.value = { kind: "all" }; return; } closeAllTabs(); } /** 确认丢弃未保存编辑并执行挂起的关闭动作。 */ function confirmPendingClose(): void { const action = pendingClose.value; if (!action) return; pendingClose.value = null; if (action.kind === "tab") closeTab(action.index, action.paneId); else if (action.kind === "others") closeOtherTabs(action.keepIndex); else closeAllTabs(); } function cancelPendingClose(): void { pendingClose.value = null; } async function refreshAnnotations() { await Promise.all( allFiles.value.filter((tab) => !tab.loading && !tab.loadError && tab.index >= 0 && tab.index !== COMPARE_TAB_INDEX).map(async (tab) => { const text = tab.text; const annotations = (await EditorService.GetAnnotationsForText(tab.index, text)) ?? []; const current = allFiles.value.find((item) => item.index === tab.index); if (!current || current.text !== text) return; current.annotations = cleanEditorAnnotations(annotations); }) ); } /** 重新读取当前 renderer 生成的文本,但保留已修改标签的脏基线。 */ async function refreshRenderedText() { await Promise.all( allFiles.value.filter((tab) => !tab.loading && !tab.loadError && tab.index >= 0 && tab.index !== COMPARE_TAB_INDEX).map(async (tab) => { const meta = await EditorService.GetFile(tab.index); const current = allFiles.value.find((item) => item.index === tab.index); if (!current || !meta) return; current.path = meta.path; current.title = meta.path.split("/").pop() ?? meta.path; current.dataType = meta.dataType; current.size = meta.size; current.editable = meta.editable; current.tags = cleanTreeTags(meta.tags); current.icon = meta.icon ?? null; current.fieldImage = meta.fieldImage ?? null; current.modified = meta.modified; // 本地有未保存编辑时保留编辑器内容,避免重载规则覆盖用户草稿。 if (!isDirty(current)) { current.text = meta.text; if (!meta.modified) current.original = meta.text; } if (isDirty(current)) { const text = current.text; const annotations = await EditorService.GetAnnotationsForText(current.index, text); if (current.text === text && allFiles.value.includes(current)) current.annotations = cleanEditorAnnotations(annotations); } else current.annotations = cleanEditorAnnotations(meta.annotations); }) ); } /** 批处理写入 overlay 后刷新已经打开的标签,但保留原始文本基准。 */ async function refreshBatchFiles(indexes: number[]) { const uniqueIndexes = [...new Set(indexes)]; await Promise.all( uniqueIndexes.map(async (index) => { const current = allFiles.value.find((tab) => tab.index === index); if (!current) return; const meta = await EditorService.GetFile(index); if (!meta) return; current.path = meta.path; current.modified = meta.modified; current.tags = cleanTreeTags(meta.tags); current.annotations = cleanEditorAnnotations(meta.annotations); current.icon = meta.icon ?? null; current.fieldImage = meta.fieldImage ?? null; // 本地有未保存编辑时保留编辑器内容,让保存动作以用户文本为准。 if (!isDirty(current)) current.text = meta.text; }) ); } /** 文件表变化后按路径重新绑定标签,并刷新被自动修改的 lst 标签。 */ async function refreshAfterArchiveChange( refreshPaths: string[] = [], resetOriginal = false ): Promise { // 结构变更后旧索引已失效;标签中的本地文本保留,后续保存会按新索引写入。 if (allFiles.value.length === 0) return; const visibleTabs = new Set(tabs.value); const currentTabs = [...allFiles.value]; const nodes = (await ArchiveService.ResolveFiles(currentTabs.map((tab) => tab.path))) ?? []; const normalize = (p: string) => p.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "").toLowerCase(); const indexByPath = new Map( nodes .filter((node): node is NonNullable => !!node && !node.isDir) .map((node) => [normalize(node.path), node]) ); const nextIndexByOld = new Map(); const remainingTabs: EditorTab[] = []; const pathsToRefresh = new Set(refreshPaths.map(normalize)); for (const tab of currentTabs) { if (tab.index === COMPARE_TAB_INDEX || tab.index < 0) { nextIndexByOld.set(tab.index, tab.index); remainingTabs.push(tab); continue; } const node = indexByPath.get(normalize(tab.path)); if (!node) continue; nextIndexByOld.set(tab.index, node.fileIndex); tab.index = node.fileIndex; tab.path = node.path; tab.title = node.path.split("/").pop() ?? node.path; tab.size = node.size; tab.dataType = node.dataType; tab.tags = cleanTreeTags(node.tags); tab.icon = node.icon ?? null; tab.fieldImage = node.fieldImage ?? null; remainingTabs.push(tab); } for (const pane of Object.values(paneStates)) { const oldActive = pane.activeKey; const nextIndexes = pane.tabIndexes .map((index) => nextIndexByOld.get(index)) .filter((index): index is number => index !== undefined); pane.tabIndexes.splice(0, pane.tabIndexes.length, ...nextIndexes); pane.activeKey = (oldActive === null ? null : nextIndexByOld.get(oldActive)) ?? pane.tabIndexes[pane.tabIndexes.length - 1] ?? null; } tabs.value = remainingTabs.filter((tab) => visibleTabs.has(tab)); guiDrafts.value = remainingTabs.filter((tab) => !visibleTabs.has(tab)); await Promise.all( remainingTabs .filter((tab) => tab.index >= 0 && pathsToRefresh.has(normalize(tab.path))) .map(async (tab) => { const meta = await EditorService.GetFile(tab.index); if (!meta) return; tab.path = meta.path; tab.title = meta.path.split("/").pop() ?? meta.path; tab.dataType = meta.dataType; tab.size = meta.size; tab.tags = cleanTreeTags(meta.tags); tab.icon = meta.icon ?? null; tab.fieldImage = meta.fieldImage ?? null; if (resetOriginal) { // 归档整体重载:索引已失效,直接采用新内容。 tab.text = meta.text; tab.original = meta.text; tab.modified = meta.modified; } else { tab.modified = meta.modified; // 本地有未保存编辑时保留编辑器内容,不被后端结果覆盖。 if (!isDirty(tab)) tab.text = meta.text; } tab.annotations = cleanEditorAnnotations(meta.annotations); }) ); while (isSplit.value) { const emptyPane = panes.value.find((pane) => pane.tabIndexes.length === 0); if (!emptyPane) break; closeSplit(emptyPane.id); } } /** Called only while the MCP preparation barrier holds and drafts are clean. */ async function syncMCPWorkspace(): Promise { await refreshAfterArchiveChange(allFiles.value.map(tab => tab.path), true); } async function refreshOpenTabTags(): Promise { const currentTabs = tabs.value.filter((tab) => !tab.loading && !tab.loadError && tab.index >= 0 && tab.index !== COMPARE_TAB_INDEX); await Promise.all( currentTabs.map(async (tab) => { const meta = await EditorService.GetFile(tab.index).catch(() => null); const current = tabs.value.find((item) => item.index === tab.index); if (!current || !meta) return; current.tags = cleanTreeTags(meta.tags); current.icon = meta.icon ?? null; current.fieldImage = meta.fieldImage ?? null; current.annotations = cleanEditorAnnotations(meta.annotations); }) ); } Events.On("archive:reloaded", () => { void refreshAfterArchiveChange(allFiles.value.map((tab) => tab.path), true); }); Events.On("archive:opened", () => { guiDrafts.value = []; }); Events.On("archive:closed", () => { guiDrafts.value = []; pendingClose.value = null; draggingTab.value = null; guiDirtyCheckers.clear(); for (const modes of Object.values(paneGUIModes)) modes.reset(); closeAllTabs(); }); /** * 归档对比合并专用刷新:强制用后端最新文本与元数据覆盖对应标签, * 并重置 dirty 基线 (original = text),确保主编辑器与合入内容严格一致。 */ async function refreshMergedFiles(paths: string[]): Promise { if (!paths || paths.length === 0 || allFiles.value.length === 0) return; const normalize = (p: string) => p.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "").toLowerCase(); const pathSet = new Set(paths.map(normalize)); const targetTabs = allFiles.value.filter((tab) => pathSet.has(normalize(tab.path))); if (targetTabs.length === 0) return; const nodes = (await ArchiveService.ResolveFiles(targetTabs.map((tab) => tab.path))) ?? []; const nodeByPath = new Map( nodes .filter((node): node is NonNullable => !!node && !node.isDir) .map((node) => [normalize(node.path), node]) ); await Promise.all( targetTabs.map(async (tab) => { const node = nodeByPath.get(normalize(tab.path)); if (!node) return; tab.index = node.fileIndex; const meta = await EditorService.GetFile(node.fileIndex); if (!meta) return; tab.path = meta.path; tab.title = meta.path.split("/").pop() ?? meta.path; tab.dataType = meta.dataType; tab.size = meta.size; tab.tags = cleanTreeTags(meta.tags); tab.icon = meta.icon ?? null; tab.fieldImage = meta.fieldImage ?? null; tab.text = meta.text; tab.original = meta.text; tab.modified = meta.modified; tab.annotations = cleanEditorAnnotations(meta.annotations); }) ); } // 脚本或批处理在别的窗口应用了变更时,本窗口的标签不会自己更新。事件是 // 广播的,所以这里同时覆盖同窗口(发起方已自行刷新,重复刷新是幂等的) // 和独立脚本窗口发起的情况。 Events.On("archive:batch-applied", (event: any) => { const data = event?.data ?? event; const paths = Array.isArray(data?.paths) ? data.paths.filter((p: unknown): p is string => typeof p === "string") : []; if (data?.source === "archive-diff" || data?.source === "archive-diff-merge" || paths.length > 0) { if (data?.structural) { void refreshAfterArchiveChange(paths, true); } else { void refreshMergedFiles(paths); } return; } // 结构变更会让后续条目重新编号,必须按路径重新解析树与标签。 if (data?.structural) { void refreshAfterArchiveChange([], false); return; } const indexes = (data?.fileIndexes ?? []).filter( (value: unknown): value is number => typeof value === "number", ); if (indexes.length > 0) void refreshBatchFiles(indexes); }); Events.On("archive:registrations-changed", (event: any) => { const data = event?.data ?? event; const indexes = (data?.fileIndexes ?? []).filter( (value: unknown): value is number => typeof value === "number", ); if (indexes.length > 0) void refreshBatchFiles(indexes); }); Events.On("archive:index-ready", () => { void refreshOpenTabTags(); }); Events.On("archive:index-updated", () => { void refreshOpenTabTags(); }); return { tabs, getFileDraft, openGUIFile, itemCreationDrafts, createItem, panes, layout, activeKey, activeTab, dirtyCount, activePaneId, draggingTab, isSplit, opening: computed(() => tabs.value.some((tab) => tab.loading)), openingPaneId, saving, archiveSaveStage, archiveSaveProgress, guiApplying, guiRefreshWarning, applyShopEdit, applyWorldDropEdit, openWorldDrop, openIndependentDrop, getGUIModes, setGUIMode, registerGUIDirtyChecker, hasGUIDirty, isTextDirty, isDirty, hasGUIOnlyDirty, pendingClose, openFile, retryOpenFile, activatePane, beginTabDrag, endTabDrag, activateTab, closeTab, closeAllTabs, closeOtherTabs, requestCloseTab, requestCloseOthers, requestCloseAll, confirmPendingClose, cancelPendingClose, moveTab, split, splitAndMoveTab, closeSplit, setSplitRatio, updateContent, toggleAnnotationsHidden, setPlaceholderText, saveTab, saveFileTab, archiveDiffDrafts, openUpgrade, openForge, openCeraShop, openCompareTab, saveActiveTab, saveAllDirty, save, saveAs, refreshAnnotations, refreshRenderedText, refreshBatchFiles, refreshAfterArchiveChange, syncMCPWorkspace, refreshMergedFiles, }; }); function cleanTreeTags(tags: (TreeTag | null)[] | null | undefined): TreeTag[] { return (tags ?? []).filter((tag): tag is TreeTag => !!tag); } /** 标注作为不可变快照使用,避免 Vue 为大文件建立深层代理。 */ function cleanEditorAnnotations(annotations: (EditorAnnotation | null)[] | null | undefined): EditorAnnotation[] { return markRaw((annotations ?? []).filter((annotation): annotation is EditorAnnotation => !!annotation)); }