Compare commits

...

9 Commits

Author SHA1 Message Date
Test
3c27403f86 fix: use globalThis instead of global in test setup (TS build compatibility) 2026-03-02 14:45:39 +01:00
Test
276b3c1a37 feat: responsive tab width — shrink tabs to fit window
Tabs now dynamically reduce their max-width when the total width exceeds
the TabBar container, similar to browser tab behavior. Uses a
ResizeObserver on the tab area to calculate per-tab max-width as
min(360, containerWidth / tabCount), floored at 60px minimum.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:45:39 +01:00
Test
cadb3500f1 feat: dark theme applies to editor content area
Three changes make the editor respect the active theme:

1. useThemeManager now derives app-specific CSS variables (--bg-primary,
   --text-primary, --border-primary, etc.) from the theme's core colors,
   so the editor's EditorTheme.css variables resolve correctly for both
   light and dark themes.

2. BlockNoteView theme prop is now dynamic (isDark ? 'dark' : 'light')
   instead of hardcoded to 'light', so BlockNote's own UI chrome (menus,
   toolbars) matches the active theme.

3. Removed forced light-mode class removal from main.tsx that was
   preventing dark mode from being applied.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 12:35:22 +01:00
Test
ee8f0d6bcd Merge branch 'main' of https://github.com/refactoringhq/laputa-app 2026-03-02 12:04:49 +01:00
Test
41d43501a0 docs: never open PRs — push directly to main always 2026-03-02 12:03:31 +01:00
Luca Rossi
32b8e2ee57 feat: toggle archive/trash shortcuts — Cmd+E unarchives, Cmd+⌫ restores (#175)
Cmd+E and Cmd+Delete now toggle: archiving a normal note or unarchiving
an archived one, trashing a normal note or restoring a trashed one.
Command palette labels update to reflect the current state.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:55:51 +01:00
Luca Rossi
b1110ead87 fix: back/forward nav arrows reopen replaced tabs instead of skipping them (#174)
handleReplaceActiveTab removes the old tab from the tabs array, but the
old path remained in the navigation history. goBack(isTabOpen) then
skipped it because the tab was no longer open, returning null and making
the buttons appear broken.

Fix: filter by vault entry existence (not tab-open state) and reopen
closed tabs via handleSelectNote when navigating back/forward.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 11:37:20 +01:00
Luca Rossi
b75b917538 fix: use openExternalUrl for release notes link in Tauri app (#171)
window.open() doesn't open URLs in the system browser from Tauri's
WebView. Switch to the existing openExternalUrl utility which uses
@tauri-apps/plugin-opener in native mode.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 11:36:44 +01:00
Luca Rossi
24bb64841e fix: customize icon & color works for types without Type entry (#173)
Types without a dedicated Type definition note in the vault silently
failed to save icon/color customizations. Both applyCustomization
(Sidebar) and handleCustomizeType (useEntryActions) returned early
when no Type entry existed. Also fixed a race condition where two
concurrent handleUpdateFrontmatter calls could overwrite each other.

Changes:
- useNoteActions: add createTypeEntrySilent for headless Type file creation
- useEntryActions: auto-create Type entry when missing, serialize writes
- Sidebar: applyCustomization proceeds with defaults when no Type entry
- App: wire createTypeEntrySilent into useEntryActions config

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:22:03 +01:00
23 changed files with 489 additions and 68 deletions

View File

@@ -140,6 +140,9 @@ The pre-push hook runs all checks locally before the push goes through. This rep
git push origin main # pre-push hook runs automatically
```
### ⛔ NEVER open a Pull Request
PRs on separate branches diverge from main with every merge, requiring continuous rebases and creating unnecessary conflicts. Always push directly to main. If the push fails (disk full, test failure, etc.) — fix the problem, then push again. There is no scenario where opening a PR is the right fallback.
### ⛔ NEVER use --no-verify
```bash
# FORBIDDEN — will be caught and rejected:

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

View File

@@ -245,6 +245,7 @@ function App() {
entries: vault.entries, updateEntry: vault.updateEntry,
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
createTypeEntry: notes.createTypeEntrySilent,
})
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
@@ -285,8 +286,8 @@ function App() {
onCreateNoteOfType: notes.handleCreateNoteImmediate,
onSave: handleSave,
onOpenSettings: dialogs.openSettings,
onTrashNote: entryActions.handleTrashNote, onArchiveNote: entryActions.handleArchiveNote,
onUnarchiveNote: entryActions.handleUnarchiveNote,
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
@@ -394,6 +395,7 @@ function App() {
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
isDarkTheme={themeManager.isDark}
/>
</div>
</div>

View File

@@ -60,6 +60,7 @@ interface EditorProps {
onGoBack?: () => void
onGoForward?: () => void
leftPanelsCollapsed?: boolean
isDarkTheme?: boolean
}
function EditorEmptyState() {
@@ -82,6 +83,7 @@ export const Editor = memo(function Editor({
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onRenameTab, onContentChange, onTitleSync,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
isDarkTheme,
}: EditorProps) {
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
@@ -161,6 +163,7 @@ export const Editor = memo(function Editor({
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
isDarkTheme={isDarkTheme}
/>
}
{showRightPanel && <ResizeHandle onResize={onInspectorResize} />}

View File

@@ -32,6 +32,7 @@ interface EditorContentProps {
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
isDarkTheme?: boolean
}
function EditorLoadingSkeleton() {
@@ -97,7 +98,7 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
export function EditorContent({
activeTab, isLoadingNewTab, entries, editor,
diffMode, diffContent, onToggleDiff,
onNavigateWikilink, onEditorChange, vaultPath,
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
...breadcrumbProps
}: EditorContentProps) {
return (
@@ -106,7 +107,7 @@ export function EditorContent({
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
{!diffMode && activeTab && (
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} />
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} />
</div>
)}
{isLoadingNewTab && !diffMode && <EditorLoadingSkeleton />}

View File

@@ -40,6 +40,7 @@ const mockThemeManager: ThemeManager = {
themes: [],
activeThemeId: null,
activeTheme: null,
isDark: false,
switchTheme: vi.fn(),
createTheme: vi.fn().mockResolvedValue('untitled'),
reloadThemes: vi.fn(),

View File

@@ -151,8 +151,9 @@ function applyCustomization(
): void {
if (!target || !onCustomizeType) return
const te = typeEntryMap[target]
if (!te) return
const [icon, color] = buildCustomizeArgs(te, prop, value)
const [icon, color] = te
? buildCustomizeArgs(te, prop, value)
: [prop === 'icon' ? value : 'file-text', prop === 'color' ? value : 'blue']
onCustomizeType(target, icon, color)
}

View File

@@ -23,12 +23,13 @@ function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
}
/** Single BlockNote editor view — content is swapped via replaceBlocks */
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath }: {
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme }: {
editor: ReturnType<typeof useCreateBlockNote>
entries: VaultEntry[]
onNavigateWikilink: (target: string) => void
onChange?: () => void
vaultPath?: string
isDarkTheme?: boolean
}) {
const navigateRef = useRef(onNavigateWikilink)
useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink])
@@ -100,7 +101,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
)}
<BlockNoteView
editor={editor}
theme="light"
theme={isDarkTheme ? 'dark' : 'light'}
onChange={onChange}
>
<SuggestionMenuController

View File

@@ -1,6 +1,7 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { TabBar } from './TabBar'
import { computeTabMaxWidth } from '../utils/tabLayout'
import type { VaultEntry } from '../types'
function makeEntry(path: string, title: string): VaultEntry {
@@ -257,4 +258,59 @@ describe('TabBar', () => {
fireEvent.click(screen.getByText('Beta'))
expect(onSwitchTab).toHaveBeenCalledWith(tabs[1].entry.path)
})
describe('responsive tab width', () => {
it('wraps tabs in an overflow-hidden flex container', () => {
const tabs = makeTabs(['Alpha'])
const { container } = render(
<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} />
)
const tabArea = container.querySelector('.overflow-hidden')
expect(tabArea).toBeInTheDocument()
expect(tabArea?.classList.contains('flex')).toBe(true)
expect(tabArea?.classList.contains('min-w-0')).toBe(true)
expect(tabArea?.classList.contains('flex-1')).toBe(true)
})
it('tab elements are shrinkable with min-w-0', () => {
const tabs = makeTabs(['Alpha', 'Beta'])
const { container } = render(
<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} />
)
const tabEls = container.querySelectorAll('[draggable="true"]')
expect(tabEls).toHaveLength(2)
for (const el of tabEls) {
expect(el.classList.contains('shrink-0')).toBe(false)
expect(el.classList.contains('min-w-0')).toBe(true)
}
})
})
describe('computeTabMaxWidth', () => {
it('caps at 360px when container is wide', () => {
expect(computeTabMaxWidth(1200, 2)).toBe(360)
})
it('divides space equally among tabs', () => {
expect(computeTabMaxWidth(500, 5)).toBe(100)
})
it('enforces minimum of 60px', () => {
expect(computeTabMaxWidth(300, 10)).toBe(60)
})
it('returns 360 for zero tabs', () => {
expect(computeTabMaxWidth(800, 0)).toBe(360)
})
it('floors the result to integer pixels', () => {
// 1000 / 3 = 333.33 → 333
expect(computeTabMaxWidth(1000, 3)).toBe(333)
})
it('handles single tab', () => {
expect(computeTabMaxWidth(200, 1)).toBe(200)
expect(computeTabMaxWidth(500, 1)).toBe(360)
})
})
})

View File

@@ -4,6 +4,7 @@ import type { VaultEntry, NoteStatus } from '../types'
import { cn } from '@/lib/utils'
import { X } from 'lucide-react'
import { Plus, Columns, ArrowsOutSimple, ArrowLeft, ArrowRight } from '@phosphor-icons/react'
import { computeTabMaxWidth } from '@/utils/tabLayout'
interface Tab {
entry: VaultEntry
@@ -199,7 +200,7 @@ function StatusDot({ status }: { status: NoteStatus }) {
)
}
function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBefore, showDropAfter, tabMaxWidth, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
tab: Tab
isActive: boolean
isEditing: boolean
@@ -207,6 +208,7 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
isDragging: boolean
showDropBefore: boolean
showDropAfter: boolean
tabMaxWidth: number
onSwitch: () => void
onClose: () => void
onDoubleClick: () => void
@@ -219,10 +221,11 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
draggable={!isEditing}
{...dragProps}
className={cn(
"group flex shrink-0 items-center gap-1.5 whitespace-nowrap max-w-[360px] transition-all relative",
"group flex min-w-0 items-center gap-1.5 whitespace-nowrap transition-all relative",
isActive ? "text-foreground" : "text-muted-foreground hover:text-secondary-foreground"
)}
style={{
maxWidth: tabMaxWidth,
background: isActive ? 'var(--background)' : 'transparent',
borderRight: `1px solid ${isActive ? 'var(--border)' : 'var(--sidebar-border)'}`,
borderBottom: isActive ? 'none' : '1px solid var(--sidebar-border)',
@@ -331,8 +334,20 @@ export const TabBar = memo(function TabBar({
}: TabBarProps) {
const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs)
const [editingPath, setEditingPath] = useState<string | null>(null)
const tabAreaRef = useRef<HTMLDivElement>(null)
const [tabMaxWidth, setTabMaxWidth] = useState(360)
const { onMouseDown: onDragMouseDown } = useDragRegion()
useEffect(() => {
const el = tabAreaRef.current
if (!el) return
const recalc = () => setTabMaxWidth(computeTabMaxWidth(el.clientWidth, tabs.length))
recalc()
const observer = new ResizeObserver(recalc)
observer.observe(el)
return () => observer.disconnect()
}, [tabs.length])
return (
<div
className="flex shrink-0 items-stretch"
@@ -340,30 +355,33 @@ export const TabBar = memo(function TabBar({
onDragLeave={handleBarDragLeave}
>
<NavButtons canGoBack={canGoBack} canGoForward={canGoForward} onGoBack={onGoBack} onGoForward={onGoForward} />
{tabs.map((tab, index) => (
<TabItem
key={tab.entry.path}
tab={tab}
isActive={tab.entry.path === activeTabPath}
isEditing={editingPath === tab.entry.path}
noteStatus={getNoteStatus?.(tab.entry.path) ?? 'clean'}
isDragging={dragIndex !== null}
showDropBefore={dropIndex === index}
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}
onSwitch={() => onSwitchTab(tab.entry.path)}
onClose={() => onCloseTab(tab.entry.path)}
onDoubleClick={() => onRenameTab && setEditingPath(tab.entry.path)}
onRenameSave={(newTitle) => { setEditingPath(null); onRenameTab?.(tab.entry.path, newTitle) }}
onRenameCancel={() => setEditingPath(null)}
dragProps={{
onDragStart: (e) => handleDragStart(e, index),
onDragEnd: handleDragEnd,
onDragOver: (e) => handleDragOver(e, index),
onDrop: handleDrop,
}}
/>
))}
<div className="flex-1" style={{ borderBottom: '1px solid var(--border)', cursor: 'default' }} onMouseDown={onDragMouseDown} />
<div ref={tabAreaRef} className="flex flex-1 min-w-0 items-stretch overflow-hidden">
{tabs.map((tab, index) => (
<TabItem
key={tab.entry.path}
tab={tab}
isActive={tab.entry.path === activeTabPath}
isEditing={editingPath === tab.entry.path}
noteStatus={getNoteStatus?.(tab.entry.path) ?? 'clean'}
isDragging={dragIndex !== null}
showDropBefore={dropIndex === index}
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}
tabMaxWidth={tabMaxWidth}
onSwitch={() => onSwitchTab(tab.entry.path)}
onClose={() => onCloseTab(tab.entry.path)}
onDoubleClick={() => onRenameTab && setEditingPath(tab.entry.path)}
onRenameSave={(newTitle) => { setEditingPath(null); onRenameTab?.(tab.entry.path, newTitle) }}
onRenameCancel={() => setEditingPath(null)}
dragProps={{
onDragStart: (e) => handleDragStart(e, index),
onDragEnd: handleDragEnd,
onDragOver: (e) => handleDragOver(e, index),
onDrop: handleDrop,
}}
/>
))}
<div className="flex-1 shrink-0" style={{ borderBottom: '1px solid var(--border)', cursor: 'default' }} onMouseDown={onDragMouseDown} />
</div>
<TabBarActions onCreateNote={onCreateNote} />
</div>
)

View File

@@ -1,3 +1,4 @@
import { useCallback, useRef } from 'react'
import { useAppKeyboard } from './useAppKeyboard'
import { useCommandRegistry } from './useCommandRegistry'
import type { CommandAction } from './useCommandRegistry'
@@ -26,6 +27,7 @@ interface AppCommandsConfig {
onSave: () => void
onOpenSettings: () => void
onTrashNote: (path: string) => void
onRestoreNote: (path: string) => void
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
onCommitPush: () => void
@@ -54,6 +56,20 @@ interface AppCommandsConfig {
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
const entriesRef = useRef(config.entries)
// eslint-disable-next-line react-hooks/refs
entriesRef.current = config.entries
const toggleArchive = useCallback((path: string) => {
const entry = entriesRef.current.find(e => e.path === path)
;(entry?.archived ? config.onUnarchiveNote : config.onArchiveNote)(path)
}, [config.onArchiveNote, config.onUnarchiveNote])
const toggleTrash = useCallback((path: string) => {
const entry = entriesRef.current.find(e => e.path === path)
;(entry?.trashed ? config.onRestoreNote : config.onTrashNote)(path)
}, [config.onTrashNote, config.onRestoreNote])
useAppKeyboard({
onQuickOpen: config.onQuickOpen,
onCommandPalette: config.onCommandPalette,
@@ -62,8 +78,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onOpenDailyNote: config.onOpenDailyNote,
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
onTrashNote: config.onTrashNote,
onArchiveNote: config.onArchiveNote,
onTrashNote: toggleTrash,
onArchiveNote: toggleArchive,
onSetViewMode: config.onSetViewMode,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
@@ -87,8 +103,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
onZoomReset: config.onZoomReset,
onArchiveNote: config.onArchiveNote,
onTrashNote: config.onTrashNote,
onArchiveNote: toggleArchive,
onTrashNote: toggleTrash,
onSearch: config.onSearch,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
@@ -107,6 +123,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
onTrashNote: config.onTrashNote,
onRestoreNote: config.onRestoreNote,
onArchiveNote: config.onArchiveNote,
onUnarchiveNote: config.onUnarchiveNote,
onCommitPush: config.onCommitPush,

View File

@@ -42,6 +42,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onTrashNote: vi.fn(),
onRestoreNote: vi.fn(),
onArchiveNote: vi.fn(),
onUnarchiveNote: vi.fn(),
onCommitPush: vi.fn(),
@@ -117,6 +118,44 @@ describe('useCommandRegistry', () => {
expect(archiveCmd!.label).toBe('Archive Note')
})
it('shows "Restore Note" when active note is trashed', () => {
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: true })]
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })),
)
const trashCmd = result.current.find(c => c.id === 'trash-note')
expect(trashCmd!.label).toBe('Restore Note')
})
it('shows "Trash Note" when active note is not trashed', () => {
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: false })]
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })),
)
const trashCmd = result.current.find(c => c.id === 'trash-note')
expect(trashCmd!.label).toBe('Trash Note')
})
it('calls onRestoreNote when trash command executes on trashed note', () => {
const onRestoreNote = vi.fn()
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: true })]
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries, onRestoreNote })),
)
result.current.find(c => c.id === 'trash-note')!.execute()
expect(onRestoreNote).toHaveBeenCalledWith('/vault/note/test.md')
})
it('calls onTrashNote when trash command executes on non-trashed note', () => {
const onTrashNote = vi.fn()
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: false })]
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries, onTrashNote })),
)
result.current.find(c => c.id === 'trash-note')!.execute()
expect(onTrashNote).toHaveBeenCalledWith('/vault/note/test.md')
})
it('disables commit when no modified files', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ modifiedCount: 0 })))
expect(result.current.find(c => c.id === 'commit-push')!.enabled).toBe(false)

