feat: proper tab handling

This commit is contained in:
2026-02-11 11:40:53 +01:00
parent 513ade1624
commit 01202d55cf
15 changed files with 1443 additions and 26 deletions

View File

@@ -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 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. 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" ### default category "article"
This is for articles that are focused on long text. They will show fully only on the full page, but will This is for articles that are focused on long text. They will show fully only on the full page, but will

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from 'react'; 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 { useAppStore, PostData, MediaData, TaskProgress } from './store';
import './App.css'; import './App.css';
@@ -23,6 +23,7 @@ const App: React.FC = () => {
togglePanel, togglePanel,
setActiveView, setActiveView,
setSelectedPost, setSelectedPost,
openTab,
} = useAppStore(); } = useAppStore();
// Load initial data // Load initial data
@@ -257,7 +258,7 @@ const App: React.FC = () => {
unsubscribers.push( unsubscribers.push(
window.electronAPI?.on('menu:configureSync', () => { window.electronAPI?.on('menu:configureSync', () => {
setActiveView('settings'); openTab({ type: 'settings', id: 'settings', isTransient: false });
}) || (() => {}) }) || (() => {})
); );
@@ -320,6 +321,7 @@ const App: React.FC = () => {
<ActivityBar /> <ActivityBar />
<Sidebar /> <Sidebar />
<div className="app-content"> <div className="app-content">
<TabBar />
<Editor /> <Editor />
<Panel /> <Panel />
</div> </div>

View File

@@ -29,10 +29,18 @@ const SyncIcon = () => (
); );
export const ActivityBar: React.FC = () => { 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; 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 ( return (
<div className="activity-bar"> <div className="activity-bar">
<div className="activity-bar-top"> <div className="activity-bar-top">
@@ -64,8 +72,8 @@ export const ActivityBar: React.FC = () => {
)} )}
</button> </button>
<button <button
className={`activity-bar-item ${activeView === 'settings' ? 'active' : ''}`} className={`activity-bar-item ${isSettingsTabActive ? 'active' : ''}`}
onClick={() => setActiveView('settings')} onClick={handleSettingsClick}
title="Settings" title="Settings"
> >
<SettingsIcon /> <SettingsIcon />

View File

@@ -7,8 +7,45 @@ import { Lightbox, useMarkdownImages } from '../Lightbox';
import { PostLinks } from '../PostLinks'; import { PostLinks } from '../PostLinks';
import { ErrorModal } from '../ErrorModal'; import { ErrorModal } from '../ErrorModal';
import { SettingsView } from '../SettingsView'; import { SettingsView } from '../SettingsView';
import { AutoSaveManager } from '../../utils';
import './Editor.css'; import './Editor.css';
// Module-level AutoSaveManager for idle-time based auto-saving
const autoSaveManager = new AutoSaveManager({
idleTimeMs: 3000, // Save after 3 seconds of idle time
onSave: async (id, changes) => {
const state = useAppStore.getState();
// Only save if post still exists in store
const postExists = state.posts.some(p => p.id === id);
if (!postExists) return;
// Build update payload from changes
const update: Parameters<typeof window.electronAPI.posts.update>[1] = {};
if ('title' in changes) update.title = changes.title as string;
if ('content' in changes) update.content = changes.content as string;
if ('tags' in changes) {
const tagsStr = changes.tags as string;
update.tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0);
}
if ('category' in changes) {
const cat = changes.category as string;
update.categories = cat ? [cat] : ['article'];
}
const updated = await window.electronAPI?.posts.update(id, update);
if (updated) {
useAppStore.getState().updatePost(id, updated as Partial<PostData>);
useAppStore.getState().markClean(id);
}
},
onSaveComplete: (id) => {
console.log(`Auto-saved post ${id}`);
},
onSaveError: (id, error) => {
console.error(`Auto-save failed for ${id}:`, error);
},
});
/** /**
* Resolves media references in markdown content to bds-media:// URLs * Resolves media references in markdown content to bds-media:// URLs
* Matches images by: * Matches images by:
@@ -176,6 +213,9 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
const prevPostId = post.id; const prevPostId = post.id;
return () => { return () => {
// Cancel any pending auto-save timer - we'll save immediately
autoSaveManager.cancel(prevPostId);
const pending = pendingChangesRef.current; const pending = pendingChangesRef.current;
// Only auto-save if the post still exists in the store (not deleted/discarded) // Only auto-save if the post still exists in the store (not deleted/discarded)
const postStillExists = useAppStore.getState().posts.some(p => p.id === prevPostId); const postStillExists = useAppStore.getState().posts.some(p => p.id === prevPostId);
@@ -207,7 +247,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
markClean(post.id); markClean(post.id);
}, [post.id, post.title, post.content, post.tags, post.categories, markClean]); }, [post.id, post.title, post.content, post.tags, post.categories, markClean]);
// Track changes // Track changes and notify auto-save manager
useEffect(() => { useEffect(() => {
const currentCategory = post.categories[0] || 'article'; const currentCategory = post.categories[0] || 'article';
const hasChanges = const hasChanges =
@@ -218,6 +258,13 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
if (hasChanges) { if (hasChanges) {
markDirty(post.id); markDirty(post.id);
// Notify auto-save manager with accumulated changes
autoSaveManager.notifyChange(post.id, {
title,
content,
tags,
category,
});
} else { } else {
markClean(post.id); markClean(post.id);
} }
@@ -232,6 +279,9 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (!isDirty || isSaving) return; if (!isDirty || isSaving) return;
// Cancel any pending auto-save since we're saving manually
autoSaveManager.cancel(post.id);
setIsSaving(true); setIsSaving(true);
try { try {
const updated = await window.electronAPI?.posts.update(post.id, { const updated = await window.electronAPI?.posts.update(post.id, {
@@ -934,6 +984,12 @@ const Dashboard: React.FC = () => {
onClick={() => { onClick={() => {
useAppStore.getState().setActiveView('posts'); useAppStore.getState().setActiveView('posts');
useAppStore.getState().setSelectedPost(post.id); useAppStore.getState().setSelectedPost(post.id);
useAppStore.getState().openTab({ type: 'post', id: post.id, isTransient: true });
}}
onDoubleClick={() => {
useAppStore.getState().setActiveView('posts');
useAppStore.getState().setSelectedPost(post.id);
useAppStore.getState().openTab({ type: 'post', id: post.id, isTransient: false });
}} }}
> >
<span className="recent-post-title">{post.title || 'Untitled'}</span> <span className="recent-post-title">{post.title || 'Untitled'}</span>
@@ -954,6 +1010,8 @@ export const Editor: React.FC = () => {
activeView, activeView,
selectedPostId, selectedPostId,
selectedMediaId, selectedMediaId,
tabs,
activeTabId,
posts, posts,
media, media,
errorModal, errorModal,
@@ -961,8 +1019,17 @@ export const Editor: React.FC = () => {
isLoading, isLoading,
setSelectedPost, setSelectedPost,
setSelectedMedia, setSelectedMedia,
closeTab,
} = useAppStore(); } = useAppStore();
// Get the active tab
const activeTab = tabs.find(t => t.id === activeTabId);
// Determine what to show based on active tab
const showPost = activeTab?.type === 'post';
const showMedia = activeTab?.type === 'media';
const showSettings = activeTab?.type === 'settings' || (activeView === 'settings' && !activeTab);
// Clear selectedPostId if the post doesn't exist (e.g., after project switch) // Clear selectedPostId if the post doesn't exist (e.g., after project switch)
useEffect(() => { useEffect(() => {
if (activeView === 'posts' && selectedPostId && !isLoading) { if (activeView === 'posts' && selectedPostId && !isLoading) {
@@ -983,12 +1050,30 @@ export const Editor: React.FC = () => {
} }
}, [activeView, selectedMediaId, media, isLoading, setSelectedMedia]); }, [activeView, selectedMediaId, media, isLoading, setSelectedMedia]);
// Close tab if the item doesn't exist anymore
useEffect(() => {
if (activeTab && !isLoading) {
if (activeTab.type === 'post') {
const postExists = posts.some(p => p.id === activeTab.id);
if (!postExists) {
closeTab(activeTab.id);
}
} else if (activeTab.type === 'media') {
const mediaExists = media.some(m => m.id === activeTab.id);
if (!mediaExists) {
closeTab(activeTab.id);
}
}
}
}, [activeTab, posts, media, isLoading, closeTab]);
// Show error modal if present // Show error modal if present
const renderErrorModal = () => ( const renderErrorModal = () => (
<ErrorModal error={errorModal} onClose={hideErrorModal} /> <ErrorModal error={errorModal} onClose={hideErrorModal} />
); );
if (activeView === 'settings') { // Show settings if settings tab is active or settings view with no tab
if (showSettings) {
return ( return (
<> <>
<SettingsView /> <SettingsView />
@@ -997,18 +1082,19 @@ export const Editor: React.FC = () => {
); );
} }
if (activeView === 'posts' && selectedPostId) { // Show post editor if a post tab is active
const post = posts.find(p => p.id === selectedPostId); if (showPost && activeTabId) {
const post = posts.find(p => p.id === activeTabId);
if (post) { if (post) {
return ( return (
<> <>
<PostEditor post={post} /> <PostEditor key={post.id} post={post} />
{renderErrorModal()} {renderErrorModal()}
</> </>
); );
} }
// Post not found - show loading or empty state while useEffect clears selection // Post not found - show loading or empty state
return ( return (
<> <>
<div className="editor-empty"> <div className="editor-empty">
@@ -1021,15 +1107,17 @@ export const Editor: React.FC = () => {
); );
} }
if (activeView === 'media' && selectedMediaId) { // Show media editor if a media tab is active
if (showMedia && activeTabId) {
return ( return (
<> <>
<MediaEditor mediaId={selectedMediaId} /> <MediaEditor key={activeTabId} mediaId={activeTabId} />
{renderErrorModal()} {renderErrorModal()}
</> </>
); );
} }
// No tab active - show dashboard
return ( return (
<> <>
<Dashboard /> <Dashboard />

View File

@@ -222,7 +222,7 @@ const SearchBox: React.FC<SearchBoxProps> = ({ onSearch }) => {
}; };
const PostsList: React.FC = () => { const PostsList: React.FC = () => {
const { posts, selectedPostId, setSelectedPost, hasMorePosts, totalPosts, appendPosts } = useAppStore(); const { posts, hasMorePosts, totalPosts, appendPosts, openTab, activeTabId } = useAppStore();
// Filter state // Filter state
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -379,6 +379,15 @@ const PostsList: React.FC = () => {
setFilteredPosts(null); setFilteredPosts(null);
}; };
// Click handlers for tabs
const handlePostClick = (postId: string) => {
openTab({ type: 'post', id: postId, isTransient: true });
};
const handlePostDoubleClick = (postId: string) => {
openTab({ type: 'post', id: postId, isTransient: false });
};
return ( return (
<div className="sidebar-content"> <div className="sidebar-content">
<div className="sidebar-section"> <div className="sidebar-section">
@@ -447,8 +456,9 @@ const PostsList: React.FC = () => {
return ( return (
<div <div
key={post.id} key={post.id}
className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`} className={`sidebar-item post-type-${postType.type} ${activeTabId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)} onClick={() => handlePostClick(post.id)}
onDoubleClick={() => handlePostDoubleClick(post.id)}
> >
<span className="post-type-icon" title={postType.type}>{postType.icon}</span> <span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content"> <div className="sidebar-item-content">
@@ -474,8 +484,9 @@ const PostsList: React.FC = () => {
return ( return (
<div <div
key={post.id} key={post.id}
className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`} className={`sidebar-item post-type-${postType.type} ${activeTabId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)} onClick={() => handlePostClick(post.id)}
onDoubleClick={() => handlePostDoubleClick(post.id)}
> >
<span className="post-type-icon" title={postType.type}>{postType.icon}</span> <span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content"> <div className="sidebar-item-content">
@@ -501,8 +512,9 @@ const PostsList: React.FC = () => {
return ( return (
<div <div
key={post.id} key={post.id}
className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`} className={`sidebar-item post-type-${postType.type} ${activeTabId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)} onClick={() => handlePostClick(post.id)}
onDoubleClick={() => handlePostDoubleClick(post.id)}
> >
<span className="post-type-icon" title={postType.type}>{postType.icon}</span> <span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content"> <div className="sidebar-item-content">
@@ -547,7 +559,7 @@ const PostsList: React.FC = () => {
}; };
const MediaList: React.FC = () => { const MediaList: React.FC = () => {
const { media, selectedMediaId, setSelectedMedia } = useAppStore(); const { media, openTab, activeTabId } = useAppStore();
const handleImportMedia = async () => { const handleImportMedia = async () => {
try { try {
@@ -557,6 +569,14 @@ const MediaList: React.FC = () => {
} }
}; };
const handleMediaClick = (mediaId: string) => {
openTab({ type: 'media', id: mediaId, isTransient: true });
};
const handleMediaDoubleClick = (mediaId: string) => {
openTab({ type: 'media', id: mediaId, isTransient: false });
};
return ( return (
<div className="sidebar-content"> <div className="sidebar-content">
<div className="sidebar-section"> <div className="sidebar-section">
@@ -574,8 +594,9 @@ const MediaList: React.FC = () => {
{media.map(item => ( {media.map(item => (
<div <div
key={item.id} key={item.id}
className={`media-item ${selectedMediaId === item.id ? 'selected' : ''}`} className={`media-item ${activeTabId === item.id ? 'selected' : ''}`}
onClick={() => setSelectedMedia(item.id)} onClick={() => handleMediaClick(item.id)}
onDoubleClick={() => handleMediaDoubleClick(item.id)}
title={item.originalName} title={item.originalName}
> >
{item.mimeType.startsWith('image/') ? ( {item.mimeType.startsWith('image/') ? (

View File

@@ -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;
}

View File

@@ -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 (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.85 4.44l-3.28-3.3-.35-.14H2.5l-.5.5v13l.5.5h11l.5-.5V4.8l-.15-.36zm-.85 10.06H3V2h6v3.5l.5.5H13v8.5z"/>
</svg>
);
case 'media':
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14.5 2h-13l-.5.5v11l.5.5h13l.5-.5v-11l-.5-.5zM14 13H2V3h12v10zm-3.5-4.5L9 7.5l-2 2.5-1.5-1L3 12h10l-2.5-3.5z"/>
</svg>
);
case 'settings':
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M9.1 4.4L8.6 2H7.4l-.5 2.4-.7.3-2-1.3-.9.8 1.3 2-.2.7-2.4.5v1.2l2.4.5.3.8-1.3 2 .8.8 2-1.3.8.3.4 2.3h1.2l.5-2.4.8-.3 2 1.3.8-.8-1.3-2 .3-.8 2.3-.4V7.4l-2.4-.5-.3-.8 1.3-2-.8-.8-2 1.3-.7-.2zM9.4 1l.5 2.4L12 2.1l2 2-1.4 2.1 2.4.4v3l-2.4.5L14 12l-2 2-2.1-1.4-.5 2.4h-3L5.9 12.5 4 14l-2-2 1.4-2.1L1 9.4v-3l2.4-.5L2 4l2-2 2.1 1.4.4-2.4h3zm.6 7c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM8 9c.6 0 1-.4 1-1s-.4-1-1-1-1 .4-1 1 .4 1 1 1z"/>
</svg>
);
default:
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.85 4.44l-3.28-3.3-.35-.14H2.5l-.5.5v13l.5.5h11l.5-.5V4.8l-.15-.36zm-.85 10.06H3V2h6v3.5l.5.5H13v8.5z"/>
</svg>
);
}
};
const CloseIcon: React.FC = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path fillRule="evenodd" clipRule="evenodd" d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
</svg>
);
const ChevronLeftIcon: React.FC = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path fillRule="evenodd" clipRule="evenodd" d="M10.354 3.146l.707.708L6.768 8l4.293 4.146-.707.708L5.354 8l5-4.854z"/>
</svg>
);
const ChevronRightIcon: React.FC = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path fillRule="evenodd" clipRule="evenodd" d="M5.646 12.854l-.707-.708L9.232 8 4.939 3.854l.707-.708L10.646 8l-5 4.854z"/>
</svg>
);
export const TabBar: React.FC = () => {
const {
tabs,
activeTabId,
posts,
media,
dirtyPosts,
setActiveTab,
closeTab,
pinTab,
} = useAppStore();
const tabsContainerRef = useRef<HTMLDivElement>(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 (
<div className="tab-bar">
{showLeftArrow && (
<button
className="tab-scroll-button tab-scroll-left"
onClick={scrollLeft}
title="Scroll tabs left"
>
<ChevronLeftIcon />
</button>
)}
<div className="tab-bar-tabs" ref={tabsContainerRef}>
{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 (
<div
key={tab.id}
data-tab-id={tab.id}
className={`tab ${isActive ? 'active' : ''} ${tab.isTransient ? 'transient' : ''} ${isDirty ? 'dirty' : ''}`}
onClick={() => handleTabClick(tab.id)}
onDoubleClick={() => handleTabDoubleClick(tab)}
onMouseDown={(e) => handleMiddleClick(e, tab.id)}
title={`${title}${tab.isTransient ? ' (Preview)' : ''}${isDirty ? ' • Modified' : ''}`}
>
<span className="tab-icon">{icon}</span>
<span className={`tab-title ${tab.isTransient ? 'italic' : ''}`}>
{title}
</span>
<div className="tab-actions">
{isDirty && <span className="tab-dirty-indicator"></span>}
<button
className="tab-close"
onClick={(e) => handleTabClose(e, tab.id)}
title="Close (Ctrl+W)"
>
<CloseIcon />
</button>
</div>
</div>
);
})}
</div>
{showRightArrow && (
<button
className="tab-scroll-button tab-scroll-right"
onClick={scrollRight}
title="Scroll tabs right"
>
<ChevronRightIcon />
</button>
)}
</div>
);
};
export default TabBar;

View File

@@ -0,0 +1 @@
export { TabBar } from './TabBar';

View File

@@ -3,6 +3,7 @@ export { Sidebar } from './Sidebar';
export { Editor } from './Editor'; export { Editor } from './Editor';
export { StatusBar } from './StatusBar'; export { StatusBar } from './StatusBar';
export { Panel } from './Panel'; export { Panel } from './Panel';
export { TabBar } from './TabBar';
export { ToastContainer, toast, showToast, type ToastType } from './Toast'; export { ToastContainer, toast, showToast, type ToastType } from './Toast';
export { ProjectSelector } from './ProjectSelector'; export { ProjectSelector } from './ProjectSelector';
export { WysiwygEditor } from './WysiwygEditor'; export { WysiwygEditor } from './WysiwygEditor';

View File

@@ -4,6 +4,20 @@ import { persist } from 'zustand/middleware';
// Storage key for persisted state // Storage key for persisted state
const STORAGE_KEY = 'bds-app-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 // Types
export interface ProjectData { export interface ProjectData {
id: string; id: string;
@@ -69,6 +83,10 @@ interface AppState {
projects: ProjectData[]; projects: ProjectData[];
activeProject: ProjectData | null; activeProject: ProjectData | null;
// Tabs
tabs: Tab[];
activeTabId: string | null;
// UI State // UI State
activeView: 'posts' | 'media' | 'settings'; activeView: 'posts' | 'media' | 'settings';
sidebarVisible: boolean; sidebarVisible: boolean;
@@ -108,6 +126,15 @@ interface AppState {
updateProject: (id: string, project: Partial<ProjectData>) => void; updateProject: (id: string, project: Partial<ProjectData>) => void;
removeProject: (id: string) => 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 // Actions
setActiveView: (view: 'posts' | 'media' | 'settings') => void; setActiveView: (view: 'posts' | 'media' | 'settings') => void;
toggleSidebar: () => void; toggleSidebar: () => void;
@@ -154,6 +181,10 @@ export const useAppStore = create<AppState>()(
projects: [], projects: [],
activeProject: null, activeProject: null,
// Initial Tabs State
tabs: [],
activeTabId: null,
// Initial UI State // Initial UI State
activeView: 'posts', activeView: 'posts',
sidebarVisible: true, sidebarVisible: true,
@@ -197,6 +228,82 @@ export const useAppStore = create<AppState>()(
projects: state.projects.filter((p) => p.id !== id), 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 // UI Actions
setActiveView: (view) => set({ activeView: view }), setActiveView: (view) => set({ activeView: view }),
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })), toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
@@ -283,15 +390,20 @@ export const useAppStore = create<AppState>()(
selectedPostId: state.selectedPostId, selectedPostId: state.selectedPostId,
selectedMediaId: state.selectedMediaId, selectedMediaId: state.selectedMediaId,
preferredEditorMode: state.preferredEditorMode, 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 // Convert Set to array for storage
dirtyPosts: [...state.dirtyPosts], dirtyPosts: [...state.dirtyPosts],
}), }),
// Merge function to restore Set from array // Merge function to restore Set from array
merge: (persisted, current) => { merge: (persisted, current) => {
const persistedState = persisted as Partial<AppState> & { dirtyPosts?: string[] }; const persistedState = persisted as Partial<AppState> & { dirtyPosts?: string[]; tabs?: Tab[] };
return { return {
...current, ...current,
...persistedState, ...persistedState,
tabs: persistedState.tabs || [],
activeTabId: persistedState.activeTabId || null,
dirtyPosts: new Set(persistedState.dirtyPosts || []), dirtyPosts: new Set(persistedState.dirtyPosts || []),
}; };
}, },

View File

@@ -5,5 +5,8 @@ export {
type MediaData, type MediaData,
type TaskProgress, type TaskProgress,
type EditorMode, type EditorMode,
type ErrorDetails type ErrorDetails,
type Tab,
type TabType,
type TabState
} from './appStore'; } from './appStore';

View File

@@ -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<string, unknown>) => Promise<void>;
/** Callback when save completes successfully */
onSaveComplete?: (id: string) => void;
/** Callback when save fails */
onSaveError?: (id: string, error: Error) => void;
}
interface PendingChange {
changes: Record<string, unknown>;
timerId: ReturnType<typeof setTimeout>;
}
export class AutoSaveManager {
private pendingChanges: Map<string, PendingChange> = 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<string, unknown>): 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<void> {
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<void> {
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();
}
}

View File

@@ -0,0 +1 @@
export { AutoSaveManager, type AutoSaveConfig } from './autoSave';

View File

@@ -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> = {}): 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> = {}): 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);
});
});
});

View File

@@ -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<typeof vi.fn>;
let onSaveCompleteCallback: ReturnType<typeof vi.fn>;
let onSaveErrorCallback: ReturnType<typeof vi.fn>;
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);
});
});
});