Merge pull request #11 from refactoringhq/task/sidebar-collapsable

feat: collapsible sidebar and note list (Cmd+1/2/3)
This commit is contained in:
Luca Rossi
2026-02-23 08:36:24 +01:00
committed by GitHub
15 changed files with 13838 additions and 65 deletions

553
design/improve-sort.pen Normal file
View File

@@ -0,0 +1,553 @@
{
"version": "2.8",
"themes": {
"Mode": [
"Dark"
]
},
"variables": {
"--background": {
"type": "color",
"value": [
{ "value": "#FFFFFF" },
{ "value": "#191919", "theme": { "Mode": "Dark" } }
]
},
"--foreground": {
"type": "color",
"value": [
{ "value": "#37352F" },
{ "value": "#ffffffcf", "theme": { "Mode": "Dark" } }
]
},
"--muted-foreground": {
"type": "color",
"value": [
{ "value": "#787774" },
{ "value": "#ffffff71", "theme": { "Mode": "Dark" } }
]
},
"--popover": {
"type": "color",
"value": [
{ "value": "#FFFFFF" },
{ "value": "#252525", "theme": { "Mode": "Dark" } }
]
},
"--popover-foreground": {
"type": "color",
"value": [
{ "value": "#37352F" },
{ "value": "#ffffffcf", "theme": { "Mode": "Dark" } }
]
},
"--border": {
"type": "color",
"value": [
{ "value": "#E9E9E7" },
{ "value": "#ffffff18", "theme": { "Mode": "Dark" } }
]
},
"--accent-blue": {
"type": "color",
"value": [
{ "value": "#2383E2" },
{ "value": "#4a9eff", "theme": { "Mode": "Dark" } }
]
},
"--sidebar": {
"type": "color",
"value": [
{ "value": "#F7F7F5" },
{ "value": "#202020", "theme": { "Mode": "Dark" } }
]
},
"--muted": {
"type": "color",
"value": [
{ "value": "#F1F1EF" },
{ "value": "#ffffff0e", "theme": { "Mode": "Dark" } }
]
},
"--font-primary": {
"type": "font",
"value": [{ "value": "Inter" }]
},
"--radius-sm": {
"type": "number",
"value": [{ "value": 4 }]
},
"--radius-md": {
"type": "number",
"value": [{ "value": 6 }]
}
},
"children": [
{
"type": "frame",
"id": "srtDD",
"x": 0,
"y": 0,
"name": "Sort Dropdown — Direction Arrows",
"clip": true,
"width": 260,
"height": 280,
"fill": "$--popover",
"cornerRadius": "$--radius-md",
"stroke": {
"align": "outside",
"thickness": 1,
"fill": "$--border"
},
"layout": "vertical",
"padding": [8, 0],
"children": [
{
"type": "frame",
"id": "srtH1",
"name": "dropdownHeader",
"width": "fill_container",
"height": 28,
"padding": [0, 12],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srtHL",
"name": "headerLabel",
"content": "Sort by",
"fontSize": 11,
"fontWeight": 600,
"fill": "$--muted-foreground",
"fontFamily": "$--font-primary",
"textTransform": "uppercase",
"letterSpacing": 0.5
}
]
},
{
"type": "frame",
"id": "srtR1",
"name": "row-modified",
"width": "fill_container",
"height": 36,
"padding": [0, 12],
"alignItems": "center",
"fill": "$--muted",
"children": [
{
"type": "text",
"id": "srtT1",
"name": "label-modified",
"content": "Modified",
"fontSize": 13,
"fontWeight": 500,
"fill": "$--popover-foreground",
"fontFamily": "$--font-primary",
"width": "fill_container"
},
{
"type": "frame",
"id": "srtA1",
"name": "arrows-modified",
"gap": 2,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srtU1",
"name": "arrow-up-active",
"content": "↑",
"fontSize": 13,
"fontWeight": 700,
"fill": "$--accent-blue"
},
{
"type": "text",
"id": "srtD1",
"name": "arrow-down",
"content": "↓",
"fontSize": 13,
"fontWeight": 400,
"fill": "$--muted-foreground"
}
]
}
]
},
{
"type": "frame",
"id": "srtR2",
"name": "row-title",
"width": "fill_container",
"height": 36,
"padding": [0, 12],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srtT2",
"name": "label-title",
"content": "Title",
"fontSize": 13,
"fontWeight": 400,
"fill": "$--popover-foreground",
"fontFamily": "$--font-primary",
"width": "fill_container"
},
{
"type": "frame",
"id": "srtA2",
"name": "arrows-title",
"gap": 2,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srtU2",
"name": "arrow-up",
"content": "↑",
"fontSize": 13,
"fontWeight": 400,
"fill": "$--muted-foreground"
},
{
"type": "text",
"id": "srtD2",
"name": "arrow-down",
"content": "↓",
"fontSize": 13,
"fontWeight": 400,
"fill": "$--muted-foreground"
}
]
}
]
},
{
"type": "frame",
"id": "srtR3",
"name": "row-created",
"width": "fill_container",
"height": 36,
"padding": [0, 12],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srtT3",
"name": "label-created",
"content": "Created",
"fontSize": 13,
"fontWeight": 400,
"fill": "$--popover-foreground",
"fontFamily": "$--font-primary",
"width": "fill_container"
},
{
"type": "frame",
"id": "srtA3",
"name": "arrows-created",
"gap": 2,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srtU3",
"name": "arrow-up",
"content": "↑",
"fontSize": 13,
"fontWeight": 400,
"fill": "$--muted-foreground"
},
{
"type": "text",
"id": "srtD3",
"name": "arrow-down",
"content": "↓",
"fontSize": 13,
"fontWeight": 400,
"fill": "$--muted-foreground"
}
]
}
]
},
{
"type": "frame",
"id": "srtR4",
"name": "row-type",
"width": "fill_container",
"height": 36,
"padding": [0, 12],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srtT4",
"name": "label-type",
"content": "Type",
"fontSize": 13,
"fontWeight": 400,
"fill": "$--popover-foreground",
"fontFamily": "$--font-primary",
"width": "fill_container"
},
{
"type": "frame",
"id": "srtA4",
"name": "arrows-type",
"gap": 2,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srtU4",
"name": "arrow-up",
"content": "↑",
"fontSize": 13,
"fontWeight": 400,
"fill": "$--muted-foreground"
},
{
"type": "text",
"id": "srtD4",
"name": "arrow-down",
"content": "↓",
"fontSize": 13,
"fontWeight": 400,
"fill": "$--muted-foreground"
}
]
}
]
},
{
"type": "frame",
"id": "srtSP",
"name": "separator",
"width": "fill_container",
"height": 1,
"fill": "$--border"
},
{
"type": "frame",
"id": "srtAN",
"name": "annotation",
"width": "fill_container",
"padding": [6, 12],
"children": [
{
"type": "text",
"id": "srtAT",
"name": "annotation-text",
"content": "Click ↑↓ to toggle direction. Blue = active.",
"fontSize": 11,
"fill": "$--muted-foreground",
"fontFamily": "$--font-primary"
}
]
}
]
},
{
"type": "frame",
"id": "srtBN",
"x": 300,
"y": 0,
"name": "Sort Button — With Direction Indicator",
"clip": true,
"width": 400,
"height": 200,
"fill": "$--background",
"cornerRadius": "$--radius-md",
"stroke": {
"align": "outside",
"thickness": 1,
"fill": "$--border"
},
"layout": "vertical",
"padding": [16, 16],
"gap": 16,
"children": [
{
"type": "frame",
"id": "srtBH",
"name": "noteListHeader",
"width": "fill_container",
"height": 36,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srtBC",
"name": "noteCount",
"content": "42 notes",
"fontSize": 12,
"fill": "$--muted-foreground",
"fontFamily": "$--font-primary",
"width": "fill_container"
},
{
"type": "frame",
"id": "srtBB",
"name": "sortButton",
"padding": [4, 8],
"cornerRadius": "$--radius-sm",
"fill": "$--muted",
"alignItems": "center",
"gap": 4,
"children": [
{
"type": "text",
"id": "srtBL",
"name": "sortLabel",
"content": "Modified",
"fontSize": 12,
"fontWeight": 500,
"fill": "$--foreground",
"fontFamily": "$--font-primary"
},
{
"type": "text",
"id": "srtBA",
"name": "directionArrow",
"content": "↓",
"fontSize": 12,
"fontWeight": 600,
"fill": "$--accent-blue"
}
]
}
]
},
{
"type": "frame",
"id": "srtSP2",
"name": "divider",
"width": "fill_container",
"height": 1,
"fill": "$--border"
},
{
"type": "frame",
"id": "srtEX",
"name": "exampleNotes",
"width": "fill_container",
"layout": "vertical",
"gap": 2,
"children": [
{
"type": "frame",
"id": "srtN1",
"name": "noteRow1",
"width": "fill_container",
"height": 32,
"padding": [0, 8],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srtNT1",
"name": "noteTitle1",
"content": "Meeting Notes — Feb 22",
"fontSize": 13,
"fill": "$--foreground",
"fontFamily": "$--font-primary",
"width": "fill_container"
},
{
"type": "text",
"id": "srtND1",
"name": "noteDate1",
"content": "2 min ago",
"fontSize": 11,
"fill": "$--muted-foreground",
"fontFamily": "$--font-primary"
}
]
},
{
"type": "frame",
"id": "srtN2",
"name": "noteRow2",
"width": "fill_container",
"height": 32,
"padding": [0, 8],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srtNT2",
"name": "noteTitle2",
"content": "Project Roadmap",
"fontSize": 13,
"fill": "$--foreground",
"fontFamily": "$--font-primary",
"width": "fill_container"
},
{
"type": "text",
"id": "srtND2",
"name": "noteDate2",
"content": "1 hr ago",
"fontSize": 11,
"fill": "$--muted-foreground",
"fontFamily": "$--font-primary"
}
]
},
{
"type": "frame",
"id": "srtN3",
"name": "noteRow3",
"width": "fill_container",
"height": 32,
"padding": [0, 8],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srtNT3",
"name": "noteTitle3",
"content": "Weekly Review",
"fontSize": 13,
"fill": "$--foreground",
"fontFamily": "$--font-primary",
"width": "fill_container"
},
{
"type": "text",
"id": "srtND3",
"name": "noteDate3",
"content": "Yesterday",
"fontSize": 11,
"fill": "$--muted-foreground",
"fontFamily": "$--font-primary"
}
]
}
]
},
{
"type": "frame",
"id": "srtAN2",
"name": "annotation",
"width": "fill_container",
"padding": [6, 0],
"children": [
{
"type": "text",
"id": "srtAT2",
"name": "annotation-text",
"content": "Sort button shows property + direction arrow. Click to open dropdown.",
"fontSize": 11,
"fill": "$--muted-foreground",
"fontFamily": "$--font-primary"
}
]
}
]
}
]
}

