diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index a39aef7..31c03f3 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -390,6 +390,7 @@ export class PreviewServer { categorySettings, page_title: pageContext.pageTitle, language: pageContext.language, + menu_items: pageContext.menuItems, pico_stylesheet_href: pageContext.picoStylesheetHref, html_theme_attribute: pageContext.htmlThemeAttribute, }, this.postEngine); diff --git a/src/main/engine/templates/single-post.liquid b/src/main/engine/templates/single-post.liquid index b099e37..9eb6a30 100644 --- a/src/main/engine/templates/single-post.liquid +++ b/src/main/engine/templates/single-post.liquid @@ -3,9 +3,9 @@ {% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
+

{{ post.title }}

+ {% render 'partials/menu', menu_items: menu_items, language: language %}
-

{{ post.title }}

- {% render 'partials/menu', menu_items: menu_items, language: language %}
{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
diff --git a/src/renderer/components/GitSidebar/GitSidebar.css b/src/renderer/components/GitSidebar/GitSidebar.css index d26e806..bda17b4 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.css +++ b/src/renderer/components/GitSidebar/GitSidebar.css @@ -42,7 +42,15 @@ min-height: 0; } +.git-sidebar-section--changes { + flex: 1 1 50%; +} + .git-sidebar-history { + flex: 0 0 50%; + max-height: 50%; + min-height: 0; + overflow: hidden; margin-top: 12px; border-top: 1px solid var(--vscode-editorWidget-border); padding-top: 8px; @@ -60,6 +68,8 @@ .git-sidebar-file-list { display: flex; flex-direction: column; + flex: 1 1 auto; + min-height: 0; overflow: auto; } @@ -110,8 +120,11 @@ .git-sidebar-history-list { display: flex; flex-direction: column; + flex: 1 1 auto; + min-height: 0; gap: 6px; padding: 0 12px 8px; + overflow: auto; } .git-sidebar-history-legend { diff --git a/src/renderer/components/GitSidebar/GitSidebar.tsx b/src/renderer/components/GitSidebar/GitSidebar.tsx index 08d0869..6ff91e4 100644 --- a/src/renderer/components/GitSidebar/GitSidebar.tsx +++ b/src/renderer/components/GitSidebar/GitSidebar.tsx @@ -57,6 +57,7 @@ export const GitSidebar: React.FC = () => { const commitMessageInputRef = useRef(null); const statusRefreshInFlightRef = useRef(false); const remoteRefreshInFlightRef = useRef(false); + const loadRepoStateRequestRef = useRef(0); const refreshRepoDetails = useCallback( async (targetProjectPath: string, options?: { background?: boolean; historyLimit?: number }) => { @@ -189,12 +190,19 @@ export const GitSidebar: React.FC = () => { }, [activeProject]); const loadRepoState = useCallback(async () => { + const requestId = ++loadRepoStateRequestRef.current; + const isCurrentRequest = () => requestId === loadRepoStateRequestRef.current; + setLoading(true); setError(null); setErrorGuidance([]); try { const availability = await window.electronAPI.git.checkAvailability(); + if (!isCurrentRequest()) { + return; + } + if (!availability.gitFound) { setError(tr('gitSidebar.error.gitMissing')); setIsRepo(false); @@ -203,6 +211,10 @@ export const GitSidebar: React.FC = () => { } const resolvedProjectPath = await resolveProjectPath(); + if (!isCurrentRequest()) { + return; + } + setProjectPath(resolvedProjectPath); if (!resolvedProjectPath) { @@ -213,14 +225,25 @@ export const GitSidebar: React.FC = () => { } const repoState = await window.electronAPI.git.getRepoState(resolvedProjectPath); + if (!isCurrentRequest()) { + return; + } + setIsRepo(repoState.isRepo); setHasRemote(repoState.hasRemote); setCurrentBranch(repoState.currentBranch || null); if (repoState.isRepo) { await refreshRepoDetails(resolvedProjectPath); + if (!isCurrentRequest()) { + return; + } + if (repoState.hasRemote) { await refreshRemoteState(resolvedProjectPath); + if (!isCurrentRequest()) { + return; + } } else { setRemoteState(null); setRemoteStateError(null); @@ -233,6 +256,10 @@ export const GitSidebar: React.FC = () => { setRemoteStateError(null); } } catch { + if (!isCurrentRequest()) { + return; + } + setError(tr('gitSidebar.error.loadRepoStatus')); setIsRepo(false); setHasRemote(false); @@ -242,7 +269,9 @@ export const GitSidebar: React.FC = () => { setRemoteState(null); setRemoteStateError(null); } finally { - setLoading(false); + if (isCurrentRequest()) { + setLoading(false); + } } }, [refreshRemoteState, refreshRepoDetails, resolveProjectPath, tr]); @@ -499,7 +528,7 @@ export const GitSidebar: React.FC = () => { )} -
+
{tr('gitSidebar.openChanges', { count: statusFiles.length })}
diff --git a/tests/engine/BlogGenerationEngine.test.ts b/tests/engine/BlogGenerationEngine.test.ts index 4d72dfe..39d509a 100644 --- a/tests/engine/BlogGenerationEngine.test.ts +++ b/tests/engine/BlogGenerationEngine.test.ts @@ -264,6 +264,34 @@ describe('BlogGenerationEngine', () => { expect(singleContentIndex).toBeGreaterThan(singleMenuIndex); }); + it('renders menu on generated category and tag archive pages', async () => { + const posts = [ + makePost({ + id: '1', + slug: 'news-post', + title: 'News Post', + categories: ['news'], + tags: ['dev'], + createdAt: new Date('2025-03-15T10:00:00Z'), + }), + ]; + + await generate(posts, { + menu: { + items: [ + { id: 'home', title: 'Home', kind: 'home', pageSlug: 'home', children: [] }, + { id: 'news', title: 'News', kind: 'category-archive', categoryName: 'news', children: [] }, + ], + }, + }); + + const categoryHtml = await readFile(path.join(tempDir, 'html', 'category', 'news', 'index.html'), 'utf-8'); + const tagHtml = await readFile(path.join(tempDir, 'html', 'tag', 'dev', 'index.html'), 'utf-8'); + + expect(categoryHtml).toContain('class="blog-menu"'); + expect(tagHtml).toContain('class="blog-menu"'); + }); + it('copies all required asset files to html/assets/ and html/images/', async () => { const result = await generate([]); diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index d93a78e..d2b7b00 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -228,6 +228,32 @@ describe('PreviewServer', () => { expect(singleTextIndex).toBeGreaterThan(singleMenuIndex); }); + it('renders menu on category and tag archive pages', async () => { + const posts = [ + makePost({ id: '1', slug: 'news-post', title: 'News Post', categories: ['news'], tags: ['dev'], createdAt: new Date('2025-01-03T10:00:00.000Z') }), + ]; + + server = new PreviewServer({ + postEngine: makeEngine(posts), + settingsEngine: makeSettings(50), + menuEngine: makeMenuEngine({ + items: [ + { id: 'home', title: 'Home', kind: 'home', pageSlug: 'home', children: [] }, + { id: 'dev', title: 'Dev', kind: 'category-archive', categoryName: 'news', children: [] }, + ], + }), + getActiveProjectContext: async () => ({ projectId: 'default' }), + }); + + await server.start(0); + + const categoryHtml = await (await fetch(`${server.getBaseUrl()}/category/news`)).text(); + expect(categoryHtml).toContain('class="blog-menu"'); + + const tagHtml = await (await fetch(`${server.getBaseUrl()}/tag/dev`)).text(); + expect(tagHtml).toContain('class="blog-menu"'); + }); + it('uses local CSS/JS assets and serves them from the preview server', async () => { server = new PreviewServer({ postEngine: makeEngine([makePost()]), @@ -591,6 +617,14 @@ describe('PreviewServer', () => { expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain('

Explicit Single Post Title

'); + + const mainIndex = html.indexOf('
'); + const h1Index = html.indexOf('

Explicit Single Post Title

'); + const articleIndex = html.indexOf('
'); + expect(mainIndex).toBeGreaterThan(-1); + expect(h1Index).toBeGreaterThan(mainIndex); + expect(articleIndex).toBeGreaterThan(mainIndex); + expect(h1Index).toBeLessThan(articleIndex); }); it('uses blog description as h1 on first date archive page and date range h1 on later pages', async () => { diff --git a/tests/renderer/components/GitSidebar.styles.test.ts b/tests/renderer/components/GitSidebar.styles.test.ts new file mode 100644 index 0000000..3cebdfa --- /dev/null +++ b/tests/renderer/components/GitSidebar.styles.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +describe('GitSidebar styles', () => { + const cssPath = path.resolve( + __dirname, + '../../../src/renderer/components/GitSidebar/GitSidebar.css' + ); + + it('limits version history section to 50% of available sidebar room', () => { + const css = fs.readFileSync(cssPath, 'utf8'); + + expect(css).toMatch(/\.git-sidebar-history\s*\{[^}]*flex:\s*0\s+0\s+50%;[^}]*max-height:\s*50%;[^}]*\}/s); + }); + + it('keeps commit list scrollable within the bounded history section', () => { + const css = fs.readFileSync(cssPath, 'utf8'); + + expect(css).toMatch(/\.git-sidebar-history-list\s*\{[^}]*overflow:\s*auto;[^}]*\}/s); + }); +}); diff --git a/tests/renderer/components/GitSidebar.test.tsx b/tests/renderer/components/GitSidebar.test.tsx index 7f1156a..dcabb6a 100644 --- a/tests/renderer/components/GitSidebar.test.tsx +++ b/tests/renderer/components/GitSidebar.test.tsx @@ -73,6 +73,49 @@ describe('GitSidebar', () => { expect(screen.getByRole('button', { name: /initialize git/i })).toBeInTheDocument(); }); + it('ignores stale load results when active project becomes available during async load', async () => { + useAppStore.setState({ activeProject: null }); + + (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ + isRepo: true, + rootPath: '/repo/path', + currentBranch: 'main', + hasRemote: false, + }); + (window as any).electronAPI.git.getStatus = vi.fn().mockResolvedValue({ + files: [{ path: 'posts/first.md', status: 'modified' }], + counts: { untracked: 0, modified: 1, deleted: 0, renamed: 0, staged: 0, total: 1 }, + }); + (window as any).electronAPI.git.getHistory = vi.fn().mockResolvedValue([]); + + let checkAvailabilityCall = 0; + (window as any).electronAPI.git.checkAvailability = vi.fn().mockImplementation(async () => { + checkAvailabilityCall += 1; + if (checkAvailabilityCall === 1) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return { gitFound: true, version: '2.49.0' }; + }); + + render(); + + await act(async () => { + useAppStore.setState({ + activeProject: { + id: 'project-1', + name: 'Test Project', + slug: 'test-project', + isActive: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + }); + + expect(await screen.findByText(/open changes/i)).toBeInTheDocument(); + expect(screen.queryByText(/no active project selected/i)).not.toBeInTheDocument(); + }); + it('renders open changes list when repository exists', async () => { (window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({ isRepo: true,