From 1de8531577c64e62b53712f196e03bdf21507372 Mon Sep 17 00:00:00 2001 From: sid597 Date: Sun, 3 May 2026 10:11:05 +0530 Subject: [PATCH 1/3] ENG-949: Add query sections to the left sidebar Users can paste a ((block-uid)) in the personal settings "Add section" field to create a query section. Query sections auto-create settings (Alias, Folded, Truncate-result, Result-limit) and render query results as clickable children in the sidebar view. Settings panel: inline alias input, block ref display, settings/trash buttons. Settings dialog: Result-limit panel for all sections. Sidebar view: QuerySectionItem with lazy query execution on first expand, refresh via inline menu, go-to-query-block navigation, and proper loading/error/empty states. --- apps/roam/src/components/LeftSidebarView.tsx | 178 +++++++++++++++-- .../settings/LeftSidebarPersonalSettings.tsx | 185 +++++++++++++++++- apps/roam/src/utils/getLeftSidebarSettings.ts | 21 ++ 3 files changed, 366 insertions(+), 18 deletions(-) diff --git a/apps/roam/src/components/LeftSidebarView.tsx b/apps/roam/src/components/LeftSidebarView.tsx index 924e77862..f3af8fbbb 100644 --- a/apps/roam/src/components/LeftSidebarView.tsx +++ b/apps/roam/src/components/LeftSidebarView.tsx @@ -30,9 +30,10 @@ import { notify, subscribe, } from "~/utils/discourseConfigRef"; -import type { - LeftSidebarConfig, - LeftSidebarPersonalSectionConfig, +import { + isQuerySection, + type LeftSidebarConfig, + type LeftSidebarPersonalSectionConfig, } from "~/utils/getLeftSidebarSettings"; import { createBlock } from "roamjs-components/writes"; import deleteBlock from "roamjs-components/writes/deleteBlock"; @@ -48,6 +49,8 @@ import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageU import { migrateLeftSidebarSettings } from "~/utils/migrateLeftSidebarSettings"; import posthog from "posthog-js"; import { commands, cleanCommandName } from "~/components/LeftSidebarCommands"; +import fireQuery from "~/utils/fireQuery"; +import parseQuery from "~/utils/parseQuery"; const parseReference = (text: string) => { const extracted = extractRef(text); @@ -289,6 +292,151 @@ const PersonalSectionItem = ({ ); }; +const QuerySectionItem = ({ + section, + dragHandle, + onloadArgs, +}: { + section: LeftSidebarPersonalSectionConfig; + dragHandle: SortableHandle; + onloadArgs: OnloadArgs; +}) => { + const queryUid = extractRef(section.text); + const alias = section.settings?.alias?.value; + const queryLabel = useMemo(() => getTextByBlockUid(queryUid), [queryUid]); + const displayName = alias || queryLabel || section.text; + const truncateAt = section.settings?.truncateResult.value; + const resultLimit = section.settings?.resultLimit?.value ?? 0; + + const [isOpen, setIsOpen] = useState( + !!section.settings?.folded.value || false, + ); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const [error, setError] = useState(null); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const runQuery = useCallback(() => { + setIsLoading(true); + setError(null); + const args = parseQuery(queryUid); + fireQuery(args) + .then((queryResults) => { + const limited = + resultLimit > 0 ? queryResults.slice(0, resultLimit) : queryResults; + const children: ChildNode[] = limited.map((r) => { + const pageTitle = getPageTitleByPageUid(r.uid); + return { + uid: r.uid, + text: pageTitle ? r.uid : `((${r.uid}))`, + }; + }); + setResults(children); + }) + .catch(() => setError("Query failed to run")) + .finally(() => { + setIsLoading(false); + setHasLoaded(true); + }); + }, [queryUid, resultLimit]); + + useEffect(() => { + if (isOpen && !hasLoaded) { + runQuery(); + } + }, [isOpen, hasLoaded, runQuery]); + + const handleChevronClick = () => { + if (!section.settings) return; + toggleFoldedState({ + isOpen, + setIsOpen, + folded: section.settings.folded, + parentUid: section.settings.uid || "", + }); + }; + + return ( + <> +
+
+
+ {displayName.toUpperCase()} +
+ setIsMenuOpen(next)} + onClose={() => setIsMenuOpen(false)} + popoverClassName="dg-leftsidebar-popover" + minimal + content={ + + { + runQuery(); + setIsMenuOpen(false); + }} + /> + { + void window.roamAlphaAPI.ui.mainWindow.openBlock({ + block: { uid: queryUid }, + }); + setIsMenuOpen(false); + }} + /> + + } + > + + + + + + + +
+
+ + {isLoading ? ( +
Loading…
+ ) : error ? ( +
{error}
+ ) : results.length > 0 ? ( + + ) : hasLoaded ? ( +
No results
+ ) : null} +
+ + ); +}; + const PersonalSections = ({ config, setConfig, @@ -358,14 +506,22 @@ const PersonalSections = ({ getId={(s) => s.uid} onReorder={reorderSections} className="personal-left-sidebar-sections" - renderItem={(section, handle) => ( - - )} + renderItem={(section, handle) => + isQuerySection(section.text) ? ( + + ) : ( + + ) + } /> ); }; diff --git a/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx b/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx index 449d51c59..351b34110 100644 --- a/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx +++ b/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx @@ -46,6 +46,7 @@ import { commands, SidebarCommandPopover, } from "~/components/LeftSidebarCommands"; +import { isQuerySection } from "~/utils/getLeftSidebarSettings"; /* eslint-disable @typescript-eslint/naming-convention */ export const sectionsToBlockProps = ( @@ -60,6 +61,8 @@ export const sectionsToBlockProps = ( Settings: { "Truncate-result?": s.settings?.truncateResult?.value ?? 75, Folded: s.settings?.folded?.value ?? false, + Alias: s.settings?.alias?.value ?? "", + "Result-limit": s.settings?.resultLimit?.value ?? 0, }, })); /* eslint-enable @typescript-eslint/naming-convention */ @@ -98,6 +101,11 @@ const SectionItem = memo( new Set(initiallyExpanded ? [section.uid] : []), ); const isExpanded = expandedChildLists.has(section.uid); + const isQuery = isQuerySection(section.text); + const [aliasValue, setAliasValue] = useState( + section.settings?.alias?.value ?? "", + ); + const aliasUpdateTimeoutRef = useRef>(); const [childSettingsUid, setChildSettingsUid] = useState( null, ); @@ -328,6 +336,54 @@ const SectionItem = memo( setChildInputKey((prev) => prev + 1); }, []); + const handleAliasChange = useCallback( + (newValue: string) => { + setAliasValue(newValue); + + clearTimeout(aliasUpdateTimeoutRef.current); + aliasUpdateTimeoutRef.current = setTimeout(() => { + const currentSection = sectionsRef.current.find( + (s) => s.uid === section.uid, + ); + const alias = currentSection?.settings?.alias; + if (!alias?.uid) return; + const aliasUid = alias.uid; + + void (async () => { + if (alias.valueUid) { + await updateBlock({ uid: alias.valueUid, text: newValue }); + } else { + const newUid = await createBlock({ + parentUid: aliasUid, + order: 0, + node: { text: newValue }, + }); + setSections((prev) => + prev.map((s) => + s.uid === section.uid && s.settings + ? { + ...s, + settings: { + ...s.settings, + alias: { + ...s.settings.alias, + valueUid: newUid, + value: newValue, + }, + }, + } + : s, + ), + ); + } + syncAllSectionsToBlockProps(sectionsRef.current); + refreshAndNotify(); + })(); + }, 300); + }, + [section.uid, sectionsRef, setSections], + ); + const handleAddChild = useCallback(async () => { if (childInput && section.childrenUid) { await addChildToSection(section, section.childrenUid, childInput); @@ -340,6 +396,45 @@ const SectionItem = memo( (!section.settings && section.children?.length === 0) || !section.children; + if (isQuery) { + return ( +
+
+ handleAliasChange(e.target.value)} + placeholder="Alias…" + small + /> + + {section.text} + +
+ +
+
+ ); + } + return (
+ { + const updatedSections = sectionsRef.current.map((s) => + s.uid === activeDialogSection.uid + ? { + ...s, + settings: s.settings + ? { + ...s.settings, + resultLimit: { + ...s.settings.resultLimit, + value, + }, + } + : s.settings, + } + : s, + ); + setSections(updatedSections); + syncAllSectionsToBlockProps(updatedSections); + }} + />
diff --git a/apps/roam/src/utils/getLeftSidebarSettings.ts b/apps/roam/src/utils/getLeftSidebarSettings.ts index 94d018690..ed1dc991f 100644 --- a/apps/roam/src/utils/getLeftSidebarSettings.ts +++ b/apps/roam/src/utils/getLeftSidebarSettings.ts @@ -9,12 +9,19 @@ import { } from "./getExportSettings"; import { getSubTree } from "roamjs-components/util"; +type AliasSetting = StringSetting & { valueUid?: string }; + type LeftSidebarPersonalSectionSettings = { uid: string; truncateResult: IntSetting; folded: BooleanSetting; + alias?: AliasSetting; + resultLimit?: IntSetting; }; +export const isQuerySection = (text: string) => + text.startsWith("((") && text.endsWith("))"); + export type PersonalSectionChild = RoamBasicNode & { alias: StringSetting; }; @@ -119,10 +126,24 @@ const getPersonalSectionSettings = ( text: "Folded", }); + const aliasNode = settingsTree.find((n) => n.text === "Alias"); + const aliasSetting: AliasSetting = { + uid: aliasNode?.uid, + value: aliasNode?.children?.[0]?.text ?? "", + valueUid: aliasNode?.children?.[0]?.uid, + }; + + const resultLimitSetting = getUidAndIntSetting({ + tree: settingsTree, + text: "Result-limit", + }); + return { uid: settingsNode.uid, truncateResult: truncateResultSetting, folded: foldedSetting, + alias: aliasSetting, + resultLimit: resultLimitSetting, }; }; From 212426a6b54fddc736e7cf2e196a33c613b15ace Mon Sep 17 00:00:00 2001 From: sid597 Date: Sun, 3 May 2026 21:59:52 +0530 Subject: [PATCH 2/3] ENG-949: Add eslint-disable for naming-convention on setter param --- .../roam/src/components/settings/LeftSidebarPersonalSettings.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx b/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx index 351b34110..3401e9e8e 100644 --- a/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx +++ b/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx @@ -941,6 +941,7 @@ const LeftSidebarPersonalSectionsContent = ({ order={2} uid={activeDialogSection.settings.resultLimit?.uid} parentUid={activeDialogSection.settings.uid || ""} + // eslint-disable-next-line @typescript-eslint/naming-convention setter={(_keys, value) => { const updatedSections = sectionsRef.current.map((s) => s.uid === activeDialogSection.uid From a27184fd013ba9d4bfe146d441c82347a91d5943 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 4 May 2026 18:36:52 +0530 Subject: [PATCH 3/3] ENG-949: Address review feedback for query sections - Reuse existing runQuery utility instead of inline parseQuery + fireQuery - Apply resultLimit at render so limit changes take effect without refetch - Validate block ref format in isQuerySection to reject malformed refs - Wrap query call in try/catch so sync parseQuery throws don't bypass error state - Persist alias edits to section state before syncing block props --- apps/roam/src/components/LeftSidebarView.tsx | 51 +++++++++---------- .../settings/LeftSidebarPersonalSettings.tsx | 42 +++++++-------- apps/roam/src/utils/getLeftSidebarSettings.ts | 4 +- 3 files changed, 48 insertions(+), 49 deletions(-) diff --git a/apps/roam/src/components/LeftSidebarView.tsx b/apps/roam/src/components/LeftSidebarView.tsx index f3af8fbbb..ea9163bd8 100644 --- a/apps/roam/src/components/LeftSidebarView.tsx +++ b/apps/roam/src/components/LeftSidebarView.tsx @@ -49,8 +49,7 @@ import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageU import { migrateLeftSidebarSettings } from "~/utils/migrateLeftSidebarSettings"; import posthog from "posthog-js"; import { commands, cleanCommandName } from "~/components/LeftSidebarCommands"; -import fireQuery from "~/utils/fireQuery"; -import parseQuery from "~/utils/parseQuery"; +import runQuery from "~/utils/runQuery"; const parseReference = (text: string) => { const extracted = extractRef(text); @@ -317,35 +316,33 @@ const QuerySectionItem = ({ const [error, setError] = useState(null); const [isMenuOpen, setIsMenuOpen] = useState(false); - const runQuery = useCallback(() => { + const loadResults = useCallback(async () => { setIsLoading(true); setError(null); - const args = parseQuery(queryUid); - fireQuery(args) - .then((queryResults) => { - const limited = - resultLimit > 0 ? queryResults.slice(0, resultLimit) : queryResults; - const children: ChildNode[] = limited.map((r) => { - const pageTitle = getPageTitleByPageUid(r.uid); - return { - uid: r.uid, - text: pageTitle ? r.uid : `((${r.uid}))`, - }; - }); - setResults(children); - }) - .catch(() => setError("Query failed to run")) - .finally(() => { - setIsLoading(false); - setHasLoaded(true); + try { + const { allProcessedResults } = await runQuery({ + parentUid: queryUid, + extensionAPI: onloadArgs.extensionAPI, }); - }, [queryUid, resultLimit]); + const children: ChildNode[] = allProcessedResults.map((r) => ({ + uid: r.uid, + text: getPageTitleByPageUid(r.uid) ? r.uid : `((${r.uid}))`, + })); + setResults(children); + } catch (e) { + console.error(e); + setError("Query failed to run"); + } finally { + setIsLoading(false); + setHasLoaded(true); + } + }, [queryUid, onloadArgs.extensionAPI]); useEffect(() => { if (isOpen && !hasLoaded) { - runQuery(); + void loadResults(); } - }, [isOpen, hasLoaded, runQuery]); + }, [isOpen, hasLoaded, loadResults]); const handleChevronClick = () => { if (!section.settings) return; @@ -389,7 +386,7 @@ const QuerySectionItem = ({ icon="refresh" text="Refresh" onClick={() => { - runQuery(); + void loadResults(); setIsMenuOpen(false); }} /> @@ -425,7 +422,9 @@ const QuerySectionItem = ({
{error}
) : results.length > 0 ? ( 0 ? results.slice(0, resultLimit) : results + } truncateAt={truncateAt} onloadArgs={onloadArgs} /> diff --git a/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx b/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx index 3401e9e8e..a702375fe 100644 --- a/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx +++ b/apps/roam/src/components/settings/LeftSidebarPersonalSettings.tsx @@ -350,33 +350,33 @@ const SectionItem = memo( const aliasUid = alias.uid; void (async () => { - if (alias.valueUid) { - await updateBlock({ uid: alias.valueUid, text: newValue }); + let valueUid = alias.valueUid; + if (valueUid) { + await updateBlock({ uid: valueUid, text: newValue }); } else { - const newUid = await createBlock({ + valueUid = await createBlock({ parentUid: aliasUid, order: 0, node: { text: newValue }, }); - setSections((prev) => - prev.map((s) => - s.uid === section.uid && s.settings - ? { - ...s, - settings: { - ...s.settings, - alias: { - ...s.settings.alias, - valueUid: newUid, - value: newValue, - }, - }, - } - : s, - ), - ); } - syncAllSectionsToBlockProps(sectionsRef.current); + const nextSections = sectionsRef.current.map((s) => + s.uid === section.uid && s.settings + ? { + ...s, + settings: { + ...s.settings, + alias: { + ...s.settings.alias, + valueUid, + value: newValue, + }, + }, + } + : s, + ); + setSections(nextSections); + syncAllSectionsToBlockProps(nextSections); refreshAndNotify(); })(); }, 300); diff --git a/apps/roam/src/utils/getLeftSidebarSettings.ts b/apps/roam/src/utils/getLeftSidebarSettings.ts index ed1dc991f..c282efee9 100644 --- a/apps/roam/src/utils/getLeftSidebarSettings.ts +++ b/apps/roam/src/utils/getLeftSidebarSettings.ts @@ -19,8 +19,8 @@ type LeftSidebarPersonalSectionSettings = { resultLimit?: IntSetting; }; -export const isQuerySection = (text: string) => - text.startsWith("((") && text.endsWith("))"); +const BLOCK_REF_PATTERN = /^\(\([\w-]{9,10}\)\)$/; +export const isQuerySection = (text: string) => BLOCK_REF_PATTERN.test(text); export type PersonalSectionChild = RoamBasicNode & { alias: StringSetting;