diff --git a/e2e/screenshot.spec.ts b/e2e/screenshot.spec.ts index 4ea7bb34..6767dbcb 100644 --- a/e2e/screenshot.spec.ts +++ b/e2e/screenshot.spec.ts @@ -18,21 +18,33 @@ test('capture editor with note selected', async ({ page }) => { await page.screenshot({ path: 'test-results/editor-screenshot.png', fullPage: true }) }) -test('live preview: click on heading reveals syntax', async ({ page }) => { +test('live preview: headings styled, syntax hidden', async ({ page }) => { await page.goto('/') await page.waitForTimeout(500) // Click a note to load it await page.click('.note-list__item') + await page.waitForTimeout(500) + + // Screenshot showing live preview (headings styled, syntax hidden) + await page.screenshot({ path: 'test-results/live-preview.png', fullPage: true }) +}) + +test('tab bar: multiple tabs open and close', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(500) + + // Click first note — opens a tab + await page.click('.note-list__item:nth-child(1)') await page.waitForTimeout(300) - // Screenshot before clicking heading (syntax hidden) - await page.screenshot({ path: 'test-results/live-preview-before.png', fullPage: true }) + // Click second note — opens a second tab + await page.click('.note-list__item:nth-child(2)') + await page.waitForTimeout(300) - // Click on the "Build Laputa App" heading text in the editor - await page.click('.cm-live-heading-1') - await page.waitForTimeout(200) + // Click third note — opens a third tab + await page.click('.note-list__item:nth-child(3)') + await page.waitForTimeout(300) - // Screenshot after clicking heading (syntax revealed) - await page.screenshot({ path: 'test-results/live-preview-after.png', fullPage: true }) + await page.screenshot({ path: 'test-results/tabs-screenshot.png', fullPage: true }) }) diff --git a/src/App.tsx b/src/App.tsx index dea6ce59..aa31fc59 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,8 +17,8 @@ const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' } function App() { const [entries, setEntries] = useState([]) const [selection, setSelection] = useState(DEFAULT_SELECTION) - const [selectedNote, setSelectedNote] = useState(null) - const [noteContent, setNoteContent] = useState('') + const [tabs, setTabs] = useState<{ entry: VaultEntry; content: string }[]>([]) + const [activeTabPath, setActiveTabPath] = useState(null) const [sidebarWidth, setSidebarWidth] = useState(250) const [noteListWidth, setNoteListWidth] = useState(300) const [inspectorWidth, setInspectorWidth] = useState(280) @@ -46,7 +46,24 @@ function App() { }, []) const handleSelectNote = useCallback(async (entry: VaultEntry) => { - setSelectedNote(entry) + // If tab already open, just switch to it + setTabs((prev) => { + if (prev.some((t) => t.entry.path === entry.path)) { + setActiveTabPath(entry.path) + return prev + } + return prev + }) + + // Check if we already have this tab (use functional check to avoid stale closure) + let alreadyOpen = false + setTabs((prev) => { + alreadyOpen = prev.some((t) => t.entry.path === entry.path) + return prev + }) + if (alreadyOpen) return + + // Load content for new tab, then add and activate try { let content: string if (isTauri()) { @@ -54,13 +71,40 @@ function App() { } else { content = await mockInvoke('get_note_content', { path: entry.path }) } - setNoteContent(content) + setTabs((prev) => { + if (prev.some((t) => t.entry.path === entry.path)) return prev + return [...prev, { entry, content }] + }) + setActiveTabPath(entry.path) } catch (err) { console.warn('Failed to load note content:', err) - setNoteContent('') + setTabs((prev) => { + if (prev.some((t) => t.entry.path === entry.path)) return prev + return [...prev, { entry, content: '' }] + }) + setActiveTabPath(entry.path) } }, []) + const handleCloseTab = useCallback((path: string) => { + setTabs((prev) => { + const next = prev.filter((t) => t.entry.path !== path) + // If closing active tab, switch to adjacent tab + if (path === activeTabPath && next.length > 0) { + const closedIdx = prev.findIndex((t) => t.entry.path === path) + const newIdx = Math.min(closedIdx, next.length - 1) + setActiveTabPath(next[newIdx].entry.path) + } else if (next.length === 0) { + setActiveTabPath(null) + } + return next + }) + }, [activeTabPath]) + + const handleSwitchTab = useCallback((path: string) => { + setActiveTabPath(path) + }, []) + const handleSidebarResize = useCallback((delta: number) => { setSidebarWidth((w) => Math.max(150, Math.min(400, w + delta))) }, []) @@ -74,6 +118,8 @@ function App() { setInspectorWidth((w) => Math.max(200, Math.min(500, w - delta))) }, []) + const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null + return (
@@ -81,11 +127,16 @@ function App() {
- +
- +
{!inspectorCollapsed && }
void + onCloseTab: (path: string) => void } const editorTheme = EditorView.theme({ @@ -49,16 +56,24 @@ const editorTheme = EditorView.theme({ }, }) -export function Editor({ content, selectedNote }: EditorProps) { +export function Editor({ tabs, activeTabPath, onSwitchTab, onCloseTab }: EditorProps) { const containerRef = useRef(null) const viewRef = useRef(null) - // Create/destroy editor view + const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null + + // Create/destroy editor view when active tab changes useEffect(() => { - if (!containerRef.current) return + if (!containerRef.current || !activeTab) return + + // If view already exists for this tab, skip + if (viewRef.current) { + viewRef.current.destroy() + viewRef.current = null + } const state = EditorState.create({ - doc: content, + doc: activeTab.content, extensions: [ lineNumbers(), highlightActiveLine(), @@ -86,24 +101,11 @@ export function Editor({ content, selectedNote }: EditorProps) { view.destroy() viewRef.current = null } - // Re-create editor when the selected note changes + // Re-create when active tab path changes OR when tab data becomes available // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedNote?.path]) + }, [activeTabPath, activeTab?.content]) - // Update content when it loads (async content fetch) - useEffect(() => { - const view = viewRef.current - if (!view) return - - const currentDoc = view.state.doc.toString() - if (currentDoc !== content) { - view.dispatch({ - changes: { from: 0, to: currentDoc.length, insert: content }, - }) - } - }, [content]) - - if (!selectedNote) { + if (tabs.length === 0) { return (
@@ -115,6 +117,26 @@ export function Editor({ content, selectedNote }: EditorProps) { return (
+
+ {tabs.map((tab) => ( +
onSwitchTab(tab.entry.path)} + > + {tab.entry.title} + +
+ ))} +
)