feat: proper tab handling
This commit is contained in:
11
VISION.md
11
VISION.md
@@ -153,6 +153,17 @@ I can link to other places, if needed, and those references should also be part
|
|||||||
posts. So each post should give a "links to" part in the UI (right sidebar or lower area of left sidebar
|
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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -29,9 +29,17 @@ 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">
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -953,7 +1009,9 @@ export const Editor: React.FC = () => {
|
|||||||
const {
|
const {
|
||||||
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 />
|
||||||
|
|||||||
@@ -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/') ? (
|
||||||
|
|||||||
200
src/renderer/components/TabBar/TabBar.css
Normal file
200
src/renderer/components/TabBar/TabBar.css
Normal 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;
|
||||||
|
}
|
||||||
249
src/renderer/components/TabBar/TabBar.tsx
Normal file
249
src/renderer/components/TabBar/TabBar.tsx
Normal 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;
|
||||||
1
src/renderer/components/TabBar/index.ts
Normal file
1
src/renderer/components/TabBar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { TabBar } from './TabBar';
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 || []),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
129
src/renderer/utils/autoSave.ts
Normal file
129
src/renderer/utils/autoSave.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/renderer/utils/index.ts
Normal file
1
src/renderer/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AutoSaveManager, type AutoSaveConfig } from './autoSave';
|
||||||
342
tests/renderer/store/tabStore.test.ts
Normal file
342
tests/renderer/store/tabStore.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
249
tests/renderer/utils/autoSave.test.ts
Normal file
249
tests/renderer/utils/autoSave.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user