12660
design/sidebar-collapsable.pen Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
import { test, expect } from '@playwright/test'
// On macOS, Alt+key produces special characters, so we dispatch events directly
function dispatchAltKey(page: import('@playwright/test').Page, key: string) {
return page.evaluate((k) => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: k, altKey: true, metaKey: false, ctrlKey: false,
bubbles: true, cancelable: true,
}))
}, key)
}
async function loadApp(page: import('@playwright/test').Page) {
await page.goto('/')
// Clear stored view mode so we start fresh
await page.evaluate(() => localStorage.removeItem('laputa-view-mode'))
await page.reload()
await page.waitForTimeout(500)
}
test.describe('Sidebar collapse', () => {
test('default: all three panels visible', async ({ page }) => {
await loadApp(page)
await expect(page.locator('.app__sidebar')).toBeVisible()
await expect(page.locator('.app__note-list')).toBeVisible()
await expect(page.locator('.app__editor')).toBeVisible()
await page.screenshot({ path: 'test-results/collapse-all-panels.png', fullPage: true })
})
test('collapse button hides sidebar', async ({ page }) => {
await loadApp(page)
const collapseBtn = page.locator('button[aria-label="Collapse sidebar"]')
await expect(collapseBtn).toBeVisible()
await collapseBtn.click()
await page.waitForTimeout(500)
await expect(page.locator('.app__sidebar')).toHaveCount(0)
await expect(page.locator('.app__note-list')).toBeVisible()
await expect(page.locator('.app__editor')).toBeVisible()
await page.screenshot({ path: 'test-results/collapse-sidebar-hidden.png', fullPage: true })
})
test('Alt+1 shows editor only', async ({ page }) => {
await loadApp(page)
await dispatchAltKey(page, '1')
await page.waitForTimeout(500)
await expect(page.locator('.app__sidebar')).toHaveCount(0)
await expect(page.locator('.app__note-list')).toHaveCount(0)
await expect(page.locator('.app__editor')).toBeVisible()
await page.screenshot({ path: 'test-results/collapse-editor-only.png', fullPage: true })
})
test('Alt+2 shows editor + note list', async ({ page }) => {
await loadApp(page)
await dispatchAltKey(page, '2')
await page.waitForTimeout(500)
await expect(page.locator('.app__sidebar')).toHaveCount(0)
await expect(page.locator('.app__note-list')).toBeVisible()
await expect(page.locator('.app__editor')).toBeVisible()
await page.screenshot({ path: 'test-results/collapse-editor-list.png', fullPage: true })
})
test('Alt+3 restores all panels after collapse', async ({ page }) => {
await loadApp(page)
// Collapse first
await dispatchAltKey(page, '1')
await page.waitForTimeout(500)
await expect(page.locator('.app__sidebar')).toHaveCount(0)
// Restore
await dispatchAltKey(page, '3')
await page.waitForTimeout(500)
await expect(page.locator('.app__sidebar')).toBeVisible()
await expect(page.locator('.app__note-list')).toBeVisible()
await expect(page.locator('.app__editor')).toBeVisible()
await page.screenshot({ path: 'test-results/collapse-restored.png', fullPage: true })
})
})

