From e01c4c445251232f0c20bcf7f0ecd38944302471 Mon Sep 17 00:00:00 2001 From: Dion Munk Date: Wed, 7 Oct 2026 09:37:44 -0600 Subject: [PATCH] feat(notes): show recently opened notes first in List Notes A note opened often, like todo.md, sat wherever its name put it in List Notes (#18). Like Quick Open, the notes opened most recently now come first under "recently opened", newest first, then the others under "other notes". - a note counts as opened when it becomes the active editor from anywhere: Notes, List Notes, the Explorer, Quick Open; previews and viewers count too - the history is the last 50 notes, kept in the extension's state on this machine, never in settings or the notes folder; it follows moved notes and folders, and deleted notes are left out - while typing, VS Code ranks the matches itself (sortByLabel is a proposed API), so the headings are only shown for the full list, where they're right - Notes: Clear Recently Opened forgets the history --- CHANGELOG.md | 1 + README.md | 2 +- package.json | 5 +++ src/extension.ts | 41 ++++++++++++++++++--- src/recentNotes.ts | 48 +++++++++++++++++++++++++ src/test/suite/recentNotes.test.ts | 58 ++++++++++++++++++++++++++++++ 6 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 src/recentNotes.ts create mode 100644 src/test/suite/recentNotes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5be1336..2ef3c17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * `Open Preview` in the right-click menu of a Markdown note, like in the Explorer (#10) +* `Notes: List Notes` (`Alt+L`) shows the notes you opened recently first, like Quick Open, wherever you opened them from, and `Notes: Clear Recently Opened` forgets them (#18) ### Changed diff --git a/README.md b/README.md index 7a7038e..db686d6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Notes are stored in a folder anywhere on your system you'd like, with as many su The extension can be accessed using the Notes icon that is placed in the Activity Bar, or in the Command Palette (CMD+Shift+P or CTRL+Shift+P) by typing `Notes`. * quickly create new notes by using the `Alt+N` shortcut, or by clicking the New Note icon at the top of Notes. -* 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. +* 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, with the notes you opened recently first. 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. * right-click a note or folder to rename, delete, move or search it, or to reveal it in your file explorer, and a Markdown note to open its preview. *Deleting a note is permanent, so be careful.* * notes open the way they do from the Explorer, with the editor VS Code uses for their file type, so images open in the image viewer. diff --git a/package.json b/package.json index d5e1f13..3185963 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,11 @@ "title": "List Notes", "category": "Notes" }, + { + "command": "Notes.clearRecentlyOpened", + "title": "Clear Recently Opened", + "category": "Notes" + }, { "command": "Notes.newNote", "title": "New Note", diff --git a/src/extension.ts b/src/extension.ts index 1b44427..5d9bc7a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,6 +7,7 @@ import { Note } from './note'; import { NotesViewProvider } from './notesViewProvider'; import { NotesWatcher } from './notesWatcher'; import { getLanguageExtensions, isMarkdownFile, resetLanguageExtensions } from './languages'; +import { RecentNotes } from './recentNotes'; import { MovableItem, NotesDragAndDrop } from './notesDragAndDrop'; import { getLocationState, getMoveDestination, getPortableFolder, isInside, isNoteName, resolveNotesLocation, splitNoteName, toWorkspaceSetting } from './location'; @@ -120,12 +121,34 @@ export function activate(context: vscode.ExtensionContext) { }); context.subscriptions.push(deleteFolderDisposable); + // remember the notes opened, from Notes or anywhere else, for List Notes + const recentNotes = new RecentNotes(context.globalState); + const rememberActiveNote = () => { + const input = vscode.window.tabGroups.activeTabGroup.activeTab?.input; + const uri = input instanceof vscode.TabInputText || input instanceof vscode.TabInputCustom ? input.uri : undefined; + if (uri?.scheme === 'file' && [notesTree.location, workspaceTree.location].some(location => location && isInside(uri.fsPath, location) && uri.fsPath !== location)) { + recentNotes.add(uri.fsPath); + } + }; + context.subscriptions.push( + vscode.window.tabGroups.onDidChangeTabs(rememberActiveNote), + vscode.window.tabGroups.onDidChangeTabGroups(rememberActiveNote), + vscode.workspace.onDidRenameFiles(e => e.files.forEach(file => recentNotes.rename(file.oldUri.fsPath, file.newUri.fsPath))) + ); + // list notes let listNotesDisposable = vscode.commands.registerCommand('Notes.listNotes', () => { - Notes.listNotes(trees); + Notes.listNotes(trees, recentNotes); }); context.subscriptions.push(listNotesDisposable); + // forget the recently opened notes + let clearRecentNotesDisposable = vscode.commands.registerCommand('Notes.clearRecentlyOpened', async () => { + await recentNotes.clear(); + vscode.window.showInformationMessage('Cleared the recently opened notes.'); + }); + context.subscriptions.push(clearRecentNotesDisposable); + // new note, in the folder of the given item, or asking where when the workspace has notes too let newNoteDisposable = vscode.commands.registerCommand('Notes.newNote', (item?: Note) => { Notes.newNote(trees, undefined, item); @@ -437,7 +460,7 @@ export class Notes { // 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 { + static async listNotes(tree: Refreshable, recentNotes: RecentNotes): Promise { const sources = [{ label: 'Notes', location: Notes.getNotesLocation() }]; if (Notes.isWorkspaceNotesAvailable()) { sources.unshift({ label: 'Workspace Notes', location: Notes.getWorkspaceNotesLocation() }); @@ -459,10 +482,19 @@ export class Notes { } } + // notes opened recently come first, newest first, like Quick Open; the others stay in name order + const recent = recentNotes.get() + .map(filePath => notes.find(note => note.filePath === filePath)) + .filter((note): note is NoteItem => !!note); + const others = notes.filter(note => !recent.includes(note)); + const listed: NoteItem[] = recent.length + ? [{ label: 'recently opened', kind: vscode.QuickPickItemKind.Separator }, ...recent, { label: 'other notes', kind: vscode.QuickPickItemKind.Separator }, ...others] + : notes; + const quickPick = vscode.window.createQuickPick(); quickPick.placeholder = 'Open a note, or type a name to create one'; quickPick.matchOnDescription = true; - quickPick.items = notes; + quickPick.items = listed; quickPick.onDidChangeValue(value => { const name = value.trim(); // offer to create the typed name in each section, unless a note already has that name @@ -472,7 +504,8 @@ export class Notes { alwaysShow: true, createIn: source.location })); - quickPick.items = [...notes, ...create]; + // while typing, VS Code ranks the matches itself, so the headings would label the wrong notes + quickPick.items = name ? [...notes, ...create] : listed; }); quickPick.onDidAccept(async () => { const picked = quickPick.activeItems[0]; diff --git a/src/recentNotes.ts b/src/recentNotes.ts new file mode 100644 index 0000000..18c1226 --- /dev/null +++ b/src/recentNotes.ts @@ -0,0 +1,48 @@ +import * as path from 'path'; + +// the storage the history is kept in, the extension's global state in VS Code +export interface HistoryStore { + get(key: string, defaultValue: T): T; + update(key: string, value: unknown): Thenable; +} + +const KEY = 'recentlyOpenedNotes'; +const LIMIT = 50; + +// the notes opened most recently, newest first, like the recently opened files of Quick Open; +// kept on this machine only, never in settings or the notes folder +export class RecentNotes { + + constructor(private store: HistoryStore, private limit: number = LIMIT) { + } + + // note paths, newest first + get(): string[] { + return this.store.get(KEY, []); + } + + // a note was opened: put it first + async add(filePath: string): Promise { + const recent = this.get(); + if (recent[0] === filePath) { + return; + } + await this.store.update(KEY, [filePath, ...recent.filter(recentPath => recentPath !== filePath)].slice(0, this.limit)); + } + + // a note or folder was moved or renamed: follow it + async rename(oldPath: string, newPath: string): Promise { + const recent = this.get(); + const renamed = recent.map(recentPath => + recentPath === oldPath ? newPath + : recentPath.startsWith(oldPath + path.sep) ? newPath + recentPath.slice(oldPath.length) + : recentPath); + if (renamed.some((renamedPath, index) => renamedPath !== recent[index])) { + await this.store.update(KEY, renamed); + } + } + + async clear(): Promise { + await this.store.update(KEY, []); + } +} diff --git a/src/test/suite/recentNotes.test.ts b/src/test/suite/recentNotes.test.ts new file mode 100644 index 0000000..fc09720 --- /dev/null +++ b/src/test/suite/recentNotes.test.ts @@ -0,0 +1,58 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import { HistoryStore, RecentNotes } from '../../recentNotes'; + +// the extension's global state, kept in memory +class MemoryStore implements HistoryStore { + private values = new Map(); + get(key: string, defaultValue: T): T { + return this.values.has(key) ? this.values.get(key) as T : defaultValue; + } + async update(key: string, value: unknown): Promise { + this.values.set(key, value); + } +} + +suite('Recently Opened Notes (#18)', () => { + const notes = path.join(path.sep, 'notes'); + const note = (...parts: string[]) => path.join(notes, ...parts); + + test('the note opened last comes first, without duplicates', async () => { + const recent = new RecentNotes(new MemoryStore()); + await recent.add(note('zz.md')); + await recent.add(note('todo.md')); + await recent.add(note('zz.md')); + assert.deepStrictEqual(recent.get(), [note('zz.md'), note('todo.md')]); + }); + + test('only the most recent notes are kept', async () => { + const recent = new RecentNotes(new MemoryStore(), 3); + for (const name of ['a', 'b', 'c', 'd']) { + await recent.add(note(`${name}.md`)); + } + assert.deepStrictEqual(recent.get(), [note('d.md'), note('c.md'), note('b.md')]); + }); + + test('a moved note keeps its place', async () => { + const recent = new RecentNotes(new MemoryStore()); + await recent.add(note('todo.md')); + await recent.add(note('zz.md')); + await recent.rename(note('todo.md'), note('archive', 'todo.md')); + assert.deepStrictEqual(recent.get(), [note('zz.md'), note('archive', 'todo.md')]); + }); + + test('notes in a renamed folder follow it, notes in a folder with a similar name do not', async () => { + const recent = new RecentNotes(new MemoryStore()); + await recent.add(note('work-old', 'a.md')); + await recent.add(note('work', 'b.md')); + await recent.rename(note('work'), note('projects')); + assert.deepStrictEqual(recent.get(), [note('projects', 'b.md'), note('work-old', 'a.md')]); + }); + + test('clearing forgets every note', async () => { + const recent = new RecentNotes(new MemoryStore()); + await recent.add(note('todo.md')); + await recent.clear(); + assert.deepStrictEqual(recent.get(), []); + }); +});