From 3e2b0ba5babdd148db745afb015d4744a10c1fbd Mon Sep 17 00:00:00 2001 From: Dion Munk Date: Wed, 7 Oct 2026 00:46:34 -0600 Subject: [PATCH] feat(notes): create a note from the List Notes search Typing a name in List Notes (Alt+L) that wasn't a note left an empty list (#34). The list now offers to create a note with the typed name, so Enter creates it when nothing matches, and still opens the top match when something does. With Workspace Notes there is one Create entry per section. No Create entry is offered when a note already has the typed name. The list also includes notes in folders, with their folder next to the name; before, only the top level of each location was listed. Creating a note is now one function used by both New Note and List Notes, so both keep a typed extension and only give Markdown notes a heading. It writes with the exclusive flag, so an existing file is never overwritten. --- CHANGELOG.md | 3 +- README.md | 2 +- src/extension.ts | 170 +++++++++++++++++++------------- src/location.ts | 6 ++ src/test/suite/location.test.ts | 10 +- 5 files changed, 118 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02d7915..2a536ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * a Create Folder button when a storage location is set but its folder doesn't exist * move notes and folders by dragging them onto a folder, onto empty space for the top level, or between Workspace Notes and Notes, or with `Move To...` in the right-click menu; open notes stay open at their new location, and several selected items move together * drag a note onto the editor area to open it +* `Notes: List Notes` (`Alt+L`) offers to create a note with the name you typed when it isn't a note yet, so `Enter` creates it when nothing matches, in either section when the workspace has notes (#34) * search inside your notes with VS Code's search: `Notes: Search Notes` covers Workspace Notes and Notes, the search button of each section covers that section, and `Search in Folder` in the right-click menu of a folder covers that folder, even when the notes are outside the workspace (#17) * notes and folders use the icons of your file icon theme * a new note keeps an extension typed with its name, like `query.sql` or `data.json`, when VS Code knows a language for it (or it is in `notes.notesExtensions`), otherwise it gets the default extension (#81) @@ -28,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * the Notes view explains why it is empty: no storage location, a storage location that can't be found, or no notes yet * `notes.notesLocation` is no longer synced between machines (#26), and the Notes section only uses the value in your user settings * a `notes.notesLocation` set in a workspace's settings is now shown as that workspace's Workspace Notes, and is replaced by `notes.workspaceNotesLocation` when a workspace notes location is selected -* `Notes: List Notes` lists the notes of both sections and only lists files +* `Notes: List Notes` lists the notes of both sections, including those in folders, and only lists files * only Markdown notes start with their name as a heading, other new notes start empty ### Fixed diff --git a/README.md b/README.md index 046f7aa..631cace 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Notes are stored in a single location (directory) located anywhere on your syste The extension can be accessed using the Notes icon that is placed in the Activity Bar, or in the Command Pallet (CMD+Shift+P or CTRL+Shift+P) by typing `Notes`. * quickly create new notes by using the `Alt+N` shortcut, or by click on the `+` icon at the top when you are in Notes. -* quickly access your list of notes by using the `Alt+L` shortcut to bring up a searchable list at the top of VSCode. +* quickly access your list of notes by using the `Alt+L` shortcut to bring up a searchable list at the top of VSCode, including the notes in folders. If what you type isn't a note, press `Enter` to create it. * search inside your notes with **Notes: Search Notes**, the search button at the top of each section, or **Search in Folder** in the right-click menu of a folder. It opens VS Code's Search view limited to your notes, wherever they are stored. * hovering over a note inside Notes displays two icons, one allows you to rename a note and the other allows you to delete a note. *Deleting a note is permanent, so be careful.* * move notes and folders by dragging them onto a folder (or onto empty space for the top level), between Workspace Notes and Notes, or with **Move To...** in the right-click menu. Select several with `Cmd`/`Ctrl` to move them together, and drag a note onto the editor to open it. diff --git a/src/extension.ts b/src/extension.ts index 7a8b791..2a414c3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,7 +7,7 @@ import { Note } from './note'; import { NotesViewProvider } from './notesViewProvider'; import { NotesWatcher } from './notesWatcher'; import { MovableItem, NotesDragAndDrop } from './notesDragAndDrop'; -import { getLocationState, getMoveDestination, getPortableFolder, isInside, resolveNotesLocation, splitNoteName, toWorkspaceSetting } from './location'; +import { getLocationState, getMoveDestination, getPortableFolder, isInside, isNoteName, resolveNotesLocation, splitNoteName, toWorkspaceSetting } from './location'; let extId = 'vscode-notes'; let extPub = 'dionmunk'; @@ -122,7 +122,7 @@ export function activate(context: vscode.ExtensionContext) { // list notes let listNotesDisposable = vscode.commands.registerCommand('Notes.listNotes', () => { - Notes.listNotes(); + Notes.listNotes(trees); }); context.subscriptions.push(listNotesDisposable); @@ -453,8 +453,9 @@ export class Notes { }); } - // list notes, from Workspace Notes and Notes - static async listNotes(): Promise { + // list notes, from Workspace Notes and Notes and the folders in them; typing a name that isn't a note + // offers to create it, so Enter creates the note when nothing matches (#34) + static async listNotes(tree: Refreshable): Promise { const sources = [{ label: 'Notes', location: Notes.getNotesLocation() }]; if (Notes.isWorkspaceNotesAvailable()) { sources.unshift({ label: 'Workspace Notes', location: Notes.getWorkspaceNotesLocation() }); @@ -465,31 +466,45 @@ export class Notes { return; } - // read the notes in each storage location, the label tells them apart when there are two - const items: (vscode.QuickPickItem & { filePath: string })[] = []; + type NoteItem = vscode.QuickPickItem & { filePath?: string, createIn?: string }; + const notes: NoteItem[] = []; for (const source of ready) { - try { - for (const entry of await fs.promises.readdir(source.location, { withFileTypes: true })) { - if (entry.isFile() && !entry.name.startsWith('.')) { - items.push({ - label: entry.name, - description: ready.length > 1 ? source.label : undefined, - filePath: path.join(source.location, entry.name) - }); - } - } - } catch (err) { - // report error - console.error(err); - vscode.window.showErrorMessage(`Failed to read ${source.location}.`); + for (const filePath of await listNoteFiles(source.location, String(Notes.getNotesExtensions()))) { + // where the note is: its section when there are two, and its folder + const folder = path.relative(source.location, path.dirname(filePath)).split(path.sep).join('/'); + const where = [ready.length > 1 ? source.label : '', folder].filter(Boolean).join(' › '); + notes.push({ label: path.basename(filePath), description: where || undefined, filePath }); } } - // show list of notes and open the selected one - const picked = await vscode.window.showQuickPick(items, { placeHolder: 'Open a note' }); - if (picked) { - vscode.window.showTextDocument(vscode.Uri.file(picked.filePath)); - } + const quickPick = vscode.window.createQuickPick(); + quickPick.placeholder = 'Open a note, or type a name to create one'; + quickPick.matchOnDescription = true; + quickPick.items = notes; + quickPick.onDidChangeValue(value => { + const name = value.trim(); + // offer to create the typed name in each section, unless a note already has that name + const create: NoteItem[] = !name || notes.some(note => isNoteName(name, note.label)) ? [] : ready.map(source => ({ + label: `$(new-file) Create '${name}'`, + description: ready.length > 1 ? `in ${source.label}` : undefined, + alwaysShow: true, + createIn: source.location + })); + quickPick.items = [...notes, ...create]; + }); + quickPick.onDidAccept(async () => { + const picked = quickPick.activeItems[0]; + const name = quickPick.value.trim(); + quickPick.hide(); + if (picked?.filePath) { + vscode.window.showTextDocument(vscode.Uri.file(picked.filePath)); + } + else if (picked?.createIn && name) { + await Notes.createNote(picked.createIn, name, tree); + } + }); + quickPick.onDidHide(() => quickPick.dispose()); + quickPick.show(); } // new note @@ -499,55 +514,47 @@ export class Notes { if (!notesLocation) { return; } - let notesDefaultNoteExtension = String(Notes.getNotesDefaultNoteExtension()); - // prompt user for a new note name - vscode.window.showInputBox({ + const noteName = await vscode.window.showInputBox({ prompt: 'Note name?', value: '', - }).then(noteName => { - if (!noteName) { - return; // User cancelled - } - - // keep an extension typed with the name, like query.sql, otherwise use the default one - const languages = Notes.getLanguageExtensions(); - const { base, extension } = splitNoteName(noteName, String(Notes.getNotesExtensions()), new Set(languages.keys())); - const noteExtension = extension ?? notesDefaultNoteExtension; - // set note path - let filePath: string = path.join(notesLocation, `${base.replace(/\:/gi, '')}.${noteExtension}`); - // a Markdown note starts with its name as a heading, other files start empty - const markdown = languages.get(noteExtension.toLowerCase()) === 'markdown' || ['md', 'markdown'].includes(noteExtension.toLowerCase()); - let firstLine: string = markdown ? `# ${base}\n\n` : ''; - // does note exist already? - let noteExists = fs.existsSync(String(filePath)); - - // if a note with name doesn't already exist - if (!noteExists) { - // try writing the file to the storage location - fs.writeFile(filePath, firstLine, err => { - if (err) { - // report error - console.error(err); - return vscode.window.showErrorMessage('Failed to create the new note.'); - } - else { - // refresh tree after creating new note - tree.refresh(); - // open file - let file = vscode.Uri.file(filePath); - vscode.window.showTextDocument(file).then(() => { - // go to last line in new file - vscode.commands.executeCommand('cursorMove', { 'to': 'viewPortBottom' }); - }); - } - }); - } - else { - // report - return vscode.window.showWarningMessage('A note with that name already exists.'); - } }); + if (noteName) { + await Notes.createNote(notesLocation, noteName, tree); + } + } + + // create a note in a folder and open it, keeping an extension typed with the name, like query.sql, + // otherwise using the default one + static async createNote(folder: string, noteName: string, tree: Refreshable): Promise { + const languages = Notes.getLanguageExtensions(); + const { base, extension } = splitNoteName(noteName, String(Notes.getNotesExtensions()), new Set(languages.keys())); + const noteExtension = extension ?? String(Notes.getNotesDefaultNoteExtension()); + // set note path + const filePath = path.join(folder, `${base.replace(/\:/gi, '')}.${noteExtension}`); + // a Markdown note starts with its name as a heading, other files start empty + const markdown = languages.get(noteExtension.toLowerCase()) === 'markdown' || ['md', 'markdown'].includes(noteExtension.toLowerCase()); + const firstLine = markdown ? `# ${base}\n\n` : ''; + + // if a note with name already exists + if (fs.existsSync(filePath)) { + vscode.window.showWarningMessage('A note with that name already exists.'); + return; + } + try { + // write the file to the storage location + await fs.promises.writeFile(filePath, firstLine, { flag: 'wx' }); + } catch (err) { + // report error + console.error(err); + vscode.window.showErrorMessage('Failed to create the new note.'); + return; + } + // refresh tree after creating new note + tree.refresh(); + // open file and go to last line in new file + await vscode.window.showTextDocument(vscode.Uri.file(filePath)); + vscode.commands.executeCommand('cursorMove', { 'to': 'viewPortBottom' }); } // new folder @@ -969,3 +976,26 @@ async function listFolders(folder: string): Promise { } return folders; } + +// every note below a folder, in its folders too, leaving out hidden files and folders and, like the tree, +// files whose extension isn't allowed +async function listNoteFiles(folder: string, notesExtensions: string): Promise { + const allowed = notesExtensions.split(',').map(extension => extension.trim().toLowerCase()); + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(folder, { withFileTypes: true }); + } catch (err) { + return []; + } + const files: string[] = []; + for (const entry of entries.filter(entry => !entry.name.startsWith('.')).sort((a, b) => a.name.localeCompare(b.name))) { + const entryPath = path.join(folder, entry.name); + if (entry.isDirectory()) { + files.push(...await listNoteFiles(entryPath, notesExtensions)); + } + else if (entry.isFile() && (allowed.includes('*') || allowed.includes(path.extname(entry.name).slice(1).toLowerCase()))) { + files.push(entryPath); + } + } + return files; +} diff --git a/src/location.ts b/src/location.ts index 1c9dcd5..61e090f 100644 --- a/src/location.ts +++ b/src/location.ts @@ -98,3 +98,9 @@ export function splitNoteName(name: string, allowedExtensions: string, languageE const usable = allowed.includes('*') ? languageExtensions.has(extension.toLowerCase()) : allowed.includes(extension.toLowerCase()); return usable ? { base: name.slice(0, -dotted.length), extension } : { base: name }; } + +// does a typed name refer to a note, by its file name or by its name without the extension? +export function isNoteName(typed: string, fileName: string): boolean { + const name = typed.trim().toLowerCase(); + return name === fileName.toLowerCase() || name === path.parse(fileName).name.toLowerCase(); +} diff --git a/src/test/suite/location.test.ts b/src/test/suite/location.test.ts index 292e03c..4ea962e 100644 --- a/src/test/suite/location.test.ts +++ b/src/test/suite/location.test.ts @@ -2,7 +2,7 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { getLocationState, getMoveDestination, getPortableFolder, isInside, resolveNotesLocation, splitNoteName, toWorkspaceSetting } from '../../location'; +import { getLocationState, getMoveDestination, getPortableFolder, isInside, isNoteName, resolveNotesLocation, splitNoteName, toWorkspaceSetting } from '../../location'; suite('Notes Location', () => { const home = path.join(path.sep, 'home', 'someone'); @@ -137,4 +137,12 @@ suite('Notes Location', () => { assert.deepStrictEqual(splitNoteName('notes.txt', 'md, txt', languages), { base: 'notes', extension: 'txt' }); assert.deepStrictEqual(splitNoteName('query.sql', 'md,txt', languages), { base: 'query.sql' }); }); + + test('a typed name refers to a note by its file name or its name without the extension (#34)', () => { + assert.strictEqual(isNoteName('animals', 'animals.md'), true); + assert.strictEqual(isNoteName('Animals.MD', 'animals.md'), true); + assert.strictEqual(isNoteName(' animals ', 'animals.md'), true); + assert.strictEqual(isNoteName('anim', 'animals.md'), false); + assert.strictEqual(isNoteName('animals.txt', 'animals.md'), false); + }); });