From 633ab63f36c89308755befa48bb9c26b36182d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 29 May 2026 11:41:41 +0800 Subject: [PATCH] feat(desktop): add browserPanelStore with history/loading/picker state Per-session in-app mini-browser state: current URL, back/forward history stack (historyIndex with forward-truncation on navigate), loading flag, and element-picker toggle. Co-Authored-By: Claude Opus 4.8 (1M context) --- desktop/src/stores/browserPanelStore.test.ts | 51 +++++++++++++++ desktop/src/stores/browserPanelStore.ts | 67 ++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 desktop/src/stores/browserPanelStore.test.ts create mode 100644 desktop/src/stores/browserPanelStore.ts diff --git a/desktop/src/stores/browserPanelStore.test.ts b/desktop/src/stores/browserPanelStore.test.ts new file mode 100644 index 00000000..fb702ab9 --- /dev/null +++ b/desktop/src/stores/browserPanelStore.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { useBrowserPanelStore } from './browserPanelStore' + +const reset = () => useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true) + +describe('browserPanelStore', () => { + beforeEach(reset) + + it('opens a session at a url and records history', () => { + useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') + const s = useBrowserPanelStore.getState().bySession['s1']! + expect(s.url).toBe('http://localhost:5173/') + expect(s.isOpen).toBe(true) + expect(s.history).toEqual(['http://localhost:5173/']) + expect(s.historyIndex).toBe(0) + expect(s.canGoBack).toBe(false) + }) + + it('navigate pushes history and truncates forward entries', () => { + const st = useBrowserPanelStore.getState() + st.open('s1', 'http://localhost/a') + st.navigate('s1', 'http://localhost/b') + st.navigate('s1', 'http://localhost/c') + st.goBack('s1') + st.navigate('s1', 'http://localhost/d') // 截断 c + const s = useBrowserPanelStore.getState().bySession['s1']! + expect(s.history).toEqual(['http://localhost/a', 'http://localhost/b', 'http://localhost/d']) + expect(s.url).toBe('http://localhost/d') + expect(s.canGoForward).toBe(false) + }) + + it('goBack/goForward move within history without mutating it', () => { + const st = useBrowserPanelStore.getState() + st.open('s1', 'http://localhost/a') + st.navigate('s1', 'http://localhost/b') + st.goBack('s1') + expect(useBrowserPanelStore.getState().bySession['s1']!.url).toBe('http://localhost/a') + expect(useBrowserPanelStore.getState().bySession['s1']!.canGoForward).toBe(true) + st.goForward('s1') + expect(useBrowserPanelStore.getState().bySession['s1']!.url).toBe('http://localhost/b') + }) + + it('tracks loading and picker per session', () => { + const st = useBrowserPanelStore.getState() + st.open('s1', 'http://localhost/a') + st.setLoading('s1', true) + expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true) + st.setPicker('s1', true) + expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(true) + }) +}) diff --git a/desktop/src/stores/browserPanelStore.ts b/desktop/src/stores/browserPanelStore.ts new file mode 100644 index 00000000..2f4a059b --- /dev/null +++ b/desktop/src/stores/browserPanelStore.ts @@ -0,0 +1,67 @@ +import { create } from 'zustand' + +export type BrowserSessionState = { + isOpen: boolean + url: string + history: string[] + historyIndex: number + loading: boolean + pickerActive: boolean + canGoBack: boolean + canGoForward: boolean +} + +type BrowserPanelState = { + bySession: Record + open: (sessionId: string, url: string) => void + navigate: (sessionId: string, url: string) => void + goBack: (sessionId: string) => void + goForward: (sessionId: string) => void + setLoading: (sessionId: string, loading: boolean) => void + setPicker: (sessionId: string, active: boolean) => void + close: (sessionId: string) => void +} + +const empty = (url: string): BrowserSessionState => ({ + isOpen: true, url, history: [url], historyIndex: 0, + loading: false, pickerActive: false, canGoBack: false, canGoForward: false, +}) + +const withNav = (s: BrowserSessionState): BrowserSessionState => ({ + ...s, + url: s.history[s.historyIndex]!, + canGoBack: s.historyIndex > 0, + canGoForward: s.historyIndex < s.history.length - 1, +}) + +export const useBrowserPanelStore = create((set) => ({ + bySession: {}, + open: (sessionId, url) => set((st) => ({ + bySession: { ...st.bySession, [sessionId]: empty(url) }, + })), + navigate: (sessionId, url) => set((st) => { + const cur = st.bySession[sessionId] ?? empty(url) + const history = [...cur.history.slice(0, cur.historyIndex + 1), url] + return { bySession: { ...st.bySession, [sessionId]: withNav({ ...cur, isOpen: true, history, historyIndex: history.length - 1 }) } } + }), + goBack: (sessionId) => set((st) => { + const cur = st.bySession[sessionId]; if (!cur || cur.historyIndex <= 0) return st + return { bySession: { ...st.bySession, [sessionId]: withNav({ ...cur, historyIndex: cur.historyIndex - 1 }) } } + }), + goForward: (sessionId) => set((st) => { + const cur = st.bySession[sessionId]; if (!cur || cur.historyIndex >= cur.history.length - 1) return st + return { bySession: { ...st.bySession, [sessionId]: withNav({ ...cur, historyIndex: cur.historyIndex + 1 }) } } + }), + setLoading: (sessionId, loading) => set((st) => { + const cur = st.bySession[sessionId]; if (!cur) return st + return { bySession: { ...st.bySession, [sessionId]: { ...cur, loading } } } + }), + setPicker: (sessionId, active) => set((st) => { + const cur = st.bySession[sessionId]; if (!cur) return st + return { bySession: { ...st.bySession, [sessionId]: { ...cur, pickerActive: active } } } + }), + close: (sessionId) => set((st) => { + const cur = st.bySession[sessionId]; if (!cur) return st + return { bySession: { ...st.bySession, [sessionId]: { ...cur, isOpen: false, pickerActive: false } } } + }), +}))