From 746b323fb93947cb912f0e55a2c4abd191ff7016 Mon Sep 17 00:00:00 2001 From: hugo Date: Tue, 17 Feb 2026 13:41:10 +0100 Subject: [PATCH] feat: links from/to posts as tab in panel --- src/renderer/components/Panel/Panel.css | 30 ++++++ src/renderer/components/Panel/Panel.tsx | 129 ++++++++++++++++++++++- src/renderer/store/appStore.ts | 2 +- tests/renderer/components/Panel.test.tsx | 61 +++++++++++ 4 files changed, 217 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/Panel/Panel.css b/src/renderer/components/Panel/Panel.css index 7f42fb1..7204950 100644 --- a/src/renderer/components/Panel/Panel.css +++ b/src/renderer/components/Panel/Panel.css @@ -157,6 +157,36 @@ background-color: var(--vscode-button-secondaryBackground); } +.post-links-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.post-links-item { + width: 100%; + text-align: left; + border: 1px solid transparent; + background-color: var(--vscode-sideBar-background); + border-radius: 4px; + padding: 8px; + cursor: pointer; +} + +.post-links-item:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.post-links-item:focus-visible { + outline: none; + border-color: var(--vscode-focusBorder); +} + +.post-links-direction { + font-size: 12px; + color: var(--vscode-editor-foreground); +} + .git-log-list { display: flex; flex-direction: column; diff --git a/src/renderer/components/Panel/Panel.tsx b/src/renderer/components/Panel/Panel.tsx index 977c3c2..c22747e 100644 --- a/src/renderer/components/Panel/Panel.tsx +++ b/src/renderer/components/Panel/Panel.tsx @@ -34,9 +34,30 @@ function toRelativePath(absolutePath: string, projectPath: string): string { } export const Panel: React.FC = () => { - const { panelVisible, panelActiveTab, setPanelActiveTab, tasks, tabs, activeTabId, posts, media, activeProject } = useAppStore(); + const { + panelVisible, + panelActiveTab, + setPanelActiveTab, + tasks, + tabs, + activeTabId, + posts, + media, + activeProject, + openTab, + setSelectedPost, + setActiveView, + } = useAppStore(); const [gitLogLoading, setGitLogLoading] = useState(false); const [gitLogError, setGitLogError] = useState(null); + const [postLinksLoading, setPostLinksLoading] = useState(false); + const [postLinksError, setPostLinksError] = useState(null); + const [postLinksEntries, setPostLinksEntries] = useState>([]); const [gitLogTargetLabel, setGitLogTargetLabel] = useState(null); const [gitLogEntries, setGitLogEntries] = useState { const recentTasks = tasks.slice(-10).reverse(); const activeEditorTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]); + const canActivatePostLinks = activeEditorTab?.type === 'post'; const canActivateGitLog = activeEditorTab?.type === 'post' || activeEditorTab?.type === 'media'; - const effectiveActivePanelTab = panelActiveTab === 'git-log' && !canActivateGitLog - ? 'tasks' - : panelActiveTab; + const effectiveActivePanelTab = useMemo(() => { + if (panelActiveTab === 'post-links' && !canActivatePostLinks) { + return 'tasks'; + } + if (panelActiveTab === 'git-log' && !canActivateGitLog) { + return 'tasks'; + } + return panelActiveTab; + }, [panelActiveTab, canActivatePostLinks, canActivateGitLog]); + + useEffect(() => { + if (!panelVisible || effectiveActivePanelTab !== 'post-links') { + setPostLinksLoading(false); + setPostLinksError(null); + return; + } + + if (!activeEditorTab || activeEditorTab.type !== 'post') { + setPostLinksEntries([]); + setPostLinksError(null); + setPostLinksLoading(false); + return; + } + + const loadPostLinks = async () => { + setPostLinksLoading(true); + setPostLinksError(null); + + try { + const [linkedBy, linksTo] = await Promise.all([ + window.electronAPI?.posts.getLinkedBy(activeEditorTab.id), + window.electronAPI?.posts.getLinksTo(activeEditorTab.id), + ]); + + const fromEntries = (linkedBy || []).map((post) => ({ + id: post.id, + title: post.title, + slug: post.slug, + direction: 'from' as const, + })); + + const toEntries = (linksTo || []).map((post) => ({ + id: post.id, + title: post.title, + slug: post.slug, + direction: 'to' as const, + })); + + setPostLinksEntries([...fromEntries, ...toEntries]); + } catch (error) { + setPostLinksError(error instanceof Error ? error.message : 'Failed to load post links.'); + setPostLinksEntries([]); + } finally { + setPostLinksLoading(false); + } + }; + + void loadPostLinks(); + }, [panelVisible, effectiveActivePanelTab, activeEditorTab]); useEffect(() => { if (!panelVisible || effectiveActivePanelTab !== 'git-log') { @@ -146,6 +224,12 @@ export const Panel: React.FC = () => { return null; } + const handlePostLinkClick = (postId: string) => { + openTab({ type: 'post', id: postId, isTransient: false }); + setSelectedPost(postId); + setActiveView('posts'); + }; + return (
@@ -168,6 +252,17 @@ export const Panel: React.FC = () => { > Output + {canActivatePostLinks && ( + + )} + ))} +
+ ) + )} + {effectiveActivePanelTab === 'git-log' && ( !canActivateGitLog ? (
Open a post or media editor to view git log
diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index 729acd3..c2358de 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -39,7 +39,7 @@ export type { DeleteReference, ConfirmDeleteDetails }; export type EditorMode = 'wysiwyg' | 'markdown' | 'preview'; export type GitDiffViewStyle = 'inline' | 'side-by-side'; -export type PanelTab = 'tasks' | 'output' | 'git-log'; +export type PanelTab = 'tasks' | 'output' | 'post-links' | 'git-log'; export interface GitDiffPreferences { wordWrap: boolean; diff --git a/tests/renderer/components/Panel.test.tsx b/tests/renderer/components/Panel.test.tsx index 9755a5c..f41a984 100644 --- a/tests/renderer/components/Panel.test.tsx +++ b/tests/renderer/components/Panel.test.tsx @@ -49,6 +49,8 @@ describe('Panel', () => { posts: { ...(window as any).electronAPI?.posts, get: vi.fn().mockResolvedValue(null), + getLinksTo: vi.fn().mockResolvedValue([]), + getLinkedBy: vi.fn().mockResolvedValue([]), }, }; @@ -83,6 +85,65 @@ describe('Panel', () => { expect(screen.queryByText('Sync Log')).not.toBeInTheDocument(); }); + it('shows Post Links tab when active editor is a post', () => { + render(); + + expect(screen.getByRole('tab', { name: 'Post Links' })).toBeInTheDocument(); + }); + + it('hides Post Links tab when active editor is not a post', () => { + useAppStore.setState({ + tabs: [{ type: 'media', id: 'media-1', isTransient: false }], + activeTabId: 'media-1', + }); + + render(); + + expect(screen.queryByRole('tab', { name: 'Post Links' })).not.toBeInTheDocument(); + }); + + it('lists from/to post slugs for links related to active post', async () => { + (window as any).electronAPI.posts.getLinkedBy = vi.fn().mockResolvedValue([ + createPost({ id: 'post-2', slug: 'source-post', title: 'Source Post' }), + ]); + (window as any).electronAPI.posts.getLinksTo = vi.fn().mockResolvedValue([ + createPost({ id: 'post-3', slug: 'target-post', title: 'Target Post' }), + ]); + + render(); + + fireEvent.click(screen.getByRole('tab', { name: 'Post Links' })); + + await vi.waitFor(() => { + expect((window as any).electronAPI.posts.getLinkedBy).toHaveBeenCalledWith('post-1'); + expect((window as any).electronAPI.posts.getLinksTo).toHaveBeenCalledWith('post-1'); + }); + + expect(await screen.findByText('from source-post')).toBeInTheDocument(); + expect(screen.getByText('to target-post')).toBeInTheDocument(); + }); + + it('opens related post tab when clicking a post link row', async () => { + (window as any).electronAPI.posts.getLinkedBy = vi.fn().mockResolvedValue([ + createPost({ id: 'post-2', slug: 'source-post', title: 'Source Post' }), + ]); + (window as any).electronAPI.posts.getLinksTo = vi.fn().mockResolvedValue([]); + + render(); + + fireEvent.click(screen.getByRole('tab', { name: 'Post Links' })); + + const fromButton = await screen.findByRole('button', { name: 'from source-post' }); + fireEvent.click(fromButton); + + expect(useAppStore.getState().tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'post', id: 'post-2', isTransient: false }), + ]) + ); + expect(useAppStore.getState().activeTabId).toBe('post-2'); + }); + it('loads git history for the focused item and updates when active editor changes', async () => { const getFileHistory = vi.fn() .mockResolvedValueOnce([