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: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
170 changes: 100 additions & 70 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, splitNoteName, toWorkspaceSetting } from './location';
import { getLocationState, getMoveDestination, getPortableFolder, isInside, isNoteName, resolveNotesLocation, splitNoteName, toWorkspaceSetting } from './location';

let extId = 'vscode-notes';
let extPub = 'dionmunk';
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -453,8 +453,9 @@ export class Notes {
});
}

// list notes, from Workspace Notes and Notes
static async listNotes(): Promise<void> {
// 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<void> {
const sources = [{ label: 'Notes', location: Notes.getNotesLocation() }];
if (Notes.isWorkspaceNotesAvailable()) {
sources.unshift({ label: 'Workspace Notes', location: Notes.getWorkspaceNotesLocation() });
Expand All @@ -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<NoteItem>();
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
Expand All @@ -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<void> {
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
Expand Down Expand Up @@ -969,3 +976,26 @@ async function listFolders(folder: string): Promise<string[]> {
}
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<string[]> {
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;
}
6 changes: 6 additions & 0 deletions src/location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
10 changes: 9 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, 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');
Expand Down Expand Up @@ -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);
});
});
Loading