View File

@@ -2,6 +2,7 @@ pub mod ai_chat;
pub mod frontmatter;
pub mod git;
pub mod github;
pub mod menu;
pub mod settings;
pub mod vault;
@@ -123,6 +124,7 @@ pub fn run() {
{
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle().plugin(tauri_plugin_process::init())?;
menu::setup_menu(app)?;
}
// Purge trashed files older than 30 days on startup

34
src-tauri/src/menu.rs Normal file
View File

@@ -0,0 +1,34 @@
use tauri::{menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}, App, Emitter};
const VIEW_ITEMS: [(&str, &str, &str); 3] = [
("view-editor-only", "Editor Only", "CmdOrCtrl+1"),
("view-editor-list", "Editor + Notes", "CmdOrCtrl+2"),
("view-all", "All Panels", "CmdOrCtrl+3"),
];
pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
let mut view_menu = SubmenuBuilder::new(app, "View");
for (id, label, accel) in &VIEW_ITEMS {
let item = MenuItemBuilder::new(*label)
.id(*id)
.accelerator(*accel)
.build(app)?;
view_menu = view_menu.item(&item);
}
let view_submenu = view_menu.build()?;
let menu = MenuBuilder::new(app)
.item(&view_submenu)
.build()?;
app.set_menu(menu)?;
app.on_menu_event(|app_handle, event| {
let id = event.id().0.as_str();
if id.starts_with("view-") {
let _ = app_handle.emit("menu-event", id);
}
});
Ok(())
}

View File

