feat: links from/to posts as tab in panel
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<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 [gitLogEntries, setGitLogEntries] = useState<Array<{
|
||||
hash: string;
|
||||
@@ -49,10 +70,67 @@ export const Panel: React.FC = () => {
|
||||
|
||||
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 (
|
||||
<div className="panel">
|
||||
<div className="panel-header">
|
||||
@@ -168,6 +252,17 @@ export const Panel: React.FC = () => {
|
||||
>
|
||||
Output
|
||||
</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
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -234,6 +329,32 @@ export const Panel: React.FC = () => {
|
||||
<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' && (
|
||||
!canActivateGitLog ? (
|
||||
<div className="panel-empty">Open a post or media editor to view git log</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(<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 () => {
|
||||
const getFileHistory = vi.fn()
|
||||
.mockResolvedValueOnce([
|
||||
|
||||
Reference in New Issue
Block a user