fix: persist layout panel widths
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user