diff --git a/CHANGELOG.md b/CHANGELOG.md index af09961..53675b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * 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 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) ### Changed @@ -27,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `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 +* only Markdown notes start with their name as a heading, other new notes start empty ### Fixed @@ -38,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * deleting a folder relied on a package that was only installed as a development dependency * New Note and New Folder failed when a note was selected, they now create next to the note (#67, #76) * in a portable VS Code, a relative storage location is relative to the folder holding VS Code, so notes can be kept with it and opened from there (#38) +* renaming a note to another extension, like `a.txt`, failed with "already exists" when all extensions were allowed, and a name with dots like `Meeting 2026.10.07` lost its last part ## [2.0.0] - 2025-03-26 diff --git a/src/extension.ts b/src/extension.ts index db4d931..69775d8 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, toWorkspaceSetting } from './location'; +import { getLocationState, getMoveDestination, getPortableFolder, isInside, resolveNotesLocation, splitNoteName, toWorkspaceSetting } from './location'; let extId = 'vscode-notes'; let extPub = 'dionmunk'; @@ -350,6 +350,24 @@ export class Notes { const target = kind ?? await Notes.pickKind(); return target ? Notes.requireLocation(target) : undefined; } + // the file extensions VS Code has a language for, with the language, from every installed extension + // including the built-in ones + static getLanguageExtensions(): Map { + // plain text is registered by VS Code itself rather than by an extension + const languages = new Map([['txt', 'plaintext']]); + for (const extension of vscode.extensions.all) { + for (const language of extension.packageJSON?.contributes?.languages ?? []) { + for (const fileExtension of language.extensions ?? []) { + const key = String(fileExtension).replace(/^\./, '').toLowerCase(); + if (!languages.has(key)) { + languages.set(key, language.id); + } + } + } + } + return languages; + } + // get notes default extension static getNotesDefaultNoteExtension() { return vscode.workspace.getConfiguration('notes').get('notesDefaultNoteExtension'); @@ -471,12 +489,15 @@ export class Notes { return; // User cancelled } - // set note name - let fileName: string = `${noteName}`; + // 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, `${fileName.replace(/\:/gi, '')}.${notesDefaultNoteExtension}`); - // set note first line - let firstLine: string = "# " + fileName + "\n\n"; + 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)); @@ -621,8 +642,8 @@ export class Notes { return; } - // get the note's extension - let noteExtension = note.name.split('.').pop(); + // get the note's extension, '' for a note without one + let noteExtension = path.extname(note.name).slice(1); // prompt user for new note name vscode.window.showInputBox({ @@ -635,25 +656,10 @@ export class Notes { return; } - // Get the extension without the dot - let newNoteExtension = path.extname(newNoteName).replace('.', ''); - let noteName: string = ''; - - // if new note name extension is in list of allowed extensions - if (String(Notes.getNotesExtensions()).split(',').includes(newNoteExtension)) { - // use the new note name - noteName = newNoteName; - } - // else if new note name has no extension - else if (path.extname(newNoteName) === '') { - // use the note's current extension - noteName = newNoteName + '.' + noteExtension; - } - // else if new note name has an extension that's not in the allowed list - else { - // use the new note name but with the current extension - noteName = path.basename(newNoteName, path.extname(newNoteName)) + '.' + noteExtension; - } + // use the new name as typed when it ends with an extension a note can have, like a.txt, + // otherwise keep the current extension, so a name like 'Meeting 2026.10.07' stays whole + const { extension } = splitNoteName(newNoteName, String(Notes.getNotesExtensions()), new Set(Notes.getLanguageExtensions().keys())); + let noteName: string = extension || !noteExtension ? newNoteName : `${newNoteName}.${noteExtension}`; // check for existing note with the same name let newNotePath = path.join(note.location, noteName); diff --git a/src/location.ts b/src/location.ts index 85b3bb0..1c9dcd5 100644 --- a/src/location.ts +++ b/src/location.ts @@ -84,3 +84,17 @@ export function getMoveDestination(source: string, targetFolder: string): string } return path.join(targetFolder, path.basename(source)); } + +// split a typed note name into its name and the extension it ends with, when that extension is one a note can have: +// in the list of allowed extensions, or with '*' one VS Code has a language for, so `query.sql` keeps `.sql` +// while the `.07` of `Meeting 2026.10.07` stays part of the name +export function splitNoteName(name: string, allowedExtensions: string, languageExtensions: ReadonlySet): { base: string, extension?: string } { + const dotted = path.extname(name); + const extension = dotted.slice(1); + if (!extension) { + return { base: name }; + } + const allowed = allowedExtensions.split(',').map(allowedExtension => allowedExtension.trim().toLowerCase()); + const usable = allowed.includes('*') ? languageExtensions.has(extension.toLowerCase()) : allowed.includes(extension.toLowerCase()); + return usable ? { base: name.slice(0, -dotted.length), extension } : { base: name }; +} diff --git a/src/test/suite/location.test.ts b/src/test/suite/location.test.ts index 3fcf999..292e03c 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, toWorkspaceSetting } from '../../location'; +import { getLocationState, getMoveDestination, getPortableFolder, isInside, resolveNotesLocation, splitNoteName, toWorkspaceSetting } from '../../location'; suite('Notes Location', () => { const home = path.join(path.sep, 'home', 'someone'); @@ -119,4 +119,22 @@ suite('Notes Location', () => { // a sibling whose name starts the same is a different folder assert.strictEqual(getMoveDestination(work, path.join(path.sep, 'notes', 'work-archive')), path.join(path.sep, 'notes', 'work-archive', 'work')); }); + + const languages = new Set(['md', 'markdown', 'txt', 'sql', 'json']); + + test('a typed extension VS Code knows a language for is kept when all extensions are allowed (#81)', () => { + assert.deepStrictEqual(splitNoteName('query.sql', '*', languages), { base: 'query', extension: 'sql' }); + assert.deepStrictEqual(splitNoteName('data.JSON', '*', languages), { base: 'data', extension: 'JSON' }); + }); + + test('a name without a usable extension stays whole', () => { + assert.deepStrictEqual(splitNoteName('Meeting 2026.10.07', '*', languages), { base: 'Meeting 2026.10.07' }); + assert.deepStrictEqual(splitNoteName('plain', '*', languages), { base: 'plain' }); + assert.deepStrictEqual(splitNoteName('.env', '*', languages), { base: '.env' }); + }); + + test('with a list of allowed extensions only those count', () => { + assert.deepStrictEqual(splitNoteName('notes.txt', 'md, txt', languages), { base: 'notes', extension: 'txt' }); + assert.deepStrictEqual(splitNoteName('query.sql', 'md,txt', languages), { base: 'query.sql' }); + }); });