@@ -574,8 +594,9 @@ const MediaList: React.FC = () => {
{media.map(item => (
setSelectedMedia(item.id)}
+ className={`media-item ${activeTabId === item.id ? 'selected' : ''}`}
+ onClick={() => handleMediaClick(item.id)}
+ onDoubleClick={() => handleMediaDoubleClick(item.id)}
title={item.originalName}
>
{item.mimeType.startsWith('image/') ? (
diff --git a/src/renderer/components/TabBar/TabBar.css b/src/renderer/components/TabBar/TabBar.css
new file mode 100644
index 0000000..528e9ca
--- /dev/null
+++ b/src/renderer/components/TabBar/TabBar.css
@@ -0,0 +1,200 @@
+/* TabBar Styles - VS Code inspired */
+.tab-bar {
+ display: flex;
+ align-items: center;
+ background-color: var(--vscode-editorGroupHeader-tabsBackground, #252526);
+ border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder, #1e1e1e);
+ height: 35px;
+ overflow: hidden;
+ flex-shrink: 0;
+ position: relative;
+}
+
+.tab-bar-tabs {
+ display: flex;
+ align-items: center;
+ height: 100%;
+ overflow-x: auto;
+ overflow-y: hidden;
+ flex: 1;
+ scroll-behavior: smooth;
+}
+
+/* Hide scrollbar but allow scrolling */
+.tab-bar-tabs::-webkit-scrollbar {
+ height: 0;
+ display: none;
+}
+
+/* Navigation arrows */
+.tab-scroll-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 100%;
+ background-color: var(--vscode-editorGroupHeader-tabsBackground, #252526);
+ border: none;
+ color: var(--vscode-icon-foreground, #c5c5c5);
+ cursor: pointer;
+ flex-shrink: 0;
+ z-index: 1;
+}
+
+.tab-scroll-button:hover {
+ background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
+}
+
+.tab-scroll-left {
+ border-right: 1px solid var(--vscode-tab-border, #252526);
+}
+
+.tab-scroll-right {
+ border-left: 1px solid var(--vscode-tab-border, #252526);
+}
+
+.tab {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0 10px;
+ height: 100%;
+ min-width: 120px;
+ max-width: 200px;
+ cursor: pointer;
+ background-color: var(--vscode-tab-inactiveBackground, #2d2d2d);
+ border-right: 1px solid var(--vscode-tab-border, #252526);
+ color: var(--vscode-tab-inactiveForeground, #969696);
+ font-size: 13px;
+ user-select: none;
+ position: relative;
+ flex-shrink: 0;
+}
+
+.tab:hover {
+ background-color: var(--vscode-tab-hoverBackground, #2a2d2e);
+}
+
+.tab.active {
+ background-color: var(--vscode-tab-activeBackground, #1e1e1e);
+ color: var(--vscode-tab-activeForeground, #fff);
+}
+
+.tab.active::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background-color: var(--vscode-tab-activeBorderTop, #0078d4);
+}
+
+.tab.transient .tab-title {
+ font-style: italic;
+}
+
+.tab-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ opacity: 0.8;
+}
+
+.tab.active .tab-icon {
+ opacity: 1;
+}
+
+.tab-title {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tab-title.italic {
+ font-style: italic;
+}
+
+.tab-actions {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ margin-left: auto;
+ flex-shrink: 0;
+}
+
+.tab-dirty-indicator {
+ color: var(--vscode-editorWarning-foreground, #e2c08d);
+ font-size: 10px;
+ line-height: 1;
+}
+
+.tab-close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ border: none;
+ background: transparent;
+ color: var(--vscode-icon-foreground, #c5c5c5);
+ cursor: pointer;
+ opacity: 0;
+ border-radius: 3px;
+ flex-shrink: 0;
+ padding: 0;
+}
+
+.tab:hover .tab-close {
+ opacity: 0.7;
+}
+
+.tab.active .tab-close {
+ opacity: 0.7;
+}
+
+.tab-close:hover {
+ opacity: 1 !important;
+ background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
+}
+
+.tab-close:active {
+ background-color: var(--vscode-toolbar-activeBackground, rgba(99, 102, 103, 0.31));
+}
+
+/* When tab is dirty, show close button on hover, swap with dirty indicator */
+.tab.dirty .tab-dirty-indicator {
+ display: block;
+}
+
+.tab.dirty .tab-close {
+ display: none;
+}
+
+.tab.dirty:hover .tab-close {
+ display: flex;
+ opacity: 0.7;
+}
+
+.tab.dirty:hover .tab-dirty-indicator {
+ display: none;
+}
+
+/* Focus styles for keyboard navigation */
+.tab:focus-visible {
+ outline: 1px solid var(--vscode-focusBorder, #007fd4);
+ outline-offset: -1px;
+}
+
+/* Empty state when no tabs */
+.tab-bar-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 100%;
+ color: var(--vscode-descriptionForeground, #999);
+ font-size: 12px;
+}
diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx
new file mode 100644
index 0000000..eb8b173
--- /dev/null
+++ b/src/renderer/components/TabBar/TabBar.tsx
@@ -0,0 +1,249 @@
+import React, { useRef, useState, useEffect, useCallback } from 'react';
+import { useAppStore, Tab } from '../../store';
+import './TabBar.css';
+
+const getTabTitle = (tab: Tab, posts: { id: string; title: string }[], media: { id: string; originalName: string }[]): string => {
+ if (tab.type === 'settings') {
+ return 'Settings';
+ }
+
+ if (tab.type === 'post') {
+ const post = posts.find(p => p.id === tab.id);
+ return post?.title || 'Untitled';
+ }
+
+ if (tab.type === 'media') {
+ const mediaItem = media.find(m => m.id === tab.id);
+ return mediaItem?.originalName || 'Media';
+ }
+
+ return 'Unknown';
+};
+
+const getTabIcon = (tab: Tab): React.ReactNode => {
+ switch (tab.type) {
+ case 'post':
+ return (
+
+
+
+ );
+ case 'media':
+ return (
+
+
+
+ );
+ case 'settings':
+ return (
+
+
+
+ );
+ default:
+ return (
+
+
+
+ );
+ }
+};
+
+const CloseIcon: React.FC = () => (
+
+
+
+);
+
+const ChevronLeftIcon: React.FC = () => (
+
+
+
+);
+
+const ChevronRightIcon: React.FC = () => (
+
+
+
+);
+
+export const TabBar: React.FC = () => {
+ const {
+ tabs,
+ activeTabId,
+ posts,
+ media,
+ dirtyPosts,
+ setActiveTab,
+ closeTab,
+ pinTab,
+ } = useAppStore();
+
+ const tabsContainerRef = useRef
(null);
+ const [showLeftArrow, setShowLeftArrow] = useState(false);
+ const [showRightArrow, setShowRightArrow] = useState(false);
+
+ // Check if arrows are needed based on scroll position
+ const updateArrowVisibility = useCallback(() => {
+ const container = tabsContainerRef.current;
+ if (!container) return;
+
+ const { scrollLeft, scrollWidth, clientWidth } = container;
+ setShowLeftArrow(scrollLeft > 0);
+ setShowRightArrow(scrollLeft + clientWidth < scrollWidth - 1);
+ }, []);
+
+ // Update arrow visibility on scroll or resize
+ useEffect(() => {
+ const container = tabsContainerRef.current;
+ if (!container) return;
+
+ updateArrowVisibility();
+
+ container.addEventListener('scroll', updateArrowVisibility);
+ const resizeObserver = new ResizeObserver(updateArrowVisibility);
+ resizeObserver.observe(container);
+
+ return () => {
+ container.removeEventListener('scroll', updateArrowVisibility);
+ resizeObserver.disconnect();
+ };
+ }, [updateArrowVisibility, tabs]);
+
+ // Scroll to active tab when it changes
+ useEffect(() => {
+ if (!activeTabId || !tabsContainerRef.current) return;
+
+ const container = tabsContainerRef.current;
+ const activeTab = container.querySelector(`[data-tab-id="${activeTabId}"]`) as HTMLElement;
+ if (activeTab) {
+ const containerRect = container.getBoundingClientRect();
+ const tabRect = activeTab.getBoundingClientRect();
+
+ if (tabRect.left < containerRect.left) {
+ container.scrollLeft -= containerRect.left - tabRect.left + 10;
+ } else if (tabRect.right > containerRect.right) {
+ container.scrollLeft += tabRect.right - containerRect.right + 10;
+ }
+ }
+ }, [activeTabId]);
+
+ // Keyboard shortcut handler (Ctrl+W to close active tab)
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
+ e.preventDefault();
+ if (activeTabId) {
+ closeTab(activeTabId);
+ }
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [activeTabId, closeTab]);
+
+ if (tabs.length === 0) {
+ return null;
+ }
+
+ const handleTabClick = (tabId: string) => {
+ setActiveTab(tabId);
+ };
+
+ const handleTabDoubleClick = (tab: Tab) => {
+ // Double-click on transient tab pins it
+ if (tab.isTransient) {
+ pinTab(tab.id);
+ }
+ };
+
+ const handleTabClose = (e: React.MouseEvent, tabId: string) => {
+ e.stopPropagation();
+ closeTab(tabId);
+ };
+
+ const handleMiddleClick = (e: React.MouseEvent, tabId: string) => {
+ // Middle-click closes the tab
+ if (e.button === 1) {
+ e.preventDefault();
+ closeTab(tabId);
+ }
+ };
+
+ const scrollLeft = () => {
+ const container = tabsContainerRef.current;
+ if (container) {
+ container.scrollBy({ left: -150, behavior: 'smooth' });
+ }
+ };
+
+ const scrollRight = () => {
+ const container = tabsContainerRef.current;
+ if (container) {
+ container.scrollBy({ left: 150, behavior: 'smooth' });
+ }
+ };
+
+ return (
+
+ {showLeftArrow && (
+
+
+
+ )}
+
+
+ {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 && ● }
+ handleTabClose(e, tab.id)}
+ title="Close (Ctrl+W)"
+ >
+
+
+
+
+ );
+ })}
+
+
+ {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);
+ });
+ });
+});