diff --git a/apps/roam/src/components/LeftSidebarView.tsx b/apps/roam/src/components/LeftSidebarView.tsx
new file mode 100644
index 000000000..d1acaee1e
--- /dev/null
+++ b/apps/roam/src/components/LeftSidebarView.tsx
@@ -0,0 +1,400 @@
+import React, { useRef, useState } from "react";
+import ReactDOM from "react-dom";
+import { Collapse, Icon } from "@blueprintjs/core";
+import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
+import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar";
+import extractRef from "roamjs-components/util/extractRef";
+import { getFormattedConfigTree } from "~/utils/discourseConfigRef";
+import type {
+ LeftSidebarConfig,
+ LeftSidebarPersonalSectionConfig,
+} from "~/utils/getLeftSidebarSettings";
+import { createBlock } from "roamjs-components/writes";
+import deleteBlock from "roamjs-components/writes/deleteBlock";
+import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid";
+
+const parseReference = (text: string) => {
+ const extracted = extractRef(text);
+ if (extracted !== text) {
+ if (text.startsWith("((") && text.endsWith("))")) {
+ return { type: "block" as const, uid: extracted, display: text };
+ } else if (text.startsWith("[[") && text.endsWith("]]")) {
+ return { type: "page" as const, title: extracted, display: extracted };
+ }
+ }
+ return { type: "text" as const, display: text };
+};
+
+const truncate = (s: string, max: number | undefined): string => {
+ if (!max || max <= 0) return s;
+ return s.length > max ? `${s.slice(0, max)}...` : s;
+};
+
+const openTarget = async (
+ e: React.MouseEvent,
+ target: { kind: "block"; uid: string } | { kind: "page"; title: string },
+) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (target.kind === "block") {
+ if (e.shiftKey) {
+ await openBlockInSidebar(target.uid);
+ return;
+ }
+ await window.roamAlphaAPI.ui.mainWindow.openBlock({
+ block: { uid: target.uid },
+ });
+ return;
+ }
+
+ const uid = getPageUidByPageTitle(target.title);
+ if (!uid) return;
+ if (e.shiftKey) {
+ await window.roamAlphaAPI.ui.rightSidebar.addWindow({
+ window: { type: "outline", "block-uid": uid },
+ });
+ } else {
+ await window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } });
+ }
+};
+
+type SectionChildrenProps = {
+ childrenNodes: { uid: string; text: string }[];
+ truncateAt?: number;
+};
+const SectionChildren = ({
+ childrenNodes,
+ truncateAt,
+}: SectionChildrenProps): JSX.Element | null => {
+ if (!childrenNodes?.length) return null;
+ return (
+
+ {childrenNodes.map((child) => {
+ const ref = parseReference(child.text);
+ const label = truncate(ref.display, truncateAt);
+ const onClick = (e: React.MouseEvent) => {
+ if (ref.type === "page")
+ return void openTarget(e, { kind: "page", title: ref.title });
+ if (ref.type === "block")
+ return void openTarget(e, { kind: "block", uid: ref.uid });
+ return void openTarget(e, { kind: "page", title: ref.display });
+ };
+ return (
+
+ );
+ })}
+
+ );
+};
+
+type PersonalSectionItemProps = {
+ section: LeftSidebarPersonalSectionConfig;
+};
+const PersonalSectionItem = ({
+ section,
+}: PersonalSectionItemProps): JSX.Element => {
+ const ref = extractRef(section.text);
+ const blockText = getTextByBlockUid(ref);
+ if (section.isSimple) {
+ const ref = parseReference(section.text);
+ const onClick = (e: React.MouseEvent) => {
+ if (ref.type === "page")
+ return void openTarget(e, { kind: "page", title: ref.title });
+ if (ref.type === "block")
+ return void openTarget(e, { kind: "block", uid: ref.uid });
+ return void openTarget(e, { kind: "page", title: ref.display });
+ };
+ return (
+
+
+ {blockText || ref.display}
+
+
+
+ );
+ }
+
+ const truncateAt = section.settings?.truncateResult.value;
+ const collapsable = !!section.settings?.collapsable.value;
+ const defaultOpen = !!section.settings?.open.value;
+ const [isOpen, setIsOpen] = useState(defaultOpen);
+ const alias = section.settings?.alias?.value;
+ const titleRef = parseReference(section.text);
+ const clickCountRef = useRef(0);
+ const clickTimerRef = useRef(null);
+
+ const handleTitleClick = (e: React.MouseEvent) => {
+ if (e.shiftKey) {
+ if (titleRef.type === "page")
+ return void openTarget(e, { kind: "page", title: titleRef.title });
+ if (titleRef.type === "block")
+ return void openTarget(e, { kind: "block", uid: titleRef.uid });
+ return void openTarget(e, { kind: "page", title: titleRef.display });
+ }
+
+ clickCountRef.current += 1;
+ if (clickTimerRef.current) window.clearTimeout(clickTimerRef.current);
+
+ clickTimerRef.current = window.setTimeout(() => {
+ if (clickCountRef.current === 1) {
+ if (collapsable) {
+ setIsOpen((prev) => !prev);
+ if (section.settings?.open.uid) {
+ deleteBlock(section.settings.open.uid);
+ section.settings.open.uid = undefined;
+ section.settings.open.value = false;
+ } else {
+ if (section.settings?.uid) {
+ const newUid = window.roamAlphaAPI.util.generateUID();
+ createBlock({
+ parentUid: section.settings?.uid,
+ node: {
+ text: "Open?",
+ uid: newUid,
+ },
+ });
+ section.settings.open.uid = newUid;
+ section.settings.open.value = true;
+ }
+ }
+ }
+ } else {
+ if (titleRef.type === "page")
+ void window.roamAlphaAPI.ui.mainWindow.openPage({
+ page: { uid: getPageUidByPageTitle(titleRef.title) },
+ });
+ else if (titleRef.type === "block")
+ void window.roamAlphaAPI.ui.mainWindow.openBlock({
+ block: { uid: titleRef.uid },
+ });
+ }
+ clickCountRef.current = 0;
+ clickTimerRef.current = null;
+ }, 250);
+ };
+
+ return (
+
+
+
+ {alias || blockText || titleRef.display}
+ {collapsable && (section.children?.length || 0) > 0 && (
+
+
+
+ )}
+
+
+
+ {collapsable ? (
+
+
+
+ ) : (
+
+ )}
+
+ );
+};
+
+type PersonalSectionsProps = {
+ config: LeftSidebarConfig["personal"];
+};
+const PersonalSections = ({
+ config,
+}: PersonalSectionsProps): JSX.Element | null => {
+ const sections = config.sections || [];
+ if (!sections.length) return null;
+ return (
+
+ {sections.map((s) => (
+
+ ))}
+
+ );
+};
+
+type GlobalSectionProps = {
+ config: LeftSidebarConfig["global"];
+};
+const GlobalSection = ({ config }: GlobalSectionProps): JSX.Element | null => {
+ const [isOpen, setIsOpen] = useState(!!config.open.value);
+ if (!config.children?.length) return null;
+
+ return (
+
+
setIsOpen((p) => !p)}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ background: "transparent",
+ border: "none",
+ cursor: "pointer",
+ fontSize: 14,
+ fontWeight: 600,
+ padding: "4px 0",
+ width: "100%",
+ outline: "none",
+ transition: "color 0.2s ease-in",
+ }}
+ >
+
+ Global
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const LeftSidebarView = (): JSX.Element | null => {
+ const config = getFormattedConfigTree().leftSidebar;
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
+
+export const mountLeftSidebarInto = (wrapper: HTMLElement): void => {
+ if (!wrapper) return;
+
+ const existingStarred = wrapper.querySelector(
+ ".starred-pages",
+ ) as HTMLDivElement | null;
+ if (existingStarred && !existingStarred.id.includes("dg-left-sidebar-root")) {
+ try {
+ existingStarred.remove();
+ } catch (e) {
+ console.warn(
+ "[DG][LeftSidebar] failed to remove default starred-pages",
+ e,
+ );
+ }
+ }
+
+ const id = "dg-left-sidebar-root";
+ let root = wrapper.querySelector(`#${id}`) as HTMLDivElement | null;
+ if (!root) {
+ root = document.createElement("div");
+ root.id = id;
+ root.className = "starred-pages";
+ root.style.overflow = "scroll";
+ root.onmousedown = (e) => e.stopPropagation();
+ wrapper.appendChild(root);
+ } else {
+ root.className = "starred-pages";
+ root.style.overflow = "scroll";
+ }
+
+ setTimeout(() => {
+ try {
+ ReactDOM.render(, root);
+ } catch (e) {
+ console.error("[DG][LeftSidebar] render error", e);
+ }
+ }, 500);
+};
+
+export default LeftSidebarView;
diff --git a/apps/roam/src/components/settings/LeftSidebar.tsx b/apps/roam/src/components/settings/LeftSidebar.tsx
new file mode 100644
index 000000000..e27186c22
--- /dev/null
+++ b/apps/roam/src/components/settings/LeftSidebar.tsx
@@ -0,0 +1,643 @@
+import {
+ FormattedConfigTree,
+ getFormattedConfigTree,
+} from "~/utils/discourseConfigRef";
+import refreshConfigTree from "~/utils/refreshConfigTree";
+import React, { useCallback, useEffect, useState } from "react";
+import FlagPanel from "roamjs-components/components/ConfigPanels/FlagPanel";
+import AutocompleteInput from "roamjs-components/components/AutocompleteInput";
+import getAllPageNames from "roamjs-components/queries/getAllPageNames";
+import { Button, Dialog, Collapse } from "@blueprintjs/core";
+import createBlock from "roamjs-components/writes/createBlock";
+import deleteBlock from "roamjs-components/writes/deleteBlock";
+import type { RoamBasicNode } from "roamjs-components/types";
+import NumberPanel from "roamjs-components/components/ConfigPanels/NumberPanel";
+import TextPanel from "roamjs-components/components/ConfigPanels/TextPanel";
+import { ensureLeftSidebarReady } from "~/utils/ensureLeftSidebarStructure";
+import {
+ LeftSidebarGlobalSectionConfig,
+ LeftSidebarPersonalSectionConfig,
+} from "~/utils/getLeftSidebarSettings";
+import { extractRef } from "roamjs-components/util";
+import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid";
+
+const SectionItem = ({
+ section,
+ expandedChildLists,
+ convertToComplexSection,
+ setSettingsDialogSectionUid,
+ removeSection,
+ toggleChildrenList,
+}: {
+ section: LeftSidebarPersonalSectionConfig;
+ expandedChildLists: Set;
+ convertToComplexSection: (section: LeftSidebarPersonalSectionConfig) => void;
+ setSettingsDialogSectionUid: (uid: string | null) => void;
+ removeSection: (section: LeftSidebarPersonalSectionConfig) => void;
+ toggleChildrenList: (uid: string) => void;
+}) => {
+ const ref = extractRef(section.text);
+ const blockText = getTextByBlockUid(ref);
+ const alias = section.settings?.alias?.value;
+
+ return (
+
+
+
+ {blockText || section.text}
+ {alias && Alias: ({alias})}
+
+
+ {section.isSimple ? (
+
+
+
+ {!section.isSimple && (section.children?.length || 0) > 0 && (
+
+
+ toggleChildrenList(section.uid)}
+ >
+ Children ({section.children?.length || 0})
+
+ setSettingsDialogSectionUid(section.uid)}
+ title="Manage children"
+ />
+
+
+
+ {(section.children || []).map((child) => (
+
+ {child.text}
+
+ ))}
+
+
+
+ )}
+
+ );
+};
+
+type LeftSidebarPersonalConfig = {
+ uid: string;
+ sections: LeftSidebarPersonalSectionConfig[];
+};
+
+export const useLeftSidebarConfig = () => {
+ const [isInitialized, setIsInitialized] = useState(false);
+ const [settings, setSettings] = useState(null);
+ const [error, setError] = useState(null);
+
+ const refreshSettings = useCallback(() => {
+ refreshConfigTree();
+ const config = getFormattedConfigTree();
+ setSettings(config);
+ return config;
+ }, []);
+
+ useEffect(() => {
+ const initialize = async () => {
+ try {
+ await ensureLeftSidebarReady();
+ const config = getFormattedConfigTree();
+ setSettings(config);
+ setIsInitialized(true);
+ } catch (err) {
+ setError(err as Error);
+ const config = getFormattedConfigTree();
+ setSettings(config);
+ }
+ };
+
+ void initialize();
+ }, []);
+
+ return {
+ settings,
+ setSettings,
+ refreshSettings,
+ isInitialized,
+ error,
+ };
+};
+
+const LeftSidebarGlobalSectionsContent = ({
+ globalSection,
+ refreshSettings,
+}: {
+ globalSection: LeftSidebarGlobalSectionConfig;
+ refreshSettings: () => FormattedConfigTree;
+}) => {
+ const parentUid = globalSection.uid;
+
+ const [pages, setPages] = useState(globalSection.children);
+ const [newPageInputs, setNewPageInputs] = useState>(
+ {},
+ );
+ const [autocompleteKeys, setAutocompleteKeys] = useState<
+ Record
+ >({});
+
+ useEffect(() => {
+ setPages(globalSection.children);
+ }, [globalSection.children]);
+
+ const refreshPages = useCallback(() => {
+ const newConfig = refreshSettings();
+ setPages(newConfig.leftSidebar.global.children);
+ }, [refreshSettings]);
+
+ const addPage = async (page: string) => {
+ if (!page || pages.some((p) => p.text === page)) {
+ return;
+ }
+ try {
+ await createBlock({
+ parentUid: globalSection.childrenUid,
+ order: "last",
+ node: { text: page },
+ });
+ refreshPages();
+ setNewPageInputs((prev) => ({
+ ...prev,
+ [globalSection.childrenUid]: "",
+ }));
+ setAutocompleteKeys((prev) => ({
+ ...prev,
+ [globalSection.childrenUid]: (prev[globalSection.childrenUid] || 0) + 1,
+ }));
+ } catch (error) {
+ console.error("Failed to add page:", error);
+ }
+ };
+
+ const removePage = useCallback(
+ async (page: RoamBasicNode) => {
+ try {
+ await deleteBlock(page.uid);
+ refreshPages();
+ } catch (error) {
+ console.error("Failed to remove page:", error);
+ }
+ },
+ [refreshPages],
+ );
+
+ const getPageInput = () => newPageInputs[globalSection.childrenUid] || "";
+ const setPageInput = useCallback(
+ (value: string) => {
+ setTimeout(() => {
+ setNewPageInputs((prev) => ({
+ ...prev,
+ [globalSection.childrenUid]: value,
+ }));
+ }, 0);
+ },
+ [globalSection.childrenUid],
+ );
+ const getAutocompleteKey = () =>
+ autocompleteKeys[globalSection.childrenUid] || 0;
+
+ return (
+
+
+
{
+ if (e.key === "Enter" && getPageInput()) {
+ e.preventDefault();
+ e.stopPropagation();
+ addPage(getPageInput());
+ }
+ }}
+ >
+
+
p.text === getPageInput())
+ }
+ onClick={() => void addPage(getPageInput())}
+ />
+
+
+ {pages.map((p) => (
+
+ {p.text}
+ void removePage(p)}
+ />
+
+ ))}
+
+
+ );
+};
+
+export const LeftSidebarGlobalSections = () => {
+ const { settings, refreshSettings, isInitialized, error } =
+ useLeftSidebarConfig();
+
+ if (error) {
+ console.error("Failed to initialize left sidebar structure:", error);
+ }
+
+ if (!isInitialized || !settings) {
+ return Loading settings...
;
+ }
+
+ return (
+
+ );
+};
+
+const LeftSidebarPersonalSectionsContent = ({
+ personalSection,
+ refreshSettings,
+}: {
+ personalSection: LeftSidebarPersonalConfig;
+ refreshSettings: () => FormattedConfigTree;
+}) => {
+ const [sections, setSections] = useState(
+ personalSection.sections || [],
+ );
+ const [newSectionInput, setNewSectionInput] = useState("");
+ const [autocompleteKey, setAutocompleteKey] = useState(0);
+ const [sectionChildInputs, setSectionChildInputs] = useState<
+ Record
+ >({});
+ const [childAutocompleteKeys, setChildAutocompleteKeys] = useState<
+ Record
+ >({});
+ const [settingsDialogSectionUid, setSettingsDialogSectionUid] = useState<
+ string | null
+ >(null);
+ const [expandedChildLists, setExpandedChildLists] = useState>(
+ new Set(),
+ );
+
+ useEffect(() => {
+ setSections(personalSection.sections || []);
+ }, [personalSection.sections]);
+
+ const refreshSections = useCallback(() => {
+ const newConfig = refreshSettings();
+ setSections(newConfig.leftSidebar.personal.sections || []);
+ }, [refreshSettings]);
+
+ const addSection = async (sectionName: string) => {
+ if (!sectionName || sections.some((s) => s.text === sectionName)) {
+ return;
+ }
+
+ try {
+ await createBlock({
+ parentUid: personalSection.uid,
+ order: "last",
+ node: { text: sectionName },
+ });
+ refreshSections();
+ setNewSectionInput("");
+ setAutocompleteKey((prev) => prev + 1);
+ } catch (error) {
+ console.error("Failed to add section:", error);
+ }
+ };
+
+ const removeSection = useCallback(
+ async (section: LeftSidebarPersonalSectionConfig) => {
+ try {
+ await deleteBlock(section.uid);
+ refreshSections();
+ } catch (error) {
+ console.error("Failed to remove section:", error);
+ }
+ },
+ [refreshSections],
+ );
+
+ const toggleChildrenList = (sectionUid: string) => {
+ setExpandedChildLists((prev) => {
+ const next = new Set(prev);
+ if (next.has(sectionUid)) next.delete(sectionUid);
+ else next.add(sectionUid);
+ return next;
+ });
+ };
+
+ const convertToComplexSection = async (
+ section: LeftSidebarPersonalSectionConfig,
+ ) => {
+ try {
+ await createBlock({
+ parentUid: section.uid,
+ order: 0,
+ node: {
+ text: "Settings",
+ children: [
+ { text: "Collapsable?", children: [{ text: "true" }] },
+ { text: "Open?", children: [{ text: "true" }] },
+ { text: "Truncate-result?", children: [{ text: "75" }] },
+ ],
+ },
+ });
+
+ await createBlock({
+ parentUid: section.uid,
+ order: 1,
+ node: { text: "Children" },
+ });
+
+ refreshSections();
+ setSettingsDialogSectionUid(section.uid);
+ } catch (error) {
+ console.error("Failed to convert to complex section:", error);
+ }
+ };
+
+ const addChildToSection = async (childrenUid: string, childName: string) => {
+ if (!childName) return;
+
+ try {
+ await createBlock({
+ parentUid: childrenUid,
+ order: "last",
+ node: { text: childName },
+ });
+ refreshSections();
+ setSectionChildInputs((prev) => ({ ...prev, [childrenUid]: "" }));
+ setChildAutocompleteKeys((prev) => ({
+ ...prev,
+ [childrenUid]: (prev[childrenUid] || 0) + 1,
+ }));
+ } catch (error) {
+ console.error("Failed to add child:", error);
+ }
+ };
+
+ const removeChild = async (child: RoamBasicNode) => {
+ try {
+ await deleteBlock(child.uid);
+ refreshSections();
+ } catch (error) {
+ console.error("Failed to remove child:", error);
+ }
+ };
+
+ const renderSectionSettings = (section: LeftSidebarPersonalSectionConfig) => {
+ if (section.isSimple || !section.settings) return null;
+
+ return (
+
+
+
+
+
+
+ );
+ };
+
+ const renderSectionChildren = (section: LeftSidebarPersonalSectionConfig) => {
+ if (section.isSimple || !section.children) return null;
+
+ const inputKey = section.childrenUid!;
+ const childInput = sectionChildInputs[inputKey] || "";
+ const setChildInput = (value: string) => {
+ setTimeout(() => {
+ setSectionChildInputs((prev) => ({ ...prev, [inputKey]: value }));
+ }, 0);
+ };
+
+ return (
+
+
+ Children Pages
+
+
{
+ if (e.key === "Enter" && childInput) {
+ e.preventDefault();
+ e.stopPropagation();
+ addChildToSection(section.childrenUid!, childInput);
+ }
+ }}
+ >
+
+
addChildToSection(section.childrenUid!, childInput)}
+ />
+
+
+ {(section.children || []).map((child) => (
+
+ {child.text}
+ removeChild(child)}
+ />
+
+ ))}
+
+
+ );
+ };
+
+ const activeDialogSection =
+ sections.find((s) => s.uid === settingsDialogSectionUid) || null;
+ const ref = extractRef(activeDialogSection?.text);
+ const blockText = getTextByBlockUid(ref);
+ const alias = activeDialogSection?.settings?.alias?.value;
+
+ return (
+
+
+
+ Add pages or create custom sections with settings and children
+
+
{
+ if (e.key === "Enter" && newSectionInput) {
+ e.preventDefault();
+ e.stopPropagation();
+ addSection(newSectionInput);
+ }
+ }}
+ >
+
+
s.text === newSectionInput)
+ }
+ onClick={() => addSection(newSectionInput)}
+ />
+
+
+
+
+ {sections.map((section) => (
+
+ ))}
+
+
+ {activeDialogSection && (
+
+ )}
+
+ );
+};
+
+export const LeftSidebarPersonalSections = () => {
+ const { settings, refreshSettings, isInitialized, error } =
+ useLeftSidebarConfig();
+
+ if (!isInitialized || !settings) {
+ return Loading settings...
;
+ }
+
+ if (error) {
+ console.error("Failed to initialize left sidebar structure:", error);
+ }
+
+ return (
+
+ );
+};
diff --git a/apps/roam/src/components/settings/Settings.tsx b/apps/roam/src/components/settings/Settings.tsx
index 058c5f103..7cffa9f43 100644
--- a/apps/roam/src/components/settings/Settings.tsx
+++ b/apps/roam/src/components/settings/Settings.tsx
@@ -25,6 +25,10 @@ import sendErrorEmail from "~/utils/sendErrorEmail";
import HomePersonalSettings from "./HomePersonalSettings";
import refreshConfigTree from "~/utils/refreshConfigTree";
import { FeedbackWidget } from "~/components/BirdEatsBugs";
+import {
+ LeftSidebarGlobalSections,
+ LeftSidebarPersonalSections,
+} from "./LeftSidebar";
import { getVersionWithDate } from "~/utils/getVersion";
type SectionHeaderProps = {
@@ -161,6 +165,20 @@ export const SettingsDialog = ({
className="overflow-y-auto"
panel={}
/>
+
+ Left Sidebar
+ }
+ />
+ }
+ />
Grammar
{
@@ -47,6 +52,7 @@ export const getFormattedConfigTree = (): FormattedConfigTree => {
tree: configTreeRef.tree,
text: "Canvas Page Format",
}),
+ leftSidebar: getLeftSidebarSettings(configTreeRef.tree),
};
};
export default configTreeRef;
diff --git a/apps/roam/src/utils/ensureLeftSidebarStructure.ts b/apps/roam/src/utils/ensureLeftSidebarStructure.ts
new file mode 100644
index 000000000..fe72b4965
--- /dev/null
+++ b/apps/roam/src/utils/ensureLeftSidebarStructure.ts
@@ -0,0 +1,119 @@
+import createBlock from "roamjs-components/writes/createBlock";
+import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
+import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid";
+import { DISCOURSE_CONFIG_PAGE_TITLE } from "~/utils/renderNodeConfigPage";
+import { getLeftSidebarPersonalSectionConfig } from "./getLeftSidebarSettings";
+import { getLeftSidebarGlobalSectionConfig } from "./getLeftSidebarSettings";
+import { LeftSidebarConfig } from "./getLeftSidebarSettings";
+import { RoamBasicNode } from "roamjs-components/types";
+import getCurrentUserDisplayName from "roamjs-components/queries/getCurrentUserDisplayName";
+import { getSubTree } from "roamjs-components/util";
+import refreshConfigTree from "~/utils/refreshConfigTree";
+
+export const ensureLeftSidebarStructure = async (): Promise => {
+ const configPageUid = getPageUidByPageTitle(DISCOURSE_CONFIG_PAGE_TITLE);
+ const configTree = getBasicTreeByParentUid(configPageUid);
+ const userName = getCurrentUserDisplayName();
+
+ let sidebarNode = configTree.find((n) => n.text === "Left Sidebar");
+
+ if (!sidebarNode) {
+ await createBlock({
+ parentUid: configPageUid,
+ node: {
+ text: "Left Sidebar",
+ children: [
+ {
+ text: "Global Section",
+ children: [
+ { text: "Open", children: [{ text: "false" }] },
+ { text: "Children" },
+ ],
+ },
+ {
+ text: userName + "/Personal Section",
+ },
+ ],
+ },
+ });
+ } else {
+ const hasGlobalSection = sidebarNode.children?.some(
+ (n) => n.text === "Global Section",
+ );
+ const hasPersonalSection = sidebarNode.children?.some(
+ (n) => n.text === userName + "/Personal Section",
+ );
+
+ if (!hasGlobalSection) {
+ await createBlock({
+ parentUid: sidebarNode.uid,
+ order: 0,
+ node: {
+ text: "Global Section",
+ children: [
+ { text: "Open", children: [{ text: "false" }] },
+ { text: "Children" },
+ ],
+ },
+ });
+ }
+
+ if (!hasPersonalSection) {
+ await createBlock({
+ parentUid: sidebarNode.uid,
+ node: { text: userName + "/Personal Section" },
+ });
+ }
+ }
+};
+
+let ensureLeftSidebarReadyPromise: Promise | null = null;
+
+export const ensureLeftSidebarReady = (): Promise => {
+ if (!ensureLeftSidebarReadyPromise) {
+ ensureLeftSidebarReadyPromise = (async () => {
+ await ensureLeftSidebarStructure();
+ refreshConfigTree();
+ })().catch((e) => {
+ ensureLeftSidebarReadyPromise = null;
+ throw e;
+ });
+ }
+ return ensureLeftSidebarReadyPromise;
+};
+
+export const getLeftSidebarSettingsWithDefaults = (
+ globalTree: RoamBasicNode[],
+): LeftSidebarConfig => {
+ const leftSidebarNode = globalTree.find(
+ (node) => node.text === "Left Sidebar",
+ );
+ const leftSidebarChildren = leftSidebarNode?.children || [];
+ const userName = getCurrentUserDisplayName();
+
+ const personalLeftSidebarNode = getSubTree({
+ tree: leftSidebarChildren,
+ key: userName + "/Personal Section",
+ });
+
+ const global = getLeftSidebarGlobalSectionConfig(leftSidebarChildren) || {
+ uid: "",
+ open: { uid: "", value: false },
+ children: [],
+ childrenUid: "",
+ };
+
+ const personal = getLeftSidebarPersonalSectionConfig(
+ personalLeftSidebarNode,
+ ) || {
+ uid: "",
+ text: "",
+ isSimple: true,
+ sections: [],
+ };
+
+ return {
+ global,
+ personal,
+ };
+};
diff --git a/apps/roam/src/utils/getLeftSidebarSettings.ts b/apps/roam/src/utils/getLeftSidebarSettings.ts
new file mode 100644
index 000000000..414369086
--- /dev/null
+++ b/apps/roam/src/utils/getLeftSidebarSettings.ts
@@ -0,0 +1,172 @@
+import { RoamBasicNode } from "roamjs-components/types";
+import {
+ BooleanSetting,
+ getUidAndBooleanSetting,
+ getUidAndStringSetting,
+ IntSetting,
+ getUidAndIntSetting,
+ StringSetting,
+} from "./getExportSettings";
+import { getSubTree } from "roamjs-components/util";
+import getCurrentUserDisplayName from "roamjs-components/queries/getCurrentUserDisplayName";
+
+export type LeftSidebarPersonalSectionSettings = {
+ uid: string;
+ truncateResult: IntSetting;
+ collapsable: BooleanSetting;
+ open: BooleanSetting;
+ alias: StringSetting | null;
+};
+
+export type LeftSidebarPersonalSectionConfig = {
+ uid: string;
+ text: string;
+ isSimple: boolean;
+ settings?: LeftSidebarPersonalSectionSettings;
+ children?: RoamBasicNode[];
+ childrenUid?: string;
+};
+
+export type LeftSidebarGlobalSectionConfig = {
+ uid: string;
+ open: BooleanSetting;
+ children: RoamBasicNode[];
+ childrenUid: string;
+};
+
+export type LeftSidebarConfig = {
+ global: LeftSidebarGlobalSectionConfig;
+ personal: {
+ uid: string;
+ sections: LeftSidebarPersonalSectionConfig[];
+ };
+};
+
+export const getLeftSidebarSettings = (
+ globalTree: RoamBasicNode[],
+): LeftSidebarConfig => {
+ const leftSidebarNode = globalTree.find(
+ (node) => node.text === "Left Sidebar",
+ );
+ const leftSidebarChildren = leftSidebarNode?.children || [];
+ const userName = getCurrentUserDisplayName();
+
+ const personalLeftSidebarNode = getSubTree({
+ tree: leftSidebarChildren,
+ key: userName + "/Personal Section",
+ });
+
+ const global = getLeftSidebarGlobalSectionConfig(leftSidebarChildren);
+ const personal = getLeftSidebarPersonalSectionConfig(personalLeftSidebarNode);
+
+ return {
+ global,
+ personal,
+ };
+};
+
+export const getLeftSidebarGlobalSectionConfig = (
+ leftSidebarChildren: RoamBasicNode[],
+): LeftSidebarGlobalSectionConfig => {
+ const globalSectionNode = leftSidebarChildren.find(
+ (node) => node.text === "Global Section",
+ );
+ const globalChildren = globalSectionNode?.children || [];
+
+ const getBoolean = (text: string) =>
+ getUidAndBooleanSetting({ tree: globalChildren, text });
+
+ const childrenNode = globalChildren.find((node) => node.text === "Children");
+
+ return {
+ uid: globalSectionNode?.uid || "",
+ open: getBoolean("Open"),
+ children: childrenNode?.children || [],
+ childrenUid: childrenNode?.uid || "",
+ };
+};
+
+export const getLeftSidebarPersonalSectionConfig = (
+ personalContainerNode: RoamBasicNode,
+): { uid: string; sections: LeftSidebarPersonalSectionConfig[] } => {
+ const sections = (personalContainerNode?.children || []).map(
+ (sectionNode): LeftSidebarPersonalSectionConfig => {
+ const hasSettings = sectionNode.children?.some(
+ (child) => child.text === "Settings",
+ );
+ const childrenNode = sectionNode.children?.find(
+ (child) => child.text === "Children",
+ );
+
+ const isSimple = !hasSettings && !childrenNode;
+
+ if (isSimple) {
+ return {
+ uid: sectionNode.uid,
+ text: sectionNode.text,
+ isSimple: true,
+ };
+ } else {
+ return {
+ uid: sectionNode.uid,
+ text: sectionNode.text,
+ isSimple: false,
+ settings: getPersonalSectionSettings(sectionNode),
+ children: childrenNode?.children || [],
+ childrenUid: childrenNode?.uid || "",
+ };
+ }
+ },
+ );
+
+ return {
+ uid: personalContainerNode?.uid || "",
+ sections,
+ };
+};
+
+export const getPersonalSectionSettings = (
+ personalSectionNode: RoamBasicNode,
+): LeftSidebarPersonalSectionSettings => {
+ const settingsNode = personalSectionNode.children?.find(
+ (node) => node.text === "Settings",
+ );
+ const settingsTree = settingsNode?.children || [];
+
+ const getInt = (text: string) =>
+ getUidAndIntSetting({ tree: settingsTree, text });
+ const getBoolean = (text: string) =>
+ getUidAndBooleanSetting({ tree: settingsTree, text });
+ const getString = (text: string) =>
+ getUidAndStringSetting({ tree: settingsTree, text });
+
+ const truncateResultSetting = getInt("Truncate-result?");
+ if (!settingsNode?.uid || !truncateResultSetting.uid) {
+ truncateResultSetting.value = 75;
+ }
+
+ const collapsableSetting = getBoolean("Collapsable?");
+ if (!settingsNode?.uid || !collapsableSetting.uid) {
+ collapsableSetting.value = false;
+ }
+
+ const openSetting = getBoolean("Open?");
+ if (!settingsNode?.uid || !openSetting.uid) {
+ openSetting.value = false;
+ }
+
+ const aliasString = getString("Alias");
+ const alias =
+ aliasString.value === "Alias" ||
+ (aliasString.value === "" && aliasString.uid)
+ ? null
+ : aliasString;
+
+ return {
+ uid: settingsNode?.uid || "",
+ truncateResult: truncateResultSetting,
+ collapsable: collapsableSetting,
+ open: openSetting,
+ alias,
+ };
+};
diff --git a/apps/roam/src/utils/initializeObserversAndListeners.ts b/apps/roam/src/utils/initializeObserversAndListeners.ts
index bce287b0a..69b08fdcc 100644
--- a/apps/roam/src/utils/initializeObserversAndListeners.ts
+++ b/apps/roam/src/utils/initializeObserversAndListeners.ts
@@ -44,6 +44,7 @@ import {
findBlockElementFromSelection,
} from "~/utils/renderTextSelectionPopup";
import { renderNodeTagPopupButton } from "./renderNodeTagPopup";
+import { mountLeftSidebarInto } from "~/components/LeftSidebarView";
const debounce = (fn: () => void, delay = 250) => {
let timeout: number;
@@ -106,6 +107,32 @@ export const initObservers = async ({
},
});
+ const attemptImmediateMount = () => {
+ try {
+ const candidate = document.querySelector(
+ ".roam-sidebar-container .starred-pages-wrapper",
+ ) as HTMLDivElement | null;
+ if (candidate) mountLeftSidebarInto(candidate);
+ } catch (e) {
+ console.error("[DG][LeftSidebar] attemptImmediateMount error", e);
+ }
+ };
+ attemptImmediateMount();
+
+ const leftSidebarObserver = createHTMLObserver({
+ tag: "DIV",
+ useBody: true,
+ className: "starred-pages-wrapper",
+ callback: (el) => {
+ try {
+ const container = el as HTMLDivElement;
+ mountLeftSidebarInto(container);
+ } catch (e) {
+ console.error("[DG][LeftSidebar] leftSidebarObserver error", e);
+ }
+ },
+ });
+
const pageActionListener = ((
e: CustomEvent<{
action: string;
@@ -316,6 +343,7 @@ export const initObservers = async ({
linkedReferencesObserver,
graphOverviewExportObserver,
nodeTagPopupButtonObserver,
+ leftSidebarObserver,
].filter((o): o is MutationObserver => !!o),
listeners: {
pageActionListener,