View File

@@ -26,6 +26,7 @@ interface CommandRegistryConfig {
onOpenSettings: () => void
onOpenVault?: () => void
onTrashNote: (path: string) => void
onRestoreNote: (path: string) => void
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
onCommitPush: () => void
@@ -128,7 +129,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
const {
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onArchiveNote, onUnarchiveNote,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,
@@ -143,6 +144,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
[entries, activeTabPath, hasActiveNote],
)
const isArchived = activeEntry?.archived ?? false
const isTrashed = activeEntry?.trashed ?? false
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
@@ -163,7 +165,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
{ id: 'close-tab', label: 'Close Tab', group: 'Note', shortcut: '⌘W', keywords: [], enabled: hasActiveNote, execute: () => { if (activeTabPath) onCloseTab(activeTabPath) } },
{ id: 'trash-note', label: 'Trash Note', group: 'Note', shortcut: '⌘⌫', keywords: ['delete', 'remove'], enabled: hasActiveNote, execute: () => { if (activeTabPath) onTrashNote(activeTabPath) } },
{
id: 'trash-note', label: isTrashed ? 'Restore Note' : 'Trash Note', group: 'Note', shortcut: '⌘⌫',
keywords: ['delete', 'remove', 'restore', 'trash'], enabled: hasActiveNote,
execute: () => { if (activeTabPath) (isTrashed ? onRestoreNote : onTrashNote)(activeTabPath) },
},
{
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note', shortcut: '⌘E',
keywords: ['archive'], enabled: hasActiveNote,
@@ -197,9 +203,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
return cmds
}, [
hasActiveNote, activeTabPath, isArchived, modifiedCount,
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onArchiveNote, onUnarchiveNote,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,

View File

@@ -36,6 +36,9 @@ describe('useEntryActions', () => {
const handleUpdateFrontmatter = vi.fn().mockResolvedValue(undefined)
const handleDeleteProperty = vi.fn().mockResolvedValue(undefined)
const setToastMessage = vi.fn()
const createTypeEntry = vi.fn().mockImplementation((typeName: string) =>
Promise.resolve(makeEntry({ isA: 'Type', title: typeName, path: `/vault/type/${typeName.toLowerCase()}.md` })),
)
function setup(entries: VaultEntry[] = []) {
return renderHook(() =>
@@ -45,6 +48,7 @@ describe('useEntryActions', () => {
handleUpdateFrontmatter,
handleDeleteProperty,
setToastMessage,
createTypeEntry,
})
)
}
@@ -118,12 +122,12 @@ describe('useEntryActions', () => {
})
describe('handleCustomizeType', () => {
it('updates icon and color on the type entry', () => {
it('updates icon and color on the type entry', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md' })
const { result } = setup([typeEntry])
act(() => {
result.current.handleCustomizeType('Recipe', 'cooking-pot', 'green')
await act(async () => {
await result.current.handleCustomizeType('Recipe', 'cooking-pot', 'green')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'cooking-pot')
@@ -131,15 +135,33 @@ describe('useEntryActions', () => {
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'cooking-pot', color: 'green' })
})
it('does nothing when type entry not found', () => {
it('auto-creates type entry when not found and applies customization', async () => {
const { result } = setup([])
act(() => {
result.current.handleCustomizeType('NonExistent', 'star', 'red')
await act(async () => {
await result.current.handleCustomizeType('Recipe', 'star', 'red')
})
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
expect(updateEntry).not.toHaveBeenCalled()
expect(createTypeEntry).toHaveBeenCalledWith('Recipe')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'star', color: 'red' })
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'star')
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'color', 'red')
})
it('serializes frontmatter writes (icon before color)', async () => {
const callOrder: string[] = []
handleUpdateFrontmatter.mockImplementation((_path: string, key: string) => {
callOrder.push(key)
return Promise.resolve()
})
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
const { result } = setup([typeEntry])
await act(async () => {
await result.current.handleCustomizeType('Project', 'wrench', 'blue')
})
expect(callOrder).toEqual(['icon', 'color'])
})
})

View File

@@ -7,6 +7,7 @@ interface EntryActionsConfig {
handleUpdateFrontmatter: (path: string, key: string, value: string | number | boolean | string[]) => Promise<void>
handleDeleteProperty: (path: string, key: string) => Promise<void>
setToastMessage: (msg: string | null) => void
createTypeEntry: (typeName: string) => Promise<VaultEntry>
}
function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined {
@@ -14,7 +15,7 @@ function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | un
}
export function useEntryActions({
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage,
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry,
}: EntryActionsConfig) {
const handleTrashNote = useCallback(async (path: string) => {
const now = new Date().toISOString().slice(0, 10)
@@ -43,13 +44,13 @@ export function useEntryActions({
setToastMessage('Note unarchived')
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
const handleCustomizeType = useCallback((typeName: string, icon: string, color: string) => {
const typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) return
handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
handleUpdateFrontmatter(typeEntry.path, 'color', color)
const handleCustomizeType = useCallback(async (typeName: string, icon: string, color: string) => {
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
updateEntry(typeEntry.path, { icon, color })
}, [entries, handleUpdateFrontmatter, updateEntry])
await handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
await handleUpdateFrontmatter(typeEntry.path, 'color', color)
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry])
const handleReorderSections = useCallback((orderedTypes: { typeName: string; order: number }[]) => {
for (const { typeName, order } of orderedTypes) {

View File

@@ -389,6 +389,14 @@ export function useNoteActions(config: NoteActionsConfig) {
const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName)), [persistNew])
/** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */
const createTypeEntrySilent = useCallback(async (typeName: string): Promise<VaultEntry> => {
const resolved = resolveNewType(typeName)
addEntryWithMock(resolved.entry, resolved.content, addEntry)
await persistNewNote(resolved.entry.path, resolved.content)
return resolved.entry
}, [addEntry])
const fmCallbacks = { updateTab: updateTabContent, updateEntry, toast: setToastMessage }
const runFrontmatterOp = useCallback(
@@ -426,6 +434,7 @@ export function useNoteActions(config: NoteActionsConfig) {
handleCreateNoteImmediate,
handleOpenDailyNote,
handleCreateType,
createTypeEntrySilent,
handleUpdateFrontmatter: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),

View File

@@ -37,7 +37,22 @@ vi.mock('../mock-tauri', () => ({
}))
// Must import after mocks
const { useThemeManager } = await import('./useThemeManager')
const { useThemeManager, isColorDark } = await import('./useThemeManager')
describe('isColorDark', () => {
it('identifies dark colors', () => {
expect(isColorDark('#000000')).toBe(true)
expect(isColorDark('#0F0F23')).toBe(true)
expect(isColorDark('#1a1a2e')).toBe(true)
expect(isColorDark('#0f0f1a')).toBe(true)
})
it('identifies light colors', () => {
expect(isColorDark('#FFFFFF')).toBe(false)
expect(isColorDark('#F7F6F3')).toBe(false)
expect(isColorDark('#E0E0E0')).toBe(false)
})
})
describe('useThemeManager', () => {
beforeEach(() => {
@@ -228,6 +243,101 @@ describe('useThemeManager', () => {
expect(newId).toBe('')
})
it('isDark is false for light theme', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.activeTheme?.id).toBe('default')
})
expect(result.current.isDark).toBe(false)
})
it('isDark is true for dark theme', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
await act(async () => {
await result.current.switchTheme('dark')
})
expect(result.current.isDark).toBe(true)
})
it('isDark is false when no active theme', async () => {
const { result } = renderHook(() => useThemeManager(null))
expect(result.current.isDark).toBe(false)
})
it('derives app-specific CSS variables from theme colors', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
const root = document.documentElement
expect(root.style.getPropertyValue('--bg-primary')).toBe('#FFFFFF')
expect(root.style.getPropertyValue('--text-primary')).toBe('#1A1A2E')
expect(root.style.getPropertyValue('--text-heading')).toBe('#1A1A2E')
expect(root.style.getPropertyValue('--border-primary')).toBe('#E2E8F0')
})
it('sets color-scheme and data-theme-mode for dark theme', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
await act(async () => {
await result.current.switchTheme('dark')
})
const root = document.documentElement
await waitFor(() => {
expect(root.style.getPropertyValue('color-scheme')).toBe('dark')
})
expect(root.dataset.themeMode).toBe('dark')
expect(root.style.getPropertyValue('--bg-primary')).toBe('#0F0F23')
expect(root.style.getPropertyValue('--text-primary')).toBe('#E2E8F0')
})
it('sets color-scheme to light for light theme', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
const root = document.documentElement
expect(root.style.getPropertyValue('color-scheme')).toBe('light')
expect(root.dataset.themeMode).toBe('light')
})
it('updates derived variables when switching between themes', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
await act(async () => {
await result.current.switchTheme('dark')
})
const root = document.documentElement
await waitFor(() => {
expect(root.style.getPropertyValue('--bg-primary')).toBe('#0F0F23')
})
await act(async () => {
await result.current.switchTheme('default')
})
await waitFor(() => {
expect(root.style.getPropertyValue('--bg-primary')).toBe('#FFFFFF')
})
expect(root.style.getPropertyValue('color-scheme')).toBe('light')
expect(root.dataset.themeMode).toBe('light')
})
it('reloadThemes re-fetches theme list', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {

View File

@@ -7,6 +7,118 @@ function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
// --- Color utilities for theme variable derivation ---
function parseHex(hex: string): [number, number, number] {
const h = hex.replace('#', '')
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
}
function toHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map(c => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('')
}
/** Blend two hex colors. ratio=0 → color1, ratio=1 → color2. */
function mixColors(hex1: string, hex2: string, ratio: number): string {
const [r1, g1, b1] = parseHex(hex1)
const [r2, g2, b2] = parseHex(hex2)
return toHex(r1 + (r2 - r1) * ratio, g1 + (g2 - g1) * ratio, b1 + (b2 - b1) * ratio)
}
/** Check if a hex color is perceptually dark (luminance < 0.5). */
export function isColorDark(hex: string): boolean {
const [r, g, b] = parseHex(hex)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5
}
// Variables derived from theme core colors (not present in theme.colors directly)
const DERIVED_VAR_NAMES = [
'bg-primary', 'bg-sidebar', 'bg-card', 'bg-hover', 'bg-hover-subtle', 'bg-selected',
'bg-input', 'bg-button', 'bg-dialog',
'text-primary', 'text-heading', 'text-secondary', 'text-tertiary', 'text-muted', 'text-faint',
'border-primary', 'border-subtle', 'border-input', 'border-dialog',
'link-color', 'link-hover',
// shadcn variables that may not be in the theme
'card', 'card-foreground', 'popover', 'popover-foreground',
'secondary', 'secondary-foreground', 'muted-foreground',
'accent', 'accent-foreground', 'input', 'ring',
'sidebar-foreground', 'sidebar-primary', 'sidebar-primary-foreground',
'sidebar-accent', 'sidebar-accent-foreground', 'sidebar-border', 'sidebar-ring',
]
/** Derive app-specific and missing shadcn CSS variables from core theme colors. */
function deriveThemeVariables(root: HTMLElement, colors: Record<string, string>): void {
const bg = colors.background
const fg = colors.foreground
if (!bg || !fg) return
const isDark = isColorDark(bg)
root.style.setProperty('color-scheme', isDark ? 'dark' : 'light')
root.dataset.themeMode = isDark ? 'dark' : 'light'
const primary = colors.primary ?? (isDark ? '#5C9CFF' : '#155DFF')
const border = colors.border ?? mixColors(bg, fg, isDark ? 0.15 : 0.08)
const muted = colors.muted ?? mixColors(bg, fg, isDark ? 0.08 : 0.05)
const sidebarBg = colors['sidebar-background'] ?? mixColors(bg, fg, 0.04)
// App-specific variables
root.style.setProperty('--bg-primary', bg)
root.style.setProperty('--bg-sidebar', sidebarBg)
root.style.setProperty('--bg-card', mixColors(bg, fg, 0.03))
root.style.setProperty('--bg-hover', mixColors(bg, fg, 0.1))
root.style.setProperty('--bg-hover-subtle', muted)
root.style.setProperty('--bg-selected', `${primary}25`)
root.style.setProperty('--bg-input', bg)
root.style.setProperty('--bg-button', mixColors(bg, fg, 0.1))
root.style.setProperty('--bg-dialog', mixColors(bg, fg, 0.02))
root.style.setProperty('--text-primary', fg)
root.style.setProperty('--text-heading', fg)
root.style.setProperty('--text-secondary', mixColors(fg, bg, 0.25))
root.style.setProperty('--text-tertiary', mixColors(fg, bg, 0.35))
root.style.setProperty('--text-muted', mixColors(fg, bg, 0.5))
root.style.setProperty('--text-faint', mixColors(fg, bg, 0.6))
root.style.setProperty('--border-primary', border)
root.style.setProperty('--border-subtle', border)
root.style.setProperty('--border-input', border)
root.style.setProperty('--border-dialog', border)
root.style.setProperty('--link-color', primary)
root.style.setProperty('--link-hover', mixColors(primary, fg, 0.2))
// Shadcn variables — only set if not already provided by the theme
const setIfMissing = (name: string, value: string) => {
if (!(name in colors)) root.style.setProperty(`--${name}`, value)
}
setIfMissing('card', mixColors(bg, fg, 0.03))
setIfMissing('card-foreground', fg)
setIfMissing('popover', mixColors(bg, fg, 0.04))
setIfMissing('popover-foreground', fg)
setIfMissing('secondary', mixColors(bg, fg, 0.08))
setIfMissing('secondary-foreground', fg)
setIfMissing('muted-foreground', mixColors(fg, bg, 0.3))
setIfMissing('accent', mixColors(bg, fg, 0.08))
setIfMissing('accent-foreground', fg)
setIfMissing('input', border)
setIfMissing('ring', primary)
setIfMissing('sidebar-foreground', fg)
setIfMissing('sidebar-accent', mixColors(sidebarBg, fg, 0.1))
setIfMissing('sidebar-accent-foreground', fg)
setIfMissing('sidebar-border', border)
setIfMissing('sidebar-primary', primary)
setIfMissing('sidebar-primary-foreground', '#FFFFFF')
setIfMissing('sidebar-ring', primary)
}
function clearDerivedVariables(root: HTMLElement): void {
for (const name of DERIVED_VAR_NAMES) {
root.style.removeProperty(`--${name}`)
}
root.style.removeProperty('color-scheme')
delete root.dataset.themeMode
}
/** Map theme colors/typography/spacing to CSS custom properties on :root. */
function applyThemeToDom(theme: ThemeFile): void {
const root = document.documentElement
@@ -23,6 +135,7 @@ function applyThemeToDom(theme: ThemeFile): void {
if (theme.colors['sidebar-background']) {
root.style.setProperty('--sidebar', theme.colors['sidebar-background'])
}
deriveThemeVariables(root, theme.colors)
}
function clearThemeFromDom(theme: ThemeFile): void {
@@ -38,12 +151,14 @@ function clearThemeFromDom(theme: ThemeFile): void {
root.style.removeProperty(`--theme-${key}`)
}
root.style.removeProperty('--sidebar')
clearDerivedVariables(root)
}
export interface ThemeManager {
themes: ThemeFile[]
activeThemeId: string | null
activeTheme: ThemeFile | null
isDark: boolean
switchTheme: (themeId: string) => Promise<void>
createTheme: (sourceId?: string) => Promise<string>
reloadThemes: () => Promise<void>
@@ -69,6 +184,7 @@ export function useThemeManager(vaultPath: string | null): ThemeManager {
const prevThemeRef = useRef<ThemeFile | null>(null)
const activeTheme = themes.find(t => t.id === activeThemeId) ?? null
const isDark = activeTheme?.colors.background ? isColorDark(activeTheme.colors.background) : false
const loadThemes = useCallback(async () => {
if (!vaultPath) return
@@ -120,5 +236,5 @@ export function useThemeManager(vaultPath: string | null): ThemeManager {
}
}, [vaultPath, loadThemes, switchTheme])
return { themes, activeThemeId, activeTheme, switchTheme, createTheme, reloadThemes: loadThemes }
return { themes, activeThemeId, activeTheme, isDark, switchTheme, createTheme, reloadThemes: loadThemes }
}

View File

@@ -7,6 +7,12 @@ vi.mock('../mock-tauri', () => ({
isTauri: vi.fn(() => false),
}))
// Mock openExternalUrl
const mockOpenExternalUrl = vi.fn()
vi.mock('../utils/url', () => ({
openExternalUrl: (...args: unknown[]) => mockOpenExternalUrl(...args),
}))
// Mock the dynamic imports
const mockCheck = vi.fn()
const mockRelaunch = vi.fn()
@@ -149,9 +155,8 @@ describe('useUpdater', () => {
expect(result.current.status).toEqual({ state: 'idle' })
})
it('openReleaseNotes opens the release notes URL', async () => {
it('openReleaseNotes opens the release notes URL via openExternalUrl', async () => {
vi.mocked(isTauri).mockReturnValue(false)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const { result } = renderHook(() => useUpdater())
@@ -159,9 +164,8 @@ describe('useUpdater', () => {
result.current.actions.openReleaseNotes()
})
expect(openSpy).toHaveBeenCalledWith(
'https://refactoringhq.github.io/laputa-app/',
'_blank'
expect(mockOpenExternalUrl).toHaveBeenCalledWith(
'https://refactoringhq.github.io/laputa-app/'
)
})

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { isTauri } from '../mock-tauri'
import { openExternalUrl } from '../utils/url'
const RELEASE_NOTES_URL = 'https://refactoringhq.github.io/laputa-app/'
@@ -80,7 +81,7 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
}, [])
const openReleaseNotes = useCallback(() => {
window.open(RELEASE_NOTES_URL, '_blank')
openExternalUrl(RELEASE_NOTES_URL)
}, [])
const dismiss = useCallback(() => {

View File

@@ -4,9 +4,6 @@ import { TooltipProvider } from '@/components/ui/tooltip'
import './index.css'
import App from './App.tsx'
// Force light mode — dark mode removed for now
document.documentElement.classList.remove('dark')
// Disable native WebKit context menu in Tauri (WKWebView intercepts right-click
// at native level before React's synthetic events can call preventDefault).
// Capture phase fires first → prevents native menu; React bubble phase still fires

View File

@@ -5,6 +5,13 @@ import { createElement, type ReactNode, type ComponentType } from 'react'
// Mock scrollIntoView for jsdom (not implemented)
Element.prototype.scrollIntoView = vi.fn()
// Mock ResizeObserver for jsdom (not implemented)
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver
// Mock @tauri-apps/plugin-opener for test environment
vi.mock('@tauri-apps/plugin-opener', () => ({
openUrl: vi.fn(),

5
src/utils/tabLayout.ts Normal file
View File

@@ -0,0 +1,5 @@
/** Compute per-tab max-width so all tabs fit within the container. */
export function computeTabMaxWidth(containerWidth: number, tabCount: number): number {
if (tabCount === 0) return 360
return Math.max(60, Math.min(360, Math.floor(containerWidth / tabCount)))
}