From 697c93e231d191536ae4393ffb9cb8a81ea7b07f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 18:52:41 +0000 Subject: [PATCH 1/7] Add handmade binary tile tree for the workbench layout Co-authored-by: Gao Yu --- src/lib/workbench/tiles.test.ts | 268 ++++++++++++++++++++++++++++ src/lib/workbench/tiles.ts | 302 ++++++++++++++++++++++++++++++++ 2 files changed, 570 insertions(+) create mode 100644 src/lib/workbench/tiles.test.ts create mode 100644 src/lib/workbench/tiles.ts diff --git a/src/lib/workbench/tiles.test.ts b/src/lib/workbench/tiles.test.ts new file mode 100644 index 0000000..6e87b38 --- /dev/null +++ b/src/lib/workbench/tiles.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect } from 'vitest'; +import { + activateTab, + closeTab, + deserializeLayout, + emptyLayout, + findLeaf, + leafOfTab, + leavesOf, + moveTab, + openTab, + resizeSplit, + serializeLayout, + singleLeafLayout, + splitLeaf, + toggleMaximize, + LAYOUT_VERSION, + RATIO_MAX, + RATIO_MIN, + type LeafNode, + type SplitNode, + type TileLayout, + type TileTab +} from './tiles'; + +const tab = (id: string, panel = id): TileTab => ({ id, panel }); + +/** One leaf [a, b] split with a fresh leaf [c] on the given edge of it. */ +function twoLeaves(zone: 'left' | 'right' | 'top' | 'bottom' = 'right') { + const layout = singleLeafLayout([tab('a'), tab('b')]); + const first = leavesOf(layout.root)[0]; + const { layout: next, leafId } = splitLeaf(layout, first.id, zone, tab('c')); + return { layout: next, firstId: first.id, secondId: leafId }; +} + +describe('split', () => { + it('replaces the leaf with a split holding it and the fresh leaf', () => { + const { layout, firstId, secondId } = twoLeaves('right'); + const root = layout.root as SplitNode; + expect(root.kind).toBe('split'); + expect(root.dir).toBe('row'); + expect(root.ratio).toBe(0.5); + expect((root.a as LeafNode).id).toBe(firstId); + expect((root.b as LeafNode).id).toBe(secondId); + expect((root.b as LeafNode).tabs.map((t) => t.id)).toEqual(['c']); + }); + + it('puts the new leaf before the target for left/top zones', () => { + const { layout, secondId } = twoLeaves('top'); + const root = layout.root as SplitNode; + expect(root.dir).toBe('col'); + expect((root.a as LeafNode).id).toBe(secondId); + }); + + it('splitting a maximized leaf clears maximize so the result is visible', () => { + const layout = singleLeafLayout([tab('a')]); + const leaf = leavesOf(layout.root)[0]; + const maxed = toggleMaximize(layout, leaf.id); + const { layout: next } = splitLeaf(maxed, leaf.id, 'bottom', tab('c')); + expect(next.maximized).toBeNull(); + }); + + it('is a no-op on an unknown leaf', () => { + const layout = singleLeafLayout([tab('a')]); + expect(splitLeaf(layout, 'nope', 'left', tab('c')).layout).toBe(layout); + }); +}); + +describe('close', () => { + it('removes a tab and activates its neighbor', () => { + const layout = activateTab(singleLeafLayout([tab('a'), tab('b'), tab('c')]), 'b'); + const next = closeTab(layout, 'b'); + const leaf = leavesOf(next.root)[0]; + expect(leaf.tabs.map((t) => t.id)).toEqual(['a', 'c']); + expect(leaf.active).toBe('c'); + }); + + it('keeps the active tab when a background tab closes', () => { + const layout = singleLeafLayout([tab('a'), tab('b')]); + const next = closeTab(layout, 'b'); + expect(leavesOf(next.root)[0].active).toBe('a'); + }); + + it('collapses an emptied leaf, promoting the sibling to the split area', () => { + const { layout, firstId } = twoLeaves('right'); + const next = closeTab(layout, 'c'); + expect(next.root?.kind).toBe('leaf'); + expect((next.root as LeafNode).id).toBe(firstId); + }); + + it('closing the last tab of the only leaf empties the tree', () => { + const layout = singleLeafLayout([tab('a')]); + expect(closeTab(layout, 'a').root).toBeNull(); + }); + + it('clears maximize when the maximized leaf collapses away', () => { + const { layout, secondId } = twoLeaves('right'); + const maxed = toggleMaximize(layout, secondId); + const next = closeTab(maxed, 'c'); + expect(next.maximized).toBeNull(); + }); + + it('is a no-op for an unknown tab', () => { + const layout = singleLeafLayout([tab('a')]); + expect(closeTab(layout, 'nope')).toBe(layout); + }); +}); + +describe('open and activate', () => { + it('appends to the target leaf and activates', () => { + const layout = singleLeafLayout([tab('a')]); + const leaf = leavesOf(layout.root)[0]; + const next = openTab(layout, leaf.id, tab('b')); + const after = leavesOf(next.root)[0]; + expect(after.tabs.map((t) => t.id)).toEqual(['a', 'b']); + expect(after.active).toBe('b'); + }); + + it('creates a root leaf in an empty tree', () => { + const next = openTab(emptyLayout(), null, tab('a')); + expect(leavesOf(next.root)[0].tabs.map((t) => t.id)).toEqual(['a']); + }); + + it('re-activates an existing tab id instead of duplicating it', () => { + const { layout, secondId } = twoLeaves('right'); + const next = openTab(layout, secondId, tab('a')); + expect(leavesOf(next.root).flatMap((l) => l.tabs).filter((t) => t.id === 'a')).toHaveLength(1); + expect(leafOfTab(next.root, 'a')?.active).toBe('a'); + }); +}); + +describe('move', () => { + it('center drop joins the target stack and leaves the source without the tab', () => { + const { layout, firstId, secondId } = twoLeaves('right'); + const next = moveTab(layout, 'b', secondId, 'center'); + expect(findLeaf(next.root, firstId)?.tabs.map((t) => t.id)).toEqual(['a']); + const target = findLeaf(next.root, secondId); + expect(target?.tabs.map((t) => t.id)).toEqual(['c', 'b']); + expect(target?.active).toBe('b'); + }); + + it('edge drop splits the target with a fresh single-tab leaf', () => { + const { layout, secondId } = twoLeaves('right'); + const next = moveTab(layout, 'a', secondId, 'bottom'); + const all = leavesOf(next.root); + expect(all).toHaveLength(3); + const fresh = leafOfTab(next.root, 'a'); + expect(fresh?.tabs).toHaveLength(1); + }); + + it('moving a leaf-emptying tab collapses the source before landing', () => { + const { layout, firstId, secondId } = twoLeaves('right'); + // 'c' is the second leaf's only tab: the leaf must vanish. + const next = moveTab(layout, 'c', firstId, 'center'); + expect(findLeaf(next.root, secondId)).toBeNull(); + expect(leavesOf(next.root)).toHaveLength(1); + expect(findLeaf(next.root, firstId)?.tabs.map((t) => t.id)).toEqual(['a', 'b', 'c']); + }); + + it('center drop on its own leaf reorders the tab to the end', () => { + const layout = singleLeafLayout([tab('a'), tab('b'), tab('c')]); + const leaf = leavesOf(layout.root)[0]; + const next = moveTab(layout, 'a', leaf.id, 'center'); + expect(leavesOf(next.root)[0].tabs.map((t) => t.id)).toEqual(['b', 'c', 'a']); + }); + + it('edge drop of a stack tab onto its own leaf splits that leaf in two', () => { + const layout = singleLeafLayout([tab('a'), tab('b')]); + const leaf = leavesOf(layout.root)[0]; + const next = moveTab(layout, 'b', leaf.id, 'right'); + const all = leavesOf(next.root); + expect(all).toHaveLength(2); + expect(all[0].tabs.map((t) => t.id)).toEqual(['a']); + expect(all[1].tabs.map((t) => t.id)).toEqual(['b']); + }); + + it("dropping a leaf's only tab onto its own edge is a no-op", () => { + const layout = singleLeafLayout([tab('a')]); + const leaf = leavesOf(layout.root)[0]; + expect(moveTab(layout, 'a', leaf.id, 'left')).toBe(layout); + }); +}); + +describe('resize', () => { + it('sets the split ratio', () => { + const { layout } = twoLeaves('right'); + const split = layout.root as SplitNode; + const next = resizeSplit(layout, split.id, 0.3); + expect((next.root as SplitNode).ratio).toBe(0.3); + }); + + it('clamps the ratio so neither side collapses', () => { + const { layout } = twoLeaves('right'); + const split = layout.root as SplitNode; + expect((resizeSplit(layout, split.id, 0).root as SplitNode).ratio).toBe(RATIO_MIN); + expect((resizeSplit(layout, split.id, 1).root as SplitNode).ratio).toBe(RATIO_MAX); + }); + + it('is a no-op on an unknown split id', () => { + const { layout } = twoLeaves('right'); + expect(resizeSplit(layout, 'nope', 0.3)).toBe(layout); + }); +}); + +describe('maximize', () => { + it('dblclick semantic toggles: maximize then restore', () => { + const { layout, firstId } = twoLeaves('right'); + const maxed = toggleMaximize(layout, firstId); + expect(maxed.maximized).toBe(firstId); + expect(toggleMaximize(maxed, firstId).maximized).toBeNull(); + }); + + it('maximizing another leaf replaces the current one', () => { + const { layout, firstId, secondId } = twoLeaves('right'); + const next = toggleMaximize(toggleMaximize(layout, firstId), secondId); + expect(next.maximized).toBe(secondId); + }); + + it('ignores unknown leaves', () => { + const layout = singleLeafLayout([tab('a')]); + expect(toggleMaximize(layout, 'nope')).toBe(layout); + }); +}); + +describe('serialize / deserialize', () => { + it('round-trips a layout with splits, stacks and maximize', () => { + const { layout, secondId } = twoLeaves('bottom'); + const withMax = toggleMaximize(activateTab(layout, 'b'), secondId); + const parsed = deserializeLayout(JSON.parse(JSON.stringify(serializeLayout(withMax)))); + expect(parsed).toEqual(withMax); + }); + + it('stamps the current version', () => { + expect(serializeLayout(emptyLayout()).version).toBe(LAYOUT_VERSION); + }); + + it('rejects unknown versions and garbage', () => { + expect(deserializeLayout({ version: 99, root: null, maximized: null })).toBeNull(); + expect(deserializeLayout('nonsense')).toBeNull(); + expect(deserializeLayout(null)).toBeNull(); + }); + + it('drops invalid tabs and collapses emptied nodes while parsing', () => { + const ser = { + version: LAYOUT_VERSION, + maximized: null, + root: { + kind: 'split', + id: 's1', + dir: 'row', + ratio: 7, // out of range → clamped + a: { kind: 'leaf', id: 'l1', tabs: [{ id: 'a', panel: 'goal' }, { bogus: true }], active: 'zz' }, + b: { kind: 'leaf', id: 'l2', tabs: [], active: '' } // empty → collapses + } + }; + const parsed = deserializeLayout(ser) as TileLayout; + expect(parsed.root?.kind).toBe('leaf'); + const leaf = parsed.root as LeafNode; + expect(leaf.tabs).toEqual([{ id: 'a', panel: 'goal' }]); + expect(leaf.active).toBe('a'); + }); + + it('clears a maximized id that no longer resolves to a leaf', () => { + const ser = serializeLayout(singleLeafLayout([tab('a')])); + const parsed = deserializeLayout({ ...ser, maximized: 'gone' }); + expect(parsed?.maximized).toBeNull(); + }); +}); diff --git a/src/lib/workbench/tiles.ts b/src/lib/workbench/tiles.ts new file mode 100644 index 0000000..75a7d17 --- /dev/null +++ b/src/lib/workbench/tiles.ts @@ -0,0 +1,302 @@ +// Handmade binary tile/split tree for the workbench ("mosaic") layout. +// +// Framework-free, pure data + pure transforms: every operation returns a new +// layout (structural sharing, untouched subtrees keep their identity) so the +// caller can hold the layout in reactive state and persist on change. No DOM, +// no Svelte — see docs/workbench-plan.md §3.2 for why this is hand-rolled +// instead of pulling in a panel framework. +// +// Shape: an inner node splits its area between exactly two children (row = +// side by side, col = stacked) at `ratio`; a leaf is a tab stack (several +// tabs, one active). Maximize is layout state *next to* the tree — restoring +// never has to rebuild anything, it just clears `maximized`. + +export type SplitDir = 'row' | 'col'; + +/** Where a dragged tab lands on a leaf: its stack, or one of the four edges. */ +export type DropZone = 'center' | 'left' | 'right' | 'top' | 'bottom'; + +export interface TileTab { + /** Unique across the whole tree — a tab lives in exactly one leaf. */ + id: string; + /** What the tab shows (panel kind); the UI maps this to a component. */ + panel: string; +} + +export interface LeafNode { + kind: 'leaf'; + id: string; + tabs: TileTab[]; + /** Active tab id ('' only for a leaf about to be collapsed away). */ + active: string; +} + +export interface SplitNode { + kind: 'split'; + id: string; + dir: SplitDir; + /** Share of the area given to `a` (clamped to RATIO_MIN..RATIO_MAX). */ + ratio: number; + a: TileNode; + b: TileNode; +} + +export type TileNode = LeafNode | SplitNode; + +export interface TileLayout { + root: TileNode | null; + /** Leaf shown full-bleed (dblclick semantic); null = normal tiling. */ + maximized: string | null; +} + +export const RATIO_MIN = 0.15; +export const RATIO_MAX = 0.85; + +export const LAYOUT_VERSION = 1; + +export interface SerializedLayout { + version: number; + root: TileNode | null; + maximized: string | null; +} + +let counter = 0; +const newId = (prefix: string) => + `${prefix}${Date.now().toString(36)}-${(counter++).toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + +const clampRatio = (r: number) => Math.min(RATIO_MAX, Math.max(RATIO_MIN, r)); + +export function makeLeaf(tabs: TileTab[], active?: string): LeafNode { + return { + kind: 'leaf', + id: newId('l'), + tabs, + active: active && tabs.some((t) => t.id === active) ? active : (tabs[0]?.id ?? '') + }; +} + +export function emptyLayout(): TileLayout { + return { root: null, maximized: null }; +} + +/** A layout with a single leaf holding `tabs` (the pre-mosaic dock shape). */ +export function singleLeafLayout(tabs: TileTab[], active?: string): TileLayout { + return { root: tabs.length ? makeLeaf(tabs, active) : null, maximized: null }; +} + +/** All leaves in DFS order (a before b) — the visual reading order. */ +export function leavesOf(node: TileNode | null): LeafNode[] { + if (!node) return []; + if (node.kind === 'leaf') return [node]; + return [...leavesOf(node.a), ...leavesOf(node.b)]; +} + +export function findLeaf(node: TileNode | null, leafId: string): LeafNode | null { + return leavesOf(node).find((l) => l.id === leafId) ?? null; +} + +/** The leaf holding `tabId`, or null. */ +export function leafOfTab(node: TileNode | null, tabId: string): LeafNode | null { + return leavesOf(node).find((l) => l.tabs.some((t) => t.id === tabId)) ?? null; +} + +// ---------- internal rebuilding helpers ---------- + +/** Replace the leaf `leafId` with `replacement` (null = delete it, letting the + * sibling take the split's whole area). Untouched subtrees are reused. */ +function replaceLeaf(node: TileNode, leafId: string, replacement: TileNode | null): TileNode | null { + if (node.kind === 'leaf') return node.id === leafId ? replacement : node; + const a = replaceLeaf(node.a, leafId, replacement); + const b = replaceLeaf(node.b, leafId, replacement); + if (a === node.a && b === node.b) return node; + if (!a) return b; + if (!b) return a; + return { ...node, a, b }; +} + +function mapLeaf(node: TileNode, leafId: string, fn: (leaf: LeafNode) => LeafNode): TileNode { + if (node.kind === 'leaf') return node.id === leafId ? fn(node) : node; + const a = mapLeaf(node.a, leafId, fn); + const b = mapLeaf(node.b, leafId, fn); + return a === node.a && b === node.b ? node : { ...node, a, b }; +} + +/** Drop `maximized` when the leaf it pointed at no longer exists. */ +function fixMaximized(layout: TileLayout): TileLayout { + if (layout.maximized && !findLeaf(layout.root, layout.maximized)) { + return { ...layout, maximized: null }; + } + return layout; +} + +// ---------- operations ---------- + +/** Add `tab` to leaf `leafId` (or the first leaf; or a fresh root leaf when the + * tree is empty) and activate it. A tab id that already exists anywhere in the + * tree is activated in place instead — tab ids are unique across the tree. */ +export function openTab(layout: TileLayout, leafId: string | null, tab: TileTab): TileLayout { + const existing = leafOfTab(layout.root, tab.id); + if (existing) return activateTab(layout, tab.id); + if (!layout.root) return { ...layout, root: makeLeaf([tab]) }; + const target = (leafId && findLeaf(layout.root, leafId)) || leavesOf(layout.root)[0]; + const root = mapLeaf(layout.root, target.id, (l) => ({ + ...l, + tabs: [...l.tabs, tab], + active: tab.id + })); + return { ...layout, root }; +} + +export function activateTab(layout: TileLayout, tabId: string): TileLayout { + const leaf = leafOfTab(layout.root, tabId); + if (!leaf || !layout.root || leaf.active === tabId) return layout; + const root = mapLeaf(layout.root, leaf.id, (l) => ({ ...l, active: tabId })); + return { ...layout, root }; +} + +/** Remove a tab. A leaf left empty collapses: its split parent is replaced by + * the sibling (root leaf → empty tree). Activation moves to the neighbor tab. */ +export function closeTab(layout: TileLayout, tabId: string): TileLayout { + const leaf = leafOfTab(layout.root, tabId); + if (!leaf || !layout.root) return layout; + if (leaf.tabs.length === 1) { + return fixMaximized({ ...layout, root: replaceLeaf(layout.root, leaf.id, null) }); + } + const idx = leaf.tabs.findIndex((t) => t.id === tabId); + const tabs = leaf.tabs.filter((t) => t.id !== tabId); + const active = leaf.active === tabId ? tabs[Math.min(idx, tabs.length - 1)].id : leaf.active; + const root = mapLeaf(layout.root, leaf.id, (l) => ({ ...l, tabs, active })); + return { ...layout, root }; +} + +/** Split leaf `leafId`, seeding the new sibling leaf with `tab`. left/top put + * the new leaf before the target, right/bottom after. Returns the new layout + * and the created leaf's id (for follow-up focus). */ +export function splitLeaf( + layout: TileLayout, + leafId: string, + zone: Exclude, + tab: TileTab +): { layout: TileLayout; leafId: string } { + const target = layout.root && findLeaf(layout.root, leafId); + if (!target || !layout.root) return { layout, leafId }; + const fresh = makeLeaf([tab]); + const dir: SplitDir = zone === 'left' || zone === 'right' ? 'row' : 'col'; + const first = zone === 'left' || zone === 'top'; + const split: SplitNode = { + kind: 'split', + id: newId('s'), + dir, + ratio: 0.5, + a: first ? fresh : target, + b: first ? target : fresh + }; + // A maximized leaf that gets split should reveal the result, not hide it. + return { + layout: { root: replaceLeaf(layout.root, leafId, split), maximized: null }, + leafId: fresh.id + }; +} + +/** + * Move `tabId` onto `targetLeafId`: 'center' joins that stack (or moves the tab + * to the stack's end when it is already there), an edge zone splits the target + * with a fresh leaf holding just this tab. Degenerate moves (dropping a leaf's + * only tab onto its own edge, moving onto a vanished target) are no-ops. + */ +export function moveTab( + layout: TileLayout, + tabId: string, + targetLeafId: string, + zone: DropZone +): TileLayout { + const source = leafOfTab(layout.root, tabId); + const tab = source?.tabs.find((t) => t.id === tabId); + if (!source || !tab || !layout.root || !findLeaf(layout.root, targetLeafId)) return layout; + if (source.id === targetLeafId) { + if (zone === 'center') { + // Reorder to the end of its own stack and activate. + const tabs = [...source.tabs.filter((t) => t.id !== tabId), tab]; + const root = mapLeaf(layout.root, source.id, (l) => ({ ...l, tabs, active: tabId })); + return { ...layout, root }; + } + if (source.tabs.length === 1) return layout; // splitting itself off itself + } + // Detach from the source stack first (collapsing it when it empties)… + const detached = closeTab(layout, tabId); + // …then land on the target, which survived the collapse (it isn't the + // vanished source: a single-tab source equal to the target returned above). + if (zone === 'center') return openTab(detached, targetLeafId, tab); + return splitLeaf(detached, targetLeafId, zone, tab).layout; +} + +/** Set a split's ratio (clamped so neither side can be crushed away). */ +export function resizeSplit(layout: TileLayout, splitId: string, ratio: number): TileLayout { + if (!layout.root) return layout; + const walk = (n: TileNode): TileNode => { + if (n.kind === 'leaf') return n; + if (n.id === splitId) return { ...n, ratio: clampRatio(ratio) }; + const a = walk(n.a); + const b = walk(n.b); + return a === n.a && b === n.b ? n : { ...n, a, b }; + }; + const root = walk(layout.root); + return root === layout.root ? layout : { ...layout, root }; +} + +/** Dblclick semantic: maximize `leafId`, or restore when it already is. */ +export function toggleMaximize(layout: TileLayout, leafId: string): TileLayout { + if (layout.maximized === leafId) return { ...layout, maximized: null }; + if (!findLeaf(layout.root, leafId)) return layout; + return { ...layout, maximized: leafId }; +} + +// ---------- serialize / deserialize ---------- + +export function serializeLayout(layout: TileLayout): SerializedLayout { + return { version: LAYOUT_VERSION, root: layout.root, maximized: layout.maximized }; +} + +const isStr = (v: unknown): v is string => typeof v === 'string' && v.length > 0; + +/** Rebuild a node from untrusted JSON: invalid tabs are dropped, an emptied + * leaf collapses, a split with one surviving child yields that child, ratios + * are re-clamped. Returns null for anything unusable. */ +function sanitizeNode(raw: unknown): TileNode | null { + if (!raw || typeof raw !== 'object') return null; + const n = raw as Record; + if (n.kind === 'leaf') { + if (!isStr(n.id) || !Array.isArray(n.tabs)) return null; + const tabs: TileTab[] = []; + for (const t of n.tabs) { + const tab = t as Record; + if (tab && isStr(tab.id) && isStr(tab.panel) && !tabs.some((x) => x.id === tab.id)) { + tabs.push({ id: tab.id, panel: tab.panel }); + } + } + if (!tabs.length) return null; + const active = isStr(n.active) && tabs.some((t) => t.id === n.active) ? n.active : tabs[0].id; + return { kind: 'leaf', id: n.id, tabs, active }; + } + if (n.kind === 'split') { + if (!isStr(n.id) || (n.dir !== 'row' && n.dir !== 'col')) return null; + const a = sanitizeNode(n.a); + const b = sanitizeNode(n.b); + if (!a && !b) return null; + if (!a) return b; + if (!b) return a; + const ratio = typeof n.ratio === 'number' && Number.isFinite(n.ratio) ? clampRatio(n.ratio) : 0.5; + return { kind: 'split', id: n.id, dir: n.dir, ratio, a, b }; + } + return null; +} + +/** Parse a persisted layout. Unknown versions and garbage return null — the + * caller decides the fallback (fresh default layout), nothing throws. */ +export function deserializeLayout(raw: unknown): TileLayout | null { + if (!raw || typeof raw !== 'object') return null; + const data = raw as Record; + if (data.version !== LAYOUT_VERSION) return null; + const root = sanitizeNode(data.root); + return fixMaximized({ root, maximized: isStr(data.maximized) ? data.maximized : null }); +} From 41f51bffd0585655a3ac3fa12920c4815729eed1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 18:52:41 +0000 Subject: [PATCH 2/7] Add app-data read/write commands for desktop shell state Co-authored-by: Gao Yu --- src-tauri/src/lib.rs | 64 +++++++++++++++++++++++++++++++++++++++++++- src/lib/protocol.ts | 9 +++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bcf9a24..308271e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -375,6 +375,55 @@ fn write_config(patch: serde_json::Value) -> Result<(), String> { write_json(&path, ¤t) } +/// App-data files owned by the desktop shell (workspaces / layout state). +/// Confined to a plain file name directly under the per-app config dir — +/// no separators, no dotfiles, so the frontend can't reach anything else. +fn valid_app_data_name(file: &str) -> bool { + !file.is_empty() + && file.len() <= 64 + && !file.starts_with('.') + && file + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') +} + +fn app_data_path(app: &AppHandle, file: &str) -> Result { + if !valid_app_data_name(file) { + return Err(format!("invalid app-data file name: {file}")); + } + let dir = app + .path() + .app_config_dir() + .map_err(|e| format!("app config dir unavailable: {e}"))?; + Ok(dir.join(file)) +} + +/// Reads a desktop app-data file; `None` when it doesn't exist yet. +#[tauri::command] +fn app_data_read(app: AppHandle, file: String) -> Result, String> { + let path = app_data_path(&app, &file)?; + match std::fs::read_to_string(&path) { + Ok(text) => Ok(Some(text)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(format!("读取 {} 失败:{e}", path.display())), + } +} + +/// Writes a desktop app-data file. Write-then-rename so a crash mid-write +/// can't leave a truncated file behind (the previous content survives). +#[tauri::command] +fn app_data_write(app: AppHandle, file: String, content: String) -> Result<(), String> { + let path = app_data_path(&app, &file)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let mut tmp = path.clone(); + tmp.set_file_name(format!("{file}.tmp")); + std::fs::write(&tmp, content.as_bytes()) + .map_err(|e| format!("写入 {} 失败:{e}", tmp.display()))?; + std::fs::rename(&tmp, &path).map_err(|e| format!("写入 {} 失败:{e}", path.display())) +} + /// Returns the provider names the user is authenticated with. JuCode is now /// an OAuth login (tokens live in the top-level `jucode` block, not the /// `providers` map), so it's reported as "jucode" whenever a refresh token @@ -2442,6 +2491,8 @@ pub fn run() { close_session, read_config, write_config, + app_data_read, + app_data_write, read_auth_providers, set_auth_key, remove_auth_key, @@ -2502,7 +2553,18 @@ pub fn run() { #[cfg(test)] mod tests { - use super::read_json_strict; + use super::{read_json_strict, valid_app_data_name}; + + #[test] + fn app_data_names_stay_inside_the_config_dir() { + assert!(valid_app_data_name("workspaces.json")); + assert!(valid_app_data_name("layout-v1.json")); + assert!(!valid_app_data_name("")); + assert!(!valid_app_data_name(".hidden")); + assert!(!valid_app_data_name("../auth.json")); + assert!(!valid_app_data_name("nested/file.json")); + assert!(!valid_app_data_name("back\\slash.json")); + } fn tmp(name: &str) -> std::path::PathBuf { let p = std::env::temp_dir().join(format!("jucode-test-{}-{}", std::process::id(), name)); diff --git a/src/lib/protocol.ts b/src/lib/protocol.ts index a6b8297..12e4e4d 100644 --- a/src/lib/protocol.ts +++ b/src/lib/protocol.ts @@ -101,6 +101,15 @@ export function writeConfig(patch: Record): Promise { export function readAuthProviders(): Promise { return invoke('read_auth_providers'); } +// Desktop app-data files (workspaces / layout), stored under the per-app +// config dir. Read resolves null when the file doesn't exist yet; write is +// atomic (write-then-rename) Rust-side. +export function appDataRead(file: string): Promise { + return invoke('app_data_read', { file }); +} +export function appDataWrite(file: string, content: string): Promise { + return invoke('app_data_write', { file, content }); +} export function setAuthKey(provider: string, key: string): Promise { return invoke('set_auth_key', { provider, key }); } From 75cb4f9ad5da20bb24884bddaf9f1f37a58430ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 18:52:41 +0000 Subject: [PATCH 3/7] Add workspace model persisted to app data with localStorage migration Co-authored-by: Gao Yu --- src/lib/workbench/workspaceStore.svelte.ts | 138 ++++++++++++++++++++ src/lib/workbench/workspaces.test.ts | 89 +++++++++++++ src/lib/workbench/workspaces.ts | 143 +++++++++++++++++++++ 3 files changed, 370 insertions(+) create mode 100644 src/lib/workbench/workspaceStore.svelte.ts create mode 100644 src/lib/workbench/workspaces.test.ts create mode 100644 src/lib/workbench/workspaces.ts diff --git a/src/lib/workbench/workspaceStore.svelte.ts b/src/lib/workbench/workspaceStore.svelte.ts new file mode 100644 index 0000000..00d72da --- /dev/null +++ b/src/lib/workbench/workspaceStore.svelte.ts @@ -0,0 +1,138 @@ +// Reactive workspace state + persistence. Workspaces (projects per workspace, +// dock tile layout, which workspace is active) live in one app-data file +// written through the Tauri layer — localStorage is only read once, to migrate +// pre-workspace installs. Writes are debounced and atomic Rust-side. + +import { appDataRead, appDataWrite } from '$lib/protocol'; +import type { SavedProject } from '$lib/session.svelte'; +import type { SerializedLayout } from './tiles'; +import { + createWorkspace, + defaultWorkspacesFile, + migrateLegacy, + parseWorkspacesFile, + serializeWorkspaces, + WORKSPACES_FILE, + type WorkspaceEntry, + type WorkspacesFile +} from './workspaces'; + +const SAVE_DELAY = 500; + +export class WorkspaceStore { + file = $state(null); + loaded = $state(false); + + /** Cleared when the on-disk file is unreadable (corrupt, or written by a + * newer app version): the session runs in memory and never clobbers it. */ + #writable = true; + #saveTimer: ReturnType | null = null; + + get workspaces(): WorkspaceEntry[] { + return this.file?.workspaces ?? []; + } + get activeId(): string { + return this.file?.active ?? ''; + } + get active(): WorkspaceEntry | null { + return this.workspaces.find((w) => w.id === this.activeId) ?? null; + } + + /** + * Load the workspaces file (or migrate the legacy localStorage layout, or + * seed a fresh default). Resolves to the active workspace. `defaultName` + * labels the workspace created when none exists yet. + */ + async load(defaultName: string): Promise { + let raw: string | null = null; + let readable = true; + try { + raw = await appDataRead(WORKSPACES_FILE); + } catch (e) { + // No Tauri backend (plain-browser dev) or IO failure: run in memory. + console.error('workspaces: read failed, running in-memory', e); + readable = false; + this.#writable = false; + } + if (raw != null) { + const parsed = parseWorkspacesFile(raw); + if (parsed) { + this.file = parsed; + this.loaded = true; + return this.active!; + } + // Present but unreadable — never overwrite it with a fresh file. + console.error('workspaces: existing file is unreadable, running in-memory'); + this.#writable = false; + } + const migrated = migrateLegacy((k) => { + try { + return localStorage.getItem(k); + } catch { + return null; + } + }, defaultName); + this.file = migrated ?? defaultWorkspacesFile(createWorkspace(defaultName)); + this.loaded = true; + // Only persist when the file was genuinely absent (fresh install or + // migration) — a failed read/parse above keeps the session in-memory. + if (raw == null && readable) this.#schedule(); + return this.active!; + } + + /** Replace the active workspace's saved projects (SessionStore.serialize). */ + updateProjects(projects: SavedProject[]) { + const ws = this.active; + if (!ws) return; + ws.projects = projects; + this.#schedule(); + } + + /** Replace the active workspace's dock tile layout. */ + updateLayout(layout: SerializedLayout | null) { + const ws = this.active; + if (!ws) return; + ws.layout = layout; + this.#schedule(); + } + + /** Mark `id` active; the caller swaps the live sessions. */ + setActive(id: string): WorkspaceEntry | null { + if (!this.file || !this.file.workspaces.some((w) => w.id === id)) return null; + this.file.active = id; + this.#schedule(); + return this.active; + } + + create(name: string): WorkspaceEntry | null { + if (!this.file) return null; + const ws = createWorkspace(name); + this.file.workspaces.push(ws); + this.#schedule(); + return ws; + } + + #schedule() { + if (!this.#writable) return; + if (this.#saveTimer != null) clearTimeout(this.#saveTimer); + this.#saveTimer = setTimeout(() => { + this.#saveTimer = null; + void this.flush(); + }, SAVE_DELAY); + } + + async flush() { + if (!this.#writable || !this.file) return; + if (this.#saveTimer != null) { + clearTimeout(this.#saveTimer); + this.#saveTimer = null; + } + try { + await appDataWrite(WORKSPACES_FILE, serializeWorkspaces($state.snapshot(this.file) as WorkspacesFile)); + } catch (e) { + console.error('workspaces: write failed', e); + } + } +} + +export const workspaces = new WorkspaceStore(); diff --git a/src/lib/workbench/workspaces.test.ts b/src/lib/workbench/workspaces.test.ts new file mode 100644 index 0000000..0ddf1f8 --- /dev/null +++ b/src/lib/workbench/workspaces.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { + createWorkspace, + defaultWorkspacesFile, + migrateLegacy, + parseLegacyDockTabs, + parseWorkspacesFile, + sanitizeProjects, + serializeWorkspaces, + LEGACY_DOCK_ACTIVE_KEY, + LEGACY_DOCK_TABS_KEY, + LEGACY_PROJECTS_KEY, + WORKSPACES_VERSION +} from './workspaces'; +import { deserializeLayout, leavesOf } from './tiles'; + +const proj = (id: string) => ({ id, name: id, path: `/tmp/${id}` }); + +describe('workspaces file', () => { + it('round-trips through serialize/parse', () => { + const ws = createWorkspace('工作台', [proj('p1'), proj('p2')]); + const file = defaultWorkspacesFile(ws); + const parsed = parseWorkspacesFile(serializeWorkspaces(file)); + expect(parsed).toEqual(file); + }); + + it('rejects unknown versions so a newer file is never clobbered', () => { + const file = defaultWorkspacesFile(createWorkspace('a')); + const newer = JSON.stringify({ ...file, version: WORKSPACES_VERSION + 1 }); + expect(parseWorkspacesFile(newer)).toBeNull(); + }); + + it('rejects garbage and files without a usable workspace', () => { + expect(parseWorkspacesFile('not json {')).toBeNull(); + expect(parseWorkspacesFile('{"version":1,"active":"x","workspaces":[]}')).toBeNull(); + expect(parseWorkspacesFile('{"version":1,"workspaces":[{"nope":true}]}')).toBeNull(); + }); + + it('falls back to the first workspace when active points nowhere', () => { + const file = defaultWorkspacesFile(createWorkspace('a')); + const parsed = parseWorkspacesFile(JSON.stringify({ ...file, active: 'missing' })); + expect(parsed?.active).toBe(file.workspaces[0].id); + }); + + it('drops structurally invalid projects but keeps optional fields', () => { + const good = { ...proj('p1'), lastBackend: 'codex', tabs: [{ sid: 's', title: 'T' }] }; + const projects = sanitizeProjects([good, { id: 'x' }, null, 'junk']); + expect(projects).toEqual([good]); + }); +}); + +describe('legacy migration', () => { + const store = (data: Record) => (key: string) => data[key] ?? null; + + it('wraps old projects and dock tabs into one default workspace', () => { + const legacy = store({ + [LEGACY_PROJECTS_KEY]: JSON.stringify([proj('p1')]), + [LEGACY_DOCK_TABS_KEY]: JSON.stringify([ + { id: 't1', panel: 'goal' }, + { id: 't2', panel: 'term' } + ]), + [LEGACY_DOCK_ACTIVE_KEY]: 't2' + }); + const file = migrateLegacy(legacy, '默认工作区'); + expect(file?.workspaces).toHaveLength(1); + const ws = file!.workspaces[0]; + expect(ws.name).toBe('默认工作区'); + expect(ws.projects).toEqual([proj('p1')]); + const layout = deserializeLayout(ws.layout); + const leaf = leavesOf(layout!.root)[0]; + expect(leaf.tabs.map((t) => t.panel)).toEqual(['goal', 'term']); + expect(leaf.active).toBe('t2'); + }); + + it('migrates the oldest dock format (bare panel strings)', () => { + const tabs = parseLegacyDockTabs(JSON.stringify(['plan', 'git', 'bogus-panel'])); + expect(tabs.map((t) => t.panel)).toEqual(['plan', 'git']); + }); + + it('tolerates corrupt legacy values', () => { + expect(parseLegacyDockTabs('{{{')).toEqual([]); + const file = migrateLegacy(store({ [LEGACY_PROJECTS_KEY]: '{{{' }), 'ws'); + expect(file?.workspaces[0].projects).toEqual([]); + }); + + it('returns null on a fresh install with no legacy keys', () => { + expect(migrateLegacy(store({}), 'ws')).toBeNull(); + }); +}); diff --git a/src/lib/workbench/workspaces.ts b/src/lib/workbench/workspaces.ts new file mode 100644 index 0000000..23e7de7 --- /dev/null +++ b/src/lib/workbench/workspaces.ts @@ -0,0 +1,143 @@ +// Workspace model + on-disk format. A workspace owns a set of projects (the +// existing SavedProject shape, unchanged) plus its dock tile layout; the file +// holds every workspace and which one is active. Pure data + pure transforms — +// the reactive store and the Tauri IO live in workspaceStore.svelte.ts. + +import type { SavedProject } from '$lib/session.svelte'; +import { serializeLayout, singleLeafLayout, type SerializedLayout, type TileTab } from './tiles'; + +export const WORKSPACES_FILE = 'workspaces.json'; +export const WORKSPACES_VERSION = 1; + +// Legacy localStorage keys this file replaces (read once for migration; never +// written again — app-data is the only source of truth for workspace/layout). +export const LEGACY_PROJECTS_KEY = 'jucode-projects'; +export const LEGACY_DOCK_TABS_KEY = 'jucode-dock-tabs'; +export const LEGACY_DOCK_ACTIVE_KEY = 'jucode-dock-active'; + +/** Dock panel kinds a tile tab may reference (RightDock keeps the icons). */ +export const DOCK_PANELS = ['plan', 'goal', 'changes', 'turns', 'files', 'git', 'term', 'browser', 'diag'] as const; + +export interface WorkspaceEntry { + id: string; + name: string; + projects: SavedProject[]; + /** Serialized dock tile layout; null until the user arranges one. */ + layout: SerializedLayout | null; +} + +export interface WorkspacesFile { + version: number; + /** Active workspace id (always one of `workspaces`). */ + active: string; + workspaces: WorkspaceEntry[]; +} + +let counter = 0; +const newId = () => `w${Date.now().toString(36)}-${(counter++).toString(36)}`; + +export function createWorkspace(name: string, projects: SavedProject[] = [], layout: SerializedLayout | null = null): WorkspaceEntry { + return { id: newId(), name, projects, layout }; +} + +export function defaultWorkspacesFile(first: WorkspaceEntry): WorkspacesFile { + return { version: WORKSPACES_VERSION, active: first.id, workspaces: [first] }; +} + +export function serializeWorkspaces(file: WorkspacesFile): string { + return JSON.stringify(file, null, '\t') + '\n'; +} + +const isStr = (v: unknown): v is string => typeof v === 'string' && v.length > 0; + +/** Keep only structurally valid saved projects (id/name/path present); the + * optional fields (tabs / worktree / lastBackend) ride along untouched — + * SessionStore.restore() already tolerates their absence. */ +export function sanitizeProjects(raw: unknown): SavedProject[] { + if (!Array.isArray(raw)) return []; + return raw.filter((p): p is SavedProject => { + const o = p as Record; + return !!o && isStr(o.id) && isStr(o.name) && isStr(o.path); + }); +} + +/** + * Parse the persisted workspaces file. Returns null for garbage, an unknown + * (newer) version, or a file without a single usable workspace — the caller + * must then fall back without overwriting what's on disk. + */ +export function parseWorkspacesFile(text: string): WorkspacesFile | null { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return null; + } + if (!raw || typeof raw !== 'object') return null; + const data = raw as Record; + if (data.version !== WORKSPACES_VERSION || !Array.isArray(data.workspaces)) return null; + const workspaces: WorkspaceEntry[] = []; + for (const w of data.workspaces) { + const o = w as Record; + if (!o || !isStr(o.id) || !isStr(o.name)) continue; + workspaces.push({ + id: o.id, + name: o.name, + projects: sanitizeProjects(o.projects), + // Layout blobs are validated lazily by tiles.deserializeLayout at use. + layout: o.layout && typeof o.layout === 'object' ? (o.layout as SerializedLayout) : null + }); + } + if (!workspaces.length) return null; + const active = isStr(data.active) && workspaces.some((w) => w.id === data.active) ? data.active : workspaces[0].id; + return { version: WORKSPACES_VERSION, active, workspaces }; +} + +/** Tolerant parse of the legacy dock-tabs value: bare panel strings (oldest + * format) and {id, panel} objects, filtered to known panel kinds. */ +export function parseLegacyDockTabs(raw: string | null): TileTab[] { + if (!raw) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + let n = 0; + const tabs: TileTab[] = []; + for (const item of parsed) { + const o = item as Record; + const tab = + typeof item === 'string' + ? { id: `m${n++}`, panel: item } + : o && isStr(o.id) && isStr(o.panel) + ? { id: o.id, panel: o.panel } + : null; + if (tab && (DOCK_PANELS as readonly string[]).includes(tab.panel) && !tabs.some((t) => t.id === tab.id)) { + tabs.push(tab); + } + } + return tabs; +} + +/** + * One-time migration of the pre-workspace localStorage state (project layout + + * dock tabs) into a single default workspace. Returns null when there is no + * legacy data at all (genuinely fresh install). + */ +export function migrateLegacy(read: (key: string) => string | null, name: string): WorkspacesFile | null { + const projectsRaw = read(LEGACY_PROJECTS_KEY); + const dockRaw = read(LEGACY_DOCK_TABS_KEY); + if (projectsRaw == null && dockRaw == null) return null; + let projects: SavedProject[] = []; + try { + projects = sanitizeProjects(JSON.parse(projectsRaw || '[]')); + } catch { + projects = []; + } + const tabs = parseLegacyDockTabs(dockRaw); + const active = read(LEGACY_DOCK_ACTIVE_KEY); + const layout = tabs.length ? serializeLayout(singleLeafLayout(tabs, active ?? undefined)) : null; + return defaultWorkspacesFile(createWorkspace(name, projects, layout)); +} From f3de9ba1a0a8c0383ed747414efbef2f9ba3c6eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 18:52:49 +0000 Subject: [PATCH 4/7] Wire workspaces into the shell with a sidebar switcher Co-authored-by: Gao Yu --- src/lib/Sidebar.svelte | 147 ++++++++++++++++++++++++++++++++- src/lib/i18n/messages/shell.ts | 18 ++++ src/routes/+page.svelte | 82 ++++++++++++------ 3 files changed, 220 insertions(+), 27 deletions(-) diff --git a/src/lib/Sidebar.svelte b/src/lib/Sidebar.svelte index 7891b79..f7a8453 100644 --- a/src/lib/Sidebar.svelte +++ b/src/lib/Sidebar.svelte @@ -1,5 +1,5 @@ +{#snippet panelBody(kind: string)} + {#if kind === 'plan'} + {:else if kind === 'goal'} + {:else if kind === 'changes'} + {:else if kind === 'turns'} + {:else if kind === 'files'} + {:else if kind === 'git'} + {:else if kind === 'term'} + {:else if kind === 'browser'} + {:else if kind === 'diag'}{/if} +{/snippet} + +{#if prefs.mosaic} +
+ ({ key: p.key, label: labelOf(p.key) }))} + onAdd={mosaicAdd} + emptyText={t('dock.dock.empty')} + > + {#snippet panel(tab)} + {@render panelBody(tab.panel)} + {/snippet} + +
+{:else}
@@ -232,15 +311,7 @@
{#each visibleTabs as tab (tab.id)}
- {#if tab.panel === 'plan'} - {:else if tab.panel === 'goal'} - {:else if tab.panel === 'changes'} - {:else if tab.panel === 'turns'} - {:else if tab.panel === 'files'} - {:else if tab.panel === 'git'} - {:else if tab.panel === 'term'} - {:else if tab.panel === 'browser'} - {:else if tab.panel === 'diag'}{/if} + {@render panelBody(tab.panel)}
{/each} {#if visibleTabs.length === 0} @@ -259,6 +330,7 @@ {/if}
+{/if} diff --git a/src/routes/workbench/+page.svelte b/src/routes/workbench/+page.svelte new file mode 100644 index 0000000..9fc8c19 --- /dev/null +++ b/src/routes/workbench/+page.svelte @@ -0,0 +1,157 @@ + + +Workbench demo + +
+
+
+

Mosaic workbench

+

+ A hand-rolled binary tile tree. Drag tabs between panels (edges split, center stacks), + drag the dividers to resize, double-click a tab bar to maximize. +

+
+
+ + +
+
+ +
+ (layout = l)} + label={(tab: TileTab) => KINDS[tab.panel]?.label ?? tab.panel} + {addOptions} + onAdd={(leafId, key) => (layout = openTab(layout, leafId, { id: `d${seq++}`, panel: key }))} + emptyText="Nothing open. Add a panel to start." + > + {#snippet panel(tab)} +
+

{KINDS[tab.panel]?.body ?? tab.panel}

+ tab id: {tab.id} +
+ {/snippet} +
+
+
+ + From 115c42aac846a1f1aa86502ad3b1f412c9386638 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 18:52:49 +0000 Subject: [PATCH 6/7] Repurpose cmd-E to audit the current change instead of opening an editor Co-authored-by: Gao Yu --- src/lib/i18n/messages/editor.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/lib/i18n/messages/editor.ts b/src/lib/i18n/messages/editor.ts index bb3c11b..e853dfc 100644 --- a/src/lib/i18n/messages/editor.ts +++ b/src/lib/i18n/messages/editor.ts @@ -2,15 +2,15 @@ // affordances in FilesPanel / ChangesPanel. const editor = { zh: { - title: '编辑器', - openInEditor: '在编辑器中打开', + title: '审阅', + openInEditor: '打开审阅', save: '保存', saveAll: '全部保存', saved: '已保存', closeTab: '关闭标签', - closePane: '收起编辑器(⌘E)', + closePane: '收起审阅面板(⌘E)', empty: '没有打开的文件', - emptyHint: '在文件面板点击文件,或按 ⌘P 快速打开', + emptyHint: '在「改动」面板点击文件查看,或按 ⌘P 快速打开', unsavedTitle: '未保存的修改', unsavedClose: '「{name}」有未保存的修改,关闭将丢弃这些修改。确定关闭?', dirtyProjectConfirm: '该项目在编辑器中还有未保存的文件,关闭项目将丢弃这些修改。继续?', @@ -28,15 +28,15 @@ const editor = { utf8: 'UTF-8' }, en: { - title: 'Editor', - openInEditor: 'Open in editor', + title: 'Audit', + openInEditor: 'Open in audit view', save: 'Save', saveAll: 'Save all', saved: 'Saved', closeTab: 'Close tab', - closePane: 'Hide editor (⌘E)', + closePane: 'Hide audit pane (⌘E)', empty: 'No file open', - emptyHint: 'Click a file in the Files panel, or press ⌘P to quick-open', + emptyHint: 'Click a file in the Changes panel to review it, or press ⌘P to quick-open', unsavedTitle: 'Unsaved changes', unsavedClose: '"{name}" has unsaved changes. Closing will discard them. Close anyway?', dirtyProjectConfirm: From 69bdb0b1f34e6da339daf73982af7bdef16a4f04 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 18:57:00 +0000 Subject: [PATCH 7/7] Load workspaces before event wiring; keep tab bars unselectable Co-authored-by: Gao Yu --- src/lib/workbench/Mosaic.svelte | 2 ++ src/routes/+page.svelte | 13 +++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/lib/workbench/Mosaic.svelte b/src/lib/workbench/Mosaic.svelte index b83e5ca..09b586a 100644 --- a/src/lib/workbench/Mosaic.svelte +++ b/src/lib/workbench/Mosaic.svelte @@ -303,6 +303,8 @@ padding: 6px 6px 5px; border-bottom: 1px solid var(--hairline); flex-shrink: 0; + /* dblclick maximizes — don't let it select panel text instead */ + user-select: none; } .ltabs { display: flex; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 36d1536..22876c3 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1102,6 +1102,10 @@ const cleanups: Array<() => void> = []; let disposed = false; (async () => { + // Load the workspaces file first (migrating a pre-workspace + // localStorage layout on first run): it has no dependency on the + // event listeners below, and the sidebar switcher can show early. + const wsEntry = await workspaces.load(t('shell.workspace.default')); const unlisten = await listen('agent-event', (e) => { const s = sessionMap.get(e.payload.session); if (!s) return; @@ -1188,12 +1192,9 @@ cleanups.forEach((f) => f()); return; } - // Load the workspaces file (migrating a pre-workspace localStorage - // layout on first run), then restore the active workspace's projects - // + their open conversations (resume by id), or seed a default - // project on first run. - const entry = await workspaces.load(t('shell.workspace.default')); - await store.restore(entry.projects); + // Restore the active workspace's projects + their open conversations + // (resume by id), or seed a default project on first run. + await store.restore(wsEntry.projects); // 深链在项目恢复完成后再注册,冷启动携带的链接(onOpenUrl 会补发当前 // 深链)才能作用于已恢复的项目列表。 const undeep = await onOpenUrl((urls) => {