diff --git a/TODO.md b/TODO.md index 950f98a..1babc85 100644 --- a/TODO.md +++ b/TODO.md @@ -179,16 +179,17 @@ Pure tab-policy helpers that decide: - [x] Update/expand contract tests for unified activity behavior and `ActivityBar` integration. ## Phase 2 — Normalize Sidebar Mounting + Ownership Mapping -- Introduce a sidebar view registry mapping in one place. -- Pick one global mounting strategy (A or B) and apply to all sidebars. -- If needed, add explicit state persistence for remount-safe UX. -- Introduce unified section activation API and migrate `SettingsNav`/`TagsNav` to use it. +- [x] Introduce a sidebar view registry mapping in one place. +- [x] Pick one global mounting strategy (A or B) and apply to all sidebars. +- [x] Add explicit state persistence for remount-safe sidebar section UX where needed. +- [x] Introduce unified section activation API and migrate `SettingsNav`/`TagsNav` to use it. ## Phase 3 — Centralize Editor/Tab Management (expanded) -- Implement shared tab policy layer for all editor tabs. -- Centralize singleton tab policies (`settings`, `tags`, `style`, `documentation`, etc.). +- [x] Implement shared tab policy layer for singleton tool tabs. +- [x] Centralize singleton tab policies (`settings`, `tags`, `style`, `documentation`, `metadata-diff`, `site-validation`). - Centralize transient/pin lifecycle rules (including promotion behavior). - Remove duplicated tab-open logic from sidebars/nav components where possible. +- [x] Ensure site-validation tab reopens with persisted last report and refreshes only on explicit validation trigger. ### Editor Tab Management Variations (current analysis) 1. **Singleton, pinned tool tabs** @@ -232,7 +233,7 @@ Pure tab-policy helpers that decide: - [x] Implement a unified section activation API for editor/tool sections. - [x] Choose global sidebar mounting variant: **B conditional mount**. - [x] Define sidebar action rule: section clicks always activate/open corresponding editor. -- [ ] Migrate existing `SettingsNav`/`TagsNav` auto-open + delayed scroll to the unified section activation API that enforces this rule. +- [x] Migrate existing `SettingsNav`/`TagsNav` auto-open + delayed scroll to the unified section activation API that enforces this rule. --- @@ -244,4 +245,12 @@ Pure tab-policy helpers that decide: - [x] Decision captured: sidebar mounting strategy Variant B (conditional mount). - [x] Decision captured: sidebar section actions always activate/open corresponding editor. - [x] Phase 1b complete: removed `sidebar-or-tab`, unified activity click behavior, and updated tests. -- [ ] Next implementation slice: Phase 2 (sidebar registry + unified section activation API migration). +- [x] Added `Edit Preferences` menu item (`CmdOrCtrl+,`) to open preferences editor directly. +- [x] Phase 2 slice complete: added shared section activation API and migrated `SettingsNav`/`TagsNav`. +- [x] Phase 2 slice complete: normalized posts/pages sidebar rendering to Variant B conditional mount. +- [x] Phase 2 slice complete: introduced canonical sidebar view registry and applied it across navigation/store/sidebar mapping. +- [x] Phase 2 slice complete: persisted settings/tags active section selection for remount-safe UX. +- [x] Phase 3 slice complete: added singleton tab policy helper and migrated menu/sidebar singleton tool openings to it. +- [x] Phase 3 slice complete: site validation report is persisted per project and reloaded by `SiteValidationView`. +- [x] Phase 3 slice complete: site validation view refreshes via explicit `bds:site-validation-updated` event (no auto-validate on mount). +- [ ] Next implementation slice: Phase 3 continuation (centralize non-singleton tab lifecycle/promotion rules). diff --git a/src/main/shared/i18n/locales/de.json b/src/main/shared/i18n/locales/de.json index 9659e79..0dc05d7 100644 --- a/src/main/shared/i18n/locales/de.json +++ b/src/main/shared/i18n/locales/de.json @@ -19,6 +19,7 @@ "menu.item.selectAll": "Alles auswählen", "menu.item.find": "Suchen", "menu.item.replace": "Ersetzen", + "menu.item.editPreferences": "Einstellungen bearbeiten", "menu.item.viewPosts": "Beiträge", "menu.item.viewMedia": "Medien", "menu.item.toggleSidebar": "Seitenleiste umschalten", diff --git a/src/main/shared/i18n/locales/en.json b/src/main/shared/i18n/locales/en.json index 9038cc6..f3d1caf 100644 --- a/src/main/shared/i18n/locales/en.json +++ b/src/main/shared/i18n/locales/en.json @@ -19,6 +19,7 @@ "menu.item.selectAll": "Select All", "menu.item.find": "Find", "menu.item.replace": "Replace", + "menu.item.editPreferences": "Edit Preferences", "menu.item.viewPosts": "Posts", "menu.item.viewMedia": "Media", "menu.item.toggleSidebar": "Toggle Sidebar", diff --git a/src/main/shared/i18n/locales/es.json b/src/main/shared/i18n/locales/es.json index 7d31b4c..d91e0c2 100644 --- a/src/main/shared/i18n/locales/es.json +++ b/src/main/shared/i18n/locales/es.json @@ -19,6 +19,7 @@ "menu.item.selectAll": "Seleccionar todo", "menu.item.find": "Buscar", "menu.item.replace": "Reemplazar", + "menu.item.editPreferences": "Editar preferencias", "menu.item.viewPosts": "Entradas", "menu.item.viewMedia": "Medios", "menu.item.toggleSidebar": "Alternar barra lateral", diff --git a/src/main/shared/i18n/locales/fr.json b/src/main/shared/i18n/locales/fr.json index 11c6074..0e8c759 100644 --- a/src/main/shared/i18n/locales/fr.json +++ b/src/main/shared/i18n/locales/fr.json @@ -19,6 +19,7 @@ "menu.item.selectAll": "Tout sélectionner", "menu.item.find": "Rechercher", "menu.item.replace": "Remplacer", + "menu.item.editPreferences": "Modifier les préférences", "menu.item.viewPosts": "Articles", "menu.item.viewMedia": "Médias", "menu.item.toggleSidebar": "Basculer la barre latérale", diff --git a/src/main/shared/i18n/locales/it.json b/src/main/shared/i18n/locales/it.json index 6fceedc..ec98be6 100644 --- a/src/main/shared/i18n/locales/it.json +++ b/src/main/shared/i18n/locales/it.json @@ -19,6 +19,7 @@ "menu.item.selectAll": "Seleziona tutto", "menu.item.find": "Trova", "menu.item.replace": "Sostituisci", + "menu.item.editPreferences": "Modifica preferenze", "menu.item.viewPosts": "Post", "menu.item.viewMedia": "Contenuti media", "menu.item.toggleSidebar": "Attiva/disattiva barra laterale", diff --git a/src/main/shared/menuCommands.ts b/src/main/shared/menuCommands.ts index 95eebc2..0e2166e 100644 --- a/src/main/shared/menuCommands.ts +++ b/src/main/shared/menuCommands.ts @@ -14,6 +14,7 @@ export type AppMenuAction = | 'selectAll' | 'find' | 'replace' + | 'editPreferences' | 'viewPosts' | 'viewMedia' | 'toggleSidebar' @@ -89,6 +90,7 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [ { label: '', action: 'edit-separator-3', separator: true }, { label: 'menu.item.find', action: 'find', accelerator: 'CmdOrCtrl+F' }, { label: 'menu.item.replace', action: 'replace', accelerator: 'CmdOrCtrl+H' }, + { label: 'menu.item.editPreferences', action: 'editPreferences', accelerator: 'CmdOrCtrl+,' }, ], }, { @@ -143,6 +145,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial> = save: 'menu:save', find: 'menu:find', replace: 'menu:replace', + editPreferences: 'menu:editPreferences', viewPosts: 'menu:viewPosts', viewMedia: 'menu:viewMedia', toggleSidebar: 'menu:toggleSidebar', diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 072661f..19eba8c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2,6 +2,8 @@ import React, { useEffect } from 'react'; import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar } from './components'; import { useAppStore, PostData, MediaData, TaskProgress } from './store'; import { loadTabsForProject, saveTabsForProject } from './utils'; +import { openSingletonToolTab } from './navigation/tabPolicy'; +import { persistSiteValidationReport } from './navigation/siteValidationPersistence'; import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme'; import { useI18n } from './i18n'; import './App.css'; @@ -224,6 +226,12 @@ const App: React.FC = () => { }) || (() => {}) ); + unsubscribers.push( + window.electronAPI?.on('menu:editPreferences', () => { + openSingletonToolTab(openTab, 'settings'); + }) || (() => {}) + ); + // Rebuild events - clear store on start, reload on complete unsubscribers.push( window.electronAPI?.on('posts:rebuildStarted', () => { @@ -288,8 +296,7 @@ const App: React.FC = () => { unsubscribers.push( window.electronAPI?.on('menu:metadataDiff', () => { - // Open metadata diff tool tab - openTab({ id: 'metadata-diff', type: 'metadata-diff', title: tr('app.metadataDiff') }); + openSingletonToolTab(openTab, 'metadata-diff'); }) || (() => {}) ); @@ -306,7 +313,23 @@ const App: React.FC = () => { unsubscribers.push( window.electronAPI?.on('menu:validateSite', () => { - openTab({ id: 'site-validation-report', type: 'site-validation', isTransient: true }); + const validateAndOpen = async () => { + try { + const report = await window.electronAPI?.blog.validateSite(); + const projectId = useAppStore.getState().activeProject?.id; + if (projectId && report) { + persistSiteValidationReport(projectId, report); + window.dispatchEvent(new CustomEvent('bds:site-validation-updated', { + detail: { projectId }, + })); + } + openSingletonToolTab(openTab, 'site-validation'); + } catch (error) { + console.error('Site validation failed:', error); + showToast.error(tr('siteValidation.error.validate')); + } + }; + void validateAndOpen(); }) || (() => {}) ); @@ -331,7 +354,7 @@ const App: React.FC = () => { unsubscribers.push( window.electronAPI?.on('menu:openDocumentation', () => { - openTab({ id: 'documentation', type: 'documentation', isTransient: false }); + openSingletonToolTab(openTab, 'documentation'); }) || (() => {}) ); diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 1b48ce0..7843804 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -4,6 +4,12 @@ import { showToast } from '../Toast'; import { getContrastColor, groupPostsByStatus } from '../../utils'; import type { ChatConversation, ImportDefinitionData } from '../../types/electron'; import { GitSidebar } from '../GitSidebar/GitSidebar'; +import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView'; +import { scrollToTagsSection, TagsCategory } from '../TagsView'; +import { activateSidebarSection } from '../../navigation/sectionActivation'; +import { getPersistedSidebarSection, setPersistedSidebarSection } from '../../navigation/sidebarUiPersistence'; +import { openSingletonToolTab } from '../../navigation/tabPolicy'; +import type { SidebarView } from '../../navigation/sidebarViewRegistry'; import { useI18n } from '../../i18n'; import './Sidebar.css'; @@ -1218,24 +1224,27 @@ const MediaList: React.FC = () => { ); }; -import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView'; -import { scrollToTagsSection, TagsCategory } from '../TagsView'; - const TagsNav: React.FC = () => { const { t } = useI18n(); const { tabs, activeTabId, openTab } = useAppStore(); - const [activeSection, setActiveSection] = useState(null); + const [activeSection, setActiveSection] = useState(() => { + const persisted = getPersistedSidebarSection('tags'); + if (persisted === 'cloud' || persisted === 'manage' || persisted === 'merge') { + return persisted; + } + return null; + }); const isTagsTabActive = tabs.some(t => t.type === 'tags' && t.id === activeTabId); const handleNavClick = (category: TagsCategory) => { - if (!isTagsTabActive) { - openTab({ type: 'tags', id: 'tags', isTransient: false }); - } setActiveSection(category); - setTimeout(() => { - scrollToTagsSection(category); - }, isTagsTabActive ? 0 : 100); + setPersistedSidebarSection('tags', category); + activateSidebarSection({ + isEditorTabActive: isTagsTabActive, + ensureEditorTabActive: () => openSingletonToolTab(openTab, 'tags'), + activateSection: () => scrollToTagsSection(category), + }); }; return ( @@ -1276,26 +1285,30 @@ const TagsNav: React.FC = () => { const SettingsNav: React.FC = () => { const { t } = useI18n(); const { tabs, activeTabId, openTab } = useAppStore(); - const [activeSection, setActiveSection] = useState(null); + const [activeSection, setActiveSection] = useState(() => { + const persisted = getPersistedSidebarSection('settings'); + if (persisted === 'project' || persisted === 'editor' || persisted === 'content' || persisted === 'ai' || persisted === 'publishing' || persisted === 'data') { + return persisted; + } + return null; + }); // Check if settings panel is currently active const isSettingsTabActive = tabs.some(t => t.type === 'settings' && t.id === activeTabId); const isStyleTabActive = tabs.some(t => t.type === 'style' && t.id === activeTabId); const handleNavClick = (category: SettingsCategory) => { - // If settings panel is not open or not active, open it first - if (!isSettingsTabActive) { - openTab({ type: 'settings', id: 'settings', isTransient: false }); - } setActiveSection(category); - // Use setTimeout to allow panel to open before scrolling - setTimeout(() => { - scrollToSettingsSection(category); - }, isSettingsTabActive ? 0 : 100); + setPersistedSidebarSection('settings', category); + activateSidebarSection({ + isEditorTabActive: isSettingsTabActive, + ensureEditorTabActive: () => openSingletonToolTab(openTab, 'settings'), + activateSection: () => scrollToSettingsSection(category), + }); }; const handleStyleClick = () => { - openTab({ type: 'style', id: 'style', isTransient: false }); + openSingletonToolTab(openTab, 'style'); }; return ( @@ -1666,34 +1679,20 @@ export const Sidebar: React.FC = () => { return null; } + const sidebarViewMap: Record = { + posts: , + pages: , + media: , + settings: , + tags: , + chat: , + import: , + git: , + }; + return (
-
- -
-
- -
- {activeView === 'media' && } - {activeView === 'settings' && } - {activeView === 'tags' && } - {activeView === 'chat' && } - {activeView === 'import' && } - {activeView === 'git' && } + {sidebarViewMap[activeView]}
); }; diff --git a/src/renderer/components/SiteValidationView/SiteValidationView.tsx b/src/renderer/components/SiteValidationView/SiteValidationView.tsx index 5fe1e0b..f3e100a 100644 --- a/src/renderer/components/SiteValidationView/SiteValidationView.tsx +++ b/src/renderer/components/SiteValidationView/SiteValidationView.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react'; +import { useAppStore } from '../../store'; import { showToast } from '../Toast'; import { useI18n } from '../../i18n'; +import { getPersistedSiteValidationReport } from '../../navigation/siteValidationPersistence'; import './SiteValidationView.css'; type SiteValidationReport = { @@ -20,27 +22,43 @@ type SiteValidationApplyResult = { export const SiteValidationView: React.FC = () => { const { t: tr } = useI18n(); + const { activeProject } = useAppStore(); const [isLoading, setIsLoading] = useState(true); const [isApplying, setIsApplying] = useState(false); const [report, setReport] = useState(null); - const loadReport = async () => { + const loadPersistedReport = () => { setIsLoading(true); try { - const result = await window.electronAPI.blog.validateSite(); - setReport(result as SiteValidationReport); - } catch (error) { - console.error('Site validation failed:', error); - showToast.error(tr('siteValidation.error.validate')); - setReport(null); + const projectId = activeProject?.id; + if (!projectId) { + setReport(null); + return; + } + + const persistedReport = getPersistedSiteValidationReport(projectId); + setReport(persistedReport); } finally { setIsLoading(false); } }; useEffect(() => { - void loadReport(); - }, []); + loadPersistedReport(); + }, [activeProject?.id]); + + useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ projectId?: string }>).detail; + if (!activeProject?.id || detail?.projectId !== activeProject.id) { + return; + } + loadPersistedReport(); + }; + + window.addEventListener('bds:site-validation-updated', handler); + return () => window.removeEventListener('bds:site-validation-updated', handler); + }, [activeProject?.id]); const canApply = useMemo(() => { if (!report) return false; @@ -59,7 +77,6 @@ export const SiteValidationView: React.FC = () => { rendered: result.renderedUrlCount, deleted: result.deletedUrlCount, })); - await loadReport(); } catch (error) { console.error('Applying site validation failed:', error); showToast.error(tr('siteValidation.error.apply')); diff --git a/src/renderer/navigation/activityBehavior.ts b/src/renderer/navigation/activityBehavior.ts index 87b5cc0..aaf2c06 100644 --- a/src/renderer/navigation/activityBehavior.ts +++ b/src/renderer/navigation/activityBehavior.ts @@ -1,7 +1,7 @@ import type { Tab } from '../store/appStore'; +import type { SidebarView } from './sidebarViewRegistry'; export type ActivityId = 'posts' | 'pages' | 'media' | 'tags' | 'chat' | 'import' | 'git' | 'settings'; -export type SidebarView = 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import' | 'git'; export interface ActivitySnapshot { activeView: SidebarView; diff --git a/src/renderer/navigation/sectionActivation.ts b/src/renderer/navigation/sectionActivation.ts new file mode 100644 index 0000000..c70c00a --- /dev/null +++ b/src/renderer/navigation/sectionActivation.ts @@ -0,0 +1,29 @@ +export interface ActivateSidebarSectionOptions { + isEditorTabActive: boolean; + ensureEditorTabActive: () => void; + activateSection: () => void; + delayWhenOpeningEditorMs?: number; + schedule?: (callback: () => void, delayMs: number) => void; +} + +export function activateSidebarSection(options: ActivateSidebarSectionOptions): void { + const { + isEditorTabActive, + ensureEditorTabActive, + activateSection, + delayWhenOpeningEditorMs = 100, + schedule, + } = options; + + const runLater = schedule ?? ((callback: () => void, delayMs: number) => { + window.setTimeout(callback, delayMs); + }); + + if (!isEditorTabActive) { + ensureEditorTabActive(); + runLater(activateSection, delayWhenOpeningEditorMs); + return; + } + + runLater(activateSection, 0); +} diff --git a/src/renderer/navigation/sidebarUiPersistence.ts b/src/renderer/navigation/sidebarUiPersistence.ts new file mode 100644 index 0000000..8e3eb6c --- /dev/null +++ b/src/renderer/navigation/sidebarUiPersistence.ts @@ -0,0 +1,24 @@ +type SidebarSectionOwner = 'settings' | 'tags'; + +const SIDEBAR_SECTION_KEY_PREFIX = 'bds-sidebar-section'; + +function getStorageKey(owner: SidebarSectionOwner): string { + return `${SIDEBAR_SECTION_KEY_PREFIX}:${owner}`; +} + +export function getPersistedSidebarSection(owner: SidebarSectionOwner): string | null { + try { + const value = localStorage.getItem(getStorageKey(owner)); + return value || null; + } catch { + return null; + } +} + +export function setPersistedSidebarSection(owner: SidebarSectionOwner, sectionId: string): void { + try { + localStorage.setItem(getStorageKey(owner), sectionId); + } catch { + // Ignore storage failures + } +} diff --git a/src/renderer/navigation/sidebarViewRegistry.ts b/src/renderer/navigation/sidebarViewRegistry.ts new file mode 100644 index 0000000..a05fab1 --- /dev/null +++ b/src/renderer/navigation/sidebarViewRegistry.ts @@ -0,0 +1,18 @@ +export const SIDEBAR_VIEW_REGISTRY = [ + 'posts', + 'pages', + 'media', + 'settings', + 'tags', + 'chat', + 'import', + 'git', +] as const; + +export type SidebarView = (typeof SIDEBAR_VIEW_REGISTRY)[number]; + +export const DEFAULT_SIDEBAR_VIEW: SidebarView = 'posts'; + +export function isSidebarView(value: string): value is SidebarView { + return (SIDEBAR_VIEW_REGISTRY as readonly string[]).includes(value); +} diff --git a/src/renderer/navigation/siteValidationPersistence.ts b/src/renderer/navigation/siteValidationPersistence.ts new file mode 100644 index 0000000..6891491 --- /dev/null +++ b/src/renderer/navigation/siteValidationPersistence.ts @@ -0,0 +1,27 @@ +import type { SiteValidationReport } from '../../main/shared/electronApi'; + +const SITE_VALIDATION_REPORT_PREFIX = 'bds-site-validation-report'; + +function getStorageKey(projectId: string): string { + return `${SITE_VALIDATION_REPORT_PREFIX}:${projectId}`; +} + +export function persistSiteValidationReport(projectId: string, report: SiteValidationReport): void { + try { + localStorage.setItem(getStorageKey(projectId), JSON.stringify(report)); + } catch { + // Ignore persistence failures. + } +} + +export function getPersistedSiteValidationReport(projectId: string): SiteValidationReport | null { + try { + const raw = localStorage.getItem(getStorageKey(projectId)); + if (!raw) { + return null; + } + return JSON.parse(raw) as SiteValidationReport; + } catch { + return null; + } +} diff --git a/src/renderer/navigation/tabPolicy.ts b/src/renderer/navigation/tabPolicy.ts new file mode 100644 index 0000000..ac48ff2 --- /dev/null +++ b/src/renderer/navigation/tabPolicy.ts @@ -0,0 +1,35 @@ +import type { TabType } from '../store/appStore'; + +export type SingletonToolTabKey = + | 'settings' + | 'tags' + | 'style' + | 'documentation' + | 'metadata-diff' + | 'site-validation'; + +export interface CanonicalTabSpec { + type: TabType; + id: string; + isTransient: boolean; +} + +const SINGLETON_TOOL_TAB_REGISTRY: Record = { + settings: { type: 'settings', id: 'settings', isTransient: false }, + tags: { type: 'tags', id: 'tags', isTransient: false }, + style: { type: 'style', id: 'style', isTransient: false }, + documentation: { type: 'documentation', id: 'documentation', isTransient: false }, + 'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false }, + 'site-validation': { type: 'site-validation', id: 'site-validation', isTransient: false }, +}; + +export function getSingletonToolTabSpec(key: SingletonToolTabKey): CanonicalTabSpec { + return SINGLETON_TOOL_TAB_REGISTRY[key]; +} + +export function openSingletonToolTab( + openTab: (tab: CanonicalTabSpec) => void, + key: SingletonToolTabKey, +): void { + openTab(getSingletonToolTabSpec(key)); +} diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index 5dc9767..f9fb28a 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { DeleteReference, ConfirmDeleteDetails } from '../components/ConfirmDeleteModal'; +import type { SidebarView } from '../navigation/sidebarViewRegistry'; import type { ProjectData, PostData, @@ -58,7 +59,7 @@ interface AppState { activeTabId: string | null; // UI State - activeView: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import' | 'git'; + activeView: SidebarView; sidebarVisible: boolean; panelVisible: boolean; panelActiveTab: PanelTab; @@ -107,7 +108,7 @@ interface AppState { restoreTabState: (state: TabState) => void; // Actions - setActiveView: (view: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import' | 'git') => void; + setActiveView: (view: SidebarView) => void; toggleSidebar: () => void; togglePanel: () => void; setPanelActiveTab: (tab: PanelTab) => void; diff --git a/tests/renderer/components/PagesShortcut.test.tsx b/tests/renderer/components/PagesShortcut.test.tsx index b46e899..6306833 100644 --- a/tests/renderer/components/PagesShortcut.test.tsx +++ b/tests/renderer/components/PagesShortcut.test.tsx @@ -142,7 +142,7 @@ describe('Pages shortcut UI', () => { expect(window.electronAPI.posts.filter).not.toHaveBeenCalledWith({ categories: ['page'] }); }); - it('uses a flex-height wrapper for active posts/pages sidebar view', async () => { + it('conditionally mounts only the active posts/pages sidebar content', async () => { useAppStore.setState({ activeView: 'posts', sidebarVisible: true, @@ -153,8 +153,7 @@ describe('Pages shortcut UI', () => { expect(await screen.findByText('POSTS')).toBeInTheDocument(); const wrappers = container.querySelectorAll('.sidebar > div'); - expect(wrappers.length).toBeGreaterThanOrEqual(2); - expect((wrappers[0] as HTMLElement).style.display).toBe('flex'); + expect(wrappers.length).toBe(1); }); it('opens style tab from settings sidebar navigation', async () => { diff --git a/tests/renderer/menuCommands.test.ts b/tests/renderer/menuCommands.test.ts index ad466bd..363ac88 100644 --- a/tests/renderer/menuCommands.test.ts +++ b/tests/renderer/menuCommands.test.ts @@ -51,4 +51,16 @@ describe('Help menu documentation entry', () => { it('maps Validate Site to a renderer menu event', () => { expect(APP_MENU_ACTION_EVENT_MAP.validateSite).toBe('menu:validateSite'); }); + + it('includes Edit Preferences action in Edit menu with comma shortcut', () => { + const editGroup = APP_MENU_GROUPS.find((group) => group.label === 'Edit'); + const preferencesItem = editGroup?.items.find((item) => item.action === 'editPreferences'); + + expect(preferencesItem).toBeDefined(); + expect(preferencesItem?.accelerator).toContain(','); + }); + + it('maps Edit Preferences to a renderer menu event', () => { + expect(APP_MENU_ACTION_EVENT_MAP.editPreferences).toBe('menu:editPreferences'); + }); }); diff --git a/tests/renderer/navigation/sectionActivation.test.ts b/tests/renderer/navigation/sectionActivation.test.ts new file mode 100644 index 0000000..b69530d --- /dev/null +++ b/tests/renderer/navigation/sectionActivation.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; +import { activateSidebarSection } from '../../../src/renderer/navigation/sectionActivation'; + +describe('sectionActivation', () => { + it('opens editor first and delays section activation when editor is not active', () => { + const ensureEditor = vi.fn(); + const activateSection = vi.fn(); + const schedule = vi.fn((callback: () => void) => callback()); + + activateSidebarSection({ + isEditorTabActive: false, + ensureEditorTabActive: ensureEditor, + activateSection, + schedule, + delayWhenOpeningEditorMs: 100, + }); + + expect(ensureEditor).toHaveBeenCalledTimes(1); + expect(schedule).toHaveBeenCalledTimes(1); + expect(schedule).toHaveBeenCalledWith(expect.any(Function), 100); + expect(activateSection).toHaveBeenCalledTimes(1); + }); + + it('activates section immediately when editor tab is already active', () => { + const ensureEditor = vi.fn(); + const activateSection = vi.fn(); + const schedule = vi.fn((callback: () => void) => callback()); + + activateSidebarSection({ + isEditorTabActive: true, + ensureEditorTabActive: ensureEditor, + activateSection, + schedule, + }); + + expect(ensureEditor).not.toHaveBeenCalled(); + expect(schedule).toHaveBeenCalledWith(expect.any(Function), 0); + expect(activateSection).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/renderer/navigation/sidebarUiPersistence.test.ts b/tests/renderer/navigation/sidebarUiPersistence.test.ts new file mode 100644 index 0000000..bd0f655 --- /dev/null +++ b/tests/renderer/navigation/sidebarUiPersistence.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { getPersistedSidebarSection, setPersistedSidebarSection } from '../../../src/renderer/navigation/sidebarUiPersistence'; + +describe('sidebarUiPersistence', () => { + it('persists and reads section ids by sidebar key', () => { + setPersistedSidebarSection('settings', 'project'); + + expect(getPersistedSidebarSection('settings')).toBe('project'); + }); + + it('returns null for missing persisted section', () => { + expect(getPersistedSidebarSection('tags')).toBeNull(); + }); +}); diff --git a/tests/renderer/navigation/sidebarViewRegistry.test.ts b/tests/renderer/navigation/sidebarViewRegistry.test.ts new file mode 100644 index 0000000..8881ec4 --- /dev/null +++ b/tests/renderer/navigation/sidebarViewRegistry.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_SIDEBAR_VIEW, + SIDEBAR_VIEW_REGISTRY, + isSidebarView, +} from '../../../src/renderer/navigation/sidebarViewRegistry'; + +describe('sidebarViewRegistry', () => { + it('defines all supported sidebar views in one canonical registry', () => { + expect(SIDEBAR_VIEW_REGISTRY).toEqual([ + 'posts', + 'pages', + 'media', + 'settings', + 'tags', + 'chat', + 'import', + 'git', + ]); + }); + + it('uses posts as default sidebar view', () => { + expect(DEFAULT_SIDEBAR_VIEW).toBe('posts'); + }); + + it('validates sidebar view values', () => { + expect(isSidebarView('tags')).toBe(true); + expect(isSidebarView('unknown')).toBe(false); + }); +}); diff --git a/tests/renderer/navigation/siteValidationPersistence.test.ts b/tests/renderer/navigation/siteValidationPersistence.test.ts new file mode 100644 index 0000000..8422471 --- /dev/null +++ b/tests/renderer/navigation/siteValidationPersistence.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import type { SiteValidationReport } from '../../../src/main/shared/electronApi'; +import { + getPersistedSiteValidationReport, + persistSiteValidationReport, +} from '../../../src/renderer/navigation/siteValidationPersistence'; + +const report: SiteValidationReport = { + sitemapPath: '/tmp/sitemap.xml', + sitemapChanged: false, + missingUrlPaths: ['/foo'], + extraUrlPaths: ['/bar'], + expectedUrlCount: 10, + existingHtmlUrlCount: 9, +}; + +describe('siteValidationPersistence', () => { + it('persists and loads site validation report by project', () => { + persistSiteValidationReport('project-1', report); + + expect(getPersistedSiteValidationReport('project-1')).toEqual(report); + }); + + it('returns null when project has no persisted report', () => { + expect(getPersistedSiteValidationReport('missing-project')).toBeNull(); + }); +}); diff --git a/tests/renderer/navigation/tabPolicy.test.ts b/tests/renderer/navigation/tabPolicy.test.ts new file mode 100644 index 0000000..0c0bd50 --- /dev/null +++ b/tests/renderer/navigation/tabPolicy.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { getSingletonToolTabSpec, openSingletonToolTab } from '../../../src/renderer/navigation/tabPolicy'; + +describe('tabPolicy', () => { + it('provides canonical singleton tab specs', () => { + expect(getSingletonToolTabSpec('settings')).toEqual({ type: 'settings', id: 'settings', isTransient: false }); + expect(getSingletonToolTabSpec('tags')).toEqual({ type: 'tags', id: 'tags', isTransient: false }); + expect(getSingletonToolTabSpec('style')).toEqual({ type: 'style', id: 'style', isTransient: false }); + expect(getSingletonToolTabSpec('documentation')).toEqual({ type: 'documentation', id: 'documentation', isTransient: false }); + expect(getSingletonToolTabSpec('metadata-diff')).toEqual({ type: 'metadata-diff', id: 'metadata-diff', isTransient: false }); + expect(getSingletonToolTabSpec('site-validation')).toEqual({ type: 'site-validation', id: 'site-validation', isTransient: false }); + }); + + it('opens singleton tool tabs using canonical tab spec', () => { + const openTab = (tab: { type: string; id: string; isTransient: boolean }) => { + captured = tab; + }; + + let captured: { type: string; id: string; isTransient: boolean } | null = null; + + openSingletonToolTab(openTab, 'site-validation'); + + expect(captured).toEqual({ type: 'site-validation', id: 'site-validation', isTransient: false }); + }); +});