fix: persist layout panel widths

This commit is contained in:
lucaronin
2026-04-30 15:53:32 +02:00
parent d53c3fa7e5
commit 0331b40ae2
10 changed files with 245 additions and 32 deletions

View File

@@ -16,8 +16,8 @@
}
.app__sidebar {
flex-shrink: 1;
min-width: 180px;
flex: 0 0 auto;
min-width: 220px;
display: flex;
flex-direction: column;
}
@@ -27,7 +27,7 @@
}
.app__note-list {
flex-shrink: 10;
flex: 0 0 auto;
min-width: 220px;
display: flex;
flex-direction: column;
@@ -39,7 +39,7 @@
.app__editor {
flex: 1;
min-width: 400px;
min-width: 480px;
min-height: 0;
overflow: hidden;
display: flex;

View File

@@ -1328,7 +1328,7 @@ describe('App', () => {
fireEvent.keyDown(window, { key: '3', metaKey: true })
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('update_current_window_min_size', {
minWidth: 880,
minWidth: 1030,
minHeight: 400,
growToFit: true,
})

View File

@@ -1217,10 +1217,13 @@ function App() {
sidebarVisible: nextSidebarVisible,
noteListVisible: nextNoteListVisible,
inspectorCollapsed: nextInspectorCollapsed,
sidebarWidth: layout.sidebarWidth,
noteListWidth: layout.noteListWidth,
inspectorWidth: layout.inspectorWidth,
})
void applyMainWindowSizeConstraints(minWidth).catch((err) => console.warn('[window] Size constraints failed:', err))
}, [layout.inspectorCollapsed, noteWindowParams])
}, [layout.inspectorCollapsed, layout.inspectorWidth, layout.noteListWidth, layout.sidebarWidth, noteWindowParams])
const handleSetViewMode = useCallback((mode: ViewMode) => {
setViewMode(mode)
@@ -1243,6 +1246,9 @@ function App() {
sidebarVisible,
noteListVisible,
inspectorCollapsed: layout.inspectorCollapsed,
sidebarWidth: layout.sidebarWidth,
noteListWidth: layout.noteListWidth,
inspectorWidth: layout.inspectorWidth,
})
const { status: updateStatus, actions: updateActions } = useUpdater(settings.release_channel)

View File

@@ -9,6 +9,7 @@ export const APP_STORAGE_KEYS = {
legacyMigrationFlag: 'tolaria:legacy-storage-migrated',
sortPreferences: 'tolaria-sort-preferences',
sidebarCollapsed: 'tolaria:sidebar-collapsed',
layoutPanels: 'tolaria:layout-panels',
welcomeDismissed: 'tolaria_welcome_dismissed',
} as const
@@ -22,6 +23,7 @@ export const LEGACY_APP_STORAGE_KEYS = {
configMigrationFlag: 'laputa:config-migrated-to-vault',
sortPreferences: 'laputa-sort-preferences',
sidebarCollapsed: 'laputa:sidebar-collapsed',
layoutPanels: 'laputa:layout-panels',
welcomeDismissed: 'laputa_welcome_dismissed',
} as const
@@ -37,6 +39,7 @@ const MIGRATABLE_STORAGE_KEYS: MigratableStorageKey[] = [
'configMigrationFlag',
'sortPreferences',
'sidebarCollapsed',
'layoutPanels',
'welcomeDismissed',
]

View File

@@ -1,10 +1,46 @@
import { describe, it, expect } from 'vitest'
import { beforeEach, describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useLayoutPanels, COLUMN_MIN_WIDTHS } from './useLayoutPanels'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
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 })
type ExpectedPanelWidths = {
sidebar: number
noteList: number
inspector: number
}
function storePanelWidths(key: string, widths: ExpectedPanelWidths): void {
localStorage.setItem(key, JSON.stringify(widths))
}
function expectPanelWidths(
result: { current: ReturnType<typeof useLayoutPanels> },
widths: ExpectedPanelWidths,
): void {
expect(result.current.sidebarWidth).toBe(widths.sidebar)
expect(result.current.noteListWidth).toBe(widths.noteList)
expect(result.current.inspectorWidth).toBe(widths.inspector)
}
describe('useLayoutPanels', () => {
beforeEach(() => {
localStorage.clear()
})
it('exports column minimum widths', () => {
expect(COLUMN_MIN_WIDTHS.sidebar).toBe(180)
expect(COLUMN_MIN_WIDTHS.sidebar).toBe(220)
expect(COLUMN_MIN_WIDTHS.noteList).toBe(220)
expect(COLUMN_MIN_WIDTHS.editor).toBe(800)
expect(COLUMN_MIN_WIDTHS.inspector).toBe(240)
@@ -12,9 +48,7 @@ describe('useLayoutPanels', () => {
it('returns default widths', () => {
const { result } = renderHook(() => useLayoutPanels())
expect(result.current.sidebarWidth).toBe(250)
expect(result.current.noteListWidth).toBe(300)
expect(result.current.inspectorWidth).toBe(280)
expectPanelWidths(result, { sidebar: 250, noteList: 300, inspector: 280 })
})
it('clamps sidebar resize to minimum', () => {
@@ -62,4 +96,58 @@ describe('useLayoutPanels', () => {
const { result } = renderHook(() => useLayoutPanels({ initialInspectorCollapsed: false }))
expect(result.current.inspectorCollapsed).toBe(false)
})
it('restores persisted panel widths', () => {
storePanelWidths(APP_STORAGE_KEYS.layoutPanels, {
sidebar: 280,
noteList: 360,
inspector: 320,
})
const { result } = renderHook(() => useLayoutPanels())
expectPanelWidths(result, { sidebar: 280, noteList: 360, inspector: 320 })
})
it('clamps persisted panel widths to supported ranges', () => {
storePanelWidths(APP_STORAGE_KEYS.layoutPanels, {
sidebar: 120,
noteList: 700,
inspector: 90,
})
const { result } = renderHook(() => useLayoutPanels())
expectPanelWidths(result, {
sidebar: COLUMN_MIN_WIDTHS.sidebar,
noteList: 500,
inspector: COLUMN_MIN_WIDTHS.inspector,
})
})
it('falls back to defaults when persisted panel widths are malformed', () => {
localStorage.setItem(APP_STORAGE_KEYS.layoutPanels, '{not json')
const { result } = renderHook(() => useLayoutPanels())
expectPanelWidths(result, { sidebar: 250, noteList: 300, inspector: 280 })
})
it('persists resized panel widths with the Tolaria storage key', () => {
storePanelWidths(LEGACY_APP_STORAGE_KEYS.layoutPanels, {
sidebar: 260,
noteList: 340,
inspector: 300,
})
const { result } = renderHook(() => useLayoutPanels())
act(() => result.current.handleSidebarResize(24))
expect(JSON.parse(localStorage.getItem(APP_STORAGE_KEYS.layoutPanels) ?? '{}')).toEqual({
sidebar: 284,
noteList: 340,
inspector: 300,
})
expect(localStorage.getItem(LEGACY_APP_STORAGE_KEYS.layoutPanels)).toBeNull()
})
})

View File

@@ -1,19 +1,103 @@
import { useCallback, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../constants/appStorage'
export const COLUMN_MIN_WIDTHS = {
sidebar: 180,
sidebar: 220,
noteList: 220,
editor: 800,
inspector: 240,
} as const
export function useLayoutPanels(options?: { initialInspectorCollapsed?: boolean }) {
const [sidebarWidth, setSidebarWidth] = useState(250)
const [noteListWidth, setNoteListWidth] = useState(300)
const [inspectorWidth, setInspectorWidth] = useState(280)
const [inspectorCollapsed, setInspectorCollapsed] = useState(options?.initialInspectorCollapsed ?? true)
const handleSidebarResize = useCallback((delta: number) => setSidebarWidth((w) => Math.max(COLUMN_MIN_WIDTHS.sidebar, Math.min(400, w + delta))), [])
const handleNoteListResize = useCallback((delta: number) => setNoteListWidth((w) => Math.max(COLUMN_MIN_WIDTHS.noteList, Math.min(500, w + delta))), [])
const handleInspectorResize = useCallback((delta: number) => setInspectorWidth((w) => Math.max(COLUMN_MIN_WIDTHS.inspector, Math.min(500, w - delta))), [])
return { sidebarWidth, noteListWidth, inspectorWidth, inspectorCollapsed, setInspectorCollapsed, handleSidebarResize, handleNoteListResize, handleInspectorResize }
const COLUMN_MAX_WIDTHS = {
sidebar: 400,
noteList: 500,
inspector: 500,
} as const
const DEFAULT_PANEL_WIDTHS = {
sidebar: 250,
noteList: 300,
inspector: 280,
} as const
type PanelWidthKey = keyof typeof DEFAULT_PANEL_WIDTHS
type PanelWidths = Record<PanelWidthKey, number>
function defaultPanelWidths(): PanelWidths {
return { ...DEFAULT_PANEL_WIDTHS }
}
function clampPanelWidth(key: PanelWidthKey, value: number): number {
return Math.max(COLUMN_MIN_WIDTHS[key], Math.min(COLUMN_MAX_WIDTHS[key], value))
}
function isPanelWidthRecord(value: unknown): value is Partial<Record<PanelWidthKey, unknown>> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readPanelWidth(source: Partial<Record<PanelWidthKey, unknown>>, key: PanelWidthKey): number {
const value = source[key]
return typeof value === 'number' && Number.isFinite(value)
? clampPanelWidth(key, value)
: DEFAULT_PANEL_WIDTHS[key]
}
function normalizePanelWidths(value: unknown): PanelWidths {
if (!isPanelWidthRecord(value)) return defaultPanelWidths()
return {
sidebar: readPanelWidth(value, 'sidebar'),
noteList: readPanelWidth(value, 'noteList'),
inspector: readPanelWidth(value, 'inspector'),
}
}
function loadPanelWidths(): PanelWidths {
const raw = getAppStorageItem('layoutPanels')
if (!raw) return defaultPanelWidths()
try {
return normalizePanelWidths(JSON.parse(raw))
} catch {
return defaultPanelWidths()
}
}
function savePanelWidths(widths: PanelWidths): void {
try {
localStorage.setItem(APP_STORAGE_KEYS.layoutPanels, JSON.stringify(widths))
localStorage.removeItem(LEGACY_APP_STORAGE_KEYS.layoutPanels)
} catch {
// Ignore unavailable or restricted localStorage implementations.
}
}
export function useLayoutPanels(options?: { initialInspectorCollapsed?: boolean }) {
const [panelWidths, setPanelWidths] = useState(loadPanelWidths)
const [inspectorCollapsed, setInspectorCollapsed] = useState(options?.initialInspectorCollapsed ?? true)
useEffect(() => {
savePanelWidths(panelWidths)
}, [panelWidths])
const resizePanel = useCallback((key: PanelWidthKey, delta: number) => {
setPanelWidths((widths) => ({
...widths,
[key]: clampPanelWidth(key, widths[key] + delta),
}))
}, [])
const handleSidebarResize = useCallback((delta: number) => resizePanel('sidebar', delta), [resizePanel])
const handleNoteListResize = useCallback((delta: number) => resizePanel('noteList', delta), [resizePanel])
const handleInspectorResize = useCallback((delta: number) => resizePanel('inspector', -delta), [resizePanel])
return {
sidebarWidth: panelWidths.sidebar,
noteListWidth: panelWidths.noteList,
inspectorWidth: panelWidths.inspector,
inspectorCollapsed,
setInspectorCollapsed,
handleSidebarResize,
handleNoteListResize,
handleInspectorResize,
}
}

View File

@@ -23,7 +23,7 @@ describe('getMainWindowMinWidth', () => {
noteListVisible: true,
inspectorCollapsed: true,
},
expectedWidth: 880,
expectedWidth: 920,
},
{
name: 'drops to the narrower editor-only floor when only the editor is visible',
@@ -52,6 +52,18 @@ describe('getMainWindowMinWidth', () => {
},
expectedWidth: 720,
},
{
name: 'uses restored pane widths when they exceed the minimum allowances',
visibility: {
sidebarVisible: true,
noteListVisible: true,
inspectorCollapsed: false,
sidebarWidth: 360,
noteListWidth: 340,
inspectorWidth: 320,
},
expectedWidth: 1500,
},
] as const
it.each(minWidthCases)('$name', ({ visibility, expectedWidth }) => {
@@ -82,7 +94,7 @@ describe('useMainWindowSizeConstraints', () => {
noteListVisible: true,
inspectorCollapsed: false,
},
expectedWidth: 1120,
expectedWidth: 1160,
},
] as const

View File

@@ -3,7 +3,7 @@ import { invoke } from '@tauri-apps/api/core'
const MAIN_WINDOW_MIN_HEIGHT = 400
const EDITOR_ONLY_MAIN_WINDOW_MIN_WIDTH = 480
const MAIN_WINDOW_SIDEBAR_MIN_WIDTH = 180
const MAIN_WINDOW_SIDEBAR_MIN_WIDTH = 220
const MAIN_WINDOW_NOTE_LIST_MIN_WIDTH = 220
const MAIN_WINDOW_INSPECTOR_MIN_WIDTH = 240
@@ -11,22 +11,34 @@ export type MainWindowPaneVisibility = {
sidebarVisible: boolean
noteListVisible: boolean
inspectorCollapsed: boolean
sidebarWidth?: number
noteListWidth?: number
inspectorWidth?: number
}
export function getMainWindowMinWidth({
sidebarVisible,
noteListVisible,
inspectorCollapsed,
sidebarWidth,
noteListWidth,
inspectorWidth,
}: MainWindowPaneVisibility): number {
let minWidth = EDITOR_ONLY_MAIN_WINDOW_MIN_WIDTH
if (sidebarVisible) minWidth += MAIN_WINDOW_SIDEBAR_MIN_WIDTH
if (noteListVisible) minWidth += MAIN_WINDOW_NOTE_LIST_MIN_WIDTH
if (!inspectorCollapsed) minWidth += MAIN_WINDOW_INSPECTOR_MIN_WIDTH
if (sidebarVisible) minWidth += getPaneWidth(sidebarWidth, MAIN_WINDOW_SIDEBAR_MIN_WIDTH)
if (noteListVisible) minWidth += getPaneWidth(noteListWidth, MAIN_WINDOW_NOTE_LIST_MIN_WIDTH)
if (!inspectorCollapsed) minWidth += getPaneWidth(inspectorWidth, MAIN_WINDOW_INSPECTOR_MIN_WIDTH)
return minWidth
}
function getPaneWidth(width: number | undefined, minimum: number): number {
return typeof width === 'number' && Number.isFinite(width)
? Math.max(minimum, Math.round(width))
: minimum
}
type MainWindowSizeConstraintsOptions = MainWindowPaneVisibility & {
enabled?: boolean
}
@@ -46,11 +58,17 @@ export function useMainWindowSizeConstraints({
sidebarVisible,
noteListVisible,
inspectorCollapsed,
sidebarWidth,
noteListWidth,
inspectorWidth,
}: MainWindowSizeConstraintsOptions): void {
const minWidth = getMainWindowMinWidth({
sidebarVisible,
noteListVisible,
inspectorCollapsed,
sidebarWidth,
noteListWidth,
inspectorWidth,
})
useEffect(() => {