Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down
60 changes: 33 additions & 27 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string> {
// plain text is registered by VS Code itself rather than by an extension
const languages = new Map<string, string>([['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');
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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({
Expand All @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions src/location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>): { 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 };
}
20 changes: 19 additions & 1 deletion src/test/suite/location.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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' });
});
});
Loading