Skip to content

Commit 6f42358

Browse files
committed
Fix(notes): the move prompt speaks the sidebar's language (#844)
On a vault whose notes live at the root, `:mv` (and Move... from the context menu, the note list, the Archive view, the palette and a multi-selection) opened on `inbox` and offered `inbox/Areas`, `inbox/Projects`, folders the vault does not have. The prompt spoke in the bucket's terms: a destination had to start with `inbox` or `archive`, suggestions were built as `<bucket>/<subpath>`, and the initial value came from the path's first segment, which on a root vault is a real folder name and so fell back to `inbox`. Every folder of the notes area is filed under the inbox bucket whatever the layout, so the words were right for the host and wrong for the person reading them. Now the prompt speaks the way the sidebar does. Destinations are folders of the notes area as it shows them (`Areas`, `Work/Research`, nothing in front), empty for the notes root, labelled "Vault root" when the notes live there and with the inbox's label otherwise, and `archive/...` for the Archive, by that name even when the archive folder is called something else on disk. The prompt opens on the note's current folder in that spelling, an empty field moves to the root, and the reserved names (Quick Notes, Trash, their directory names) are refused with one message instead of being turned into folders. The older `inbox/Work` spelling the manual taught still lands in Work; on a root vault too, unless a real folder named `inbox` sits at the root, in which case it means that folder. Folder and database moves use the same vocabulary. `moveNoteVocabulary(settings, labels, folders)` carries these facts, every caller passes it, and only null is the Cancel now that empty is an answer. The manual's three mentions and the website were reworded.
1 parent c852528 commit 6f42358

12 files changed

Lines changed: 467 additions & 128 deletions

File tree

‎packages/app-core/src/browse-actions.test.ts‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,15 @@ describe('public Browse actions', () => {
314314
)
315315
})
316316

