From 01202d55cfb2ee86bb101e8d27aaad99717400c8 Mon Sep 17 00:00:00 2001 From: hugo Date: Wed, 11 Feb 2026 11:40:53 +0100 Subject: [PATCH] feat: proper tab handling --- VISION.md | 11 + src/renderer/App.tsx | 6 +- .../components/ActivityBar/ActivityBar.tsx | 14 +- src/renderer/components/Editor/Editor.tsx | 106 +++++- src/renderer/components/Sidebar/Sidebar.tsx | 41 ++- src/renderer/components/TabBar/TabBar.css | 200 ++++++++++ src/renderer/components/TabBar/TabBar.tsx | 249 +++++++++++++ src/renderer/components/TabBar/index.ts | 1 + src/renderer/components/index.ts | 1 + src/renderer/store/appStore.ts | 114 +++++- src/renderer/store/index.ts | 5 +- src/renderer/utils/autoSave.ts | 129 +++++++ src/renderer/utils/index.ts | 1 + tests/renderer/store/tabStore.test.ts | 342 ++++++++++++++++++ tests/renderer/utils/autoSave.test.ts | 249 +++++++++++++ 15 files changed, 1443 insertions(+), 26 deletions(-) create mode 100644 src/renderer/components/TabBar/TabBar.css create mode 100644 src/renderer/components/TabBar/TabBar.tsx create mode 100644 src/renderer/components/TabBar/index.ts create mode 100644 src/renderer/utils/autoSave.ts create mode 100644 src/renderer/utils/index.ts create mode 100644 tests/renderer/store/tabStore.test.ts create mode 100644 tests/renderer/utils/autoSave.test.ts diff --git a/VISION.md b/VISION.md index 27401bc..a8e5d13 100644 --- a/VISION.md +++ b/VISION.md @@ -153,6 +153,17 @@ I can link to other places, if needed, and those references should also be part posts. So each post should give a "links to" part in the UI (right sidebar or lower area of left sidebar like with vscode?) and a "linked to by" part where incoming links are shown. +Post need to support drag-and-drop image insert, and adding images must automatically create the related +media file entry, so that metadata for images can easily be set in the UI, but users get their posts +set up quickly without lots of hassle. Images that are referenced by posts are also linked in metadata to +the post, so that we have full overview what imags a post references in the actual post data. And that data +is also included in the published post file on the file system. + +Linkage data must be recoverable form posts and image links must be discoverable from post text, too, +so that a blog can be repaired if anything goes wrong. There must be a strong focus on being indestructable +for the blog, the most that could get lost can be draft content, but everything published must be +fully recoverable from data on the file system. + ### default category "article" This is for articles that are focused on long text. They will show fully only on the full page, but will diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index caf0875..7cae318 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { ActivityBar, Sidebar, Editor, StatusBar, Panel, ToastContainer, showToast } from './components'; +import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast } from './components'; import { useAppStore, PostData, MediaData, TaskProgress } from './store'; import './App.css'; @@ -23,6 +23,7 @@ const App: React.FC = () => { togglePanel, setActiveView, setSelectedPost, + openTab, } = useAppStore(); // Load initial data @@ -257,7 +258,7 @@ const App: React.FC = () => { unsubscribers.push( window.electronAPI?.on('menu:configureSync', () => { - setActiveView('settings'); + openTab({ type: 'settings', id: 'settings', isTransient: false }); }) || (() => {}) ); @@ -320,6 +321,7 @@ const App: React.FC = () => {
+
diff --git a/src/renderer/components/ActivityBar/ActivityBar.tsx b/src/renderer/components/ActivityBar/ActivityBar.tsx index 511d8de..ca530e6 100644 --- a/src/renderer/components/ActivityBar/ActivityBar.tsx +++ b/src/renderer/components/ActivityBar/ActivityBar.tsx @@ -29,9 +29,17 @@ const SyncIcon = () => ( ); export const ActivityBar: React.FC = () => { - const { activeView, setActiveView, syncStatus, pendingChanges } = useAppStore(); + const { activeView, setActiveView, syncStatus, pendingChanges, openTab, tabs, activeTabId } = useAppStore(); const totalPending = pendingChanges.posts + pendingChanges.media; + + // Check if settings tab is currently active + const isSettingsTabActive = tabs.some(t => t.type === 'settings' && t.id === activeTabId); + + const handleSettingsClick = () => { + // Open settings as a dedicated (non-transient) tab + openTab({ type: 'settings', id: 'settings', isTransient: false }); + }; return (
@@ -64,8 +72,8 @@ export const ActivityBar: React.FC = () => { )} + )} + +
+ {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id); + const title = getTabTitle(tab, posts, media); + const icon = getTabIcon(tab); + + return ( +
handleTabClick(tab.id)} + onDoubleClick={() => handleTabDoubleClick(tab)} + onMouseDown={(e) => handleMiddleClick(e, tab.id)} + title={`${title}${tab.isTransient ? ' (Preview)' : ''}${isDirty ? ' • Modified' : ''}`} + > + {icon} + + {title} + +
+ {isDirty && } + +
+
+ ); + })} +
+ + {showRightArrow && ( + + )} +
+ ); +}; + +export default TabBar; diff --git a/src/renderer/components/TabBar/index.ts b/src/renderer/components/TabBar/index.ts new file mode 100644 index 0000000..a639f01 --- /dev/null +++ b/src/renderer/components/TabBar/index.ts @@ -0,0 +1 @@ +export { TabBar } from './TabBar'; diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts index 0966f65..7298f7f 100644 --- a/src/renderer/components/index.ts +++ b/src/renderer/components/index.ts @@ -3,6 +3,7 @@ export { Sidebar } from './Sidebar'; export { Editor } from './Editor'; export { StatusBar } from './StatusBar'; export { Panel } from './Panel'; +export { TabBar } from './TabBar'; export { ToastContainer, toast, showToast, type ToastType } from './Toast'; export { ProjectSelector } from './ProjectSelector'; export { WysiwygEditor } from './WysiwygEditor'; diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index 22d39d5..03d22a2 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -4,6 +4,20 @@ import { persist } from 'zustand/middleware'; // Storage key for persisted state const STORAGE_KEY = 'bds-app-state'; +// Tab types +export type TabType = 'post' | 'media' | 'settings'; + +export interface Tab { + type: TabType; + id: string; + isTransient: boolean; +} + +export interface TabState { + tabs: Tab[]; + activeTabId: string | null; +} + // Types export interface ProjectData { id: string; @@ -69,6 +83,10 @@ interface AppState { projects: ProjectData[]; activeProject: ProjectData | null; + // Tabs + tabs: Tab[]; + activeTabId: string | null; + // UI State activeView: 'posts' | 'media' | 'settings'; sidebarVisible: boolean; @@ -108,6 +126,15 @@ interface AppState { updateProject: (id: string, project: Partial) => void; removeProject: (id: string) => void; + // Tab Actions + openTab: (tab: { type: TabType; id: string; isTransient: boolean }) => void; + closeTab: (id: string) => void; + setActiveTab: (id: string) => void; + pinTab: (id: string) => void; + clearTabs: () => void; + getTabState: () => TabState; + restoreTabState: (state: TabState) => void; + // Actions setActiveView: (view: 'posts' | 'media' | 'settings') => void; toggleSidebar: () => void; @@ -154,6 +181,10 @@ export const useAppStore = create()( projects: [], activeProject: null, + // Initial Tabs State + tabs: [], + activeTabId: null, + // Initial UI State activeView: 'posts', sidebarVisible: true, @@ -197,6 +228,82 @@ export const useAppStore = create()( projects: state.projects.filter((p) => p.id !== id), })), + // Tab Actions + openTab: ({ type, id, isTransient }) => set((state) => { + const existingTabIndex = state.tabs.findIndex((t) => t.id === id && t.type === type); + + if (existingTabIndex >= 0) { + // Tab already exists - if trying to pin (isTransient=false), update it + if (!isTransient) { + const updatedTabs = [...state.tabs]; + updatedTabs[existingTabIndex] = { ...updatedTabs[existingTabIndex], isTransient: false }; + return { tabs: updatedTabs, activeTabId: id }; + } + // Just switch to the existing tab + return { activeTabId: id }; + } + + // If opening as transient, replace existing transient tab of the same type + if (isTransient) { + const transientIndex = state.tabs.findIndex((t) => t.isTransient && t.type === type); + if (transientIndex >= 0) { + const updatedTabs = [...state.tabs]; + updatedTabs[transientIndex] = { type, id, isTransient: true }; + return { tabs: updatedTabs, activeTabId: id }; + } + } + + // Add new tab + const newTab: Tab = { type, id, isTransient }; + return { tabs: [...state.tabs, newTab], activeTabId: id }; + }), + + closeTab: (id) => set((state) => { + const tabIndex = state.tabs.findIndex((t) => t.id === id); + if (tabIndex === -1) return state; + + const newTabs = state.tabs.filter((t) => t.id !== id); + let newActiveTabId = state.activeTabId; + + // If closing the active tab, activate an adjacent tab + if (state.activeTabId === id) { + if (newTabs.length === 0) { + newActiveTabId = null; + } else if (tabIndex < newTabs.length) { + // Activate the tab that moved into this position (next tab) + newActiveTabId = newTabs[tabIndex].id; + } else { + // Activate the previous tab + newActiveTabId = newTabs[newTabs.length - 1].id; + } + } + + return { tabs: newTabs, activeTabId: newActiveTabId }; + }), + + setActiveTab: (id) => set((state) => { + // Only set if the tab exists + const tabExists = state.tabs.some((t) => t.id === id); + if (!tabExists) return state; + return { activeTabId: id }; + }), + + pinTab: (id) => set((state) => ({ + tabs: state.tabs.map((t) => (t.id === id ? { ...t, isTransient: false } : t)), + })), + + clearTabs: () => set({ tabs: [], activeTabId: null }), + + getTabState: () => { + const state = get(); + return { tabs: state.tabs, activeTabId: state.activeTabId }; + }, + + restoreTabState: (tabState) => set({ + tabs: tabState.tabs, + activeTabId: tabState.activeTabId + }), + // UI Actions setActiveView: (view) => set({ activeView: view }), toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })), @@ -283,15 +390,20 @@ export const useAppStore = create()( selectedPostId: state.selectedPostId, selectedMediaId: state.selectedMediaId, preferredEditorMode: state.preferredEditorMode, + // Tabs are persisted here for now (project-specific persistence handled separately) + tabs: state.tabs, + activeTabId: state.activeTabId, // Convert Set to array for storage dirtyPosts: [...state.dirtyPosts], }), // Merge function to restore Set from array merge: (persisted, current) => { - const persistedState = persisted as Partial & { dirtyPosts?: string[] }; + const persistedState = persisted as Partial & { dirtyPosts?: string[]; tabs?: Tab[] }; return { ...current, ...persistedState, + tabs: persistedState.tabs || [], + activeTabId: persistedState.activeTabId || null, dirtyPosts: new Set(persistedState.dirtyPosts || []), }; }, diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index fc1e4c9..89d6c93 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -5,5 +5,8 @@ export { type MediaData, type TaskProgress, type EditorMode, - type ErrorDetails + type ErrorDetails, + type Tab, + type TabType, + type TabState } from './appStore'; diff --git a/src/renderer/utils/autoSave.ts b/src/renderer/utils/autoSave.ts new file mode 100644 index 0000000..9b0c17b --- /dev/null +++ b/src/renderer/utils/autoSave.ts @@ -0,0 +1,129 @@ +/** + * AutoSaveManager - handles automatic saving of drafts with idle detection + * + * This manager tracks changes to multiple items and saves them after a configurable + * idle period. Changes are accumulated and merged before saving. + */ + +export interface AutoSaveConfig { + /** Time in milliseconds to wait after last change before saving (default: 3000) */ + idleTimeMs: number; + /** Callback to perform the save operation */ + onSave: (id: string, changes: Record) => Promise; + /** Callback when save completes successfully */ + onSaveComplete?: (id: string) => void; + /** Callback when save fails */ + onSaveError?: (id: string, error: Error) => void; +} + +interface PendingChange { + changes: Record; + timerId: ReturnType; +} + +export class AutoSaveManager { + private pendingChanges: Map = new Map(); + private config: AutoSaveConfig; + private disposed = false; + + constructor(config: AutoSaveConfig) { + this.config = config; + } + + /** + * Notify the manager of a change to an item. + * Resets the idle timer and accumulates the changes. + */ + notifyChange(id: string, changes: Record): void { + if (this.disposed) return; + + const existing = this.pendingChanges.get(id); + + // Clear existing timer if any + if (existing) { + clearTimeout(existing.timerId); + } + + // Merge changes with existing pending changes + const mergedChanges = { + ...(existing?.changes || {}), + ...changes, + }; + + // Set new timer + const timerId = setTimeout(() => { + this.performSave(id); + }, this.config.idleTimeMs); + + this.pendingChanges.set(id, { + changes: mergedChanges, + timerId, + }); + } + + /** + * Perform the save operation for a specific item. + */ + private async performSave(id: string): Promise { + const pending = this.pendingChanges.get(id); + if (!pending) return; + + // Remove from pending before saving + this.pendingChanges.delete(id); + + try { + await this.config.onSave(id, pending.changes); + this.config.onSaveComplete?.(id); + } catch (error) { + this.config.onSaveError?.(id, error instanceof Error ? error : new Error(String(error))); + } + } + + /** + * Force save all pending changes immediately. + */ + async forceSave(): Promise { + const ids = Array.from(this.pendingChanges.keys()); + + // Cancel all timers + for (const pending of this.pendingChanges.values()) { + clearTimeout(pending.timerId); + } + + // Save all pending changes in parallel + await Promise.all(ids.map((id) => this.performSave(id))); + } + + /** + * Cancel pending save for a specific item. + */ + cancel(id: string): void { + const pending = this.pendingChanges.get(id); + if (pending) { + clearTimeout(pending.timerId); + this.pendingChanges.delete(id); + } + } + + /** + * Check if there are any pending changes. + * If id is provided, checks only for that specific item. + */ + hasPendingChanges(id?: string): boolean { + if (id) { + return this.pendingChanges.has(id); + } + return this.pendingChanges.size > 0; + } + + /** + * Dispose of the manager, canceling all pending saves. + */ + dispose(): void { + this.disposed = true; + for (const pending of this.pendingChanges.values()) { + clearTimeout(pending.timerId); + } + this.pendingChanges.clear(); + } +} diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts new file mode 100644 index 0000000..43dd111 --- /dev/null +++ b/src/renderer/utils/index.ts @@ -0,0 +1 @@ +export { AutoSaveManager, type AutoSaveConfig } from './autoSave'; diff --git a/tests/renderer/store/tabStore.test.ts b/tests/renderer/store/tabStore.test.ts new file mode 100644 index 0000000..128177c --- /dev/null +++ b/tests/renderer/store/tabStore.test.ts @@ -0,0 +1,342 @@ +/** + * Tests for tab management in the app store + * Validates tabbed interface behavior: transient tabs, pinned tabs, persistence + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { useAppStore, PostData, MediaData, Tab } from '../../../src/renderer/store/appStore'; + +// Helper to create a mock post +const createMockPost = (overrides: Partial = {}): PostData => ({ + id: `post-${Date.now()}-${Math.random().toString(36).substring(7)}`, + title: 'Test Post', + slug: 'test-post', + content: '# Test Content', + status: 'draft', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + tags: [], + categories: [], + ...overrides, +}); + +// Helper to create a mock media +const createMockMedia = (overrides: Partial = {}): MediaData => ({ + id: `media-${Date.now()}-${Math.random().toString(36).substring(7)}`, + filename: 'test.jpg', + originalName: 'test.jpg', + mimeType: 'image/jpeg', + size: 1024, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + tags: [], + ...overrides, +}); + +// Direct store access without React rendering +const getStore = () => useAppStore.getState(); +const setState = useAppStore.setState; + +describe('Tab Management', () => { + beforeEach(() => { + // Reset store state before each test + setState({ + tabs: [], + activeTabId: null, + posts: [], + media: [], + dirtyPosts: new Set(), + }); + }); + + describe('Opening Tabs', () => { + it('should open a post in a transient tab on single click', () => { + const post = createMockPost({ id: 'post-1', title: 'Test Post' }); + getStore().addPost(post); + + getStore().openTab({ type: 'post', id: 'post-1', isTransient: true }); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().tabs[0].type).toBe('post'); + expect(getStore().tabs[0].id).toBe('post-1'); + expect(getStore().tabs[0].isTransient).toBe(true); + expect(getStore().activeTabId).toBe('post-1'); + }); + + it('should open a media file in a transient tab on single click', () => { + const media = createMockMedia({ id: 'media-1' }); + getStore().addMedia(media); + + getStore().openTab({ type: 'media', id: 'media-1', isTransient: true }); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().tabs[0].type).toBe('media'); + expect(getStore().tabs[0].id).toBe('media-1'); + expect(getStore().tabs[0].isTransient).toBe(true); + expect(getStore().activeTabId).toBe('media-1'); + }); + + it('should replace transient tab when opening another item with single click', () => { + const post1 = createMockPost({ id: 'post-1', title: 'Post 1' }); + const post2 = createMockPost({ id: 'post-2', title: 'Post 2' }); + getStore().addPost(post1); + getStore().addPost(post2); + + getStore().openTab({ type: 'post', id: 'post-1', isTransient: true }); + getStore().openTab({ type: 'post', id: 'post-2', isTransient: true }); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().tabs[0].id).toBe('post-2'); + expect(getStore().activeTabId).toBe('post-2'); + }); + + it('should not replace pinned tabs when opening with single click', () => { + const post1 = createMockPost({ id: 'post-1', title: 'Post 1' }); + const post2 = createMockPost({ id: 'post-2', title: 'Post 2' }); + getStore().addPost(post1); + getStore().addPost(post2); + + // Double click opens pinned tab + getStore().openTab({ type: 'post', id: 'post-1', isTransient: false }); + // Single click opens transient tab + getStore().openTab({ type: 'post', id: 'post-2', isTransient: true }); + + expect(getStore().tabs).toHaveLength(2); + expect(getStore().tabs[0].id).toBe('post-1'); + expect(getStore().tabs[0].isTransient).toBe(false); + expect(getStore().tabs[1].id).toBe('post-2'); + expect(getStore().tabs[1].isTransient).toBe(true); + }); + + it('should open a pinned tab on double click', () => { + const post = createMockPost({ id: 'post-1', title: 'Test Post' }); + getStore().addPost(post); + + getStore().openTab({ type: 'post', id: 'post-1', isTransient: false }); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().tabs[0].isTransient).toBe(false); + }); + + it('should convert transient tab to pinned on double click', () => { + const post = createMockPost({ id: 'post-1', title: 'Test Post' }); + getStore().addPost(post); + + // First single click - transient + getStore().openTab({ type: 'post', id: 'post-1', isTransient: true }); + expect(getStore().tabs[0].isTransient).toBe(true); + + // Double click - convert to pinned + getStore().openTab({ type: 'post', id: 'post-1', isTransient: false }); + expect(getStore().tabs).toHaveLength(1); + expect(getStore().tabs[0].isTransient).toBe(false); + }); + + it('should switch to existing tab if already open', () => { + const post = createMockPost({ id: 'post-1' }); + const post2 = createMockPost({ id: 'post-2' }); + getStore().addPost(post); + getStore().addPost(post2); + + getStore().openTab({ type: 'post', id: 'post-1', isTransient: false }); + getStore().openTab({ type: 'post', id: 'post-2', isTransient: false }); + getStore().openTab({ type: 'post', id: 'post-1', isTransient: false }); + + expect(getStore().tabs).toHaveLength(2); + expect(getStore().activeTabId).toBe('post-1'); + }); + }); + + describe('Closing Tabs', () => { + it('should close a tab by id', () => { + const post = createMockPost({ id: 'post-1' }); + getStore().addPost(post); + + getStore().openTab({ type: 'post', id: 'post-1', isTransient: false }); + getStore().closeTab('post-1'); + + expect(getStore().tabs).toHaveLength(0); + expect(getStore().activeTabId).toBeNull(); + }); + + it('should activate the next tab when closing the active tab', () => { + const post1 = createMockPost({ id: 'post-1' }); + const post2 = createMockPost({ id: 'post-2' }); + getStore().addPost(post1); + getStore().addPost(post2); + + getStore().openTab({ type: 'post', id: 'post-1', isTransient: false }); + getStore().openTab({ type: 'post', id: 'post-2', isTransient: false }); + getStore().setActiveTab('post-1'); + getStore().closeTab('post-1'); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().activeTabId).toBe('post-2'); + }); + + it('should activate the previous tab when closing the last tab', () => { + const post1 = createMockPost({ id: 'post-1' }); + const post2 = createMockPost({ id: 'post-2' }); + getStore().addPost(post1); + getStore().addPost(post2); + + getStore().openTab({ type: 'post', id: 'post-1', isTransient: false }); + getStore().openTab({ type: 'post', id: 'post-2', isTransient: false }); + getStore().closeTab('post-2'); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().activeTabId).toBe('post-1'); + }); + + it('should not remove dirty posts from dirtyPosts when closing tab', () => { + const post = createMockPost({ id: 'post-1' }); + getStore().addPost(post); + getStore().markDirty('post-1'); + + getStore().openTab({ type: 'post', id: 'post-1', isTransient: false }); + getStore().closeTab('post-1'); + + // Dirty state is preserved - the post still has unsaved changes + expect(getStore().isDirty('post-1')).toBe(true); + }); + }); + + describe('Tab Switching', () => { + it('should set active tab', () => { + const post1 = createMockPost({ id: 'post-1' }); + const post2 = createMockPost({ id: 'post-2' }); + getStore().addPost(post1); + getStore().addPost(post2); + + getStore().openTab({ type: 'post', id: 'post-1', isTransient: false }); + getStore().openTab({ type: 'post', id: 'post-2', isTransient: false }); + getStore().setActiveTab('post-1'); + + expect(getStore().activeTabId).toBe('post-1'); + }); + + it('should not change active tab when switching to non-existent tab', () => { + const post = createMockPost({ id: 'post-1' }); + getStore().addPost(post); + + getStore().openTab({ type: 'post', id: 'post-1', isTransient: false }); + getStore().setActiveTab('non-existent'); + + expect(getStore().activeTabId).toBe('post-1'); + }); + }); + + describe('Pin Tab', () => { + it('should pin a transient tab', () => { + const post = createMockPost({ id: 'post-1' }); + getStore().addPost(post); + + getStore().openTab({ type: 'post', id: 'post-1', isTransient: true }); + getStore().pinTab('post-1'); + + expect(getStore().tabs[0].isTransient).toBe(false); + }); + + it('should convert transient tab to pinned when editing starts', () => { + const post = createMockPost({ id: 'post-1' }); + getStore().addPost(post); + + getStore().openTab({ type: 'post', id: 'post-1', isTransient: true }); + getStore().markDirty('post-1'); + getStore().pinTab('post-1'); + + expect(getStore().tabs[0].isTransient).toBe(false); + }); + }); + + describe('Tab Persistence', () => { + it('should return tabs state for project persistence', () => { + const post1 = createMockPost({ id: 'post-1' }); + const post2 = createMockPost({ id: 'post-2' }); + getStore().addPost(post1); + getStore().addPost(post2); + + getStore().openTab({ type: 'post', id: 'post-1', isTransient: false }); + getStore().openTab({ type: 'post', id: 'post-2', isTransient: false }); + + const tabState = getStore().getTabState(); + + expect(tabState.tabs).toHaveLength(2); + expect(tabState.activeTabId).toBe('post-2'); + }); + + it('should restore tabs from persisted state', () => { + const tabState = { + tabs: [ + { type: 'post' as const, id: 'post-1', isTransient: false }, + { type: 'media' as const, id: 'media-1', isTransient: false }, + ], + activeTabId: 'media-1', + }; + + getStore().restoreTabState(tabState); + + expect(getStore().tabs).toHaveLength(2); + expect(getStore().activeTabId).toBe('media-1'); + }); + + it('should clear tabs when switching projects', () => { + const post = createMockPost({ id: 'post-1' }); + getStore().addPost(post); + getStore().openTab({ type: 'post', id: 'post-1', isTransient: false }); + + getStore().clearTabs(); + + expect(getStore().tabs).toHaveLength(0); + expect(getStore().activeTabId).toBeNull(); + }); + }); + + describe('Settings Tab', () => { + it('should open settings as a special tab', () => { + getStore().openTab({ type: 'settings', id: 'settings', isTransient: false }); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().tabs[0].type).toBe('settings'); + expect(getStore().activeTabId).toBe('settings'); + }); + + it('should not allow multiple settings tabs', () => { + getStore().openTab({ type: 'settings', id: 'settings', isTransient: false }); + getStore().openTab({ type: 'settings', id: 'settings', isTransient: false }); + + expect(getStore().tabs).toHaveLength(1); + }); + }); + + describe('Multiple Tab Types', () => { + it('should handle mixed posts and media tabs', () => { + const post = createMockPost({ id: 'post-1' }); + const media = createMockMedia({ id: 'media-1' }); + getStore().addPost(post); + getStore().addMedia(media); + + getStore().openTab({ type: 'post', id: 'post-1', isTransient: false }); + getStore().openTab({ type: 'media', id: 'media-1', isTransient: false }); + + expect(getStore().tabs).toHaveLength(2); + expect(getStore().tabs[0].type).toBe('post'); + expect(getStore().tabs[1].type).toBe('media'); + }); + + it('should keep transient tabs separate by type', () => { + const post = createMockPost({ id: 'post-1' }); + const media = createMockMedia({ id: 'media-1' }); + getStore().addPost(post); + getStore().addMedia(media); + + // Open transient post tab + getStore().openTab({ type: 'post', id: 'post-1', isTransient: true }); + // Open transient media tab - should NOT replace the post tab + getStore().openTab({ type: 'media', id: 'media-1', isTransient: true }); + + // Both transient tabs should exist since they're different types + expect(getStore().tabs).toHaveLength(2); + }); + }); +}); diff --git a/tests/renderer/utils/autoSave.test.ts b/tests/renderer/utils/autoSave.test.ts new file mode 100644 index 0000000..83e4217 --- /dev/null +++ b/tests/renderer/utils/autoSave.test.ts @@ -0,0 +1,249 @@ +/** + * Tests for auto-save functionality with idle detection + * Validates that drafts are automatically saved after a configurable idle period + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { AutoSaveManager } from '../../../src/renderer/utils/autoSave'; + +// Mock the electronAPI +const mockSaveDraft = vi.fn().mockResolvedValue({ id: 'post-1', title: 'Test' }); +const mockGetPost = vi.fn().mockResolvedValue({ id: 'post-1', title: 'Test', content: 'Content' }); + +describe('AutoSaveManager', () => { + let autoSaveManager: AutoSaveManager; + let onSaveCallback: ReturnType; + let onSaveCompleteCallback: ReturnType; + let onSaveErrorCallback: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + + onSaveCallback = vi.fn().mockResolvedValue(undefined); + onSaveCompleteCallback = vi.fn(); + onSaveErrorCallback = vi.fn(); + + autoSaveManager = new AutoSaveManager({ + idleTimeMs: 3000, // 3 seconds idle before save + onSave: onSaveCallback, + onSaveComplete: onSaveCompleteCallback, + onSaveError: onSaveErrorCallback, + }); + }); + + afterEach(() => { + autoSaveManager.dispose(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + describe('Idle Detection', () => { + it('should not save until idle time has passed', () => { + autoSaveManager.notifyChange('post-1', { content: 'Updated content' }); + + // Advance time by less than idle time + vi.advanceTimersByTime(2000); + + expect(onSaveCallback).not.toHaveBeenCalled(); + }); + + it('should save after idle time has passed', async () => { + autoSaveManager.notifyChange('post-1', { content: 'Updated content' }); + + // Advance time past idle threshold + vi.advanceTimersByTime(3500); + + expect(onSaveCallback).toHaveBeenCalledTimes(1); + expect(onSaveCallback).toHaveBeenCalledWith('post-1', { content: 'Updated content' }); + }); + + it('should reset idle timer on each change', () => { + autoSaveManager.notifyChange('post-1', { content: 'First change' }); + + // Advance time by 2 seconds + vi.advanceTimersByTime(2000); + + // Make another change - should reset timer + autoSaveManager.notifyChange('post-1', { content: 'Second change' }); + + // Advance time by 2 more seconds (4 seconds since first change) + vi.advanceTimersByTime(2000); + + // Should not have saved yet because timer was reset + expect(onSaveCallback).not.toHaveBeenCalled(); + + // Advance past the new idle threshold + vi.advanceTimersByTime(1500); + + // Now it should save + expect(onSaveCallback).toHaveBeenCalledTimes(1); + expect(onSaveCallback).toHaveBeenCalledWith('post-1', { content: 'Second change' }); + }); + + it('should accumulate changes before saving', () => { + autoSaveManager.notifyChange('post-1', { content: 'Change 1' }); + vi.advanceTimersByTime(1000); + + autoSaveManager.notifyChange('post-1', { title: 'New Title' }); + vi.advanceTimersByTime(1000); + + autoSaveManager.notifyChange('post-1', { content: 'Change 3' }); + vi.advanceTimersByTime(3500); + + expect(onSaveCallback).toHaveBeenCalledTimes(1); + // Should have merged the changes + expect(onSaveCallback).toHaveBeenCalledWith('post-1', { + title: 'New Title', + content: 'Change 3' + }); + }); + }); + + describe('Multiple Items', () => { + it('should track changes for multiple items independently', () => { + autoSaveManager.notifyChange('post-1', { content: 'Post 1 content' }); + vi.advanceTimersByTime(1000); + + autoSaveManager.notifyChange('post-2', { content: 'Post 2 content' }); + vi.advanceTimersByTime(2500); + + // Post 1 should save first (3.5 seconds since its last change) + expect(onSaveCallback).toHaveBeenCalledTimes(1); + expect(onSaveCallback).toHaveBeenCalledWith('post-1', { content: 'Post 1 content' }); + + // Advance to save post 2 + vi.advanceTimersByTime(1000); + + expect(onSaveCallback).toHaveBeenCalledTimes(2); + expect(onSaveCallback).toHaveBeenLastCalledWith('post-2', { content: 'Post 2 content' }); + }); + }); + + describe('Force Save', () => { + it('should immediately save all pending changes on forceSave', async () => { + autoSaveManager.notifyChange('post-1', { content: 'Content 1' }); + autoSaveManager.notifyChange('post-2', { content: 'Content 2' }); + + await autoSaveManager.forceSave(); + + expect(onSaveCallback).toHaveBeenCalledTimes(2); + }); + + it('should clear pending changes after forceSave', async () => { + autoSaveManager.notifyChange('post-1', { content: 'Content' }); + + await autoSaveManager.forceSave(); + + // Advance time - no additional saves should occur + vi.advanceTimersByTime(5000); + + expect(onSaveCallback).toHaveBeenCalledTimes(1); + }); + }); + + describe('Cancel', () => { + it('should cancel pending save for specific item', () => { + autoSaveManager.notifyChange('post-1', { content: 'Content' }); + autoSaveManager.cancel('post-1'); + + vi.advanceTimersByTime(5000); + + expect(onSaveCallback).not.toHaveBeenCalled(); + }); + + it('should not affect other pending saves when canceling one', () => { + autoSaveManager.notifyChange('post-1', { content: 'Content 1' }); + autoSaveManager.notifyChange('post-2', { content: 'Content 2' }); + + autoSaveManager.cancel('post-1'); + + vi.advanceTimersByTime(5000); + + expect(onSaveCallback).toHaveBeenCalledTimes(1); + expect(onSaveCallback).toHaveBeenCalledWith('post-2', { content: 'Content 2' }); + }); + }); + + describe('Callbacks', () => { + it('should call onSaveComplete after successful save', async () => { + autoSaveManager.notifyChange('post-1', { content: 'Content' }); + + vi.advanceTimersByTime(3500); + + // Wait for async save to complete + await vi.runAllTimersAsync(); + + expect(onSaveCompleteCallback).toHaveBeenCalledWith('post-1'); + }); + + it('should call onSaveError when save fails', async () => { + const error = new Error('Save failed'); + onSaveCallback.mockRejectedValueOnce(error); + + autoSaveManager.notifyChange('post-1', { content: 'Content' }); + + vi.advanceTimersByTime(3500); + + // Wait for async save to complete + await vi.runAllTimersAsync(); + + expect(onSaveErrorCallback).toHaveBeenCalledWith('post-1', error); + }); + }); + + describe('Has Pending Changes', () => { + it('should report pending changes correctly', () => { + expect(autoSaveManager.hasPendingChanges()).toBe(false); + + autoSaveManager.notifyChange('post-1', { content: 'Content' }); + + expect(autoSaveManager.hasPendingChanges()).toBe(true); + expect(autoSaveManager.hasPendingChanges('post-1')).toBe(true); + expect(autoSaveManager.hasPendingChanges('post-2')).toBe(false); + }); + + it('should clear pending status after save', async () => { + autoSaveManager.notifyChange('post-1', { content: 'Content' }); + + vi.advanceTimersByTime(3500); + await vi.runAllTimersAsync(); + + expect(autoSaveManager.hasPendingChanges('post-1')).toBe(false); + }); + }); + + describe('Dispose', () => { + it('should cancel all pending saves on dispose', () => { + autoSaveManager.notifyChange('post-1', { content: 'Content' }); + + autoSaveManager.dispose(); + + vi.advanceTimersByTime(5000); + + expect(onSaveCallback).not.toHaveBeenCalled(); + }); + }); + + describe('Configuration', () => { + it('should use custom idle time', () => { + autoSaveManager.dispose(); + + autoSaveManager = new AutoSaveManager({ + idleTimeMs: 5000, // 5 seconds + onSave: onSaveCallback, + onSaveComplete: onSaveCompleteCallback, + onSaveError: onSaveErrorCallback, + }); + + autoSaveManager.notifyChange('post-1', { content: 'Content' }); + + // Should not save at 4 seconds + vi.advanceTimersByTime(4000); + expect(onSaveCallback).not.toHaveBeenCalled(); + + // Should save at 5.5 seconds + vi.advanceTimersByTime(1500); + expect(onSaveCallback).toHaveBeenCalledTimes(1); + }); + }); +});