From 5a8df0a3921e8d7f5692bc046316cf6febe3f81c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:26:44 +0000 Subject: [PATCH 1/2] Initial plan From 47c5d2645a6a07ae9522f5ddbbb48184d6dc4712 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:31:26 +0000 Subject: [PATCH 2/2] feat: add Export first thought and Export subthoughts advanced options Co-authored-by: raineorshine <750276+raineorshine@users.noreply.github.com> --- src/components/modals/Export.tsx | 76 +++++++++++++++++++----- src/selectors/__tests__/exportContext.ts | 47 +++++++++++++++ src/selectors/exportContext.ts | 14 ++++- 3 files changed, 121 insertions(+), 16 deletions(-) diff --git a/src/components/modals/Export.tsx b/src/components/modals/Export.tsx index ebe4f2c9f98..90b80d48b56 100644 --- a/src/components/modals/Export.tsx +++ b/src/components/modals/Export.tsx @@ -30,6 +30,7 @@ import * as selection from '../../device/selection' import globals from '../../globals' import documentSort from '../../selectors/documentSort' import exportContext, { exportFilter } from '../../selectors/exportContext' +import { getChildrenRanked } from '../../selectors/getChildren' import getDescendantThoughtIds from '../../selectors/getDescendantThoughtIds' import hasMulticursor from '../../selectors/hasMulticursor' import simplifyPath from '../../selectors/simplifyPath' @@ -296,6 +297,8 @@ const ModalExport: FC<{ simplePaths: SimplePath[] }> = ({ simplePaths }) => { const [shouldIncludeMetaAttributes, setShouldIncludeMetaAttributes] = useState(false) const [shouldIncludeArchived, setShouldIncludeArchived] = useState(false) const [shouldIncludeMarkdownFormatting, setShouldIncludeMarkdownFormatting] = useState(true) + const [shouldExportFirstThought, setShouldExportFirstThought] = useState(true) + const [shouldExportSubthoughts, setShouldExportSubthoughts] = useState(true) const [selected, setSelected] = useState(exportOptions[0]) const [numDescendantsInState, setNumDescendantsInState] = useState(null) @@ -348,12 +351,20 @@ const ModalExport: FC<{ simplePaths: SimplePath[] }> = ({ simplePaths }) => { // Sort in document order. At this point, all thoughts are pulled and in state. const sortedPaths = documentSort(exportedState, simplePaths) - const exported = sortedPaths - .map(simplePath => - exportContext(exportedState, head(simplePath), selected.type, { + // When shouldExportFirstThought is false, expand each selected path to its children's IDs. + // For single selection this skips the root thought; for multiple selection it skips the entire first level. + const exportIds = !shouldExportFirstThought + ? sortedPaths.flatMap(simplePath => getChildrenRanked(exportedState, head(simplePath)).map(child => child.id)) + : sortedPaths.map(simplePath => head(simplePath)) + + const exported = exportIds + .map(thoughtId => + exportContext(exportedState, thoughtId, selected.type, { excludeArchived: !shouldIncludeArchived, excludeMarkdownFormatting: !shouldIncludeMarkdownFormatting, excludeMeta: !shouldIncludeMetaAttributes, + // maxDepth 0 means only the thought itself with no children + maxDepth: !shouldExportSubthoughts ? 0 : undefined, }), ) .join('\n') @@ -396,15 +407,17 @@ const ModalExport: FC<{ simplePaths: SimplePath[] }> = ({ simplePaths }) => { // when exporting HTML, we have to do a full traversal since the numDescendants heuristic of counting the number of lines in the exported content does not work if (selected.type === 'text/html' && exportedState) { - setNumDescendantsInState( - getDescendantThoughtIds(exportedState, id, { - filterAndTraverse: thought => shouldIncludeMetaAttributes || thought.value !== '=note', - filterFunction: exportFilter({ - excludeArchived: !shouldIncludeArchived, - excludeMeta: !shouldIncludeMetaAttributes, - }), - }).length, - ) + // When subthoughts are excluded, there are no descendants by definition. + const numDescendants = !shouldExportSubthoughts + ? 0 + : getDescendantThoughtIds(exportedState, id, { + filterAndTraverse: thought => shouldIncludeMetaAttributes || thought.value !== '=note', + filterFunction: exportFilter({ + excludeArchived: !shouldIncludeArchived, + excludeMeta: !shouldIncludeMetaAttributes, + }), + }).length + setNumDescendantsInState(numDescendants) } if (!isPulling) { @@ -412,7 +425,14 @@ const ModalExport: FC<{ simplePaths: SimplePath[] }> = ({ simplePaths }) => { } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [selected, shouldIncludeMetaAttributes, shouldIncludeArchived, shouldIncludeMarkdownFormatting], + [ + selected, + shouldIncludeMetaAttributes, + shouldIncludeArchived, + shouldIncludeMarkdownFormatting, + shouldExportFirstThought, + shouldExportSubthoughts, + ], ) useEffect( @@ -551,9 +571,31 @@ const ModalExport: FC<{ simplePaths: SimplePath[] }> = ({ simplePaths }) => { /** Updates archived checkbox value when clicked and set the appropriate value in the selected option. */ const onChangeFormattingCheckbox = () => setShouldIncludeMarkdownFormatting(!shouldIncludeMarkdownFormatting) + /** Toggles whether the first (top-level) thought is included in the export. */ + const onChangeExportFirstThoughtCheckbox = () => setShouldExportFirstThought(!shouldExportFirstThought) + + /** Toggles whether subthoughts (descendants) are included in the export. */ + const onChangeExportSubthoughtsCheckbox = () => setShouldExportSubthoughts(!shouldExportSubthoughts) + /** Created an array of objects so that we can just add object here to get multiple checkbox options created. */ const advancedSettingsArray: AdvancedSetting[] = useMemo( () => [ + { + id: 'exportFirstThought', + onChange: onChangeExportFirstThoughtCheckbox, + checked: shouldExportFirstThought, + title: 'Export first thought', + description: + 'When checked, the top-level thought is included in the export. When unchecked, the first thought is skipped and only its descendants are exported. When multiple thoughts are selected, the entire first level is skipped.', + }, + { + id: 'exportSubthoughts', + onChange: onChangeExportSubthoughtsCheckbox, + checked: shouldExportSubthoughts, + title: 'Export subthoughts', + description: + 'When checked, all subthoughts are included in the export. When unchecked, only the top-level thoughts are exported without any descendants.', + }, { id: 'meta', onChange: onChangeMetaCheckbox, @@ -583,7 +625,13 @@ const ModalExport: FC<{ simplePaths: SimplePath[] }> = ({ simplePaths }) => { ], // eslint-disable-next-line react-hooks/exhaustive-deps - [shouldIncludeArchived, shouldIncludeMetaAttributes, shouldIncludeMarkdownFormatting], + [ + shouldIncludeArchived, + shouldIncludeMetaAttributes, + shouldIncludeMarkdownFormatting, + shouldExportFirstThought, + shouldExportSubthoughts, + ], ) return ( diff --git a/src/selectors/__tests__/exportContext.ts b/src/selectors/__tests__/exportContext.ts index 5a2b9005ec6..dbb070ce71b 100644 --- a/src/selectors/__tests__/exportContext.ts +++ b/src/selectors/__tests__/exportContext.ts @@ -241,3 +241,50 @@ it('export note as a normal thought if lossless not selected', () => { - b - c`) }) + +it('maxDepth 0 exports only the root thought with no children', () => { + const text = ` + - a + - b + - c + ` + + const steps = [importText({ text }), setCursor(['a'])] + const stateNew = reducerFlow(steps)(initialState()) + const exported = exportContext(stateNew, ['a'], 'text/plain', { maxDepth: 0 }) + + expect(exported).toBe(`- a`) +}) + +it('maxDepth 1 exports the root thought and its direct children only', () => { + const text = ` + - a + - b + - c + - d + - e + ` + + const steps = [importText({ text }), setCursor(['a'])] + const stateNew = reducerFlow(steps)(initialState()) + const exported = exportContext(stateNew, ['a'], 'text/plain', { maxDepth: 1 }) + + expect(exported).toBe(`- a + - b + - d`) +}) + +it('maxDepth 0 exports only the root thought as HTML with no children', () => { + const text = ` + - a + - b + ` + + const steps = [importText({ text }), setCursor(['a'])] + const stateNew = reducerFlow(steps)(initialState()) + const exported = exportContext(stateNew, ['a'], 'text/html', { maxDepth: 0 }) + + expect(exported).toBe(``) +}) diff --git a/src/selectors/exportContext.ts b/src/selectors/exportContext.ts index 608a0578952..7fa2b9c1c4a 100644 --- a/src/selectors/exportContext.ts +++ b/src/selectors/exportContext.ts @@ -40,6 +40,13 @@ interface Options { excludeMeta?: boolean /** Exclude archived thoughts. */ excludeArchived?: boolean + /** + * Limits the export depth relative to the root thought. + * When set to 0, only the root thought is exported with no children. + * When set to 1, the root thought and its direct children are exported. + * When undefined, all descendants are exported (default). + */ + maxDepth?: number } /** Exports the navigable subtree of the given context. */ @@ -47,7 +54,7 @@ export const exportContext = ( state: State, contextOrThoughtId: Context | ThoughtId, format: MimeType = 'text/html', - { indent = 0, title, excludeMarkdownFormatting, excludeMeta, excludeSrc, excludeArchived }: Options = {}, + { indent = 0, title, excludeMarkdownFormatting, excludeMeta, excludeSrc, excludeArchived, maxDepth }: Options = {}, ): string => { const linePostfix = format === 'text/html' ? (indent === 0 ? ' ' : '') + '' : '' const tab0 = Array(indent).fill('').join(' ') @@ -61,7 +68,9 @@ export const exportContext = ( const context = Array.isArray(contextOrThoughtId) ? contextOrThoughtId : thoughtToContext(state, thoughtId!) const isNoteAndMetaExcluded = excludeMeta && head(context) === '=note' - const childrenFiltered = children.filter(exportFilter({ excludeArchived, excludeMeta })) + const childrenFiltered = (maxDepth !== undefined && maxDepth <= 0 ? [] : children).filter( + exportFilter({ excludeArchived, excludeMeta }), + ) // Note: export single thought without bullet const linePrefix = format === 'text/html' ? '
  • ' : '- ' @@ -74,6 +83,7 @@ export const exportContext = ( excludeMeta, excludeArchived, excludeMarkdownFormatting, + maxDepth: maxDepth !== undefined ? maxDepth - 1 : undefined, indent: indent + (isNoteAndMetaExcluded ? 0 : format === 'text/html' ? (indent === 0 ? 3 : 2) : 1), })