@@ -335,6 +335,38 @@ describe('getSortComparator', () => {
const sorted = [a, b].sort(getSortComparator('status'))
expect(sorted.map((e) => e.title)).toEqual(['NewerActive', 'OlderActive'])
})
it('sorts by modified date ascending when direction is asc', () => {
const a = makeEntry({ title: 'A', modifiedAt: 1000 })
const b = makeEntry({ title: 'B', modifiedAt: 3000 })
const c = makeEntry({ title: 'C', modifiedAt: 2000 })
const sorted = [a, b, c].sort(getSortComparator('modified', 'asc'))
expect(sorted.map((e) => e.title)).toEqual(['A', 'C', 'B'])
})
it('sorts by title descending when direction is desc', () => {
const a = makeEntry({ title: 'Zebra' })
const b = makeEntry({ title: 'Alpha' })
const c = makeEntry({ title: 'Middle' })
const sorted = [a, b, c].sort(getSortComparator('title', 'desc'))
expect(sorted.map((e) => e.title)).toEqual(['Zebra', 'Middle', 'Alpha'])
})
it('sorts by created date ascending when direction is asc', () => {
const a = makeEntry({ title: 'A', createdAt: 3000, modifiedAt: 1000 })
const b = makeEntry({ title: 'B', createdAt: 1000, modifiedAt: 3000 })
const c = makeEntry({ title: 'C', createdAt: 2000, modifiedAt: 2000 })
const sorted = [a, b, c].sort(getSortComparator('created', 'asc'))
expect(sorted.map((e) => e.title)).toEqual(['B', 'C', 'A'])
})
it('sorts by status descending (null first, Done before Active)', () => {
const a = makeEntry({ title: 'Done', status: 'Done', modifiedAt: 1000 })
const b = makeEntry({ title: 'Active', status: 'Active', modifiedAt: 1000 })
const c = makeEntry({ title: 'NoStatus', status: null, modifiedAt: 1000 })
const sorted = [a, b, c].sort(getSortComparator('status', 'desc'))
expect(sorted.map((e) => e.title)).toEqual(['NoStatus', 'Done', 'Active'])
})
})
describe('NoteList sort controls', () => {
@@ -425,6 +457,71 @@ describe('NoteList sort controls', () => {
expect(screen.queryByTestId('sort-menu-__list__')).not.toBeInTheDocument()
})
it('shows direction arrows in sort dropdown menu', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
// Each option should have asc and desc direction buttons
expect(screen.getByTestId('sort-dir-asc-modified')).toBeInTheDocument()
expect(screen.getByTestId('sort-dir-desc-modified')).toBeInTheDocument()
expect(screen.getByTestId('sort-dir-asc-title')).toBeInTheDocument()
expect(screen.getByTestId('sort-dir-desc-title')).toBeInTheDocument()
})
it('reverses sort order when clicking direction arrow', () => {
const entries = [
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
)
// Default sort: modified descending (Zebra first at 3000)
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
expect(titles).toEqual(['Zebra', 'Middle', 'Alpha'])
// Click the asc arrow for modified to reverse
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.click(screen.getByTestId('sort-dir-asc-modified'))
// Now ascending: Alpha (1000) first
titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
expect(titles).toEqual(['Alpha', 'Middle', 'Zebra'])
})
it('persists sort direction via saveSortPreferences', () => {
const entries = [
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
)
// Select title sort with desc direction
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.click(screen.getByTestId('sort-dir-desc-title'))
// Verify direction took effect: desc title means Z before A
const titles = screen.getAllByText(/Zebra|Alpha/).map((el) => el.textContent)
expect(titles).toEqual(['Zebra', 'Alpha'])
})
it('shows direction icon on the sort button that reflects current direction', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />
)
// Default: modified desc → should have ArrowDown icon
expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument()
// Switch to title (default asc) and verify icon changes
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.click(screen.getByTestId('sort-option-title'))
// Title default is asc → should show ArrowUp icon
expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument()
})
it('sorts relationship subsection entries when sort option changed', () => {
// Create an entity with children that have different titles
const parent = makeEntry({

View File

@@ -8,14 +8,14 @@ import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { NoteItem, getTypeIcon } from './NoteItem'
import { SortDropdown } from './SortDropdown'
import {
type SortOption, type RelationshipGroup,
type SortOption, type SortDirection, type SortConfig, type RelationshipGroup,
getSortComparator,
buildRelationshipGroups, filterEntries,
sortByModified, relativeDate, getDisplayDate,
loadSortPreferences, saveSortPreferences,
} from '../utils/noteListHelpers'
// eslint-disable-next-line react-refresh/only-export-components -- re-exports for consumers
// Re-export for consumers
export { sortByModified, filterEntries, buildRelationshipGroups, getSortComparator }
export type { SortOption }
@@ -38,10 +38,9 @@ function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: {
const te = typeEntryMap[entry.isA ?? '']
const color = getTypeColor(entry.isA ?? '', te?.color)
const bgColor = getTypeLightColor(entry.isA ?? '', te?.color)
const Icon = useMemo(() => getTypeIcon(entry.isA, te?.icon), [entry.isA, te?.icon])
const Icon = getTypeIcon(entry.isA, te?.icon)
return (
<div className="relative cursor-pointer border-b border-[var(--border)]" style={{ backgroundColor: bgColor, padding: '14px 16px' }} onClick={() => onSelectNote(entry)}>
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
<Icon width={16} height={16} className="absolute right-3 top-3.5" style={{ color }} data-testid="type-icon" />
<div className="pr-6 text-[14px] font-bold" style={{ color }}>{entry.title}</div>
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{entry.snippet}</div>
@@ -53,13 +52,13 @@ function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: {
function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, handleSortChange, renderItem }: {
group: RelationshipGroup
isCollapsed: boolean
sortPrefs: Record<string, SortOption>
sortPrefs: Record<string, SortConfig>
onToggle: () => void
handleSortChange: (groupLabel: string, option: SortOption) => void
handleSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
renderItem: (entry: VaultEntry) => React.ReactNode
}) {
const groupSort = sortPrefs[group.label] ?? 'modified'
const sortedEntries = [...group.entries].sort(getSortComparator(groupSort))
const groupConfig = sortPrefs[group.label] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection }
const sortedEntries = [...group.entries].sort(getSortComparator(groupConfig.option, groupConfig.direction))
return (
<div>
<div className="flex w-full items-center justify-between bg-muted" style={{ height: 32, padding: '0 16px' }}>
@@ -68,7 +67,7 @@ function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, han
<span className="font-mono-label text-muted-foreground" style={{ fontWeight: 400 }}>{group.entries.length}</span>
</button>
<span className="flex items-center gap-1.5">
<SortDropdown groupLabel={group.label} current={groupSort} onChange={handleSortChange} />
<SortDropdown groupLabel={group.label} current={groupConfig.option} direction={groupConfig.direction} onChange={handleSortChange} />
<button className="flex items-center border-none bg-transparent cursor-pointer p-0 text-muted-foreground" onClick={onToggle}>
{isCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
</button>
@@ -118,8 +117,8 @@ function useTypeEntryMap(entries: VaultEntry[]) {
function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem, typeEntryMap, onSelectNote }: {
entity: VaultEntry; groups: RelationshipGroup[]; query: string
collapsedGroups: Set<string>; sortPrefs: Record<string, SortOption>
onToggleGroup: (label: string) => void; onSortChange: (label: string, opt: SortOption) => void
collapsedGroups: Set<string>; sortPrefs: Record<string, SortConfig>
onToggleGroup: (label: string) => void; onSortChange: (label: string, opt: SortOption, dir: SortDirection) => void
renderItem: (entry: VaultEntry) => React.ReactNode
typeEntryMap: Record<string, VaultEntry>; onSelectNote: (entry: VaultEntry) => void
}) {
@@ -175,10 +174,10 @@ function countExpiredTrash(entries: VaultEntry[]): number {
interface NoteListDataParams {
entries: VaultEntry[]; selection: SidebarSelection; allContent: Record<string, string>
query: string; listSort: SortOption
query: string; listSort: SortOption; listDirection: SortDirection; modifiedFiles?: ModifiedFile[]
}
function useNoteListData({ entries, selection, allContent, query, listSort }: NoteListDataParams) {
function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedFiles }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
@@ -189,9 +188,9 @@ function useNoteListData({ entries, selection, allContent, query, listSort }: No
const searched = useMemo(() => {
if (isEntityView) return []
const sorted = [...filterEntries(entries, selection)].sort(getSortComparator(listSort))
const sorted = [...filterEntries(entries, selection, modifiedFiles)].sort(getSortComparator(listSort, listDirection))
return filterByQuery(sorted, query)
}, [entries, selection, isEntityView, listSort, query])
}, [entries, selection, modifiedFiles, isEntityView, listSort, listDirection, query])
const searchedGroups = useMemo(() => {
if (!isEntityView) return []
@@ -209,24 +208,26 @@ function useNoteListData({ entries, selection, allContent, query, listSort }: No
// --- Main component ---
function NoteListInner({ entries, selection, selectedNote, allContent, onSelectNote, onCreateNote }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const [sortPrefs, setSortPrefs] = useState<Record<string, SortOption>>(loadSortPreferences)
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
const handleSortChange = useCallback((groupLabel: string, option: SortOption) => {
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: option }; saveSortPreferences(next); return next })
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
}, [])
const toggleGroup = useCallback((label: string) => {
setCollapsedGroups((prev) => { const next = new Set(prev); if (next.has(label)) next.delete(label); else next.add(label); return next })
setCollapsedGroups((prev) => { const next = new Set(prev); next.has(label) ? next.delete(label) : next.add(label); return next })
}, [])
const typeEntryMap = useTypeEntryMap(entries)
const query = search.trim().toLowerCase()
const listSort = sortPrefs['__list__'] ?? 'modified'
const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort })
const listConfig = sortPrefs['__list__'] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection }
const listSort = listConfig.option
const listDirection = listConfig.direction
const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedFiles })
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />
@@ -237,7 +238,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, onSelectN
<div className="flex h-[45px] shrink-0 items-center justify-between border-b border-border px-4" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}>
<h3 className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold">{resolveHeaderTitle(selection, typeDocument)}</h3>
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} onChange={handleSortChange} />}
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} direction={listDirection} onChange={handleSortChange} />}
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => { setSearchVisible(!searchVisible); if (searchVisible) setSearch('') }} title="Search notes">
<MagnifyingGlass size={16} />
</button>

