feat: links from/to posts as tab in panel

This commit is contained in:
2026-02-17 13:41:10 +01:00
parent 449374b79f
commit 746b323fb9
4 changed files with 217 additions and 5 deletions

View File

@@ -157,6 +157,36 @@
background-color: var(--vscode-button-secondaryBackground); 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 { .git-log-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -34,9 +34,30 @@ function toRelativePath(absolutePath: string, projectPath: string): string {
} }
export const Panel: React.FC = () => { 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 [gitLogLoading, setGitLogLoading] = useState(false);
const [gitLogError, setGitLogError] = useState<string | null>(null); const [gitLogError, setGitLogError] = useState<string | null>(null);
const [postLinksLoading, setPostLinksLoading] = useState(false);
const [postLinksError, setPostLinksError] = useState<string | null>(null);
const [postLinksEntries, setPostLinksEntries] = useState<Array<{
id: string;
title: string;
slug: string;
direction: 'from' | 'to';
}>>([]);
const [gitLogTargetLabel, setGitLogTargetLabel] = useState<string | null>(null); const [gitLogTargetLabel, setGitLogTargetLabel] = useState<string | null>(null);
const [gitLogEntries, setGitLogEntries] = useState<Array<{ const [gitLogEntries, setGitLogEntries] = useState<Array<{
hash: string; hash: string;
@@ -49,10 +70,67 @@ export const Panel: React.FC = () => {
const recentTasks = tasks.slice(-10).reverse(); const recentTasks = tasks.slice(-10).reverse();
const activeEditorTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]); 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 canActivateGitLog = activeEditorTab?.type === 'post' || activeEditorTab?.type === 'media';
const effectiveActivePanelTab = panelActiveTab === 'git-log' && !canActivateGitLog const effectiveActivePanelTab = useMemo(() => {
? 'tasks' if (panelActiveTab === 'post-links' && !canActivatePostLinks) {
: panelActiveTab; 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(() => { useEffect(() => {
if (!panelVisible || effectiveActivePanelTab !== 'git-log') { if (!panelVisible || effectiveActivePanelTab !== 'git-log') {
@@ -146,6 +224,12 @@ export const Panel: React.FC = () => {
return null; return null;
} }
const handlePostLinkClick = (postId: string) => {
openTab({ type: 'post', id: postId, isTransient: false });
setSelectedPost(postId);
setActiveView('posts');
};
return ( return (
<div className="panel"> <div className="panel">
<div className="panel-header"> <div className="panel-header">
@@ -168,6 +252,17 @@ export const Panel: React.FC = () => {
> >
Output Output
</button> </button>
{canActivatePostLinks && (
<button
type="button"
role="tab"
className={`panel-tab ${effectiveActivePanelTab === 'post-links' ? 'active' : ''}`}
aria-selected={effectiveActivePanelTab === 'post-links'}
onClick={() => setPanelActiveTab('post-links')}
>
Post Links
</button>
)}
<button <button
type="button" type="button"
role="tab" role="tab"
@@ -234,6 +329,32 @@ export const Panel: React.FC = () => {
<div className="panel-empty">No output</div> <div className="panel-empty">No output</div>
)} )}
{effectiveActivePanelTab === 'post-links' && (
!canActivatePostLinks ? (
<div className="panel-empty">Open a post editor to view post links</div>
) : postLinksLoading ? (
<div className="panel-empty">Loading post links...</div>
) : postLinksError ? (
<div className="panel-empty">{postLinksError}</div>
) : postLinksEntries.length === 0 ? (
<div className="panel-empty">No post links for this post</div>
) : (
<div className="post-links-list">
{postLinksEntries.map((entry) => (
<button
key={`${entry.direction}-${entry.id}`}
type="button"
className="post-links-item"
onClick={() => handlePostLinkClick(entry.id)}
title={`Open ${entry.title || entry.slug}`}
>
<span className="post-links-direction">{entry.direction} {entry.slug}</span>
</button>
))}
</div>
)
)}
{effectiveActivePanelTab === 'git-log' && ( {effectiveActivePanelTab === 'git-log' && (
!canActivateGitLog ? ( !canActivateGitLog ? (
<div className="panel-empty">Open a post or media editor to view git log</div> <div className="panel-empty">Open a post or media editor to view git log</div>

View File

@@ -39,7 +39,7 @@ export type { DeleteReference, ConfirmDeleteDetails };
export type EditorMode = 'wysiwyg' | 'markdown' | 'preview'; export type EditorMode = 'wysiwyg' | 'markdown' | 'preview';
export type GitDiffViewStyle = 'inline' | 'side-by-side'; 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 { export interface GitDiffPreferences {
wordWrap: boolean; wordWrap: boolean;

View File

@@ -49,6 +49,8 @@ describe('Panel', () => {
posts: { posts: {
...(window as any).electronAPI?.posts, ...(window as any).electronAPI?.posts,
get: vi.fn().mockResolvedValue(null), 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(); expect(screen.queryByText('Sync Log')).not.toBeInTheDocument();
}); });
it('shows Post Links tab when active editor is a post', () => {
render(<Panel />);
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(<Panel />);
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(<Panel />);
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(<Panel />);
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 () => { it('loads git history for the focused item and updates when active editor changes', async () => {
const getFileHistory = vi.fn() const getFileHistory = vi.fn()
.mockResolvedValueOnce([ .mockResolvedValueOnce([