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); + }); });