View File

@@ -12,7 +12,7 @@ import {
import { CSS } from '@dnd-kit/utilities'
import {
FileText, Star, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive,
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft,
} from '@phosphor-icons/react'
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
import {
@@ -31,6 +31,7 @@ interface SidebarProps {
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
modifiedCount?: number
onCommitPush?: () => void
onCollapse?: () => void
}
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
@@ -169,6 +170,24 @@ function CommitButton({ modifiedCount, onClick }: { modifiedCount: number; onCli
)
}
function SidebarTitleBar({ onCollapse }: { onCollapse?: () => void }) {
return (
<div className="shrink-0 flex items-center justify-end border-b border-border" style={{ height: 38, WebkitAppRegion: 'drag', padding: '0 8px' } as React.CSSProperties} data-tauri-drag-region>
{onCollapse && (
<button
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ width: 24, height: 24, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
onClick={onCollapse}
aria-label="Collapse sidebar"
title="Collapse sidebar"
>
<CaretLeft size={14} weight="bold" />
</button>
)}
</div>
)
}
function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: {
pos: { x: number; y: number } | null; type: string | null
innerRef: React.Ref<HTMLDivElement>
@@ -208,7 +227,7 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose
export const Sidebar = memo(function Sidebar({
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onCustomizeType, onReorderSections, modifiedCount = 0, onCommitPush,
onCustomizeType, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse,
}: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
@@ -268,7 +287,7 @@ export const Sidebar = memo(function Sidebar({
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
<div className="shrink-0 border-b border-border" style={{ height: 38, WebkitAppRegion: 'drag' } as React.CSSProperties} data-tauri-drag-region />
<SidebarTitleBar onCollapse={onCollapse} />
<nav className="flex-1 overflow-y-auto">
{/* Top nav */}
<div className="border-b border-border" style={{ padding: '4px 6px' }}>

View File

@@ -1,12 +1,13 @@
import { useState, useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
import { ArrowsDownUp, Check } from '@phosphor-icons/react'
import { type SortOption, SORT_OPTIONS } from '../utils/noteListHelpers'
import { ArrowUp, ArrowDown } from '@phosphor-icons/react'
import { type SortOption, type SortDirection, DEFAULT_DIRECTIONS, SORT_OPTIONS } from '../utils/noteListHelpers'
export function SortDropdown({ groupLabel, current, onChange }: {
export function SortDropdown({ groupLabel, current, direction, onChange }: {
groupLabel: string
current: SortOption
onChange: (groupLabel: string, option: SortOption) => void
direction: SortDirection
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
@@ -20,11 +21,13 @@ export function SortDropdown({ groupLabel, current, onChange }: {
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
const handleSelect = (opt: SortOption) => {
onChange(groupLabel, opt)
const handleSelect = (opt: SortOption, dir: SortDirection) => {
onChange(groupLabel, opt, dir)
setOpen(false)
}
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
return (
<div ref={ref} className="relative" style={{ zIndex: open ? 10 : 0 }}>
<button
@@ -33,23 +36,47 @@ export function SortDropdown({ groupLabel, current, onChange }: {
title={`Sort by ${current}`}
data-testid={`sort-button-${groupLabel}`}
>
<ArrowsDownUp size={12} />
<DirectionIcon size={12} data-testid={`sort-direction-icon-${groupLabel}`} />
<span className="text-[10px] font-medium">{SORT_OPTIONS.find((o) => o.value === current)?.label}</span>
</button>
{open && (
<div className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md" style={{ width: 130, padding: 4 }} data-testid={`sort-menu-${groupLabel}`}>
{SORT_OPTIONS.map((opt) => (
<button
key={opt.value}
className={cn("flex w-full items-center gap-1.5 rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", opt.value === current && "bg-accent font-medium")}
style={{ height: 28, border: 'none', cursor: 'pointer', background: opt.value === current ? 'var(--accent)' : 'transparent' }}
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value) }}
data-testid={`sort-option-${opt.value}`}
>
{opt.value === current ? <Check size={12} /> : <span style={{ width: 12, height: 12, display: 'inline-block' }} />}
{opt.label}
</button>
))}
<div className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md" style={{ width: 150, padding: 4 }} data-testid={`sort-menu-${groupLabel}`}>
{SORT_OPTIONS.map((opt) => {
const isActive = opt.value === current
return (
<div
key={opt.value}
className={cn("flex w-full items-center justify-between rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", isActive && "bg-accent font-medium")}
style={{ height: 28, cursor: 'pointer', background: isActive ? 'var(--accent)' : 'transparent' }}
data-testid={`sort-option-${opt.value}`}
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value, isActive ? direction : DEFAULT_DIRECTIONS[opt.value]) }}
>
<span className="flex flex-1 items-center gap-1.5 text-inherit">
{opt.label}
</span>
<span className="flex items-center gap-0.5 ml-1">
<button
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'asc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
style={{ padding: 2 }}
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value, 'asc') }}
data-testid={`sort-dir-asc-${opt.value}`}
title="Ascending"
>
<ArrowUp size={12} />
</button>
<button
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'desc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
style={{ padding: 2 }}
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value, 'desc') }}
data-testid={`sort-dir-desc-${opt.value}`}
title="Descending"
>
<ArrowDown size={12} />
</button>
</span>
</div>
)
})}
</div>
)}
</div>

View File

@@ -0,0 +1,89 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useAppKeyboard } from './useAppKeyboard'
function fireKey(key: string, mods: { altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean } = {}) {
const event = new KeyboardEvent('keydown', {
key,
altKey: mods.altKey ?? false,
metaKey: mods.metaKey ?? false,
ctrlKey: mods.ctrlKey ?? false,
bubbles: true,
cancelable: true,
})
window.dispatchEvent(event)
}
function makeActions() {
return {
onQuickOpen: vi.fn(),
onCreateNote: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onTrashNote: vi.fn(),
onArchiveNote: vi.fn(),
onSetViewMode: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
}
}
describe('useAppKeyboard', () => {
afterEach(() => vi.restoreAllMocks())
it('Cmd+1 sets view mode to editor-only', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('1', { metaKey: true })
expect(actions.onSetViewMode).toHaveBeenCalledWith('editor-only')
})
it('Cmd+2 sets view mode to editor-list', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('2', { metaKey: true })
expect(actions.onSetViewMode).toHaveBeenCalledWith('editor-list')
})
it('Cmd+3 sets view mode to all', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('3', { metaKey: true })
expect(actions.onSetViewMode).toHaveBeenCalledWith('all')
})
it('does not fire view mode when Cmd+Alt pressed', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('1', { metaKey: true, altKey: true })
expect(actions.onSetViewMode).not.toHaveBeenCalled()
})
it('Cmd+P triggers quick open', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('p', { metaKey: true })
expect(actions.onQuickOpen).toHaveBeenCalled()
})
it('Cmd+N triggers create note', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('n', { metaKey: true })
expect(actions.onCreateNote).toHaveBeenCalled()
})
it('Cmd+W closes the active tab', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('w', { metaKey: true })
expect(actions.handleCloseTabRef.current).toHaveBeenCalledWith('/vault/test.md')
})
it('Alt+4 does not trigger any view mode', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('4', { altKey: true })
expect(actions.onSetViewMode).not.toHaveBeenCalled()
})
})

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo } from 'react'
import type { ViewMode } from './useViewMode'
interface KeyboardActions {
onQuickOpen: () => void
@@ -7,22 +8,52 @@ interface KeyboardActions {
onOpenSettings: () => void
onTrashNote: (path: string) => void
onArchiveNote: (path: string) => void
onSetViewMode: (mode: ViewMode) => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
}
type ShortcutHandler = () => void
const VIEW_MODE_KEYS: Record<string, ViewMode> = {
'1': 'editor-only',
'2': 'editor-list',
'3': 'all',
}
function isCmdOnly(e: KeyboardEvent): boolean {
return (e.metaKey || e.ctrlKey) && !e.altKey
}
function handleViewModeKey(e: KeyboardEvent, onSetViewMode: (m: ViewMode) => void): boolean {
if (!isCmdOnly(e)) return false
const mode = VIEW_MODE_KEYS[e.key]
if (!mode) return false
e.preventDefault()
onSetViewMode(mode)
return true
}
function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>): boolean {
const mod = e.metaKey || e.ctrlKey
if (!mod) return false
const handler = keyMap[e.key]
if (!handler) return false
e.preventDefault()
handler()
return true
}
export function useAppKeyboard({
onQuickOpen, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
activeTabPathRef, handleCloseTabRef,
onSetViewMode, activeTabPathRef, handleCloseTabRef,
}: KeyboardActions) {
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
const path = activeTabPathRef.current
if (path) fn(path)
}
const keyMap = useMemo((): Record<string, ShortcutHandler> => ({
const cmdKeyMap = useMemo((): Record<string, ShortcutHandler> => ({
p: onQuickOpen,
n: onCreateNote,
s: onSave,
@@ -35,15 +66,9 @@ export function useAppKeyboard({
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (!mod) return
const handler = keyMap[e.key]
if (handler) {
e.preventDefault()
handler()
}
handleViewModeKey(e, onSetViewMode) || handleCmdKey(e, cmdKeyMap)
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [keyMap])
}, [cmdKeyMap, onSetViewMode])
}

27
src/hooks/useDialogs.ts Normal file
View File

@@ -0,0 +1,27 @@
import { useState, useCallback } from 'react'
export function useDialogs() {
const [showCreateTypeDialog, setShowCreateTypeDialog] = useState(false)
const [showQuickOpen, setShowQuickOpen] = useState(false)
const [showCommitDialog, setShowCommitDialog] = useState(false)
const [showAIChat, setShowAIChat] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const openCreateType = useCallback(() => setShowCreateTypeDialog(true), [])
const closeCreateType = useCallback(() => setShowCreateTypeDialog(false), [])
const openQuickOpen = useCallback(() => setShowQuickOpen(true), [])
const closeQuickOpen = useCallback(() => setShowQuickOpen(false), [])
const openCommitDialog = useCallback(() => setShowCommitDialog(true), [])
const closeCommitDialog = useCallback(() => setShowCommitDialog(false), [])
const openSettings = useCallback(() => setShowSettings(true), [])
const closeSettings = useCallback(() => setShowSettings(false), [])
const toggleAIChat = useCallback(() => setShowAIChat((c) => !c), [])
return {
showCreateTypeDialog, openCreateType, closeCreateType,
showQuickOpen, openQuickOpen, closeQuickOpen,
showCommitDialog, openCommitDialog, closeCommitDialog,
showAIChat, toggleAIChat,
showSettings, openSettings, closeSettings,
}
}

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useViewMode } from './useViewMode'
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
}))
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
describe('useViewMode', () => {
beforeEach(() => {
localStorageMock.clear()
})
it('defaults to "all" when no stored value', () => {
const { result } = renderHook(() => useViewMode())
expect(result.current.viewMode).toBe('all')
expect(result.current.sidebarVisible).toBe(true)
expect(result.current.noteListVisible).toBe(true)
})
it('loads persisted view mode from localStorage', () => {
localStorageMock.setItem('laputa-view-mode', 'editor-only')
const { result } = renderHook(() => useViewMode())
expect(result.current.viewMode).toBe('editor-only')
expect(result.current.sidebarVisible).toBe(false)
expect(result.current.noteListVisible).toBe(false)
})
it('setViewMode updates state and persists to localStorage', () => {
const { result } = renderHook(() => useViewMode())
act(() => result.current.setViewMode('editor-list'))
expect(result.current.viewMode).toBe('editor-list')
expect(result.current.sidebarVisible).toBe(false)
expect(result.current.noteListVisible).toBe(true)
expect(localStorageMock.getItem('laputa-view-mode')).toBe('editor-list')
})
it('editor-only hides both sidebar and note list', () => {
const { result } = renderHook(() => useViewMode())
act(() => result.current.setViewMode('editor-only'))
expect(result.current.sidebarVisible).toBe(false)
expect(result.current.noteListVisible).toBe(false)
})
it('editor-list hides sidebar but shows note list', () => {
const { result } = renderHook(() => useViewMode())
act(() => result.current.setViewMode('editor-list'))
expect(result.current.sidebarVisible).toBe(false)
expect(result.current.noteListVisible).toBe(true)
})
it('all mode shows both sidebar and note list', () => {
const { result } = renderHook(() => useViewMode())
act(() => result.current.setViewMode('editor-only'))
act(() => result.current.setViewMode('all'))
expect(result.current.sidebarVisible).toBe(true)
expect(result.current.noteListVisible).toBe(true)
})
it('ignores invalid localStorage values', () => {
localStorageMock.setItem('laputa-view-mode', 'garbage')
const { result } = renderHook(() => useViewMode())
expect(result.current.viewMode).toBe('all')
})
})

