Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f9f9562
feat(pin): add gesture shortcut for pin command
raineorshine Aug 17, 2026
6c59d63
Bump the selected thoughts' parent down on multiselect (#4896)
Copilot Aug 17, 2026
cf4a256
Fix delayed autofocus cleanup so hidden thoughts are shimmed (#4969)
marcus-pousette Aug 17, 2026
03a1ce1
test: make swapParent Created-sort test deterministic across machine …
raineorshine Aug 17, 2026
a195c97
Allow New Subthought (next) to execute on a multiselect (#4927)
raineorshine Aug 17, 2026
09ff8a0
Allow New Grandchild to execute on a multiselect (#4928)
raineorshine Aug 17, 2026
b6902cf
Create a new thought for each selected thought (#4929)
raineorshine Aug 17, 2026
8fab64f
Create a new thought above each selected thought (#4933)
raineorshine Aug 17, 2026
80bc044
Allow Bind Context to execute on a multiselect (#4934)
raineorshine Aug 17, 2026
ef9dc8e
Allow Extract to execute on a multiselect (#4935)
raineorshine Aug 17, 2026
c0b5d26
Allow New Subthought (above) to execute on a multiselect (#4926)
raineorshine Aug 17, 2026
36ca166
Select parents on Cursor Back when thoughts are multiselected (#4938)
raineorshine Aug 17, 2026
9445316
Allow Generate Thought to execute on a multiselect (#4936)
raineorshine Aug 17, 2026
54aea5b
Merge upstream main
marcus-pousette Aug 17, 2026
d95ce67
Fix cursorless Generate Thought test setup (#4978)
marcus-pousette Aug 18, 2026
54ac8f6
Create a new subthought for each selected thought (#4932)
raineorshine Aug 18, 2026
5588144
chore(deps): upgrade lottie-react to v3 (#4971)
raineorshine Aug 18, 2026
feeb2ba
chore(deps-dev): bump expect-webdriverio from 5.7.0 to 6.0.3 (#4973)
raineorshine Aug 18, 2026
771f265
Run VirtualThought's size-release cleanup only on unmount (#4975)
raineorshine Aug 18, 2026
41ad6ad
Only adjust the drag hoverCount on an actual hover transition (#4977)
raineorshine Aug 18, 2026
ebdfdef
Fix emoji spacing immediately after paste (#4974)
patbyte Aug 18, 2026
5307fed
Merge latest upstream main
marcus-pousette Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ Both filter `globalCommands` by name and respect `hideFromDesktopCommandUniverse

When `state.multicursors` is non-empty, the user has one or more thoughts selected. A selection of exactly one thought is common — on mobile, opening the Command Center selects the cursor thought. Every command must declare how it behaves in this case via the required `multicursor` field — there is no implicit default.

- **`multicursor: false`** — execute on `state.cursor` as if no multicursor existed; selection stays. For commands that don't interact with the thoughtspace (e.g. opening modals). The cursor-navigation commands also declare `multicursor: false` yet still respond to a selection, since navigating a multiselect means moving the selection itself rather than executing once per selected thought: [`cursorUp`](../src/commands/cursorUp.ts) and [`cursorDown`](../src/commands/cursorDown.ts) read `state.multicursors` in their own `exec` to extend or collapse the selection, and the [`cursorForward`](../src/actions/cursorForward.ts) reducer replaces the selection with the thoughts one level forward.
- **`multicursor: false`** — execute on `state.cursor` as if no multicursor existed; selection stays. For commands that don't interact with the thoughtspace (e.g. opening modals). The cursor-navigation commands also declare `multicursor: false` yet still respond to a selection, since navigating a multiselect means moving the selection itself rather than executing once per selected thought: [`cursorUp`](../src/commands/cursorUp.ts) and [`cursorDown`](../src/commands/cursorDown.ts) read `state.multicursors` in their own `exec` to extend or collapse the selection, and the [`cursorForward`](../src/actions/cursorForward.ts) and [`cursorBack`](../src/actions/cursorBack.ts) reducers replace the selection with the thoughts one level forward or back.
- **`multicursor: true`** — execute once per selected thought.
- **`multicursor: { ... }`** — fine-grained control with these options:

Expand All @@ -144,6 +144,8 @@ When `state.multicursors` is non-empty, the user has one or more thoughts select

`setIsMulticursorExecuting` is the general mechanism for that collapsing, not a private detail of the command loop: [`undoRedoEnhancer`](../src/redux-enhancers/undoRedoEnhancer.ts) merges every action dispatched while `state.isMulticursorExecuting` is true into the preceding undo patch, and shows `undoLabel` in the undo/redo alert. Any code path that edits every selected thought without going through a `multicursor: true` command must bracket its dispatch with the same pair, or the user has to undo once per thought. The [`ColorPicker`](../src/components/ColorPicker.tsx) and [`LetterCasePicker`](../src/components/LetterCasePicker.tsx) reach the thoughtspace through [`formatSelection`](../src/actions/formatSelection.ts) and [`formatLetterCase`](../src/actions/formatLetterCase.ts) rather than through their `multicursor: false` toolbar commands, so those two action-creators do the bracketing themselves; drag-and-drop of a multiselect does the same.

The whole of `executeCommandWithMulticursor` is synchronous, including that bracket, so an **asynchronous** command gets no help from it: the bracket is opened and closed around the call, and anything dispatched after the first `await` lands outside it. [`generateThought`](../src/commands/generateThought.ts) is the case in point — its `exec` only reaches the thoughtspace once a network request has returned. It defines an `execMulticursor` that yields once (so that the loop's own synchronous bracket has closed), opens a second bracket of its own, generates every selected thought, and closes it only after all of them have settled. A `multicursor: true` declaration would instead leave one undo step per generated thought, and each `exec`'s own `setCursor` would drop the caret on whichever request happened to finish last.

### Gating and defaults

Three fields shape what happens when the command might not be runnable:
Expand All @@ -159,7 +161,7 @@ Three fields shape what happens when the command might not be runnable:

`keyboardIndex` is recorded alongside the command and restored when it is repeated, since it cannot be derived from the Command/Ctrl + . keypress — that keypress matches none of the repeated command's own shortcuts. Without it, repeating `applyColor` would have no swatch to apply and would silently do nothing. `executeCommandWithMulticursor` resolves `repeat` itself and then delegates an already-resolved command, so it forwards the recorded index through executeCommand's `keyboardIndex` option.

Only commands that make an *undoable, non-navigational* change are recorded, so that Repeat repeats the last edit no matter how many navigation or non-undoable commands intervened. `executeCommand` detects this by comparing the last non-navigation undo patch (the patch that Undo would revert, as classified by [`actionMetadata.registry`](../src/util/actionMetadata.registry.ts)) before and after `exec`. Consequently:
Only commands that make an *undoable, non-navigational* change are recorded, so that Repeat repeats the last edit no matter how many navigation or non-undoable commands intervened. This is detected by comparing the last non-navigation undo patch (the patch that Undo would revert, as classified by [`actionMetadata.registry`](../src/util/actionMetadata.registry.ts)) before and after execution. A command with a custom `execMulticursor` never reaches `executeCommand`, so `executeCommandWithMulticursor` records it around that call instead, comparing the patch from the same point the per-cursor loop does — after `setIsMulticursorExecuting`. Consequently:

- Navigation commands (Cursor Down, Jump Back) are skipped — their actions are registered `isNavigation`.
- Commands that dispatch no undoable action (Export, Settings, Command Universe) are skipped, since they add no patch.
Expand All @@ -185,7 +187,7 @@ The full list of user-facing commands. For the canonical, always-up-to-date set,

### Back

Move the cursor up a level. If Clear Thought is active, cancel it instead and leave the cursor where it is.
Move the cursor up a level. If Clear Thought is active, cancel it instead and leave the cursor where it is. When thoughts are selected, deselect them and select the parent of each selected thought instead — except on desktop, where <kbd>Escape</kbd> clears the selection rather than moving it.

<kbd>Escape</kbd>

Expand Down Expand Up @@ -421,7 +423,7 @@ https://github.com/user-attachments/assets/95f037cc-cf88-4392-98fb-4d79cdae4fba

### Bump Thought Down

Bump the current thought down one level and replace it with a new, empty thought.
Bump the current thought down one level and replace it with a new, empty thought. When multiple thoughts are selected, their parent is bumped down and the selected thoughts are moved into the new thought.

<kbd>Command + Option + d</kbd>

Expand Down
2 changes: 2 additions & 0 deletions docs/drag-and-drop.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ When the press ends, `useLongPress` defers `onLongPressEnd` by 10 ms so that the

[`useDragLeave`](../src/hooks/useDragLeave.ts) tracks how many drop targets are currently being deep-hovered (a module-level `hoverCount`). When the count drops to zero, it debounces a 50 ms clear of `state.hoveringPath`. This prevents flicker when the cursor briefly leaves one drop zone before entering an adjacent one.

Because `hoverCount` is shared across every drop target, only a change in `isDeepHovering` may adjust it. The hook's effect also re-runs on mount and when `canDropThought` or `hoverZone` change, and treating those as hover transitions would let a thought mounting mid-drag decrement the count to zero and blank the drop indicator while a target is still hovered. A separate unmount-only effect releases a target's contribution to the count, so a thought the layout unmounts mid-drag doesn't leak one.

### `useDropHoverColor`

[`useDropHoverColor`](../src/hooks/useDropHoverColor.ts) — small UI hook that maps the drop zone's depth to its hover color. Used by the various Drop* components.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@
"immer": "^11.1.16",
"ipfs-http-client": "^43.0.1",
"lodash": "^4.18.1",
"lottie-react": "^2.4.1",
"lottie-react": "^3.1.0",
"marked": "^18.0.9",
"moize": "^6.1.7",
"motion": "^13.1.0",
Expand Down Expand Up @@ -238,7 +238,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.4",
"expect-webdriverio": "^5.7.0",
"expect-webdriverio": "^6.0.2",
"fake-indexeddb": "^6.2.5",
"happy-dom": "^20.11.2",
"it-all": "^3.0.11",
Expand Down
126 changes: 126 additions & 0 deletions src/actions/__tests__/cursorBack.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import State from '../../@types/State'
import importText from '../../actions/importText'
import toggleContextView from '../../actions/toggleContextView'
import childIdsToThoughts from '../../selectors/childIdsToThoughts'
import contextToPath from '../../selectors/contextToPath'
import addMulticursor from '../../test-helpers/addMulticursorAtFirstMatch'
import expectPathToEqual from '../../test-helpers/expectPathToEqual'
import setCursor from '../../test-helpers/setCursorFirstMatch'
import hashPath from '../../util/hashPath'
import initialState from '../../util/initialState'
import reducerFlow from '../../util/reducerFlow'
import cursorBack from '../cursorBack'
import cursorForward from '../cursorForward'
import newSubthought from '../newSubthought'
import newThought from '../newThought'

/** Converts the multicursor set to a list of contexts in a readable way. */
const multicursorContexts = (state: State): string[][] =>
Object.values(state.multicursors).map(path => childIdsToThoughts(state, path).map(thought => thought.value))

it('move cursor to parent', () => {
const steps = [newThought('a'), newSubthought('b'), cursorBack]

Expand All @@ -20,3 +33,116 @@

expect(stateNew.cursor).toEqual(null)
})

// https://github.com/cybersemics/em/issues/3526
describe('multicursor', () => {
it('select the parents of the selected thoughts', () => {
const text = `
- x
- =children
- =pin
- a
- b
- c
- d
- e
- f
- g
`
const steps = [
importText({ text }),
setCursor(['x', 'a', 'b']),
addMulticursor(['x', 'a', 'b']),
addMulticursor(['x', 'a', 'c']),
addMulticursor(['x', 'd', 'e']),
cursorBack,
]

const stateNew = reducerFlow(steps)(initialState())

// b and c share the parent a, which is selected only once
expect(multicursorContexts(stateNew)).toEqual([

Check failure on line 64 in src/actions/__tests__/cursorBack.ts

View workflow job for this annotation

GitHub Actions / TDD — Unit tests

[unit] src/actions/__tests__/cursorBack.ts > multicursor > select the parents of the selected thoughts

AssertionError: expected [ [ 'x', 'a', 'b' ], …(2) ] to deeply equal [ [ 'x', 'a' ], [ 'x', 'd' ] ] - Expected + Received [ [ "x", "a", + "b", ], [ "x", + "a", + "c", + ], + [ + "x", "d", + "e", ], ] ❯ src/actions/__tests__/cursorBack.ts:64:43
['x', 'a'],
['x', 'd'],
])
})

it('select the context view thought when its contexts are selected', () => {
const text = `
- a
- m
- x
- b
- m
- y
`
const steps = [
importText({ text }),
setCursor(['a', 'm']),
toggleContextView,
addMulticursor(['a', 'm']),
// select the contexts a and b of the context view thought m
cursorForward,
cursorBack,
]

const stateNew = reducerFlow(steps)(initialState())

expect(multicursorContexts(stateNew)).toEqual([['a', 'm']])

Check failure on line 91 in src/actions/__tests__/cursorBack.ts

View workflow job for this annotation

GitHub Actions / TDD — Unit tests

[unit] src/actions/__tests__/cursorBack.ts > multicursor > select the context view thought when its contexts are selected

AssertionError: expected [ Array(2) ] to deeply equal [ [ 'a', 'm' ] ] - Expected + Received [ [ "a", "m", + "a", + ], + [ + "a", + "m", + "b", ], ] ❯ src/actions/__tests__/cursorBack.ts:91:43
})

it('keep a selected thought selected when it is the parent of another selected thought', () => {
const text = `
- x
- a
- b
`
const steps = [
importText({ text }),
setCursor(['x']),
addMulticursor(['x', 'a']),
addMulticursor(['x', 'a', 'b']),
cursorBack,
]

const stateNew = reducerFlow(steps)(initialState())

expect(multicursorContexts(stateNew)).toEqual([['x'], ['x', 'a']])

Check failure on line 110 in src/actions/__tests__/cursorBack.ts

View workflow job for this annotation

GitHub Actions / TDD — Unit tests

[unit] src/actions/__tests__/cursorBack.ts > multicursor > keep a selected thought selected when it is the parent of another selected thought

AssertionError: expected [ [ 'x', 'a' ], [ 'x', 'a', 'b' ] ] to deeply equal [ [ 'x' ], [ 'x', 'a' ] ] - Expected + Received [ [ "x", + "a", ], [ "x", "a", + "b", ], ] ❯ src/actions/__tests__/cursorBack.ts:110:43
})

it('do nothing if all of the selected thoughts are in the root context', () => {
const text = `
- a
- b
`
const steps = [importText({ text }), setCursor(['a']), addMulticursor(['a']), addMulticursor(['b']), cursorBack]

const stateNew = reducerFlow(steps)(initialState())

expect(multicursorContexts(stateNew)).toEqual([['a'], ['b']])
})

it('collapse the newly selected parents so that the previously selected children are hidden', () => {
// x needs a second child, otherwise a is already expanded by the only-child rule
const text = `
- x
- a
- b
- c
- d
`
const steps = [
importText({ text }),
setCursor(['x']),
addMulticursor(['x', 'a']),
// select b and c, which expands their deselected parent a
cursorForward,
cursorBack,
]

const stateNew = reducerFlow(steps)(initialState())

expect(multicursorContexts(stateNew)).toEqual([['x', 'a']])

Check failure on line 145 in src/actions/__tests__/cursorBack.ts

View workflow job for this annotation

GitHub Actions / TDD — Unit tests

[unit] src/actions/__tests__/cursorBack.ts > multicursor > collapse the newly selected parents so that the previously selected children are hidden

AssertionError: expected [ Array(2) ] to deeply equal [ [ 'x', 'a' ] ] - Expected + Received [ [ "x", "a", + "b", + ], + [ + "x", + "a", + "c", ], ] ❯ src/actions/__tests__/cursorBack.ts:145:43
expect(stateNew.expanded[hashPath(contextToPath(stateNew, ['x', 'a'])!)]).toBeFalsy()
})
})
39 changes: 23 additions & 16 deletions src/actions/__tests__/swapParent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,27 +283,34 @@ describe('sort', () => {

it('root children are re-sorted after swapParent with active sort', () => {
// Reproduce the issue: cursor on A, set Created sort, create subthought B, swap B with A.
// B must be created as a separate step so its creation order is after A, C, D.
// B must be created in a later millisecond than A, C, and D: Created sort falls back to alphabetical order on
// thoughts created in the same millisecond, which would put b first regardless of the swap. The reducers run
// synchronously, so the clock has to be advanced explicitly between the two creation steps.
vi.useFakeTimers()
vi.setSystemTime(0)

let stateNew = initialState()
stateNew = reducerFlow([
importText({
text: `
let stateNew
try {
const stateBefore = reducerFlow([
importText({
text: `
- a
- c
- d
`,
}),
setCursor(['a']),
setSortPreference({ simplePath: HOME_PATH, sortPreference: { type: 'Created', direction: 'Asc' } }),
])(stateNew)

vi.setSystemTime(1000)
stateNew = reducerFlow([newThought({ value: 'b', insertNewSubthought: true }), setCursor(['a', 'b']), swapParent])(
stateNew,
)
}),
setCursor(['a']),
setSortPreference({ simplePath: HOME_PATH, sortPreference: { type: 'Created', direction: 'Asc' } }),
])(initialState())

vi.advanceTimersByTime(1000)

stateNew = reducerFlow([
newThought({ value: 'b', insertNewSubthought: true }),
setCursor(['a', 'b']),
swapParent,
])(stateBefore)
} finally {
vi.useRealTimers()
}

// Use excludeMeta to focus on regular thoughts only.
// b was created last (separate newThought step), so it always sorts after c and d in Created Asc order.
Expand Down
45 changes: 41 additions & 4 deletions src/actions/bumpThoughtDown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import editThought from '../actions/editThought'
import editableRender from '../actions/editableRender'
import moveThought from '../actions/moveThought'
import setCursor from '../actions/setCursor'
import { AlertType } from '../constants'
import documentSort from '../selectors/documentSort'
import findDescendant from '../selectors/findDescendant'
import { getAllChildren } from '../selectors/getChildren'
import getPrevRank from '../selectors/getPrevRank'
Expand All @@ -17,16 +19,35 @@ import getThoughtById from '../selectors/getThoughtById'
import simplifyPath from '../selectors/simplifyPath'
import { registerActionMetadata } from '../util/actionMetadata.registry'
import appendToPath from '../util/appendToPath'
import createId from '../util/createId'
import equalPath from '../util/equalPath'
import head from '../util/head'
import parentOf from '../util/parentOf'
import reducerFlow from '../util/reducerFlow'
import alert from './alert'
import categorize from './categorize'

/** Clears a thought's text, moving it to its first child. */
const bumpThoughtDown = (state: State, { simplePath }: { simplePath?: SimplePath }): State => {
if (!simplePath && !state.cursor) return state
/** Clears a thought's text, moving it to its first child. If multiple thoughts are selected, bumps their parent down and moves the selected thoughts into the new thought. */
const bumpThoughtDown = (state: State, { paths, simplePath }: { paths?: Path[]; simplePath?: SimplePath }): State => {
// the selected thoughts that are moved into the new thought, in document order
const selection = paths && paths.length > 1 ? documentSort(state, paths) : null

simplePath = simplePath || simplifyPath(state, state.cursor!)
if (selection && !selection.every(path => equalPath(parentOf(path), parentOf(selection[0])))) {
return alert(state, {
alertType: AlertType.MulticursorError,
value: 'Cannot bump down thoughts from different parents.',
})
}

// The home context has no text to bump down, so simply move the selected thoughts into a new empty thought.
if (selection && parentOf(selection[0]).length === 0) return categorize(state)

// Bump the parent of the selected thoughts down, otherwise bump the selected thought or the cursor down.
const path = simplePath || (selection ? parentOf(selection[0]) : paths?.[0]) || state.cursor

if (!path) return state

simplePath = simplePath || simplifyPath(state, path)

const headThought = getThoughtById(state, head(simplePath))
if (!headThought) {
Expand Down Expand Up @@ -55,6 +76,9 @@ const bumpThoughtDown = (state: State, { simplePath }: { simplePath?: SimplePath
const simplePathWithNewRank: SimplePath = appendToPath(parentPath, head(simplePath))
const simplePathWithNewRankAndValue: Path = appendToPath(parentPath, head(simplePathWithNewRank))

// the id of the new thought that the bumped value is moved to, and that the selected thoughts are moved into
const newThoughtId = createId()

return reducerFlow([
// modify the rank to get the thought to re-render (via the Subthoughts child key)
moveThought({
Expand All @@ -67,6 +91,7 @@ const bumpThoughtDown = (state: State, { simplePath }: { simplePath?: SimplePath
state => {
// the context of the new empty thought
return createThought(state, {
id: newThoughtId,
path: simplePath as Path,
// If there is a sort preference, use it. Otherwise, insert at the top.
rank: sortId ? getSortedRank(state, head(simplePath), value) : getPrevRank(state, head(simplePath)),
Expand All @@ -81,6 +106,18 @@ const bumpThoughtDown = (state: State, { simplePath }: { simplePath?: SimplePath
path: simplePathWithNewRank,
}),

// move the selected thoughts into the new thought, preserving their order
// we ignore selected thoughts that are somehow missing, see getThoughtById
...(selection || [])
.filter(path => getThoughtById(state, head(path)))
.map(path =>
moveThought({
oldPath: simplifyPath(state, path),
newPath: appendToPath(simplePathWithNewRank, newThoughtId, head(path)),
newRank: getThoughtById(state, head(path))!.rank,
}),
),

// set cursor
setCursor({
path: simplePathWithNewRankAndValue,
Expand Down
Loading
Loading