feat: links from/to posts as tab in panel
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
Reference in New Issue
Block a user