45
src/hooks/useViewMode.ts Normal file
View File

@@ -0,0 +1,45 @@
import { useState, useCallback, useEffect } from 'react'
import { isTauri } from '../mock-tauri'
export type ViewMode = 'editor-only' | 'editor-list' | 'all'
const STORAGE_KEY = 'laputa-view-mode'
function loadViewMode(): ViewMode {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'editor-only' || stored === 'editor-list' || stored === 'all') return stored
} catch { /* ignore */ }
return 'all'
}
export function useViewMode() {
const [viewMode, setViewModeState] = useState<ViewMode>(loadViewMode)
const setViewMode = useCallback((mode: ViewMode) => {
setViewModeState(mode)
try { localStorage.setItem(STORAGE_KEY, mode) } catch { /* ignore */ }
}, [])
const sidebarVisible = viewMode === 'all'
const noteListVisible = viewMode === 'all' || viewMode === 'editor-list'
// Listen for Tauri menu events
useEffect(() => {
if (!isTauri()) return
let cleanup: (() => void) | undefined
import('@tauri-apps/api/event').then(({ listen }) => {
const unlisten = listen<string>('menu-event', (event) => {
if (event.payload === 'view-editor-only') setViewMode('editor-only')
else if (event.payload === 'view-editor-list') setViewMode('editor-list')
else if (event.payload === 'view-all') setViewMode('all')
})
cleanup = () => { unlisten.then((fn) => fn()) }
}).catch(() => { /* not in Tauri */ })
return () => cleanup?.()
}, [setViewMode])
return { viewMode, setViewMode, sidebarVisible, noteListVisible }
}

