diff --git a/apps/desktop2/src/components/main/sidebar/timeline/index.tsx b/apps/desktop2/src/components/main/sidebar/timeline/index.tsx index b559730ba3..62705d3984 100644 --- a/apps/desktop2/src/components/main/sidebar/timeline/index.tsx +++ b/apps/desktop2/src/components/main/sidebar/timeline/index.tsx @@ -74,7 +74,8 @@ export function TimelineView() { variant="outline" onClick={scrollToToday} className={clsx([ - "flex items-center gap-1", + "group", + "relative", "absolute left-1/2 transform -translate-x-1/2", "bg-white hover:bg-gray-50", "border border-gray-200", @@ -83,8 +84,17 @@ export function TimelineView() { isScrolledPastToday ? "top-2" : "bottom-2", ])} > - - Go to Today +
+ + Go to Today +
+ )} diff --git a/apps/desktop2/src/devtool/index.tsx b/apps/desktop2/src/devtool/index.tsx index 2b0b44151d..aef3c661b0 100644 --- a/apps/desktop2/src/devtool/index.tsx +++ b/apps/desktop2/src/devtool/index.tsx @@ -1,9 +1,10 @@ +import { Link } from "@tanstack/react-router"; import { useCallback, useEffect, useRef, useState } from "react"; import { useStores } from "tinybase/ui-react"; import { cn } from "@hypr/ui/lib/utils"; import { METRICS, type Store as PersistedStore, STORE_ID as STORE_ID_PERSISTED, UI } from "../store/tinybase/persisted"; -import { type SeedDefinition, seeds } from "./seed/index"; +import { SeedDefinition, seeds } from "./seed/index"; declare global { interface Window { @@ -16,7 +17,6 @@ declare global { export function Devtool() { const [open, setOpen] = useState(false); - const [lastSeedId, setLastSeedId] = useState(null); const autoSeedRef = useRef(false); const stores = useStores(); @@ -43,7 +43,6 @@ export function Devtool() { } autoSeedRef.current = true; seed.run(persistedStore); - setLastSeedId(seed.id); }, [humansCount, persistedStore]); useEffect(() => { @@ -58,7 +57,6 @@ export function Devtool() { const target = id ? seeds.find(item => item.id === id) : seeds[0]; if (target) { target.run(persistedStore); - setLastSeedId(target.id); } }, seeds: seeds.map(({ id, label }) => ({ id, label })), @@ -81,7 +79,6 @@ export function Devtool() { return; } seed.run(persistedStore); - setLastSeedId(seed.id); }, [persistedStore], ); @@ -97,7 +94,7 @@ export function Devtool() { return ( <> - + ); } @@ -124,12 +121,7 @@ function DevtoolTrigger({ open, onToggle }: { ); } -function DevtoolDrawer({ open, lastSeedId, humansCount, onSeed }: { - open: boolean; - lastSeedId: string | null; - humansCount: number; - onSeed: (seed: SeedDefinition) => void; -}) { +function DevtoolDrawer({ open, onSeed }: { open: boolean; onSeed: (seed: SeedDefinition) => void }) { return ( ); } + +function DevtoolSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function NavigationList() { + return ( + + + + ); +} + +function SeedList({ onSeed }: { onSeed: (seed: SeedDefinition) => void }) { + return ( + +
+ {seeds.map(seed => onSeed(seed)} />)} +
+
+ ); +} + +function SeedButton({ seed, onClick }: { seed: SeedDefinition; onClick: () => void }) { + return ( + + ); +} diff --git a/apps/desktop2/src/routes/app/main/_layout.tsx b/apps/desktop2/src/routes/app/main/_layout.tsx index e2e7d9a0d7..fff694ca5c 100644 --- a/apps/desktop2/src/routes/app/main/_layout.tsx +++ b/apps/desktop2/src/routes/app/main/_layout.tsx @@ -17,6 +17,22 @@ export const Route = createFileRoute("/app/main/_layout")({ function Component() { const { persistedStore } = useRouteContext({ from: "__root__" }); + const { registerOnClose } = useTabs(); + + useEffect(() => { + return registerOnClose((tab) => { + if (tab.type === "sessions" && persistedStore) { + const row = persistedStore.getRow("sessions", tab.id); + if (!row) { + return; + } + + if (!row.title && !row.raw_md && !row.enhanced_md) { + persistedStore.delRow("sessions", tab.id); + } + } + }); + }, [persistedStore, registerOnClose]); return ( diff --git a/apps/desktop2/src/store/zustand/tabs.ts b/apps/desktop2/src/store/zustand/tabs.ts deleted file mode 100644 index 2523fce277..0000000000 --- a/apps/desktop2/src/store/zustand/tabs.ts +++ /dev/null @@ -1,369 +0,0 @@ -import { z } from "zod"; -import { create } from "zustand"; - -import { TABLES } from "../tinybase/persisted"; - -type TabHistory = { - stack: Tab[]; - currentIndex: number; -}; - -type State = { - currentTab: Tab | null; - tabs: Tab[]; - history: Map; - canGoBack: boolean; - canGoNext: boolean; -}; - -type Actions = - & TabUpdater - & TabStateUpdater - & TabNavigator; - -type TabUpdater = { - setTabs: (tabs: Tab[]) => void; - openCurrent: (tab: Tab) => void; - openNew: (tab: Tab) => void; - select: (tab: Tab) => void; - close: (tab: Tab) => void; - reorder: (tabs: Tab[]) => void; -}; - -type TabStateUpdater = { - updateContactsTabState: (tab: Tab, state: Extract["state"]) => void; - updateSessionTabState: (tab: Tab, state: Extract["state"]) => void; -}; - -type TabNavigator = { - goBack: () => void; - goNext: () => void; -}; - -type Store = State & Actions; - -const ACTIVE_TAB_SLOT_ID = "active-tab-history"; - -const getSlotId = (tab: Tab): string => { - return tab.active ? ACTIVE_TAB_SLOT_ID : `inactive-${uniqueIdfromTab(tab)}`; -}; - -const computeHistoryFlags = ( - history: Map, - currentTab: Tab | null, -): { canGoBack: boolean; canGoNext: boolean } => { - if (!currentTab) { - return { canGoBack: false, canGoNext: false }; - } - const slotId = getSlotId(currentTab); - const tabHistory = history.get(slotId); - if (!tabHistory) { - return { canGoBack: false, canGoNext: false }; - } - return { - canGoBack: tabHistory.currentIndex > 0, - canGoNext: tabHistory.currentIndex < tabHistory.stack.length - 1, - }; -}; - -const pushHistory = (history: Map, tab: Tab): Map => { - const newHistory = new Map(history); - const slotId = getSlotId(tab); - const existing = newHistory.get(slotId); - - if (existing) { - const newStack = existing.stack.slice(0, existing.currentIndex + 1); - newStack.push(tab); - newHistory.set(slotId, { stack: newStack, currentIndex: newStack.length - 1 }); - } else { - newHistory.set(slotId, { stack: [tab], currentIndex: 0 }); - } - - return newHistory; -}; - -const updateHistoryCurrent = (history: Map, tab: Tab): Map => { - const newHistory = new Map(history); - const slotId = getSlotId(tab); - const existing = newHistory.get(slotId); - - if (existing && existing.currentIndex >= 0) { - const newStack = [...existing.stack]; - newStack[existing.currentIndex] = tab; - newHistory.set(slotId, { ...existing, stack: newStack }); - } - - return newHistory; -}; - -export const useTabs = create((set, get, _store) => ({ - currentTab: null, - tabs: [], - history: new Map(), - canGoBack: false, - canGoNext: false, - setTabs: (tabs) => { - const tabsWithDefaults = tabs.map(t => tabSchema.parse(t)); - const currentTab = tabsWithDefaults.find((t) => t.active) || null; - const history = new Map(); - - tabsWithDefaults.forEach((tab) => { - if (tab.active) { - history.set(getSlotId(tab), { stack: [tab], currentIndex: 0 }); - } - }); - - const flags = computeHistoryFlags(history, currentTab); - set({ tabs: tabsWithDefaults, currentTab, history, ...flags }); - }, - openCurrent: (newTab) => { - const { tabs, history } = get(); - const tabWithDefaults = tabSchema.parse(newTab); - const activeTab = { ...tabWithDefaults, active: true }; - const existingTabIdx = tabs.findIndex((t) => t.active); - - const nextTabs = existingTabIdx === -1 - ? tabs - .filter((t) => !isSameTab(t, tabWithDefaults)) - .map((t) => ({ ...t, active: false })) - .concat([activeTab]) - : tabs - .map((t, idx) => - idx === existingTabIdx - ? activeTab - : isSameTab(t, tabWithDefaults) - ? null - : { ...t, active: false } - ) - .filter((t): t is Tab => t !== null); - - const nextHistory = pushHistory(history, activeTab); - const flags = computeHistoryFlags(nextHistory, activeTab); - set({ tabs: nextTabs, currentTab: activeTab, history: nextHistory, ...flags }); - }, - openNew: (tab) => { - const { tabs, history } = get(); - const tabWithDefaults = tabSchema.parse(tab); - const activeTab = { ...tabWithDefaults, active: true }; - const nextTabs = tabs - .filter((t) => !isSameTab(t, tabWithDefaults)) - .map((t) => ({ ...t, active: false })) - .concat([activeTab]); - const nextHistory = pushHistory(history, activeTab); - const flags = computeHistoryFlags(nextHistory, activeTab); - set({ tabs: nextTabs, currentTab: activeTab, history: nextHistory, ...flags }); - }, - select: (tab) => { - const { tabs, history } = get(); - const nextTabs = tabs.map((t) => ({ ...t, active: isSameTab(t, tab) })); - const flags = computeHistoryFlags(history, tab); - set({ tabs: nextTabs, currentTab: tab, ...flags }); - }, - close: (tab) => { - const { tabs, history } = get(); - const remainingTabs = tabs.filter((t) => !isSameTab(t, tab)); - - if (remainingTabs.length === 0) { - return set({ tabs: [], currentTab: null, canGoBack: false, canGoNext: false }); - } - - const closedTabIndex = tabs.findIndex((t) => isSameTab(t, tab)); - const nextActiveIndex = closedTabIndex < remainingTabs.length - ? closedTabIndex - : remainingTabs.length - 1; - - const nextTabs = remainingTabs.map((t, idx) => ({ ...t, active: idx === nextActiveIndex })); - const nextCurrentTab = nextTabs[nextActiveIndex]; - - const nextHistory = new Map(history); - nextHistory.delete(getSlotId(tab)); - - const flags = computeHistoryFlags(nextHistory, nextCurrentTab); - set({ tabs: nextTabs, currentTab: nextCurrentTab, history: nextHistory, ...flags }); - }, - reorder: (tabs) => { - const { history } = get(); - const currentTab = tabs.find((t) => t.active) || null; - const flags = computeHistoryFlags(history, currentTab); - set({ tabs, currentTab, ...flags }); - }, - updateSessionTabState: (tab, state) => { - const { tabs, currentTab, history } = get(); - const nextTabs = tabs.map((t) => - isSameTab(t, tab) && t.type === "sessions" - ? { ...t, state } - : t - ); - const nextCurrentTab = currentTab && isSameTab(currentTab, tab) && currentTab.type === "sessions" - ? { ...currentTab, state } - : currentTab; - - const nextHistory = nextCurrentTab && isSameTab(nextCurrentTab, tab) - ? updateHistoryCurrent(history, nextCurrentTab) - : history; - - set({ tabs: nextTabs, currentTab: nextCurrentTab, history: nextHistory }); - }, - updateContactsTabState: (tab, state) => { - const { tabs, currentTab, history } = get(); - const nextTabs = tabs.map((t) => - isSameTab(t, tab) && t.type === "contacts" - ? { ...t, state } - : t - ); - - const nextCurrentTab = currentTab && isSameTab(currentTab, tab) && currentTab.type === "contacts" - ? { ...currentTab, state } - : currentTab; - - const nextHistory = nextCurrentTab && isSameTab(nextCurrentTab, tab) - ? updateHistoryCurrent(history, nextCurrentTab) - : history; - - set({ tabs: nextTabs, currentTab: nextCurrentTab, history: nextHistory }); - }, - goBack: () => { - const { tabs, history, currentTab } = get(); - if (!currentTab) { - return; - } - - const slotId = getSlotId(currentTab); - const tabHistory = history.get(slotId); - if (!tabHistory || tabHistory.currentIndex === 0) { - return; - } - - const prevIndex = tabHistory.currentIndex - 1; - const prevTab = tabHistory.stack[prevIndex]; - - const nextTabs = tabs.map((t) => t.active ? prevTab : t); - - const nextHistory = new Map(history); - nextHistory.set(slotId, { ...tabHistory, currentIndex: prevIndex }); - - const flags = computeHistoryFlags(nextHistory, prevTab); - set({ tabs: nextTabs, currentTab: prevTab, history: nextHistory, ...flags }); - }, - goNext: () => { - const { tabs, history, currentTab } = get(); - if (!currentTab) { - return; - } - - const slotId = getSlotId(currentTab); - const tabHistory = history.get(slotId); - if (!tabHistory || tabHistory.currentIndex >= tabHistory.stack.length - 1) { - return; - } - - const nextIndex = tabHistory.currentIndex + 1; - const nextTab = tabHistory.stack[nextIndex]; - - const nextTabs = tabs.map((t) => t.active ? nextTab : t); - - const nextHistory = new Map(history); - nextHistory.set(slotId, { ...tabHistory, currentIndex: nextIndex }); - - const flags = computeHistoryFlags(nextHistory, nextTab); - set({ tabs: nextTabs, currentTab: nextTab, history: nextHistory, ...flags }); - }, -})); - -const baseTabSchema = z.object({ - active: z.boolean(), -}); - -export const tabSchema = z.discriminatedUnion("type", [ - baseTabSchema.extend({ - type: z.literal("sessions" satisfies typeof TABLES[number]), - id: z.string(), - state: z.object({ - editor: z.enum(["raw", "enhanced", "transcript"]).default("raw"), - }).default({ editor: "raw" }), - }), - baseTabSchema.extend({ - type: z.literal("contacts"), - state: z.object({ - selectedOrganization: z.string().nullable().default(null), - selectedPerson: z.string().nullable().default(null), - }).default({ - selectedOrganization: null, - selectedPerson: null, - }), - }), - baseTabSchema.extend({ - type: z.literal("events" satisfies typeof TABLES[number]), - id: z.string(), - }), - baseTabSchema.extend({ - type: z.literal("humans" satisfies typeof TABLES[number]), - id: z.string(), - }), - baseTabSchema.extend({ - type: z.literal("organizations" satisfies typeof TABLES[number]), - id: z.string(), - }), - baseTabSchema.extend({ - type: z.literal("folders" satisfies typeof TABLES[number]), - id: z.string().nullable(), - }), - - baseTabSchema.extend({ - type: z.literal("calendars"), - month: z.coerce.date(), - }), - baseTabSchema.extend({ - type: z.literal("daily"), - date: z.coerce.date(), - }), -]); - -export type Tab = z.infer; - -export const rowIdfromTab = (tab: Tab): string => { - switch (tab.type) { - case "sessions": - return tab.id; - case "events": - return tab.id; - case "humans": - return tab.id; - case "organizations": - return tab.id; - case "calendars": - case "contacts": - case "daily": - throw new Error("invalid_resource"); - case "folders": - if (!tab.id) { - throw new Error("invalid_resource"); - } - return tab.id; - } -}; - -export const uniqueIdfromTab = (tab: Tab): string => { - switch (tab.type) { - case "sessions": - return `sessions-${tab.id}`; - case "events": - return `events-${tab.id}`; - case "humans": - return `humans-${tab.id}`; - case "organizations": - return `organizations-${tab.id}`; - case "calendars": - return `calendars-${tab.month.getFullYear()}-${tab.month.getMonth()}`; - case "contacts": - return `contacts`; - case "daily": - return `daily-${tab.date.getFullYear()}-${tab.date.getMonth()}-${tab.date.getDate()}`; - case "folders": - return `folders-${tab.id ?? "all"}`; - } -}; - -export const isSameTab = (a: Tab, b: Tab) => { - return uniqueIdfromTab(a) === uniqueIdfromTab(b); -}; diff --git a/apps/desktop2/src/store/zustand/tabs/basic.test.ts b/apps/desktop2/src/store/zustand/tabs/basic.test.ts new file mode 100644 index 0000000000..f9b0ed73fe --- /dev/null +++ b/apps/desktop2/src/store/zustand/tabs/basic.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { type Tab, useTabs } from "."; +import { createContactsTab, createSessionTab, resetTabsStore } from "./test-utils"; + +const isSessionsTab = (tab: Tab): tab is Extract => tab.type === "sessions"; + +const expectTabs = (tabs: Tab[], expected: Array>) => { + expect(tabs).toHaveLength(expected.length); + expected.forEach((partial, index) => { + expect(tabs[index]).toMatchObject(partial); + }); +}; + +describe("Basic Tab Actions", () => { + beforeEach(() => { + resetTabsStore(); + }); + + test("setTabs normalizes input, sets current and history flags", () => { + const rawTabs = [ + createSessionTab({ id: "a", active: false }), + createSessionTab({ id: "b", active: true, state: { editor: "enhanced" } }), + createContactsTab({ active: false }), + ]; + + useTabs.getState().setTabs(rawTabs); + + const state = useTabs.getState(); + expect(state.currentTab).toMatchObject({ id: "b", active: true }); + expectTabs(state.tabs, [ + { id: "a", active: false }, + { id: "b", active: true }, + { type: "contacts", active: false }, + ]); + expect(state.history.get("active-tab-history")?.stack).toHaveLength(1); + expect(state.canGoBack).toBe(false); + expect(state.canGoNext).toBe(false); + }); + + test("openCurrent replaces duplicates of same tab and activates new instance", () => { + const duplicateA = createSessionTab({ id: "target", active: false }); + const duplicateB = createSessionTab({ id: "target", active: false }); + const other = createSessionTab({ id: "other", active: false }); + useTabs.getState().setTabs([duplicateA, duplicateB, other]); + + const newActive = createSessionTab({ id: "target", active: false }); + useTabs.getState().openCurrent(newActive); + + const state = useTabs.getState(); + expectTabs(state.tabs, [ + { id: "other", active: false }, + { id: "target", active: true }, + ]); + const stack = state.history.get("active-tab-history")?.stack; + expect(stack && stack[stack.length - 1]).toMatchObject({ id: "target" }); + expect(state.canGoBack).toBe(false); + }); + + test("openCurrent closes existing active tab via lifecycle handlers", () => { + const handler = vi.fn(); + const active = createSessionTab({ id: "first", active: false }); + useTabs.getState().registerOnClose(handler); + useTabs.getState().openCurrent(active); + + const next = createSessionTab({ id: "second", active: false }); + useTabs.getState().openCurrent(next); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "first" })); + }); + + test("openNew appends unique active tab and closes duplicates", () => { + const duplicate = createSessionTab({ id: "dup", active: false }); + const handler = vi.fn(); + useTabs.getState().registerOnClose(handler); + useTabs.getState().setTabs([duplicate]); + + useTabs.getState().openNew(createSessionTab({ id: "dup", active: false })); + + const state = useTabs.getState(); + expectTabs(state.tabs, [ + { id: "dup", active: true }, + ]); + expect(handler).toHaveBeenCalledTimes(1); + expect(state.history.get("active-tab-history")?.stack).toHaveLength(1); + }); + + test("select toggles active flag without changing history", () => { + const tabA = createSessionTab({ id: "a", active: true }); + const tabB = createSessionTab({ id: "b", active: false }); + useTabs.getState().setTabs([tabA, tabB]); + + useTabs.getState().select(tabB); + + const state = useTabs.getState(); + if (!state.currentTab || !isSessionsTab(state.currentTab)) { + throw new Error("expected current tab to be a sessions tab"); + } + expect(state.currentTab.id).toBe("b"); + const target = state.tabs.find((t) => isSessionsTab(t) && t.id === "b"); + expect(target?.active).toBe(true); + expectTabs(state.tabs, [ + { id: "a", active: false }, + { id: "b", active: true }, + ]); + expect(state.history.get("active-tab-history")?.stack).toHaveLength(1); + }); + + test("close removes tab, picks fallback active, updates history", () => { + const active = createSessionTab({ id: "active", active: true }); + const next = createSessionTab({ id: "next", active: false }); + useTabs.getState().setTabs([active, next]); + + useTabs.getState().close(active); + + const state = useTabs.getState(); + expectTabs(state.tabs, [ + { id: "next", active: true }, + ]); + expect(state.currentTab).toMatchObject({ id: "next" }); + expect(state.history.size).toBe(0); + expect(state.canGoBack).toBe(false); + expect(state.canGoNext).toBe(false); + }); + + test("close last tab empties store", () => { + const only = createSessionTab({ id: "only", active: true }); + useTabs.getState().setTabs([only]); + + useTabs.getState().close(only); + + const state = useTabs.getState(); + expect(state.tabs).toHaveLength(0); + expect(state.currentTab).toBeNull(); + expect(state.canGoBack).toBe(false); + expect(state.canGoNext).toBe(false); + }); + + test("reorder keeps current tab and flags consistent", () => { + const active = createSessionTab({ id: "active", active: true }); + const other = createSessionTab({ id: "other", active: false }); + useTabs.getState().setTabs([active, other]); + + useTabs.getState().reorder([other, { ...active, active: true }]); + + const state = useTabs.getState(); + expectTabs(state.tabs, [ + { id: "other", active: false }, + { id: "active", active: true }, + ]); + expect(state.currentTab).toMatchObject({ id: "active" }); + expect(state.canGoBack).toBe(false); + }); +}); diff --git a/apps/desktop2/src/store/zustand/tabs/basic.ts b/apps/desktop2/src/store/zustand/tabs/basic.ts new file mode 100644 index 0000000000..3d7fed63c1 --- /dev/null +++ b/apps/desktop2/src/store/zustand/tabs/basic.ts @@ -0,0 +1,141 @@ +import type { StoreApi } from "zustand"; + +import type { LifecycleState } from "./lifecycle"; +import type { NavigationState } from "./navigation"; +import type { Tab, TabHistory } from "./schema"; +import { isSameTab, tabSchema } from "./schema"; +import { computeHistoryFlags, getSlotId, notifyTabClose, notifyTabsClose, pushHistory } from "./utils"; + +export type BasicState = { + currentTab: Tab | null; + tabs: Tab[]; +}; + +export type BasicActions = { + setTabs: (tabs: Tab[]) => void; + openCurrent: (tab: Tab) => void; + openNew: (tab: Tab) => void; + select: (tab: Tab) => void; + close: (tab: Tab) => void; + reorder: (tabs: Tab[]) => void; +}; + +export const createBasicSlice = ( + set: StoreApi["setState"], + get: StoreApi["getState"], +): BasicState & BasicActions => ({ + currentTab: null, + tabs: [], + setTabs: (tabs) => { + const tabsWithDefaults = tabs.map(t => tabSchema.parse(t)); + const currentTab = tabsWithDefaults.find((t) => t.active) || null; + const history = new Map(); + + tabsWithDefaults.forEach((tab) => { + if (tab.active) { + history.set(getSlotId(tab), { stack: [tab], currentIndex: 0 }); + } + }); + + const flags = computeHistoryFlags(history, currentTab); + set({ tabs: tabsWithDefaults, currentTab, history, ...flags } as Partial); + }, + openCurrent: (newTab) => { + const { tabs, history, onCloseHandlers } = get(); + const tabWithDefaults = tabSchema.parse(newTab); + const activeTab = { ...tabWithDefaults, active: true }; + const existingTabIdx = tabs.findIndex((t) => t.active); + + const tabsToClose: Tab[] = []; + if (existingTabIdx !== -1) { + tabsToClose.push(tabs[existingTabIdx]); + } + tabs.forEach((tab) => { + if (!tab.active && isSameTab(tab, tabWithDefaults)) { + tabsToClose.push(tab); + } + }); + + notifyTabsClose(onCloseHandlers, tabsToClose); + + const nextTabs = existingTabIdx === -1 + ? tabs + .filter((t) => !isSameTab(t, tabWithDefaults)) + .map((t) => ({ ...t, active: false })) + .concat([activeTab]) + : tabs + .map((t, idx) => + idx === existingTabIdx + ? activeTab + : isSameTab(t, tabWithDefaults) + ? null + : { ...t, active: false } + ) + .filter((t): t is Tab => t !== null); + + const nextHistory = pushHistory(history, activeTab); + const flags = computeHistoryFlags(nextHistory, activeTab); + set({ tabs: nextTabs, currentTab: activeTab, history: nextHistory, ...flags } as Partial); + }, + openNew: (tab) => { + const { tabs, history, onCloseHandlers } = get(); + const tabWithDefaults = tabSchema.parse(tab); + const activeTab = { ...tabWithDefaults, active: true }; + const tabsToClose = tabs.filter((t) => isSameTab(t, tabWithDefaults)); + notifyTabsClose(onCloseHandlers, tabsToClose); + const nextTabs = tabs + .filter((t) => !isSameTab(t, tabWithDefaults)) + .map((t) => ({ ...t, active: false })) + .concat([activeTab]); + const nextHistory = pushHistory(history, activeTab); + const flags = computeHistoryFlags(nextHistory, activeTab); + set({ tabs: nextTabs, currentTab: activeTab, history: nextHistory, ...flags } as Partial); + }, + select: (tab) => { + const { tabs, history } = get(); + const nextTabs = tabs.map((t) => ({ ...t, active: isSameTab(t, tab) })); + const flags = computeHistoryFlags(history, tab); + set({ tabs: nextTabs, currentTab: tab, ...flags } as Partial); + }, + close: (tab) => { + const { tabs, history, onCloseHandlers } = get(); + const remainingTabs = tabs.filter((t) => !isSameTab(t, tab)); + + notifyTabClose(onCloseHandlers, tab); + + if (remainingTabs.length === 0) { + return set({ + tabs: [] as Tab[], + currentTab: null, + history: new Map(), + canGoBack: false, + canGoNext: false, + } as Partial); + } + + const closedTabIndex = tabs.findIndex((t) => isSameTab(t, tab)); + const nextActiveIndex = closedTabIndex < remainingTabs.length + ? closedTabIndex + : remainingTabs.length - 1; + + const nextTabs = remainingTabs.map((t, idx) => ({ ...t, active: idx === nextActiveIndex })); + const nextCurrentTab = nextTabs[nextActiveIndex]; + + const nextHistory = new Map(history); + nextHistory.delete(getSlotId(tab)); + + const flags = computeHistoryFlags(nextHistory, nextCurrentTab); + set({ + tabs: nextTabs, + currentTab: nextCurrentTab, + history: nextHistory, + ...flags, + } as Partial); + }, + reorder: (tabs) => { + const { history } = get(); + const currentTab = tabs.find((t) => t.active) || null; + const flags = computeHistoryFlags(history, currentTab); + set({ tabs, currentTab, ...flags } as Partial); + }, +}); diff --git a/apps/desktop2/src/store/zustand/tabs/index.ts b/apps/desktop2/src/store/zustand/tabs/index.ts new file mode 100644 index 0000000000..be9615f554 --- /dev/null +++ b/apps/desktop2/src/store/zustand/tabs/index.ts @@ -0,0 +1,20 @@ +import { create } from "zustand"; + +import { type BasicActions, type BasicState, createBasicSlice } from "./basic"; +import { createLifecycleSlice, type LifecycleActions, type LifecycleState } from "./lifecycle"; +import { createNavigationSlice, type NavigationActions, type NavigationState } from "./navigation"; +import { createStateUpdaterSlice, type StateBasicActions } from "./state"; + +export type { Tab } from "./schema"; +export { isSameTab, rowIdfromTab, tabSchema, uniqueIdfromTab } from "./schema"; + +type State = BasicState & NavigationState & LifecycleState; +type Actions = BasicActions & StateBasicActions & NavigationActions & LifecycleActions; +type Store = State & Actions; + +export const useTabs = create()((set, get) => ({ + ...createBasicSlice(set, get), + ...createStateUpdaterSlice(set, get), + ...createNavigationSlice(set, get), + ...createLifecycleSlice(set, get), +})); diff --git a/apps/desktop2/src/store/zustand/tabs/lifecycle.test.ts b/apps/desktop2/src/store/zustand/tabs/lifecycle.test.ts new file mode 100644 index 0000000000..fe75fb4601 --- /dev/null +++ b/apps/desktop2/src/store/zustand/tabs/lifecycle.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { type Tab, useTabs } from "."; + +describe("Tab Lifecycle", () => { + beforeEach(() => { + useTabs.setState({ + currentTab: null, + tabs: [], + history: new Map(), + canGoBack: false, + canGoNext: false, + onCloseHandlers: new Set(), + }); + }); + + test("registerOnClose triggers handler when close removes tab", () => { + const tab: Tab = { + type: "sessions", + id: "session-1", + active: true, + state: { editor: "raw" }, + }; + + const handler = vi.fn(); + useTabs.getState().openCurrent(tab); + const unregister = useTabs.getState().registerOnClose(handler); + useTabs.getState().close(tab); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "session-1", type: "sessions" })); + + unregister(); + expect(useTabs.getState().onCloseHandlers.size).toBe(0); + }); + + test("registerOnClose triggers handler when openCurrent replaces tab", () => { + const tab1: Tab = { + type: "sessions", + id: "session-1", + active: true, + state: { editor: "raw" }, + }; + const tab2: Tab = { + type: "sessions", + id: "session-2", + active: true, + state: { editor: "raw" }, + }; + + const handler = vi.fn(); + useTabs.getState().openCurrent(tab1); + useTabs.getState().registerOnClose(handler); + useTabs.getState().openCurrent(tab2); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: "session-1", type: "sessions" })); + }); + + test("registerOnClose handler receives correct tab when multiple tabs close", () => { + const tab1: Tab = { + type: "sessions", + id: "session-1", + active: true, + state: { editor: "raw" }, + }; + const tab2: Tab = { + type: "sessions", + id: "session-2", + active: true, + state: { editor: "raw" }, + }; + + const closedTabs: Tab[] = []; + const handler = vi.fn((tab: Tab) => closedTabs.push(tab)); + + useTabs.getState().registerOnClose(handler); + useTabs.getState().openCurrent(tab1); + useTabs.getState().openNew(tab2); + useTabs.getState().close(tab2); + + expect(closedTabs).toHaveLength(1); + expect(closedTabs[0]).toMatchObject({ id: "session-2", type: "sessions" }); + }); +}); diff --git a/apps/desktop2/src/store/zustand/tabs/lifecycle.ts b/apps/desktop2/src/store/zustand/tabs/lifecycle.ts new file mode 100644 index 0000000000..186a184e22 --- /dev/null +++ b/apps/desktop2/src/store/zustand/tabs/lifecycle.ts @@ -0,0 +1,30 @@ +import type { StoreApi } from "zustand"; + +import type { Tab } from "./schema"; + +export type LifecycleState = { + onCloseHandlers: Set<(tab: Tab) => void>; +}; + +export type LifecycleActions = { + registerOnClose: (handler: (tab: Tab) => void) => () => void; +}; + +export const createLifecycleSlice = ( + set: StoreApi["setState"], + get: StoreApi["getState"], +): LifecycleState & LifecycleActions => ({ + onCloseHandlers: new Set(), + registerOnClose: (handler) => { + const { onCloseHandlers } = get(); + const nextHandlers = new Set(onCloseHandlers); + nextHandlers.add(handler); + set({ onCloseHandlers: nextHandlers } as Partial); + return () => { + const { onCloseHandlers: currentHandlers } = get(); + const nextHandlers = new Set(currentHandlers); + nextHandlers.delete(handler); + set({ onCloseHandlers: nextHandlers } as Partial); + }; + }, +}); diff --git a/apps/desktop2/src/store/zustand/tabs.test.ts b/apps/desktop2/src/store/zustand/tabs/navigation.test.ts similarity index 98% rename from apps/desktop2/src/store/zustand/tabs.test.ts rename to apps/desktop2/src/store/zustand/tabs/navigation.test.ts index 517a6b1e78..fcc750300c 100644 --- a/apps/desktop2/src/store/zustand/tabs.test.ts +++ b/apps/desktop2/src/store/zustand/tabs/navigation.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from "vitest"; -import { type Tab, useTabs } from "./tabs"; +import { type Tab, useTabs } from "."; describe("Tab History Navigation", () => { beforeEach(() => { diff --git a/apps/desktop2/src/store/zustand/tabs/navigation.ts b/apps/desktop2/src/store/zustand/tabs/navigation.ts new file mode 100644 index 0000000000..d5dbc22fa7 --- /dev/null +++ b/apps/desktop2/src/store/zustand/tabs/navigation.ts @@ -0,0 +1,71 @@ +import type { StoreApi } from "zustand"; + +import type { BasicState } from "./basic"; +import type { TabHistory } from "./schema"; +import { computeHistoryFlags, getSlotId } from "./utils"; + +export type NavigationState = { + history: Map; + canGoBack: boolean; + canGoNext: boolean; +}; + +export type NavigationActions = { + goBack: () => void; + goNext: () => void; +}; + +export const createNavigationSlice = ( + set: StoreApi["setState"], + get: StoreApi["getState"], +): NavigationState & NavigationActions => ({ + history: new Map(), + canGoBack: false, + canGoNext: false, + goBack: () => { + const { tabs, history, currentTab } = get(); + if (!currentTab) { + return; + } + + const slotId = getSlotId(currentTab); + const tabHistory = history.get(slotId); + if (!tabHistory || tabHistory.currentIndex === 0) { + return; + } + + const prevIndex = tabHistory.currentIndex - 1; + const prevTab = tabHistory.stack[prevIndex]; + + const nextTabs = tabs.map((t) => t.active ? prevTab : t); + + const nextHistory = new Map(history); + nextHistory.set(slotId, { ...tabHistory, currentIndex: prevIndex }); + + const flags = computeHistoryFlags(nextHistory, prevTab); + set({ tabs: nextTabs, currentTab: prevTab, history: nextHistory, ...flags } as Partial); + }, + goNext: () => { + const { tabs, history, currentTab } = get(); + if (!currentTab) { + return; + } + + const slotId = getSlotId(currentTab); + const tabHistory = history.get(slotId); + if (!tabHistory || tabHistory.currentIndex >= tabHistory.stack.length - 1) { + return; + } + + const nextIndex = tabHistory.currentIndex + 1; + const nextTab = tabHistory.stack[nextIndex]; + + const nextTabs = tabs.map((t) => t.active ? nextTab : t); + + const nextHistory = new Map(history); + nextHistory.set(slotId, { ...tabHistory, currentIndex: nextIndex }); + + const flags = computeHistoryFlags(nextHistory, nextTab); + set({ tabs: nextTabs, currentTab: nextTab, history: nextHistory, ...flags } as Partial); + }, +}); diff --git a/apps/desktop2/src/store/zustand/tabs/schema.ts b/apps/desktop2/src/store/zustand/tabs/schema.ts new file mode 100644 index 0000000000..e726303a2d --- /dev/null +++ b/apps/desktop2/src/store/zustand/tabs/schema.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; + +import { TABLES } from "../../tinybase/persisted"; + +const baseTabSchema = z.object({ + active: z.boolean(), +}); + +export const tabSchema = z.discriminatedUnion("type", [ + baseTabSchema.extend({ + type: z.literal("sessions" satisfies typeof TABLES[number]), + id: z.string(), + state: z.object({ + editor: z.enum(["raw", "enhanced", "transcript"]).default("raw"), + }).default({ editor: "raw" }), + }), + baseTabSchema.extend({ + type: z.literal("contacts"), + state: z.object({ + selectedOrganization: z.string().nullable().default(null), + selectedPerson: z.string().nullable().default(null), + }).default({ + selectedOrganization: null, + selectedPerson: null, + }), + }), + baseTabSchema.extend({ + type: z.literal("events" satisfies typeof TABLES[number]), + id: z.string(), + }), + baseTabSchema.extend({ + type: z.literal("humans" satisfies typeof TABLES[number]), + id: z.string(), + }), + baseTabSchema.extend({ + type: z.literal("organizations" satisfies typeof TABLES[number]), + id: z.string(), + }), + baseTabSchema.extend({ + type: z.literal("folders" satisfies typeof TABLES[number]), + id: z.string().nullable(), + }), + + baseTabSchema.extend({ + type: z.literal("calendars"), + month: z.coerce.date(), + }), + baseTabSchema.extend({ + type: z.literal("daily"), + date: z.coerce.date(), + }), +]); + +export type Tab = z.infer; + +export type TabHistory = { + stack: Tab[]; + currentIndex: number; +}; + +export const rowIdfromTab = (tab: Tab): string => { + switch (tab.type) { + case "sessions": + return tab.id; + case "events": + return tab.id; + case "humans": + return tab.id; + case "organizations": + return tab.id; + case "calendars": + case "contacts": + case "daily": + throw new Error("invalid_resource"); + case "folders": + if (!tab.id) { + throw new Error("invalid_resource"); + } + return tab.id; + } +}; + +export const uniqueIdfromTab = (tab: Tab): string => { + switch (tab.type) { + case "sessions": + return `sessions-${tab.id}`; + case "events": + return `events-${tab.id}`; + case "humans": + return `humans-${tab.id}`; + case "organizations": + return `organizations-${tab.id}`; + case "calendars": + return `calendars-${tab.month.getFullYear()}-${tab.month.getMonth()}`; + case "contacts": + return `contacts`; + case "daily": + return `daily-${tab.date.getFullYear()}-${tab.date.getMonth()}-${tab.date.getDate()}`; + case "folders": + return `folders-${tab.id ?? "all"}`; + } +}; + +export const isSameTab = (a: Tab, b: Tab) => { + return uniqueIdfromTab(a) === uniqueIdfromTab(b); +}; diff --git a/apps/desktop2/src/store/zustand/tabs/state.test.ts b/apps/desktop2/src/store/zustand/tabs/state.test.ts new file mode 100644 index 0000000000..9b0861bb6f --- /dev/null +++ b/apps/desktop2/src/store/zustand/tabs/state.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, test } from "vitest"; + +import { type Tab, useTabs } from "."; +import { createContactsTab, createSessionTab, resetTabsStore } from "./test-utils"; + +const expectTabState = (tab: Tab | null, partial: Partial) => { + expect(tab).not.toBeNull(); + expect(tab).toMatchObject(partial); +}; + +describe("State Updater Actions", () => { + beforeEach(() => { + resetTabsStore(); + }); + + describe("updateSessionTabState", () => { + test("updates matching session tab and current tab state", () => { + const tab = createSessionTab({ id: "s1", active: true }); + useTabs.getState().setTabs([tab]); + + useTabs.getState().updateSessionTabState(tab, { editor: "enhanced" }); + + const state = useTabs.getState(); + expect(state.tabs[0]).toMatchObject({ id: "s1", state: { editor: "enhanced" } }); + expectTabState(state.currentTab, { id: "s1", state: { editor: "enhanced" } }); + const stack = state.history.get("active-tab-history")?.stack; + expect(stack && stack[stack.length - 1]).toMatchObject({ state: { editor: "enhanced" } }); + }); + + test("updates only matching tab instances", () => { + const tab = createSessionTab({ id: "s", active: false }); + const active = createSessionTab({ id: "active", active: true }); + useTabs.getState().setTabs([tab, active]); + + useTabs.getState().updateSessionTabState(tab, { editor: "enhanced" }); + + const state = useTabs.getState(); + expect(state.tabs[0]).toMatchObject({ id: "s", state: { editor: "enhanced" } }); + expect(state.tabs[1]).toMatchObject({ id: "active", state: { editor: "raw" } }); + const stack = state.history.get("active-tab-history")?.stack; + expect(stack && stack[stack.length - 1]).toMatchObject({ id: "active", state: { editor: "raw" } }); + }); + + test("no-op when tab types mismatch", () => { + const session = createSessionTab({ id: "s", active: true }); + const contacts = createContactsTab(); + useTabs.getState().setTabs([session, contacts]); + + useTabs.getState().updateSessionTabState(contacts as Tab, { editor: "enhanced" } as any); + + const state = useTabs.getState(); + expect(state.tabs[0]).toMatchObject({ id: "s", state: { editor: "raw" } }); + expect(state.tabs[1]).toMatchObject({ type: "contacts" }); + }); + }); + + describe("updateContactsTabState", () => { + const newContactsState = { + selectedOrganization: "org-1", + selectedPerson: "person-1", + } as const; + + test("updates contacts tab and current tab state", () => { + const contacts = createContactsTab({ active: true }); + useTabs.getState().setTabs([contacts]); + + useTabs.getState().updateContactsTabState(contacts, newContactsState); + + const state = useTabs.getState(); + expect(state.tabs[0]).toMatchObject({ state: newContactsState }); + expectTabState(state.currentTab, { state: newContactsState }); + const stack = state.history.get("active-tab-history")?.stack; + expect(stack && stack[stack.length - 1]).toMatchObject({ state: newContactsState }); + }); + + test("only matching contacts tab receives update", () => { + const contacts = createContactsTab({ active: false }); + const session = createSessionTab({ id: "s", active: true }); + useTabs.getState().setTabs([contacts, session]); + + useTabs.getState().updateContactsTabState(contacts, newContactsState); + + const state = useTabs.getState(); + expect(state.tabs[0]).toMatchObject({ state: newContactsState }); + expect(state.tabs[1]).toMatchObject({ state: { editor: "raw" } }); + const stack = state.history.get("active-tab-history")?.stack; + expect(stack && stack[stack.length - 1]).toMatchObject({ id: "s" }); + }); + + test("updates all contacts tabs sharing identity even when instance differs", () => { + const contacts = createContactsTab({ active: true }); + const other = createContactsTab({ active: false }); + useTabs.getState().setTabs([contacts, other]); + + const otherInstance = createContactsTab({ active: true }); + useTabs.getState().updateContactsTabState(otherInstance, newContactsState); + + const state = useTabs.getState(); + expect(state.tabs[0]).toMatchObject({ state: newContactsState }); + expect(state.tabs[1]).toMatchObject({ state: newContactsState }); + }); + }); +}); diff --git a/apps/desktop2/src/store/zustand/tabs/state.ts b/apps/desktop2/src/store/zustand/tabs/state.ts new file mode 100644 index 0000000000..bcc2480097 --- /dev/null +++ b/apps/desktop2/src/store/zustand/tabs/state.ts @@ -0,0 +1,53 @@ +import type { StoreApi } from "zustand"; + +import type { BasicState } from "./basic"; +import type { NavigationState } from "./navigation"; +import type { Tab } from "./schema"; +import { isSameTab } from "./schema"; +import { updateHistoryCurrent } from "./utils"; + +export type StateBasicActions = { + updateContactsTabState: (tab: Tab, state: Extract["state"]) => void; + updateSessionTabState: (tab: Tab, state: Extract["state"]) => void; +}; + +export const createStateUpdaterSlice = ( + set: StoreApi["setState"], + get: StoreApi["getState"], +): StateBasicActions => ({ + updateSessionTabState: (tab, state) => { + const { tabs, currentTab, history } = get(); + const nextTabs = tabs.map((t) => + isSameTab(t, tab) && t.type === "sessions" + ? { ...t, state } + : t + ); + const nextCurrentTab = currentTab && isSameTab(currentTab, tab) && currentTab.type === "sessions" + ? { ...currentTab, state } + : currentTab; + + const nextHistory = nextCurrentTab && isSameTab(nextCurrentTab, tab) + ? updateHistoryCurrent(history, nextCurrentTab) + : history; + + set({ tabs: nextTabs, currentTab: nextCurrentTab, history: nextHistory } as Partial); + }, + updateContactsTabState: (tab, state) => { + const { tabs, currentTab, history } = get(); + const nextTabs = tabs.map((t) => + isSameTab(t, tab) && t.type === "contacts" + ? { ...t, state } + : t + ); + + const nextCurrentTab = currentTab && isSameTab(currentTab, tab) && currentTab.type === "contacts" + ? { ...currentTab, state } + : currentTab; + + const nextHistory = nextCurrentTab && isSameTab(nextCurrentTab, tab) + ? updateHistoryCurrent(history, nextCurrentTab) + : history; + + set({ tabs: nextTabs, currentTab: nextCurrentTab, history: nextHistory } as Partial); + }, +}); diff --git a/apps/desktop2/src/store/zustand/tabs/test-utils.ts b/apps/desktop2/src/store/zustand/tabs/test-utils.ts new file mode 100644 index 0000000000..8eaf076b7e --- /dev/null +++ b/apps/desktop2/src/store/zustand/tabs/test-utils.ts @@ -0,0 +1,48 @@ +import { type Tab, useTabs } from "."; + +type SessionTab = Extract; +type ContactsTab = Extract; + +let tabCounter = 0; + +const nextId = (prefix: string) => `${prefix}-${++tabCounter}`; + +type SessionOverrides = Partial> & { + state?: Partial; +}; + +type ContactsOverrides = Partial> & { + state?: Partial; +}; + +export const createSessionTab = (overrides: SessionOverrides = {}): SessionTab => ({ + type: "sessions", + id: overrides.id ?? nextId("session"), + active: overrides.active ?? false, + state: { + editor: "raw", + ...overrides.state, + }, +}); + +export const createContactsTab = (overrides: ContactsOverrides = {}): ContactsTab => ({ + type: "contacts", + active: overrides.active ?? false, + state: { + selectedOrganization: null, + selectedPerson: null, + ...overrides.state, + }, +}); + +export const resetTabsStore = (): void => { + tabCounter = 0; + useTabs.setState(() => ({ + currentTab: null, + tabs: [], + history: new Map(), + canGoBack: false, + canGoNext: false, + onCloseHandlers: new Set(), + })); +}; diff --git a/apps/desktop2/src/store/zustand/tabs/utils.ts b/apps/desktop2/src/store/zustand/tabs/utils.ts new file mode 100644 index 0000000000..72a4ca50b6 --- /dev/null +++ b/apps/desktop2/src/store/zustand/tabs/utils.ts @@ -0,0 +1,76 @@ +import type { Tab, TabHistory } from "./schema"; +import { uniqueIdfromTab } from "./schema"; + +export const ACTIVE_TAB_SLOT_ID = "active-tab-history"; + +export const getSlotId = (tab: Tab): string => { + return tab.active ? ACTIVE_TAB_SLOT_ID : `inactive-${uniqueIdfromTab(tab)}`; +}; + +export const notifyTabClose = ( + handlers: Set<(tab: Tab) => void>, + tab: Tab, +): void => { + handlers.forEach((handler) => { + try { + handler(tab); + } catch (error) { + console.error("tab onClose handler failed", error); + } + }); +}; + +export const notifyTabsClose = ( + handlers: Set<(tab: Tab) => void>, + tabs: Tab[], +): void => { + tabs.forEach((tab) => notifyTabClose(handlers, tab)); +}; + +export const computeHistoryFlags = ( + history: Map, + currentTab: Tab | null, +): { canGoBack: boolean; canGoNext: boolean } => { + if (!currentTab) { + return { canGoBack: false, canGoNext: false }; + } + const slotId = getSlotId(currentTab); + const tabHistory = history.get(slotId); + if (!tabHistory) { + return { canGoBack: false, canGoNext: false }; + } + return { + canGoBack: tabHistory.currentIndex > 0, + canGoNext: tabHistory.currentIndex < tabHistory.stack.length - 1, + }; +}; + +export const pushHistory = (history: Map, tab: Tab): Map => { + const newHistory = new Map(history); + const slotId = getSlotId(tab); + const existing = newHistory.get(slotId); + + if (existing) { + const newStack = existing.stack.slice(0, existing.currentIndex + 1); + newStack.push(tab); + newHistory.set(slotId, { stack: newStack, currentIndex: newStack.length - 1 }); + } else { + newHistory.set(slotId, { stack: [tab], currentIndex: 0 }); + } + + return newHistory; +}; + +export const updateHistoryCurrent = (history: Map, tab: Tab): Map => { + const newHistory = new Map(history); + const slotId = getSlotId(tab); + const existing = newHistory.get(slotId); + + if (existing && existing.currentIndex >= 0) { + const newStack = [...existing.stack]; + newStack[existing.currentIndex] = tab; + newHistory.set(slotId, { ...existing, stack: newStack }); + } + + return newHistory; +};