diff --git a/apps/roam/src/components/LeftSidebarView.tsx b/apps/roam/src/components/LeftSidebarView.tsx index 4647d3238..da00355fe 100644 --- a/apps/roam/src/components/LeftSidebarView.tsx +++ b/apps/roam/src/components/LeftSidebarView.tsx @@ -21,15 +21,27 @@ import { import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; import extractRef from "roamjs-components/util/extractRef"; +import { getFormattedConfigTree, notify } from "~/utils/discourseConfigRef"; import { - getFormattedConfigTree, - notify, - subscribe, -} from "~/utils/discourseConfigRef"; -import type { - LeftSidebarConfig, - LeftSidebarPersonalSectionConfig, + onSettingChange, + settingKeys, +} from "~/components/settings/utils/settingsEmitter"; +import { + type LeftSidebarConfig, + type LeftSidebarPersonalSectionConfig, + mergeGlobalSectionWithAccessor, + mergePersonalSectionsWithAccessor, } from "~/utils/getLeftSidebarSettings"; +import { + getGlobalSetting, + getPersonalSetting, + setGlobalSetting, + setPersonalSetting, +} from "~/components/settings/utils/accessors"; +import type { + LeftSidebarGlobalSettings, + PersonalSection, +} from "~/components/settings/utils/zodSchema"; import { createBlock } from "roamjs-components/writes"; import deleteBlock from "roamjs-components/writes/deleteBlock"; import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; @@ -95,12 +107,18 @@ const toggleFoldedState = ({ setIsOpen, folded, parentUid, + isGlobal, + sectionIndex, }: { isOpen: boolean; setIsOpen: Dispatch>; folded: { uid?: string; value: boolean }; parentUid: string; + isGlobal?: boolean; + sectionIndex?: number; }) => { + const newFolded = !isOpen; + if (isOpen) { setIsOpen(false); if (folded.uid) { @@ -118,6 +136,17 @@ const toggleFoldedState = ({ folded.uid = newUid; folded.value = true; } + + if (isGlobal) { + setGlobalSetting(["Left sidebar", "Settings", "Folded"], newFolded); + } else if (sectionIndex !== undefined) { + const sections = + getPersonalSetting(["Left sidebar"]) || []; + if (sections[sectionIndex]) { + sections[sectionIndex].Settings.Folded = newFolded; + setPersonalSetting(["Left sidebar"], sections); + } + } }; const SectionChildren = ({ @@ -160,8 +189,10 @@ const SectionChildren = ({ const PersonalSectionItem = ({ section, + sectionIndex, }: { section: LeftSidebarPersonalSectionConfig; + sectionIndex: number; }) => { const titleRef = parseReference(section.text); const blockText = useMemo( @@ -182,6 +213,7 @@ const PersonalSectionItem = ({ setIsOpen, folded: section.settings.folded, parentUid: section.settings.uid || "", + sectionIndex, }); }; @@ -226,9 +258,9 @@ const PersonalSections = ({ config }: { config: LeftSidebarConfig }) => { return (
- {sections.map((section) => ( + {sections.map((section, index) => (
- +
))}
@@ -253,6 +285,7 @@ const GlobalSection = ({ config }: { config: LeftSidebarConfig["global"] }) => { setIsOpen, folded: config.settings.folded, parentUid: config.settings.uid, + isGlobal: true, }); }} > @@ -276,22 +309,62 @@ const GlobalSection = ({ config }: { config: LeftSidebarConfig["global"] }) => { ); }; +// TODO(ENG-1471): Remove old-system merge when migration complete — just use accessor values directly. +// See mergeGlobalSectionWithAccessor/mergePersonalSectionsWithAccessor for why the merge exists. +const buildConfig = (): LeftSidebarConfig => { + // Read VALUES from accessor (handles flag routing + mismatch detection) + const globalValues = getGlobalSetting([ + "Left sidebar", + ]); + const personalValues = getPersonalSetting([ + "Left sidebar", + ]); + + // Read UIDs from old system (needed for fold CRUD during dual-write) + const oldConfig = getFormattedConfigTree().leftSidebar; + + return { + uid: oldConfig.uid, + favoritesMigrated: oldConfig.favoritesMigrated, + sidebarMigrated: oldConfig.sidebarMigrated, + global: mergeGlobalSectionWithAccessor(oldConfig.global, globalValues), + personal: { + uid: oldConfig.personal.uid, + sections: mergePersonalSectionsWithAccessor( + oldConfig.personal.sections, + personalValues, + ), + }, + allPersonalSections: oldConfig.allPersonalSections, + }; +}; + export const useConfig = () => { - const [config, setConfig] = useState( - () => getFormattedConfigTree().leftSidebar, - ); + const [config, setConfig] = useState(() => buildConfig()); useEffect(() => { const handleUpdate = () => { - setConfig(getFormattedConfigTree().leftSidebar); + refreshConfigTree(); + setConfig(buildConfig()); }; - const unsubscribe = subscribe(handleUpdate); + const unsubGlobal = onSettingChange( + settingKeys.globalLeftSidebar, + handleUpdate, + ); + const unsubPersonal = onSettingChange( + settingKeys.personalLeftSidebar, + handleUpdate, + ); return () => { - unsubscribe(); + unsubGlobal(); + unsubPersonal(); }; }, []); return { config, setConfig }; }; +// TODO(ENG-1471): refreshAndNotify still needed by settings panels +// (LeftSidebarGlobalSettings, LeftSidebarPersonalSettings) for old-system CRUD. +// Remove when settings panels also read via accessors + emitter. export const refreshAndNotify = () => { refreshConfigTree(); notify(); diff --git a/apps/roam/src/components/settings/GeneralSettings.tsx b/apps/roam/src/components/settings/GeneralSettings.tsx index c3f5a6bfc..875015700 100644 --- a/apps/roam/src/components/settings/GeneralSettings.tsx +++ b/apps/roam/src/components/settings/GeneralSettings.tsx @@ -6,6 +6,7 @@ import { GlobalTextPanel, FeatureFlagPanel, } from "./components/BlockPropSettingPanels"; +import { isNewSettingsStoreEnabled } from "./utils/accessors"; import posthog from "posthog-js"; const DiscourseGraphHome = () => { @@ -43,7 +44,7 @@ const DiscourseGraphHome = () => { uid={settings.leftSidebarEnabled.uid} parentUid={settings.settingsUid} onAfterChange={(checked: boolean) => { - if (checked) { + if (checked && !isNewSettingsStoreEnabled()) { setIsAlertOpen(true); } posthog.capture("General Settings: Left Sidebar Toggled", { diff --git a/apps/roam/src/components/settings/LeftSidebarGlobalSettings.tsx b/apps/roam/src/components/settings/LeftSidebarGlobalSettings.tsx index 838346ab5..4cd214fae 100644 --- a/apps/roam/src/components/settings/LeftSidebarGlobalSettings.tsx +++ b/apps/roam/src/components/settings/LeftSidebarGlobalSettings.tsx @@ -1,7 +1,11 @@ import React, { useCallback, useEffect, useMemo, useState, memo } from "react"; import { Button, ButtonGroup, Collapse } from "@blueprintjs/core"; import { GlobalFlagPanel } from "~/components/settings/components/BlockPropSettingPanels"; -import { setGlobalSetting } from "~/components/settings/utils/accessors"; +import { + setGlobalSetting, + getGlobalSetting, +} from "~/components/settings/utils/accessors"; +import type { LeftSidebarGlobalSettings } from "~/components/settings/utils/zodSchema"; import AutocompleteInput from "roamjs-components/components/AutocompleteInput"; import getAllPageNames from "roamjs-components/queries/getAllPageNames"; import createBlock from "roamjs-components/writes/createBlock"; @@ -11,8 +15,11 @@ import { extractRef, getSubTree } from "roamjs-components/util"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import discourseConfigRef from "~/utils/discourseConfigRef"; import { DISCOURSE_CONFIG_PAGE_TITLE } from "~/utils/renderNodeConfigPage"; -import { getLeftSidebarGlobalSectionConfig } from "~/utils/getLeftSidebarSettings"; -import { LeftSidebarGlobalSectionConfig } from "~/utils/getLeftSidebarSettings"; +import { + getLeftSidebarGlobalSectionConfig, + mergeGlobalSectionWithAccessor, + type LeftSidebarGlobalSectionConfig, +} from "~/utils/getLeftSidebarSettings"; import { render as renderToast } from "roamjs-components/components/Toast"; import refreshConfigTree from "~/utils/refreshConfigTree"; import { refreshAndNotify } from "~/components/LeftSidebarView"; @@ -98,6 +105,9 @@ const LeftSidebarGlobalSectionsContent = ({ const initialize = async () => { setIsInitializing(true); const globalSectionText = "Global-Section"; + const globalValues = getGlobalSetting([ + "Left sidebar", + ]); const config = getLeftSidebarGlobalSectionConfig(leftSidebar.children); const existingGlobalSection = leftSidebar.children.find( @@ -142,9 +152,10 @@ const LeftSidebarGlobalSectionsContent = ({ }); } } else { - setChildrenUid(config.childrenUid || null); - setPages(config.children || []); - setGlobalSection(config); + const merged = mergeGlobalSectionWithAccessor(config, globalValues); + setChildrenUid(merged.childrenUid || null); + setPages(merged.children || []); + setGlobalSection(merged); } setIsInitializing(false); }; diff --git a/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx b/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx index ab4eeb61a..eef335d2b 100644 --- a/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx +++ b/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx @@ -19,7 +19,11 @@ import createBlock from "roamjs-components/writes/createBlock"; import deleteBlock from "roamjs-components/writes/deleteBlock"; import updateBlock from "roamjs-components/writes/updateBlock"; import type { RoamBasicNode } from "roamjs-components/types"; -import { setPersonalSetting } from "~/components/settings/utils/accessors"; +import { + setPersonalSetting, + getPersonalSetting, +} from "~/components/settings/utils/accessors"; +import type { PersonalSection } from "~/components/settings/utils/zodSchema"; import { PersonalNumberPanel, PersonalTextPanel, @@ -27,6 +31,7 @@ import { import { LeftSidebarPersonalSectionConfig, getLeftSidebarPersonalSectionConfig, + mergePersonalSectionsWithAccessor, PersonalSectionChild, } from "~/utils/getLeftSidebarSettings"; import { extractRef, getSubTree } from "roamjs-components/util"; @@ -507,7 +512,7 @@ const SectionItem = memo( ([ + "Left sidebar", + ]); const loadedSections = getLeftSidebarPersonalSectionConfig( leftSidebar.children, ).sections; - setSections(loadedSections); + setSections( + mergePersonalSectionsWithAccessor(loadedSections, personalValues), + ); } }; @@ -786,7 +797,7 @@ const LeftSidebarPersonalSectionsContent = ({ { return settings.leftSidebar.personal.sections.map((section) => ({ name: section.text, Children: (section.children || []).map((child) => ({ - uid: child.uid, + uid: child.text, Alias: child.alias?.value || "", })), Settings: { @@ -724,6 +724,8 @@ export const getGlobalSettings = (): GlobalSettings => { export const getGlobalSetting = ( keys: string[], ): T | undefined => { + if (keys.length === 0) return undefined; + if (!isNewSettingsStoreEnabled()) { return getLegacyGlobalSetting(keys) as T | undefined; } @@ -788,6 +790,8 @@ export const getPersonalSettings = (): PersonalSettings => { export const getPersonalSetting = ( keys: string[], ): T | undefined => { + if (keys.length === 0) return undefined; + if (!isNewSettingsStoreEnabled()) { return getLegacyPersonalSetting(keys) as T | undefined; } diff --git a/apps/roam/src/components/settings/utils/pullWatchers.ts b/apps/roam/src/components/settings/utils/pullWatchers.ts index 81026957c..e3d7e2979 100644 --- a/apps/roam/src/components/settings/utils/pullWatchers.ts +++ b/apps/roam/src/components/settings/utils/pullWatchers.ts @@ -12,6 +12,7 @@ import { type PersonalSettings, type DiscourseNodeSettings, } from "./zodSchema"; +import { emitSettingChange, settingKeys } from "./settingsEmitter"; type PullWatchCallback = Parameters[2]; @@ -90,10 +91,11 @@ export const featureFlagHandlers: Partial< (newValue: boolean, oldValue: boolean, allFlags: FeatureFlags) => void > > = { - // Add handlers as needed: - // "Enable Left Sidebar": (newValue) => { ... }, - // "Suggestive Mode Enabled": (newValue) => { ... }, - // "Reified Relation Triples": (newValue) => { ... }, + /* eslint-disable @typescript-eslint/naming-convention */ + "Enable left sidebar": (newValue, oldValue) => { + emitSettingChange(settingKeys.leftSidebarFlag, newValue, oldValue); + }, + /* eslint-enable @typescript-eslint/naming-convention */ }; type GlobalSettingsHandlers = { @@ -105,12 +107,11 @@ type GlobalSettingsHandlers = { }; export const globalSettingsHandlers: GlobalSettingsHandlers = { - // Add handlers as needed: - // "Trigger": (newValue) => { ... }, - // "Canvas Page Format": (newValue) => { ... }, - // "Left Sidebar": (newValue) => { ... }, - // "Export": (newValue) => { ... }, - // "Suggestive Mode": (newValue) => { ... }, + /* eslint-disable @typescript-eslint/naming-convention */ + "Left sidebar": (newValue, oldValue) => { + emitSettingChange(settingKeys.globalLeftSidebar, newValue, oldValue); + }, + /* eslint-enable @typescript-eslint/naming-convention */ }; type PersonalSettingsHandlers = { @@ -122,28 +123,9 @@ type PersonalSettingsHandlers = { }; export const personalSettingsHandlers: PersonalSettingsHandlers = { - // "Left Sidebar" stub for testing with stubSetLeftSidebarPersonalSections() in accessors.ts /* eslint-disable @typescript-eslint/naming-convention */ "Left sidebar": (newValue, oldValue) => { - const oldSections = Object.keys(oldValue || {}); - const newSections = Object.keys(newValue || {}); - - if (newSections.length === 0 && oldSections.length === 0) return; - - console.group("👤 [PullWatch] Personal Settings Changed: Left Sidebar"); - console.log("Old value:", JSON.stringify(oldValue, null, 2)); - console.log("New value:", JSON.stringify(newValue, null, 2)); - - const addedSections = newSections.filter((s) => !oldSections.includes(s)); - const removedSections = oldSections.filter((s) => !newSections.includes(s)); - - if (addedSections.length > 0) { - console.log(" → Sections added:", addedSections); - } - if (removedSections.length > 0) { - console.log(" → Sections removed:", removedSections); - } - console.groupEnd(); + emitSettingChange(settingKeys.personalLeftSidebar, newValue, oldValue); }, /* eslint-enable @typescript-eslint/naming-convention */ }; diff --git a/apps/roam/src/components/settings/utils/settingsEmitter.ts b/apps/roam/src/components/settings/utils/settingsEmitter.ts new file mode 100644 index 000000000..9a72b5155 --- /dev/null +++ b/apps/roam/src/components/settings/utils/settingsEmitter.ts @@ -0,0 +1,30 @@ +type SettingChangeCallback = (newValue: unknown, oldValue: unknown) => void; + +export const settingKeys = { + leftSidebarFlag: "Enable left sidebar", + globalLeftSidebar: "global:Left sidebar", + personalLeftSidebar: "personal:Left sidebar", +} as const; + +const listeners = new Map>(); + +export const onSettingChange = ( + key: string, + callback: SettingChangeCallback, +): (() => void) => { + if (!listeners.has(key)) { + listeners.set(key, new Set()); + } + listeners.get(key)!.add(callback); + return () => { + listeners.get(key)?.delete(callback); + }; +}; + +export const emitSettingChange = ( + key: string, + newValue: unknown, + oldValue: unknown, +): void => { + listeners.get(key)?.forEach((cb) => cb(newValue, oldValue)); +}; diff --git a/apps/roam/src/index.ts b/apps/roam/src/index.ts index 12f363e64..84ecb6ff4 100644 --- a/apps/roam/src/index.ts +++ b/apps/roam/src/index.ts @@ -1,3 +1,4 @@ +import ReactDOM from "react-dom"; import { addStyle } from "roamjs-components/dom"; import { render as renderToast } from "roamjs-components/components/Toast"; import { runExtension } from "roamjs-components/util"; @@ -37,6 +38,12 @@ import { getFeatureFlag, getPersonalSetting, } from "./components/settings/utils/accessors"; +import { setupPullWatchOnSettingsPage } from "./components/settings/utils/pullWatchers"; +import { + onSettingChange, + settingKeys, +} from "./components/settings/utils/settingsEmitter"; +import { mountLeftSidebar } from "./components/LeftSidebarView"; export const DEFAULT_CANVAS_PAGE_FORMAT = "Canvas/*"; @@ -149,7 +156,31 @@ export default runExtension(async (onloadArgs) => { }); } - await initSchema(); + const unsubLeftSidebarFlag = onSettingChange( + settingKeys.leftSidebarFlag, + (newValue) => { + const enabled = Boolean(newValue); + const wrapper = document.querySelector( + ".starred-pages-wrapper", + ); + if (!wrapper) return; + if (enabled) { + wrapper.style.padding = "0"; + void mountLeftSidebar(wrapper, onloadArgs); + } else { + const root = wrapper.querySelector("#dg-left-sidebar-root"); + if (root) { + // eslint-disable-next-line react/no-deprecated + ReactDOM.unmountComponentAtNode(root); + root.remove(); + } + wrapper.style.padding = ""; + } + }, + ); + + const { blockUids } = await initSchema(); + const cleanupPullWatchers = setupPullWatchOnSettingsPage(blockUids); return { elements: [ @@ -161,6 +192,8 @@ export default runExtension(async (onloadArgs) => { ], observers: observers, unload: () => { + unsubLeftSidebarFlag(); + cleanupPullWatchers(); setSyncActivity(false); window.roamjs.extension?.smartblocks?.unregisterCommand("QUERYBUILDER"); // @ts-expect-error - tldraw throws a warning on multiple loads diff --git a/apps/roam/src/utils/getLeftSidebarSettings.ts b/apps/roam/src/utils/getLeftSidebarSettings.ts index 94d018690..928f6a067 100644 --- a/apps/roam/src/utils/getLeftSidebarSettings.ts +++ b/apps/roam/src/utils/getLeftSidebarSettings.ts @@ -8,6 +8,10 @@ import { getUidAndStringSetting, } from "./getExportSettings"; import { getSubTree } from "roamjs-components/util"; +import type { + LeftSidebarGlobalSettings, + PersonalSection, +} from "~/components/settings/utils/zodSchema"; type LeftSidebarPersonalSectionSettings = { uid: string; @@ -199,6 +203,58 @@ export const getAllLeftSidebarPersonalSectionConfigs = ( return result; }; + +// TODO(ENG-1471): Remove when migration complete — just use accessor values directly. +// During dual-read, we need old-system UIDs for block CRUD (fold toggle, reorder, delete) +// but read setting VALUES from accessors (which route through the feature flag and log +// mismatches). These helpers merge accessor values onto old-system config objects. +export const mergeGlobalSectionWithAccessor = ( + config: LeftSidebarGlobalSectionConfig, + globalValues: LeftSidebarGlobalSettings | undefined, +): LeftSidebarGlobalSectionConfig => { + if (!config.settings) return config; + return { + ...config, + settings: { + uid: config.settings.uid, + collapsable: { + uid: config.settings.collapsable.uid, + value: + globalValues?.Settings.Collapsable ?? + config.settings.collapsable.value, + }, + folded: { + uid: config.settings.folded.uid, + value: globalValues?.Settings.Folded ?? config.settings.folded.value, + }, + }, + }; +}; + +export const mergePersonalSectionsWithAccessor = ( + sections: LeftSidebarPersonalSectionConfig[], + personalValues: PersonalSection[] | undefined, +): LeftSidebarPersonalSectionConfig[] => { + return sections.map((section, i) => { + const newSection = personalValues?.[i]; + if (!section.settings || !newSection) return section; + return { + ...section, + settings: { + ...section.settings, + truncateResult: { + ...section.settings.truncateResult, + value: newSection.Settings["Truncate-result?"], + }, + folded: { + ...section.settings.folded, + value: newSection.Settings.Folded, + }, + }, + }; + }); +}; + export const getLeftSidebarSettings = ( globalTree: RoamBasicNode[], ): LeftSidebarConfig => {