View File

@@ -53,6 +53,19 @@ export function sortByModified(a: VaultEntry, b: VaultEntry): number {
}
export type SortOption = 'modified' | 'created' | 'title' | 'status'
export type SortDirection = 'asc' | 'desc'
export interface SortConfig {
option: SortOption
direction: SortDirection
}
export const DEFAULT_DIRECTIONS: Record<SortOption, SortDirection> = {
modified: 'desc',
created: 'desc',
title: 'asc',
status: 'asc',
}
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'modified', label: 'Modified' },
@@ -65,36 +78,51 @@ const STATUS_ORDER: Record<string, number> = {
Active: 0, Paused: 1, Done: 2, Finished: 3,
}
export function getSortComparator(option: SortOption): (a: VaultEntry, b: VaultEntry) => number {
export function getSortComparator(option: SortOption, direction?: SortDirection): (a: VaultEntry, b: VaultEntry) => number {
const dir = direction ?? DEFAULT_DIRECTIONS[option]
const flip = dir === 'asc' ? 1 : -1
switch (option) {
case 'modified':
return sortByModified
return (a, b) => flip * ((getDisplayDate(a) ?? 0) - (getDisplayDate(b) ?? 0))
case 'created':
return (a, b) => (b.createdAt ?? b.modifiedAt ?? 0) - (a.createdAt ?? a.modifiedAt ?? 0)
return (a, b) => flip * ((a.createdAt ?? a.modifiedAt ?? 0) - (b.createdAt ?? b.modifiedAt ?? 0))
case 'title':
return (a, b) => a.title.localeCompare(b.title)
return (a, b) => flip * a.title.localeCompare(b.title)
case 'status':
return (a, b) => {
const sa = STATUS_ORDER[a.status ?? ''] ?? 999
const sb = STATUS_ORDER[b.status ?? ''] ?? 999
if (sa !== sb) return sa - sb
return sortByModified(a, b)
if (sa !== sb) return flip * (sa - sb)
// Tiebreaker: always newest first regardless of direction
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
}
}
}
const SORT_STORAGE_KEY = 'laputa-sort-preferences'
export function loadSortPreferences(): Record<string, SortOption> {
export function loadSortPreferences(): Record<string, SortConfig> {
try {
const raw = localStorage.getItem(SORT_STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
if (!raw) return {}
const parsed = JSON.parse(raw)
const result: Record<string, SortConfig> = {}
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === 'string') {
// Migrate old format: bare SortOption string → SortConfig
const opt = value as SortOption
result[key] = { option: opt, direction: DEFAULT_DIRECTIONS[opt] }
} else {
result[key] = value as SortConfig
}
}
return result
} catch {
return {}
}
}
export function saveSortPreferences(prefs: Record<string, SortOption>) {
export function saveSortPreferences(prefs: Record<string, SortConfig>) {
try {
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(prefs))
} catch { /* ignore */ }