feat: wiki like linkage for posts

This commit is contained in:
2026-02-27 16:36:45 +01:00
parent f9527b384b
commit bd10825e74
12 changed files with 390 additions and 18 deletions

View File

@@ -847,6 +847,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
content={content} content={content}
onChange={setContent} onChange={setContent}
placeholder={tr('editor.placeholder.startWriting')} placeholder={tr('editor.placeholder.startWriting')}
currentPostTags={tags}
currentPostCategories={selectedCategories}
/> />
)} )}
@@ -921,6 +923,8 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
onInsertLink={handleInsertLink} onInsertLink={handleInsertLink}
onInsertImage={() => {}} onInsertImage={() => {}}
onClose={() => setShowPostSearch(false)} onClose={() => setShowPostSearch(false)}
currentPostTags={tags}
currentPostCategories={selectedCategories}
/> />
)} )}

View File

@@ -240,3 +240,58 @@
.insert-modal-results::-webkit-scrollbar-thumb:hover { .insert-modal-results::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted, #555); background: var(--color-text-muted, #555);
} }
/* Create post option */
.insert-modal-result-create {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 12px 16px;
border: none;
border-top: 1px solid var(--color-border, #3c3c3c);
margin-top: 4px;
padding-top: 16px;
background: transparent;
color: var(--vscode-notificationsInfoIcon-foreground, #75beff);
font-family: inherit;
font-size: 14px;
text-align: left;
cursor: pointer;
border-radius: 0 0 4px 4px;
transition: background-color 0.15s ease;
}
.insert-modal-result-create:first-child {
border-top: none;
margin-top: 0;
padding-top: 12px;
}
.insert-modal-result-create:hover,
.insert-modal-result-create.selected {
background: var(--color-bg-tertiary, #2a2a2a);
}
.insert-modal-result-create.selected {
border-left: 3px solid var(--vscode-notificationsInfoIcon-foreground, #75beff);
padding-left: 13px;
}
.insert-modal-result-create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.insert-modal-create-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px dashed currentColor;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}

View File

@@ -1,5 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../i18n'; import { useI18n } from '../../i18n';
import { useAppStore } from '../../store/appStore';
import { showToast } from '../Toast';
import './InsertModal.css'; import './InsertModal.css';
interface PostSearchResult { interface PostSearchResult {
@@ -38,6 +40,8 @@ interface InsertModalProps {
onInsertImage: (url: string, alt: string, mediaId?: string) => void; onInsertImage: (url: string, alt: string, mediaId?: string) => void;
onClose: () => void; onClose: () => void;
initialText?: string; // Selected text in editor initialText?: string; // Selected text in editor
currentPostTags?: string[];
currentPostCategories?: string[];
} }
function isPostResult(result: SearchResult): result is PostSearchResult { function isPostResult(result: SearchResult): result is PostSearchResult {
@@ -54,8 +58,11 @@ export const InsertModal: React.FC<InsertModalProps> = ({
onInsertImage, onInsertImage,
onClose, onClose,
initialText = '', initialText = '',
currentPostTags,
currentPostCategories,
}) => { }) => {
const { t: tr } = useI18n(); const { t: tr } = useI18n();
const openTabInBackground = useAppStore((s) => s.openTabInBackground);
const [activeTab, setActiveTab] = useState<Tab>('internal'); const [activeTab, setActiveTab] = useState<Tab>('internal');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [externalUrl, setExternalUrl] = useState(''); const [externalUrl, setExternalUrl] = useState('');
@@ -64,9 +71,20 @@ export const InsertModal: React.FC<InsertModalProps> = ({
const [results, setResults] = useState<SearchResult[]>([]); const [results, setResults] = useState<SearchResult[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const externalUrlRef = useRef<HTMLInputElement>(null); const externalUrlRef = useRef<HTMLInputElement>(null);
// Whether to show the "Create post" option
const showCreateOption = mode === 'link' &&
activeTab === 'internal' &&
query.trim().length >= 2 &&
!isSearching &&
!results.some(r => isPostResult(r) && r.title.toLowerCase() === query.trim().toLowerCase());
// Total selectable items count (results + optional create option)
const totalItems = results.length + (showCreateOption ? 1 : 0);
// Focus appropriate input on mount and tab change // Focus appropriate input on mount and tab change
useEffect(() => { useEffect(() => {
if (activeTab === 'internal') { if (activeTab === 'internal') {
@@ -106,6 +124,34 @@ export const InsertModal: React.FC<InsertModalProps> = ({
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [query, mode, activeTab]); }, [query, mode, activeTab]);
// Handle creating a new post from the search query
const handleCreatePost = useCallback(async () => {
const title = query.trim();
if (!title || isCreating) return;
setIsCreating(true);
try {
const newPost = await window.electronAPI.posts.create({
title,
tags: currentPostTags || [],
categories: currentPostCategories || [],
});
if (newPost) {
openTabInBackground({ type: 'post', id: newPost.id, isTransient: false });
const linkUrl = `/posts/${newPost.slug}`;
onInsertLink(linkUrl, title);
showToast.success(tr('insert.createdPost', { title }));
onClose();
}
} catch (error) {
const err = error as Error;
showToast.error(err.message);
} finally {
setIsCreating(false);
}
}, [query, isCreating, currentPostTags, currentPostCategories, openTabInBackground, onInsertLink, onClose, tr]);
// Keyboard navigation handler // Keyboard navigation handler
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) { switch (e.key) {
@@ -116,7 +162,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
case 'ArrowDown': case 'ArrowDown':
if (activeTab === 'internal') { if (activeTab === 'internal') {
e.preventDefault(); e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, results.length - 1)); setSelectedIndex(prev => Math.min(prev + 1, totalItems - 1));
} }
break; break;
case 'ArrowUp': case 'ArrowUp':
@@ -127,9 +173,13 @@ export const InsertModal: React.FC<InsertModalProps> = ({
break; break;
case 'Enter': case 'Enter':
e.preventDefault(); e.preventDefault();
if (activeTab === 'internal' && results[selectedIndex]) { if (activeTab === 'internal') {
if (selectedIndex < results.length && results[selectedIndex]) {
handleSelectResult(results[selectedIndex]); handleSelectResult(results[selectedIndex]);
} else if (activeTab === 'external' && externalUrl) { } else if (showCreateOption && selectedIndex === results.length) {
handleCreatePost();
}
} else if (externalUrl) {
handleExternalSubmit(); handleExternalSubmit();
} }
break; break;
@@ -137,7 +187,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
// Allow tab switching with Tab key when on the tab buttons // Allow tab switching with Tab key when on the tab buttons
break; break;
} }
}, [activeTab, results, selectedIndex, externalUrl, onClose]); }, [activeTab, results, selectedIndex, totalItems, showCreateOption, externalUrl, onClose, handleCreatePost]);
// Handle selecting a search result // Handle selecting a search result
const handleSelectResult = useCallback(async (result: SearchResult) => { const handleSelectResult = useCallback(async (result: SearchResult) => {
@@ -180,7 +230,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
// Scroll selected item into view // Scroll selected item into view
useEffect(() => { useEffect(() => {
const selectedElement = document.querySelector('.insert-modal-result-item.selected'); const selectedElement = document.querySelector('.insert-modal-result-item.selected, .insert-modal-result-create.selected');
if (selectedElement) { if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
} }
@@ -224,6 +274,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
autoComplete="off" autoComplete="off"
/> />
</div> </div>
@@ -239,7 +290,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
</div> </div>
)} )}
{!isSearching && query.length >= 2 && results.length === 0 && ( {!isSearching && query.length >= 2 && results.length === 0 && !showCreateOption && (
<div className="insert-modal-status"> <div className="insert-modal-status">
{tr('insert.status.noResults', { kind: mode === 'link' ? tr('activity.posts').toLowerCase() : tr('activity.media').toLowerCase(), query })} {tr('insert.status.noResults', { kind: mode === 'link' ? tr('activity.posts').toLowerCase() : tr('activity.media').toLowerCase(), query })}
</div> </div>
@@ -274,6 +325,19 @@ export const InsertModal: React.FC<InsertModalProps> = ({
)} )}
</div> </div>
))} ))}
{showCreateOption && (
<button
type="button"
className={`insert-modal-result-create ${selectedIndex === results.length ? 'selected' : ''}`}
onClick={handleCreatePost}
onMouseEnter={() => setSelectedIndex(results.length)}
disabled={isCreating}
>
<span className="insert-modal-create-icon">+</span>
<span>{tr('insert.createPost', { title: query.trim() })}</span>
</button>
)}
</div> </div>
</> </>
) : ( ) : (

View File

@@ -54,6 +54,8 @@ interface MilkdownEditorProps {
content: string; content: string;
onChange: (markdown: string) => void; onChange: (markdown: string) => void;
placeholder?: string; placeholder?: string;
currentPostTags?: string[];
currentPostCategories?: string[];
} }
interface MilkdownChangePropagationInput { interface MilkdownChangePropagationInput {
@@ -86,10 +88,12 @@ export const shouldPropagateMilkdownChange = ({
interface EditorToolbarProps { interface EditorToolbarProps {
onUserInteraction: () => void; onUserInteraction: () => void;
currentPostTags?: string[];
currentPostCategories?: string[];
} }
// Toolbar component that uses the editor instance // Toolbar component that uses the editor instance
const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => { const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction, currentPostTags, currentPostCategories }) => {
const { t: tr } = useI18n(); const { t: tr } = useI18n();
const [loading, getEditor] = useInstance(); const [loading, getEditor] = useInstance();
const [insertMode, setInsertMode] = useState<InsertModalMode>(null); const [insertMode, setInsertMode] = useState<InsertModalMode>(null);
@@ -269,6 +273,8 @@ const EditorToolbar: React.FC<EditorToolbarProps> = ({ onUserInteraction }) => {
onInsertImage={handleInsertImage} onInsertImage={handleInsertImage}
onClose={() => setInsertMode(null)} onClose={() => setInsertMode(null)}
initialText={selectedText} initialText={selectedText}
currentPostTags={currentPostTags}
currentPostCategories={currentPostCategories}
/> />
)} )}
</> </>
@@ -289,6 +295,8 @@ const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
content, content,
onChange, onChange,
placeholder, placeholder,
currentPostTags,
currentPostCategories,
}) => { }) => {
const { t: tr } = useI18n(); const { t: tr } = useI18n();
const resolvedPlaceholder = placeholder || tr('editor.placeholder'); const resolvedPlaceholder = placeholder || tr('editor.placeholder');
@@ -376,7 +384,7 @@ const MilkdownProviderInner: React.FC<MilkdownEditorProps> = ({
onPasteCapture={markUserInteraction} onPasteCapture={markUserInteraction}
onInputCapture={markUserInteraction} onInputCapture={markUserInteraction}
> >
<EditorToolbar onUserInteraction={markUserInteraction} /> <EditorToolbar onUserInteraction={markUserInteraction} currentPostTags={currentPostTags} currentPostCategories={currentPostCategories} />
<div className="milkdown-content" data-placeholder={resolvedPlaceholder}> <div className="milkdown-content" data-placeholder={resolvedPlaceholder}>
<Milkdown /> <Milkdown />
</div> </div>

View File

@@ -248,6 +248,8 @@
"insert.hint.external": "URL eingeben und Enter drücken oder auf die Schaltfläche klicken, Esc zum Schließen", "insert.hint.external": "URL eingeben und Enter drücken oder auf die Schaltfläche klicken, Esc zum Schließen",
"insert.hint.canonicalPost": "Kanonisch: /YYYY/MM/DD/slug", "insert.hint.canonicalPost": "Kanonisch: /YYYY/MM/DD/slug",
"insert.hint.canonicalMedia": "Kanonisch: /media/YYYY/MM/datei.ext", "insert.hint.canonicalMedia": "Kanonisch: /media/YYYY/MM/datei.ext",
"insert.createPost": "Beitrag \"{title}\" erstellen",
"insert.createdPost": "Beitrag \"{title}\" erstellt",
"postLinks.loading": "Links werden geladen...", "postLinks.loading": "Links werden geladen...",
"postLinks.link": "Link", "postLinks.link": "Link",
"postLinks.links": "Links", "postLinks.links": "Links",

View File

@@ -248,6 +248,8 @@
"insert.hint.external": "Enter URL and press Enter or click button, Esc to close", "insert.hint.external": "Enter URL and press Enter or click button, Esc to close",
"insert.hint.canonicalPost": "Canonical: /YYYY/MM/DD/slug", "insert.hint.canonicalPost": "Canonical: /YYYY/MM/DD/slug",
"insert.hint.canonicalMedia": "Canonical: /media/YYYY/MM/file.ext", "insert.hint.canonicalMedia": "Canonical: /media/YYYY/MM/file.ext",
"insert.createPost": "Create post \"{title}\"",
"insert.createdPost": "Post \"{title}\" created",
"postLinks.loading": "Loading links...", "postLinks.loading": "Loading links...",
"postLinks.link": "link", "postLinks.link": "link",
"postLinks.links": "links", "postLinks.links": "links",

View File

@@ -248,6 +248,8 @@
"insert.hint.external": "Introduce la URL y pulsa Enter o haz clic en el botón, Esc para cerrar", "insert.hint.external": "Introduce la URL y pulsa Enter o haz clic en el botón, Esc para cerrar",
"insert.hint.canonicalPost": "Canónico: /YYYY/MM/DD/slug", "insert.hint.canonicalPost": "Canónico: /YYYY/MM/DD/slug",
"insert.hint.canonicalMedia": "Canónico: /media/YYYY/MM/archivo.ext", "insert.hint.canonicalMedia": "Canónico: /media/YYYY/MM/archivo.ext",
"insert.createPost": "Crear artículo \"{title}\"",
"insert.createdPost": "Artículo \"{title}\" creado",
"postLinks.loading": "Cargando enlaces...", "postLinks.loading": "Cargando enlaces...",
"postLinks.link": "enlace", "postLinks.link": "enlace",
"postLinks.links": "enlaces", "postLinks.links": "enlaces",

View File

@@ -246,6 +246,8 @@
"insert.hint.external": "Entrez lURL et appuyez sur Entrée ou cliquez sur le bouton, Esc pour fermer", "insert.hint.external": "Entrez lURL et appuyez sur Entrée ou cliquez sur le bouton, Esc pour fermer",
"insert.hint.canonicalPost": "Canonique : /YYYY/MM/DD/slug", "insert.hint.canonicalPost": "Canonique : /YYYY/MM/DD/slug",
"insert.hint.canonicalMedia": "Canonique : /media/YYYY/MM/fichier.ext", "insert.hint.canonicalMedia": "Canonique : /media/YYYY/MM/fichier.ext",
"insert.createPost": "Créer l'article « {title} »",
"insert.createdPost": "Article « {title} » créé",
"postLinks.loading": "Chargement des liens...", "postLinks.loading": "Chargement des liens...",
"postLinks.link": "lien", "postLinks.link": "lien",
"postLinks.links": "liens", "postLinks.links": "liens",

View File

@@ -246,6 +246,8 @@
"insert.hint.external": "Inserisci URL e premi Invio o clicca il pulsante, Esc per chiudere", "insert.hint.external": "Inserisci URL e premi Invio o clicca il pulsante, Esc per chiudere",
"insert.hint.canonicalPost": "Canonico: /YYYY/MM/DD/slug", "insert.hint.canonicalPost": "Canonico: /YYYY/MM/DD/slug",
"insert.hint.canonicalMedia": "Canonico: /media/YYYY/MM/file.ext", "insert.hint.canonicalMedia": "Canonico: /media/YYYY/MM/file.ext",
"insert.createPost": "Crea articolo \"{title}\"",
"insert.createdPost": "Articolo \"{title}\" creato",
"postLinks.loading": "Caricamento link...", "postLinks.loading": "Caricamento link...",
"postLinks.link": "collegamento", "postLinks.link": "collegamento",
"postLinks.links": "link", "postLinks.links": "link",

View File

@@ -118,6 +118,7 @@ interface AppState {
// Tab Actions // Tab Actions
openTab: (tab: { type: TabType; id: string; isTransient: boolean }) => void; openTab: (tab: { type: TabType; id: string; isTransient: boolean }) => void;
openTabInBackground: (tab: { type: TabType; id: string; isTransient: boolean }) => void;
closeTab: (id: string) => void; closeTab: (id: string) => void;
setActiveTab: (id: string) => void; setActiveTab: (id: string) => void;
pinTab: (id: string) => void; pinTab: (id: string) => void;
@@ -275,6 +276,22 @@ export const useAppStore = create<AppState>()(
return { tabs: [...state.tabs, newTab], activeTabId: id }; return { tabs: [...state.tabs, newTab], activeTabId: id };
}), }),
openTabInBackground: ({ type, id, isTransient }) => set((state) => {
const existingTabIndex = state.tabs.findIndex((t) => t.id === id && t.type === type);
if (existingTabIndex >= 0) {
if (!isTransient) {
const updatedTabs = [...state.tabs];
updatedTabs[existingTabIndex] = { ...updatedTabs[existingTabIndex], isTransient: false };
return { tabs: updatedTabs };
}
return state;
}
const newTab: Tab = { type, id, isTransient };
return { tabs: [...state.tabs, newTab] };
}),
closeTab: (id) => set((state) => { closeTab: (id) => set((state) => {
const tabIndex = state.tabs.findIndex((t) => t.id === id); const tabIndex = state.tabs.findIndex((t) => t.id === id);
if (tabIndex === -1) return state; if (tabIndex === -1) return state;

View File

@@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen, fireEvent, act } from '@testing-library/react';
import { InsertModal } from '../../../src/renderer/components/InsertModal/InsertModal'; import { InsertModal } from '../../../src/renderer/components/InsertModal/InsertModal';
import { useAppStore } from '../../../src/renderer/store/appStore';
describe('InsertModal format hints', () => { describe('InsertModal format hints', () => {
it('shows canonical post link format hint in internal link mode', () => { it('shows canonical post link format hint in internal link mode', () => {
@@ -30,3 +31,170 @@ describe('InsertModal format hints', () => {
expect(screen.getByText('Canonical: /media/YYYY/MM/file.ext')).toBeInTheDocument(); expect(screen.getByText('Canonical: /media/YYYY/MM/file.ext')).toBeInTheDocument();
}); });
}); });
describe('InsertModal create post', () => {
const mockOnInsertLink = vi.fn();
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
useAppStore.setState({ tabs: [], activeTabId: 'current-post' });
});
it('does not show create option when query is shorter than 2 characters', () => {
render(
<InsertModal
mode="link"
onInsertLink={mockOnInsertLink}
onInsertImage={vi.fn()}
onClose={mockOnClose}
/>
);
const input = screen.getByPlaceholderText('Search posts by title or content...');
fireEvent.input(input, { target: { value: 'a' } });
expect(screen.queryByText(/Create post/)).not.toBeInTheDocument();
});
it('does not show create option in image mode', async () => {
(window.electronAPI.media.search as ReturnType<typeof vi.fn>).mockResolvedValue([]);
render(
<InsertModal
mode="image"
onInsertLink={vi.fn()}
onInsertImage={vi.fn()}
onClose={mockOnClose}
/>
);
const input = screen.getByPlaceholderText('Search media by name, title, or alt text...');
fireEvent.input(input, { target: { value: 'test query' } });
// Wait for search to complete by finding the no-results message
await screen.findByText(/No.*found/i);
expect(screen.queryByText(/Create post/)).not.toBeInTheDocument();
});
it('shows create option when search has no exact title match', async () => {
(window.electronAPI.posts.search as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'p1', title: 'Different Title', slug: 'different-title', excerpt: 'Some text' },
]);
render(
<InsertModal
mode="link"
onInsertLink={mockOnInsertLink}
onInsertImage={vi.fn()}
onClose={mockOnClose}
/>
);
const input = screen.getByPlaceholderText('Search posts by title or content...');
fireEvent.input(input, { target: { value: 'My New Post' } });
// Wait for search results to render
await screen.findByText('Different Title');
expect(screen.getByText('Create post "My New Post"')).toBeInTheDocument();
});
it('does not show create option when an exact title match exists', async () => {
(window.electronAPI.posts.search as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'p1', title: 'My New Post', slug: 'my-new-post', excerpt: '' },
]);
render(
<InsertModal
mode="link"
onInsertLink={mockOnInsertLink}
onInsertImage={vi.fn()}
onClose={mockOnClose}
/>
);
const input = screen.getByPlaceholderText('Search posts by title or content...');
fireEvent.input(input, { target: { value: 'My New Post' } });
// Wait for results to render (slug appears in the result path)
await screen.findByText(/my-new-post/);
expect(screen.queryByText('Create post "My New Post"')).not.toBeInTheDocument();
});
it('creates post and inserts link when create option is clicked', async () => {
(window.electronAPI.posts.search as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(window.electronAPI.posts.create as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 'new-post-id',
title: 'New Post Title',
slug: 'new-post-title',
content: '',
status: 'draft',
tags: ['tag1'],
categories: ['article'],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
render(
<InsertModal
mode="link"
onInsertLink={mockOnInsertLink}
onInsertImage={vi.fn()}
onClose={mockOnClose}
currentPostTags={['tag1']}
currentPostCategories={['article']}
/>
);
const input = screen.getByPlaceholderText('Search posts by title or content...');
fireEvent.input(input, { target: { value: 'New Post Title' } });
// Wait for the create option to appear after debounced search completes
const createButton = await screen.findByText('Create post "New Post Title"');
await act(async () => {
fireEvent.click(createButton);
});
expect(window.electronAPI.posts.create).toHaveBeenCalledWith({
title: 'New Post Title',
tags: ['tag1'],
categories: ['article'],
});
expect(mockOnInsertLink).toHaveBeenCalledWith('/posts/new-post-title', 'New Post Title');
expect(mockOnClose).toHaveBeenCalled();
// Check that the tab was opened in the background
const storeState = useAppStore.getState();
expect(storeState.tabs).toContainEqual(
expect.objectContaining({ type: 'post', id: 'new-post-id', isTransient: false })
);
expect(storeState.activeTabId).toBe('current-post');
});
it('shows create option when no results exist (standalone)', async () => {
(window.electronAPI.posts.search as ReturnType<typeof vi.fn>).mockResolvedValue([]);
render(
<InsertModal
mode="link"
onInsertLink={mockOnInsertLink}
onInsertImage={vi.fn()}
onClose={mockOnClose}
/>
);
const input = screen.getByPlaceholderText('Search posts by title or content...');
fireEvent.input(input, { target: { value: 'Nonexistent Post' } });
// Wait for create option to appear (replaces no-results message)
expect(await screen.findByText('Create post "Nonexistent Post"')).toBeInTheDocument();
// The "no results" message should not appear when create option is shown
expect(screen.queryByText(/No posts found/i)).not.toBeInTheDocument();
});
});

View File

@@ -215,4 +215,50 @@ describe('AppStore', () => {
expectTypeOf<TaskProgress>().toEqualTypeOf<SharedTaskProgress>(); expectTypeOf<TaskProgress>().toEqualTypeOf<SharedTaskProgress>();
}); });
}); });
describe('Tab Management', () => {
beforeEach(() => {
setState({
tabs: [],
activeTabId: null,
});
});
it('should open a tab in the background without changing activeTabId', () => {
// Set up an existing active tab
getStore().openTab({ type: 'post', id: 'existing-post', isTransient: false });
expect(getStore().activeTabId).toBe('existing-post');
// Open a new tab in the background
getStore().openTabInBackground({ type: 'post', id: 'background-post', isTransient: false });
expect(getStore().tabs).toHaveLength(2);
expect(getStore().tabs[1].id).toBe('background-post');
expect(getStore().activeTabId).toBe('existing-post');
});
it('should not duplicate a tab when opening in background if it already exists', () => {
getStore().openTab({ type: 'post', id: 'post-1', isTransient: false });
getStore().openTabInBackground({ type: 'post', id: 'post-1', isTransient: false });
expect(getStore().tabs).toHaveLength(1);
});
it('should pin an existing transient tab when opening in background as non-transient', () => {
getStore().openTab({ type: 'post', id: 'post-1', isTransient: true });
getStore().openTabInBackground({ type: 'post', id: 'post-1', isTransient: false });
expect(getStore().tabs).toHaveLength(1);
expect(getStore().tabs[0].isTransient).toBe(false);
});
it('should preserve null activeTabId when opening background tab with no prior active tab', () => {
setState({ activeTabId: null, tabs: [] });
getStore().openTabInBackground({ type: 'post', id: 'bg-post', isTransient: false });
expect(getStore().tabs).toHaveLength(1);
expect(getStore().activeTabId).toBeNull();
});
});
}); });