317+
it('moves a nested folder to the notes root on an empty answer', async () => {
318+
const s = await setup()
319+
const result = s.requestMoveBrowseDirectory(s.host, 'Work/Nested')
320+
expect(s.getPromptRequest()?.options.validate?.('')).toBeNull()
321+
s.answer('')
322+
expect(await result).toBe('completed')
323+
expect(s.rename).toHaveBeenCalledWith('inbox', 'Work/Nested', 'Nested', expect.any(Function))
324+
})
325+
317326
it('offers only real destinations: not itself, its children, databases, or archive', async () => {
318327
const s = await setup()
319328
s.useStore.setState({
@@ -325,10 +334,10 @@ describe('public Browse actions', () => {
325334
]
326335
})
327336
const result = s.requestMoveBrowseDirectory(s.host, 'Work')
328-
expect(s.getPromptRequest()?.options.suggestions?.map((row) => row.value)).toEqual([
329-
'inbox',
330-
'inbox/Home'
331-
])
337+
// The notes root is the empty path, labelled the way the sidebar labels it.
338+
const suggestions = s.getPromptRequest()?.options.suggestions
339+
expect(suggestions?.map((row) => row.value)).toEqual(['', 'Home'])
340+
expect(suggestions?.[0].label).toBe('Inbox')
332341
s.answer(null)
333342
expect(await result).toBe('cancelled')
334343
})
@@ -337,9 +346,6 @@ describe('public Browse actions', () => {
337346
const s = await setup()
338347
for (const value of [
339348
null,
340-
'',
341-
' ',
342-
'Work',
343349
'archive',
344350
'inbox/Work/Nested',
345351
'inbox/Work/Nested/Deeper',
@@ -355,11 +361,14 @@ describe('public Browse actions', () => {
355361
s.answer(value)
356362
expect(await result).toBe('cancelled')
357363
}
358-
// Its current parent is a valid answer that changes nothing.
359-
const same = s.requestMoveBrowseDirectory(s.host, 'Work/Nested')
360-
expect(s.getPromptRequest()?.options.validate?.('inbox/Work')).toBeNull()
361-
s.answer('inbox/Work')
362-
expect(await same).toBe('cancelled')
364+
// Its current parent is a valid answer that changes nothing, in either
365+
// spelling: the sidebar's, or the older inbox/ one.
366+
for (const parent of ['Work', 'inbox/Work']) {
367+
const same = s.requestMoveBrowseDirectory(s.host, 'Work/Nested')
368+
expect(s.getPromptRequest()?.options.validate?.(parent)).toBeNull()
369+
s.answer(parent)
370+
expect(await same).toBe('cancelled')
371+
}
363372
expect(s.rename).not.toHaveBeenCalled()
364373
})
365374

‎packages/app-core/src/components/ArchiveView.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { isArchiveViewActive, useStore } from '../store'
66
import { ArchiveIcon, ArrowUpRightIcon, TrashIcon } from './icons'
77
import { CollectionViewHeader } from './CollectionViewHeader'
88
import { ContextMenu } from './ContextMenu'
9-
import { buildMoveNotePrompt, parseMoveNoteTarget } from '../lib/move-note'
9+
import { buildMoveNotePrompt, moveNoteVocabulary, parseMoveNoteTarget } from '../lib/move-note'
1010
import { promptApp } from '../lib/prompt-requests'
1111
import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps'
1212
import { resolveSystemFolderLabels } from '../lib/system-folder-labels'
@@ -44,6 +44,7 @@ export function ArchiveView(): JSX.Element {
4444
const vimMode = useStore((s) => s.vimMode)
4545
const setFocusedPanel = useStore((s) => s.setFocusedPanel)
4646
const systemFolderLabels = useStore((s) => s.systemFolderLabels)
47+
const vaultSettings = useStore((s) => s.vaultSettings)
4748
const workspaceMode = useStore((s) => s.workspaceMode)
4849
const amActive = useStore(isArchiveViewActive)
4950
const folderLabels = useMemo(
@@ -176,9 +177,11 @@ export function ArchiveView(): JSX.Element {
176177
items.push({
177178
label: 'Move…',
178179
onSelect: async () => {
179-
const target = await promptApp(buildMoveNotePrompt(note, folders))
180-
if (!target) return
181-
const dest = parseMoveNoteTarget(target)
180+
const vocabulary = moveNoteVocabulary(vaultSettings, systemFolderLabels, folders)
181+
const target = await promptApp(buildMoveNotePrompt(note, folders, vocabulary))
182+
// Empty is an answer (the notes root); only null is the Cancel.
183+
if (target === null) return
184+
const dest = parseMoveNoteTarget(target, vocabulary)
182185
await moveNote(note.path, dest.folder, dest.subpath)
183186
}
184187
})

‎packages/app-core/src/components/Editor.tsx‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
} from "../lib/internal-links";
5353
import {
5454
buildMoveNotePrompt,
55+
moveNoteVocabulary,
5556
parseMoveNoteTarget,
5657
parseTemplateDestination,
5758
validateMoveNoteTarget,
@@ -1114,20 +1115,26 @@ function registerVimNoteCommands(): void {
11141115
const active = state.activeNote;
11151116
if (!active) return;
11161117

1117-
const value = raw.trim();
1118-
let target = value;
1118+
const vocabulary = moveNoteVocabulary(
1119+
state.vaultSettings,
1120+
state.systemFolderLabels,
1121+
state.folders,
1122+
);
1123+
let target: string | null = raw.trim();
11191124
if (!target) {
1120-
target =
1121-
(await promptApp(buildMoveNotePrompt(active, state.folders))) ?? "";
1122-
if (!target) return;
1125+
// Empty is an answer (the notes root); only null is the Cancel.
1126+
target = await promptApp(
1127+
buildMoveNotePrompt(active, state.folders, vocabulary),
1128+
);
1129+
if (target === null) return;
11231130
}
11241131

1125-
const error = validateMoveNoteTarget(target);
1132+
const error = validateMoveNoteTarget(target, vocabulary);
11261133
if (error) {
11271134
alertEditorError(error);
11281135
return;
11291136
}
1130-
const dest = parseMoveNoteTarget(target);
1137+
const dest = parseMoveNoteTarget(target, vocabulary);
11311138
await state.moveNote(active.path, dest.folder, dest.subpath);
11321139
};
11331140

‎packages/app-core/src/components/NoteList.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
import { ContextMenu, type ContextMenuItem } from './ContextMenu'
1616
import { ResizeHandle } from './ResizeHandle'
1717
import { Button, IconButton } from './ui/Button'
18-
import { buildMoveNotePrompt, parseMoveNoteTarget } from '../lib/move-note'
18+
import { buildMoveNotePrompt, moveNoteVocabulary, parseMoveNoteTarget } from '../lib/move-note'
1919
import { naturalCompare } from '../lib/natural-sort'
2020
import { extractTags } from '../lib/tags'
2121
import { setDragPayload } from '../lib/dnd'
@@ -186,9 +186,12 @@ export function NoteList(): JSX.Element {
186186
await runNoteLifecycleAction(n.path, 'trash')
187187
}
188188
const onMove = async (): Promise<void> => {
189-
const target = await promptApp(buildMoveNotePrompt(n, folders))
190-
if (!target) return
191-
const dest = parseMoveNoteTarget(target)
189+
const state = useStore.getState()
190+
const vocabulary = moveNoteVocabulary(state.vaultSettings, state.systemFolderLabels, folders)
191+
const target = await promptApp(buildMoveNotePrompt(n, folders, vocabulary))
192+
// Empty is an answer (the notes root); only null is the Cancel.
193+
if (target === null) return
194+
const dest = parseMoveNoteTarget(target, vocabulary)
192195
await moveNote(n.path, dest.folder, dest.subpath)
193196
}
194197
const onRestore = async (): Promise<void> => {

‎packages/app-core/src/components/Sidebar.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
useStore,
2525
} from "../store";
2626
import { Button } from "./ui/Button";
27-
import { buildMoveNotePrompt, parseMoveNoteTarget } from "../lib/move-note";
27+
import { buildMoveNotePrompt, moveNoteVocabulary, parseMoveNoteTarget } from "../lib/move-note";
2828
import { buildTagTree, extractTags, flattenTagTree } from "../lib/tags";
2929
import { isTypstPreamblePath, resolveTypstPreambleFolder } from "../lib/typst-preamble";
3030
import { focusEditorNormalMode } from "../lib/editor-focus";
@@ -2213,9 +2213,12 @@ export function Sidebar(): JSX.Element {
22132213
items.push({
22142214
label: "Move…",
22152215
onSelect: async () => {
2216-
const target = await promptApp(buildMoveNotePrompt(n, allFolders));
2217-
if (!target) return;
2218-
const dest = parseMoveNoteTarget(target);
2216+
const state = useStore.getState();
2217+
const vocabulary = moveNoteVocabulary(state.vaultSettings, state.systemFolderLabels, allFolders);
2218+
const target = await promptApp(buildMoveNotePrompt(n, allFolders, vocabulary));
2219+
// Empty is an answer (the notes root); only null is the Cancel.
2220+
if (target === null) return;
2221+
const dest = parseMoveNoteTarget(target, vocabulary);
22192222
await moveNoteAction(n.path, dest.folder, dest.subpath);
22202223
},
22212224
});

‎packages/app-core/src/lib/browse-actions.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { getPromptRequest, promptApp } from './prompt-requests'
1212
import { parentDirOf } from './manual-order'
1313
import {
1414
buildMoveDirectoryPrompt,
15+
moveNoteVocabulary,
16+
type MoveNoteVocabulary,
1517
parseMoveNoteTarget,
1618
validateMoveDirectoryTarget
1719
} from './move-note'
@@ -150,14 +152,19 @@ export async function requestMoveBrowseDirectory(
150152
if (!context) return 'unavailable'
151153
const { isCurrent, targetExists } = context
152154
try {
155+
const vocabulary = (): MoveNoteVocabulary => {
156+
const state = useStore.getState()
157+
return moveNoteVocabulary(state.vaultSettings, state.systemFolderLabels, state.folders)
158+
}
153159
const validate = (value: string): string | null =>
154-
validateMoveDirectoryTarget(directory, value, useStore.getState().folders)
160+
validateMoveDirectoryTarget(directory, value, useStore.getState().folders, vocabulary())
155161
const target = await promptApp({
156-
...buildMoveDirectoryPrompt(directory, useStore.getState().folders),
162+
...buildMoveDirectoryPrompt(directory, useStore.getState().folders, vocabulary()),
157163
validate
158164
})
159-
if (!target || validate(target)) return 'cancelled'
160-
const parent = parseMoveNoteTarget(target).subpath
165+
// Empty is an answer (the notes root); only null is the Cancel.
166+
if (target === null || validate(target)) return 'cancelled'
167+
const parent = parseMoveNoteTarget(target, vocabulary()).subpath
161168
if (parent === parentDirOf(directory)) return 'cancelled'
162169
if (!isCurrent() || !targetExists()) return 'stale'
163170
const leaf = directory.split('/').pop()!

‎packages/app-core/src/lib/commands.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { isTagsViewActive, isTasksViewActive, isTrashViewActive, useStore } from
1010
import { confirmApp } from './confirm-requests'
1111
import { promptApp } from './prompt-requests'
1212
import { captureNavigationContext } from './navigation-context'
13-
import { buildMoveNotePrompt, parseMoveNoteTarget } from './move-note'
13+
import { buildMoveNotePrompt, moveNoteVocabulary, parseMoveNoteTarget } from './move-note'
1414
import { focusPaneInDirection } from './pane-nav'
1515
import { focusSidebarPanel } from './sidebar-focus'
1616
import { findLeaf } from './pane-layout'
@@ -550,9 +550,11 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma
550550
const state = getState()
551551
const active = state.activeNote
552552
if (!active) return
553-
const target = await promptApp(buildMoveNotePrompt(active, state.folders))
554-
if (!target || !isCurrent()) return
555-
const dest = parseMoveNoteTarget(target)
553+
const vocabulary = moveNoteVocabulary(state.vaultSettings, state.systemFolderLabels, state.folders)
554+
const target = await promptApp(buildMoveNotePrompt(active, state.folders, vocabulary))
555+
// Empty is an answer (the notes root); only null is the Cancel.
556+
if (target === null || !isCurrent()) return
557+
const dest = parseMoveNoteTarget(target, vocabulary)
556558
await state.moveNote(active.path, dest.folder, dest.subpath, isCurrent)
557559
}
558560
}

‎packages/app-core/src/lib/help.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [
129129
{
130130
title: 'Move a note without dragging',
131131
body:
132-
'Use the note context menu, search for `move` or `mv` in the command palette, or run `:move` or `:mv`. With no argument, ZenNotes opens a folder picker; with a target like `archive/Reference` or `inbox/Work`, it moves the note directly.'
132+
'Use the note context menu, search for `move` or `mv` in the command palette, or run `:move` or `:mv`. With no argument, ZenNotes opens a folder picker that speaks the sidebar\'s language: folders of your notes area as you see them there (`Work/Research`, no `inbox/` in front, and empty for the notes root, which is Inbox or the vault root depending on your Primary notes location), plus `archive/…` for the Archive. With a target like `:mv Work/Research` or `:mv archive/Reference`, it moves the note directly; `inbox/Work` still works on an Inbox vault.'
133133
},
134134
{
135135
title: 'Act on multiple sidebar items',
@@ -327,7 +327,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [
327327
{
328328
title: 'Moving notes is path-first',
329329
body:
330-
'Use the note context menu, search `move` or `mv` in the command palette, or run `:move` / `:mv` from the ex line to move the active note into Inbox or Archive. With no argument, the command opens the folder picker; with a target like `:mv archive/Reference` or `:move inbox/Work`, it moves the note directly. The move prompt autocompletes folder paths, so you can type and Tab through existing destinations instead of dragging. “Duplicate” (palette or context menu) copies a note in place, appending “ (copy)” to the name.'
330+
'Use the note context menu, search `move` or `mv` in the command palette, or run `:move` / `:mv` from the ex line to move the active note into a folder of your notes area or the Archive. With no argument, the command opens the folder picker; with a target like `:mv Work/Research` or `:mv archive/Reference`, it moves the note directly (`:move inbox/Work` still works on an Inbox vault). The picker spells destinations the way the sidebar does: no `inbox/` in front, empty for the notes root (Inbox, or the vault root when your notes live there), `archive/…` for the Archive, and it autocompletes folder paths, so you can type and Tab through existing destinations instead of dragging. “Duplicate” (palette or context menu) copies a note in place, appending “ (copy)” to the name.'
331331
},
332332
{
333333
title: 'Renaming a note fixes its links',
@@ -856,7 +856,7 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [
856856
{
857857
command: ':move [folder] / :mv [folder]',
858858
summary: 'Move the active note',
859-
detail: 'Both names are supported explicitly. Without an argument they open the move prompt; with a path like `archive/Reference` or `inbox/Work` they move the active note there directly.'
859+
detail: 'Both names are supported explicitly. Without an argument they open the move prompt; with a path like `Work/Research` (a folder of your notes area, as the sidebar shows it) or `archive/Reference` they move the active note there directly. `inbox/Work` still works on an Inbox vault.'
860860
},
861861
{
862862
command: ':bn / :bp',

0 commit comments

Comments
 (0)