fix: unified handling of editor reloading (#32)
Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { dispatchAssistantAction } from '../../navigation/assistantActionDispatc
|
|||||||
import { useA2UISurface } from '../../a2ui/useA2UISurface';
|
import { useA2UISurface } from '../../a2ui/useA2UISurface';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import { ChatTranscript } from '../ChatSurface';
|
import { ChatTranscript } from '../ChatSurface';
|
||||||
|
import { useEntityLoader } from '../../navigation/useEntityEditor';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import '../../styles/chatSurface.css';
|
import '../../styles/chatSurface.css';
|
||||||
import './ChatPanel.css';
|
import './ChatPanel.css';
|
||||||
@@ -89,31 +90,43 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load conversation and messages
|
// Load conversation data via shared entity loader (handles activeProject
|
||||||
const loadData = useCallback(async () => {
|
// guard, cancellation on ID change, and close-tab-on-not-found).
|
||||||
try {
|
const fetchChatData = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
const [conv, msgs, modelsResult] = await Promise.all([
|
const [conv, msgs, modelsResult] = await Promise.all([
|
||||||
window.electronAPI?.chat.getConversation(conversationId),
|
window.electronAPI?.chat.getConversation(id),
|
||||||
window.electronAPI?.chat.getHistory(conversationId),
|
window.electronAPI?.chat.getHistory(id),
|
||||||
window.electronAPI?.chat.getAvailableModels()
|
window.electronAPI?.chat.getAvailableModels(),
|
||||||
]);
|
]);
|
||||||
|
if (!conv) return null;
|
||||||
|
|
||||||
if (conv) setConversation(conv);
|
return { conversation: conv, messages: msgs, models: modelsResult?.models };
|
||||||
if (msgs) {
|
},
|
||||||
setMessages(msgs);
|
[],
|
||||||
replayFromMessages(msgs);
|
);
|
||||||
|
|
||||||
|
useEntityLoader(conversationId, fetchChatData, {
|
||||||
|
onLoaded: (data) => {
|
||||||
|
setConversation(data.conversation);
|
||||||
|
if (data.messages) {
|
||||||
|
setMessages(data.messages);
|
||||||
|
replayFromMessages(data.messages);
|
||||||
}
|
}
|
||||||
if (modelsResult?.models) setAvailableModels(modelsResult.models);
|
if (data.models) setAvailableModels(data.models);
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Failed to load chat data:', error);
|
onReset: () => {
|
||||||
}
|
setConversation(null);
|
||||||
}, [conversationId, replayFromMessages]);
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check API key readiness
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkReady();
|
checkReady();
|
||||||
loadData();
|
}, [checkReady]);
|
||||||
|
|
||||||
// Subscribe to stream events
|
// Subscribe to stream / tool / title / token events
|
||||||
|
useEffect(() => {
|
||||||
const unsubDelta = window.electronAPI?.chat.onStreamDelta((data) => {
|
const unsubDelta = window.electronAPI?.chat.onStreamDelta((data) => {
|
||||||
if (data.conversationId === conversationId) {
|
if (data.conversationId === conversationId) {
|
||||||
appendStreamDelta(data.delta);
|
appendStreamDelta(data.delta);
|
||||||
@@ -164,7 +177,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
unsubTitle?.();
|
unsubTitle?.();
|
||||||
unsubTokenUsage?.();
|
unsubTokenUsage?.();
|
||||||
};
|
};
|
||||||
}, [conversationId, loadData, scrollToBottom, checkReady, appendStreamDelta, recordToolCall, recordToolResult]);
|
}, [conversationId, scrollToBottom, appendStreamDelta, recordToolCall, recordToolResult]);
|
||||||
|
|
||||||
// Scroll on new messages or streaming content
|
// Scroll on new messages or streaming content
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { InsertModal } from '../InsertModal';
|
|||||||
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
|
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
|
||||||
import { openEntityTab } from '../../navigation/tabPolicy';
|
import { openEntityTab } from '../../navigation/tabPolicy';
|
||||||
import { EditorRoute, resolveEditorRoute } from '../../navigation/editorRouting';
|
import { EditorRoute, resolveEditorRoute } from '../../navigation/editorRouting';
|
||||||
|
import { useEntityLoader, useSaveShortcut } from '../../navigation/useEntityEditor';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import documentationContent from '../../../../DOCUMENTATION.md?raw';
|
import documentationContent from '../../../../DOCUMENTATION.md?raw';
|
||||||
import apiDocumentationContent from '../../../../API.md?raw';
|
import apiDocumentationContent from '../../../../API.md?raw';
|
||||||
@@ -169,29 +170,25 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
// Fetch full post data from backend
|
// Fetch full post data from backend
|
||||||
|
const fetchPost = useCallback(
|
||||||
|
(id: string) => window.electronAPI?.posts.get(id).then((p) => (p as PostData) || null) ?? Promise.resolve(null),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const [post, setPost] = useState<PostData | null>(null);
|
const [post, setPost] = useState<PostData | null>(null);
|
||||||
const [isLoadingPost, setIsLoadingPost] = useState(true);
|
|
||||||
// Track whether form state has been initialized from post data
|
// Track whether form state has been initialized from post data
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const { isLoading: isLoadingPost } = useEntityLoader<PostData>(postId, fetchPost, {
|
||||||
let cancelled = false;
|
onLoaded: (loadedPost) => {
|
||||||
setIsLoadingPost(true);
|
setPost(loadedPost);
|
||||||
setIsInitialized(false);
|
useAppStore.getState().updatePost(postId, loadedPost as Partial<PostData>);
|
||||||
window.electronAPI?.posts.get(postId).then((fetchedPost) => {
|
},
|
||||||
if (cancelled) return;
|
onReset: () => {
|
||||||
if (fetchedPost) {
|
setPost(null);
|
||||||
setPost(fetchedPost as PostData);
|
setIsInitialized(false);
|
||||||
// Also update the store so other components have the full data
|
},
|
||||||
useAppStore.getState().updatePost(postId, fetchedPost as Partial<PostData>);
|
});
|
||||||
} else {
|
|
||||||
// Post doesn't exist, close the tab
|
|
||||||
closeTab(postId);
|
|
||||||
}
|
|
||||||
setIsLoadingPost(false);
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [postId, closeTab]);
|
|
||||||
|
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
@@ -705,17 +702,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Save on Ctrl+S
|
// Save on Ctrl+S
|
||||||
useEffect(() => {
|
useSaveShortcut(handleSave);
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSave();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [handleSave]);
|
|
||||||
|
|
||||||
// Listen for menu events
|
// Listen for menu events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1055,6 +1042,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
||||||
const { t: tr } = useI18n();
|
const { t: tr } = useI18n();
|
||||||
const { media, updateMedia, showErrorModal, showConfirmDeleteModal, openTab } = useAppStore();
|
const { media, updateMedia, showErrorModal, showConfirmDeleteModal, openTab } = useAppStore();
|
||||||
|
const activeProjectId = useAppStore((s) => s.activeProject?.id ?? null);
|
||||||
const item = media.find(m => m.id === mediaId);
|
const item = media.find(m => m.id === mediaId);
|
||||||
|
|
||||||
const [title, setTitle] = useState(item?.title || '');
|
const [title, setTitle] = useState(item?.title || '');
|
||||||
@@ -1081,12 +1069,13 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
|||||||
|
|
||||||
// Load project language setting
|
// Load project language setting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!activeProjectId) return;
|
||||||
window.electronAPI?.meta.getProjectMetadata().then(metadata => {
|
window.electronAPI?.meta.getProjectMetadata().then(metadata => {
|
||||||
if (metadata?.mainLanguage) {
|
if (metadata?.mainLanguage) {
|
||||||
setProjectLanguage(metadata.mainLanguage);
|
setProjectLanguage(metadata.mainLanguage);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, [activeProjectId]);
|
||||||
|
|
||||||
// Close quick actions menu when clicking outside
|
// Close quick actions menu when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1152,7 +1141,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
|||||||
// Load linked posts for this media and fetch their titles
|
// Load linked posts for this media and fetch their titles
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadLinkedPosts = async () => {
|
const loadLinkedPosts = async () => {
|
||||||
if (!mediaId) return;
|
if (!mediaId || !activeProjectId) return;
|
||||||
try {
|
try {
|
||||||
const links = await window.electronAPI?.postMedia.getForMedia(mediaId);
|
const links = await window.electronAPI?.postMedia.getForMedia(mediaId);
|
||||||
if (links) {
|
if (links) {
|
||||||
@@ -1172,7 +1161,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadLinkedPosts();
|
loadLinkedPosts();
|
||||||
}, [mediaId]);
|
}, [mediaId, activeProjectId]);
|
||||||
|
|
||||||
// Fetch posts for the picker when it opens
|
// Fetch posts for the picker when it opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import type { ChatModel } from '../../types/electron';
|
import type { ChatModel } from '../../types/electron';
|
||||||
|
import { useEntityLoader } from '../../navigation/useEntityEditor';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import './ImportAnalysisView.css';
|
import './ImportAnalysisView.css';
|
||||||
|
|
||||||
@@ -162,7 +163,6 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
|
|||||||
const [wxrFilePath, setWxrFilePath] = useState<string | null>(null);
|
const [wxrFilePath, setWxrFilePath] = useState<string | null>(null);
|
||||||
const [report, setReport] = useState<AnalysisReport | null>(null);
|
const [report, setReport] = useState<AnalysisReport | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingDefinition, setIsLoadingDefinition] = useState(true);
|
|
||||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||||
const [progressStep, setProgressStep] = useState<string>('');
|
const [progressStep, setProgressStep] = useState<string>('');
|
||||||
const [progressDetail, setProgressDetail] = useState<string>('');
|
const [progressDetail, setProgressDetail] = useState<string>('');
|
||||||
@@ -308,31 +308,33 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
|
|||||||
await persistReport(updatedReport);
|
await persistReport(updatedReport);
|
||||||
}, [report, persistReport]);
|
}, [report, persistReport]);
|
||||||
|
|
||||||
// Load definition on mount
|
// Load definition via shared entity loader (handles activeProject guard,
|
||||||
useEffect(() => {
|
// cancellation on ID change, and close-tab-on-not-found).
|
||||||
const load = async () => {
|
const fetchDefinition = useCallback(
|
||||||
setIsLoadingDefinition(true);
|
(id: string) => window.electronAPI?.importDefinitions.get(id) ?? Promise.resolve(null),
|
||||||
try {
|
[],
|
||||||
const def = await window.electronAPI?.importDefinitions.get(definitionId);
|
);
|
||||||
if (def) {
|
const { isLoading: isLoadingDefinition } = useEntityLoader(definitionId, fetchDefinition, {
|
||||||
setName(def.name);
|
onLoaded: (def) => {
|
||||||
if (def.uploadsFolderPath) setUploadsFolder(def.uploadsFolderPath);
|
setName(def.name);
|
||||||
if (def.wxrFilePath) setWxrFilePath(def.wxrFilePath);
|
setUploadsFolder(def.uploadsFolderPath ?? null);
|
||||||
if (def.lastAnalysisResult) {
|
setWxrFilePath(def.wxrFilePath ?? null);
|
||||||
const parsed = typeof def.lastAnalysisResult === 'string'
|
if (def.lastAnalysisResult) {
|
||||||
? JSON.parse(def.lastAnalysisResult)
|
const parsed = typeof def.lastAnalysisResult === 'string'
|
||||||
: def.lastAnalysisResult;
|
? JSON.parse(def.lastAnalysisResult)
|
||||||
setReport(parsed as AnalysisReport);
|
: def.lastAnalysisResult;
|
||||||
}
|
setReport(parsed as AnalysisReport);
|
||||||
}
|
} else {
|
||||||
} catch (error) {
|
setReport(null);
|
||||||
console.error('Failed to load import definition:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingDefinition(false);
|
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
load();
|
onReset: () => {
|
||||||
}, [definitionId]);
|
setName(t('importAnalysis.untitledImport'));
|
||||||
|
setUploadsFolder(null);
|
||||||
|
setWxrFilePath(null);
|
||||||
|
setReport(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleNameBlur = useCallback(async () => {
|
const handleNameBlur = useCallback(async () => {
|
||||||
const trimmed = name.trim() || t('importAnalysis.untitledImport');
|
const trimmed = name.trim() || t('importAnalysis.untitledImport');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Tree } from 'react-arborist';
|
import { Tree } from 'react-arborist';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
|
import { useAppStore } from '../../store';
|
||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi';
|
import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi';
|
||||||
import { PageInput } from '../PageInput';
|
import { PageInput } from '../PageInput';
|
||||||
@@ -185,6 +186,7 @@ function renderMenuKindIcon(kind: MenuItemData['kind']): React.ReactNode {
|
|||||||
|
|
||||||
export const MenuEditorView: React.FC = () => {
|
export const MenuEditorView: React.FC = () => {
|
||||||
const { t: tr } = useI18n();
|
const { t: tr } = useI18n();
|
||||||
|
const activeProjectId = useAppStore((s) => s.activeProject?.id ?? null);
|
||||||
const [items, setItems] = useState<MenuItemData[]>([]);
|
const [items, setItems] = useState<MenuItemData[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -217,6 +219,7 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!activeProjectId) return;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -258,7 +261,7 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
void load();
|
void load();
|
||||||
}, [tr]);
|
}, [tr, activeProjectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useAppStore } from '../../store';
|
||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import './MetadataDiffPanel.css';
|
import './MetadataDiffPanel.css';
|
||||||
@@ -42,6 +43,7 @@ type ScanPhase = 'idle' | 'loading-stats' | 'scanning' | 'complete';
|
|||||||
|
|
||||||
export const MetadataDiffPanel: React.FC = () => {
|
export const MetadataDiffPanel: React.FC = () => {
|
||||||
const { t: tr } = useI18n();
|
const { t: tr } = useI18n();
|
||||||
|
const activeProjectId = useAppStore((s) => s.activeProject?.id ?? null);
|
||||||
const [stats, setStats] = useState<TableStats | null>(null);
|
const [stats, setStats] = useState<TableStats | null>(null);
|
||||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||||
const [scanPhase, setScanPhase] = useState<ScanPhase>('idle');
|
const [scanPhase, setScanPhase] = useState<ScanPhase>('idle');
|
||||||
@@ -51,6 +53,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
|
|
||||||
// Load initial stats
|
// Load initial stats
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!activeProjectId) return;
|
||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
setScanPhase('loading-stats');
|
setScanPhase('loading-stats');
|
||||||
try {
|
try {
|
||||||
@@ -65,7 +68,7 @@ export const MetadataDiffPanel: React.FC = () => {
|
|||||||
setScanPhase('idle');
|
setScanPhase('idle');
|
||||||
};
|
};
|
||||||
loadStats();
|
loadStats();
|
||||||
}, [tr]);
|
}, [tr, activeProjectId]);
|
||||||
|
|
||||||
// Subscribe to task progress
|
// Subscribe to task progress
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ScriptData } from '../../../main/shared/electronApi';
|
|||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import { BDS_EVENT_SCRIPTS_CHANGED, dispatchWindowEvent } from '../../utils';
|
import { BDS_EVENT_SCRIPTS_CHANGED, dispatchWindowEvent } from '../../utils';
|
||||||
import { getPythonRuntimeManager } from '../../python/runtimeManagerInstance';
|
import { getPythonRuntimeManager } from '../../python/runtimeManagerInstance';
|
||||||
|
import { useEntityLoader, useSaveShortcut } from '../../navigation/useEntityEditor';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
import './ScriptsView.css';
|
import './ScriptsView.css';
|
||||||
@@ -46,6 +47,12 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
|||||||
const { t, language } = useI18n();
|
const { t, language } = useI18n();
|
||||||
const appendPanelOutputEntry = useAppStore((state) => state.appendPanelOutputEntry);
|
const appendPanelOutputEntry = useAppStore((state) => state.appendPanelOutputEntry);
|
||||||
const closeTab = useAppStore((state) => state.closeTab);
|
const closeTab = useAppStore((state) => state.closeTab);
|
||||||
|
|
||||||
|
const fetchScript = useCallback(
|
||||||
|
(id: string) => window.electronAPI?.scripts.get(id) ?? Promise.resolve(null),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const [script, setScript] = useState<ScriptData | null>(null);
|
const [script, setScript] = useState<ScriptData | null>(null);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [slug, setSlug] = useState('');
|
const [slug, setSlug] = useState('');
|
||||||
@@ -63,6 +70,65 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
|||||||
// Token incremented to signal Monaco to remount with fresh defaultValue.
|
// Token incremented to signal Monaco to remount with fresh defaultValue.
|
||||||
// Prevents controlled-mode cursor jumps during typing.
|
// Prevents controlled-mode cursor jumps during typing.
|
||||||
const [monacoResetToken, setMonacoResetToken] = useState(0);
|
const [monacoResetToken, setMonacoResetToken] = useState(0);
|
||||||
|
// Ref for entrypoint refresh cancellation — survives across renders
|
||||||
|
// without triggering the entityLoader effect.
|
||||||
|
const entrypointCancelRef = useRef(false);
|
||||||
|
|
||||||
|
useEntityLoader<ScriptData>(scriptId, fetchScript, {
|
||||||
|
onLoaded: (loadedScript) => {
|
||||||
|
setScript(loadedScript);
|
||||||
|
setTitle(loadedScript.title || '');
|
||||||
|
setSlug(toFunctionSlug(loadedScript.slug || loadedScript.title || ''));
|
||||||
|
setKind(loadedScript.kind || 'utility');
|
||||||
|
setEntrypoint(loadedScript.entrypoint || 'render');
|
||||||
|
setEnabled(loadedScript.enabled ?? true);
|
||||||
|
setScriptContent(loadedScript.content || '');
|
||||||
|
setMonacoResetToken(prev => prev + 1);
|
||||||
|
const normalizedExisting = toFunctionSlug(loadedScript.slug || loadedScript.title || '');
|
||||||
|
setIsSlugManuallyEdited(normalizedExisting !== toFunctionSlug(loadedScript.title || ''));
|
||||||
|
|
||||||
|
// Refresh entrypoints asynchronously
|
||||||
|
entrypointCancelRef.current = true; // cancel any pending refresh
|
||||||
|
const cancelToken = {};
|
||||||
|
entrypointCancelRef.current = false;
|
||||||
|
const refreshEntrypoints = async () => {
|
||||||
|
try {
|
||||||
|
const runtimeManager = getPythonRuntimeManager();
|
||||||
|
const discoveredEntrypoints = await runtimeManager.inspectEntrypoints(
|
||||||
|
loadedScript.content || '',
|
||||||
|
{ cacheKey: buildCacheKey(loadedScript, loadedScript.content || '') },
|
||||||
|
);
|
||||||
|
const available = withMainEntrypoint(discoveredEntrypoints);
|
||||||
|
|
||||||
|
if (entrypointCancelRef.current) return;
|
||||||
|
|
||||||
|
setAvailableEntrypoints(available);
|
||||||
|
const preferredEntrypoint = available.includes(loadedScript.entrypoint)
|
||||||
|
? loadedScript.entrypoint
|
||||||
|
: 'main';
|
||||||
|
setEntrypoint(preferredEntrypoint);
|
||||||
|
} catch {
|
||||||
|
if (entrypointCancelRef.current) return;
|
||||||
|
setAvailableEntrypoints(['main']);
|
||||||
|
setEntrypoint('main');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void refreshEntrypoints();
|
||||||
|
},
|
||||||
|
onReset: () => {
|
||||||
|
entrypointCancelRef.current = true;
|
||||||
|
setScript(null);
|
||||||
|
setTitle('');
|
||||||
|
setSlug('');
|
||||||
|
setKind('utility');
|
||||||
|
setEntrypoint('render');
|
||||||
|
setAvailableEntrypoints(['main']);
|
||||||
|
setEnabled(true);
|
||||||
|
setScriptContent('');
|
||||||
|
setMonacoResetToken(prev => prev + 1);
|
||||||
|
setIsSlugManuallyEdited(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const buildCacheKey = (scriptMeta: Pick<ScriptData, 'id' | 'version'>, content: string): string => {
|
const buildCacheKey = (scriptMeta: Pick<ScriptData, 'id' | 'version'>, content: string): string => {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
@@ -166,86 +232,6 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
|||||||
}
|
}
|
||||||
}, [appendPanelOutputEntry, applySyntaxMarkers, isCheckingSyntax, script, scriptContent, t]);
|
}, [appendPanelOutputEntry, applySyntaxMarkers, isCheckingSyntax, script, scriptContent, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const refreshEntrypoints = async (content: string, scriptMeta: ScriptData) => {
|
|
||||||
try {
|
|
||||||
const runtimeManager = getPythonRuntimeManager();
|
|
||||||
const discoveredEntrypoints = await runtimeManager.inspectEntrypoints(content, {
|
|
||||||
cacheKey: buildCacheKey(scriptMeta, content),
|
|
||||||
});
|
|
||||||
const available = withMainEntrypoint(discoveredEntrypoints);
|
|
||||||
|
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAvailableEntrypoints(available);
|
|
||||||
|
|
||||||
const preferredEntrypoint = available.includes(scriptMeta.entrypoint)
|
|
||||||
? scriptMeta.entrypoint
|
|
||||||
: 'main';
|
|
||||||
setEntrypoint(preferredEntrypoint);
|
|
||||||
} catch (error) {
|
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAvailableEntrypoints(['main']);
|
|
||||||
setEntrypoint('main');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadScript = async () => {
|
|
||||||
if (!scriptId) {
|
|
||||||
setScript(null);
|
|
||||||
setTitle('');
|
|
||||||
setSlug('');
|
|
||||||
setKind('utility');
|
|
||||||
setEntrypoint('render');
|
|
||||||
setAvailableEntrypoints(['main']);
|
|
||||||
setEnabled(true);
|
|
||||||
setScriptContent('');
|
|
||||||
setMonacoResetToken(prev => prev + 1);
|
|
||||||
setIsSlugManuallyEdited(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = await window.electronAPI?.scripts.get(scriptId);
|
|
||||||
if (cancelled || !item) {
|
|
||||||
setScript(null);
|
|
||||||
setTitle('');
|
|
||||||
setSlug('');
|
|
||||||
setKind('utility');
|
|
||||||
setEntrypoint('render');
|
|
||||||
setAvailableEntrypoints(['main']);
|
|
||||||
setEnabled(true);
|
|
||||||
setScriptContent('');
|
|
||||||
setMonacoResetToken(prev => prev + 1);
|
|
||||||
setIsSlugManuallyEdited(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setScript(item);
|
|
||||||
setTitle(item.title || '');
|
|
||||||
setSlug(toFunctionSlug(item.slug || item.title || ''));
|
|
||||||
setKind(item.kind || 'utility');
|
|
||||||
setEntrypoint(item.entrypoint || 'render');
|
|
||||||
setEnabled(item.enabled ?? true);
|
|
||||||
setScriptContent(item.content || '');
|
|
||||||
setMonacoResetToken(prev => prev + 1);
|
|
||||||
const normalizedExisting = toFunctionSlug(item.slug || item.title || '');
|
|
||||||
setIsSlugManuallyEdited(normalizedExisting !== toFunctionSlug(item.title || ''));
|
|
||||||
await refreshEntrypoints(item.content || '', item);
|
|
||||||
};
|
|
||||||
|
|
||||||
void loadScript();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [scriptId]);
|
|
||||||
|
|
||||||
const hasChanges = !!script && (
|
const hasChanges = !!script && (
|
||||||
title !== script.title ||
|
title !== script.title ||
|
||||||
slug !== script.slug ||
|
slug !== script.slug ||
|
||||||
@@ -340,21 +326,7 @@ export const ScriptsView: React.FC<ScriptsViewProps> = ({ scriptId }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useSaveShortcut(useCallback(() => { void handleSaveScript(); }, [handleSaveScript]));
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
|
||||||
event.preventDefault();
|
|
||||||
void handleSaveScript();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof window.addEventListener !== 'function' || typeof window.removeEventListener !== 'function') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [handleSaveScript]);
|
|
||||||
|
|
||||||
const handleRunScript = async () => {
|
const handleRunScript = async () => {
|
||||||
if (!script || isRunning) {
|
if (!script || isRunning) {
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ const SectionHeader: React.FC<{
|
|||||||
export const TagsView: React.FC = () => {
|
export const TagsView: React.FC = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { showErrorModal } = useAppStore();
|
const { showErrorModal } = useAppStore();
|
||||||
|
const activeProjectId = useAppStore((s) => s.activeProject?.id ?? null);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [tagsWithCounts, setTagsWithCounts] = useState<TagWithCount[]>([]);
|
const [tagsWithCounts, setTagsWithCounts] = useState<TagWithCount[]>([]);
|
||||||
@@ -181,22 +182,25 @@ export const TagsView: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!activeProjectId) return;
|
||||||
loadTags();
|
loadTags();
|
||||||
}, [loadTags]);
|
}, [loadTags, activeProjectId]);
|
||||||
|
|
||||||
// Listen for tag events
|
// Listen for tag events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!activeProjectId) return;
|
||||||
return subscribeToTagEvents(window.electronAPI?.on, loadTags, {
|
return subscribeToTagEvents(window.electronAPI?.on, loadTags, {
|
||||||
includeUpdated: true,
|
includeUpdated: true,
|
||||||
});
|
});
|
||||||
}, [loadTags]);
|
}, [loadTags, activeProjectId]);
|
||||||
|
|
||||||
// Load post templates on mount
|
// Load post templates on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!activeProjectId) return;
|
||||||
window.electronAPI?.templates.getEnabledByKind('post').then((templates) => {
|
window.electronAPI?.templates.getEnabledByKind('post').then((templates) => {
|
||||||
setPostTemplates(templates.map((t) => ({ slug: t.slug, title: t.title })));
|
setPostTemplates(templates.map((t) => ({ slug: t.slug, title: t.title })));
|
||||||
});
|
});
|
||||||
}, []);
|
}, [activeProjectId]);
|
||||||
|
|
||||||
// Handle tag selection
|
// Handle tag selection
|
||||||
const handleTagSelect = (name: string) => {
|
const handleTagSelect = (name: string) => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import MonacoEditor from '@monaco-editor/react';
|
import MonacoEditor from '@monaco-editor/react';
|
||||||
import type { TemplateData, TemplateKind } from '../../../main/shared/electronApi';
|
import type { TemplateData, TemplateKind } from '../../../main/shared/electronApi';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import { BDS_EVENT_TEMPLATES_CHANGED, dispatchWindowEvent } from '../../utils';
|
import { BDS_EVENT_TEMPLATES_CHANGED, dispatchWindowEvent } from '../../utils';
|
||||||
|
import { useEntityLoader, useSaveShortcut } from '../../navigation/useEntityEditor';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
import './TemplatesView.css';
|
import './TemplatesView.css';
|
||||||
@@ -30,6 +31,12 @@ interface TemplatesViewProps {
|
|||||||
export const TemplatesView: React.FC<TemplatesViewProps> = ({ templateId }) => {
|
export const TemplatesView: React.FC<TemplatesViewProps> = ({ templateId }) => {
|
||||||
const { t, language } = useI18n();
|
const { t, language } = useI18n();
|
||||||
const closeTab = useAppStore((state) => state.closeTab);
|
const closeTab = useAppStore((state) => state.closeTab);
|
||||||
|
|
||||||
|
const fetchTemplate = useCallback(
|
||||||
|
(id: string) => window.electronAPI?.templates.get(id) ?? Promise.resolve(null),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const [template, setTemplate] = useState<TemplateData | null>(null);
|
const [template, setTemplate] = useState<TemplateData | null>(null);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [slug, setSlug] = useState('');
|
const [slug, setSlug] = useState('');
|
||||||
@@ -41,52 +48,29 @@ export const TemplatesView: React.FC<TemplatesViewProps> = ({ templateId }) => {
|
|||||||
const [isValidating, setIsValidating] = useState(false);
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
const [monacoResetToken, setMonacoResetToken] = useState(0);
|
const [monacoResetToken, setMonacoResetToken] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEntityLoader<TemplateData>(templateId, fetchTemplate, {
|
||||||
let cancelled = false;
|
onLoaded: (loadedTemplate) => {
|
||||||
|
setTemplate(loadedTemplate);
|
||||||
const loadTemplate = async () => {
|
setTitle(loadedTemplate.title || '');
|
||||||
if (!templateId) {
|
setSlug(loadedTemplate.slug || toTemplateSlug(loadedTemplate.title || ''));
|
||||||
setTemplate(null);
|
setKind(loadedTemplate.kind || 'post');
|
||||||
setTitle('');
|
setEnabled(loadedTemplate.enabled ?? true);
|
||||||
setSlug('');
|
setTemplateContent(loadedTemplate.content || '');
|
||||||
setKind('post');
|
|
||||||
setEnabled(true);
|
|
||||||
setTemplateContent('');
|
|
||||||
setMonacoResetToken((prev) => prev + 1);
|
|
||||||
setIsSlugManuallyEdited(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = await window.electronAPI?.templates.get(templateId);
|
|
||||||
if (cancelled || !item) {
|
|
||||||
setTemplate(null);
|
|
||||||
setTitle('');
|
|
||||||
setSlug('');
|
|
||||||
setKind('post');
|
|
||||||
setEnabled(true);
|
|
||||||
setTemplateContent('');
|
|
||||||
setMonacoResetToken((prev) => prev + 1);
|
|
||||||
setIsSlugManuallyEdited(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTemplate(item);
|
|
||||||
setTitle(item.title || '');
|
|
||||||
setSlug(item.slug || toTemplateSlug(item.title || ''));
|
|
||||||
setKind(item.kind || 'post');
|
|
||||||
setEnabled(item.enabled ?? true);
|
|
||||||
setTemplateContent(item.content || '');
|
|
||||||
setMonacoResetToken((prev) => prev + 1);
|
setMonacoResetToken((prev) => prev + 1);
|
||||||
const normalizedExisting = toTemplateSlug(item.slug || item.title || '');
|
const normalizedExisting = toTemplateSlug(loadedTemplate.slug || loadedTemplate.title || '');
|
||||||
setIsSlugManuallyEdited(normalizedExisting !== toTemplateSlug(item.title || ''));
|
setIsSlugManuallyEdited(normalizedExisting !== toTemplateSlug(loadedTemplate.title || ''));
|
||||||
};
|
},
|
||||||
|
onReset: () => {
|
||||||
void loadTemplate();
|
setTemplate(null);
|
||||||
|
setTitle('');
|
||||||
return () => {
|
setSlug('');
|
||||||
cancelled = true;
|
setKind('post');
|
||||||
};
|
setEnabled(true);
|
||||||
}, [templateId]);
|
setTemplateContent('');
|
||||||
|
setMonacoResetToken((prev) => prev + 1);
|
||||||
|
setIsSlugManuallyEdited(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
!!template &&
|
!!template &&
|
||||||
@@ -214,21 +198,7 @@ export const TemplatesView: React.FC<TemplatesViewProps> = ({ templateId }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useSaveShortcut(useCallback(() => { void handleSaveTemplate(); }, [handleSaveTemplate]));
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
|
||||||
event.preventDefault();
|
|
||||||
void handleSaveTemplate();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof window.addEventListener !== 'function' || typeof window.removeEventListener !== 'function') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [handleSaveTemplate]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="templates-view-shell">
|
<div className="templates-view-shell">
|
||||||
|
|||||||
108
src/renderer/navigation/useEntityEditor.ts
Normal file
108
src/renderer/navigation/useEntityEditor.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useAppStore } from '../store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared hook for entity editors that load data by ID.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - Deferring load until activeProject is available (startup race condition)
|
||||||
|
* - Cancellation of in-flight fetches on ID change / unmount
|
||||||
|
* - Closing the tab when the entity is not found (e.g. after project switch)
|
||||||
|
* - Providing a reload() helper for external-change refresh
|
||||||
|
*
|
||||||
|
* Instead of returning data that requires a separate sync effect, this hook
|
||||||
|
* calls `onLoaded` / `onReset` directly from within the fetch effect so
|
||||||
|
* that local state updates in the same render cycle as the load — matching
|
||||||
|
* the timing behaviour editors had when each hand-rolled its own load logic.
|
||||||
|
*
|
||||||
|
* @param entityId The entity primary key, or null for the "no entity" state.
|
||||||
|
* @param fetcher Async function that takes an ID and returns the entity or null.
|
||||||
|
* @param callbacks `onLoaded(data)` when entity arrives, `onReset()` when
|
||||||
|
* entityId is null or entity was not found.
|
||||||
|
*/
|
||||||
|
export function useEntityLoader<T>(
|
||||||
|
entityId: string | null,
|
||||||
|
fetcher: (id: string) => Promise<T | null>,
|
||||||
|
callbacks: {
|
||||||
|
onLoaded: (data: T) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
},
|
||||||
|
): {
|
||||||
|
isLoading: boolean;
|
||||||
|
reload: () => void;
|
||||||
|
} {
|
||||||
|
const activeProjectId = useAppStore((s) => s.activeProject?.id ?? null);
|
||||||
|
const closeTab = useAppStore((s) => s.closeTab);
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [reloadToken, setReloadToken] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!entityId) {
|
||||||
|
callbacks.onReset();
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeProjectId) {
|
||||||
|
// Project context not ready yet — stay in loading state, will
|
||||||
|
// re-run once activeProjectId appears.
|
||||||
|
callbacks.onReset();
|
||||||
|
setIsLoading(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
fetcher(entityId).then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
callbacks.onLoaded(result);
|
||||||
|
} else {
|
||||||
|
// Entity doesn't exist in this project — close the orphaned tab.
|
||||||
|
callbacks.onReset();
|
||||||
|
closeTab(entityId);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// callbacks intentionally excluded — they are inline objects whose
|
||||||
|
// identity changes each render; the real dependencies are the
|
||||||
|
// entityId, activeProjectId, and reloadToken.
|
||||||
|
}, [entityId, activeProjectId, reloadToken, fetcher, closeTab]);
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
setReloadToken((prev) => prev + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isLoading, reload };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind Cmd/Ctrl+S to a save callback.
|
||||||
|
*
|
||||||
|
* Replaces the identical keydown listener that was copy-pasted across
|
||||||
|
* PostEditor, ScriptsView, and TemplatesView.
|
||||||
|
*/
|
||||||
|
export function useSaveShortcut(onSave: () => void): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window.addEventListener !== 'function' || typeof window.removeEventListener !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||||
|
event.preventDefault();
|
||||||
|
onSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [onSave]);
|
||||||
|
}
|
||||||
@@ -185,6 +185,7 @@ describe('Editor does not reset content on auto-save (cursor stability)', () =>
|
|||||||
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
|
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
|
||||||
|
|
||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
|
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||||
preferredEditorMode: 'markdown',
|
preferredEditorMode: 'markdown',
|
||||||
posts: [],
|
posts: [],
|
||||||
media: [],
|
media: [],
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ describe('Editor dashboard timeline', () => {
|
|||||||
(window as any).electronAPI.app.setPreviewPostTarget = vi.fn().mockResolvedValue(undefined);
|
(window as any).electronAPI.app.setPreviewPostTarget = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
|
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||||
tabs: [],
|
tabs: [],
|
||||||
activeTabId: null,
|
activeTabId: null,
|
||||||
posts: [],
|
posts: [],
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ describe('Editor metadata collapse', () => {
|
|||||||
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
|
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
|
||||||
|
|
||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
|
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||||
preferredEditorMode: 'wysiwyg',
|
preferredEditorMode: 'wysiwyg',
|
||||||
posts: [],
|
posts: [],
|
||||||
media: [],
|
media: [],
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ describe('Editor visual mode persistence', () => {
|
|||||||
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
|
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
|
||||||
|
|
||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
|
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||||
preferredEditorMode: 'wysiwyg',
|
preferredEditorMode: 'wysiwyg',
|
||||||
posts: [],
|
posts: [],
|
||||||
media: [],
|
media: [],
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
import { MenuEditorView } from '../../../src/renderer/components/MenuEditorView/MenuEditorView';
|
import { MenuEditorView } from '../../../src/renderer/components/MenuEditorView/MenuEditorView';
|
||||||
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
describe('MenuEditorView entry editor', () => {
|
describe('MenuEditorView entry editor', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
useAppStore.setState({
|
||||||
|
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||||
|
});
|
||||||
|
|
||||||
(window as any).electronAPI = {
|
(window as any).electronAPI = {
|
||||||
...(window as any).electronAPI,
|
...(window as any).electronAPI,
|
||||||
menu: {
|
menu: {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ describe('ScriptsView', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
|
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||||
panelVisible: false,
|
panelVisible: false,
|
||||||
panelActiveTab: 'tasks',
|
panelActiveTab: 'tasks',
|
||||||
panelOutputEntries: [],
|
panelOutputEntries: [],
|
||||||
@@ -437,4 +438,30 @@ describe('ScriptsView', () => {
|
|||||||
expect(startTaskMock).not.toHaveBeenCalled();
|
expect(startTaskMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defers loading until activeProject is set to avoid startup race condition', async () => {
|
||||||
|
useAppStore.setState({ activeProject: null });
|
||||||
|
|
||||||
|
const getMock = (window as any).electronAPI.scripts.get;
|
||||||
|
const { rerender } = render(<ScriptsView scriptId="script-1" />);
|
||||||
|
|
||||||
|
// Give the effect a chance to run
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now simulate project context becoming available
|
||||||
|
useAppStore.setState({
|
||||||
|
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-render to pick up store change
|
||||||
|
rerender(<ScriptsView scriptId="script-1" />);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getMock).toHaveBeenCalledWith('script-1');
|
||||||
|
const textarea = screen.getByLabelText('Script Content') as HTMLTextAreaElement;
|
||||||
|
expect(textarea.value).toContain('print("hello")');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ import React from 'react';
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { render, act, screen, fireEvent } from '@testing-library/react';
|
import { render, act, screen, fireEvent } from '@testing-library/react';
|
||||||
import { TagsView } from '../../../src/renderer/components/TagsView/TagsView';
|
import { TagsView } from '../../../src/renderer/components/TagsView/TagsView';
|
||||||
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
describe('TagsView subscriptions', () => {
|
describe('TagsView subscriptions', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const onMock = vi.fn((_channel: string, _callback: (...args: unknown[]) => void) => vi.fn());
|
const onMock = vi.fn((_channel: string, _callback: (...args: unknown[]) => void) => vi.fn());
|
||||||
|
|
||||||
|
useAppStore.setState({
|
||||||
|
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||||
|
});
|
||||||
|
|
||||||
(window as any).electronAPI = {
|
(window as any).electronAPI = {
|
||||||
...(window as any).electronAPI,
|
...(window as any).electronAPI,
|
||||||
tags: {
|
tags: {
|
||||||
@@ -66,6 +71,10 @@ describe('TagsView template dropdown', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const onMock = vi.fn((_channel: string, _callback: (...args: unknown[]) => void) => vi.fn());
|
const onMock = vi.fn((_channel: string, _callback: (...args: unknown[]) => void) => vi.fn());
|
||||||
|
|
||||||
|
useAppStore.setState({
|
||||||
|
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||||
|
});
|
||||||
|
|
||||||
(window as any).electronAPI = {
|
(window as any).electronAPI = {
|
||||||
...(window as any).electronAPI,
|
...(window as any).electronAPI,
|
||||||
tags: {
|
tags: {
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ describe('TemplatesView', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
useAppStore.setState({
|
||||||
|
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||||
|
});
|
||||||
|
|
||||||
(window as any).electronAPI = {
|
(window as any).electronAPI = {
|
||||||
...(window as any).electronAPI,
|
...(window as any).electronAPI,
|
||||||
templates: {
|
templates: {
|
||||||
@@ -208,4 +212,27 @@ describe('TemplatesView', () => {
|
|||||||
expect(saveButton).toBeDisabled();
|
expect(saveButton).toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defers loading until activeProject is set to avoid startup race condition', async () => {
|
||||||
|
useAppStore.setState({ activeProject: null });
|
||||||
|
|
||||||
|
const getMock = (window as any).electronAPI.templates.get;
|
||||||
|
const { rerender } = render(<TemplatesView templateId="template-1" />);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
useAppStore.setState({
|
||||||
|
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(<TemplatesView templateId="template-1" />);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getMock).toHaveBeenCalledWith('template-1');
|
||||||
|
const titleInput = screen.getByLabelText('Title') as HTMLInputElement;
|
||||||
|
expect(titleInput.value).toBe('Custom Post');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('chat surface shared usage guards', () => {
|
|||||||
Element.prototype.scrollIntoView = vi.fn();
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
}
|
}
|
||||||
|
|
||||||
useAppStore.setState({ tabs: [], activeTabId: null, activeView: 'posts' });
|
useAppStore.setState({ tabs: [], activeTabId: null, activeView: 'posts', activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any });
|
||||||
|
|
||||||
window.electronAPI.chat = {
|
window.electronAPI.chat = {
|
||||||
checkReady: vi.fn().mockResolvedValue({ ready: true }),
|
checkReady: vi.fn().mockResolvedValue({ ready: true }),
|
||||||
|
|||||||
258
tests/renderer/navigation/useEntityEditor.test.ts
Normal file
258
tests/renderer/navigation/useEntityEditor.test.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { useEntityLoader, useSaveShortcut } from '../../../src/renderer/navigation/useEntityEditor';
|
||||||
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
|
describe('useEntityLoader', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useAppStore.setState({
|
||||||
|
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||||
|
tabs: [{ type: 'scripts', id: 'entity-1', isTransient: false }],
|
||||||
|
activeTabId: 'entity-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers loading until activeProject is set', async () => {
|
||||||
|
useAppStore.setState({ activeProject: null });
|
||||||
|
|
||||||
|
const fetcher = vi.fn().mockResolvedValue({ id: 'entity-1', title: 'Test' });
|
||||||
|
const onLoaded = vi.fn();
|
||||||
|
const onReset = vi.fn();
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ id }) => useEntityLoader(id, fetcher, { onLoaded, onReset }),
|
||||||
|
{ initialProps: { id: 'entity-1' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.isLoading).toBe(true);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
// onReset is called when activeProject is missing (clear state)
|
||||||
|
expect(onReset).toHaveBeenCalled();
|
||||||
|
|
||||||
|
onReset.mockClear();
|
||||||
|
|
||||||
|
// Simulate project becoming available
|
||||||
|
act(() => {
|
||||||
|
useAppStore.setState({
|
||||||
|
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
rerender({ id: 'entity-1' });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(fetcher).toHaveBeenCalledWith('entity-1');
|
||||||
|
expect(onLoaded).toHaveBeenCalledWith({ id: 'entity-1', title: 'Test' });
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads entity data when activeProject is already set', async () => {
|
||||||
|
const fetcher = vi.fn().mockResolvedValue({ id: 'entity-1', title: 'Hello' });
|
||||||
|
const onLoaded = vi.fn();
|
||||||
|
const onReset = vi.fn();
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useEntityLoader('entity-1', fetcher, { onLoaded, onReset }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onLoaded).toHaveBeenCalledWith({ id: 'entity-1', title: 'Hello' });
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes tab when entity is not found', async () => {
|
||||||
|
const fetcher = vi.fn().mockResolvedValue(null);
|
||||||
|
const onLoaded = vi.fn();
|
||||||
|
const onReset = vi.fn();
|
||||||
|
|
||||||
|
renderHook(() =>
|
||||||
|
useEntityLoader('entity-1', fetcher, { onLoaded, onReset }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(fetcher).toHaveBeenCalledWith('entity-1');
|
||||||
|
expect(onLoaded).not.toHaveBeenCalled();
|
||||||
|
expect(onReset).toHaveBeenCalled();
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
expect(state.tabs.find(t => t.id === 'entity-1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets and reloads when entity ID changes', async () => {
|
||||||
|
const fetcher = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ id: 'entity-1', title: 'First' })
|
||||||
|
.mockResolvedValueOnce({ id: 'entity-2', title: 'Second' });
|
||||||
|
|
||||||
|
const onLoaded = vi.fn();
|
||||||
|
const onReset = vi.fn();
|
||||||
|
|
||||||
|
useAppStore.setState({
|
||||||
|
tabs: [
|
||||||
|
{ type: 'scripts', id: 'entity-1', isTransient: false },
|
||||||
|
{ type: 'scripts', id: 'entity-2', isTransient: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ id }) => useEntityLoader(id, fetcher, { onLoaded, onReset }),
|
||||||
|
{ initialProps: { id: 'entity-1' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onLoaded).toHaveBeenCalledWith({ id: 'entity-1', title: 'First' });
|
||||||
|
});
|
||||||
|
|
||||||
|
onLoaded.mockClear();
|
||||||
|
rerender({ id: 'entity-2' });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onLoaded).toHaveBeenCalledWith({ id: 'entity-2', title: 'Second' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels in-flight load when entity ID changes quickly', async () => {
|
||||||
|
let resolveFirst: (value: unknown) => void;
|
||||||
|
const firstPromise = new Promise((resolve) => { resolveFirst = resolve; });
|
||||||
|
const fetcher = vi.fn()
|
||||||
|
.mockReturnValueOnce(firstPromise)
|
||||||
|
.mockResolvedValueOnce({ id: 'entity-2', title: 'Second' });
|
||||||
|
|
||||||
|
const onLoaded = vi.fn();
|
||||||
|
const onReset = vi.fn();
|
||||||
|
|
||||||
|
useAppStore.setState({
|
||||||
|
tabs: [
|
||||||
|
{ type: 'scripts', id: 'entity-1', isTransient: false },
|
||||||
|
{ type: 'scripts', id: 'entity-2', isTransient: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ id }) => useEntityLoader(id, fetcher, { onLoaded, onReset }),
|
||||||
|
{ initialProps: { id: 'entity-1' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Switch before first resolves
|
||||||
|
rerender({ id: 'entity-2' });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onLoaded).toHaveBeenCalledWith({ id: 'entity-2', title: 'Second' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Late resolve of first should have no effect
|
||||||
|
resolveFirst!({ id: 'entity-1', title: 'First' });
|
||||||
|
|
||||||
|
// onLoaded should only have been called once (for entity-2)
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onLoaded).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onReset and isLoading=false when entityId is null', async () => {
|
||||||
|
const fetcher = vi.fn();
|
||||||
|
const onLoaded = vi.fn();
|
||||||
|
const onReset = vi.fn();
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useEntityLoader(null, fetcher, { onLoaded, onReset }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onReset).toHaveBeenCalled();
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows reload via returned reload function', async () => {
|
||||||
|
const fetcher = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ id: 'entity-1', title: 'V1' })
|
||||||
|
.mockResolvedValueOnce({ id: 'entity-1', title: 'V2' });
|
||||||
|
|
||||||
|
const onLoaded = vi.fn();
|
||||||
|
const onReset = vi.fn();
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useEntityLoader('entity-1', fetcher, { onLoaded, onReset }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onLoaded).toHaveBeenCalledWith({ id: 'entity-1', title: 'V1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onLoaded).toHaveBeenCalledWith({ id: 'entity-1', title: 'V2' });
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useSaveShortcut', () => {
|
||||||
|
let eventTarget: EventTarget;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
eventTarget = new EventTarget();
|
||||||
|
window.addEventListener = eventTarget.addEventListener.bind(eventTarget);
|
||||||
|
window.removeEventListener = eventTarget.removeEventListener.bind(eventTarget);
|
||||||
|
window.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSave when Cmd+S is pressed', () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
renderHook(() => useSaveShortcut(onSave));
|
||||||
|
|
||||||
|
const event = new KeyboardEvent('keydown', {
|
||||||
|
key: 's',
|
||||||
|
metaKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSave when Ctrl+S is pressed', () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
renderHook(() => useSaveShortcut(onSave));
|
||||||
|
|
||||||
|
const event = new KeyboardEvent('keydown', {
|
||||||
|
key: 's',
|
||||||
|
ctrlKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onSave for other key combinations', () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
renderHook(() => useSaveShortcut(onSave));
|
||||||
|
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true }));
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', metaKey: true, bubbles: true }));
|
||||||
|
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up event listener on unmount', () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
const { unmount } = renderHook(() => useSaveShortcut(onSave));
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', {
|
||||||
|
key: 's',
|
||||||
|
metaKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user