diff --git a/frontend/src-tauri/src/preference_records.rs b/frontend/src-tauri/src/preference_records.rs index ce862dd..daf1fed 100644 --- a/frontend/src-tauri/src/preference_records.rs +++ b/frontend/src-tauri/src/preference_records.rs @@ -4,6 +4,13 @@ use serde::Deserialize; use serde_json::Value; #[derive(Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Layout { + primary_expanded: bool, + workspace_width: f64, + chat_width: f64, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] struct Level { size: f64, weight: u16, @@ -84,6 +91,12 @@ fn markdown(value: &Markdown) -> bool { } pub fn validate(kind: &str, value: &Value) -> Result<()> { let valid = match kind { + "layout" => { + let value: Layout = decode(value)?; + let _ = value.primary_expanded; + (200.0..=520.0).contains(&value.workspace_width) + && (200.0..=520.0).contains(&value.chat_width) + } "theme_settings" => { let value: Theme = decode(value)?; let _ = value.headings.custom; @@ -144,6 +157,23 @@ mod tests { use super::*; use serde_json::json; #[test] + fn layout_schema_limits_widths_and_rejects_device_fields() { + let good = json!({"primaryExpanded":true,"workspaceWidth":400.5,"chatWidth":320}); + let path = crate::records::path_for("layout", "sidebars").unwrap(); + assert!(crate::records::allowed(&path)); + let record = json!({"schema":1,"kind":"layout","id":"sidebars","data":good}); + crate::records::validate(&path, &serde_json::to_vec(&record).unwrap()).unwrap(); + for invalid in [199.0, 521.0, -1.0] { + let mut data = good.clone(); + data["workspaceWidth"] = json!(invalid); + assert!(validate("layout", &data).is_err()); + } + let mut bad = good; + bad["windowPath"] = json!("private-device-path"); + assert!(validate("layout", &bad).is_err()); + assert!(crate::records::path_for("layout", "other").is_err()); + } + #[test] fn portable_preferences_reject_unowned_nested_fields_and_invalid_values() { let theme = json!({"themeId":"dark","fontEditorSize":18,"fontEditorFamily":"system-ui","lineHeight":1.7,"codeBlockTheme":"auto","headings":{"custom":false,"family":"inherit","levels":([32,28,24,21,18,16].map(|size|json!({"size":size,"weight":700})))}}); validate("theme_settings", &theme).unwrap(); diff --git a/frontend/src-tauri/src/records.rs b/frontend/src-tauri/src/records.rs index dad7fd1..0a316d7 100644 --- a/frontend/src-tauri/src/records.rs +++ b/frontend/src-tauri/src/records.rs @@ -38,6 +38,7 @@ pub fn path_for(kind: &str, id: &str) -> Result { ("theme_settings", "appearance") => { Ok("opennexus-records/v1/theme-settings/appearance.json".into()) } + ("layout", "sidebars") => Ok("opennexus-records/v1/layout/sidebars.json".into()), ("preferences", "editor") => Ok("opennexus-records/v1/preferences/editor.json".into()), _ => Err(HostError::new("RECORD_ID_INVALID")), } @@ -50,6 +51,7 @@ pub fn allowed(path_value: &str) -> bool { path_value, "opennexus-records/v1/theme-settings/appearance.json" | "opennexus-records/v1/preferences/editor.json" + | "opennexus-records/v1/layout/sidebars.json" ) { return true; } diff --git a/frontend/src/components/common/PrimarySidebar.vue b/frontend/src/components/common/PrimarySidebar.vue index 07ccf3e..2e13059 100644 --- a/frontend/src/components/common/PrimarySidebar.vue +++ b/frontend/src/components/common/PrimarySidebar.vue @@ -1,13 +1,15 @@ diff --git a/frontend/src/components/common/SecondarySidebar.spec.ts b/frontend/src/components/common/SecondarySidebar.spec.ts index dc8fec2..3971bb1 100644 --- a/frontend/src/components/common/SecondarySidebar.spec.ts +++ b/frontend/src/components/common/SecondarySidebar.spec.ts @@ -2,6 +2,8 @@ import { expect, it } from 'vitest' import { mount } from '@vue/test-utils' import { createRouter, createMemoryHistory } from 'vue-router' +import { createPinia } from 'pinia' +import { useLayoutPreferencesStore } from '@/stores/layoutPreferences' import SecondarySidebar from './SecondarySidebar.vue' it('keeps conversation and file widths separate across route changes', async () => { @@ -9,7 +11,7 @@ it('keeps conversation and file widths separate across route changes', async () localStorage.setItem('workspace-sidebar-width', '240') const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }) await router.push('/') - const wrapper = mount(SecondarySidebar, {props:{component:'conversation-list'}, global:{plugins:[router],stubs:{ConversationListPanel:true,FileTreePanel:true}}}) + const wrapper = mount(SecondarySidebar, {props:{component:'conversation-list'}, global:{plugins:[router, createPinia()],stubs:{ConversationListPanel:true,FileTreePanel:true}}}) await wrapper.vm.$nextTick() expect(wrapper.get('aside').attributes('style')).toContain('320px') await wrapper.get('[role="separator"]').trigger('keydown', {key:'ArrowRight'}) @@ -27,7 +29,7 @@ it('resizes by keyboard, clamps bounds and restores the saved width', async () = localStorage.removeItem('workspace-sidebar-width') const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }) await router.push('/') - const options = { props: { component: 'file-tree' }, global: { plugins: [router], stubs: { FileTreePanel: true } } } + const options = { props: { component: 'file-tree' }, global: { plugins: [router, createPinia()], stubs: { FileTreePanel: true } } } let wrapper = mount(SecondarySidebar, options) await wrapper.get('[role="separator"]').trigger('keydown', { key: 'ArrowRight' }) expect(localStorage.getItem('workspace-sidebar-width')).toBe('288') @@ -40,3 +42,33 @@ it('resizes by keyboard, clamps bounds and restores the saved width', async () = wrapper.unmount() localStorage.removeItem('workspace-sidebar-width') }) + + +it('applies remote widths but keeps viewport clamping out of portable preferences', async () => { + const pinia = createPinia() + const layout = useLayoutPreferencesStore(pinia) + layout.workspaceWidth = 480 + const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }) + await router.push('/') + const wrapper = mount(SecondarySidebar, { props: { component: 'file-tree' }, global: { plugins: [router, pinia], stubs: { FileTreePanel: true } } }) + const original = window.innerWidth + try { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + await wrapper.vm.$nextTick() + expect(wrapper.get('aside').attributes('style')).toContain('280px') + expect(layout.workspaceWidth).toBe(480) + layout.workspaceWidth = 420 + await wrapper.vm.$nextTick() + expect(wrapper.get('aside').attributes('style')).toContain('280px') + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1200 }) + window.dispatchEvent(new Event('resize')) + await wrapper.vm.$nextTick() + expect(wrapper.get('aside').attributes('style')).toContain('420px') + expect(layout.workspaceWidth).toBe(420) + } finally { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: original }) + wrapper.unmount() + localStorage.clear() + } +}) diff --git a/frontend/src/components/common/SecondarySidebar.vue b/frontend/src/components/common/SecondarySidebar.vue index 5d40ef7..609f077 100644 --- a/frontend/src/components/common/SecondarySidebar.vue +++ b/frontend/src/components/common/SecondarySidebar.vue @@ -8,6 +8,7 @@ import TaskFiltersPanel from '@/features/tasks/TaskFiltersPanel.vue' import ExtensionListPanel from '@/components/common/ExtensionListPanel.vue' import { useRoute } from 'vue-router' import { t } from '@/i18n' +import { useLayoutPreferencesStore } from '@/stores/layoutPreferences' const props = defineProps<{ component: string | null @@ -17,15 +18,19 @@ const route = useRoute() const routeName = computed(() => route.name as string) const sidebar = ref(null) const resizable = computed(() => ['file-tree', 'conversation-list'].includes(props.component ?? '')) -const storageKey = computed(() => props.component === 'conversation-list' ? 'chat-sidebar-width' : 'workspace-sidebar-width') +const layout = useLayoutPreferencesStore() +const preferredWidth = computed(() => props.component === 'conversation-list' ? layout.chatWidth : layout.workspaceWidth) const width = ref(272) const maxWidth = ref(520) let dragging = false -function saveWidth() { try { localStorage.setItem(storageKey.value, String(width.value)) } catch { /* Keep resizing available when storage is unavailable. */ } } +function saveWidth() { + if (props.component === 'conversation-list') layout.chatWidth = width.value + else layout.workspaceWidth = width.value +} function clampWidth(value: number) { return Math.max(200, Math.min(maxWidth.value, value)) } function updateBounds() { maxWidth.value = Math.max(200, Math.min(520, window.innerWidth - (sidebar.value?.getBoundingClientRect().left ?? 0) - 320)) - width.value = clampWidth(width.value) + width.value = clampWidth(dragging ? width.value : preferredWidth.value) } function beginResize(event: PointerEvent) { if (event.button !== 0) return @@ -46,11 +51,10 @@ function resizeWithKeyboard(event: KeyboardEvent) { saveWidth() } function restoreWidth() { - width.value = 272 - try { const saved = Number(localStorage.getItem(storageKey.value)); if (saved >= 200 && Number.isFinite(saved)) width.value = saved } catch { /* Use default width. */ } + width.value = preferredWidth.value updateBounds() } -watch(() => props.component, () => { dragging = false; restoreWidth() }) +watch([() => props.component, preferredWidth], () => { dragging = false; restoreWidth() }) onMounted(() => { restoreWidth() window.addEventListener('resize', updateBounds) diff --git a/frontend/src/services/platform/preferenceSync.spec.ts b/frontend/src/services/platform/preferenceSync.spec.ts index bdf30b3..3650b8d 100644 --- a/frontend/src/services/platform/preferenceSync.spec.ts +++ b/frontend/src/services/platform/preferenceSync.spec.ts @@ -4,6 +4,7 @@ import { flushPromises } from '@vue/test-utils' import { afterEach, expect, it, vi } from 'vitest' import { useWorkspaceStore } from '@/stores/workspace' import { useThemeStore } from '@/stores/theme' +import { useLayoutPreferencesStore } from '@/stores/layoutPreferences' import { useSettingsStore } from '@/stores/settings' import { hostInvoke } from './desktop' import { installPreferenceSync, seedCurrentPreferences } from './preferenceSync' @@ -14,15 +15,18 @@ it('applies remote appearance without echoing it and only exports approved local const workspace = useWorkspaceStore(), theme = useThemeStore(), settings = useSettingsStore() settings.permissionPolicy = { plantedSecretPermission: 'allow' } const data = { themeId: 'dark', fontEditorSize: 18, fontEditorFamily: 'system-ui', lineHeight: 1.7, codeBlockTheme: 'auto', headings: { custom: false, family: 'inherit', levels: [32,28,24,21,18,16].map(size => ({ size, weight: 700 })) } } + const layout = useLayoutPreferencesStore() + const remoteLayout = { record: { schema: 1, kind: 'layout', id: 'sidebars', data: { primaryExpanded: true, workspaceWidth: 400, chatWidth: 320 } }, hash: '3'.repeat(64), file_id: 'layout-file' } let remote: Record = { record: { schema: 1, kind: 'theme_settings', id: 'appearance', data }, hash: '1'.repeat(64), file_id: 'theme-file' } vi.mocked(hostInvoke).mockImplementation(async (command, args) => { const request = args!.request as { kind: string; record?: Record } - if (command === 'record_get') return request.kind === 'theme_settings' ? remote : null + if (command === 'record_get') return request.kind === 'theme_settings' ? remote : request.kind === 'layout' ? remoteLayout : null const result = { record: request.record, hash: '2'.repeat(64), file_id: 'theme-file' } if (request.record?.kind === 'theme_settings') remote = result return result }) workspace.vaultId = 'one'; installPreferenceSync(); await flushPromises() + expect(layout.workspaceWidth).toBe(400); expect(layout.chatWidth).toBe(320); expect(layout.primaryExpanded).toBe(true) expect(theme.fontEditorSize).toBe(18); expect(theme.currentThemeId).toBe('dark') expect(vi.mocked(hostInvoke).mock.calls.filter(([command]) => command === 'record_write')).toHaveLength(0) theme.fontEditorSize = 24; await vi.advanceTimersByTimeAsync(1500); await flushPromises() @@ -33,5 +37,7 @@ it('applies remote appearance without echoing it and only exports approved local writes = vi.mocked(hostInvoke).mock.calls.filter(([command]) => command === 'record_write') expect(writes.some(([, args]) => (args!.request as { record: { kind: string } }).record.kind === 'preferences')).toBe(true) expect(JSON.stringify(writes)).not.toContain('permissionPolicy') + layout.workspaceWidth = 440; await vi.advanceTimersByTimeAsync(1500); await flushPromises() + expect(vi.mocked(hostInvoke).mock.calls.some(([command, args]) => command === 'record_write' && (args!.request as { record: { kind: string } }).record.kind === 'layout')).toBe(true) await expect(seedCurrentPreferences('other')).rejects.toThrow('PREFERENCE_BINDING_NOT_READY') }) diff --git a/frontend/src/services/platform/preferenceSync.ts b/frontend/src/services/platform/preferenceSync.ts index d414906..2a0b159 100644 --- a/frontend/src/services/platform/preferenceSync.ts +++ b/frontend/src/services/platform/preferenceSync.ts @@ -7,12 +7,13 @@ import { useHeadingAppearanceStore, normalizeHeadingAppearance } from '@/stores/ import { useMarkdownPreferencesStore, normalizeMarkdownPreferences } from '@/stores/markdownPreferences' import { hostInvoke, isDesktop } from './desktop' import { RecordBinding } from './recordBinding' +import { useLayoutPreferencesStore } from '@/stores/layoutPreferences' interface Controller { label: string; binding: Pick, 'capture' | 'poll' | 'seed' | 'stop' | 'keepLocal' | 'useRemote' | 'error' | 'hasDraft'> } export const preferenceSyncIssues = ref>([]) let controllers = new Map(), activeVault = '', installed = false export async function seedCurrentPreferences(vaultId: string) { if (!isDesktop()) return - if (vaultId !== activeVault || controllers.size !== 2) throw new Error('PREFERENCE_BINDING_NOT_READY') + if (vaultId !== activeVault || controllers.size !== 3) throw new Error('PREFERENCE_BINDING_NOT_READY') for (const { binding } of controllers.values()) { await binding.seed() if (binding.error) throw new Error(binding.error) @@ -29,6 +30,8 @@ export function installPreferenceSync() { installed = true const workspace = useWorkspaceStore(), theme = useThemeStore(), settings = useSettingsStore() const headings = useHeadingAppearanceStore(), markdown = useMarkdownPreferencesStore() + const layout = useLayoutPreferencesStore() + const readLayout = () => ({ primaryExpanded: layout.primaryExpanded, workspaceWidth: layout.workspaceWidth, chatWidth: layout.chatWidth }) let applying = 0 const readTheme = () => ({ themeId: theme.currentThemeId, fontEditorSize: theme.fontEditorSize, fontEditorFamily: theme.fontEditorFamily, lineHeight: theme.lineHeight, codeBlockTheme: theme.codeBlockTheme, headings: normalizeHeadingAppearance(headings.preferences) }) const readPreferences = () => ({ restoreLastVault: settings.restoreLastVault, autoSaveInterval: settings.autoSaveInterval, language: settings.language, defaultEditorMode: settings.defaultEditorMode, editorLineWidth: settings.editorLineWidth, spellCheck: settings.spellCheck, markdown: normalizeMarkdownPreferences(markdown.preferences), presets: markdown.customPresets.map(preset => ({ name: preset.name, preferences: normalizeMarkdownPreferences(preset.preferences) })) }) @@ -49,9 +52,13 @@ export function installPreferenceSync() { settings.defaultEditorMode = data.defaultEditorMode; settings.editorLineWidth = data.editorLineWidth; settings.spellCheck = data.spellCheck markdown.apply(data.markdown); markdown.customPresets = data.presets.map(preset => ({ name: preset.name, preferences: normalizeMarkdownPreferences(preset.preferences) })) }) }) }) + controllers.set('layout', { label: '侧栏布局', binding: new RecordBinding({ ...common, kind: 'layout', id: 'sidebars', read: readLayout, apply: data => apply(() => { + layout.primaryExpanded = data.primaryExpanded; layout.workspaceWidth = data.workspaceWidth; layout.chatWidth = data.chatWidth + }) }) }) changed() for (const value of controllers.values()) void value.binding.poll() }, { immediate: true, flush: 'sync' }) + watch(readLayout, () => { if (!applying) controllers.get('layout')?.binding.capture() }, { deep: true, flush: 'sync' }) watch(readTheme, () => { if (!applying) controllers.get('theme_settings')?.binding.capture() }, { deep: true, flush: 'sync' }) watch(readPreferences, () => { if (!applying) controllers.get('preferences')?.binding.capture() }, { deep: true, flush: 'sync' }) setInterval(() => { for (const value of controllers.values()) void value.binding.poll() }, 1500) diff --git a/frontend/src/stores/layoutPreferences.ts b/frontend/src/stores/layoutPreferences.ts new file mode 100644 index 0000000..93386e7 --- /dev/null +++ b/frontend/src/stores/layoutPreferences.ts @@ -0,0 +1,21 @@ +import { defineStore } from 'pinia' +import { ref, watch } from 'vue' + +export const useLayoutPreferencesStore = defineStore('layoutPreferences', () => { + function stored(key: string) { try { return localStorage.getItem(key) } catch { return null } } + function width(key: string) { + const value = Number(stored(key)) + return Number.isFinite(value) && value >= 200 ? Math.min(520, value) : 272 + } + const primaryExpanded = ref(stored('primary-sidebar-expanded') === 'true') + const workspaceWidth = ref(width('workspace-sidebar-width')) + const chatWidth = ref(width('chat-sidebar-width')) + watch(() => [primaryExpanded.value, workspaceWidth.value, chatWidth.value], () => { + try { + localStorage.setItem('primary-sidebar-expanded', String(primaryExpanded.value)) + localStorage.setItem('workspace-sidebar-width', String(workspaceWidth.value)) + localStorage.setItem('chat-sidebar-width', String(chatWidth.value)) + } catch { /* Keep the current layout usable when local storage is unavailable. */ } + }, { flush: 'sync' }) + return { primaryExpanded, workspaceWidth, chatWidth } +})