feat(sync): 按 Vault 持久化可移植侧栏布局
This commit is contained in:
@@ -73,3 +73,10 @@ Host 在写入 journal、捕获外部修改和上传前验证记录;未知字
|
|||||||
主题白名单为已安装主题标识、编辑字体与字号、行高、代码配色、六级标题字体大小与字重;偏好白名单为恢复上次库的布尔偏好、自动保存间隔、语言、编辑模式、行宽、拼写检查、Markdown 格式与最多 20 项格式预设。Rust 拒绝未知嵌套字段并限制字符串、枚举、数量和数值范围。不传输实际 Vault 路径、凭据、权限、环境变量或自定义主题程序包。目标设备缺少主题时使用内置浅色主题并显示提示。
|
主题白名单为已安装主题标识、编辑字体与字号、行高、代码配色、六级标题字体大小与字重;偏好白名单为恢复上次库的布尔偏好、自动保存间隔、语言、编辑模式、行宽、拼写检查、Markdown 格式与最多 20 项格式预设。Rust 拒绝未知嵌套字段并限制字符串、枚举、数量和数值范围。不传输实际 Vault 路径、凭据、权限、环境变量或自定义主题程序包。目标设备缺少主题时使用内置浅色主题并显示提示。
|
||||||
|
|
||||||
前端按 Vault 和记录类别持久保存草稿、基线摘要与操作 ID;重启恢复原草稿,CAS 冲突必须选择保留本地或采用 Workspace 版本。选择期间出现新编辑时保留新编辑。远端应用不产生回流。轮询空库不自动创建设置;初始上传和合并预览显式写入当前偏好,空库下载不预写设置。
|
前端按 Vault 和记录类别持久保存草稿、基线摘要与操作 ID;重启恢复原草稿,CAS 冲突必须选择保留本地或采用 Workspace 版本。选择期间出现新编辑时保留新编辑。远端应用不产生回流。轮询空库不自动创建设置;初始上传和合并预览显式写入当前偏好,空库下载不预写设置。
|
||||||
|
|
||||||
|
|
||||||
|
## 侧栏布局记录 v1
|
||||||
|
|
||||||
|
`opennexus-records/v1/layout/sidebars.json` 使用 schema=1、kind=layout、id=sidebars。data 仅包含 primaryExpanded 布尔值、workspaceWidth 与 chatWidth 两个 200–520 的像素数值;允许小数以保留指针拖动结果。未知字段、窗口路径或越界宽度均拒绝。记录使用相同的 Vault 草稿、CAS 和冲突解决机制,初始上传/合并准备包含当前布局。
|
||||||
|
|
||||||
|
本机已有三项侧栏 localStorage 值仍作为初始偏好读取并保存,不删除来源。组件共享布局状态,远端值立即反映在侧栏;窗口可用空间不足时仅收窄渲染宽度,不改写同步偏好,恢复空间后恢复偏好宽度。布局记录不含本机窗口坐标、显示器信息、已打开文件路径或执行权限。本版本未支持的旧客户端不能被视作已验证兼容,跨版本发布兼容验收仍需覆盖新增记录类别。
|
||||||
|
|||||||
@@ -671,3 +671,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
|||||||
- 本改动限制同时进行的后台探测数量,不能强杀陷入 OS/驱动 I/O 的线程,不作为异常退出时限证明。真实依赖超时、远端恢复和服务负载验收仍未完成。
|
- 本改动限制同时进行的后台探测数量,不能强杀陷入 OS/驱动 I/O 的线程,不作为异常退出时限证明。真实依赖超时、远端恢复和服务负载验收仍未完成。
|
||||||
- 首次全量测试暴露提取代码时误删相邻 handshake 路由(1 failed / 28 passed);差异审查已恢复原路由,此失败不计为最终通过证据,最终结果另列。
|
- 首次全量测试暴露提取代码时误删相邻 handshake 路由(1 failed / 28 passed);差异审查已恢复原路由,此失败不计为最终通过证据,最终结果另列。
|
||||||
- 修正后最终 Sync 全套 29 通过、2 项依赖弃用警告,22.83 秒,包含实际四并发 100 MiB TCP 传输。日志 .build/sync-readiness-full-final.log,JUnit .build/sync-readiness-full-final.xml;完整生产化目标仍未完成。
|
- 修正后最终 Sync 全套 29 通过、2 项依赖弃用警告,22.83 秒,包含实际四并发 100 MiB TCP 传输。日志 .build/sync-readiness-full-final.log,JUnit .build/sync-readiness-full-final.xml;完整生产化目标仍未完成。
|
||||||
|
|
||||||
|
|
||||||
|
## 增量:侧栏布局进入 Vault 逻辑同步
|
||||||
|
|
||||||
|
- 新增 layout/sidebars 记录,Host path_for/allowed 和严格 schema 同时登记。字段白名单为 primaryExpanded、workspaceWidth、chatWidth,宽度 200–520;未知字段和路径身份拒绝。
|
||||||
|
- 新增 Pinia layoutPreferences,将主侧栏展开与文件/对话侧栏宽度接入共享状态,沿用现有本地键读取和保存。preferenceSync 新增第三个 RecordBinding,初始种子、草稿、CAS 冲突和远端应用抑制回流使用既有机制;没有直接同步任意 localStorage。
|
||||||
|
- SecondarySidebar 对目标宽度与可用视口宽度分开处理:调整窗口仅改变呈现,不产生同步写入;窗口恢复空间后重新使用偏好宽度。收到远端宽度时组件也重新计算本机范围。
|
||||||
|
- 定向前端 4 项通过,包括远端布局不回流、本地布局写入、两个侧栏宽度分离、窄窗口限制不污染偏好。新增 Rust 测试覆盖布局记录完整信封/允许路径、非法宽度和设备字段拒绝。完整前端 101 文件、526 项通过,日志 .build/layout-sync-frontend.log。
|
||||||
|
- 本轮覆盖现有侧栏布局,不代表全部逻辑数据已经完成:Skills、对话、Agent/人设、Provider 非秘密参数和安装清单仍需各自适配;真实双设备 UI 与旧版本新增记录兼容验收尚未完成。
|
||||||
|
- 最终 desktop Rust 全目标 137 通过、12 ignored(库 122、Host 8、其余集成 7),日志 .build/layout-sync-rust.log;全目标 Clippy -D warnings 与前端 type-check 均通过,日志 .build/layout-sync-clippy.log、.build/layout-sync-types.log。没有改动用户 Vault 内容,完整生产化验收仍未完成。
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ use serde::Deserialize;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
#[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 {
|
struct Level {
|
||||||
size: f64,
|
size: f64,
|
||||||
weight: u16,
|
weight: u16,
|
||||||
@@ -84,6 +91,12 @@ fn markdown(value: &Markdown) -> bool {
|
|||||||
}
|
}
|
||||||
pub fn validate(kind: &str, value: &Value) -> Result<()> {
|
pub fn validate(kind: &str, value: &Value) -> Result<()> {
|
||||||
let valid = match kind {
|
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" => {
|
"theme_settings" => {
|
||||||
let value: Theme = decode(value)?;
|
let value: Theme = decode(value)?;
|
||||||
let _ = value.headings.custom;
|
let _ = value.headings.custom;
|
||||||
@@ -144,6 +157,23 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
#[test]
|
#[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() {
|
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})))}});
|
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();
|
validate("theme_settings", &theme).unwrap();
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ pub fn path_for(kind: &str, id: &str) -> Result<String> {
|
|||||||
("theme_settings", "appearance") => {
|
("theme_settings", "appearance") => {
|
||||||
Ok("opennexus-records/v1/theme-settings/appearance.json".into())
|
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()),
|
("preferences", "editor") => Ok("opennexus-records/v1/preferences/editor.json".into()),
|
||||||
_ => Err(HostError::new("RECORD_ID_INVALID")),
|
_ => Err(HostError::new("RECORD_ID_INVALID")),
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,7 @@ pub fn allowed(path_value: &str) -> bool {
|
|||||||
path_value,
|
path_value,
|
||||||
"opennexus-records/v1/theme-settings/appearance.json"
|
"opennexus-records/v1/theme-settings/appearance.json"
|
||||||
| "opennexus-records/v1/preferences/editor.json"
|
| "opennexus-records/v1/preferences/editor.json"
|
||||||
|
| "opennexus-records/v1/layout/sidebars.json"
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { computed, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useLayoutPreferencesStore } from '@/stores/layoutPreferences'
|
||||||
import { ArrowLeftBold, ArrowRightBold, Brush, ChatDotRound, CircleCheck, Connection, Cpu, Document, FolderOpened, Lightning, Monitor, Search, Setting } from '@element-plus/icons-vue'
|
import { ArrowLeftBold, ArrowRightBold, Brush, ChatDotRound, CircleCheck, Connection, Cpu, Document, FolderOpened, Lightning, Monitor, Search, Setting } from '@element-plus/icons-vue'
|
||||||
import AppIcon from './AppIcon.vue'
|
import AppIcon from './AppIcon.vue'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const expanded = ref(localStorage.getItem('primary-sidebar-expanded') === 'true')
|
const { primaryExpanded: expanded } = storeToRefs(useLayoutPreferencesStore())
|
||||||
|
|
||||||
const navItems = computed(() => [
|
const navItems = computed(() => [
|
||||||
{ name: 'workspace', icon: FolderOpened, label: t('工作区', 'Workspace') },
|
{ name: 'workspace', icon: FolderOpened, label: t('工作区', 'Workspace') },
|
||||||
@@ -36,7 +38,6 @@ function navigate(name: string) {
|
|||||||
|
|
||||||
function toggleExpanded() {
|
function toggleExpanded() {
|
||||||
expanded.value = !expanded.value
|
expanded.value = !expanded.value
|
||||||
localStorage.setItem('primary-sidebar-expanded', String(expanded.value))
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { useLayoutPreferencesStore } from '@/stores/layoutPreferences'
|
||||||
import SecondarySidebar from './SecondarySidebar.vue'
|
import SecondarySidebar from './SecondarySidebar.vue'
|
||||||
|
|
||||||
it('keeps conversation and file widths separate across route changes', async () => {
|
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')
|
localStorage.setItem('workspace-sidebar-width', '240')
|
||||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] })
|
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] })
|
||||||
await router.push('/')
|
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()
|
await wrapper.vm.$nextTick()
|
||||||
expect(wrapper.get('aside').attributes('style')).toContain('320px')
|
expect(wrapper.get('aside').attributes('style')).toContain('320px')
|
||||||
await wrapper.get('[role="separator"]').trigger('keydown', {key:'ArrowRight'})
|
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')
|
localStorage.removeItem('workspace-sidebar-width')
|
||||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] })
|
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }] })
|
||||||
await router.push('/')
|
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)
|
let wrapper = mount(SecondarySidebar, options)
|
||||||
await wrapper.get('[role="separator"]').trigger('keydown', { key: 'ArrowRight' })
|
await wrapper.get('[role="separator"]').trigger('keydown', { key: 'ArrowRight' })
|
||||||
expect(localStorage.getItem('workspace-sidebar-width')).toBe('288')
|
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()
|
wrapper.unmount()
|
||||||
localStorage.removeItem('workspace-sidebar-width')
|
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: '<div />' } }] })
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import TaskFiltersPanel from '@/features/tasks/TaskFiltersPanel.vue'
|
|||||||
import ExtensionListPanel from '@/components/common/ExtensionListPanel.vue'
|
import ExtensionListPanel from '@/components/common/ExtensionListPanel.vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
import { useLayoutPreferencesStore } from '@/stores/layoutPreferences'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
component: string | null
|
component: string | null
|
||||||
@@ -17,15 +18,19 @@ const route = useRoute()
|
|||||||
const routeName = computed(() => route.name as string)
|
const routeName = computed(() => route.name as string)
|
||||||
const sidebar = ref<HTMLElement | null>(null)
|
const sidebar = ref<HTMLElement | null>(null)
|
||||||
const resizable = computed(() => ['file-tree', 'conversation-list'].includes(props.component ?? ''))
|
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 width = ref(272)
|
||||||
const maxWidth = ref(520)
|
const maxWidth = ref(520)
|
||||||
let dragging = false
|
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 clampWidth(value: number) { return Math.max(200, Math.min(maxWidth.value, value)) }
|
||||||
function updateBounds() {
|
function updateBounds() {
|
||||||
maxWidth.value = Math.max(200, Math.min(520, window.innerWidth - (sidebar.value?.getBoundingClientRect().left ?? 0) - 320))
|
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) {
|
function beginResize(event: PointerEvent) {
|
||||||
if (event.button !== 0) return
|
if (event.button !== 0) return
|
||||||
@@ -46,11 +51,10 @@ function resizeWithKeyboard(event: KeyboardEvent) {
|
|||||||
saveWidth()
|
saveWidth()
|
||||||
}
|
}
|
||||||
function restoreWidth() {
|
function restoreWidth() {
|
||||||
width.value = 272
|
width.value = preferredWidth.value
|
||||||
try { const saved = Number(localStorage.getItem(storageKey.value)); if (saved >= 200 && Number.isFinite(saved)) width.value = saved } catch { /* Use default width. */ }
|
|
||||||
updateBounds()
|
updateBounds()
|
||||||
}
|
}
|
||||||
watch(() => props.component, () => { dragging = false; restoreWidth() })
|
watch([() => props.component, preferredWidth], () => { dragging = false; restoreWidth() })
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
restoreWidth()
|
restoreWidth()
|
||||||
window.addEventListener('resize', updateBounds)
|
window.addEventListener('resize', updateBounds)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { flushPromises } from '@vue/test-utils'
|
|||||||
import { afterEach, expect, it, vi } from 'vitest'
|
import { afterEach, expect, it, vi } from 'vitest'
|
||||||
import { useWorkspaceStore } from '@/stores/workspace'
|
import { useWorkspaceStore } from '@/stores/workspace'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
import { useLayoutPreferencesStore } from '@/stores/layoutPreferences'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { hostInvoke } from './desktop'
|
import { hostInvoke } from './desktop'
|
||||||
import { installPreferenceSync, seedCurrentPreferences } from './preferenceSync'
|
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()
|
const workspace = useWorkspaceStore(), theme = useThemeStore(), settings = useSettingsStore()
|
||||||
settings.permissionPolicy = { plantedSecretPermission: 'allow' }
|
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 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<string, unknown> = { record: { schema: 1, kind: 'theme_settings', id: 'appearance', data }, hash: '1'.repeat(64), file_id: 'theme-file' }
|
let remote: Record<string, unknown> = { record: { schema: 1, kind: 'theme_settings', id: 'appearance', data }, hash: '1'.repeat(64), file_id: 'theme-file' }
|
||||||
vi.mocked(hostInvoke).mockImplementation(async (command, args) => {
|
vi.mocked(hostInvoke).mockImplementation(async (command, args) => {
|
||||||
const request = args!.request as { kind: string; record?: Record<string, unknown> }
|
const request = args!.request as { kind: string; record?: Record<string, unknown> }
|
||||||
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' }
|
const result = { record: request.record, hash: '2'.repeat(64), file_id: 'theme-file' }
|
||||||
if (request.record?.kind === 'theme_settings') remote = result
|
if (request.record?.kind === 'theme_settings') remote = result
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
workspace.vaultId = 'one'; installPreferenceSync(); await flushPromises()
|
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(theme.fontEditorSize).toBe(18); expect(theme.currentThemeId).toBe('dark')
|
||||||
expect(vi.mocked(hostInvoke).mock.calls.filter(([command]) => command === 'record_write')).toHaveLength(0)
|
expect(vi.mocked(hostInvoke).mock.calls.filter(([command]) => command === 'record_write')).toHaveLength(0)
|
||||||
theme.fontEditorSize = 24; await vi.advanceTimersByTimeAsync(1500); await flushPromises()
|
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')
|
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(writes.some(([, args]) => (args!.request as { record: { kind: string } }).record.kind === 'preferences')).toBe(true)
|
||||||
expect(JSON.stringify(writes)).not.toContain('permissionPolicy')
|
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')
|
await expect(seedCurrentPreferences('other')).rejects.toThrow('PREFERENCE_BINDING_NOT_READY')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { useHeadingAppearanceStore, normalizeHeadingAppearance } from '@/stores/
|
|||||||
import { useMarkdownPreferencesStore, normalizeMarkdownPreferences } from '@/stores/markdownPreferences'
|
import { useMarkdownPreferencesStore, normalizeMarkdownPreferences } from '@/stores/markdownPreferences'
|
||||||
import { hostInvoke, isDesktop } from './desktop'
|
import { hostInvoke, isDesktop } from './desktop'
|
||||||
import { RecordBinding } from './recordBinding'
|
import { RecordBinding } from './recordBinding'
|
||||||
|
import { useLayoutPreferencesStore } from '@/stores/layoutPreferences'
|
||||||
interface Controller { label: string; binding: Pick<RecordBinding<never>, 'capture' | 'poll' | 'seed' | 'stop' | 'keepLocal' | 'useRemote' | 'error' | 'hasDraft'> }
|
interface Controller { label: string; binding: Pick<RecordBinding<never>, 'capture' | 'poll' | 'seed' | 'stop' | 'keepLocal' | 'useRemote' | 'error' | 'hasDraft'> }
|
||||||
export const preferenceSyncIssues = ref<Array<{ kind: string; label: string; error: string; hasDraft: boolean }>>([])
|
export const preferenceSyncIssues = ref<Array<{ kind: string; label: string; error: string; hasDraft: boolean }>>([])
|
||||||
let controllers = new Map<string, Controller>(), activeVault = '', installed = false
|
let controllers = new Map<string, Controller>(), activeVault = '', installed = false
|
||||||
export async function seedCurrentPreferences(vaultId: string) {
|
export async function seedCurrentPreferences(vaultId: string) {
|
||||||
if (!isDesktop()) return
|
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()) {
|
for (const { binding } of controllers.values()) {
|
||||||
await binding.seed()
|
await binding.seed()
|
||||||
if (binding.error) throw new Error(binding.error)
|
if (binding.error) throw new Error(binding.error)
|
||||||
@@ -29,6 +30,8 @@ export function installPreferenceSync() {
|
|||||||
installed = true
|
installed = true
|
||||||
const workspace = useWorkspaceStore(), theme = useThemeStore(), settings = useSettingsStore()
|
const workspace = useWorkspaceStore(), theme = useThemeStore(), settings = useSettingsStore()
|
||||||
const headings = useHeadingAppearanceStore(), markdown = useMarkdownPreferencesStore()
|
const headings = useHeadingAppearanceStore(), markdown = useMarkdownPreferencesStore()
|
||||||
|
const layout = useLayoutPreferencesStore()
|
||||||
|
const readLayout = () => ({ primaryExpanded: layout.primaryExpanded, workspaceWidth: layout.workspaceWidth, chatWidth: layout.chatWidth })
|
||||||
let applying = 0
|
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 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) })) })
|
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
|
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) }))
|
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()
|
changed()
|
||||||
for (const value of controllers.values()) void value.binding.poll()
|
for (const value of controllers.values()) void value.binding.poll()
|
||||||
}, { immediate: true, flush: 'sync' })
|
}, { 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(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' })
|
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)
|
setInterval(() => { for (const value of controllers.values()) void value.binding.poll() }, 1500)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user